771 lines
32 KiB
Markdown
771 lines
32 KiB
Markdown
|
|
# 动态主题色 + 头像图片化 实施计划
|
|||
|
|
|
|||
|
|
> **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三处实施校准"
|
|||
|
|
```
|