工程笔记

August 30, 2026 · View on GitHub

实现决策与它们背后的实测数据。本文回答「为什么是这样写的」,不重复功能说明(见 features.md)或色值取法(见 design-language.md)。

排在前面的几条是结构性约束——不了解就会重新踩进去。


目录


变量必须声明在 body 而不是 :root

应用把主题令牌写成 <body> 的行内样式dsh-client-ui-layout 对每个令牌调用 body.style.setProperty)。

推论一:任何引用 --dsw-* 令牌的自定义属性都必须声明在 body 上。:rootbody 的父元素,在那里替换一个不存在的变量属于 guaranteed-invalid,计算值为

实测(真实浏览器中逐个读取计算值):

变量声明位置计算值
--edge-signal:root(不引用令牌)#fff500
--edge-line / --edge-paper / --edge-soft:root(引用令牌)""
同上三者body#d8d9d5 / #e8e8e2 / #dcddd6

这曾经是一个真实缺陷:scrollbar-color: var(--edge-line) transparent 里的变量为空,整条声明被丢弃,主题滚动条一直没生效(实测 scrollbar-color 计算为 auto)。现已全部移到 body,并由 check.js结构化检查(遍历每个 :root 块,查找引用 --dsw-*--edge-* 声明)。

推论二:令牌值自身可以是 var() 引用。主题把 --dsw-alias-brand-primary 的暗色值声明为 var(--edge-accent),而调色板变量也在 body 上,所以引用在同一元素解析,并随 class 翻转自动重解析——这是「换配色不需要重新注册令牌层」的全部原因。


样式表是一整个模板字符串

整份样式表是一个传给 insertCss() 的模板字符串(反引号包裹)。有三类改动会在「文件仍能解析」的情况下静默破坏效果,因此固化为 check.js

  1. 反引号。字符串里出现一个反引号(哪怕写在 CSS 注释里)会提前结束模板字符串,整个 client bundle 解析失败。注释里要引用代码请用单引号。
  2. ${...}。在模板字符串里那是插值,不是 CSS。
  3. CSS 注释提前闭合。这是最隐蔽的一类:注释提前收束后注释本身仍是配平的,真正的破坏是残留的说明文字落到顶层、与下一条选择器黏在一起,整条规则被丢弃。曾因此导致 --edge-word 未定义、加载屏品牌块整块塌回左上角。node --check 查不出这类问题——文件照样能解析。

check.js 的「顶层漏进散文」判据经过一次修正:第一版用「含逗号或英文单词」,在合法选择器上产生了 33 个误报(:is([role='tab'], …)input, textareatbody tr:hover)。可靠信号窄得多——CSS 选择器里不会出现「跟在字母后的句点 + 空格」,而散文会


层叠与挂载点

三个图层各有各的挂载点,都是实测定下来的。

等高线:应用外框内部

从应用自身 CSS 实测:三个元素会用不透明的 --dsw-alias-bg-base 盖住任何 body 级图层——应用外框、对话列、详情列。所以图层挂进外框内部,并在挂载期间把这几处底色置为透明(:has() 守卫使功能关闭时全部规则失效)。

外框本身已是 position: relative不产生层叠上下文,因此 inset:0; z-index:0 的子元素正好落在「外框底色之上、所有定位子元素之下」。

侧栏底色也一并透明:本主题里 --dsw-specific-sidebar-fill--dsw-alias-bg-base 本就是同一个值,所以这不改变任何像素,只是让整片地形连续穿过侧栏。

hero 水印:body,z-index: 0

水印曾画在下拉菜单之上——暗色模式下模型选择菜单内部能看到横穿而过的大字母。

成因是一次 z-index 平局,不是「层级不够高」。 hero 容器带 position:relative; z-index:1,它本身就是一个层叠上下文,因此下拉菜单自己的 z-index:20关在里面,根本不参与 body 层的比较;而水印当时也是 z-index:1,与 hero 容器同级——平局按 DOM 顺序决定,水印作为最后追加到 <body> 的兄弟节点赢下每一次平局,于是盖住了整个 composer 子树。

改为 z-index: 0:既输给 hero 容器(1)、标签页(1)、输入区(7),又仍然盖在应用外框自身的不透明底色之上——外框是 position:relativez-index:auto,不产生层叠上下文,两者在同一绘制步骤里按 DOM 顺序排列。

实测(1440×900,逐像素):

下拉菜单内被水印改动的像素菜单外仍绘制的像素
修复前9945 px(越界)37000 px
修复后0 px36084 px

