# 工程笔记

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

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

---

## 目录

- [变量必须声明在 body 而不是 :root](#变量必须声明在-body-而不是-root)
- [字体令牌是应用的公共接口，不是主题的开关](#字体令牌是应用的公共接口不是主题的开关)
- [源码是真源，产物是拼出来的](#源码是真源产物是拼出来的)
- [样式表是一整个模板字符串](#样式表是一整个模板字符串)
- [层叠与挂载点](#层叠与挂载点)
- [等高线背景](#等高线背景)
- [输出滚动动画：为什么不能改 scrollTop 的写后回读语义](#输出滚动动画为什么不能改-scrolltop-的写后回读语义)
- [布局重排：展开思考块为什么抽搐](#布局重排展开思考块为什么抽搐)
- [启动加载屏](#启动加载屏)
- [设置页国际化](#设置页国际化)
- [峰谷定价窗口与法定节假日](#峰谷定价窗口与法定节假日)
- [为什么设置必须落在 Host 的设置命名空间（不再用 localStorage）](#为什么设置必须落在-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 决定**，与页面 origin、端口完全无关。因此 **dsh web（浏览器、固定/默认端口）** 和 **DSH Desktop（随机临时端口）** 走同一条路径：它们都是 `127.0.0.1` loopback 页面，DSH 把连接解析成 `host` 持久化模式，值写进 profile 落盘位置，换端口也能读回。

### DSH 0.1.7-rc.1 换掉了整套 settings API（v1.1.0 已跟进）

0.1.7-rc.1 移除了 `ctx.settings.register` / `settings.get` / `settings.watch`、`@deepseek-ai/dsh-settings-file` 这个包，以及浏览器侧 `ctx.settingsScope` 服务：

| | ≤ 0.1.5-rc.2（旧） | 0.1.7-rc.1（新） |
| --- | --- | --- |
| Host 声明 | `ctx.settings.register('dsh-theme-endfield', schema)` | 模块导出 schemastery `Config`，每个字段 `.volatile()` |
| 命名空间 | 插件自定义字符串 `dsh-theme-endfield` | **profile entry id** = 本包 `cordis.patch.yml` 中该行的 `id: theme-endfield`（`index.js` 的 `SETTINGS_ENTRY`） |
| 落盘 | `@deepseek-ai/dsh-settings-file` → `<dshHome>/settings.yaml` | DSH 设置服务 → profile patch `<profile>/cordis.patch.yml`（`ctx.configEditor` 加文件锁 + 原子写 + 热重载） |
| Client 读写 | `ctx.settingsScope` 的 `bind({ namespace, decode })` | `ctx.configForms.get(<entryId>)` |
| `set()` 返回 | 无（fire-and-forget） | `Promise<boolean>`；`false` = Host 拒绝或**跳过**（非 loopback 页面是 memory 模式，永不下盘） |

关键差异，逐条都有对应的防护：

- **只有 `.volatile()` 字段可编辑。** `ctx.settings` 只把 volatile 路径投影成表单，写非 volatile 路径会直接报 `Config field "x" is not volatile`；整份 schema 一个 volatile 字段都没有时该条目**根本不出现**在设置里。所以 `index.js` 的 `Config` **34 个字段全部 `.volatile()`**，`test/settings-config-forms.test.js` 会逐字段断言这一点（漏一个就是「设置保存不了」的现代写法）。
- **两个 schemastery 必须区分。** DSH 同时装了带 `.volatile()` 的 `@deepseek-ai/schemastery`（3.18.4）和不带它的旧 `schemastery`（3.18.0）。`index.js` 的 `loadSchemastery(true)` 会逐个候选检查 `.volatile` 是否真的存在，找不到就返回 `undefined`（本插件退化成无需配置，而不是挂一个假表单）。
- **命名空间是 entry id，不是包名。** `theme-endfield` 这个串同时出现在 `cordis.patch.yml` 的 `id:`、`index.js` 的 `SETTINGS_ENTRY` 和 client 的 `PREFS_ENTRY`；三者由新测试交叉校验。client 另外会依次尝试 `include:` 前缀等几种安装别名，优先选真正被 Host served（`status:'ready'`）的那个拼写；一个都没 served 时先绑定首选拼写（表单只是共享镜像的懒视图，早绑定才能等到迟到的 section）。此后每次镜像重载都会让每个表单重新派生，`unavailable`→`ready` 的转变会把「另一个拼写被 served」通知过来，此时自动切过去；万一镜像只更新却不通知（表单快照存储丢弃等价快照），还有**有界 settle watch**（20 × 500ms）自己轮询兜底。两种情况下切换期间 held 的编辑都会补写到新拼写上。
- **`settings.yaml` 已废弃。** DSH 启动时把已有的 `settings.yaml` 改名为 `settings.yaml.imported`，并只迁移 `LEGACY_SECTION_ENTRIES` 里那几段。旧的主题段落名 `dsh-theme-endfield` 不等于 entry id `theme-endfield`，因此**不在迁移之列**，需要用户在设置页重设一次；这是 DSH 侧的行为，不是本插件丢的。旧值仍留在 `settings.yaml.imported` 里可手工对照。
- **旧宿主仍然可用。** client 在找不到 `configForms` 时回落到 `ctx.settingsScope`（`binder.bind({ namespace: 'dsh-theme-endfield', decode })`），Host 在 `settings.register` 存在时才注册旧命名空间。两代 transport 的快照字段基本同名（`status` / `value` / `writable`，0.1.7 另有可选 `mode`），所以除了「取句柄」和「写返回」之外整条存储逻辑是同一份。
- **三代设置快照的 `mode` 差异（0.2.1 兼容修复）。** ≤0.1.5-rc.2 的 `settingsScope` 与 0.1.7-rc.\* 的 `ConfigForm` 都带 `mode: 'host' | 'memory'`；`0.2.1-rc.2` 的 `ConfigFormController` 把它连同 `persistence` 一起删除（`status` 也从 `'unavailable'` 改由 `derive()` 在缺 ns 时设置，语义不变）。client 的闸门因此只看 `status:'ready' && writable`，并对 `mode` 做「只有显式 `'memory'` 才拒写」的判定；`boot report` 的 healthy 判定复用同一个谓词，避免在健康页面上误报。

### Host ↔ Client 数据流

1. **声明**（Host `index.js`）：0.1.7-rc.1 导出 `Config`（`z.object({ 每个字段: z.string().default(...).volatile() })`）；同时用 `ctx.inject(['settings'], sctx => sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber)))` 关掉自动生成的设置页（本插件自带四组页面）。旧宿主则在 `settings.register` 存在时执行 `register('dsh-theme-endfield', schema, { applies:'live' })`。schema 每个字段都是**字符串**字段并带 `.default(...)`：default-ON 存 `'1'`（读作 `!== '0'`），default-OFF 存 `'0'`（读作 `=== '1'`）；palette/radius/fps/speed 各存一个文档里写明的字面量。字符串化让三代存盘点位的值模型完全一致，client 的键表、面板与测试都不必随 transport 改动。
2. **写**（浏览器 `client.js`）：设置面板每个 toggle 调用内部 `prefsSet(field, value)` → 只有当快照是 **durably served**（`status:'ready' && writable`，且 `mode` 不是显式的 `'memory'`）时才调用 transport 的 `set(field, value)`，Host 收到后原子写盘。`mode` 只是 **0.1.7-rc.\* 才有的可选字段**：`@deepseek-ai/dsh-client-ui-settings` 0.2.1-rc.2 连同 `persistence` 概念一起删掉了它，快照只剩 `{ status, value, base, user, revision, writable }`，写一律经 `remote.settings.mutate()` 落到 Host 文档，所以**缺省 `mode` 必须按可持久化处理**，只有显式的 `mode:'memory'`（0.1.7 非 loopback 页面）才截断写入。这正是 v1.2.2 在新版桌面「改了不保存」的根因：闸门要求 `mode === 'host'`，而新版恒为 `undefined`，于是每次 toggle 都只 held + 有界重试，约 10s 后丢弃，刷新即丢（启动报告即 `boot report … status= ready mode= undefined writable= true`）。只凭 `writable` 判写是另一个坑：host 模式的快照即便本命名空间**尚未被 served** 也会返回 `writable:true` 与 `status:'unavailable'`（`commit radius = round … status= unavailable` 就是这么打出来的）——旧代码照写不误、清了脏标记但什么都没落盘，刷新即丢。现在这种写被**拦下并标脏**（页面内仍生效），等快照在后续 ready 回相（Host 文档提交、镜像重载）时由 subscription 自动补写；`configForms.set()` 明确返回 `false`（Host 拒绝/跳过）时同样重新标脏等待下次回相，而不是假装写成功。
3. **读 / 生效**：transport 的 `getSnapshot().value` 就是已由 schema 校验并合并默认值的整段（client 再经 `prefsResolveSection()` 归一化到 34 个声明字段）；主题层的 `isEnabled()` 与其它 getter 每次调用都 `prefsGet(field)` 现读本段，天然随值变化。
4. **订阅同步**：`form.subscribe(...)` / `scope.subscribe(...)` 在每次落盘/镜像变化时唤醒，client 再跑一遍 `reconcileFromPrefs()`，把主题开关（enabled → mount/unmount token+样式表）、圆角/配色 class、水印、等高线、雷霆大字重新对齐。这样同 profile 里**另一个窗口/设备**编辑落盘文件（或本轮写入被 Host 回相确认）都不需要刷新即可热生效。订阅返回的 disposer 现在会被保存并在 run 拆除时调用：`ConfigForm` 是 provider 拥有、跨插件共享的实例，漏掉它会把这一个监听器泄漏给同页面的下一次 run。
5. **启动恢复**：`apply()` 早于 transport 就绪时，读 schema 默认值（内存镜像），一旦 `status:'ready'` 的第一个真值镜像到达就切换到持久值——即使 Desktop 在随机端口上启动，也能立刻恢复到上次的设置。

防御性：若 ctx 里既没有 `configForms` 也没有 `settingsScope`（纯独立页/测试桩），则回落内存默认值 + 会话内本地覆盖，写不过盘但**不引入 localStorage**，也绝不 throw。

### 加载期解析 schemastery：dev-link 安装必须显式去找（v1.1.1 起加固，v1.1.2 加自检报告，v1.1.3 改为结构化扫描）

- **为什么只能在顶层解析。** loader 导入模块后立刻 `runtime.Config = plugin.Config`（`@deepseek-ai/cordis-plugin-loader` 的 `Entry._init` → `registry.plugin`），这发生在任何插件体运行之前，`Config` 因此必须是模块求值期的静态导出——不能延迟到 `apply()` 里再做（那里能拿到 `ctx`，但已经太晚了）。
- **dev-link 安装为什么解析不到。** 本包在 profile 里是软链/junction（`<profile>/node_modules/dsh-theme-endfield` → 本仓库）。Node 的 ESM/CJS 解析都**先取真实路径**，于是本文件的 `require` 链从 `E:\…` 开始，profile 的 `node_modules` 根本不在链上：一句 `require('@deepseek-ai/schemastery')` 必然 `MODULE_NOT_FOUND`。发布版（真实目录安装）才走普通 require 链。
- **怎么找。** `resolutionRoots()` 显式列出所有可能持有 schemastery 的目录，再用 `require.resolve(spec, { paths: [dir] })`（语义是「当作从该目录发起」）逐个尝试，按 **spec 分组、scoped 优先**（只要存在 `@deepseek-ai/schemastery`，那份一定胜出，不会被兄弟插件留在更早路径上的旧 `schemastery` 抢走）。候选根依次是：本模块自身目录 → `require.main` 的目录与 `require.main.path` → `process.cwd()` → 本模块的各级祖先目录 → `DSH_HOME` 与 `~/.dsh` 的布局 → **结构化扫描**（对 `__dirname` / cwd / `require.main` / `process.argv[1]` / `process.execPath` 的每一级祖先调用 `pushLayout`，即把 `<祖先>`、`<祖先>/node_modules`、`<祖先>/profiles/*` 及其 `node_modules` 全部纳入）→ `%APPDATA%\npm\node_modules` → `PATH` 的每个目录（上限 40 条）。最后两道兜底：`require.main.require(spec)`，以及**扫描本进程 `require.cache` 里已加载过的 schemastery**（DSH 自己的设置服务就 import 它，所以只要它在本进程里，就能按对象身份复用）。
  **结构化扫描是这里的关键**：它不依赖 `DSH_HOME`、`homedir()` 或 cwd 取到任何特定值——只要进程还能说出自己从哪儿启动，就能顺着祖先目录找到 `<profile>/node_modules`。v1.1.1/1.1.2 只按环境变量拼路径，一旦启动器把 `DSH_HOME` 设成 profile 自身（或设成别的值）就会整条落空，这正是「Host 侧依旧 `absent`」的成因。
- **失败长什么样。** 找不到时 `Config` 是 `undefined`：`Config.listConfigs`（cordis_inspect 的 host provider）对该 entry 报 **`absent`**，DSH 不为它投影任何表单，client 于是永远停在 session-local —— **面板照常打开、开关照常能拨、刷新即复位**。这条症状与主题其它功能是否正常无关，所以极易被误判成「主题坏了」或「存储又变了」。注意 `absent` 只表示「没有 Config」；有 Config 但字段不 volatile 会以 `unsupported`/整条不出现的形式表现，含义不同。
- **不再静默。** Host 侧 `Config === undefined` 时 `apply()` 打 `ctx.logger.warn`（成功则 `debug` 一行写明从哪个根解析到）；client 侧把 Host 真正 served 的命名空间列表并进既有的 `dbg` 诊断（`hostServes=`），`settle watch` 放弃时也明确打一行（`boundNs=` / `status=` / `hostServes=`）。有这一行就能立刻区分「Host 半没导出 Config」与「client 绑错了 entry 拼写」——两者现象完全一样。
- **自检报告（v1.1.2 起）。** 构建不出 `Config` 时，除日志外还会把一份 JSON 写到 **profile 目录**（`ctx.baseUrl` 可解析时）或 `$DSH_HOME`：`theme-endfield-diagnostic.json`。内容包含 `dshHomeDirs` / `homedir` / `cwd` / `mainModule` / `argv` / `baseUrl` / `loaderStartedAt`（本进程启动时刻，用来判断「到底有没有真的重启过」）/ `cachedSchemasteryModules`，以及**每个候选根 × 每个包名**的 `require.resolve` 结果（成功路径或错误码）。`Config` 一旦构建成功该文件会被自动删除（`clearStaleDiagnostic`）：所以它**存在**＝host 侧仍拿不到 schemastery，**不存在**＝host 侧已正常、问题在别处。它只是诊断，不参与任何运行逻辑。
- **改 Host 半必须重启 DSH 进程。** 浏览器刷新只重新拉 `client.js`（client 模块由 host 按请求从磁盘读取，所以改了就生效）；而 **Host 半的模块只在 profile 启动时 import 一次**（`EntryTree.import` 走 Node 内部 ESM loader，命中 ESM 缓存，不做 cache-busting），`dsh-hmr` 的 watch glob 默认忽略 `**/node_modules`，软链到仓库的插件文件不在它的观察范围内。因此「改完 `index.js` → 刷新页面 → 设置仍然不保存」是预期现象：进程里跑的还是启动时那份旧模块（旧版没有 `Config` 导出 → `absent`）。排查持久化问题前，先整进程重启一次再看——重启后若 `theme-endfield-diagnostic.json` 仍出现，才说明是解析问题；若它消失且 `Config.listConfigs` 变成 `schema`，说明已修好。
- **`Config` 必须是字面量属性（v1.1.4）。** 这条独立于「找不找得到 schemastery」：`index.js` 是 **CommonJS**，而 loader 用 Node 内部 **ESM** loader 导入 entry，CJS→ESM 的命名空间由 `cjs-module-lexer` 的**静态分析**决定。写成 `const exported = {...}; module.exports = exported; exported.Config = Config;`（赋值在字面量之外）时，lexer 可能只认得出字面量里的 `apply`/`name`，于是 `unwrapExports()` 拿到的对象**有 `apply`（entry 照常 active）却没有 `Config`** —— 表现与「解析不到 schemastery」完全一样（`absent`、无表单、刷新复位），但日志里既不会出现解析失败的 warn，也不会留下自检报告，因为那份 reporting 代码根本没被跑到。
  **判据**：在普通 node 里 `require()` 这个包得到 `Config = true`，而宿主进程里 `Config.listConfigs` 仍报 `absent`，且重启后 `theme-endfield-diagnostic.json` **不出现** —— 三点同时成立就说明是这里，而不是路径解析。修法是把 `Config` 写成导出字面量的**静态属性**（值为 `undefined` 时表示「本机确实没有 schemastery，无需配置」，是合法状态而非法 schema）。

### 找不到 `.volatile()` 也必须能存（v1.1.5）

v1.1.4 之前的判据是「**必须**找到带 `.volatile()` 的 schemastery，否则不导出 `Config`」。这条判据把一个**可降级**的情况升级成了**完全不可用**：没有 `Config` 不是「主题少了个功能」，而是 DSH 不为该 entry 投影任何表单，于是**用户拨的每一个开关都只活在页面里，刷新即丢**——正是本文件反复记录的那个症状。

实测成因（本机 web profile）：`@deepseek-ai/schemastery@3.18.4`（全机唯一带 `.volatile()` 的副本）**能 `require.resolve` 到，却 require 不进来**——自检报告 `resolution` 里那一行报 `resolved`，而 `cachedSchemasteryModules` 里根本没有它；同一轮扫描反而成功加载了 `xiaofeishu` profile 的 `3.18.1` 与无 scope 的 `3.18.0`，这两个都**没有** `.volatile()`。于是候选列表非空、却没有一个能过旧判据，`Config` 落到 `undefined`。

关键事实是：**`.volatile()` 就是 `this.extra('volatile', true)`**（3.18.4 源码里只有这一行），而 `.extra()` 在 3.18.0 / 3.18.1 里都有。DSH 侧读的是**投影结果**（`meta.volatile`），不是那个方法本身，所以标记可以在没有该方法时**合成**：

| 能力 | 3.18.4（DSH 自带） | 无 scope 3.18.0 | 3.18.1 |
| --- | --- | --- | --- |
| `.extra(key, value)` | ✓ | ✓ | ✓ |
| `.volatile()` | ✓ | ✗ | ✗ |
| 能否加载（本机 web profile） | **✗** | ✓ | ✓ |

现在 `selectBuilder()` 分两轮挑：先要 `native`（真的 `.volatile()`，保真度最高），没有才退到只有 `.extra()` 的副本并**合成标记**（`mode: 'synthesized'`）；两者都没有才不导出 `Config`。两种模式下每个字段最终都带 `meta.volatile === true`，DSH 都投影出可编辑表单。

- **判据写进自检报告**：新增 `schemaMode` 记录走了哪条路；`resolution` 每一行现在除 `resolved` / `error` 外还带 `loaded` / `loadError` / `volatile` / `marker`。「解析得到但加载失败」以前在报告里读起来像自相矛盾，现在是一行结论。
- **不再有「默认跳过」的断言。** `settings-config-forms.test.js` 的 Host 侧断言在拿不到 schemastery 时**整段跳过**，而拿不到 schemastery 恰恰是它要守的那个场景——于是在唯一要紧的环境里它什么都没验。新的 `settings-config-fallback.test.js` 不依赖本机 schemastery：它把**显式 builder**（含「只有 `.extra()`」这一形状）交给 `buildSchemaWith()`，逐字段断言默认值与 `meta.volatile`，并断言选择顺序（native 优先、`.extra()` 兜底、两者皆无则不导出）。

### 求值期 `require(esm)` 的启动竞态与负缓存

v1.1.5 解决的是「副本没有 `.volatile()`」，这一段解决的是**同一句 `require` 本身会失败**——两者症状一模一样（`Config === undefined`、无表单、刷新复位），但成因完全不同，且后者在**全新启动、只装本插件**的 profile 上同样复现。

**抓现行。** 在真实 `dsh web` 启动里放一个探针，于模块求值期同步 `require('@deepseek-ai/schemastery')`，拿到的不是 `MODULE_NOT_FOUND`，而是 Node 的互操作竞态：

```
ERR_REQUIRE_ESM_RACE_CONDITION:
Cannot require() ES Module <profile>/node_modules/@deepseek-ai/cosmokit/lib/index.js
because it is not yet fully loaded.
```

**机制。** `@deepseek-ai/schemastery` 的 CJS 构建（`lib/index.cjs`）顶层 `require` 了 `@deepseek-ai/cosmokit`，而 cosmokit 是**纯 ESM**（`"type": "module"`，`exports` 只有 `default` 条件、没有 `require` 条件）。DSH 启动时**并发**挂载各 entry，而它自己的 bundle（`@deepseek-ai/cordis`、`dsh-settings`…）也在通过 ESM 加载器 import cosmokit。只要本插件模块求值的那一瞬 cosmokit 的 ESM job 还在飞，Node 就拒绝这次 CJS→ESM 的 `require`，`schemasteryCandidates()` 于是**一个候选都拿不到**，`Config` 落到 `undefined`。

- 自检报告里那个看似自相矛盾的现象由此解释：`resolution` 段是在 `apply()` 里跑的，那时 cosmokit 早已加载完，**同一句 require 就成功了**——所以同一份报告里 `configBuilt: false` 与 `loaded: true, volatile: true` 能并存。`cachedSchemasteryModules: []` 才是真正的证据：求值期那次 require 从来没成功过。
- **与第三方插件数量无关**：`dsh-base` / `dsh-web-app` 自己就 import cosmokit，所以把 profile 精简到只剩本插件也照样中招。缩减插件不是解法。
- 与「dev-link 解析不到」也无关：那条路径下 `require.resolve` 会失败并留下 `error: MODULE_NOT_FOUND`；这里是**解析得到、require 抛错**。

**为什么没有留在 CJS 里彻底修掉。** `require()` 没有等待能力——必须在模块求值期同步拿到 builder（见上文 `Entry._init` 那条），而 cosmokit 的 job 还在飞就无法等待；ESM 加载器**会排队**（同一句 `import` 会等 cosmokit 结束），所以 v1.1.x 曾给包加一个手写的 ESM 入口 `index.mjs`：`warmSchemastery()` 先经 ESM 加载器预热 schemastery（`@deepseek-ai/schemastery`，失败退到无 scope 的 `schemastery`，再失败就静默放行），再 `await import('./index.js')` 并原样转出，`index.js` 一行逻辑都不用改。那个入口后来被移除了：`d5efc4c` 起包回到单一 CJS 入口，`main` 与 `exports["."]` 都直接指向 `index.js`，`index.mjs` 也从 `files` 白名单里拿掉（那次提交只解除引用，文件本体仍留在工作树里，之后才被单独删除）。

因此**求值期这道竞态并没有被消除，只是被接受**：极少数启动里 `Config` 仍是 `undefined`（无表单、刷新复位），靠下面的负缓存修复把损失限制在单次求值内，靠整进程重启恢复。只有当将来又必须在求值期拿到 builder、而 DSH 的挂载顺序对这道竞态零容忍时，才需要重新评估加回 ESM 入口。

**同一分支顺带修掉的第二个缺陷：负缓存。** `SCHEMA_SCAN_CACHE` 是进程级、以 registry Symbol 为键、**永不失效**，而且连失败结果也缓存。于是一次竞态失败被永久记成「本进程没有 schemastery」：插件重新挂载（模块重新求值）也救不回来——这正是「重启后的热挂载同样 `absent`」的原因。现在只有**可用**的扫描结果才入缓存（`cacheableScan`），`take()` 也拒收建不出 schema 的半求值模块（`exports` 还是 `{}` 时是 truthy，否则会被当成候选记下来）。实测：竞态后同进程重新求值仍失败 → 改为「可用才缓存」后，重新求值即可自愈。

**验证（真实启动内的对照）。** 同一次 `dsh web` 启动里同时观察三种取用方式，这是记录当时现象的证据，不是当前入口形态的描述（当前包只有 `index.js` 一个入口）：

| 探针动作 | 结果 |
| --- | --- |
| 求值期同步 `require('@deepseek-ai/schemastery')` | ❌ `ERR_REQUIRE_ESM_RACE_CONDITION` |
| `import('@deepseek-ai/schemastery')`（ESM） | ✅ 成功，`.volatile()` 可用 |
| 预热**之后**再 `require` 本插件 `index.js` | ✅ `Config` 建出（`nativeMarker: true`） |

那段对照只能在真实宿主启动里观察到，所以留在这里作为判据；当时用它固化入口行为的 `test/host-esm-entry.test.js` 已随 `index.mjs` 一起删除。

### DSH 0.2.0-rc.2 上失效的三处应用侧钩子（v1.1.6 已跟进）

0.2 **没有再动 settings API**：`Config` + `ctx.configForms` 那套接缝与 0.1.7 完全一致（唯一新增的语义是 `set()` 用**布尔值**回答而不是 reject，client 的写账本两种都认），所以本插件的 Host/Client 两半都不需要改。0.2 换掉的是三个**应用侧**细节，而它们的共同点是**旧写法不报错、只静默失效**——所以三处都在真实 0.2.0-rc.2 页面上实测过（scratch profile + 真运行时 + CDP 探针）：

| 钩子 | 0.1.x | 0.2 | 旧写法的后果（实测） |
| --- | --- | --- | --- |
| 回合状态标签 | `@deepseek-ai/dsh-client-ui-conversation` 的 `<hash>_turnStatus`，**渐变文字**（`background-image` + `background-clip:text`） | `@deepseek-ai/dsh-client-ui-chat` 的 `<hash>_running`，**遮罩扫光文字**，着色只认 `--dsw-alias-label-deep-diving` / `-shimmer` | 规则永不命中；该令牌仍是应用自带值 `color-mix(in srgb, #101110 70%, #172554)`，标签完全没有主题色 |
| 右侧栏列 | `_detailsCol`，不透明底色在其内部 `*_root` 上 | `_rightbarCol`，不透明底色**在列元素本身** | 选择器与目标元素**同时**变了：右侧栏一打开就整块盖住等高线图层 |
| `_heroGlow` | 0.1.2-rc.1 起已无该模块 | 0.2 仍无 | 规则保持 self-healing 空钩子，不改行为 |

因此回合状态标签的换色**从 CSS 移到 `theme.overrideTokens` 层**：给 `--dsw-alias-label-deep-diving` / `-shimmer` 各写一对 light/dark 值，复用既有的 `--edge-status-*` 色标（对比度结论完全不变，见[§ 四类：回合状态标签](#四类回合状态标签)与 [design-language.md](design-language.md#为什么亮色模式的强调色要下沉)），并用 `var()` 引用让配色切换依旧零 JS 重绘。护栏同步跟上：`check.js` 的第 4 条改成检查这两个令牌的四个值、并**禁止** `[class*='turnStatus']` 复活（那会是一条永远匹配不到的「假修复」），`test/selector-guard.test.js` 同时钉住 `_rightbarCol` 与两个令牌名。

插件卡片的展示文案在 0.2 由 `locale/<语言>.json` 的 `meta.title` / `meta.description` 提供（`dsh.client` 声明本身不变），本版补上 `locale/en.json` 与 `locale/zh.json`。`dsh.client.inject` 里那两个 0.2 已不存在的包名（`@deepseek-ai/dsh-client-runtime`、`@deepseek-ai/dsh-client-ui-slots`）换成了 0.2 真正的提供方（`dsh-client-ui-theme` / `-ui-renderer` / `-ui-settings` / `-locale` / `-api-session-controller`）。这里要分清两件事：`dsh.client.inject` 是**浏览器包预载列表**（只影响加载顺序，指向不存在的包会被静默跳过，所以旧列表在 0.2 上「没坏」但也不再表达任何意图），而 `theme` / `configForms` 这类**服务**依赖由 client bundle 自己的 `exports.inject` 声明——`theme` 从来不是包名，写在这里本来就是无效项。

### 存储字段名必须来自 schema，不能用「去掉前缀」推出来（issue #15）

**曾经的写法（错误）**：客户端要往命名空间里写一个字段时，把 UI 键的前缀切掉当作字段名：

```js
const field = PREFS_KEY_TO_FIELD[rawKey] || rawKey.slice(PREFS_NS.length + 1)
// 'dsh-theme-endfield-thunder-anim' -> 'thunder-anim'
```

而 Host 端的 schema（`index.js` 的 `FIELD_DEFAULTS`）声明的是 **camelCase**：`thunderAnim`。**只有单个词的字段两者才碰巧相同**，一切复合字段都不同：

| UI 键（客户端） | 客户端推出的字段 | schema 声明的字段 |
| --- | --- | --- |
| `dsh-theme-endfield-thunder-anim` | `thunder-anim` ✗ | `thunderAnim` |
| `dsh-theme-endfield-contour-anim` | `contour-anim` ✗ | `contourAnim` |
| `dsh-theme-endfield-contour-fps` | `contour-fps` ✗ | `contourFps` |
| `dsh-theme-endfield-contour-speed` | `contour-speed` ✗ | `contourSpeed` |
| `dsh-theme-endfield-contour-scroll-pause` | `contour-scroll-pause` ✗ | `contourScrollPause` |
| `dsh-theme-endfield-watermark-persist` | `watermark-persist` ✗ | `watermarkPersist` |

**为什么表现是「开关刷新后复位」，而且是静默的。** schemastery 只校验它声明过的字段，**多出来的键原样带过去**。所以这一写并不是「没写进去」：`settings.yaml` 里真的多了一行 `thunder-anim: "1"`，而 `thunderAnim` 仍是默认值 `'0'`。于是：

- 页面内开关正常工作（`prefsLocal` 里有本会话的值）；
- 刷新后 schema 合并出 `thunderAnim: '0'`，客户端只复制**声明字段**，那行 `thunder-anim` 谁都不读；
- 又一次回到默认值。用户观察到的正是「打开后刷新又变回关闭」。

配套的坑是**测试桩复述了同一个错误**：`test/fixtures/settings-scope.js` 当时也用 `slice` 去前缀，于是「客户端读 `contour-fps`」与「测试桩写 `contour-fps`」两边对上，全绿——而生产环境里那个名字根本没人认。**测试桩必须按生产 schema 的形状（camelCase 字段名）造数据，否则它证明的是自己跟自己的约定。**

现在的写法：`PREFS_KEY_TO_FIELD` 把每个键**显式**映射到字段名，读写两侧都走 `prefsFieldOf()`，所以「读的字段」与「写的字段」不可能再是两条路径。`test/settings-namespace.test.js` 同时对着三份东西交叉验证：`client.js` 的表、Host 的 `FIELD_DEFAULTS`、以及设置面板真实渲染出来的每个开关。

### 已经写坏的存档：把旧拼写里的值搬回声明字段

用户的 `settings.yaml` 里已经躺着 `thunder-anim` / `contour-anim` / … 这些**未声明键**。只修字段名的话，这些值会被忽略、字段回到默认——对老用户来说还是「又复位了一次」。所以 `prefsMigrateLegacy()` 在拿到第一段 served section 时把这些值**重新提交到声明字段上**（走的是 prefsSet，所以未就绪时照样会被 held + 补写）。判断「声明字段是不是用户自己的值」只有**一个**可靠信号：

- 声明字段缺失或等于出厂默认 → 没人在这上面留下选择，旧拼写的键就是唯一的痕迹，**采纳**；
- 声明字段是别的值 → 那是会正确写盘的版本存下来的，**让位**，旧键不得覆盖它。

不能用 `hasOwnProperty` 判断：拿到的 section 是 schema **已经合并默认值**之后的视图，每个声明字段都在，分不出「存过这个默认值」和「schema 补的默认值」。

`test/settings-namespace.test.js` 覆盖了这三种情形，并对四类改坏方式做过反向对照（见 [testing.md](testing.md)）。

### 读路径：本会话的编辑排在已取回的 section 之上

`prefsGetValue()` 曾经**优先**返回已取回的 section（`prefsFieldValue`），而 `prefsSet()` 只写 `prefsLocal`。宿主回相是异步的，所以「点一下开关，面板显示新状态、主题却还是旧状态」在回相到达前是真实存在的。现在只把 `prefsLocalEdited`（本会话真正被用户改过的字段）叠加在最上层：其余字段仍然以宿主为准，不会把服务端刚给的值盖掉。

### 脏标记只能由「写成功」或「宿主已有该值」清除

`prefsReplayDirty()` 曾在本地值**等于出厂默认**时就直接清掉脏标记。若宿主仍存着非默认值，用户「改回默认」这一操作就永远不会落盘，下次刷新旧的（非默认）值又回来了。同步地，本地值等于**宿主当前值**也不能直接清：宿主视图可能还是旧的，而用户刚刚把它改成同一个值——那也是一次真实编辑。现在只有两种情况清标记：写出去了，或者宿主取回的 section 确实已经持有该值。

## 变量必须声明在 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 翻转自动重解析——这是「换配色不需要重新注册令牌层」的全部原因。

---

## 字体令牌是应用的公共接口，不是主题的开关

**曾经的写法（错误）**：主题在自己的样式表顶部声明

```css
:root {
  --dsw-font-family: Arial, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
  --ds-font-family-code: 'SF Mono', …;
}
```

**为什么错**：`--dsw-font-family` 不是主题私有变量，而是**应用的 UI 根字体令牌**——`dsh-web-frontend` 里就是这一条：

```css
body{font-family:var( --dsw-font-family, -apple-system, … )}
```

而应用把该令牌声明在 `:root`。主题再声明一次就是在**同一个元素上后写覆盖**，于是整个界面根字体变成 Arial。任何注入到应用根节点、写着 `font-family:inherit` 的第三方挂件（如 DeepSeek-Balance-Whale-Widget）都会**继承**这个字体，余额数字原有的字形随之丢失。实测（本机真实浏览器）：挂件计算字体是主题的 Arial，而应用自己的字体栈已经消失。

同类漏出还有挂在 `body` 上的全局文本属性：`font-feature-settings:"tnum" 1,"ss01" 1` 与 `font-variant-ligatures:no-common-ligatures` 同样会被挂件继承。

**现在怎么做**：

1. 两个字体令牌**一个都不再声明**（连 `--ds-font-family-code` 也去掉：它与应用的列表几乎一致，覆盖它对非主题自有元素是视觉惰性的）。`check.js` 新增结构检查——样式表里只要出现 `--dsw-font-family:` / `--ds-font-family-code:` 就判失败，`selftest.js` 有一个注入用例证明这条检查真的会红。
2. 主题自己的字体族抽成 `--edge-font`，声明在 `body` 上（原因同上：引用令牌的自定义属性不能在 `:root`），**只施加在主题自己创建/拥有的节点**上：启动加载屏 `[data-endfield-loader]`、水印字标 `[data-endfield-watermark]`、设置面板根 `.endfield-settings`。三者都是主题自己注入的节点，第三方挂件既不会是它们的祖先也不会是后代。
3. **主题字体栈在前，`--dsw-font-family` 作为末尾兜底**。这个顺序是量出来的，不是猜的：本机同一串文字在应用令牌下渲染 **Segoe UI**（81.688px），在主题字体栈下渲染 **Arial**（84.516px）。若把令牌放在前面，加载屏、水印、设置面板会全部换脸，品牌块那套按 Arial 量出来的比例（见「启动加载屏」）随之失效。放在末尾既保住主题自有表面的现有观感，又保留「应用侧配置了根字体仍能到达主题元素」的协议含义；宿主没有 Arial / Narrow / PingFang / YaHei 时也仍然落在应用字体栈上，而不是某个任意 generic。
4. 加载屏与水印的 `tnum` / `ss01` **跟着字体一起下移到元素自身**（不能再挂 `body`），否则挂件会被继承，加载屏也会失去它排版时依赖的等宽数字与变体字形。
5. 回归测试 `test/font-scope.test.js`：在同一页里同时放一个 `font-family:inherit` 的第三方挂件与主题的全部自有表面，断言挂件拿回应用字体、`font-feature-settings` / `font-variant-ligatures` 为默认值，而加载屏与水印仍在主题字体（Arial）上且各自带着 `tnum` + `ss01`。把旧写法注回去，这条测试与 `check.js` 会同时变红。

---

## 源码是真源，产物是拼出来的

`client.js` 曾经是 8986 行 / 547KB 的单文件，`index.js` 1263 行。拆分的**目标只有一个：让人能在正确的位置改代码**，因此定下的硬约束是 —— **产物逐字节不变**。

### 为什么不做逻辑重构

看起来更「干净」的做法是把 `apply()` 里那 352 个顶层声明重新组织成模块、用参数传递依赖。评估后放弃：

- **跨分区引用密集成网**。设置页那一段引用 109 个外部名字，峰谷定价段跨区引用 48 个，durable gate 36 个。真按模块切，等于要同时改这几百处引用。
- **测试是冲着产物写的**。40 多个测试用 `fs.readFileSync('client.js')` 切片断言（余额胶囊、渠道额度、峰谷窗口、等高线、水印层叠……），另有若干把 `client.js` 复制到临时目录、用 `<script src>` 在无头 Chrome 里跑。产物一变形，这些断言就得整体重写。
- **DSH 只加载一个 bundle**。`package.json` 的 `exports["./client"]` 指向单文件，运行时没有模块解析这一步，拆成多个文件并不减少任何加载次数。

所以拆分做的是**切片**：产物内容一个字不改，源码按功能分区存进 `src/`，由构建脚本按清单拼回去。

### 拼接规则

`scripts/lib/bundle-build.js` 是唯一的拼接器，`scripts/build-client.js` / `scripts/build-host.js` 只是它的两个入口。规则与原因：

| 规则 | 原因 |
| --- | --- |
| 顺序来自 `src/client/manifest.js` / `src/host/manifest.js` | 顶层 `const` 之间有 TDZ 依赖，顺序不能随手改 |
| 片段路径相对 manifest 所在目录解析 | 这样 `src/client/manifest.js` 里才能写 `'../styles/theme.css'` |
| 每个片段先归一化成 LF 再拼 | 编辑器保存、`core.autocrlf` 各行其是，归一化后结果才是确定的 |
| 产物行尾沿用现有产物文件（CRLF 工作区就写 CRLF） | 否则 `git diff` 会把整个文件标成改动 |
| 非末段片段必须以换行结尾 | 否则两段粘成一行，而 `--check` 只能告诉你「第一个差异字符」 |
| 片段末尾不留空行，分区之间的空行算下一段的开头 | 片段末尾的裸空行会被 CI 的 `git diff --check` 判成 `new blank line at EOF`，整条静态检查就红了——拼接器现在会直接拒绝这种片段 |
| 拼完先 `new vm.Script(out)` 解析一次 | 拼接错误在语法层就报出来，不用等浏览器 |
| `--check` 严格相等，并报出第一个差异所在行 | 只报「有差异」没用，要能直接跳到出问题的那一行 |

片段名前缀就是执行顺序：`00-shell-head` 文件头与 `insertCss`，`1x` 偏好存储与观察器，`14-durable-gate` 落盘门，`20`/`22` 等高线，`3x` 启动屏 / 雷霆大字 / 胶囊 / 渠道额度，`4x` 峰谷定价与挂载点，`5x` 设置页，`99-shell-tail` 收尾。

### 等高线 worker 是特例

`src/contour-worker.js` + `src/contour-webgl.js` 由 `npm run build:worker` 把 9 个 kernel 函数抠出来，嵌成 `src/client/21-contour-worker.embed.js` —— **那是生成物，不要手改**。它整块是一行 `JSON.stringify` 出来的字符串，所以 `check.js` 的样式表护栏看不见它；改了 `src/contour-worker.js` 就必须重新生成。

### 门禁与报错

三个 `--check` 串在 `npm run check` 与 `npm run test:ci` 的链首，CI 的 static job 里另有一步。产物过期时直接说清第一个差异行：

```text
client.js is stale (first difference at line 4096); run npm run build:client
```

照提示跑那条命令即可；**不要直接改产物文件**，下一次 `--check` 还是会红。

---

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

整份样式表是一个传给 `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 级图层**——应用外框、对话列、右侧栏列（0.1.x 叫详情列）。所以图层挂进外框内部，并在挂载期间把这几处底色置为透明（`: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. **有界散射（1.9× 提速）。** 最初让每个高斯凸起在每个网格点上求值；高斯在 2.6σ 之外数值上已归零，改成每个凸起只写自己包围盒内的格点后，1440×900 实测 **8.30ms → 4.40ms**。这是本功能能开在背景里的前提。

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

3. **用二次曲线画线，而不是直线段。** marching squares 每个网格边至多产出一个顶点，粗网格下折线**本身就是有棱角的**：实测线段平均 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× 手柄上限）。平滑效果由平滑/尖点测试固定，本表保留为当时的实测记录。
   >
   > 上表的角度是在**当时的 10px 采样网格**上量的；现行 `CONTOUR_STEP` 是 **6**（网格加密后折线本身的转角更小，所以那张表是保守上界）。也正因为顶点数随面积平方增长，网格不能靠一味加密来解决棱角——见下一节。

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

### v1.1.6：短窗口不再缠绕，同步画布路径不再压满一帧

两个独立缺陷，都是「看着像画风问题、实际是数值 / 复杂度问题」。前者影响**所有渲染路径**（它发生在场生成阶段），后者只影响**主线程 Canvas2D 回退路径**（worker + WebGL2 路径下绘制不在主线程，但同一段画线代码在 worker 里省下的分配与 GC 依然是净收益）。

**一、短 / 窄窗口下场被采样混叠（缠绕堆叠）。** 高斯凸起半径写的是 `(0.05 + rnd() * 0.09) * min(w, h)`——把地形特征尺寸绑在**较短边**上。外框一矮（或一窄），凸起半径就缩到比 6px 采样步长还小，于是相邻格点直接跨过好几条等值线，marching squares 吐出一圈圈紧贴的同心环加发夹尖刺，看起来就是线缠在一起、叠成一堆。实测：1440×130 明显缠绕，1440×60 退化成括号状碎屑，1440×900 正常。修法是给半径一个**不小于 4 个采样格**的下限（`CONTOUR_MIN_BUMPSAMPLES`）；常规窗口的相对项本来就远大于该下限，所以画面逐像素不变。

**二、同步画布路径的单帧成本。** 在主线程 Canvas2D 路径上实测（1440×900，每张图）：

| 项 | 实测 | 处理 |
| --- | --- | --- |
| `closePath()` | 18 次 / 张，均值 **0.278ms**、最大 1.2ms，约占一帧的 **16%** | 它把当前子路径拼进已累积的 path，成本随 path 里已有内容增长；改成**先画环、再画开放折线**，让每次拼接只面对环几何 |
| 每帧贝塞尔段 | **37,865** 条（来自约 4.7k 抽取顶点，Chaikin ×3 把点数变成 8 倍） | 按抽取顶点数自适应 Chaikin 次数：≤8000 维持原本 3 次，>8000 降到 2 次、>20000 降到 1 次 |
| `tangent()` | 每段 8 个 `[x, y]` 数组 + 最多 6 次 `Math.hypot`，剖析器显示该项独占全页 CPU **14%** | 改成标量切向 + 扁平数组 + `Math.sqrt`（参数都是像素差，远离 `hypot` 的溢出区间），几何保持逐字节等价 |
| `smoothPath()` | 每个源顶点约 14 个小数组（3 次 pass × 每点 2 个） | 全程扁平 scratch 数组 |

另外加了两道护栏：**格子数上限**（`CONTOUR_MAX_CELLS = 60000`，超出时按 `contourStepFor()` 加大步长；1440×900 是 36k 格，仍用 6px，普通窗口完全不变）把「一张图的成本」从随面积平方增长压成近似常数，同时让大 HiDPI 画布继续留在 worker 的显存上限之内；**占空比上限**（`CONTOUR_DUTY = 0.5`）只在同步画布路径生效——worker 绘制时一次刷新远低于 1ms，这个钳制永不生效；一旦回退到主线程且单帧超预算，就按「本次实测成本 / 0.5」拉长间隔，把线程还回去。相位仍然每次**只前进一个标称帧**（绝不按墙上时钟的间隔追赶），所以机器再忙看到的也是同一段动画放慢，而不是卡顿后跳一下。

> 判定口径要写清楚：worker 路径下**不存在**「页面被绘制卡死」这一症状（抽取与绘制都在 worker 里）；上面那半张表量的是主线程 Canvas2D 路径，而 worker 拒绝超过 8M 显存像素的画布、或在 worker 不可用时，这条路径就是**实际在跑**的那条（`test/contour-loop-reentry.test.js` 专门覆盖这个回退态）。两条路径共用同一份 `contourDrawLines`，所以省下的分配在 worker 里同样成立。

### 平滑只管中段：三处真正的锐角（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** 条高度带。

**但「几次穿越」并不等于「有多少墨」**（v1.1.6 补上的一环）。3 条高度带可以全部只擦过同一格的同一个角，格子照样几乎空白——这正是 CI 上 `contour-specks` 偶发飘红的原因：最薄的那批合格布局在 CI 的栅格化下量到 **0.47%**，低于 0.6% 的判据，而校验器认为它们合格。三处证据把这条缝隙钉死了：

- 判据本身没问题：把采样步长从 2px 改成 1px、把 alpha 阈值从 >10 放宽到 >0，同一格只涨到 1.25 倍（**c1≈c2**），说明不是采样混叠也不是阈值把墨算丢了，格子是**真的薄**；
- 提高穿越次数不是出路：要求每格 ≥**4** 条高度带时，32 次重抽有 **39%** 的加载触顶、只能落回兜底布局（正是重抽机制要避免的那种失败）；
- 提高「墨」才是出路，而且很便宜。

所以校验器现在**同时**给每格算一个廉价的墨量代理：遍历该区域的场四边形，数每个四边形的四角值域里落进了多少条被绘制的高度带。一次这样的命中就意味着等值线穿过该四边形，约等于 `step` 像素的描边——不必跑 marching squares、也不必在构建期做提取。在 1406×756 与 1440×757 两个视口、各 400 个随机布局上对着**真实提取出的每格描边长度**标定：

| 墨量代理 | 最薄区域的真实描边长度 |
| --- | --- |
| 47–52（加门槛前的最差） | **182–201px** |
| 80（现行门槛） | **~310px** |
| 92（中位布局） | ~437px |

于是 `CONTOUR_MIN_INK = 80` 把「最薄区域」的保证从 ~182px 抬到 ~310px，代价是平均重抽次数 3.97 → 5.71、p95 15、最差 19（上限 32，200 个种子**从未触顶**）。实测效果：40 次加载里最薄的格子从 **1.02%** 抬到 **1.61%**，对 0.6% 判据的余量由约 1.7 倍变成 **2.7 倍**，CI 上那次 0.47% 的病态布局不再可能出现。

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

### 视口密度自适应：小屏不再是一堆乱线

上面所有常量——21 条高度带、22 个凸起——都是**按桌面窗口的观感**调出来的，却按「每面积」施加：窗口缩到手机宽度，同样的线数与岛数挤进一小条竖带，嵌套环密到读作乱线。375×812 实测（暗色）：**62 条路径 / 23,890px 墨长**，单屏叠出 8~9 层同心环。

杠杆选的是**层级数**：线距 = `span*2/LEVELS`，直接按比例缩放屏幕上的线间距。短边 **<640px** 时层级数从 20 向 10 线性递减（320px 触底，下限 6），凸起数从 22 向 12 同步递减（让岛屿保持分离而不是叠成靶心）；**≥640px 逐字节回到出厂值**，桌面窗口输出不变。640 这个锚点取在「手机宽（360–430）与窄分屏不再主导短边」之上，320 触底保证最小可用窗口仍是一幅完整地形。

三处消费同一组函数（`contourLevelsFor` / `contourBumpsFor`，以视口短边为锚）：布隆布局 `contourBuildCandidate`、覆盖率校验 `contourCoverageScore`、抽取 `contourExtract`。其中校验器必须按「该视口**实际将画的**层级数」算墨量代理——若仍按 `CONTOUR_LEVELS` 评分，10 级的小屏会被要求 2 倍墨量，重抽循环每次都触顶。

改造后 375×812 实测：路径数降到 ~54（另有种子随机性），最薄分区墨迹 **3.56%**（8×5 网格，0 空白格），嵌套环从 8~9 层降到 3~4 层。回归锁在 `test/contour-density.test.js`（≥640px 断言出厂值逐值相等；小屏断言按公式递减且能完整成图）。注意 worker 内核里 `src/contour-worker.js` 有一份**手工镜像**的同名函数——构建脚本只抠函数不抠常量，闭包名缺失会在 worker 首帧抛 ReferenceError（见 worker 一节的教训）。

### 重启动画的首帧曾是空转

「关掉动态等高线再打开，图案不动了」的成因不是开关没接上，而是**重启后的第一帧必定无效**：重启把「上一帧时刻」置为 `-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 时**换位重生。

---

## 输出滚动动画：为什么不能改 scrollTop 的写后回读语义

### 成因：应用用「写后回读」判断自己还要不要跟随

DSH 流式输出时不是滚动，而是**直接赋值钉底**（`useScrollFollow.jump`）：

```js
element.scrollTop = metrics.floor
const landed = { ...metrics, top: element.scrollTop }
this.following = this.nearBottom(landed)        // 25px 阈值
```

第二次读取**不是**出于好奇——`follow intent` 就是从它算出来的。因此凡是改变「写完 `scrollTop` 之后立刻读回什么」的方案，都在动应用的跟随状态机。

平台基线实测（用发布版 ChatView / ChatReading 的代码搭的夹具）：一次增长步进把端口移动恰好 **+60px，且没有任何中间帧**、0 次 smooth `scrollTo`。这就是「硬生生滚动」的全部：内容一整段瞬移，眼睛看到的是一格一格跳。

### 那条否决：`scroll-behavior: smooth`

这是最显眼的一行式写法，也是这里**最危险**的一行。实测：smooth 下应用写完 `scrollTop` 后立刻回读，拿到的是**旧位置**（`403`，而目标是 `4409`），因为只有动画的**起点**被同步提交。这个回读喂进 `nearBottom()` 后，`floor - oldTop > 25` 让应用认定「读者已经离开底部」，于是**放弃跟随**——输出会静默地不再跟随自己，没有任何报错。

**任何改变「写后回读语义」的机制都被否决**，无论它在 CSS 里看起来多诱人。

### 修法：不写端口，只在 scroll 事件里补偿对话栏

新机制**从不写端口**。它监听端口的 `scroll` 事件，只对对话栏（`[data-chat-flow]`）施加一个补偿性 `translateY`，再逐帧把它衰减归零：

```js
offset += (top_after - top_before)
column.style.transform = 'translateY(' + offset + 'px)'
```

补偿量正好抵消应用刚做的那一步，所以内容在屏幕上**停在原地**，随后几帧里滑回应用给的位置——眼睛看到的是滑行，而应用执行的仍是跳转。实测（同一页面、96px 步进 / 60ms，功能关 vs 功能开）：

| | 功能关（控制组） | 功能开 |
| --- | --- | --- |
| 最差单帧屏上位移 | 96px | 约 20–38px（多次运行区间） |
| 应用 `scrollTop` 回读误差 | 0px | **0px**（跟随意图不受影响） |
| 静止时 transform | — | 已清空 |
| 端口相对应用自己的 floor 的残差 | 0 | **0** |

控制组这一列就是负向对照，它证明夹具**真的**能复现那个硬跳：关掉功能时 `maxStep == rawStride`（96px）且零 transform。没有这一列，「平滑了」只是夹具的自证。

同页另一次实测确认了第二条不变式：`translateY(240px)` **不会**改变端口的 `scrollHeight`，也不改变 floor——应用自己那条 `overflow: visible clip` 链把位移兜住了，因此补偿永远不可能反馈进应用的算术。

### 为什么是 `scroll` 事件，而不是 patch `scrollTop` 访问器

直觉上遮蔽访问器更「精确」，但同一循环下实测：

| 应用的写入口 | `scroll` 事件 | patch 访问器 |
| --- | --- | --- |
| `scrollTop = v` | 21.88px | 21.87px（持平） |
| `scrollTo({ behavior:'instant' })` | **21.88px** | 32.0px（**完全看不到**） |

差距的来源是时序：Chrome 在渲染步骤里、**rAF 与绘制之前**派发 `scroll`，所以处理函数里的写能赶上这一帧；而 patch 只能看到走 setter 的那一条路径。除此之外，监听还有两个非性能的理由：它**不遮蔽任何 DOM 访问器**（避免把「应用自己的读写」也变成我们的责任），并且能**跨 React 重建元素存活**。

同理，补偿必须**写在处理函数里**，不能挪进 `requestAnimationFrame` 或任何调度回调：第一版就是在下一帧才写 transform，实测 `maxStep` 等于原始步进——等于没有动画。

### 衰减常数：`tau` 是「顺滑换滞后」的唯一旋钮

`offset *= exp(-dt/tau)` 里的 `tau` 同时决定两件事：越大越顺滑，也越大越滞后。稳态滞后有闭式解

```text
lag = stride / (1 - e^(-interval / tau))
```

所以这不是一个可以「调到既顺滑又不滞后」的参数——档位之间的差别就是这条取舍线上的位置。

`tau` 必须**远大于一帧**（约 16.7ms），否则 offset 在下次绘制之前就已经归零，补偿静默地退化成空操作。

`standard`（`tau` 70ms）是默认值。实测与闭式解吻合，因此可以放心引用**两个**速率（只测一个会误导，这正是本节留下的一条教训）：

| 速率 | 闭式解 | 实测 |
| --- | --- | --- |
| 真实流式（每 250ms 一行 24px，约 96px/s） | 6.7px | **3.1px**（不足一行行高） |
| 刻意夸张的合成速率（每 60ms 一步 96px，约 1600px/s） | 167px | **163px** |

也就是说：真实速率下滞后在「一行以内」，夸张速率下才到 ~110–160px。功能关闭时两个速率都是 **0px**。

> **量滞后本身就是个陷阱**（改那条断言前值得先读）。滞后必须读成「尾部相对端口下缘的**正值**距离」，且基线要在**屏幕上没有 offset 时**取。取早了——`scroll` 事件在渲染步骤里投递，不与写入同步——就会把那一次钉底的 offset 留在基线里，让**之后每个样本都偏低**，低到可以报出**负滞后**（内容跑到视口上方，物理上不可能）。本仓库真的踩过：基线早了 9.6px，指标读到 -12px。现在测试先等两帧再排空，并断言 `minLag >= -1`，让这个指标不能再往「看起来更安全」的方向说谎。


### 0.5px epsilon：让静止态**可达**，而不只是「趋近」

指数衰减是渐近的，永远到不了零。加 epsilon 之前实测：offset 卡在 `translateY(0.09px)`，端口**一直停在 `active` 状态**、transform 永远不清除——一个看不见的位移，却永久持有合成层。

因此衰减到 `0.5px` 以下就**直接归零并清空 transform**。取 0.5 是 CSS 像素而不是设备像素：任何 DPR ≥ 1 时它至多半个设备像素，且就在到达它的那一帧被移除，所以静止几何与应用自己的几何**完全相同**，而不是「差不多」。

### 为什么不做上限截断（clamp），而是拒绝过大的步进

第一版把 `offset` 钳到上限，听起来更安全，实测却更糟：**被 clamp 的变体 `stepMax` 等于原始步进（192/192，即完全没有平滑）**，而未 clamp 的曲线是 **43.9px**。原因是钳制只让内容「少走一点」，那点缺口会在**下一帧变成一次瞬时跳**——把要消除的东西原样还回来。

所以上限不是用来塑形的旋钮，而是**安全检查**：超过该档 `cap` 的步进被整条拒绝、瞬时呈现。`cap` 远高于任何真实内容块（一个落地的工具结果或代码围栏是几百 px），所以那些——本功能真正存在的理由——仍然会滑，只有恢复会话、跳转到某个回合这类荒谬的大步进绕过动画。

### 门控：几何 + 布局测量，且刻意不读上游属性

一个步进会被柔化，当且仅当：**步进前端口已在底部**（应用自己的 25px 容差，再加一点余量到 40px，好让晚到一帧的步进仍算数）、**方向向下**、**不超过该档 `cap`**、**且这一步是纯增长而不是布局重排**（最后一条见下一节，它是后补的，因为前三条曾经不够）。

这个判据不需要任何上游钩子。前三条能排除掉大多数情形：加载更早的历史、恢复会话、跳到某个回合都**不是**从底部出发；向上移动永远不满足；读者自己的滚轮位移也不可能「从底部出发并向下」，因为底下已经没有可滚的东西了。

> **但前三条并不充分——这是被用户实测推翻的一个结论，值得留在这里。** 原本这里写的是「从底部出发、向下的位移只可能是应用在钉新长出来的内容」。**展开思考块**就是反例：它同样从底部出发、同样向下、同样可能小于 `cap`，但它是**布局重排**而不是增长。当时因此把一个 159px 的重排当成增长去补偿，用户看到的就是「展开时明显抽搐」。判据缺了一条，详见 [布局重排的补偿](#布局重排展开思考块为什么抽搐)。

应用确实有一个语义相同的 `data-chat-following-tail` 属性，但**刻意不用**：我们自己加的无哈希属性加几何判据，在上游改名后仍然工作；而依赖上游契约会把一次改名变成「功能静默死掉」。

其余一律保持瞬时，且这条清单是**行为契约**而不是实现细节：向上滚动、从阅读位置跳转、超大步进、`wheel` / `touchstart` / `pointerdown` / `keydown` 手势、`prefers-reduced-motion`、页面隐藏（页面隐藏这一条同时也防止冻结的 rAF 把一大段 offset 晾在那里、等回来时再补算）。

### 布局重排：展开思考块为什么抽搐

**现象**（用户报告）：展开思考（reasoning）时对话区明显抽搐一下。

**成因**：补偿量的数学前提错了。真实被补偿的量是**眼睛看到的位移**：

```text
visual = layoutDelta - delta
```

`delta` 是应用钉底的位移，`layoutDelta` 是**布局自身**在同一步里移动既有内容的距离。纯流式追加时 `layoutDelta` 恒为 0，所以 `visual == -delta`，补偿 `+delta` 正好把画面按回原位、再衰减滑到新位置。但展开不是追加：`ReasoningRow` 折叠时是一个固定 24px 的盒子（`contain:size layout`），展开时**整段 markdown body 一次性挂载**，列在数百 px 的量级上变高，而这个增长发生在**尾部内容之上**——布局把尾部往下推了 `H`，应用又把它往上钉了差不多的量，两者几乎抵消，**屏幕上内容根本没动**。此时再补 `+delta`，等于凭空造出 `H` 的位移，然后再花 0.6 秒滑回来。

实测（默认档位 `standard`，展开 8 行思考）：

| | 关闭功能 | 修复前 | 修复后 |
| --- | --- | --- | --- |
| 尾部最大位移 | 39.5px（一帧掠过） | **159.5px** | 39.5px |
| 之后的滑行 | 无 | **659ms** | 无 |
| 最大 transform | 0 | 159px | **0** |

**修法**：不再假设「`scrollTop` 的增量就是视觉位移」，而是**测量**布局自己贡献了多少：

```js
const layout = anchor.getBoundingClientRect().top - column.getBoundingClientRect().top
const layoutDelta = layout - entry.lastLayout      // 同一节点才相减
const visual = delta - layoutDelta
```

`anchor` 取**列的最后子节点**（流式就是在它这一段追加，所以底部追加不能移动它的顶边）。两个 rect 都包含我们自己写的 transform，相减时**恰好抵消**，所以量到的只有布局位移。

**踩过的三个坑**（都是测量纠正的，不是推理出来的，所以写在这里）：

1. **锚点不能用「端口」做参照。** 端口不随 transform 移动，于是两次 stride 之间**我们自己 offset 的衰减**（`tau=70ms` 下每步几十 px）被当成布局重排，动画被中途拆掉——测试立刻抓到：`maxStep` 156px 而原始步进只有 96px。改为相对**列自身顶边**后，transform 在两者中相等而抵消。
2. **锚点不能用最深的最后一个后代。** 那正是展开时自己会变形的节点（markdown body），实测对 159px 的重排只报 0.5px——完全瞎。
3. **空列不能回退成「列自己」。** 列相对自己的偏移恒为 0，于是基线被永久钉在 0，第一次大重排就会被读成 `layoutDelta 0` 而照旧动画——正是这个 gate 要防的场景。

**护栏**：`test/scroll-anim.test.js` 里有一条专门的展开回归断言——用真实 `ReasoningRow` 的形状（折叠 24px 盒子 + 展开挂载 body）、用应用自己的 `ResizeObserver` 触发重钉底，断言展开时**不得比关闭功能时多位移**、且**不得产生任何 transform**；同时保留「流式必须仍然动画」那一半，因为一个「见到 resize 就整个关掉」的 gate 也能通过前一半。变异验证里 **M4 就是把补偿改回 `visual = delta`**（即修复前的代码），该断言报 `151.5px vs 0.5px` 并变红。


### 副作用审计：transform 的包含块与滚动溢出

在对话栏上加 `transform` 不是零成本的原语，落地前对着发布版 bundle 核过两件事：

- **包含块**：`transform` 会给 `position: fixed` 后代创建包含块。对话子树里**没有**这样的后代（chat / conversation 两个 bundle 里的 `position: fixed` 属于 portal 出去的统计对话框，渲染在这棵树之外；唯一一处 JS `style.transform` 属于回合轨的虚拟化器，它的元素是对话栏的**兄弟**），而粘性元素（输入区座位、回合轨、分组标题）同样都是兄弟而不是后代，因此都不受影响。
- **滚动溢出**：如上所述，`translateY` 不改变 `scrollHeight` 与 floor，应用测到的仍是它预期的那个 floor。

### 护栏

`test/scroll-anim.test.js` 把上面每一条都变成断言：A 部分在**源码切片**上钉接线（片段里不得出现任何 `scrollTop` 赋值或 `scrollTo(` 调用、必须挂 passive 的 `scroll` 监听、只用无哈希的 `data-*` 钩子、门控表达式与 prefs 契约都在）；B 部分在**真实浏览器**里跑真实 `client.js`，并在**同一页**上先取下负向对照（功能关时 96px 一步跳满、零 transform），再断言功能开的 96px → 约 38px、回读误差 0px、静止时 transform 清空且端口落在 floor 上、滞后在合理速率下不超过一行、`translateY(240px)` 不改变 `scrollHeight` 与 floor。

`scroll-behavior: smooth` 那条否决因此有一个可执行的形式：**回读误差必须是精确的 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，并在真实速度截图中抓到中途帧。

### 胶囊收起：transition 不能和状态选择器同生共死

顶部余额胶囊的开场 pose（448×72 品牌面板）收进 300×32 药丸时，余额行的淡入**原本**写成 `[data-endfield-balance][data-endfield-balance-boot] > :not(brand) { opacity: 0; transition: opacity … }`。JS 收起的第一步就是 `removeAttribute('data-endfield-balance-boot')`——选择器**当场不再匹配**，`opacity: 0` 与 `transition` 声明在同一瞬间一起消失。transition 只在「属性变更后元素上仍有声明」时才执行，于是行内容直接从 0 **跳到** 1：时序采样（每 40ms 读 computed style）显示收起起始帧 `rowO: 0 → 1` 一步到位，而品牌层的 220ms 淡出（transition 写在基础规则 `[data-endfield-balance-brand]` 上，所以活着）还在中途，72px 的高盒子里两套文字叠印了约五分之一秒——用户看到的"动画不连贯"就是这一帧。

修法是拆开两个半边：**「按住」的 `opacity: 0` 留在 pose 选择器里，能跑的 `transition` 挪到不依赖 pose 的基础规则** `[data-endfield-balance] > :not(brand)`，并给它 90ms 延迟。延迟是交叉淡化的关键——品牌（220ms）淡出大半时数字（170ms）才爬到三分之一，两者都远早于盒子的 420ms 收缩结束，整段收起读成一个动作。复测同一采样器得到 `0 → 0.16 → 0.36 → 0.65 → 0.94 → 1` 的平滑爬升，与品牌淡出完全交叠。

这与上面加载屏那条是同一课的两面：那条说**有的渲染器根本不跑 transition**（要 JS 逐帧兜底）；这条说即便在跑 transition 的渲染器里，**声明的存续期也是动画的一部分**——把 transition 挂在会被移除的状态属性下，等于亲手在动画起始帧掐死它。`test/balance-capsule.test.js` 因此钉住三条：基础选择器带 transition、pose 选择器**不带**、且 transition 带延迟。

### 胶囊两端不圆：形状不能依赖"收起时写回"

第一版修法（挂载只写常量 `999px !important`、pose 不再借用 14px、收起不再写回）在夹具里完美，但用户重载后**仍然**看到椭圆端头——第二张截图证明：那张图没有被拉伸（同图里 27px 表盘实测 27×27 正圆、药丸 300×32 分毫不差），端头弧深却仍是 ~14px。结论只剩一个：**那个渲染器对 999px 的钳制本身就坏**（渲染出 squircle 而非半圆），任何依赖 `border-radius` 计算终值的方案在它上面都不可信；而同一帧里表盘那圈 `conic-gradient` + `radial mask` 是**完美圆形**——渐变是这个渲染器从未失手的形状原语。

最终修法即用户所言「直接在 CSS 上左右画个半圆」：**轮廓改为绘制，不再依赖圆角**。胶囊 `border-radius` 钉成 0（内联带 priority——任何半径都会把背景裁进圆角盒，把坏渲染器的 squircle 重新暴露出来），背景换成三层渐变：两端各一个 `radial-gradient` 圆盘（圆心在 `--endfield-balance-cap` / `calc(100% - cap)` 的垂直中点，半径 cap，边缘 0.5px 羽化抗锯齿）加一条 `linear-gradient` 实色带铺在两圆心之间。`@property --endfield-balance-cap` 注册为 `<length>` 并进入 420ms transition 列表，与 `height` 同曲线补间（16px ↔ 36px），于是收起全程 **cap(t) = height(t)/2 每帧成立**（实测 83 个采样点零失步），画出来的 stadium 与变形的盒子始终重合。像素验证：端头轮廓逐行内缩 21,16,14,12,10,9,8,…,0 精确落在 R=16 圆弧上。

教训合并成一句话：**凡是"最终必须成立"的视觉状态，都不能只靠动画、状态切换或某个几何属性的钳制去达成**——transition 可能不跑、声明可能随选择器消失、内联写回可能被吞、border-radius 的超大值钳制本身可能渲染成 squircle；把不变式压到最原始的绘制原语上（这里：渐变），让坏不了的东西替你维持形状。`balance-capsule.test.js` 钉住：三层渐变轮廓、@property 注册 + 16/36 两值 + transition 补间、挂载写 `0 !important` 且源码不得再出现任何 999px/14px 的半径写入。

### 启动读取不能只信 `apply()` 那一刻

加载屏是唯一一个**必须在启动瞬间做决定**的表面：它在 `apply()` 里同步读一次 `loader`，读到就播。而设置节的实际到达顺序是异步的——`settingsScope` 的首次快照是 `{ status:'loading', value: undefined }`（`dsh-client-ui-settings` 的 `SettingsScopeSnapshot` 契约明写了这一点），Host 那份 section 走线上回来时 `apply()` 早已跑完。于是那次读落在 schema 默认值 `'0'` 上：用户即使存着 `loader:"1"`，也什么都不播。

其余每个开关都从这场竞态里活了下来，因为它们**各自都有稍后重推的路径**：主开关 / 圆角 / 配色 / 水印随 `reconcileFromPrefs` 重画，等高线由水印的 `MutationObserver` 反复重试，雷霆大字在每次节变化时重订阅。**只有加载屏被显式排除在重放之外**（它是一次页面加载只播一次的片子，不能让运行期改动静悄悄盖上全屏），所以它没有第二次机会——这就是「开关是开的、动画却再也不出现」的全部原因。

修法不是放开重放，而是把「首次权威节」做成一个独立信号：存储层读到一个 `status:'ready'` 的节、并跑完解析与旧拼写迁移之后，回调一次 `onPrefsSettled`；加载屏挂在这个信号上，且仍然只播一次。后续的节变化根本不经过它，「每次页面加载」的语义没有被稀释。

这个 bug 能穿过整套测试，是因为既有用例的假 scope 都**同步答 `ready`**：`apply()` 那一刻读到的已经是真值。`test/loader-late-prefs.test.js` 因此给假 scope 加上 `{ readyDelayMs }`——先答 `loading`、再翻 `ready`，只有这个形状能复现线上。

---

## 设置页国际化

设置页此前是**硬编码中文**的：把 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 插件」从「设置页显示中文」变成「设置页直接崩」。所以该键只在服务确实存在时才并入注册项，并且两个方向都写了断言。

---

## 峰谷定价窗口与法定节假日

顶部余额胶囊要在本地算出「现在是高峰还是低谷」——Host 没有任何服务携带这份排班表，主题只能自己推算、自己保管日历。规则的两次修订都必须进代码：

- **2026-09-10 起** flash 系列改为峰谷分时计费：**北京时间周一至周五 9:00-12:00、14:00-18:00 为高峰**，价格是空闲时段的 2 倍（api-docs.deepseek.com pricing）。
- **2026-09-19 的「API 峰谷时间补充说明」** 补上了两条从主规则里读不出来的边界：中国**法定节假日全天**按空闲计费；**调休上班的周末仍按周末计费**（「只要是周末，就按空闲价执行」）。

第二条推论出本节最重要的决定：既然官方把调休日直接归进周末规则，代码就**不需要**任何调休例外表——周末分支天然把它们算成低谷。代价是这条推理必须被机器守住：`check.js` 第 7 段要求**每一条 `makeup` 日期都真的是周六或周日**，一旦将来某份通知把普通工作日调去上班，守卫立刻失败，逼着重新审视模型，而不是继续默默按高峰计价。

### 日历是数据，不是逻辑

`BALANCE_HOLIDAY_NOTICES` 按年抄录国务院的放假安排通知（2026 年 = **国办发明电〔2025〕7号**，https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm ），`from` / `to` 是**含端点的 MM-DD**，`makeup` 存调休日——它不只是给人看的，也是上面那条推理的证据。启动时逐日展开成 `BALANCE_HOLIDAY_DATES`（`'YYYY-MM-DD' → 节日名`），再由此得出 `balanceHolidayName` 与 `balanceDayIsOffPeak`。

**没有收录的年份回退到普通工作日规则**，也就是一个未录入的节假日会被算成高峰。这是刻意的：宁可把假期显示成高峰（用户读到的只是「现在贵」），也不猜一个假的半价读数。代价是这份数据会随时间失效，而且失效是**静默**的——所以 `check.js` 在「当前北京年份不在表里」时响亮失败，作为每年 11 月（通知发布月）的提醒。

### 窗口是「最长同价连续段」，不是日历天

旧实现把窗口限制在**当天 00:00-24:00**，于是周五 18:00 到周一 09:00 这段连续 63 小时的低谷被切成三块，周六上午的读数变成「剩余 14:20:00（到周日 24:00）」——一个与价格无关的数字。午夜不是价格边界，现在窗口是**同价连续块的最长游程**：7 天国庆读成一段 183 小时，跨节、跨周末自然合并。

实现上 `blocksAt(day)` 给出当天的块序列（整天谷 / 工作日五块），非高峰时从当前块向两侧逐块走，遇到高峰块即停；步数上限 `BALANCE_WINDOW_WALK_CAP = 4096` 只作防呆（9 天春节连周末约 20 块，实际不可达）。顺带把 `BALANCE_PEAK_WINDOWS` 从**死常量**改造成块序列的推导来源：此前它在声明后从未被使用，函数里另写死了一份 `edges`，两者随时可能漂移。

### 节假日名只出现在开场 pose
胶囊宽 300px，窄屏断点 316px 正是按它推出的，所以 **settled 行的文案一个字都不能加**。「今天是国庆节」只在开场品牌 pose 的标题里体现（`DeepSeek 国庆节低谷`，普通日子仍是 `DeepSeek 当前低谷`）；`test/balance-capsule.test.js` 直接断言 `win.holiday` 只被**一行**读取、且那一行在 brand-title 块内，防止它以后爬进 settled 行把胶囊撑宽。

### 窄屏是两级阶梯，不是一刀切
316px 那一级只保护桌面布局——手机（390–480px）根本到不了它，「低谷时段剩余42:08:13」照样把胶囊撑到表头标签之上。所以拆成两级：**≤480px** 隐藏「时段剩余hh:mm:ss」（`data-endfield-balance-remain-run`，秒级倒计时是手机上最没人看的读数，也是最宽的 token 串），高峰/低谷 标签保留；**≤316px** 再把整段灰色读数（含标签）删掉，守住 300px 药丸的原始契约。要做到只砍尾巴，静态胶水文案「时段剩余」必须有自己的元素——文本节点没有可隐藏的 selector，因此它在标记里被包进 `remain-run`，`phase` 留在外面。

480px 那一格里 `min-width` 必须**跟着一起让**：300px 地板是为「倒计时 ↔ 已过百分比」之间那段参考稿间距存在的，尾巴删掉后它的 `margin-left: auto` 会把全部富余像素吞进读数左边的空白——**读数少了，胶囊却纹丝不动**。手机格里写 `min-width: 0` 让盒子收缩到内容，间距回到 6px gap。开场 pose 不受影响：它的规则带 `[data-endfield-balance-boot]` 属性，特异性 (0,2,0) 压过裸 `[data-endfield-balance]` 的 (0,1,0)，媒体查询本身不加特异性，所以 448px 地板在手机上照常morph（`min(448px, 100vw - 16px)` 自行封顶）。



算术本身（节假日 / 周末 / 工作日各读到什么窗口、倒计时多少）钉在 `test/balance-window.test.js`；守卫与注入用例见 [testing.md](testing.md#顶部余额胶囊)。

---

## 已修问题归档

按成因分类。共同点：**都是量出来的，不是读代码读出来的。**

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

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

以设置 › 模型 的 `编辑` 按钮（类名 `<hash>_secondaryButton`，0.1.1 bundle 里哈希为 `zGbnIq_`）为例，上游声明：

```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 处**（类名中的哈希随构建变化，现按语义后缀匹配；0.1.1 bundle 中的哈希备查）：

| 元素（语义后缀） | 0.1.1 哈希 | 位置 | 修复前（暗色·谷地黄） | 修复后 |
| --- | --- | --- | --- | --- |
| `_secondaryButton` | `.zGbnIq_…` | 设置 › 模型 行操作（`编辑`） | **1.05:1** | 16.50:1 |
| `_inspectButton` | `.gNWCoW_…` | Cordis 检查面板 | 1.05:1 | 16.50:1 |
| `_inspectButton` | `.iWrAna_…` | 技能检查面板 | 1.05:1 | 16.50:1 |
| `_inspectButton` | `.o3BgMG_…` | 工具检查面板 | 1.05:1 | 16.50:1 |
| `_arrow`（限输入区容器内） | `.JVDQca_…` | 附件轮播箭头 | 1.05:1 | 16.50:1 |
| `_add`（限定输入区容器内） | `.uV2eYG_…` | 输入区 `+` | 早前已修；0.1.7 因裸子串命中容器而复发，见八类 | 18.31:1 |

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

**选择器匹配的四条铁律**（前三条来自 0.1.2-rc.1 全量重哈希、33 个哈希选择器同日全灭，见 issue #17；第四条来自 0.1.5-rc.2 的 header 重构，见七类）：

1. **禁止把模块哈希写进选择器。** CSS Module 类名是 `<hash>_<语义后缀>`，每次上游重新构建哈希全变，钉哈希的选择器**静默失效**。`test/selector-guard.test.js` 会在哈希重新出现时报警。
2. **复合状态用子串匹配，不用 `[class$=]`。** 属性后缀选择器要求**整个 class 属性**以该串结尾，而元素常常还带第二个类（实测 `[class$='_inspectButton']` 在 `class="gNWCoW_inspectButton HOVERPROBE"` 上直接漏掉）。`[class*='_语义名']` 对拼接免疫；`_unselected` 因下划线断词不会误中 `_selected`。
3. **泛化后缀必须加作用域，而且 0.2 连「作用域本身」也会改名。** 轨迹与工作区也有 `*_arrow` 类但**没有 hover 填充**，裸匹配会给它们强行刷墨色（暗色下黑-on-黑）。附件箭头按输入区容器（`_composerSeat`/`_composerHero`）限定；同理清等高线背景必须用列后缀限定 `_root`——0.1.x 是 `_centerCol`/`_detailsCol`，0.2 右侧栏改成 `_rightbarCol` 且不透明底色搬到了列元素本身，所以限定词与目标元素要**同时**更新（见[§ DSH 0.2.0-rc.2 上失效的三处应用侧钩子](#dsh-020-rc2-上失效的三处应用侧钩子v116-已跟进)）。这类改名不会报错，只会让规则静默不命中。
4. **子树位置不是语义，不要用 `>` 把中间层数写死。** 语义后缀能扛住重新哈希，却扛不住上游**插入一层包裹元素**：`A > B` 在 `A > C > B` 上直接失配，同样静默。这条 bug 在一次会话里被犯了**两次**（见七类）：先是把徽章当成 header 的直系子节点，改成 `_headerActions >` 之后又漏掉了**插槽自己那层没有 class 的包裹 div**。层级要么用后代组合器表达，要么更好——**改用元素自身的特征**把目标锁定（不依赖任何一层的位置）。新增或调整这类选择器时，必须对着**真实 DOM**（浏览器里量出来，或从上游渲染代码读出来）验一遍，而不是对着测试夹具。

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

提问卡片的「推荐」徽标在亮 / 暗两种模式下都完全不可见。上游把 `--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）。

### 四类：回合状态标签

该标签在 **0.1.x** 上是 `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` 与流光动画保持不变。

**0.2 换掉了整套机制**（v1.1.6 已跟进，实测见[§ DSH 0.2.0-rc.2 上失效的三处应用侧钩子](#dsh-020-rc2-上失效的三处应用侧钩子v116-已跟进)）：标签搬到 `@deepseek-ai/dsh-client-ui-chat` 并改叫 `<hash>_running`，是**遮罩扫光文字**——渐变、`background-clip` 都不存在了，上面两条结论随之失效：写 `background-image` 没有任何作用，改色只能落到 `--dsw-alias-label-deep-diving` 与 `-shimmer` 这两个令牌上。本主题因此把换色移进 `theme.overrideTokens` 层，值仍复用同一批 `--edge-status-*` 色标，两代共享同一份对比度结论。

色标取法见 [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 态，把令牌与样式表的挂载一起推迟——主题必须先能上色，即使这个娱乐功能永远拿不到服务。

- **列表快照里的 `current` 字段消失，把「暂时没有当前会话」写成了终态。** 上一个 bug 修的是**服务迟到**，这一个修的是**契约变化**：上游把 `SessionListState` 收窄成 `{ ids, byId, phase, projectionsBySession }`，`current` 整个没了（view selection 早在 `service.d.ts` 里就被注明「remains outside the Controller」）。旧代码 `snap.current` 于是恒为 `undefined`，而它落进的分支是 `thunderDetach(); return`——**没有订阅、没有定时器、没有任何后续推送能再进来**，功能在新版本上静默失效，而测试全绿：假 `sessions` 服务当时也按同一份旧形状造形，等于把 bug 一起固化了。

  现在的权威答案是 **mainView retention**，而不是某个字段：workspace 面板用 `this.sessions.retain(target, { source: 'mainView' })`（`@deepseek-ai/dsh-client-ui-workspace`）标记「用户正在看这个会话」，行上体现为 `retainedBy.mainView > 0`；全应用统一读法是

  ```js
  Object.values(state.byId).find((row) => (row.retainedBy?.mainView ?? 0) > 0)?.id
  ```

  7 个官方包（`ui-layout` 的文档标题、`ui-cordis`、`ui-open-in-app`、`ui-settings-general`、`ui-agent-preset`、`ui-session`、`ui-workspace`）与第三方 `@nanmicoder/dsh-agent-teams` 都用的这一式。**注意 `mainView` 并不在 `SessionReferenceSourceMap` 的声明联合里**（那里只有 `controllerOperation` / `gateway`），它是运行时扩展——只能按运行时行为读，不能照 d.ts 穷举。

  修法分三层：`thunderCurrentId(snap)` 先扫 `byId` 的 mainView 保留、再退回旧的 `snap.current`（兼容旧宿主）；列表快照读不到时**保留旧 watch**，不把「读不到」当「用户离开了」；id 解析不到时不再当终态——`list.subscribe` 还在就等下一次 publish（`publishRetention()` 会把新的 `retainedBy` 推进列表，所以切换会话照样能驱动重绑），只有连 `list.subscribe` 都拿不到才走 120ms 有界重试。上一版的教训在这里再次成立：**主题不声明 `inject`，所以「服务/字段暂时拿不到」必须永远留在等待态，而不是终态。**

- **子开关在已挂载时失效。** 为避免每个流式 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 犯了两次）

会话头部的 agent-preset 徽章（「创造模式」）在 0.1.5-rc.2 上悄悄退回上游的灰色胶囊——没有任何报错，因为**匹配不到任何元素的选择器不会报错**。

旧规则 `[class$='_centerCol'] [class$='_header'] > [class*='_label']` 的后缀全对、哈希也没钉，前三条铁律条条满足，它错在**位置**：`>` 要求这个标签是 header 行的直系子节点，而真实 DOM 里它深在四层之下。第一次修复把 `>` 下移一层到 `_headerActions`——**还是错的**，因为 header 渲染插槽时，插槽会给**每一个条目再包一层没有 class 的 div**：

```
div.pI_x6G_centerCol
  > header.wSkVaW_header
      > div.wSkVaW_titleRow
        > div.wSkVaW_titleCluster
          > div.wSkVaW_headerActions
            > div                     ← 插槽的条目包裹层，className 为空字符串
              > span.SVAs4q_label     ← 徽章
                > svg.SVAs4q_icon
```

也就是说：**凡是靠 `>` 数层数的写法，都在赌上游渲染几层包裹；上游这一版加了两层。** 而包裹层本身没有 class，连"点它的名"这条退路都没有。

**为什么第一次"修完"看起来像没生效。** 在浏览器控制台里量了一次真实 DOM，得到的三个值把三种可能一次分清了：`HASNEW true`（我改的规则**已经到页面**，所以不是缓存/没刷新）、`ACCENT #fff500`（变量有值，不是变量问题）、`BG rgb(36, 38, 36)`（最终仍是上游灰底）＋祖先链里那个 `DIV.`。**一条"已经生效但什么都没匹配到"的规则，和一条"根本没送到浏览器"的规则，症状完全一样**；不先在页面里量这三个值，只会在"是不是缓存"和"选择器又写错了"之间来回猜——本次就是这样多花了一整轮。

**为什么整套验证都没发现。** `test/shoot.js` 的截图夹具先后把标签直接挂在 `.wSkVaW_header` 和 `.wSkVaW_headerActions` 底下，**两次都是照着当时的选择器搭的**；`test/preset-chip.test.js` 的夹具也一样漏了那层无 class 包裹层。于是选择器在夹具上命中、截图正常、测试全绿，而真实页面一个元素都没匹配到。这是本次最值得记的教训：**夹具一旦是为了"让选择器通过"而搭的，它就从验证退化成了同义反复。**

**最终修法：不再数层数，改用徽章自身的特征。**

```
[class$='_centerCol'] [class$='_header'] [class$='_headerActions'] [class*='_label']:has(> svg)
```

作用域停在 `_headerActions`，目标由徽章**自己**的性质确定——它是该插槽里唯一带图标的 `_label`。这条判据是有依据的：同一插槽里 jobs 渲染出的是 `_root` / `_trigger` 根，它那些 `_label` 出现在下拉菜单**深处**且**只有文字**；schedule 模块里根本没有 `_label` 这个局部名。**把裸后代匹配当反例做了变异验证**（去掉图标判据）——jobs 的行标签立刻被刷成强调色，说明这条判据不是装饰。

另外一处不是选择器问题，而是**越界**：主题在这里的职责是**配色**，不是几何。第一次修好选择器时，我顺手按旧注释里的"撑满动作行"意图把包裹层压平（`display:contents`）并让 `_headerActions` 增长（`flex:1 1 auto`），结果在真实头部上变成一条**横贯整个会话列的黄色长条**（实测 976px 的行里占了 923px），被原样反馈回来（"现在变成一个长条了"）。那套几何是 0.1.2-rc.1 时代为"徽章是 header 行直系子节点"写的，骨架变了以后照搬只会得到另一个坏结果。**结论：只改颜色，尺寸与命中区域沿上游**（高度、180px 上限、超长省略号全部保留）。这条也写进了代码注释，防止下次又被人按旧注释"还原"回去。

**变异验证 5 类，全部报错**：换回第一次修完的那版选择器（漏包裹层）→ 2 条断言红（背景正是线上看到的灰底）；把图标判据换成裸后代 → jobs 标签与"容器外 `_label`"两条反向断言红；重新加回压平 + 增长 → 两条"变成长条"断言红。

`test/preset-chip.test.js` 现在按**量出来的**骨架搭夹具（含那层无 class 包裹层，以及一个"根带 class、`_label` 藏在菜单里"的 jobs 式兄弟条目），断言**结果**而不是选择器文本，并且**同时守住两侧**：既不能没上色，也不能被拉成长条。

### 八类：子串选择器命中了**容器**（`_add` → 设置 › 模型的包裹层）

反馈：设置 › 模型中「添加模型提供商」被鼠标指上去时，整条虚线按钮变成实心信号黄，文字近乎不可见（截图像素：底色 `#fff500`、文字 `#f5f5f0`）。

根因不是"底色归主题、文字归应用"（一类），而是**规则命中了不该命中的元素**：输入区 `+` 的钩子是裸子串

```css
body[data-ds-dark-theme] [class*='_add']:not([class*='_addButton']) { color: var(--edge-accent) !important; }
body[data-ds-dark-theme] [class*='_add']:not([class*='_addButton']):hover { color:#000 !important; background: var(--edge-accent) !important; }
```

而"添加模型提供商"的 `<button>` 上游用**一个普通 `<div>` 包着**（0.1.7 bundle 里 `_3nPmjq_addActions`，按钮是 `_3nPmjq_addButton`）。`_addActions` 不含 `_addButton`，于是它**通过**了 `:not()`；它又是 `div`，`:disabled` 永不成立；**指针停在按钮上时包裹层同样处于 `:hover`**。实测（真实鼠标 hover，暗色·谷地黄）：

| 元素 | 属性 | 修复前 | 修复后 |
| --- | --- | --- | --- |
| `div._3nPmjq_addActions`（包裹层） | background | **rgb(255,245,0)** | rgba(0,0,0,0) |
| `button._3nPmjq_addButton`（按钮） | color / background | rgb(245,245,240) / rgba(255,245,0,.18) | 不变（上游值） |
| 合成后字形对比度 | — | **1.05:1**（夹具里量到 1.06:1） | 9.65:1 |

按钮自己的半透明淡底（α 0.18）盖在包裹层的实心黄上，等于没盖；虚线边框是**按钮自己**的，所以画在黄底之上——这正是截图的样子。`:not()` 挡不住它：**包裹层不是按钮**，而且每出现一个含 `_add` 的新容器，就得再补一条 `:not()`，一个反馈补一条。

顺着审计安装态 bundle 里所有含 `_add` 的类名，命中面是：

```
RlGAzG_add            输入区 + 按钮                     ← 目标
_3nPmjq_addActions    设置 › 模型 添加块包裹层 <div>    ← 本次反馈
_3nPmjq_addBlock / _addCard / _addModes / _addPanel / _addModelButton
_0SbxAa_add           deliverables 文件 diff 的"新增行"
LFNH1G_added / kuvljq_added / pFy1Ka_diffAdded / haSm5q_promptDiffLineadded
qWvkEq_address*       侧栏浏览器的地址栏
fO69Vq_addButton / _3nPmjq_addButton    （旧 `:not()` 排除的两个）
```

**修法：按第三条铁律加作用域**，与 `_arrow` 完全同款——把 `_add` 限定在输入区容器内：

```
:is([data-composer-seat], [class$='_composerSeat'], [class$='_composerHero']) [class*='_add']
```

作用域同时写上 `[data-composer-seat]`（上游给这个座位声明的 e2e 锚点）与既有的 `_composerSeat`/`_composerHero` 类钩子：`[class$=]` 只要上游给该元素追加第二个类就会**静默失效**，两个钩子取并集可以多扛一次重构。输入区内只有 `+` 一个类名含 `_add`，所以作用域不会误伤设置页、diff 行或地址栏。

**反向对照**（把 `client.js` 换回修复前那版跑同一脚本）——修复前如实报错：

| 用例 | 修复前 | 修复后 |
| --- | --- | --- |
| 谷地黄 · 暗色 · 模型页「添加模型提供商」hover | **1.06:1** + 外层容器 `rgb(255,244,0) ≈ accent` | 9.65:1，容器未被填充 |
| 武陵青 · 暗色 · 同上 | **1.75:1** | 11.16:1 |
| 输入区 `+` hover（暗色） | 18.31:1（本来正常） | 18.31:1（**未因收敛作用域而丢失**） |
| 输入区 `+` 常态墨色 | `#fff500` | `#fff500`（作用域仍命中） |

`test/hover-check.js` 现在同时守这三面（编辑按钮 / 模型页添加按钮及其容器 / 输入区 `+`），`test/selector-guard.test.js` 新增第三条断言：**任何裸的 `[class*='_add']` 都要报错**，把这条钩子钉在输入区作用域里。

> 亮色模式本来就不受影响（该规则是 `body[data-ds-dark-theme]` 限定），测试也如实只在暗色下报红——这本身就是"作用域写对了"的证据。

---

## 验证方法论

这些是**测试自己**踩过的坑。它们比被测代码的 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`）。

### 上游把菜单改成半透明材质后，「不透明」就成了主题的责任

0.2 起菜单不再自己上色。`MenuSurface` 原语在面板背后挂一层 `_material`，值是 `background: var(--dsw-menu-surface-fill)`（亮 `#f8f9fa94` 58% / 暗 `#43454a73` 45%）加 `backdrop-filter: var(--dsw-menu-backdrop-filter)`（默认 `blur(40px) saturate(150%)`）。在纯色底上模糊兜住了透明度；在本主题的等高线底上，合成值与页面底色只差几个 RGB 步进（暗色实测 `rgb(38,40,41)` vs 页面 `rgb(16,17,16)`，面板内部 904 种颜色），面板就读成「没有背景」——用户报的是「菜单的背景没了」。

这类失效最难定性：不报错、不写日志，主题里也没有任何「写错」的选择器——**变的是上游默认值的语义**。修法是认领令牌（`--dsw-menu-surface-fill` = 本主题的 `--dsw-alias-bg-overlay`，一个令牌同时覆盖 `--dsw-specific-menu` 与全部菜单），而不是给上游的哈希类名补一条选择器（那也过不了 `selector-guard`）。

反方向的坑同样要防：把令牌钉成页面底色（`--dsw-alias-bg-base`）也是「没有背景」，只是换成了看不见的实心块——应用自己在 macOS 上的菜单 backing 用的就是 `--dsw-alias-bg-base`，所以这不是假想。测试因此**同时**断言「不透明」与「与页面底色不同」，并用像素证明菜单矩形内没有任何东西透出来。

### JS 块注释里出现 `*/` 会让整个 bundle 静默不注册

给令牌写说明时顺手把平台默认值写成 CSS 风格的行内注释（`/* light, 58% alpha */`），其中的 `*/` 提前闭合了外层 JS 块注释，于是剩下的文字落进代码——`client.js` 抛 `SyntaxError: Unexpected token '--'`，整个模块无法注册（`__ModuleLoader__` 拿到的模块是 `undefined`），而主题只是「没生效」，页面看起来像主题关着。`check.js` 的 `vm.Script` 编译检查能在进程内抓到它，所以**任何一次 `client.js` 改动之后都要先跑 `node check.js`**，再去跑浏览器测试。

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

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

### 幂等标志会把「改了代码但页面没变」变成一次静默空转

`apply()` 顶部的 `window.__dshThemeEndfieldApplied` 布尔守卫是为重复挂载写的（boot loader + cordis 组合都会挂一次）。但 `dsh-client-modules` 的 `rebuilt()` / HMR 会把新 bundle 送进**已经跑过旧 build** 的长驻标签页，而这条路**不会走 dispose**：页面同时持有旧样式表和旧构建写下的布尔标志，于是新代码的 `apply()` 在第一行返回，旧 CSS 一直挂到标签页关闭。不报错、不写日志，用户看到的就是「弹窗还是没变」，而源码、门禁、`node check.js` 全是绿的。

修法是给标志配一个 build marker（`SHEET_MARKER`，同时以注释形式落在样式表首行）：同 build 重复挂载照旧短路，而「标志在、marker 缺失或不等」正是重建换装的签名，`apply()` 会先摘掉旧样式表再正常安装。marker 写在样式表里还有个副作用是好事——挂的是哪一版可以直接读出来：

```js
document.querySelector('style[data-plugin="dsh-theme-endfield"]').textContent.slice(0, 80)
```

`test/theme-sheet-refresh.test.js` 分两部分：源码侧钉住接线（每次置标志必须同时记 marker、dispose 必须一起释放、样式表首行注释必须等于 marker 字面量——`check.js` 禁止样式表里出现模板插值，两者只能靠这条断言同步）；真实 DOM 侧跑三段行为（全新挂载 = 一张表带 marker；同 build 重复 `apply()` 不换表；删掉 marker 后再 `apply()` 必须换表）。live 复核（`live-probe15.js`）在真实 GUI 上重放了长驻标签页：保留标志、删掉 marker、把已挂样式表改成旧内容，再执行服务器当场下发的 bundle，旧表被摘掉、新表带上 marker。

同源的投递事实一并记下：combo 的 `rev` 随内容变（本次 `b0a8114dfd13` → `94f9c40e7ee8`），所以**普通重载**就能拿到新代码；但**旧 rev 的 URL 是 `immutable, max-age=31536000`**，只要标签页还捧着旧 URL，浏览器永远不会自己去取新的——这也是「服务端明明是新代码、页面却是旧样式」的一半原因（另一半就是上面那个标志）。

### 形状结论只能来自像素，命中测试量不出 border-radius

「两端到底是半圆还是圆角矩形」这件事上，`document.elementFromPoint` 逐行扫左边界给出的是**假象**：同一套例程对准一个显式 `border-radius: 999px` 的纯 `div`，得到的 profile 与真按钮**逐字节相同**（`maxOffset=10`、`firstMaxIndex=35`）。该几何下命中测试根本分辨不出半径，第一版据此报出的「radius 6px / ROUNDED RECTANGLE」是测量假象。

可用的判据是截图逐行找最左深色像素（自己用 `zlib` 解码 PNG + 逐行反滤波即可，不必引依赖），而且**三条对照必须在同一张截图里**：真按钮 / `border-radius:999px` / `border-radius:10px`，同为 190×36。结果真按钮与 999px 只差前 3 行 1–4px（由 664.5 这种次像素对齐解释），而 10px 那条的直线段长达 32 设备行，明显分离。标定也吻合：r=18px 时「边界 <0.33px」的平台约 10 个设备行（实测 12），r=10px 时约 32 行（实测 32）。**两条教训**：形状类断言不要走命中测试；对照实验里被测变量之外的每一维都必须相同。

### 「渲染器不可靠」是个昂贵的结论，先查投递和挂载的是哪一版

余额胶囊曾经把「两端被渲染成 squircle」归因为**该渲染器不可靠**（`border-radius` 的 14px → 999px 写回被判定为不可信），于是改用两只 `radial-gradient` 圆盘加中间渐变带来画端点。径向渐变实现本身没问题（它不依赖半径命中，端点半径随高度补间也更好控），但**那个理由站不住**：本轮像素复核显示同一个渲染器把 999px 和渐变端点都渲染成了真体育场（轮廓一致，首行偏移恰好等于半径），而当时更可能的真相是 round 模式下主题的 blanket 直角化整段不生效、上游 `--dsw-radius-md` 的小圆角露了出来；再往后「已提交版本里根本没有那几条胶囊规则」又叠了一层。

归因顺序因此固定为：**① 页面挂的是哪一版样式表**（marker / 与工作区逐字节比对）→ **② 投递层**（combo `rev` / 缓存头 / 是否走了 dispose）→ **③ 才轮到渲染器**。前两步都能拿到硬证据，而「渲染器不可靠」既不能证伪，又会劝退后来人对 `border-radius` 的使用。


### 「渲染器不可靠」后来真的抓到了：是 app 根上的 `corner-shape: superellipse(1.5)`

上一节的结论（「同一个渲染器把 999px 渲染成了真体育场」）只在**当时的渲染器**上成立。2026-10-06 在用户的浏览器（Chrome 154）上复核「确认启用完全权限？」胶囊时拿到了完整的因果链，两个渲染器都对，分歧在于 **CSS `corner-shape`（Chrome 139+ 实现）**：

- DSH 自带的样式表里有一段 `@supports (corner-shape:superellipse(1.5)) { :root { --dsw-corner-shape: superellipse(1.5); } *, ::before, ::after { corner-shape: var(--dsw-corner-shape); } }` —— **全元素默认方圆角**，然后 app 对自己的胶囊/开关/圆点逐个 `corner-shape: round` 找补；
- 所以 computed `border-radius: 999px`（钳到 h/2=18px）**没有任何异常**，但画出来是 superellipse 曲线：36px 按钮的圆等效端头只有 ~14px，逐行 inset 与用户截图逐点吻合（9,6,5,4,3,2）；
- 同页对照实验：`999px`、`18px`、`9999px` 三个纯 div 画出来**逐字节相同**（拟合 r≈12.5），只有 `14px` 分离——说明钳制正确、是曲线形状被换了；补上 `corner-shape: round` 后同一按钮拟合 r=17.8，恢复真体育场；
- 桌面端的 Chromium 若未实现 `corner-shape`，`@supports` 整段跳过， stadium 依旧为真——这就是两轮测量「都对」而结论相反的原因。

主题侧的修法（本次 `corner-shape: round` 一批）：审批面板与确认框的胶囊对、Cordis 开关/动作按钮、50% 圆形化通道（avatar/spinner/dot），以及余额胶囊根规则（badge/ring/clock/brand-mark 靠继承）——**每条要「半圆/正圆」的规则必须在同一规则里声明 `corner-shape: round`**，形状只在与非零半径同元素时才有意义。在不支持该属性的渲染器上这些声明会被当作未知属性丢弃，零成本。回归守卫：`test/corner-shape.test.js`（源码级：999px/50% 规则体里必须带 `corner-shape: round`，余额胶囊家族按继承豁免）。

余额胶囊「用渐变画端点」的方案因此依然成立但理由更新：它不是绕开「不可靠的渲染器」，而是绕开「宿主把圆角形状全局改成了 superellipse」这一**按规范实现的特性**。

### 确认框第二轮：白条两端全宽、上下遮罩、胶囊骑在白条下沿上（2026-10-06）

用户对着 Endfield 参考图复核确认框，落定三条事实：白条只横向通宽；上下是遮罩不是全屏；**胶囊对骑在白条的下沿上——上半在白条里、下半落在遮罩上**；且**深色模式下白条也得是白的**。

实现上的三个关键点，都来自对真实 DOM/CSS 的核对：

- **遮罩的真实来源**：Modal 的变暗不是 `_mask_o6lrb_18` 元素本身（它只出 `backdrop-filter`），而是它的 `::after` 画 `var(--dsw-alias-bg-mask-1)`（实测 `#00000080`）。胶囊下半的「落在遮罩上」因此不需要主题画任何东西——对话框盒子下缘以下的部分只要**透明**，遮罩自然透出来。
- **骑缝的做法**：白条是背景不是伪元素——`padding-bottom: 0`（对话框盒子终止在胶囊下缘）+ `background: linear-gradient(#f5f5f0,#f5f5f0) 0 0 / 100% calc(100% - 18px) no-repeat`（18px = 36px 胶囊的半高）。用背景而不用 `z-index:-1` 伪元素，避开「绝对定位伪元素盖住流内内容」和「负 z-index 掉进祖先层叠上下文」两个坑。投影随之换成 `drop-shadow`：`box-shadow` 包住整个盒子会在悬空的下半截下面浮出一圈假影，`drop-shadow` 跟随**绘制出的剪影**（白条边缘 + 胶囊形状），正是参考图的画法。胶囊半高是从按钮实测来的（padding 9px×2 + 20px 圆盘 = 36px），18px 在 live 上验证 bandBottom 与按钮中点 delta = 0。
- **深色模式白底黑字**：白条直接钉死字面量 `#f5f5f0`（Endfield 白；浅色 paper `#e8e8e2` 略灰，两档统一取参考图的亮白）。墨色靠**在对话框作用域内重声明 label tokens**（`body[data-ds-dark-theme] [role='dialog'][class*='_confirmation'] { --dsw-alias-label-primary: #101110; … }`）——应用文案全是 var() 引用，作用域内换 token 一次翻全所有引用，而自带颜色的元素（危险红警告行）不受影响；标题/正文/关闭按钮再补显式 `!important` 兜底。

上一轮的灰白格纹角标（`--endfield-check` + 对话框 `::before/::after`）按用户要求整体移除，对话框的伪元素随之空闲。

### 确认框第三轮：深色遮罩压到黑、禁用胶囊按部件减淡（2026-10-06）

用户复核深色模式，报了两条：遮罩发灰；未勾选时确认胶囊「变灰而不是半透明」。两条的根都在**半透明叠了两种底**上：

- **遮罩灰**：app 的遮罩 token 在深色档是黑 50%（浅色 24%），叠在 `#101110` 页面上只把内容压到中灰——实测遮罩下最亮的文字还有 rgb(118,118,116)，整片背景读作灰蒙蒙而不是压黑。修法是把确认框自己 root 里的 mask `::after` 钉到 `rgba(0,0,0,0.8)`，实测最亮内容掉到 rgb(47)，白条重新「浮」在近黑上。mask 元素本身是 hash 类名，无 hash 的抓手是「确认框 root 的直接 `aria-hidden` 子元素」（root 的孩子只有 mask + dialog）。作用域挂在 `:has(> [role='dialog'][class*='_confirmation'])` 上，其他弹窗与浅色档（保持原生 24%）不受影响。
- **禁用胶囊灰**：app 的禁用画法是**元素级 `opacity: 0.4`**，主题原来也跟了一个 0.45——但这只胶囊骑在白条和黑遮罩两种底上，半透明黄叠白变浅卡其、叠黑变暗橄榄，一只按钮两截脏色，读作「灰」。修法是禁用态**整体不透明、按部件分别减淡**：底色烘死成「半透明黄叠白条」的等价色 `color-mix(in srgb, var(--edge-accent) 42%, #f5f5f0)`（上半截观感与旧 45% 透明完全一致），文字降到 `rgba(16,17,16,0.45)`，keyline 和 ◎ 盘用伪元素**自己的** `opacity: 0.35` 减淡，阴影去掉。两截从此同色，黄也不再变灰。
