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

13 KiB
Raw Blame History

设计文档:客服挂件动态主题色(品牌色)

  • 日期: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. 测试与验收

  1. tests/test_theme_static.py(pytest,随现有测试套件本地跑;仓库暂无 CI 配置,未来接入时一并纳入):
    • 扫描 widget.js / style.css:所有 var(--w-*) 引用均有定义(防漏注入)
    • 橙色字面量只允许出现在默认值定义/静态色板/断言用例处(防漏改残留,带行级允许清单;扫描范围仅 widget.js 与 style.css——widget-test.html 自身的 #f08a31 不在此网内,随该页重写自然消化)
    • 两份 derivePalette 源码归一化后逐字相等(§7.2)
  2. widget-test.html 断言面板(浏览器跑,PASS/FAIL 可视): derivePalette("#f08a31") 输出 vs 现状硬编码值表,偏差容忍 ≤ 每通道 8/255
  3. 手动验收:
    • 默认不传参,改造前后截图对比,除 §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 兜底
  • 手动截图:默认橙 + 蓝/绿主题各确认头像与绿点位置正常