真实截图里量到的越界像素为 11002 px(合成色 #464844 = #f5f5f0 以 α0.13 叠在菜单底色 #2c2e2a 上,逐位吻合),与复现值同量级。

保持显示的水印:会话列内部,z-index: -1

非 hero 页面没有标题可跟随,而一个 fixed 的 body 子节点会画在消息文字之上(它没有 z-index 竞争者可输给)。所以改为挂进会话列内部并取 z-index: -1,需要该列同时具备两个属性(仅在水印挂载期间,由 :has() 守卫):

  • isolation: isolate —— 没有层叠上下文时 z-index:-1 会逃到最近的祖先上下文,画到该列自己的不透明底色后面,即完全不可见;
  • position: relative —— 该列原生是 position:static,绝对定位子元素会对着应用外框解析,横跨侧栏溢出。

两块全屏遮罩的层级

表面z-index
启动加载屏2147483000
雷霆大字2147482000

加载屏是唯一有资格独占整屏的表面,所以大字必须低一档。两个数字都写成了断言。


等高线背景

四个实测决定的设计

  1. 有界散射(1.9× 提速)。 最初让每个高斯凸起在每个网格点上求值;高斯在 2.6σ 之外数值上已归零,改成每个凸起只写自己包围盒内的格点后,1440×900 实测 8.30ms → 4.40ms。这是本功能能开在背景里的前提。

  2. 补一层长波起伏。 高斯之和在岛屿之间恰好衰减为零,那里的场完全平坦、没有任何高度线穿过——首版渲染因此出现大片空白,暴露了构造痕迹。加入三道极缓的长波正弦后,间隙里仍有梯度可穿越,孤立的「靶心」才连成一整片地形。代价 1.70ms。

  3. 用二次曲线画线,而不是直线段。 marching squares 每个网格边至多产出一个顶点,10px 网格下折线本身就是有棱角的:实测线段平均 7.8px,顶点转角 p99 达 41.7°lineJoin 救不了——1px 描边根本没有接合处可倒角。改成让每个原顶点当控制点、曲线穿过线段中点(C1 连续,数学上无折角),且不增加任何顶点:

    方案转角 p99顶点数增加耗时
    Chaikin ×141.7° → 22.4°11.1k+0.81ms
    Chaikin ×241.7° → 12.0°22.2k+1.45ms
    二次曲线(当时采用)不再有折线转角5.5k(不变)+0.15ms

    后续演进:中点二次曲线在长边上仍留下可见的「圆角多边形」弯折。现行实现改为 Chaikin ×3 预处理 + 约束 Catmull-Rom 三次曲线——曲线穿过原顶点、相邻段共享切向(切向系数 0.32),手柄长度上限(0.62× 较短邻段)防止窄鞍部过冲;开放路径的两个端点保持固定(0.4 系数、0.55× 手柄上限)。平滑效果由平滑/尖点测试固定,本表保留为当时的实测记录。

  4. 缝合成连续折线。 线段按边 ID 缝合后,数千条散段变成约 80 条连续折线,整片地形只需一次 stroke() 而非数千次 moveTo

平滑只管中段:三处真正的锐角(issue #3)

上面第 3 条的中点样条只在折线内部无折角。反馈「等高线变化时出现大量不规则锐角锯齿」时,中段确实是干净的(转角 p99 30.5° → 5.6°),锐角全部来自样条管不到的三处边界情形。实测每帧约 127 个尖刺,而整套测试全绿——因为它们在每一帧都存在,只是随场漂移不断换位置,所以看起来像「一变化就冒锯齿」。

  1. 末段二次曲线是退化的(95/95 条折线)。 循环最后一次迭代已经停在 mid(v[n-2], v[n-1]),紧接着那句 quadraticCurveTo 又拿 v[n-2] 当控制点、v[n-1] 当终点——控制点落在起点后方且三点共线。以线段长度归一化后 Q(t)t=1/3 处取到 0.333(起点是 0.5),也就是曲线先倒退 1/6 个线段再折返:一个精确的 180° 尖点,拖出一根倒刺。实测倒刺平均 1.39px、最长 1.95px,与闭式解 7.8/6 = 1.30px 吻合。改成 lineTo——入射曲线本就沿 v[n-2] → v[n-1] 方向抵达那个中点,直接连过去不产生折角。

  2. 闭合环被当成开放曲线画(32/95 条)。 walk()重复起点来闭环,把这样的折线喂给开放中点样条,起点处的出射切线与终点处的入射切线毫无关系,于是每个环的接缝上都留一个角:实测中位数 10.0°、最大 26.5°。改成循环形式——在 mid(u[m-1], u[0]) 起笔,每个顶点都是控制点,接缝因此落在某一段的中间而不是两端,整环 C1 连续,且不增加顶点。

  3. 相切针刺。 某条等高线与场近乎相切处,真实等值线是一个曲率极高但光滑的尖端;marching squares 在 10px 网格上线性插值表示不出来,只能吐出一根出去又原路折回的发夹,其底宽比 1px 描边还窄(最差实测:底宽 0.831px,却外伸 6.26px)。这种宽度下往返两笔压在同一批像素上,读不出「窄谷」,只看见那根倒刺——平滑救不了,因为几何里真的有这个特征,只能在提取处删掉。

    判定要两个条件同时成立(24 帧 / 15.26 万顶点 / 118 万 px 墨迹实测):底宽 < 2px(描边分辨不出)转角 > 90°(是折返而非拐弯)。命中率每帧 0.7 个顶点、总墨迹 0.0037%,是清伪影而非减密度;真实的窄特征毫发无损——转角 > 90° 的顶点底宽中位数 4.03px,而 8–12px 底宽整段(29562 个顶点)转角 p99 仅 21.7°。

    只删尖端比不删更糟。 第一版只丢弃发夹顶点,结果底边自己变成了一条真线段,把那次折返继承成两个约 78° 的转角(10.20 → 0.83 → 10.25px),全局最大转角反而从 65° 回升到 127°。因此顶点和它对面的邻居一起消掉,并把存活的前一个顶点拉到底边中点——位移最多 1px(2px 底宽的一半),1px 描边显示不出来,发夹处只剩一个光滑顶点。

    修好后整段动画序列:尖点 0 个、>90° 转角 0 个,最大转角 65.2°、p99 4.7°;总墨迹比值 0.997(形状没被扭曲),24fps 预算仍有 78% 余量。

小点是两类合法残渣

反馈的「莫名其妙的小点」不是渲染噪声,而是 marching squares 的两类产物:

  1. 画布外的碎屑。 网格是 ceil(w/step)+1 列 × ceil(h/step)+1 行,最后一行 / 列落在画布边界上或之外(1432×753 实测超出 +8px / +7px)。那里提取出的等值线被裁成短碎片,甚至完全不可见:85 条折线里 33 条含画布外顶点,3 条可见长度为 0——纯开销、零像素。
  2. 山顶靶心环。 距高斯峰顶一两格处,最内层等值线闭合成一个极小的圆。实测 4 个闭合环包围盒小于 26×26,最小 11.8×15.1px。

阈值不靠拍脑袋,而是量图案自身的尺度:横竖扫描 7322 个样本,等高线间距中位数 21px(p25 13px)。包围盒装不下一个线距的闭合环,里面放不下任何相邻环线,所以它读作「点」而不是「嵌套岛屿」。开放折线不受此限——那是延伸到画布外的线的可见一角,剪掉会在用户看得见的线上打个洞。

代价实测:丢掉全部 40px 以下的折线只损失 0.223% 的总描边长度,是清残渣而非减密度。

过滤器判定的几何和真正画出来的几何不是同一个。 绘制时折线被改画成二次曲线,端点是线段中点(原顶点降为控制点),因此曲线可能比原始折线更短、包围盒更小。第一版按原始折线判定「已清干净」的种子,实际仍画出了 35.1px 的短描边与 15.4×17.8 / 2.1×20.1 的小环。故阈值留出余量(长度 ×1.35、环包围盒 ×1.5)——曲线在两端各最多缩掉半个线段,而线段平均 7.8px。

随机地形与空白格

contourRng 的种子曾是常量,所以「随机地形」每次打开都是同一张图(两次独立加载实测均为 85 条折线、42497px 总长,顶点逐一相同)。改为每次加载抽一次种子(crypto.getRandomValues,退化时用时间 / Math.random 混合)。

同一次加载内仍保持确定性:地形会在每次 resize 重建,若每次重抽,拖动窗口就会把地形整个洗牌。实测拖走再拖回同一尺寸(含 520×900 窄窗与 2560×1100)地形完全复现。

写死的种子一直掩盖着一个真实缺陷:那唯一的布局恰好分布均匀。改成真随机后,8×5 覆盖网格(「近空」= 墨迹 < 0.6%)实测:

布局策略失败率最差
独立均匀采样5 / 12 种子3 个空格
仅分层采样6 / 24 种子低至 0.00%
分层 + 校验(现行)0 / 20 种子

两步都不可省:

  • 分层采样(抖动网格):视口切成至少 K 格的近方形格阵,每个凸起落在自己格内的随机点。格序用 Fisher-Yates 打乱,避免「屏幕左侧」与「先抽到的尺寸」相关。
  • 接受-或-重抽校验:分层修不了真正的机制——线只出现在场穿越 21 条固定高度之处,局部平坦且卡在两条高度之间的区域,凸起排得再匀也是空白。而若强行加大倾斜以保证每格必有穿越,需要横跨全宽约 9.5 条平行线,那读作条纹不是地形。所以改为按测试所用的同一不变量校验候选布局,不合格就换 salt 重抽(每次仅一次粗网格场求值,不提取不绘制)。

校验门槛也是实测定的:只要求「至少 1 次穿越」时仍有 4 个空白格漏过,且每个都含已绘制顶点——高度只擦过格子一角,产出 0.16%~0.44% 墨迹,几何上成立、视觉上空白。故要求每格至少扫过 3 条高度带。

去掉分层只留校验:实测 8 次加载有 4 次耗尽 12 次重抽上限并落回兜底布局。分层让「一次过」成为常态(平均 2.5 个候选,最多 6,从未触顶),校验把它变成保证。一次性构建耗时 18.7ms,且只发生在挂载 / resize,动画稳态仍有 76% 余量。

重启动画的首帧曾是空转

「关掉动态等高线再打开,图案不动了」的成因不是开关没接上,而是重启后的第一帧必定无效:重启把「上一帧时刻」置为 -1,而帧函数在这种情况下取 dt = 0,于是按原样重新提取并重绘了同一个相位——花掉约 4.4ms 产出逐字节相同的像素。

dt = 0 的本意是防「传送」:开关可能关了几分钟,把这段真实间隔当作 dt 灌进去会让地形瞬移。但代价是那一帧完全没有推进。实测(插桩构建):重新开启后 700ms 内变化像素为 0,因为渲染器在该窗口里只投递了一次 rAF 回调,而它正好被这个空转帧吃掉。

改为按一个标称帧1 / CONTOUR_FIELD_FPS)推进:既保留防瞬移的钳制,又让每一帧都做真正的工作。修复后同一断言实测变化像素 78450

关于「光点移动」

早前版本的文档与测试里写着第三个「光点移动」开关(光点沿等高线流动)。该功能从未实现过git log -S 查无任何提交),相关文档与测试断言属于描述未落地的设计——settings-rows 测试因此在每次全新签出时都必然失败。这些断言已删掉;一个默认就是红的测试提供不了任何信息。

