Co-Authored-By: Claude Code <noreply@anthropic.com>
32 KiB
动态主题色 + 头像图片化 实施计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 客服挂件与独立聊天页支持按网站自定义品牌色(<script data-color> / ?color=),单色 HSL 派生 7 槽位 CSS 变量;🤖 emoji 头像全部替换为图片,带 emoji 兜底。
Architecture: 派生纯函数 derivePalette(hex→HSL→固定规则→7 槽位)存在于两处——widget.js 内联(自包含嵌入)与 static/js/theme.js(独立页共享),pytest 源码级断言防漂移。widget.js 在 Shadow DOM style 字符串头部注入 :host{--w-*} 变量块,全部 CSS 引用 var(--w-*)。头像 <img> 为绝对定位覆盖层,load 后隐藏底层 emoji,error 时自移除露出 emoji。
Tech Stack: 原生 JS(ES2019+,无构建)、CSS 自定义属性、Shadow DOM、pytest(静态扫描)、Pillow(素材压缩)。
Spec: docs/superpowers/specs/2026-08-31-dynamic-theme-color-design.md
三处实施细化(相对 spec,收尾任务会回写 spec):
- §5 borderSoft 规则调参:
s×0.75, l=82→s×0.72, l=84(原参数输出与现状蓝通道差 12,超出 ≤8 容差;调后偏差 (0,2,2)) - §12.4 遮盖机制:素材是透明底 PNG,
<img>无法"遮盖"emoji——改为 load 成功后隐藏 emoji 兄弟节点,error 时移除 img 露出 emoji(兜底语义不变) - §12.2"单一常量"落在挂件侧(widget.js
DEFAULT_AVATAR_PATH,跨域场景所在);chat.html 为同源直接字面量,不跨文件共享
数值纪律: DEFAULT_PALETTE 与 style.css :root 的默认字面量必须以算法实跑输出为准(已用 Python 复刻 JS Math.round 半进位舍入验证——手算会差 1),不得手改。
约定(全计划适用):
- 每个任务结束跑
python -m pytest tests/ -x -q确认无回归 - commit 信息用中文,与仓库风格一致
- 派生函数体内不得出现字符串字面量含
{/}(pytest 用花括号计数提取函数),两份拷贝的函数体(含注释)必须逐字符一致(空白归一化后)
Task 1: pytest 契约测试(theme.js 存在性与导出)
Files:
-
Create:
tests/test_theme_static.py -
Step 1: 写失败测试
"""动态主题色静态回归网(spec §8.1、§12.7)。"""
import re
from pathlib import Path
STATIC = Path(__file__).resolve().parent.parent / "static"
SLOTS = [
"--w-primary", "--w-primary-rgb", "--w-gradient-end", "--w-deep-text",
"--w-caption-text", "--w-soft-bg", "--w-border-soft",
]
# 现状全部橙色系字面量(spec §4 表格逆向)
ORANGE = re.compile(
r"#(f08a31|F08A34|EC8C2F|f59e0b|8C4D1D|C16C2E|A8591F|d97706"
r"|F0D5BF|EECFB6|E8C4A9|F1CCAE|fef3c7"
r"|FFF8F3|fef9f3|FFF9F4|FFF7F1|faf7f3)\b",
re.IGNORECASE,
)
STRAY_RGBA = re.compile(r"rgba?\(\s*(240,\s*138,\s*49|196,\s*91,\s*25)")
def _read(rel):
return (STATIC / rel).read_text(encoding="utf-8")
def test_theme_js_exists_and_exports():
src = _read("js/theme.js")
for token in ("derivePalette", "DEFAULT_COLOR", "DEFAULT_PALETTE"):
assert token in src, f"theme.js 缺少 {token}"
- Step 2: 跑测试确认失败
Run: python -m pytest tests/test_theme_static.py -v
Expected: FAIL(js/theme.js 不存在,读取报错)
Task 2: 实现 theme.js
Files:
-
Create:
static/js/theme.js -
Step 1: 写完整实现(逐字拷贝此代码;widget.js 内联时同一份函数体)
/* 客服主题派生——单品牌色 → 7 槽位色板(spec 2026-08-31 §5)。
* 注意:此文件与 static/widget.js 内联拷贝必须逐字符一致(空白归一化后),
* 由 tests/test_theme_static.py 强制;修改务必两边同步。 */
(function (global) {
"use strict";
var DEFAULT_COLOR = "#f08a31";
var DEFAULT_PALETTE = {
primary: "#f08a31",
primaryRgb: "240,138,49",
gradientEnd: "#f08e2c",
deepText: "#8a4c1d",
captionText: "#c16d2d",
softBg: "#fef8f4",
borderSoft: "#f0d3bd"
};
function clampNum(v, lo, hi) {
return Math.min(hi, Math.max(lo, v));
}
function hexToRgb(hex) {
hex = String(hex || "").replace(/^#/, "");
if (hex.length === 3) {
hex = hex.split("").map(function (ch) { return ch + ch; }).join("");
}
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
};
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b);
var l = (max + min) / 2;
var d = max - min;
var h = 0, s = 0;
if (d !== 0) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
if (h < 0) h += 360;
}
return { h: h, s: s * 100, l: l * 100 };
}
function toHex2(v) {
var s = Math.round(clampNum(v, 0, 255)).toString(16);
return s.length < 2 ? "0" + s : s;
}
function hslToHex(h, s, l) {
h = ((h % 360) + 360) % 360;
s = clampNum(s, 0, 100) / 100;
l = clampNum(l, 0, 100) / 100;
var c = (1 - Math.abs(2 * l - 1)) * s;
var x = c * (1 - Math.abs(((h / 60) % 2) - 1));
var m = l - c / 2;
var rp = 0, gp = 0, bp = 0;
if (h < 60) { rp = c; gp = x; }
else if (h < 120) { rp = x; gp = c; }
else if (h < 180) { gp = c; bp = x; }
else if (h < 240) { gp = x; bp = c; }
else if (h < 300) { rp = x; bp = c; }
else { rp = c; bp = x; }
return "#" + toHex2((rp + m) * 255) + toHex2((gp + m) * 255) + toHex2((bp + m) * 255);
}
function derivePalette(color) {
try {
var rgb = hexToRgb(color);
if (!rgb) {
if (typeof console !== "undefined" && console.warn) console.warn("[chat-theme] invalid color, fallback:", color);
return DEFAULT_PALETTE;
}
var hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
var h = hsl.h, s = hsl.s, l = hsl.l;
return {
primary: "#" + toHex2(rgb.r) + toHex2(rgb.g) + toHex2(rgb.b),
primaryRgb: rgb.r + "," + rgb.g + "," + rgb.b,
gradientEnd: hslToHex(h + 2, s, l - 1),
deepText: hslToHex(h - 2, s * 0.76, clampNum(l - 24, 28, 40)),
captionText: hslToHex(h - 2, s * 0.72, clampNum(l - 10, 40, 52)),
softBg: hslToHex(h, s * 0.9, 97.5),
borderSoft: hslToHex(h - 2, s * 0.72, 84)
};
} catch (e) {
return DEFAULT_PALETTE;
}
}
global.ChatTheme = {
derivePalette: derivePalette,
DEFAULT_COLOR: DEFAULT_COLOR,
DEFAULT_PALETTE: DEFAULT_PALETTE
};
})(typeof window !== "undefined" ? window : this);
- Step 2: 跑 Task 1 测试确认通过
Run: python -m pytest tests/test_theme_static.py -v
Expected: PASS
- Step 3: Commit
git add static/js/theme.js tests/test_theme_static.py
git commit -m "feat(theme): 派生纯函数 theme.js——单色HSL派生7槽位+4层兜底"
Task 3: 生成压缩头像素材
Files:
-
Create:
static/kefu-small.png(从static/kefu.png派生,原图不动) -
Step 1: 用 Pillow 生成(168px 长边,保留透明)
python -c "
from PIL import Image
im = Image.open('static/kefu.png').convert('RGBA')
im.thumbnail((168, 168), Image.LANCZOS)
im.save('static/kefu-small.png', optimize=True)
print(im.size)
"
Expected: 输出尺寸 ≤(168,168);文件显著小于 682KB(通常 <60KB)
- Step 2: Commit
git add static/kefu-small.png
git commit -m "feat(avatar): 压缩头像素材 kefu-small.png——168px显示级,原图保留"
Task 4: pytest 静态扫描网(槽位/橙残留/同步/avatar)
2026-08-31 审查加固(提交 9b82949,以下代码块为初版,以实际提交的测试文件为准): ① 同步检查扩展为
SYNCED_FUNCTIONS(6 个函数逐一比对)并新增test_widget_and_theme_defaults_identical(DEFAULT_PALETTE 归一化比对 + DEFAULT_COLOR 精确比对);②test_style_no_stray_orange增加负断言--user-bubble/--primary-dark不得存在,test_widget_no_stray_orange增加c.primary不得存在(堵"删定义留使用"逃生口);③ 失败信息增强(残留样本、首个差异位置)。红状态相应变为 7 失败/2 通过(新增 defaults 红)。
Files:
-
Modify:
tests/test_theme_static.py(追加) -
Step 1: 追加失败测试(对现状 widget.js/style.css 全红,即安全网)
def _extract_function(src, name):
"""花括号计数提取函数源码(要求函数体内字符串字面量不含大括号)。"""
start = src.index("function " + name + "(")
i = src.index("{", start)
depth = 0
for j in range(i, len(src)):
if src[j] == "{":
depth += 1
elif src[j] == "}":
depth -= 1
if depth == 0:
return src[start:j + 1]
raise AssertionError("unbalanced braces in " + name)
def test_widget_and_theme_derive_identical():
a = _extract_function(_read("js/theme.js"), "derivePalette")
b = _extract_function(_read("widget.js"), "derivePalette")
norm = lambda s: re.sub(r"\s+", "", s)
assert norm(a) == norm(b), "两份 derivePalette 漂移"
def test_widget_host_injection_complete():
src = _read("widget.js")
for slot in SLOTS:
assert slot + ":" in src, f"widget.js :host 注入缺 {slot}"
def test_style_root_defines_all_slots():
src = _read("css/style.css")
for slot in SLOTS:
assert re.search(re.escape(slot) + r"\s*:", src), f"style.css :root 缺 {slot}"
def test_var_refs_within_known_slots():
for rel in ("widget.js", "css/style.css"):
used = set(re.findall(r"var\((--w-[a-z-]+)", _read(rel)))
unknown = used - set(SLOTS)
assert not unknown, f"{rel} 引用未定义槽位: {unknown}"
def _strip_defaults(src):
m = re.search(r"var DEFAULT_PALETTE = \{.*?\};", src, re.S)
assert m, "DEFAULT_PALETTE 块不存在"
src = src.replace(m.group(0), "")
return re.sub(r'var DEFAULT_COLOR = "[^"]*"', "", src)
def test_widget_no_stray_orange():
src = _strip_defaults(_read("widget.js"))
assert not ORANGE.findall(src), "widget.js 残留橙色 hex"
assert not STRAY_RGBA.findall(src), "widget.js 残留橙色 rgba"
def test_style_no_stray_orange():
bad = [
ln for ln in _read("css/style.css").splitlines()
if (ORANGE.search(ln) or STRAY_RGBA.search(ln)) and "--w-" not in ln
]
assert not bad, "style.css 残留橙色(非 :root 槽位行): " + "; ".join(bad[:5])
def test_widget_avatar_src_uses_wsbase():
src = _read("widget.js")
assert 'DEFAULT_AVATAR_PATH = "/static/kefu-small.png"' in src
assert 'src="/static/kefu-small.png"' not in src, "挂件禁用相对路径头像(客户站会 404)"
assert "wsBase + DEFAULT_AVATAR_PATH" in src
- Step 2: 跑测试确认失败
Run: python -m pytest tests/test_theme_static.py -v
Expected: Task 1 的测试仍 PASS;test_var_refs_within_known_slots 也 PASS(现状无任何 var(--w- 引用,空集通过——安全网在 Task 5/6 引入引用后生效);其余 7 项 FAIL(现状文件未改造):derive_identical、defaults_identical、host_injection_complete、style_root_defines_all_slots、style_no_stray_orange、widget_no_stray_orange、avatar_src_uses_wsbase
- Step 3: Commit
git add tests/test_theme_static.py
git commit -m "test(theme): 静态扫描安全网——槽位完整性/橙残留/双份同步/avatar跨域"
Task 5: style.css 槽位化
Files:
-
Modify:
static/css/style.css -
Step 1: 重写
:root(第 7-20 行区域)
删除 --primary、--primary-dark、--user-bubble(死代码/迁移),改为:
:root {
--w-primary: #f08a31;
--w-primary-rgb: 240, 138, 49;
--w-gradient-end: #f08e2c;
--w-deep-text: #8a4c1d;
--w-caption-text: #c16d2d;
--w-soft-bg: #fef8f4;
--w-border-soft: #f0d3bd;
--bg: #f5f5f5;
--chat-bg: #ffffff;
--ai-bubble: #f0f0f0;
--text: #333333;
--text-light: #666666;
--text-white: #ffffff;
--border: #e0e0e0;
--success: #34a853;
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
- Step 2: 按映射表全量替换(先 Read 文件,再逐项 Edit;每项在文件中唯一或用 replace_all)
| 旧字面量 | 新值 |
|---|---|
background: #f08a31;(4 处:L45/L155/L220/L395) |
background: var(--w-primary); |
border: 1px solid #fef3c7;(L329,帮助块) |
border: 1px solid var(--w-border-soft); |
border: 3px solid #f08a31; |
border: 3px solid var(--w-primary); |
var(--user-bubble)(消息气泡) |
var(--w-primary) |
rgba(240, 138, 49, X)(全部 α 变体) |
rgba(var(--w-primary-rgb), X) |
linear-gradient(135deg, #f08a31, #f59e0b) |
linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)) |
background: #fef9f3; / #faf7f3; / #FFF8F3; |
background: var(--w-soft-bg); |
color: #8C4D1D; |
color: var(--w-deep-text); |
color: #C16C2E; / #d97706; |
color: var(--w-caption-text); |
border: 1px solid #F0D5BF; |
border: 1px solid var(--w-border-soft); |
替换后自检:grep -nE "f08a31|f59e0b|8C4D1D|C16C2E|d97706|F0D5BF|FFF8F3|fef9f3|faf7f3|240, ?138" static/css/style.css 应只剩 :root 槽位行。
- Step 3: 跑扫描网确认 style.css 侧转绿
Run: python -m pytest tests/test_theme_static.py -v
Expected: test_style_root_defines_all_slots、test_style_no_stray_orange PASS;widget.js 相关仍 FAIL(后续任务)
- Step 4: Commit
git add static/css/style.css
git commit -m "feat(theme): style.css 槽位化——:root 7槽位默认值,清除全部硬编码橙"
Task 6: widget.js 派生注入 + CSS 全量槽位化
Files:
-
Modify:
static/widget.js -
Step 1: 属性读取区(L8-16 之后)加入 data-color/data-avatar/data-debug 读取与内联派生
在 var wsBase = ...;(L16)后追加(派生函数体逐字拷贝 theme.js 的 clampNum/hexToRgb/rgbToHsl/toHex2/hslToHex/derivePalette 六个函数与 DEFAULT_COLOR/DEFAULT_PALETTE 常量,含相同注释):
var DEFAULT_AVATAR_PATH = "/static/kefu-small.png";
var avatarAttr = script.getAttribute("data-avatar");
var avatarUrl = avatarAttr
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, ""))
: wsBase + DEFAULT_AVATAR_PATH;
var palette = derivePalette(script.getAttribute("data-color") || DEFAULT_COLOR);
if (script.getAttribute("data-debug") === "true") {
window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette };
}
同时删除 L31-36 的旧 colors/c 对象中 primary 的使用点(见 Step 3 的 c.primary 替换;c.bg/c.bubble 保留给明暗模式)。
- Step 2: style 数组头部插入 :host 注入块(作为第一个数组元素)
":host{--w-primary:" + palette.primary + ";--w-primary-rgb:" + palette.primaryRgb + ";--w-gradient-end:" + palette.gradientEnd + ";--w-deep-text:" + palette.deepText + ";--w-caption-text:" + palette.captionText + ";--w-soft-bg:" + palette.softBg + ";--w-border-soft:" + palette.borderSoft + ";}",
- Step 3: 按映射表替换 style 字符串全部橙色(先 Read widget.js L49-170)
| 行(现状) | 旧 → 新 |
|---|---|
| L55 | background:#f08a31 → background:var(--w-primary);rgba(240,138,49,0.4) → rgba(var(--w-primary-rgb),0.4) |
| L59 | 同上(0.45 阴影同理) |
| L60 | border:2px solid #f08a31 → var(--w-primary) |
| L61 | color:#8C4D1D → var(--w-deep-text) |
| L67 | background:" + c.bg + " 保留 |
| L71 | background:#f08a31 → var(--w-primary) |
| L73 | border:3px solid #f08a31 → var(--w-primary) |
| L82 | #faf7f3 → var(--w-soft-bg) |
| L85 | #f08a31 → var(--w-primary) |
| L86 | #fef9f3→var(--w-soft-bg);#fef3c7→var(--w-border-soft) |
| L91 | #8C4D1D→var(--w-deep-text);#F0D5BF→var(--w-border-soft) |
| L95 | #C16C2E → var(--w-caption-text) |
| L97 | #F0D5BF→var(--w-border-soft);#FFF8F3→var(--w-soft-bg);#8C4D1D→var(--w-deep-text) |
| L100 | #f08a31→var(--w-primary);rgba(240,138,49,0.3)→rgba(var(--w-primary-rgb),0.3) |
| L102 | c.primary → var(--w-primary) |
| L106 | #f08a31 → var(--w-primary) |
| L109 | #d97706 → var(--w-caption-text) |
| L122 | #fef9f3 → var(--w-soft-bg) |
| L126 | 渐变→linear-gradient(135deg, var(--w-primary), var(--w-gradient-end));阴影 0.25 同理 |
| L135 | #F1CCAE→var(--w-border-soft);rgba(196,91,25,0.22)→rgba(var(--w-primary-rgb),0.22) |
| L140 | 渐变→linear-gradient(135deg, var(--w-primary), var(--w-gradient-end)) |
| L143 | #F08A34 → var(--w-primary) |
| L150-151 | #8C4D1D→var(--w-deep-text);#F0D5BF→var(--w-border-soft);#FFF8F3→var(--w-soft-bg) |
| L154 | #FFF9F4 → var(--w-soft-bg) |
| L158-159 | #EECFB6→var(--w-border-soft);#8C4D1D→var(--w-deep-text);#FFF7F1→var(--w-soft-bg) |
| L163 | 渐变+阴影同 L126 |
| L165 | #E8C4A9→var(--w-border-soft);#A8591F→var(--w-deep-text) |
| L166 | #FEFEFE 保留(中性色) |
自检:grep -nE "f08a31|F08A34|EC8C2F|f59e0b|8C4D1D|C16C2E|A8591F|d97706|F0D5BF|EECFB6|E8C4A9|F1CCAE|fef3c7|FFF8F3|fef9f3|FFF9F4|FFF7F1|faf7f3|240,138|196,91" static/widget.js 应只剩:DEFAULT_COLOR/DEFAULT_PALETTE 块内 + 4 处 SVG stroke #8C4D1D(L189×2/L219/L223)+ toggle 模板 inline #f08a31(L268)——这 5 处属 Task 7 范围。
- Step 4: 跑扫描网
Run: python -m pytest tests/test_theme_static.py -v
Expected: test_widget_and_theme_derive_identical、test_widget_and_theme_defaults_identical、test_widget_host_injection_complete、test_var_refs_within_known_slots、test_widget_avatar_src_uses_wsbase PASS;test_widget_no_stray_orange 仍 FAIL(残留即上述 5 处 Task 7 项——grep 输出会精确指向它们);style.css 侧两项已在 Task 5 转绿
- Step 5: Commit
git add static/widget.js
git commit -m "feat(theme): widget.js 槽位化——内联派生+:host注入+40处橙色换var()"
Task 7: widget.js SVG currentColor + toggle 内联样式类化
Files:
-
Modify:
static/widget.js -
Step 1: SVG 描边改 currentColor(4 处 stroke:L189 两处、L219、L223)
stroke="#8C4D1D" → stroke="currentColor"(父级 .call-btn/.invite-action-btn 已有对应 color)
- Step 2: 桌面 toggle 按钮 inline 样式类化(为头像与定位上下文铺路)
L265-274 模板改为:
toggleBtn.innerHTML = `
<div class="toggle-avatar-wrap">
<div class="toggle-avatar-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${avatarUrl}"></div>
<div class="toggle-avatar-dot"></div>
</div>
<div style="color:white;">
<div style="font-size:14px;font-weight:600;">Chat with us</div>
<div style="font-size:11px;opacity:0.9;">Usually replies quickly</div>
</div>
`;
style 数组追加(内圆显式 position:relative,spec §12.4 复审修正):
.toggle-avatar-wrap { position:relative; }
.toggle-avatar-circle { position:relative; width:36px; height:36px; background:rgba(255,255,255,0.2); border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:18px; }
.toggle-avatar-dot { position:absolute; top:0; right:0; width:10px; height:10px; background:#22c55e; border-radius:50%; border:2px solid var(--w-primary); }
- Step 3: 跑扫描网确认全绿
Run: python -m pytest tests/test_theme_static.py -v
Expected: 全部 PASS(test_widget_no_stray_orange 在本任务转绿——最后 5 处橙色已清除)
- Step 4: Commit
git add static/widget.js
git commit -m "refactor(widget): SVG改currentColor+toggle内联样式类化(含定位上下文)"
Task 8: widget.js 头像替换(4 处)+ 兜底监听
Files:
-
Modify:
static/widget.js -
Step 1: style 数组追加头像通用规则
.avatar-emoji { line-height:1; }
.avatar-img { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; border-radius:50%; }
-
Step 2: 三处模板替换(桌面 toggle 已在 Task 7 完成)
-
L181
<div class="avatar">🤖</div>→<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"></div>(面板模板为普通字符串拼接) -
L209
<div class="invite-icon">🤖<span class="invite-status-dot"></span></div>→<div class="invite-icon"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"><span class="invite-status-dot"></span></div>(同为普通拼接) -
L261 mobile 模板
<div class="mobile-circle">🤖<span class="mobile-dot"></span></div>→<div class="mobile-circle"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="${avatarUrl}"><span class="mobile-dot"></span></div>(反引号模板,用${})
注意:L179-202 面板模板是普通字符串拼接('...' + title + '...'),用 '+avatarUrl+' 形式;L205+ 邀请卡同为普通串;仅 toggle 是反引号模板。
- Step 3: 兜底监听(container/inviteEl appendChild 之后、事件绑定区之前)
// 头像兜底:load 成功隐藏底层 emoji;error 移除 img 露出 emoji(spec §12.4)
Array.prototype.forEach.call(shadow.querySelectorAll(".avatar-img"), function (img) {
function hideEmoji() {
var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji");
if (s) s.style.display = "none";
}
if (img.complete && img.naturalWidth > 0) { hideEmoji(); return; }
img.addEventListener("load", hideEmoji);
img.addEventListener("error", function () { img.remove(); });
});
- Step 4: 跑扫描网全绿确认
Run: python -m pytest tests/test_theme_static.py -v
Expected: 全部 PASS(含 test_widget_avatar_src_uses_wsbase)
- Step 5: Commit
git add static/widget.js
git commit -m "feat(avatar): 挂件4处emoji换图片头像——绝对定位+emoji兜底+data-avatar可配"
Task 9: 独立页接入(chat.html + chat.js)
Files:
-
Modify:
static/chat.html、static/js/chat.js -
Step 1: chat.html——L7 stylesheet 后加 theme.js,L12 头像替换,L36/L40 SVG,L50 死注释删除
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/theme.js"></script>
L12:<div class="avatar">🤖</div> → <div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="/static/kefu-small.png"></div>
L36/L40:stroke="#8C4D1D" → stroke="currentColor"
L50:删除整行(<!-- <div class="avatar">🤖</div> -->)
- Step 2: chat.js——文件顶部追加 bootstrap(在现有逻辑之前)
// ---- 动态主题色 bootstrap(spec §3):?color= → 派生 → :root 变量 ----
(function () {
if (typeof ChatTheme === "undefined") return; // theme.js 加载失败→style.css :root 默认橙兜底
var m = /(?:^|[?&])color=([^&]*)/.exec(location.search);
var palette = ChatTheme.derivePalette(m ? decodeURIComponent(m[1]) : ChatTheme.DEFAULT_COLOR);
var root = document.documentElement.style;
root.setProperty("--w-primary", palette.primary);
root.setProperty("--w-primary-rgb", palette.primaryRgb);
root.setProperty("--w-gradient-end", palette.gradientEnd);
root.setProperty("--w-deep-text", palette.deepText);
root.setProperty("--w-caption-text", palette.captionText);
root.setProperty("--w-soft-bg", palette.softBg);
root.setProperty("--w-border-soft", palette.borderSoft);
// 头像兜底(与挂件同机制)
Array.prototype.forEach.call(document.querySelectorAll(".avatar-img"), function (img) {
function hideEmoji() {
var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji");
if (s) s.style.display = "none";
}
if (img.complete && img.naturalWidth > 0) { hideEmoji(); return; }
img.addEventListener("load", hideEmoji);
img.addEventListener("error", function () { img.remove(); });
});
})();
- Step 3: style.css 追加头像规则(若 Task 5 未含)
.avatar-emoji { line-height: 1; }
.avatar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; border-radius: 50%; }
- Step 4: 全量 pytest + Commit
Run: python -m pytest tests/ -q → Expected: 全 PASS
git add static/chat.html static/js/chat.js static/css/style.css
git commit -m "feat(theme): 独立页接入——?color=参数+theme.js+头像图片+SVG currentColor"
Task 10: widget-test.html 重写(预览 + 断言面板)
Files:
-
Modify:
static/widget-test.html(整页重写) -
Step 1: 重写页面——保留原说明,新增控制区与断言区
结构:原 info 卡片保留;<head> 中必须加 <script src="/static/js/theme.js"></script>(断言面板依赖 window.ChatTheme,漏掉则面板永远显示钩子未就绪);新增:
<div class="info">
<h2>Theme Color Preview</h2>
<p><input type="color" id="colorPicker" value="#f08a31">
<button onclick="applyColor()">Apply</button>
<button onclick="applyColor('#f08a31')">Orange</button>
<button onclick="applyColor('#1a73e8')">Blue</button>
<button onclick="applyColor('#16a34a')">Green</button>
<button onclick="applyColor('#7c3aed')">Purple</button>
<button onclick="applyBrokenAvatar()">Test Broken Avatar</button></p>
</div>
<div class="info">
<h2>Assertion Panel</h2>
<div id="assertions"></div>
</div>
- Step 2: teardown/重建逻辑(spec §7.1 陷阱;基础属性对象 + extra 合并,缓存戳防缓存)
var BASE_ATTRS = {
"data-position": "bottom-right",
"data-theme": "light",
"data-mobile": "true",
"data-debug": "true",
"data-whatsapp": "https://wa.me/8613800138000",
"data-email": "mailto:demo@hesu-auto.com"
};
function rebuildWidget(extra) {
var host = document.getElementById("cc-widget-host");
if (host) host.remove();
document.querySelectorAll("script[src*='widget.js']").forEach(function (s) { s.remove(); });
try { sessionStorage.removeItem("widget_invite_dismissed_" + window.location.pathname); } catch (e) {}
var s = document.createElement("script");
s.src = "/static/widget.js?" + Date.now();
var attrs = Object.assign({}, BASE_ATTRS, extra || {});
Object.keys(attrs).forEach(function (k) { s.setAttribute(k, attrs[k]); });
s.onload = runAssertions;
document.body.appendChild(s);
}
function applyColor(c) {
rebuildWidget({ "data-color": c || document.getElementById("colorPicker").value });
}
function applyBrokenAvatar() {
rebuildWidget({ "data-color": "#1a73e8", "data-avatar": "/static/nope.png" });
}
- Step 3: 断言面板(spec §8.2;页面加载后自动跑一次)
var CURRENT_HARDCODED = { // 现状代表值(spec §4),Δ ≤ 8/通道
primary: "#f08a31", gradientEnd: "#F08A34", deepText: "#8C4D1D",
captionText: "#C16C2E", softBg: "#FFF8F3", borderSoft: "#F0D5BF"
};
function hexToRgb6(h) { /* 复用 theme.js 的 hexToRgb 逻辑或简化实现 */ }
function channelDelta(a, b) { /* 返回 max 通道差 */ }
function runAssertions() {
var rows = [];
function check(name, ok, detail) { rows.push((ok ? "✅ " : "❌ ") + name + (detail ? " — " + detail : "")); }
var t = window.ChatTheme, d = window.__chatWidgetDebug;
if (!t || !d) { rows.push("❌ theme.js / widget debug 钩子未就绪"); }
else {
var p = t.derivePalette("#f08a31");
Object.keys(CURRENT_HARDCODED).forEach(function (k) {
var delta = channelDelta(p[k], CURRENT_HARDCODED[k]);
check("default " + k + " Δ≤8 (" + p[k] + " vs " + CURRENT_HARDCODED[k] + ")", delta <= 8, "Δ=" + delta);
});
check("derive(default) === DEFAULT_PALETTE",
JSON.stringify(p) === JSON.stringify(t.DEFAULT_PALETTE));
check("inline copy === theme.js copy",
d.derivePalette.toString().replace(/\s+/g, "") === t.derivePalette.toString().replace(/\s+/g, ""));
check("invalid color → DEFAULT_PALETTE",
JSON.stringify(t.derivePalette("not-a-color")) === JSON.stringify(t.DEFAULT_PALETTE));
}
document.getElementById("assertions").innerHTML = rows.map(function (r) { return "<div>" + r + "</div>"; }).join("");
}
页面初始嵌入(替代原 L69 的静态 script 标签)同样带 data-debug="true" 并挂 onload="runAssertions()",属性与 BASE_ATTRS 一致。
- Step 4: 手动验收(浏览器,无自动化,如实记录结果)
- 打开
/static/widget-test.html:断言面板全 ✅ - 默认橙:邀请卡(1.8s)+ 聊天面板 + 头像 5 处中挂件侧 4 处,与改造前截图对比无可见差异
- Blue/Green/Purple:全套配色跟随,无橙色残留
- Test Broken Avatar:头像回退 🤖
/(独立页)与/?color=1a73e8:独立页跟随;头像替换;SVG 图标为深主题色?color=red(非法):回退默认橙,console 有 warn
- Step 5: Commit
git add static/widget-test.html
git commit -m "feat(test): widget-test重写——换色预览+teardown重建+浏览器断言面板"
Task 11: changelog 文档 + spec 校准 + 终验
Files:
-
Create:
docs/2026-08-31-dynamic-theme-changelog.md -
Modify:
docs/superpowers/specs/2026-08-31-dynamic-theme-color-design.md(三处校准) -
Step 1: 写 changelog(按仓库惯例,含嵌入方用法)
# 2026-08-31 动态主题色 + 头像图片化
## 嵌入方用法
- 品牌色:`<script src=".../widget.js" data-color="#1a73e8">`(3/6 位 hex,缺省=默认橙 #f08a31)
- 自定义头像:`data-avatar="https://.../logo.png"`(完整 URL)或 `data-avatar="/static/xxx.png"`(本服务路径);缺省=内置客服头像
- 独立页:`/?color=1a73e8`
- 已知限制:极浅品牌色(l>65)配白字对比度低;非法色值回退默认橙并 console.warn
## 实现要点
- 单色 HSL 派生 7 语义槽位,CSS 变量注入(:host / :root)
- 4 层兜底:缺失→默认 / 非法→warn+默认 / 派生异常→静态色板 / 结构性(:root 字面量)
- 头像:绝对定位覆盖 + load 隐藏 emoji / error 移除回退 emoji
- 回归网:tests/test_theme_static.py(槽位完整/橙残留/双份同步/跨域头像)
-
Step 2: spec 三处校准(与实施一致)
-
§5 表 borderSoft 行:
h-2, s×0.75, l=82→h-2, s×0.72, l=84(容差内) -
§12.4:遮盖机制改为 "load 成功后隐藏 emoji 兄弟节点;error 移除 img(透明底素材无法物理遮盖)"
-
§12.2:"单一常量"限定为挂件侧
DEFAULT_AVATAR_PATH(跨域场景所在);chat.html 为同源直接字面量,不跨文件共享 -
Step 3: 终验
Run: python -m pytest tests/ -q → Expected: 全 PASS
Run: Task 10 Step 4 手动清单全部通过(若未做,补做)
- Step 4: Commit
git add docs/2026-08-31-dynamic-theme-changelog.md docs/superpowers/specs/2026-08-31-dynamic-theme-color-design.md
git commit -m "docs: 动态主题色changelog——嵌入方用法+spec三处实施校准"