摘要
产品矩阵里有四个 Web 工作台(语音、视频、配音、外贸)加一个官网主站,用户提了两个要求:全部默认暗黑模式,风格与主站统一;以后要做主站白天模式时不能翻车。听起来是”改四个开关”的小事,实际上最有价值的是排查方法论:代码里写了默认暗 ≠ 首屏渲染是暗。localStorage 残留、系统主题跟随、登录页和主应用两张皮,都能让”我以为暗了”变成”用户看到的还是亮”。本文记录判定方法和三个真实根因。涉及域名与路径已脱敏。

一、判定标准:先定义”什么叫做默认暗黑”
三条硬标准,缺一不认:
- 干净环境:全新的浏览器 profile,
localStorage.clear()都嫌不够,直接每站开新 context——老用户残留的主题偏好会骗人; - 零交互:页面加载完立即测,不点任何按钮(点了切换钮再测等于测了个别的状态);
- 量像素:不信变量、不信 class,量实际渲染出来的背景色。
from playwright.sync_api import sync_playwright
import math
def luminance(page):
rgb = page.evaluate("""() => {
const c = getComputedStyle(document.body).backgroundColor;
return c.match(/\\d+/g).map(Number);
}""")
r, g, b = rgb[:3]
return round(0.2126*r + 0.7152*g + 0.0722*b, 1)
for url in SITES:
with sync_playwright() as p:
b = p.chromium.launch() # 不装任何 storage_state
pg = b.new_page()
pg.goto(url, wait_until="load")
print(url, luminance(pg))
b.close()
实测输出(阈值 <40 判暗):
voice.example.com → rgb(10,14,23) L=13.8 DARK ✓
video.example.com → rgb(11,13,16) L=12.8 DARK ✓
dub.example.com → rgb(10,14,23) L=13.8 DARK ✓
trade.example.com → rgb(10,14,23) L=13.8 DARK ✓
二、根因一:登录页和控制台是两张皮
外贸站公网 /console/ 会 302 到登录页——如果只量登录页就收工,属于测了个假目标。本案例里登录页恰好早就被钉成暗色(html,body 直接 background:#0a0e17 加 color-scheme:dark),但真正的控制台本体是另一份代码、另一台服务器上的独立应用。
正确动作分两步:

- 公网实测登录页渲染 luminance(上面那套脚本);
- 进源码查本体:
grep出样式 token 与主题切换逻辑——
:root { --bg:#0a0e17; --panel:#0f1520; --text:#e6edf3; }
本体没有 prefers-color-scheme 自动切亮逻辑、没有“默认 light 等 JS 覆盖”的窗口期 → 判定通过。教训:多页应用要按“用户会看到的所有页面”逐个量,302 跳转链上的每一站都算。
三、根因二:编译产物改不了,就在注入层做主权
视频站是打包后的 SPA,assets/index-*.js 是压缩混淆产物,bundle 不能动(改了 hash 就变,文件名对不上)。但 Gradio/SPA 类应用大多有一个可读写的 index.html 外壳——静态服务直接读盘,改完刷新即生效,连后端都不用重启。
策略:一切定制进外壳,不动 bundle。注入层做四件事:
<style>/* 品牌色/布局覆盖,选择器用 !important 压过 bundle */</style>
<script>
// 1. 主题兜底:无记录时强制暗色,跟随 bundle 自己的存储键
const K = "site-theme";
if (!localStorage.getItem(K)) localStorage.setItem(K, "night");
// 2. 品牌导航条(顶栏三药丸 + 互链)
// 3. 语言切换(中英词典,DOM 改写)
// 4. 图标 SVG 替换
</script>
注意坑:注入的 CSS 若依赖“选择器权重恰好赢”,一次 bundle 升级就可能输。所有关键规则显式 !important,并把 bundle 里的存储键名(这里是 "night" 这类字符串值)抄准——猜错值等于白写。
四、根因三:SVG 图标的两个隐形炸弹
品牌统一还揪出两个图标问题,都不影响“能渲染”,但影响“在别的上下文里正常”:
炸弹一:零宽 bbox + objectBoundingBox 渐变。渐变写成 gradientUnits="objectBoundingBox"(默认值)时,渐变坐标相对元素自身包围盒归一化。一旦图标在某些容器里包围盒退化(宽度为 0 的包裹元素、display:none 预热渲染),渐变直接算成零面积——图标中心整块空白,换个页面布局又“莫名好了”。修法:改 gradientUnits="userSpaceOnUse",坐标钉死在 viewBox 用户坐标系,与包围盒脱钩。
炸弹二:emoji 当功能图标。主题切换钮用 🌙/☀️ 表达,Windows/macOS/Android 的 emoji 字形、尺寸、基线全不一样,跨平台视觉必然漂移。定稿换手绘矢量:月亮/太阳各一枚 path,描边加填充,currentColor 跟随主题变量:
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>
</svg>
五、为未来的白天模式留好门
现在全员暗、以后要加主站白天模式,最怕的是“当时钉死的暗”以后翻不动。三条留门规矩:
- 默认值集中:暗色只通过“存储键缺省 = night”表达,不散落 hardcode;白天模式上线 = 改缺省逻辑一处,用户手动偏好仍优先;
- 存储键对齐:各站主题键统一语义与取值(别一个用
theme:dark一个用mode:night),跨站跳变时才不会出现“主站亮、工作台暗”的撕裂; - 回归用同一把尺:把本文的 luminance 探针存成常驻脚本,未来任何主题改动后重跑四站,10 秒出结论,不靠人眼。
六、可迁移清单
- “默认暗黑”这类视觉状态断言,用新 profile + 零交互 + 像素 luminance三件套验收,代码审查只能当辅助;
- 登录页 ≠ 应用本体,302 链上每个页面都要量;
- 编译产物不可改时,
index.html注入层是主权阵地,但存储键与取值必须从 bundle 里抄真实值; - SVG 渐变用
userSpaceOnUse更抗布局变化;功能图标一律矢量,不用 emoji; - 给未来的反向需求(白天模式)留单一缺省点,比任何文档都可靠。