设计阶段留下的一条结论仍有价值,记录在此备将来参考:光点若要落地,抽搐会是索引失配而不是缺动画。 等值线提取每帧整个重建折线数组,而随着场变形环线会合并 / 分裂——折线的顺序和数量都不稳定。实测 120 帧里数量在 81–87 之间跳动、有 27 帧发生变化;一旦光点靠数组下标记住「自己在哪条线上」,那个下标很快就指向另一条曲线。正确做法有两条:每帧按最近几何重新认领曲线,以及万一接不上就在 alpha 为 0 时换位重生。


启动加载屏

定位用「锚右边距」而非「锚左百分比」

早前用 --edge-rail: 73% 定左边缘,实测暴露两个问题:左百分比只决定文字从哪开始,其宽度自由地向右伸展,于是「离右边缘多远」——也就是肉眼真正读到的那段留白——从来不是被控制的量;且纯 vh 取值在宽而矮的窗口里会把字标缩小。

现在改为 right: var(--edge-gap),把右侧留白变成显式声明值,节奏线由最宽的一行自然决定。

项目参考图修改前修改后
字标 cap(占画面高)4.23%3.01%(偏小)3.72%
右侧留白7.4%10.5%(且不受控)12%(显式声明)
行距 / cap1.101.101.10
右侧溢出

