Files
ai-kefu-jc/docs/superpowers/plans/2026-08-31-dynamic-theme-color.md
T
2026-09-29 14:15:12 +08:00

32 KiB
Raw Blame 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

三处实施细化(相对 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: 写失败测试

"""动态主题色静态回归网(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: 手动验收(浏览器,无自动化,如实记录结果)
  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
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三处实施校准"