Co-Authored-By: Claude Code <noreply@anthropic.com>
13 KiB
设计文档:客服挂件动态主题色(品牌色)
- 日期:2026-08-31
- 状态:设计定稿,待实施
- 分支:chany_dev_dev
1. 背景与目标
当前客服挂件(widget.js)与独立聊天页(chat.html)的配色为固定橙色, 约 40 处(widget.js)+ 24 处(style.css)硬编码色值散落在样式字符串/CSS 中。
目标:像 Intercom / WhatsApp Business 那样,让每个嵌入网站传入自己的品牌色, 整套配色(深色文字、浅背景、边框、阴影、渐变)自动从这一个颜色派生, 不传则回退默认橙,视觉与现状一致(唯一例外见 §9.1 Send 渐变简化)。
非目标
- 不做多站点/多租户后台配置(单租户部署,嵌入方在 data 属性里自行控制)
- 不做完整 dark 模式(现状
data-theme="dark"本就只影响气泡/面板背景,维持现状) - 不做品牌色之外的多色自定义(YAGNI,派生已覆盖)
- admin.html(内部后台,蓝色系)不在范围内
2. 需求决策记录
| 决策点 | 结论 |
|---|---|
| 配置入口 | 嵌入方 script 标签 data-color 属性;独立页 URL 参数 ?color= |
| 生效范围 | widget.js 挂件 + chat.html 独立聊天页 |
| 颜色派生 | 单品牌色 + HSL 规则自动派生全套色板 |
| 技术路线 | CSS 自定义属性(语义槽位)+ JS 派生注入(方案 A) |
| Send 渐变 | 接受简化:与邀请卡渐变统一,近乎纯色(用户已确认) |
| 输入格式 | 仅 3/6 位 hex(# 可选);其他格式告警并回退默认 |
| 头像图标 | 🤖 emoji 全部替换为图片 static/kefu.png,挂件侧支持 data-avatar 覆盖(默认 kefu-small.png,原图 kefu.png 为素材),详见 §12 |
3. 架构与数据流
客户网站
┌──────────────────────────────────────┐
│ <script src=".../static/widget.js" │
│ data-color="#1a73e8"> │
│ │
│ widget.js(自包含,内联派生函数) │
│ 1. 读 data-color(缺失/非法→默认) │
│ 2. derivePalette(hex) → 7 槽位色板 │
│ 3. Shadow DOM style 字符串头部注入 │
│ :host { --w-primary: ...; ... } │
│ 4. 全部 CSS 引用 var(--w-*) │
└──────────────────────────────────────┘
独立聊天页(我们部署)
chat.html?color=1a73e8
→ static/js/theme.js(同一派生函数)
→ 写入 document.documentElement(:root)变量
→ style.css 中 :root 自带默认橙字面量(JS 失败也有兜底)
要点:
- widget.js 保持单文件自包含(嵌入方只加一个 script 标签,不多发请求),
派生函数在 widget.js 内联一份;独立页用
static/js/theme.js共享一份。 两份同步由 pytest 源码级断言保证(§7.2)。 - 变量块与样式字符串同一次注入,结构上不存在"挂件显示了但变量丢了"的中间态。
- 后端零改动:WebSocket/REST 协议、会话、邀请卡、流式逻辑全部不动。
4. 语义槽位清单(7 个)
现状 20 个相近色值合并为 7 个语义槽位:
| 槽位 | 默认值(现状) | 吸收的现状颜色 | 用途 |
|---|---|---|---|
--w-primary |
#f08a31 |
原值 | 头部背景、按钮、头像、分隔线、用户气泡 |
--w-primary-rgb |
240,138,49 |
rgba(240,138,49,α)、rgba(196,91,25,.22) |
阴影色(alpha 留在 CSS 原处) |
--w-gradient-end |
派生 | #F08A34→#EC8C2F、#f59e0b |
渐变副色(两处渐变统一) |
--w-deep-text |
#8C4D1D |
#8C4D1D、#A8591F |
图标描边(SVG→currentColor)、按钮文字、移动端标签 |
--w-caption-text |
#C16C2E |
#C16C2E、#d97706 |
POPULAR TOPICS 标签、页脚小字 |
--w-soft-bg |
#FFF8F3 |
#fef9f3、#FFF9F4、#FFF7F1、#faf7f3 |
卡片底、输入框底、hover 背景 |
--w-border-soft |
#F0D5BF |
#EECFB6、#E8C4A9、#F1CCAE、#fef3c7 |
按钮/卡片/邀请卡描边 |
不派生、保持固定(语义色而非品牌色):在线绿点 #22c55e、AI 气泡灰
#f3f4f6、深灰文字 #1f2937、结束横幅绿 #34a853、白色遮罩 rgba(255,255,255,x)。
5. 派生规则(derivePalette,纯函数)
实测现状橙 #f08a31 = hsl(28, 86%, 57%),各派生色与主色的关系:
| 槽位 | 规则(相对输入 h,s,l) | 亮度钳制 |
|---|---|---|
| primary | 原样 | 不动(品牌色必须精确) |
| gradient-end | h+2, s 不变, l-1 | — |
| deep-text | h-2, s×0.76, l-24 | l ∈ [28, 40] |
| caption-text | h-2, s×0.72, l-10 | l ∈ [40, 52] |
| soft-bg | h 不变, s×0.9, l=97.5 | l 固定 |
| border-soft | h-2, s×0.72, l=84 | l 固定 |
- 亮度钳制保证任意色相输入都产出可读配色(蓝/绿/紫/红均安全)
- 色相加减需 mod 360(红区 h≈0/360 边界)
- 默认输入
#f08a31的派生结果与现状各槽位代表值偏差 ≤ 每通道 8/255 - border-soft 参数为实测校准值:原设计
s×0.75, l=82对默认橙派生的 蓝通道与现状代表值差 12,超出每通道 8/255 容差,校准为s×0.72, l=84
6. 兜底策略(4 层)
| 层 | 场景 | 行为 |
|---|---|---|
| ① 属性缺失 | 无 data-color / ?color= |
#f08a31 走同一条派生管线(默认是一等公民,非特例) |
| ② 格式非法 | 非 3/6 位 hex | console.warn + 回退默认 |
| ③ 派生异常 | 意外输入绕过校验、HSL 计算抛错 | derivePalette 整体 try-catch,catch 返回预计算静态色板常量(字面量,零计算) |
| ④ 结构性 | — | widget.js:变量与样式同字符串同注入;chat.html:style.css :root 自带默认字面量,JS 全挂也正常显示默认橙 |
不存在"未设置颜色导致挂件不显示/无样式白板"的路径。
7. 文件级改动清单
| 文件 | 改动 |
|---|---|
static/js/theme.js(新,~60 行) |
derivePalette 纯函数、DEFAULT_COLOR、静态 DEFAULT_PALETTE 常量 |
static/widget.js |
① 内联同款派生函数+静态色板;② style 字符串 ~40 处换 var(--w-*);③ 头部注入 :host 变量块;④ SVG stroke="#8C4D1D" → currentColor(父级已设 color);⑤ toggle 模板 inline #f08a31 移入 CSS 类;⑥ 4 处 🤖 emoji → <img> 头像(§12) |
static/css/style.css |
:root 补全 7 槽位默认值,清除散落硬编码(.chat-header 等);清理旧变量:--primary/--primary-dark 为死代码(无规则引用)直接删除,--user-bubble 被 .msg.user 引用,迁移为 var(--w-primary),避免两套变量并存 |
static/chat.html |
引入 theme.js;SVG → currentColor;头部 🤖 → <img> 头像(§12) |
static/js/chat.js |
头部 ~10 行 bootstrap:读 ?color= → 派生 → documentElement.style.setProperty(chat.js 现无任何颜色代码,已核实) |
static/widget-test.html |
颜色选择器 + 预设色(橙/蓝/绿/紫)动态重建 script 标签;内嵌断言面板 |
docs/2026-08-31-dynamic-theme-changelog.md |
按仓库惯例记录改动与嵌入方用法(data-color 使用说明) |
7.1 测试页重建陷阱(实施注意)
widget-test.html 动态换色预览时,必须先移除旧 #cc-widget-host、
移除旧 script 标签、清掉 sessionStorage 的邀请卡标记
(widget_invite_dismissed_<pathname>),再注入新 script;
否则重复挂载/邀请卡不弹,会误判为功能损坏。
7.2 两份拷贝防漂移(同步断言)
pytest 用正则分别提取 widget.js 与 theme.js 中的 derivePalette
函数源码,归一化空白后要求逐字相等——单边改动必红。
(比"比对规则常量"强,算法级修改也无法漏检。)
8. 测试与验收
tests/test_theme_static.py(pytest,随现有测试套件本地跑;仓库暂无 CI 配置,未来接入时一并纳入):- 扫描 widget.js / style.css:所有
var(--w-*)引用均有定义(防漏注入) - 橙色字面量只允许出现在默认值定义/静态色板/断言用例处(防漏改残留,带行级允许清单;扫描范围仅 widget.js 与 style.css——widget-test.html 自身的
#f08a31不在此网内,随该页重写自然消化) - 两份 derivePalette 源码归一化后逐字相等(§7.2)
- 扫描 widget.js / style.css:所有
- widget-test.html 断言面板(浏览器跑,PASS/FAIL 可视):
derivePalette("#f08a31")输出 vs 现状硬编码值表,偏差容忍 ≤ 每通道 8/255 - 手动验收:
- 默认不传参,改造前后截图对比,除 §9.1 外无可见差异
- 蓝/绿各截一组(挂件+邀请卡+独立页)确认派生效果
- 非法色值(
data-color="red")回退默认橙
9. 已知限制与风险
9.1 Send 按钮渐变简化(唯一默认视觉可见变化)
现状 Send 按钮 #f08a31→#f59e0b(双色相差 10°),统一为
primary→gradient-end 后接近平纯色。用户已确认接受;
实施后截图验收,若不满意再拆独立槽位 --w-gradient-accent(h+8, l-3)。
9.2 其余已知项
- 槽位合并决定默认模式是"肉眼不可辨"(ΔRGB≤8/通道),非像素级一致;
若个别合并处(如帮助块边框
#fef3c7)实际可见,拆回独立槽位 - caption 色现状 ~3.9:1 本就低于 WCAG AA,派生钳制保持同水平,非回归; 可选把钳制上限收到 48 略改善(实施时定)
- 极浅品牌色(l>65)配白字对比度低,v1 不做自动黑白切换,文档注明
- 视觉正确性最终靠人工截图对比,浏览器断言面板不进 CI(项目无 Node 设施的现实妥协)
10. 回滚
纯静态文件改动(widget.js/theme.js/style.css/chat.html/chat.js/
新增 kefu-small.png),无数据迁移、无协议变更;git revert 即完整回滚。
(kefu.png 素材已在库中,不涉回滚。)
另:chat.html 第 50 行存在一处注释掉的 <div class="avatar">🤖</div>
(零行为影响),实施时顺手删除。
11. 明确排除的风险面(不因本设计引入)
- 已嵌入客户网站无需改嵌入代码(不传
data-color即默认橙,向后兼容) - widget.js URL 不变;浏览器旧缓存=旧样式自包含文件,无新旧行为撕裂
- 聊天功能(WebSocket 消息、会话、邀请卡、断线重连)零改动
12. 增补:客服头像图片化(2026-08-31)
用图片替换所有 🤖 emoji 头像。素材:static/kefu.png
(1297×1213,RGBA 透明底,近方形)。
12.1 替换位置(5 处,全部当前为 🤖)
| 位置 | 文件 | 容器现状 |
|---|---|---|
| 聊天面板头部头像 | widget.js .panel-header .avatar |
48px 圆,rgba 白底,::after 绿点 |
| 邀请卡图标 | widget.js .invite-icon |
40px 圆,rgba 白底,.invite-status-dot 绿点 |
| 桌面 toggle 按钮 | widget.js inline 36px 圆 |
inline 样式,绝对定位绿点 |
| 移动端 toggle 按钮 | widget.js .mobile-circle |
56px 圆,橙底,.mobile-dot 绿点 |
| 独立页头部头像 | chat.html .chat-header .avatar |
48px 圆,rgba 白底,::after 绿点 |
(消息气泡 .msg-wrapper .avatar 的 CSS 存在但当前未渲染任何元素,不在替换范围。)
12.2 URL 解析(关键差异)
- widget.js 嵌入在客户网站,相对路径会 404:头像 src 必须以
wsBase(new URL(script.src).origin,代码已有)拼接默认路径; 单一常量DEFAULT_AVATAR_PATH = "/static/kefu-small.png"落在挂件侧 widget.js(跨域场景所在)(见 §12.6) - chat.html 为同源直接字面量
/static/kefu-small.png,不跨文件共享常量
12.3 可配置性
- 挂件新增
data-avatar属性(与 data-color 同模式): 值为完整 URL(http/https 开头)则原样使用;否则视为服务端相对路径按 wsBase 拼接; 缺省 → 默认kefu-small.png(由原图 kefu.png 压缩派生,§12.6) - 独立页不做
?avatar=参数(我们自己的页面,YAGNI)
12.4 兜底(与本文档分层兜底哲学一致)
emoji 不删除,作为 <img> 的底层兄弟节点保留;<img onerror> 时移除自身
→ 🤖 露出。覆盖场景:客户站 CSP img-src 拦截、图片 404、网络失败。
实际遮盖机制(实施校准):load 成功后隐藏 emoji 兄弟节点;error 移除 img
露出 emoji——透明底素材无法物理遮盖,绝对定位(§12.5)仅为视觉层叠。
定位上下文核查(复审结论):5 个容器中 4 个已是 position:relative;
桌面 toggle 的 36px 内圆(inline)无定位,其外层 wrapper 才是 relative——
实施时给该内圆补 position:relative,否则 img 的 inset:0 会锚到
wrapper(box 恰好重合但不显式,易错位)。
12.5 CSS 与视觉
<img>规则:position:absolute; inset:0; width/height:100%; object-fit:contain; border-radius:50%(img 绝对定位叠于 emoji 之上,load 后 emoji 隐藏,见 §12.4;透明底近方形素材用 contain 不裁切; 容器圆底色透过透明区显示,任意主题色下均成立——头像不参与颜色派生)- 各绿点(在线状态)为绝对定位兄弟节点/伪元素,不受 emoji→img 替换影响
- alt=""(装饰性),避免读屏重复播报
12.6 素材体积(已定决策,非建议)
原图 682KB/1297px,而最大显示尺寸为移动端圆 56px(@3x≈168px),对挂件过重。
实施时产出压缩版 static/kefu-small.png(168×168,保留透明通道)供代码
统一引用(§12.2 常量);kefu.png 原图保留不动,仅作素材。
若倾向直接用原图,改 DEFAULT_AVATAR_PATH 一行常量即可。
12.7 测试增补
- pytest 静态断言:widget.js 中头像 src 必须经 wsBase 拼接 (防相对路径在客户站 404 的回归)
- widget-test.html:4 处挂件头像 + data-avatar 覆盖用例 + 故意错误 URL 验证 emoji 兜底
- 手动截图:默认橙 + 蓝/绿主题各确认头像与绿点位置正常