字标尺寸 --edge-word: clamp(26px, min(5.2vh, 4.8vw), 64px)

  • min(5.2vh, 4.8vw)较小轴,因此无论窗口「矮」还是「窄」,品牌块都不会挤进左侧进度轨;
  • 26px 下限保证辅助小字在极小画面下仍可读;
  • 64px 上限是实测结论——不加它时 2560×1440 下 cap 占比会掉到 2.69%(大屏上字标反而变小),加上后稳定在 3.2% 以上。

跨视口验证:520×900 到 2560×1440 共 13 种视口逐一实测(用精确尺寸的 iframe,vh/vw 与媒体查询按真实视口解析),全部无任何方向溢出,cap 占比维持 3.1–3.9%,与进度轨 / 读数的水平间距 ≥150px。字标仍刻意略小于参考图:参考图是整幅出血海报,而这里是一闪而过、盖在应用之上的遮罩。

三处易错的排版细节

  1. 字标行 margin-left: -0.055em 抵消 Arial 左侧字身空隙,让字形墨边(而非字盒)落在节奏线上;
  2. line-height0.80 而非 1.10——参考图的 1.10 是墨边间距,而 line-height 覆盖整个 em 盒(Arial-900 约为 cap 的 1.38 倍),直接写 1.10 会渲染成松散的 1.52;
  3. 品牌块不能加 max-width:各行均为 nowrap,宽度上限只会裁字而不换行;要收窄应改 --edge-gap

