工程笔记
August 30, 2026 · View on GitHub
实现决策与它们背后的实测数据。本文回答「为什么是这样写的」,不重复功能说明(见 features.md)或色值取法(见 design-language.md)。
排在前面的几条是结构性约束——不了解就会重新踩进去。
目录
变量必须声明在 body 而不是 :root
应用把主题令牌写成 <body> 的行内样式(dsh-client-ui-layout 对每个令牌调用 body.style.setProperty)。
推论一:任何引用 --dsw-* 令牌的自定义属性都必须声明在 body 上。:root 是 body 的父元素,在那里替换一个不存在的变量属于 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:
- 反引号。字符串里出现一个反引号(哪怕写在 CSS 注释里)会提前结束模板字符串,整个 client bundle 解析失败。注释里要引用代码请用单引号。
${...}。在模板字符串里那是插值,不是 CSS。- CSS 注释提前闭合。这是最隐蔽的一类:注释提前收束后注释本身仍是配平的,真正的破坏是残留的说明文字落到顶层、与下一条选择器黏在一起,整条规则被丢弃。曾因此导致
--edge-word未定义、加载屏品牌块整块塌回左上角。node --check查不出这类问题——文件照样能解析。
check.js 的「顶层漏进散文」判据经过一次修正:第一版用「含逗号或英文单词」,在合法选择器上产生了 33 个误报(:is([role='tab'], …)、input, textarea、tbody 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:relative 且 z-index:auto,不产生层叠上下文,两者在同一绘制步骤里按 DOM 顺序排列。
实测(1440×900,逐像素):
| 下拉菜单内被水印改动的像素 | 菜单外仍绘制的像素 | |
|---|---|---|
| 修复前 | 9945 px(越界) | 37000 px |
| 修复后 | 0 px | 36084 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.9× 提速)。 最初让每个高斯凸起在每个网格点上求值;高斯在 2.6σ 之外数值上已归零,改成每个凸起只写自己包围盒内的格点后,1440×900 实测 8.30ms → 4.40ms。这是本功能能开在背景里的前提。
-
补一层长波起伏。 高斯之和在岛屿之间恰好衰减为零,那里的场完全平坦、没有任何高度线穿过——首版渲染因此出现大片空白,暴露了构造痕迹。加入三道极缓的长波正弦后,间隙里仍有梯度可穿越,孤立的「靶心」才连成一整片地形。代价 1.70ms。
-
用二次曲线画线,而不是直线段。 marching squares 每个网格边至多产出一个顶点,10px 网格下折线本身就是有棱角的:实测线段平均 7.8px,顶点转角 p99 达 41.7°。
lineJoin救不了——1px 描边根本没有接合处可倒角。改成让每个原顶点当控制点、曲线穿过线段中点(C1 连续,数学上无折角),且不增加任何顶点:方案 转角 p99 顶点数 增加耗时 Chaikin ×1 41.7° → 22.4° 11.1k +0.81ms Chaikin ×2 41.7° → 12.0° 22.2k +1.45ms 二次曲线(当时采用) 不再有折线转角 5.5k(不变) +0.15ms 后续演进:中点二次曲线在长边上仍留下可见的「圆角多边形」弯折。现行实现改为 Chaikin ×3 预处理 + 约束 Catmull-Rom 三次曲线——曲线穿过原顶点、相邻段共享切向(切向系数 0.32),手柄长度上限(0.62× 较短邻段)防止窄鞍部过冲;开放路径的两个端点保持固定(0.4 系数、0.55× 手柄上限)。平滑效果由平滑/尖点测试固定,本表保留为当时的实测记录。
-
缝合成连续折线。 线段按边 ID 缝合后,数千条散段变成约 80 条连续折线,整片地形只需一次
stroke()而非数千次moveTo。
平滑只管中段:三处真正的锐角(issue #3)
上面第 3 条的中点样条只在折线内部无折角。反馈「等高线变化时出现大量不规则锐角锯齿」时,中段确实是干净的(转角 p99 30.5° → 5.6°),锐角全部来自样条管不到的三处边界情形。实测每帧约 127 个尖刺,而整套测试全绿——因为它们在每一帧都存在,只是随场漂移不断换位置,所以看起来像「一变化就冒锯齿」。
-
末段二次曲线是退化的(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]方向抵达那个中点,直接连过去不产生折角。 -
闭合环被当成开放曲线画(32/95 条)。
walk()靠重复起点来闭环,把这样的折线喂给开放中点样条,起点处的出射切线与终点处的入射切线毫无关系,于是每个环的接缝上都留一个角:实测中位数 10.0°、最大 26.5°。改成循环形式——在mid(u[m-1], u[0])起笔,每个顶点都是控制点,接缝因此落在某一段的中间而不是两端,整环 C1 连续,且不增加顶点。 -
相切针刺。 某条等高线与场近乎相切处,真实等值线是一个曲率极高但光滑的尖端;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 的两类产物:
- 画布外的碎屑。 网格是
ceil(w/step)+1列 ×ceil(h/step)+1行,最后一行 / 列落在画布边界上或之外(1432×753 实测超出 +8px / +7px)。那里提取出的等值线被裁成短碎片,甚至完全不可见:85 条折线里 33 条含画布外顶点,3 条可见长度为 0——纯开销、零像素。 - 山顶靶心环。 距高斯峰顶一两格处,最内层等值线闭合成一个极小的圆。实测 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%(显式声明) |
| 行距 / cap | 1.10 | 1.10 | 1.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。字标仍刻意略小于参考图:参考图是整幅出血海报,而这里是一闪而过、盖在应用之上的遮罩。
三处易错的排版细节
- 字标行
margin-left: -0.055em抵消 Arial 左侧字身空隙,让字形墨边(而非字盒)落在节奏线上; line-height取 0.80 而非 1.10——参考图的 1.10 是墨边间距,而line-height覆盖整个 em 盒(Arial-900 约为 cap 的 1.38 倍),直接写 1.10 会渲染成松散的 1.52;- 品牌块不能加
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:1 | 16.50:1 |
.gNWCoW_inspectButton | Cordis 检查面板 | 1.05:1 | 16.50:1 |
.iWrAna_inspectButton | 技能检查面板 | 1.05:1 | 16.50:1 |
.o3BgMG_inspectButton | 工具检查面板 | 1.05:1 | 16.50:1 |
.JVDQca_arrow | 附件轮播箭头 | 1.05:1 | 16.50:1 |
.uV2eYG_add | 输入区 + | 早前已修 | — |
武陵青下同一处是 2.61:1——也不合格,只是没那么刺眼,这正是它一直没被发现的原因。
两个「看起来对、其实不对」的选择器坑:
[class$='_inspectButton']匹配不到。 属性后缀选择器要求整个 class 属性以该串结尾,而元素常常还带第二个类(实测class="gNWCoW_inspectButton HOVERPROBE"直接漏掉)。上游会自由拼接类名,所以只能逐个点名。[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:1 | 1296 px,16.50:1 |
| 选项编号 · 暗色 · 选中行 | 207 px,1.25:1 | 225 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:1(bg-layer-1上更低,2.11:1)。只读 CSS 看不出来,是渲染出来量像素才抓到的:alpha bg-base #e8e8e2bg-layer-1 #f2f2ec暗色 #1011100.28(初版) 2.29:1 2.11:1 18.92:1 0.40 3.13:1 2.90:1 18.92:1 0.50 4.17:1 3.89:1 18.92:1 0.55(现行) 4.85:1 4.55:1 18.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 做流光动画。由此两个结论:
- 写
color:完全无效——透明文字填充优先,字仍由渐变决定;改色必须改渐变本身。 - 不能去动
--dsw-static-deepseek-500/200这两个共享令牌。 它们同时支撑--dsw-alias-button-info-fill、--dsw-alias-state-business-primary与--dsw-specific-bubble-highlight,本主题刻意把它们映射成墨 / 纸色。因此只覆盖background-image,上游的background-size、background-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 !important,document.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 预热,否则会把启动成本报成稳态成本)。