Files
ai-kefu-jc/docs/superpowers/plans/2026-08-31-dynamic-theme-color.md
T

771 lines
32 KiB
Markdown
Raw Normal View History

# 动态主题色 + 头像图片化 实施计划
> **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](../specs/2026-08-31-dynamic-theme-color-design.md)
**三处实施细化(相对 spec,收尾任务会回写 spec):**
1. §5 borderSoft 规则调参:`s×0.75, l=82` → **`s×0.72, l=84`**(原参数输出与现状蓝通道差 12,超出 ≤8 容差;调后偏差 (0,2,2))
2. §12.4 遮盖机制:素材是透明底 PNG,`<img>` 无法"遮盖"emoji——改为 **load 成功后隐藏 emoji 兄弟节点**,error 时移除 img 露出 emoji(兜底语义不变)
3. §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: 写失败测试**
```python
"""动态主题色静态回归网(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 内联时同一份函数体)**
```javascript
/* 客服主题派生——单品牌色 → 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**
```bash
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 长边,保留透明)**
```bash
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**
```bash
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 全红,即安全网)**
```python
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**
```bash
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`(死代码/迁移),改为:
```css
: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**
```bash
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 常量,含相同注释):
```javascript
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 注入块(作为第一个数组元素)**
```javascript
":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**
```bash
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 模板改为:
```javascript
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 复审修正):
```css
.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**
```bash
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 数组追加头像通用规则**
```css
.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 之后、事件绑定区之前)**
```javascript
// 头像兜底: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**
```bash
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 死注释删除**
```html
<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(在现有逻辑之前)**
```javascript
// ---- 动态主题色 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 未含)**
```css
.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
```bash
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`,漏掉则面板永远显示钩子未就绪);新增:
```html
<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 合并,缓存戳防缓存)**
```javascript
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;页面加载后自动跑一次)**
```javascript
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: 手动验收(浏览器,无自动化,如实记录结果)**
1. 打开 `/static/widget-test.html`:断言面板全 ✅
2. 默认橙:邀请卡(1.8s)+ 聊天面板 + 头像 5 处中挂件侧 4 处,与改造前截图对比无可见差异
3. Blue/Green/Purple:全套配色跟随,无橙色残留
4. Test Broken Avatar:头像回退 🤖
5. `/`(独立页)与 `/?color=1a73e8`:独立页跟随;头像替换;SVG 图标为深主题色
6. `?color=red`(非法):回退默认橙,console 有 warn
- [ ] **Step 5: Commit**
```bash
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(按仓库惯例,含嵌入方用法)**
```markdown
# 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**
```bash
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三处实施校准"
```