两个只有量像素才发现的问题

  • 小字号必须有 px 下限。 各辅助行原先只写 em 比例,缩小整块后实测只剩 3px 高、峰值亮度 94 的灰糊——肉眼是一道模糊而非文字。现在统一改为 max(8~10px, …em);实测每行笔画分组数 16~37(模糊时仅个位数),确认为可读字形。
  • 标语用 Arial Narrow 压缩字体。 早前判断「Arial 下标语无法满足参考比例」只对了一半:探测渲染器后发现 Arial Narrow 确实可用(同一探测串 245px vs Arial 299px);在缺少该字体的机器上会回退到 Arial,因尺寸是比例值而非固定 px,仍然不会溢出。

收尾动画不能用 CSS transition

最初「铺满 + 淡出」写成 transition: width …,在验证渲染器里完全不执行——transitionrun / transitionstart / transitionend 一个都不触发,width 过了时长仍停在 10px。做过对照实验(transition 写在状态选择器里 / 预先写在基础规则里,两种写法都不动),确认是渲染器不跑 transition,与写法无关。

若照此发布,用户会看到左边一条 10px 黄条僵在原地。改为 JS 按墙钟时间逐帧写 width / opacity 后,实测同一渲染器下取到 17 个不同宽度、10px → 500px,并在真实速度截图中抓到中途帧。


设置页国际化

设置页此前是硬编码中文的:把 DSH 切到 English,这一页仍然整页中文。现在全部文案走应用自己的 locale 服务(@deepseek-ai/dsh-client-locale),随语言设置即时切换。

  • 不自己猜语言。 不读 navigator.language、也不另存一份偏好——那会和用户在设置里真正选的语言漂移。locale 服务已经持有偏好、durable 存储与重渲染通道。
  • 词典命名空间 settings.theme-endfield,通过 ctx.effect(() => locale.register(ns, { zh, en })) 注册,随 run 释放;否则重复挂载会撞上服务自身的 (ns, locale) 去重而抛错。
  • label 用 thunk 而非字符串label: () => t('nav')):槽位契约会按读取次数重新求值,导航行标题才能跟着语言变,而不必重新注册。
  • zh 是键集的事实来源,en 必须与之完全对齐。 少一个键不会崩,而是把原始键名渲染到界面上——静默且丑。因此测试直接对比两份词典的键集。
  • 分隔符也是词典键。 行读数原本写成 label + ':' + value,全角冒号是硬编码的,于是每一行英文都带着一个中文冒号。现在 sep 由词典给出(zh 用 ,en 用 : ),并有「英文页面不允许出现任何中日韩标点」的断言。
  • 分组标题在英文下不重复打印拉丁行。 中文下是「04 娱乐 / ENTERTAINMENT」这种编辑式双行;英文下两行会 collapse 成同一个词,所以第二行直接省掉。
  • 没有 locale 服务也能用t 回退到 zh 词典(最后才回退到键名本身),设置页照常渲染为中文——进程内测试就是以这种最小 ctx 挂载主题的。

locale: 只在服务存在时才声明

直觉上应该无条件写上 locale: NS,但 dsh-client-ui-renderer 对「声明了命名空间却没有安装 locale face」的条目会SlotAssemblyError

而重渲染其实并不依赖这个声明:renderer 的 useLocaleRevision每一个 outlet 都订阅到 locale revision 上,所以本页在任何情况下都会随语言切换重渲染,t 也是从 apply 闭包里取的、不走注入的 seat。

无条件声明的唯一后果,是把「没装 locale 插件」从「设置页显示中文」变成「设置页直接崩」。所以该键只在服务确实存在时才并入注册项,并且两个方向都写了断言。


已修问题归档

按成因分类。共同点:都是量出来的,不是读代码读出来的。

一类:底色归主题、文字归应用的裂缝

主题把某个背景令牌映射成实心强调色,却没有接管前景,于是应用自己声明的 color 直接落在强调底上。

.zGbnIq_secondaryButton(设置 › 模型 的 编辑)为例,上游声明:

color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover-solid);   /* :hover */

暗色模式下 label-primary#f5f5f0)就直接落在信号黄上。从反馈截图里逐像素量到:63 个 #f5f5f0 像素压在 #fff500 上 = 1.05:1,等于不可见。

早前的 hover 反色规则没兜住,是因为那条规则刻意排除了普通 button,好让「黄底黑字的开关」和「深底白图标的发送键」各自保住配色。而这类按钮恰好是「底色来自主题、文字来自应用」的,只能点名修。

