# 工程笔记 实现决策与它们背后的实测数据。本文回答「为什么是这样写的」,不重复功能说明(见 [features.md](features.md))或色值取法(见 [design-language.md](design-language.md))。 排在前面的几条是**结构性约束**——不了解就会重新踩进去。 --- ## 目录 - [变量必须声明在 body 而不是 :root](#变量必须声明在-body-而不是-root) - [样式表是一整个模板字符串](#样式表是一整个模板字符串) - [层叠与挂载点](#层叠与挂载点) - [等高线背景](#等高线背景) - [启动加载屏](#启动加载屏) - [设置页国际化](#设置页国际化) - [持久化说明(见下文正文)](#为什么设置必须落在-host-的设置命名空间不再用-localstorage) - [已修问题归档](#已修问题归档) - [验证方法论](#验证方法论) --- ## 为什么设置必须落在 Host 的设置命名空间(不再用 localStorage) 最初这些开关用浏览器 `localStorage` 持久化。**这对 DSH Desktop 是错的**:浏览器存储的作用域是「协议 + 主机 + 端口」的同源维度(origin),而 DSH Desktop 每次启动都在 `127.0.0.1` 上绑定一个**随机临时端口**。端口一变,origin 就变,于是上次保存的设置永远读不到,表现就是「**重启后设置恢复默认**」;localStorage 也因此从来没真正持久过跨会话。 ### 选哪条存储 原生浏览器侧的**三个候选**都治标不治本,因为它们的键空间同样挂在 origin 上: - `localStorage`——origin 维度,random port 一换就清空(本 bug 的根源)。 - `sessionStorage`——更糟,连同一会话的标签页都不共享。 - IndexedDB——仍按 origin 分库,与 Desktop 随机端口的组合同样无法根治。 正解是 DSH 自己的用户设置服务:**解析与落盘都由 Host 决定**(`ctx.settings.register` → `@deepseek-ai/dsh-settings-file` 写到 `/<…>/settings.yaml`),与页面 origin、端口完全无关。因此 **dsh web(浏览器、固定/默认端口)** 和 **DSH Desktop(随机临时端口)** 走同一条路径:它们都是 `127.0.0.1` loopback 页面,DSH 把连接解析成 `host` 持久化模式,值写进 profile 落盘位置,换端口也能读回。 ### Host ↔ Client 数据流 1. **声明**(Host `index.js`):`ctx.inject(['settings'], sctx => sctx.settings.register('dsh-theme-endfield', schema, { applies:'live' }))`。schema 每个字段都是**字符串**字段并带 `.default(...)`:default-ON 存 `'1'`(读作 `!== '0'`),default-OFF 存 `'0'`(读作 `=== '1'`);palette/radius/fps/speed 各存一个文档里写明的字面量。字符串化的好处:与旧的存盘点位完全一致,老的 `` 节无需迁移即可原样命中。 2. **写**(浏览器 `client.js`):设置面板每个 toggle 调用内部 `prefsSet(field, value)` → 只有当 scope 快照是 **durably served**(`mode:'host' && status:'ready' && writable`,即 Host 的 describe 视图已真正列出本命名空间)时才调用 `ctx.settingsScope` 的 `scope.set(field, value)`,Host 收到后原子写盘。只凭快照里的 `writable` 判写是一个坑:host 模式的 describe 视图即便本命名空间**尚未被注册**也会返回 `writable:true` 与 `status:'unavailable'`(`commit radius = round … status= unavailable` 就是这么打出来的)——旧代码照写不误、清了脏标记但什么都没落盘,刷新即丢。现在这种写被**拦下并标脏**(页面内仍生效),等快照在后续 ready 回相(Host `register` 提交文档、镜像重载)时由 subscription 自动补写;注册后仍迟迟不出现则说明 host 半部未挂载,属安装侧问题,前台不再假装写成功了。 3. **读 / 生效**:同一段 scope 的 `getSnapshot().value` 就是已由 schema 校验并合并默认值的整段;主题层的 `isEnabled()` 与其它 getter 每次调用都 `prefsGet(field)` 现读本段,天然随值变化。 4. **订阅同步**:`scope.subscribe(...)` 在每次落盘/镜像变化时唤醒,client 再跑一遍 `reconcileFromPrefs()`,把主题开关(enabled → mount/unmount token+样式表)、圆角/配色 class、水印、等高线、雷霆大字重新对齐。这样同 profile 里**另一个窗口/设备**编辑 ``(或本轮写入被 Host 回相确认)都不需要刷新即可热生效。 5. **启动恢复**:`apply()` 早于 transport 就绪时,读 schema 默认值(内存镜像),一旦 `status:'ready'` 的第一个真值镜像到达就切换到持久值——即使 Desktop 在随机端口上启动,也能立刻恢复到上次的设置。 防御性:若 ctx 里根本没有 `settingsScope` 服务(纯独立页/测试桩),则回落内存默认值 + 会话内本地覆盖,写不过盘但**不引入 localStorage**,也绝不 throw。 ## 变量必须声明在 body 而不是 :root **应用把主题令牌写成 `` 的行内样式**(`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`: 1. **反引号**。字符串里出现一个反引号(哪怕写在 CSS 注释里)会提前结束模板字符串,整个 client bundle 解析失败。注释里要引用代码请用单引号。 2. **`${...}`**。在模板字符串里那是插值,不是 CSS。 3. **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 顺序决定,水印作为最后追加到 `` 的兄弟节点赢下每一次平局,于是盖住了整个 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. **有界散射(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 ×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× 手柄上限)。平滑效果由平滑/尖点测试固定,本表保留为当时的实测记录。 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%(显式声明) | | 行距 / 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。字标仍**刻意略小于参考图**:参考图是整幅出血海报,而这里是一闪而过、盖在应用之上的遮罩。 ### 三处易错的排版细节 1. 字标行 `margin-left: -0.055em` 抵消 Arial 左侧字身空隙,让**字形墨边**(而非字盒)落在节奏线上; 2. `line-height` 取 **0.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`(设置 › 模型 的 `编辑`)为例,上游声明: ```css 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——也不合格,只是没那么刺眼,这正是它一直没被发现的原因。 **两个「看起来对、其实不对」的选择器坑:** 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: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 #e8e8e2` | `bg-layer-1 #f2f2ec` | 暗色 `#101110` | |---|---|---|---| | 0.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` 做流光动画。由此两个结论: 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-size`、`background-position` 与流光动画保持不变。 色标取法见 [design-language.md](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 预热,否则会把启动成本报成稳态成本)。