顺着这个模式审计安装态 bundle,发现同样的缺陷共 6 处

元素位置修复前(暗色·谷地黄)修复后
.zGbnIq_secondaryButton设置 › 模型 行操作(编辑1.05:116.50:1
.gNWCoW_inspectButtonCordis 检查面板1.05:116.50:1
.iWrAna_inspectButton技能检查面板1.05:116.50:1
.o3BgMG_inspectButton工具检查面板1.05:116.50:1
.JVDQca_arrow附件轮播箭头1.05:116.50:1
.uV2eYG_add输入区 +早前已修

武陵青下同一处是 2.61:1——也不合格,只是没那么刺眼,这正是它一直没被发现的原因。

两个「看起来对、其实不对」的选择器坑:

  1. [class$='_inspectButton'] 匹配不到。 属性后缀选择器要求整个 class 属性以该串结尾,而元素常常还带第二个类(实测 class="gNWCoW_inspectButton HOVERPROBE" 直接漏掉)。上游会自由拼接类名,所以只能逐个点名。
  2. [class$='_arrow'] 会误伤。 轨迹与工作区也有以 _arrow 结尾的类,但它们没有 hover 填充;按后缀匹配会给保持原底色的元素强行刷上墨色字。故只点名真正会拿到强调底的附件箭头,并把这两个「不该被改」的箭头写成回归断言。

二类:前景与背景被映射成同一个值

提问卡片的「推荐」徽标在亮 / 暗两种模式下都完全不可见。上游把 --dsw-alias-button-info-fill前景色用,底色用 --dsw-specific-sidebar-nav-item-active-accent;而本主题为了中和残留蓝色,把这两个令牌映射成了同一个值(亮 #101110 / 暗 #fff500),于是标签把自己画在自己的底色上。

实测「有文字 / 无文字」两版渲染逐像素差为 0——DOM 里有字,画面上一个像素都没有。修复不去动这两个令牌(它们在别处确实被当作背景填充使用),而是只在选项行内把这对前景 / 背景显式钉死。

选项编号是相邻的一处:既有的暗色选中行反色规则只把后代文字改黑,改不到后代自己的背景,所以编号仍保留 --dsw-alias-bg-overlay#1c1e1c)的底色,实测 1.25:1。改为给编号加一层半透明墨色淡底(而非填死),让数字落在强调底上仍读得出,同时保留「小方块」的形态。

用例修复前修复后
「推荐」徽标 · 暗色 · 普通行0 px(不可见)1275 px,16.50:1
「推荐」徽标 · 亮色 · 普通行0 px(不可见)1275 px,16.50:1
「推荐」徽标 · 暗色 · 选中行1274 px,18.31:11296 px,16.50:1
选项编号 · 暗色 · 选中行207 px,1.25:1225 px,11.69:1

三类:色值对某一种模式不成立

  • 亮色模式「移除」按钮。 --dsw-alias-state-error-primary#ff3b30,在设置面板底色 #f2f2ec 上只有 3.16:1。iOS 风格的红是为「白字红底」调的,不是为「红字纸底」。只压暗文字颜色(令牌本身在别处仍作填充 / 状态点使用)后为 5.16:1。

  • 雷霆大字的压暗底 alpha。 初版取 rgba(16,17,16,0.28):暗色模式下 18.92:1 毫无问题,亮色模式下白字只有 2.29:1bg-layer-1 上更低,2.11:1)。只读 CSS 看不出来,是渲染出来量像素才抓到的:

    alphabg-base #e8e8e2bg-layer-1 #f2f2ec暗色 #101110
    0.28(初版)2.29:12.11:118.92:1
    0.403.13:12.90:118.92:1
    0.504.17:13.89:118.92:1
    0.55(现行)4.85:14.55:118.92:1

    取 0.55——两个亮色表面同时越过 4.5:1,对一个只需满足 3:1 大字号下限的词来说是刻意留的余量。暗色模式无论取哪个值都不变,所以这个数字是由亮色表面定的。

  • 大字的白色必须写字面量 #fff,不能用令牌。 --dsw-alias-label-primary 在亮色模式下是墨黑 #101110,用令牌会把「白色大字」在奶油纸底上渲染成近黑字。

  • 深色水印过响。 在近黑底上叠加亮度,比在纸底上减去亮度显得响得多,同样的 alpha 在深色下更吵。故深色从 0.13/0.16 降到 0.085(1.215:1),亮色保留 0.13(1.310:1)。

四类:回合状态标签

该标签(Md3f7G_turnStatus)是渐变文字而非普通着色文字:上游画了一层 linear-gradient 背景,再用 -webkit-text-fill-color: transparent + background-clip: text 把字「镂空」,并以 background-position 做流光动画。由此两个结论:

  1. color: 完全无效——透明文字填充优先,字仍由渐变决定;改色必须改渐变本身。
  2. 不能去动 --dsw-static-deepseek-500/200 这两个共享令牌。 它们同时支撑 --dsw-alias-button-info-fill--dsw-alias-state-business-primary--dsw-specific-bubble-highlight,本主题刻意把它们映射成墨 / 纸色。因此只覆盖 background-image,上游的 background-sizebackground-position 与流光动画保持不变。

色标取法见 design-language.md

五类:生命周期与竞态

  • sessions 服务迟到导致功能永久失效。 Web 启动是 Promise.all 并发挂载所有插件行,而本主题不声明 inject,所以 apply() 完全可能跑在 dsh-client-runtime 提供 sessions 之前。初版在 apply() 里缓存了一次 ctx.get('sessions'),于是在这类加载顺序下雷霆大字会永久失效——只在慢速 / 冷启动时偶发。现在改为惰性解析 + 120ms 重试

    之所以不用 inject: ['sessions']:那会让整个主题进入 cordis 的 pending 态,把令牌与样式表的挂载一起推迟——主题必须先能上色,即使这个娱乐功能永远拿不到服务。

  • 子开关在已挂载时失效。 为避免每个流式 token 触发重排而加的快速返回,把开关协调代码一起跳过了。

  • TDZ 崩溃隐患。 拆除函数赋值的 let 声明在它下方,而该函数经 unmount() 可达,typeof 也挡不住这种 ReferenceError

六类:写法统一

强调色此前存在两种写法(CSS 里是 hex,设置行文案与画布描边是 rgb()/rgba()),这是「同一个颜色的两份拼写各自漂移」的温床。现已统一为十六进制:

  • 画布描边改用 8 位十六进制 #RRGGBBAA。这一步先在真实浏览器里验证过再落地:canvas 会把 #14d0d045 规范化为完全相同的 rgba(20, 208, 208, 0.267),实测绘制像素 alpha 为 68/255;

  • 设置行文案改为标注 #14d0d0

  • 仅保留 --edge-accent-rgb 这一个通道列表,因为约 30 处半透明色块用的是 rgba(var(--edge-accent-rgb), α)

    试过用 color-mix() 把它也去掉:数值上完全等价(实测 color(srgb 0.0784314 0.815686 0.815686 / 0.16)rgba(20, 208, 208, 0.16)),但序列化形式不同,会改变 30 多处色块的计算值字符串。为删掉一个派生量去动 30 处、并让现有断言全部跟着改,是纯风险无收益,故保留——它与 hex 紧邻声明,不会各自漂移。

等高线描边的 alpha 不是照抄黄色的:相同 alpha ≠ 相同存在感。初版青色按黄色的 0.20 叠在 #101110 上实测只有 1.332:1,而黄色是 1.734:1——低 23%,观感上像功能变弱了。因此按「合成后对比度对齐」分别调参:

描边合成对比度相差
谷地黄 暗色#fff50033(α 0.20)1.734:1
武陵青 暗色#14d0d045(α 0.27)1.758:1+1.4%
谷地黄 亮色#beaf006b(α 0.42)1.290:1
武陵青 亮色#14d0d07a(α 0.48)1.288:1−0.2%

亮色青色无需像黄色那样另配一个压暗值(#beaf00),因为 #14d0d0 并不接近纸白。


验证方法论

这些是测试自己踩过的坑。它们比被测代码的 bug 更值得记录,因为一个不可能失败的断言比没有断言更危险。

稀疏掩码的一致率必须先算零假设基线

视觉模型曾断言等高线图案存在「明显的左右镜像对称」。逐像素验证为——镜像一致率 89.99%,而在 5.405% 墨迹覆盖率下,纯随机的期望一致率就是 89.77%(两侧同时为空即算一致)。真正的镜像会接近 100%。

视觉模型的文字描述不能作为颜色结论的依据

它曾断言某候选在亮色面板下「金色更亮」,而两个候选的亮色值完全相同;靠逐像素取色才纠正。

同样,第一版取色脚本误测到灰色的计时数字而非标签本身(计时器有自己的不透明颜色,在亮底上比浅黄更「显眼」),改为量「无计时器」状态才得到正确数据。

命中测试判断不了 pointer-events: none 的层叠

水印带 pointer-events:none !importantdocument.elementsFromPoint() 永远看不到它,该断言在结构上不可能失败——第一版测试因此对着确凿有 bug 的构建报「ok」。

改为对比「水印可见 / 不可见」两版真实截图:下拉菜单是不透明的,若水印正确地在其后方,菜单内每个像素都必须逐位相同。

两版渲染必须只差一个变量

用开关增删水印会改变 DOM,实测导致文字位移、出现 148/255 的假差异,且菜单位置整体偏移 74px。改为「同一 DOM,仅把 alpha 置 0」。

同理,菜单矩形不能取自另一次浏览器运行的 getBoundingClientRect()(实测偏差 74px),而是在截图里按菜单自己的不透明底色定位。测试还会先断言字形与菜单确有重叠,否则「无越界」只是一句空话——早期版本重叠仅 1984 px²,属于假通过。

计算样式触发不了 :hover

settings-buttons.test.js 读计算样式,所以它用同特异度的 .HOVERPROBE 类替代 :hover(类与伪类特异度同为 0,1,0,层叠结果不变)。这是合理的层叠等价,但反向对照暴露了它的边界:把主题规则里真正的 :hover 那一半删掉,该测试照样全绿——因为 .HOVERPROBE 那一半独自扛住了。

因此补了 test/hover-check.js:通过 DevTools 协议真的移动鼠标到按钮上,再截图量字形与填充的对比度。同样的反向对照下,它如实报出 1.05:1 / 2.61:1,与反馈截图逐位吻合。

做「有字 / 无字」差分时要保留一个零宽空格

inline-block 里没有任何在流内容时高度会塌成 0(line-height 只作用于由内容产生的行盒),空文字那版会连背景都不画,差出来的是背景而不是字形——第一次就是这样量出「假可见」的。

虚拟时间会快进过要拍的那一帧

--virtual-time-budget=4000 会快进过雷霆大字的 3 秒自动隐藏,于是四张截图全是空页面,而初版脚本只检查「PNG 文件存在」,照样报成功。现在既把预算压到 1400ms,又去解码像素验证。

--force-prefers-reduced-motion 后来必须撤掉:系统偏好与「动画关闭」走的是同一条静态分支,继续强制它就意味着「静态标记根本没打上」这种默认态回归也照样拍得完美,测试再也分辨不出好坏。

计数断言不能用同一个集合既当预期又当计数器

设置页行数断言原先以 ROW_KEYS 既当预期集合又当计数器,于是未登记的新行会被静默忽略——加入「大字入场动画」后屏幕上已是 10 行,断言却仍以 9 通过。现在额外按「分组容器的直接子节点」独立数一遍并交叉比对。

定时器泄漏要让两个到期时刻错开才观测得到

「提前关闭会取消 3 秒定时器」原写法是「关闭后立刻再播报一次」,但那样两块遮罩的到期时刻会重合,泄漏的定时器销毁第二块时它本来也该消失了。必须把两个到期时刻错开:t≈0 关闭、t≈1.5s 再播报、t≈3.2s 采样——此时旧定时器若还活着就会把新大字提前 1.3 秒抹掉。

而「监听器泄漏」在 DOM 上完全不可见(多余的副本只是重复调用同一个幂等销毁函数),只能靠给 document.addEventListener / removeEventListener 计数来核账。

沙箱对象展开会带上幂等标志

{ ...sandbox } 会把 __dshThemeEndfieldApplied 一起复制,导致两个「新沙箱」用例的 apply() 其实在第一行就返回了。现已显式清除该标志。

按固定字节长度截取源码的断言会悄悄失去覆盖

一条样式契约断言用固定字节长度截取样式表,在该节注释变长后就再也覆盖不到 prefers-reduced-motion 那一段,报了「规则缺失」而它其实好好地在下面两行。现已改为截到模板串结束。

注入式自检必须断言注入本身生效

selftest.js 会把真实 bug 注入 client.js副本并断言 check.js 确实失败——同时断言注入本身生效,避免「测试其实什么都没改」的空跑。

这条护栏当场发挥过作用:调色板重构把渐变色标从字面量换成了 var(--edge-status-*),于是针对 #6b5d00 / #fff500 的注入不再匹配、变成空跑,脚本立刻报「INJECTION DID NOT APPLY (test is vacuous)」而不是假装通过。另外两个坑也是这样暴露的:--edge-accent-deep 两套配色各定义一次,只删一处不算删掉;本仓库是 CRLF,注入模式里写字面 \n 永远匹配不上(改用 \r?\n)。

无头环境的 rAF 不能用来采样性能

headless 会挂起 / 合并 rAF,导致无论怎么设虚拟时钟都只采到 n=1,而没有分布支撑的数字不算测量。contour-perf.test.js 因此按函数名把算法源码从 client.js原样切出后在紧循环里计时,并丢弃前两次采样(冷启动含 JIT 预热,否则会把启动成本报成稳态成本)。