dsh-theme-endfield 0.0.0-stage → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1147 @@
1
+ # 工程笔记
2
+
3
+ 实现决策与它们背后的实测数据。本文回答「为什么是这样写的」,不重复功能说明(见 [features.md](features.md))或色值取法(见 [design-language.md](design-language.md))。
4
+
5
+ 排在前面的几条是**结构性约束**——不了解就会重新踩进去。
6
+
7
+ ---
8
+
9
+ ## 目录
10
+
11
+ - [变量必须声明在 body 而不是 :root](#变量必须声明在-body-而不是-root)
12
+ - [字体令牌是应用的公共接口,不是主题的开关](#字体令牌是应用的公共接口不是主题的开关)
13
+ - [源码是真源,产物是拼出来的](#源码是真源产物是拼出来的)
14
+ - [样式表是一整个模板字符串](#样式表是一整个模板字符串)
15
+ - [层叠与挂载点](#层叠与挂载点)
16
+ - [等高线背景](#等高线背景)
17
+ - [输出滚动动画:为什么不能改 scrollTop 的写后回读语义](#输出滚动动画为什么不能改-scrolltop-的写后回读语义)
18
+ - [布局重排:展开思考块为什么抽搐](#布局重排展开思考块为什么抽搐)
19
+ - [启动加载屏](#启动加载屏)
20
+ - [设置页国际化](#设置页国际化)
21
+ - [峰谷定价窗口与法定节假日](#峰谷定价窗口与法定节假日)
22
+ - [为什么设置必须落在 Host 的设置命名空间(不再用 localStorage)](#为什么设置必须落在-host-的设置命名空间不再用-localstorage)
23
+ - [已修问题归档](#已修问题归档)
24
+ - [验证方法论](#验证方法论)
25
+
26
+ ---
27
+
28
+ ## 为什么设置必须落在 Host 的设置命名空间(不再用 localStorage)
29
+
30
+ 最初这些开关用浏览器 `localStorage` 持久化。**这对 DSH Desktop 是错的**:浏览器存储的作用域是「协议 + 主机 + 端口」的同源维度(origin),而 DSH Desktop 每次启动都在 `127.0.0.1` 上绑定一个**随机临时端口**。端口一变,origin 就变,于是上次保存的设置永远读不到,表现就是「**重启后设置恢复默认**」;localStorage 也因此从来没真正持久过跨会话。
31
+
32
+ ### 选哪条存储
33
+
34
+ 原生浏览器侧的**三个候选**都治标不治本,因为它们的键空间同样挂在 origin 上:
35
+
36
+ - `localStorage`——origin 维度,random port 一换就清空(本 bug 的根源)。
37
+ - `sessionStorage`——更糟,连同一会话的标签页都不共享。
38
+ - IndexedDB——仍按 origin 分库,与 Desktop 随机端口的组合同样无法根治。
39
+
40
+ 正解是 DSH 自己的用户设置服务:**解析与落盘都由 Host 决定**,与页面 origin、端口完全无关。因此 **dsh web(浏览器、固定/默认端口)** 和 **DSH Desktop(随机临时端口)** 走同一条路径:它们都是 `127.0.0.1` loopback 页面,DSH 把连接解析成 `host` 持久化模式,值写进 profile 落盘位置,换端口也能读回。
41
+
42
+ ### DSH 0.1.7-rc.1 换掉了整套 settings API(v1.1.0 已跟进)
43
+
44
+ 0.1.7-rc.1 移除了 `ctx.settings.register` / `settings.get` / `settings.watch`、`@deepseek-ai/dsh-settings-file` 这个包,以及浏览器侧 `ctx.settingsScope` 服务:
45
+
46
+ | | ≤ 0.1.5-rc.2(旧) | 0.1.7-rc.1(新) |
47
+ | --- | --- | --- |
48
+ | Host 声明 | `ctx.settings.register('dsh-theme-endfield', schema)` | 模块导出 schemastery `Config`,每个字段 `.volatile()` |
49
+ | 命名空间 | 插件自定义字符串 `dsh-theme-endfield` | **profile entry id** = 本包 `cordis.patch.yml` 中该行的 `id: theme-endfield`(`index.js` 的 `SETTINGS_ENTRY`) |
50
+ | 落盘 | `@deepseek-ai/dsh-settings-file` → `<dshHome>/settings.yaml` | DSH 设置服务 → profile patch `<profile>/cordis.patch.yml`(`ctx.configEditor` 加文件锁 + 原子写 + 热重载) |
51
+ | Client 读写 | `ctx.settingsScope` 的 `bind({ namespace, decode })` | `ctx.configForms.get(<entryId>)` |
52
+ | `set()` 返回 | 无(fire-and-forget) | `Promise<boolean>`;`false` = Host 拒绝或**跳过**(非 loopback 页面是 memory 模式,永不下盘) |
53
+
54
+ 关键差异,逐条都有对应的防护:
55
+
56
+ - **只有 `.volatile()` 字段可编辑。** `ctx.settings` 只把 volatile 路径投影成表单,写非 volatile 路径会直接报 `Config field "x" is not volatile`;整份 schema 一个 volatile 字段都没有时该条目**根本不出现**在设置里。所以 `index.js` 的 `Config` **31 个字段全部 `.volatile()`**,`test/settings-config-forms.test.js` 会逐字段断言这一点(漏一个就是「设置保存不了」的现代写法)。
57
+ - **两个 schemastery 必须区分。** DSH 同时装了带 `.volatile()` 的 `@deepseek-ai/schemastery`(3.18.4)和不带它的旧 `schemastery`(3.18.0)。`index.js` 的 `loadSchemastery(true)` 会逐个候选检查 `.volatile` 是否真的存在,找不到就返回 `undefined`(本插件退化成无需配置,而不是挂一个假表单)。
58
+ - **命名空间是 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 的编辑都会补写到新拼写上。
59
+ - **`settings.yaml` 已废弃。** DSH 启动时把已有的 `settings.yaml` 改名为 `settings.yaml.imported`,并只迁移 `LEGACY_SECTION_ENTRIES` 里那几段。旧的主题段落名 `dsh-theme-endfield` 不等于 entry id `theme-endfield`,因此**不在迁移之列**,需要用户在设置页重设一次;这是 DSH 侧的行为,不是本插件丢的。旧值仍留在 `settings.yaml.imported` 里可手工对照。
60
+ - **旧宿主仍然可用。** client 在找不到 `configForms` 时回落到 `ctx.settingsScope`(`binder.bind({ namespace: 'dsh-theme-endfield', decode })`),Host 在 `settings.register` 存在时才注册旧命名空间。两代 transport 的快照字段同名(`status` / `value` / `writable` / `mode`),所以除了「取句柄」和「写返回」之外整条存储逻辑是同一份。
61
+
62
+ ### Host ↔ Client 数据流
63
+
64
+ 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 改动。
65
+ 2. **写**(浏览器 `client.js`):设置面板每个 toggle 调用内部 `prefsSet(field, value)` → 只有当快照是 **durably served**(`mode:'host' && status:'ready' && writable`)时才调用 transport 的 `set(field, value)`,Host 收到后原子写盘。只凭 `writable` 判写是一个坑:host 模式的快照即便本命名空间**尚未被 served** 也会返回 `writable:true` 与 `status:'unavailable'`(`commit radius = round … status= unavailable` 就是这么打出来的)——旧代码照写不误、清了脏标记但什么都没落盘,刷新即丢。现在这种写被**拦下并标脏**(页面内仍生效),等快照在后续 ready 回相(Host 文档提交、镜像重载)时由 subscription 自动补写;`configForms.set()` 明确返回 `false`(Host 拒绝/跳过)时同样重新标脏等待下次回相,而不是假装写成功。
66
+ 3. **读 / 生效**:transport 的 `getSnapshot().value` 就是已由 schema 校验并合并默认值的整段(client 再经 `prefsResolveSection()` 归一化到 31 个声明字段);主题层的 `isEnabled()` 与其它 getter 每次调用都 `prefsGet(field)` 现读本段,天然随值变化。
67
+ 4. **订阅同步**:`form.subscribe(...)` / `scope.subscribe(...)` 在每次落盘/镜像变化时唤醒,client 再跑一遍 `reconcileFromPrefs()`,把主题开关(enabled → mount/unmount token+样式表)、圆角/配色 class、水印、等高线、雷霆大字重新对齐。这样同 profile 里**另一个窗口/设备**编辑落盘文件(或本轮写入被 Host 回相确认)都不需要刷新即可热生效。订阅返回的 disposer 现在会被保存并在 run 拆除时调用:`ConfigForm` 是 provider 拥有、跨插件共享的实例,漏掉它会把这一个监听器泄漏给同页面的下一次 run。
68
+ 5. **启动恢复**:`apply()` 早于 transport 就绪时,读 schema 默认值(内存镜像),一旦 `status:'ready'` 的第一个真值镜像到达就切换到持久值——即使 Desktop 在随机端口上启动,也能立刻恢复到上次的设置。
69
+
70
+ 防御性:若 ctx 里既没有 `configForms` 也没有 `settingsScope`(纯独立页/测试桩),则回落内存默认值 + 会话内本地覆盖,写不过盘但**不引入 localStorage**,也绝不 throw。
71
+
72
+ ### 加载期解析 schemastery:dev-link 安装必须显式去找(v1.1.1 起加固,v1.1.2 加自检报告,v1.1.3 改为结构化扫描)
73
+
74
+ - **为什么只能在顶层解析。** loader 导入模块后立刻 `runtime.Config = plugin.Config`(`@deepseek-ai/cordis-plugin-loader` 的 `Entry._init` → `registry.plugin`),这发生在任何插件体运行之前,`Config` 因此必须是模块求值期的静态导出——不能延迟到 `apply()` 里再做(那里能拿到 `ctx`,但已经太晚了)。
75
+ - **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 链。
76
+ - **怎么找。** `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 它,所以只要它在本进程里,就能按对象身份复用)。
77
+ **结构化扫描是这里的关键**:它不依赖 `DSH_HOME`、`homedir()` 或 cwd 取到任何特定值——只要进程还能说出自己从哪儿启动,就能顺着祖先目录找到 `<profile>/node_modules`。v1.1.1/1.1.2 只按环境变量拼路径,一旦启动器把 `DSH_HOME` 设成 profile 自身(或设成别的值)就会整条落空,这正是「Host 侧依旧 `absent`」的成因。
78
+ - **失败长什么样。** 找不到时 `Config` 是 `undefined`:`Config.listConfigs`(cordis_inspect 的 host provider)对该 entry 报 **`absent`**,DSH 不为它投影任何表单,client 于是永远停在 session-local —— **面板照常打开、开关照常能拨、刷新即复位**。这条症状与主题其它功能是否正常无关,所以极易被误判成「主题坏了」或「存储又变了」。注意 `absent` 只表示「没有 Config」;有 Config 但字段不 volatile 会以 `unsupported`/整条不出现的形式表现,含义不同。
79
+ - **不再静默。** Host 侧 `Config === undefined` 时 `apply()` 打 `ctx.logger.warn`(成功则 `debug` 一行写明从哪个根解析到);client 侧把 Host 真正 served 的命名空间列表并进既有的 `dbg` 诊断(`hostServes=`),`settle watch` 放弃时也明确打一行(`boundNs=` / `status=` / `hostServes=`)。有这一行就能立刻区分「Host 半没导出 Config」与「client 绑错了 entry 拼写」——两者现象完全一样。
80
+ - **自检报告(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 侧已正常、问题在别处。它只是诊断,不参与任何运行逻辑。
81
+ - **改 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`,说明已修好。
82
+ - **`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 代码根本没被跑到。
83
+ **判据**:在普通 node 里 `require()` 这个包得到 `Config = true`,而宿主进程里 `Config.listConfigs` 仍报 `absent`,且重启后 `theme-endfield-diagnostic.json` **不出现** —— 三点同时成立就说明是这里,而不是路径解析。修法是把 `Config` 写成导出字面量的**静态属性**(值为 `undefined` 时表示「本机确实没有 schemastery,无需配置」,是合法状态而非法 schema)。
84
+
85
+ ### 找不到 `.volatile()` 也必须能存(v1.1.5)
86
+
87
+ v1.1.4 之前的判据是「**必须**找到带 `.volatile()` 的 schemastery,否则不导出 `Config`」。这条判据把一个**可降级**的情况升级成了**完全不可用**:没有 `Config` 不是「主题少了个功能」,而是 DSH 不为该 entry 投影任何表单,于是**用户拨的每一个开关都只活在页面里,刷新即丢**——正是本文件反复记录的那个症状。
88
+
89
+ 实测成因(本机 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`。
90
+
91
+ 关键事实是:**`.volatile()` 就是 `this.extra('volatile', true)`**(3.18.4 源码里只有这一行),而 `.extra()` 在 3.18.0 / 3.18.1 里都有。DSH 侧读的是**投影结果**(`meta.volatile`),不是那个方法本身,所以标记可以在没有该方法时**合成**:
92
+
93
+ | 能力 | 3.18.4(DSH 自带) | 无 scope 3.18.0 | 3.18.1 |
94
+ | --- | --- | --- | --- |
95
+ | `.extra(key, value)` | ✓ | ✓ | ✓ |
96
+ | `.volatile()` | ✓ | ✗ | ✗ |
97
+ | 能否加载(本机 web profile) | **✗** | ✓ | ✓ |
98
+
99
+ 现在 `selectBuilder()` 分两轮挑:先要 `native`(真的 `.volatile()`,保真度最高),没有才退到只有 `.extra()` 的副本并**合成标记**(`mode: 'synthesized'`);两者都没有才不导出 `Config`。两种模式下每个字段最终都带 `meta.volatile === true`,DSH 都投影出可编辑表单。
100
+
101
+ - **判据写进自检报告**:新增 `schemaMode` 记录走了哪条路;`resolution` 每一行现在除 `resolved` / `error` 外还带 `loaded` / `loadError` / `volatile` / `marker`。「解析得到但加载失败」以前在报告里读起来像自相矛盾,现在是一行结论。
102
+ - **不再有「默认跳过」的断言。** `settings-config-forms.test.js` 的 Host 侧断言在拿不到 schemastery 时**整段跳过**,而拿不到 schemastery 恰恰是它要守的那个场景——于是在唯一要紧的环境里它什么都没验。新的 `settings-config-fallback.test.js` 不依赖本机 schemastery:它把**显式 builder**(含「只有 `.extra()`」这一形状)交给 `buildSchemaWith()`,逐字段断言默认值与 `meta.volatile`,并断言选择顺序(native 优先、`.extra()` 兜底、两者皆无则不导出)。
103
+
104
+ ### 宿主入口为什么必须是 ESM:`require(esm)` 的启动竞态
105
+
106
+ v1.1.5 解决的是「副本没有 `.volatile()`」,这一段解决的是**同一句 `require` 本身会失败**——两者症状一模一样(`Config === undefined`、无表单、刷新复位),但成因完全不同,且后者在**全新启动、只装本插件**的 profile 上同样复现。
107
+
108
+ **抓现行。** 在真实 `dsh web` 启动里放一个探针,于模块求值期同步 `require('@deepseek-ai/schemastery')`,拿到的不是 `MODULE_NOT_FOUND`,而是 Node 的互操作竞态:
109
+
110
+ ```
111
+ ERR_REQUIRE_ESM_RACE_CONDITION:
112
+ Cannot require() ES Module <profile>/node_modules/@deepseek-ai/cosmokit/lib/index.js
113
+ because it is not yet fully loaded.
114
+ ```
115
+
116
+ **机制。** `@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`。
117
+
118
+ - 自检报告里那个看似自相矛盾的现象由此解释:`resolution` 段是在 `apply()` 里跑的,那时 cosmokit 早已加载完,**同一句 require 就成功了**——所以同一份报告里 `configBuilt: false` 与 `loaded: true, volatile: true` 能并存。`cachedSchemasteryModules: []` 才是真正的证据:求值期那次 require 从来没成功过。
119
+ - **与第三方插件数量无关**:`dsh-base` / `dsh-web-app` 自己就 import cosmokit,所以把 profile 精简到只剩本插件也照样中招。缩减插件不是解法。
120
+ - 与「dev-link 解析不到」也无关:那条路径下 `require.resolve` 会失败并留下 `error: MODULE_NOT_FOUND`;这里是**解析得到、require 抛错**。
121
+
122
+ **为什么不能留在 CJS 里修。** `require()` 没有等待能力——必须在模块求值期同步拿到 builder(见上文 `Entry._init` 那条),而 cosmokit 的 job 还在飞就无法等待。ESM 加载器则**会排队**:同一句 `import` 会等 cosmokit 结束。
123
+
124
+ **做法。** 给包加一个 ESM 入口 `index.mjs`,`package.json` 的 `main` 指向它,`exports["."]` 写成条件导出:
125
+
126
+ | 条件 | 文件 | 谁走这条路 |
127
+ | --- | --- | --- |
128
+ | `import` | `index.mjs` | DSH 的 loader(Node 内部 ESM loader)、`--dump-config-schema`、dshmarket 的热挂载 |
129
+ | `require` | `index.js` | 测试、以及任何直接 `require` 本包的消费者 |
130
+
131
+ `index.mjs` 只做两件事:先经 ESM 加载器预热 schemastery(`@deepseek-ai/schemastery`,失败退到无 scope 的 `schemastery`,再失败就静默放行),再 `await import('./index.js')` 并原样转出。**`index.js` 一行逻辑都不用改**:它的扫描此时必然成功,自检报告也不会再写。两个约束写死在实现里:
132
+
133
+ - **必须带 `default` 导出**:cordis 的 `unwrapExports()` 是 `exports.default ?? exports`,DSH 挂载的是 `default`,`Config` 必须挂在它上面。
134
+ - **预热必须 best-effort**:宿主上一份 schemastery 都没有时要照旧降级成文档里的 no-op(由 `index.js` 报告),不能连 entry 都加载不了。
135
+
136
+ **同一分支顺带修掉的第二个缺陷:负缓存。** `SCHEMA_SCAN_CACHE` 是进程级、以 registry Symbol 为键、**永不失效**,而且连失败结果也缓存。于是一次竞态失败被永久记成「本进程没有 schemastery」:插件重新挂载(模块重新求值)也救不回来——这正是「重启后的热挂载同样 `absent`」的原因。现在只有**可用**的扫描结果才入缓存(`cacheableScan`),`take()` 也拒收建不出 schema 的半求值模块(`exports` 还是 `{}` 时是 truthy,否则会被当成候选记下来)。实测:竞态后同进程重新求值仍失败 → 改为「可用才缓存」后,重新求值即可自愈。
137
+
138
+ **验证(真实启动内的对照)。** 同一次 `dsh web` 启动里同时观察两条路径:
139
+
140
+ | 探针动作 | 结果 |
141
+ | --- | --- |
142
+ | 求值期同步 `require('@deepseek-ai/schemastery')` | ❌ `ERR_REQUIRE_ESM_RACE_CONDITION` |
143
+ | `import('@deepseek-ai/schemastery')`(ESM) | ✅ 成功,`.volatile()` 可用 |
144
+ | 预热**之后**再 `require` 本插件 `index.js` | ✅ `Config` 建出(`nativeMarker: true`) |
145
+
146
+ `test/host-esm-entry.test.js` 把入口的转出面(`default` 上的 `Config`/`apply`/`name`)与「没有 schemastery 时仍能加载」的降级路径固化成断言;真正的竞态只能在真实宿主启动里观察,所以那段对照留在本节作为判据。
147
+
148
+ ### DSH 0.2.0-rc.2 上失效的三处应用侧钩子(v1.1.6 已跟进)
149
+
150
+ 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 探针):
151
+
152
+ | 钩子 | 0.1.x | 0.2 | 旧写法的后果(实测) |
153
+ | --- | --- | --- | --- |
154
+ | 回合状态标签 | `@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)`,标签完全没有主题色 |
155
+ | 右侧栏列 | `_detailsCol`,不透明底色在其内部 `*_root` 上 | `_rightbarCol`,不透明底色**在列元素本身** | 选择器与目标元素**同时**变了:右侧栏一打开就整块盖住等高线图层 |
156
+ | `_heroGlow` | 0.1.2-rc.1 起已无该模块 | 0.2 仍无 | 规则保持 self-healing 空钩子,不改行为 |
157
+
158
+ 因此回合状态标签的换色**从 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` 与两个令牌名。
159
+
160
+ 插件卡片的展示文案在 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` 从来不是包名,写在这里本来就是无效项。
161
+
162
+ ### 存储字段名必须来自 schema,不能用「去掉前缀」推出来(issue #15)
163
+
164
+ **曾经的写法(错误)**:客户端要往命名空间里写一个字段时,把 UI 键的前缀切掉当作字段名:
165
+
166
+ ```js
167
+ const field = PREFS_KEY_TO_FIELD[rawKey] || rawKey.slice(PREFS_NS.length + 1)
168
+ // 'dsh-theme-endfield-thunder-anim' -> 'thunder-anim'
169
+ ```
170
+
171
+ 而 Host 端的 schema(`index.js` 的 `FIELD_DEFAULTS`)声明的是 **camelCase**:`thunderAnim`。**只有单个词的字段两者才碰巧相同**,一切复合字段都不同:
172
+
173
+ | UI 键(客户端) | 客户端推出的字段 | schema 声明的字段 |
174
+ | --- | --- | --- |
175
+ | `dsh-theme-endfield-thunder-anim` | `thunder-anim` ✗ | `thunderAnim` |
176
+ | `dsh-theme-endfield-contour-anim` | `contour-anim` ✗ | `contourAnim` |
177
+ | `dsh-theme-endfield-contour-fps` | `contour-fps` ✗ | `contourFps` |
178
+ | `dsh-theme-endfield-contour-speed` | `contour-speed` ✗ | `contourSpeed` |
179
+ | `dsh-theme-endfield-contour-scroll-pause` | `contour-scroll-pause` ✗ | `contourScrollPause` |
180
+ | `dsh-theme-endfield-watermark-persist` | `watermark-persist` ✗ | `watermarkPersist` |
181
+
182
+ **为什么表现是「开关刷新后复位」,而且是静默的。** schemastery 只校验它声明过的字段,**多出来的键原样带过去**。所以这一写并不是「没写进去」:`settings.yaml` 里真的多了一行 `thunder-anim: "1"`,而 `thunderAnim` 仍是默认值 `'0'`。于是:
183
+
184
+ - 页面内开关正常工作(`prefsLocal` 里有本会话的值);
185
+ - 刷新后 schema 合并出 `thunderAnim: '0'`,客户端只复制**声明字段**,那行 `thunder-anim` 谁都不读;
186
+ - 又一次回到默认值。用户观察到的正是「打开后刷新又变回关闭」。
187
+
188
+ 配套的坑是**测试桩复述了同一个错误**:`test/fixtures/settings-scope.js` 当时也用 `slice` 去前缀,于是「客户端读 `contour-fps`」与「测试桩写 `contour-fps`」两边对上,全绿——而生产环境里那个名字根本没人认。**测试桩必须按生产 schema 的形状(camelCase 字段名)造数据,否则它证明的是自己跟自己的约定。**
189
+
190
+ 现在的写法:`PREFS_KEY_TO_FIELD` 把每个键**显式**映射到字段名,读写两侧都走 `prefsFieldOf()`,所以「读的字段」与「写的字段」不可能再是两条路径。`test/settings-namespace.test.js` 同时对着三份东西交叉验证:`client.js` 的表、Host 的 `FIELD_DEFAULTS`、以及设置面板真实渲染出来的每个开关。
191
+
192
+ ### 已经写坏的存档:把旧拼写里的值搬回声明字段
193
+
194
+ 用户的 `settings.yaml` 里已经躺着 `thunder-anim` / `contour-anim` / … 这些**未声明键**。只修字段名的话,这些值会被忽略、字段回到默认——对老用户来说还是「又复位了一次」。所以 `prefsMigrateLegacy()` 在拿到第一段 served section 时把这些值**重新提交到声明字段上**(走的是 prefsSet,所以未就绪时照样会被 held + 补写)。判断「声明字段是不是用户自己的值」只有**一个**可靠信号:
195
+
196
+ - 声明字段缺失或等于出厂默认 → 没人在这上面留下选择,旧拼写的键就是唯一的痕迹,**采纳**;
197
+ - 声明字段是别的值 → 那是会正确写盘的版本存下来的,**让位**,旧键不得覆盖它。
198
+
199
+ 不能用 `hasOwnProperty` 判断:拿到的 section 是 schema **已经合并默认值**之后的视图,每个声明字段都在,分不出「存过这个默认值」和「schema 补的默认值」。
200
+
201
+ `test/settings-namespace.test.js` 覆盖了这三种情形,并对四类改坏方式做过反向对照(见 [testing.md](testing.md))。
202
+
203
+ ### 读路径:本会话的编辑排在已取回的 section 之上
204
+
205
+ `prefsGetValue()` 曾经**优先**返回已取回的 section(`prefsFieldValue`),而 `prefsSet()` 只写 `prefsLocal`。宿主回相是异步的,所以「点一下开关,面板显示新状态、主题却还是旧状态」在回相到达前是真实存在的。现在只把 `prefsLocalEdited`(本会话真正被用户改过的字段)叠加在最上层:其余字段仍然以宿主为准,不会把服务端刚给的值盖掉。
206
+
207
+ ### 脏标记只能由「写成功」或「宿主已有该值」清除
208
+
209
+ `prefsReplayDirty()` 曾在本地值**等于出厂默认**时就直接清掉脏标记。若宿主仍存着非默认值,用户「改回默认」这一操作就永远不会落盘,下次刷新旧的(非默认)值又回来了。同步地,本地值等于**宿主当前值**也不能直接清:宿主视图可能还是旧的,而用户刚刚把它改成同一个值——那也是一次真实编辑。现在只有两种情况清标记:写出去了,或者宿主取回的 section 确实已经持有该值。
210
+
211
+ ## 变量必须声明在 body 而不是 :root
212
+
213
+ **应用把主题令牌写成 `<body>` 的行内样式**(`dsh-client-ui-layout` 对每个令牌调用 `body.style.setProperty`)。
214
+
215
+ 推论一:任何**引用 `--dsw-*` 令牌**的自定义属性都必须声明在 `body` 上。`:root` 是 `body` 的父元素,在那里替换一个不存在的变量属于 *guaranteed-invalid*,计算值为**空**。
216
+
217
+ 实测(真实浏览器中逐个读取计算值):
218
+
219
+ | 变量 | 声明位置 | 计算值 |
220
+ | --- | --- | --- |
221
+ | `--edge-signal` | `:root`(不引用令牌) | `#fff500` ✓ |
222
+ | `--edge-line` / `--edge-paper` / `--edge-soft` | `:root`(引用令牌) | **`""` 空** ✗ |
223
+ | 同上三者 | `body` | `#d8d9d5` / `#e8e8e2` / `#dcddd6` ✓ |
224
+
225
+ 这曾经是一个真实缺陷:`scrollbar-color: var(--edge-line) transparent` 里的变量为空,整条声明被丢弃,主题滚动条一直没生效(实测 `scrollbar-color` 计算为 `auto`)。现已全部移到 `body`,并由 `check.js` 做**结构化检查**(遍历每个 `:root` 块,查找引用 `--dsw-*` 的 `--edge-*` 声明)。
226
+
227
+ 推论二:**令牌值自身可以是 `var()` 引用**。主题把 `--dsw-alias-brand-primary` 的暗色值声明为 `var(--edge-accent)`,而调色板变量也在 `body` 上,所以引用在同一元素解析,并随 class 翻转自动重解析——这是「换配色不需要重新注册令牌层」的全部原因。
228
+
229
+ ---
230
+
231
+ ## 字体令牌是应用的公共接口,不是主题的开关
232
+
233
+ **曾经的写法(错误)**:主题在自己的样式表顶部声明
234
+
235
+ ```css
236
+ :root {
237
+ --dsw-font-family: Arial, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
238
+ --ds-font-family-code: 'SF Mono', …;
239
+ }
240
+ ```
241
+
242
+ **为什么错**:`--dsw-font-family` 不是主题私有变量,而是**应用的 UI 根字体令牌**——`dsh-web-frontend` 里就是这一条:
243
+
244
+ ```css
245
+ body{font-family:var( --dsw-font-family, -apple-system, … )}
246
+ ```
247
+
248
+ 而应用把该令牌声明在 `:root`。主题再声明一次就是在**同一个元素上后写覆盖**,于是整个界面根字体变成 Arial。任何注入到应用根节点、写着 `font-family:inherit` 的第三方挂件(如 DeepSeek-Balance-Whale-Widget)都会**继承**这个字体,余额数字原有的字形随之丢失。实测(本机真实浏览器):挂件计算字体是主题的 Arial,而应用自己的字体栈已经消失。
249
+
250
+ 同类漏出还有挂在 `body` 上的全局文本属性:`font-feature-settings:"tnum" 1,"ss01" 1` 与 `font-variant-ligatures:no-common-ligatures` 同样会被挂件继承。
251
+
252
+ **现在怎么做**:
253
+
254
+ 1. 两个字体令牌**一个都不再声明**(连 `--ds-font-family-code` 也去掉:它与应用的列表几乎一致,覆盖它对非主题自有元素是视觉惰性的)。`check.js` 新增结构检查——样式表里只要出现 `--dsw-font-family:` / `--ds-font-family-code:` 就判失败,`selftest.js` 有一个注入用例证明这条检查真的会红。
255
+ 2. 主题自己的字体族抽成 `--edge-font`,声明在 `body` 上(原因同上:引用令牌的自定义属性不能在 `:root`),**只施加在主题自己创建/拥有的节点**上:启动加载屏 `[data-endfield-loader]`、水印字标 `[data-endfield-watermark]`、设置面板根 `.endfield-settings`。三者都是主题自己注入的节点,第三方挂件既不会是它们的祖先也不会是后代。
256
+ 3. **主题字体栈在前,`--dsw-font-family` 作为末尾兜底**。这个顺序是量出来的,不是猜的:本机同一串文字在应用令牌下渲染 **Segoe UI**(81.688px),在主题字体栈下渲染 **Arial**(84.516px)。若把令牌放在前面,加载屏、水印、设置面板会全部换脸,品牌块那套按 Arial 量出来的比例(见「启动加载屏」)随之失效。放在末尾既保住主题自有表面的现有观感,又保留「应用侧配置了根字体仍能到达主题元素」的协议含义;宿主没有 Arial / Narrow / PingFang / YaHei 时也仍然落在应用字体栈上,而不是某个任意 generic。
257
+ 4. 加载屏与水印的 `tnum` / `ss01` **跟着字体一起下移到元素自身**(不能再挂 `body`),否则挂件会被继承,加载屏也会失去它排版时依赖的等宽数字与变体字形。
258
+ 5. 回归测试 `test/font-scope.test.js`:在同一页里同时放一个 `font-family:inherit` 的第三方挂件与主题的全部自有表面,断言挂件拿回应用字体、`font-feature-settings` / `font-variant-ligatures` 为默认值,而加载屏与水印仍在主题字体(Arial)上且各自带着 `tnum` + `ss01`。把旧写法注回去,这条测试与 `check.js` 会同时变红。
259
+
260
+ ---
261
+
262
+ ## 源码是真源,产物是拼出来的
263
+
264
+ `client.js` 曾经是 8986 行 / 547KB 的单文件,`index.js` 1263 行。拆分的**目标只有一个:让人能在正确的位置改代码**,因此定下的硬约束是 —— **产物逐字节不变**。
265
+
266
+ ### 为什么不做逻辑重构
267
+
268
+ 看起来更「干净」的做法是把 `apply()` 里那 352 个顶层声明重新组织成模块、用参数传递依赖。评估后放弃:
269
+
270
+ - **跨分区引用密集成网**。设置页那一段引用 109 个外部名字,峰谷定价段跨区引用 48 个,durable gate 36 个。真按模块切,等于要同时改这几百处引用。
271
+ - **测试是冲着产物写的**。40 多个测试用 `fs.readFileSync('client.js')` 切片断言(余额胶囊、渠道额度、峰谷窗口、等高线、水印层叠……),另有若干把 `client.js` 复制到临时目录、用 `<script src>` 在无头 Chrome 里跑。产物一变形,这些断言就得整体重写。
272
+ - **DSH 只加载一个 bundle**。`package.json` 的 `exports["./client"]` 指向单文件,运行时没有模块解析这一步,拆成多个文件并不减少任何加载次数。
273
+
274
+ 所以拆分做的是**切片**:产物内容一个字不改,源码按功能分区存进 `src/`,由构建脚本按清单拼回去。
275
+
276
+ ### 拼接规则
277
+
278
+ `scripts/lib/bundle-build.js` 是唯一的拼接器,`scripts/build-client.js` / `scripts/build-host.js` 只是它的两个入口。规则与原因:
279
+
280
+ | 规则 | 原因 |
281
+ | --- | --- |
282
+ | 顺序来自 `src/client/manifest.js` / `src/host/manifest.js` | 顶层 `const` 之间有 TDZ 依赖,顺序不能随手改 |
283
+ | 片段路径相对 manifest 所在目录解析 | 这样 `src/client/manifest.js` 里才能写 `'../styles/theme.css'` |
284
+ | 每个片段先归一化成 LF 再拼 | 编辑器保存、`core.autocrlf` 各行其是,归一化后结果才是确定的 |
285
+ | 产物行尾沿用现有产物文件(CRLF 工作区就写 CRLF) | 否则 `git diff` 会把整个文件标成改动 |
286
+ | 非末段片段必须以换行结尾 | 否则两段粘成一行,而 `--check` 只能告诉你「第一个差异字符」 |
287
+ | 片段末尾不留空行,分区之间的空行算下一段的开头 | 片段末尾的裸空行会被 CI 的 `git diff --check` 判成 `new blank line at EOF`,整条静态检查就红了——拼接器现在会直接拒绝这种片段 |
288
+ | 拼完先 `new vm.Script(out)` 解析一次 | 拼接错误在语法层就报出来,不用等浏览器 |
289
+ | `--check` 严格相等,并报出第一个差异所在行 | 只报「有差异」没用,要能直接跳到出问题的那一行 |
290
+
291
+ 片段名前缀就是执行顺序:`00-shell-head` 文件头与 `insertCss`,`1x` 偏好存储与观察器,`14-durable-gate` 落盘门,`20`/`22` 等高线,`3x` 启动屏 / 雷霆大字 / 胶囊 / 渠道额度,`4x` 峰谷定价与挂载点,`5x` 设置页,`99-shell-tail` 收尾。
292
+
293
+ ### 等高线 worker 是特例
294
+
295
+ `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` 就必须重新生成。
296
+
297
+ ### 门禁与报错
298
+
299
+ 三个 `--check` 串在 `npm run check` 与 `npm run test:ci` 的链首,CI 的 static job 里另有一步。产物过期时直接说清第一个差异行:
300
+
301
+ ```text
302
+ client.js is stale (first difference at line 4096); run npm run build:client
303
+ ```
304
+
305
+ 照提示跑那条命令即可;**不要直接改产物文件**,下一次 `--check` 还是会红。
306
+
307
+ ---
308
+
309
+ ## 样式表是一整个模板字符串
310
+
311
+ 整份样式表是一个传给 `insertCss()` 的模板字符串(反引号包裹)。有三类改动会在「文件仍能解析」的情况下静默破坏效果,因此固化为 `check.js`:
312
+
313
+ 1. **反引号**。字符串里出现一个反引号(哪怕写在 CSS 注释里)会提前结束模板字符串,整个 client bundle 解析失败。注释里要引用代码请用单引号。
314
+ 2. **`${...}`**。在模板字符串里那是插值,不是 CSS。
315
+ 3. **CSS 注释提前闭合**。这是最隐蔽的一类:注释提前收束后**注释本身仍是配平的**,真正的破坏是残留的说明文字落到顶层、与下一条选择器黏在一起,整条规则被丢弃。曾因此导致 `--edge-word` 未定义、加载屏品牌块整块塌回左上角。`node --check` 查不出这类问题——文件照样能解析。
316
+
317
+ `check.js` 的「顶层漏进散文」判据经过一次修正:第一版用「含逗号或英文单词」,在合法选择器上产生了 33 个误报(`:is([role='tab'], …)`、`input, textarea`、`tbody tr:hover`)。可靠信号窄得多——**CSS 选择器里不会出现「跟在字母后的句点 + 空格」,而散文会**。
318
+
319
+ ---
320
+
321
+ ## 层叠与挂载点
322
+
323
+ 三个图层各有各的挂载点,都是实测定下来的。
324
+
325
+ ### 等高线:应用外框内部
326
+
327
+ 从应用自身 CSS 实测:**三个元素会用不透明的 `--dsw-alias-bg-base` 盖住任何 body 级图层**——应用外框、对话列、右侧栏列(0.1.x 叫详情列)。所以图层挂进外框内部,并在挂载期间把这几处底色置为透明(`:has()` 守卫使功能关闭时全部规则失效)。
328
+
329
+ 外框本身已是 `position: relative` 且**不产生层叠上下文**,因此 `inset:0; z-index:0` 的子元素正好落在「外框底色之上、所有定位子元素之下」。
330
+
331
+ 侧栏底色也一并透明:本主题里 `--dsw-specific-sidebar-fill` 与 `--dsw-alias-bg-base` **本就是同一个值**,所以这不改变任何像素,只是让整片地形连续穿过侧栏。
332
+
333
+ ### hero 水印:body,`z-index: 0`
334
+
335
+ 水印曾画在下拉菜单**之上**——暗色模式下模型选择菜单内部能看到横穿而过的大字母。
336
+
337
+ **成因是一次 `z-index` 平局,不是「层级不够高」。** hero 容器带 `position:relative; z-index:1`,它**本身就是一个层叠上下文**,因此下拉菜单自己的 `z-index:20` 被**关在里面**,根本不参与 body 层的比较;而水印当时也是 `z-index:1`,与 hero 容器**同级**——平局按 DOM 顺序决定,水印作为最后追加到 `<body>` 的兄弟节点赢下每一次平局,于是盖住了整个 composer 子树。
338
+
339
+ 改为 `z-index: 0`:既输给 hero 容器(1)、标签页(1)、输入区(7),又仍然盖在应用外框自身的不透明底色之上——外框是 `position:relative` 且 `z-index:auto`,不产生层叠上下文,两者在同一绘制步骤里按 DOM 顺序排列。
340
+
341
+ 实测(1440×900,逐像素):
342
+
343
+ | | 下拉菜单内被水印改动的像素 | 菜单外仍绘制的像素 |
344
+ |---|---|---|
345
+ | 修复前 | **9945 px**(越界) | 37000 px |
346
+ | 修复后 | **0 px** | 36084 px |
347
+
348
+ 真实截图里量到的越界像素为 11002 px(合成色 `#464844` = `#f5f5f0` 以 α0.13 叠在菜单底色 `#2c2e2a` 上,逐位吻合),与复现值同量级。
349
+
350
+ ### 保持显示的水印:会话列内部,`z-index: -1`
351
+
352
+ 非 hero 页面没有标题可跟随,而一个 fixed 的 body 子节点会画在消息文字**之上**(它没有 `z-index` 竞争者可输给)。所以改为挂进会话列内部并取 `z-index: -1`,需要该列同时具备两个属性(仅在水印挂载期间,由 `:has()` 守卫):
353
+
354
+ - `isolation: isolate` —— 没有层叠上下文时 `z-index:-1` 会逃到最近的祖先上下文,画到该列自己的不透明底色**后面**,即完全不可见;
355
+ - `position: relative` —— 该列原生是 `position:static`,绝对定位子元素会对着应用外框解析,横跨侧栏溢出。
356
+
357
+ ### 两块全屏遮罩的层级
358
+
359
+ | 表面 | `z-index` |
360
+ | --- | --- |
361
+ | 启动加载屏 | `2147483000` |
362
+ | 雷霆大字 | `2147482000` |
363
+
364
+ 加载屏是唯一有资格独占整屏的表面,所以大字必须低一档。两个数字都写成了断言。
365
+
366
+ ---
367
+
368
+ ## 等高线背景
369
+
370
+ ### 四个实测决定的设计
371
+
372
+ 1. **有界散射(1.9× 提速)。** 最初让每个高斯凸起在每个网格点上求值;高斯在 2.6σ 之外数值上已归零,改成每个凸起只写自己包围盒内的格点后,1440×900 实测 **8.30ms → 4.40ms**。这是本功能能开在背景里的前提。
373
+
374
+ 2. **补一层长波起伏。** 高斯之和在岛屿之间**恰好衰减为零**,那里的场完全平坦、没有任何高度线穿过——首版渲染因此出现大片空白,暴露了构造痕迹。加入三道极缓的长波正弦后,间隙里仍有梯度可穿越,孤立的「靶心」才连成一整片地形。代价 1.70ms。
375
+
376
+ 3. **用二次曲线画线,而不是直线段。** marching squares 每个网格边至多产出一个顶点,粗网格下折线**本身就是有棱角的**:实测线段平均 7.8px,顶点转角 p99 达 **41.7°**。`lineJoin` 救不了——1px 描边根本没有接合处可倒角。改成让每个原顶点当**控制点**、曲线穿过线段中点(C1 连续,数学上无折角),且不增加任何顶点:
377
+
378
+ | 方案 | 转角 p99 | 顶点数 | 增加耗时 |
379
+ | --- | --- | --- | --- |
380
+ | Chaikin ×1 | 41.7° → 22.4° | 11.1k | +0.81ms |
381
+ | Chaikin ×2 | 41.7° → 12.0° | 22.2k | +1.45ms |
382
+ | **二次曲线(当时采用)** | **不再有折线转角** | **5.5k(不变)** | **+0.15ms** |
383
+
384
+ > **后续演进**:中点二次曲线在长边上仍留下可见的「圆角多边形」弯折。现行实现改为 **Chaikin ×3 预处理 + 约束 Catmull-Rom 三次曲线**——曲线穿过原顶点、相邻段共享切向(切向系数 0.32),手柄长度上限(0.62× 较短邻段)防止窄鞍部过冲;开放路径的两个端点保持固定(0.4 系数、0.55× 手柄上限)。平滑效果由平滑/尖点测试固定,本表保留为当时的实测记录。
385
+ >
386
+ > 上表的角度是在**当时的 10px 采样网格**上量的;现行 `CONTOUR_STEP` 是 **6**(网格加密后折线本身的转角更小,所以那张表是保守上界)。也正因为顶点数随面积平方增长,网格不能靠一味加密来解决棱角——见下一节。
387
+
388
+ 4. **缝合成连续折线。** 线段按边 ID 缝合后,数千条散段变成约 80 条连续折线,整片地形只需一次 `stroke()` 而非数千次 `moveTo`。
389
+
390
+ ### v1.1.6:短窗口不再缠绕,同步画布路径不再压满一帧
391
+
392
+ 两个独立缺陷,都是「看着像画风问题、实际是数值 / 复杂度问题」。前者影响**所有渲染路径**(它发生在场生成阶段),后者只影响**主线程 Canvas2D 回退路径**(worker + WebGL2 路径下绘制不在主线程,但同一段画线代码在 worker 里省下的分配与 GC 依然是净收益)。
393
+
394
+ **一、短 / 窄窗口下场被采样混叠(缠绕堆叠)。** 高斯凸起半径写的是 `(0.05 + rnd() * 0.09) * min(w, h)`——把地形特征尺寸绑在**较短边**上。外框一矮(或一窄),凸起半径就缩到比 6px 采样步长还小,于是相邻格点直接跨过好几条等值线,marching squares 吐出一圈圈紧贴的同心环加发夹尖刺,看起来就是线缠在一起、叠成一堆。实测:1440×130 明显缠绕,1440×60 退化成括号状碎屑,1440×900 正常。修法是给半径一个**不小于 4 个采样格**的下限(`CONTOUR_MIN_BUMPSAMPLES`);常规窗口的相对项本来就远大于该下限,所以画面逐像素不变。
395
+
396
+ **二、同步画布路径的单帧成本。** 在主线程 Canvas2D 路径上实测(1440×900,每张图):
397
+
398
+ | 项 | 实测 | 处理 |
399
+ | --- | --- | --- |
400
+ | `closePath()` | 18 次 / 张,均值 **0.278ms**、最大 1.2ms,约占一帧的 **16%** | 它把当前子路径拼进已累积的 path,成本随 path 里已有内容增长;改成**先画环、再画开放折线**,让每次拼接只面对环几何 |
401
+ | 每帧贝塞尔段 | **37,865** 条(来自约 4.7k 抽取顶点,Chaikin ×3 把点数变成 8 倍) | 按抽取顶点数自适应 Chaikin 次数:≤8000 维持原本 3 次,>8000 降到 2 次、>20000 降到 1 次 |
402
+ | `tangent()` | 每段 8 个 `[x, y]` 数组 + 最多 6 次 `Math.hypot`,剖析器显示该项独占全页 CPU **14%** | 改成标量切向 + 扁平数组 + `Math.sqrt`(参数都是像素差,远离 `hypot` 的溢出区间),几何保持逐字节等价 |
403
+ | `smoothPath()` | 每个源顶点约 14 个小数组(3 次 pass × 每点 2 个) | 全程扁平 scratch 数组 |
404
+
405
+ 另外加了两道护栏:**格子数上限**(`CONTOUR_MAX_CELLS = 60000`,超出时按 `contourStepFor()` 加大步长;1440×900 是 36k 格,仍用 6px,普通窗口完全不变)把「一张图的成本」从随面积平方增长压成近似常数,同时让大 HiDPI 画布继续留在 worker 的显存上限之内;**占空比上限**(`CONTOUR_DUTY = 0.5`)只在同步画布路径生效——worker 绘制时一次刷新远低于 1ms,这个钳制永不生效;一旦回退到主线程且单帧超预算,就按「本次实测成本 / 0.5」拉长间隔,把线程还回去。相位仍然每次**只前进一个标称帧**(绝不按墙上时钟的间隔追赶),所以机器再忙看到的也是同一段动画放慢,而不是卡顿后跳一下。
406
+
407
+ > 判定口径要写清楚:worker 路径下**不存在**「页面被绘制卡死」这一症状(抽取与绘制都在 worker 里);上面那半张表量的是主线程 Canvas2D 路径,而 worker 拒绝超过 8M 显存像素的画布、或在 worker 不可用时,这条路径就是**实际在跑**的那条(`test/contour-loop-reentry.test.js` 专门覆盖这个回退态)。两条路径共用同一份 `contourDrawLines`,所以省下的分配在 worker 里同样成立。
408
+
409
+ ### 平滑只管中段:三处真正的锐角(issue #3)
410
+
411
+ > 上面第 3 条的中点样条**只在折线内部**无折角。反馈「等高线变化时出现大量不规则锐角锯齿」时,中段确实是干净的(转角 p99 30.5° → 5.6°),锐角全部来自样条管不到的三处边界情形。实测每帧约 **127 个尖刺**,而整套测试全绿——因为它们在每一帧都存在,只是随场漂移不断换位置,所以看起来像「一变化就冒锯齿」。
412
+
413
+ 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]` 方向抵达那个中点,直接连过去不产生折角。
414
+
415
+ 2. **闭合环被当成开放曲线画(32/95 条)。** `walk()` 靠**重复起点**来闭环,把这样的折线喂给开放中点样条,起点处的出射切线与终点处的入射切线毫无关系,于是每个环的接缝上都留一个角:实测中位数 **10.0°**、最大 **26.5°**。改成**循环形式**——在 `mid(u[m-1], u[0])` 起笔,每个顶点都是控制点,接缝因此落在某一段的**中间**而不是两端,整环 C1 连续,且不增加顶点。
416
+
417
+ 3. **相切针刺。** 某条等高线与场**近乎相切**处,真实等值线是一个曲率极高但光滑的尖端;marching squares 在 10px 网格上线性插值表示不出来,只能吐出一根**出去又原路折回**的发夹,其**底宽比 1px 描边还窄**(最差实测:底宽 **0.831px**,却外伸 6.26px)。这种宽度下往返两笔压在同一批像素上,读不出「窄谷」,只看见那根倒刺——平滑救不了,因为几何里**真的**有这个特征,只能在提取处删掉。
418
+
419
+ 判定要两个条件同时成立(24 帧 / 15.26 万顶点 / 118 万 px 墨迹实测):底宽 < 2px(描边分辨不出)**且**转角 > 90°(是折返而非拐弯)。命中率每帧 **0.7 个顶点、总墨迹 0.0037%**,是清伪影而非减密度;真实的窄特征毫发无损——转角 > 90° 的顶点底宽**中位数 4.03px**,而 8–12px 底宽整段(29562 个顶点)转角 p99 仅 21.7°。
420
+
421
+ > **只删尖端比不删更糟。** 第一版只丢弃发夹顶点,结果**底边自己变成了一条真线段**,把那次折返继承成**两个约 78° 的转角**(10.20 → 0.83 → 10.25px),全局最大转角反而从 65° 回升到 127°。因此顶点**和它对面的邻居一起消掉**,并把存活的前一个顶点拉到底边中点——位移最多 1px(2px 底宽的一半),1px 描边显示不出来,发夹处只剩一个光滑顶点。
422
+
423
+ 修好后整段动画序列:**尖点 0 个、>90° 转角 0 个**,最大转角 65.2°、p99 4.7°;总墨迹比值 0.997(形状没被扭曲),24fps 预算仍有 78% 余量。
424
+
425
+
426
+ ### 小点是两类合法残渣
427
+
428
+ 反馈的「莫名其妙的小点」不是渲染噪声,而是 marching squares 的两类产物:
429
+
430
+ 1. **画布外的碎屑。** 网格是 `ceil(w/step)+1` 列 × `ceil(h/step)+1` 行,最后一行 / 列落在画布**边界上或之外**(1432×753 实测超出 +8px / +7px)。那里提取出的等值线被裁成短碎片,甚至完全不可见:85 条折线里 **33 条**含画布外顶点,**3 条**可见长度为 0——纯开销、零像素。
431
+ 2. **山顶靶心环。** 距高斯峰顶一两格处,最内层等值线闭合成一个极小的圆。实测 4 个闭合环包围盒小于 26×26,最小 11.8×15.1px。
432
+
433
+ 阈值不靠拍脑袋,而是量图案**自身的尺度**:横竖扫描 7322 个样本,等高线间距**中位数 21px**(p25 13px)。包围盒装不下一个线距的闭合环,里面放不下任何相邻环线,所以它读作「点」而不是「嵌套岛屿」。开放折线不受此限——那是延伸到画布外的线的可见一角,剪掉会在用户看得见的线上打个洞。
434
+
435
+ 代价实测:丢掉全部 40px 以下的折线只损失 **0.223%** 的总描边长度,是清残渣而非减密度。
436
+
437
+ > **过滤器判定的几何和真正画出来的几何不是同一个。** 绘制时折线被改画成二次曲线,端点是线段**中点**(原顶点降为控制点),因此曲线可能比原始折线更短、包围盒更小。第一版按原始折线判定「已清干净」的种子,实际仍画出了 35.1px 的短描边与 15.4×17.8 / 2.1×20.1 的小环。故阈值留出余量(长度 ×1.35、环包围盒 ×1.5)——曲线在两端各最多缩掉半个线段,而线段平均 7.8px。
438
+
439
+ ### 随机地形与空白格
440
+
441
+ `contourRng` 的种子曾是常量,所以「随机地形」**每次打开都是同一张图**(两次独立加载实测均为 85 条折线、42497px 总长,顶点逐一相同)。改为每次加载抽一次种子(`crypto.getRandomValues`,退化时用时间 / `Math.random` 混合)。
442
+
443
+ **同一次加载内仍保持确定性**:地形会在每次 resize 重建,若每次重抽,拖动窗口就会把地形整个洗牌。实测拖走再拖回同一尺寸(含 520×900 窄窗与 2560×1100)地形完全复现。
444
+
445
+ 写死的种子一直**掩盖着**一个真实缺陷:那唯一的布局恰好分布均匀。改成真随机后,8×5 覆盖网格(「近空」= 墨迹 < 0.6%)实测:
446
+
447
+ | 布局策略 | 失败率 | 最差 |
448
+ | --- | --- | --- |
449
+ | 独立均匀采样 | **5 / 12 种子** | 3 个空格 |
450
+ | 仅分层采样 | **6 / 24 种子** | 低至 0.00% |
451
+ | 分层 + 校验(现行) | **0 / 20 种子** | — |
452
+
453
+ 两步都不可省:
454
+
455
+ - **分层采样(抖动网格)**:视口切成至少 K 格的近方形格阵,每个凸起落在自己格内的随机点。格序用 Fisher-Yates 打乱,避免「屏幕左侧」与「先抽到的尺寸」相关。
456
+ - **接受-或-重抽校验**:分层修不了真正的机制——线只出现在场**穿越** 21 条固定高度之处,局部平坦且卡在两条高度之间的区域,凸起排得再匀也是空白。而若强行加大倾斜以保证每格必有穿越,需要横跨全宽约 9.5 条平行线,那读作条纹不是地形。所以改为按测试所用的同一不变量校验候选布局,不合格就换 salt 重抽(每次仅一次粗网格场求值,不提取不绘制)。
457
+
458
+ 校验门槛也是实测定的:只要求「至少 1 次穿越」时仍有 4 个空白格漏过,且**每个都含已绘制顶点**——高度只擦过格子一角,产出 0.16%~0.44% 墨迹,几何上成立、视觉上空白。故要求每格至少扫过 **3** 条高度带。
459
+
460
+ **但「几次穿越」并不等于「有多少墨」**(v1.1.6 补上的一环)。3 条高度带可以全部只擦过同一格的同一个角,格子照样几乎空白——这正是 CI 上 `contour-specks` 偶发飘红的原因:最薄的那批合格布局在 CI 的栅格化下量到 **0.47%**,低于 0.6% 的判据,而校验器认为它们合格。三处证据把这条缝隙钉死了:
461
+
462
+ - 判据本身没问题:把采样步长从 2px 改成 1px、把 alpha 阈值从 >10 放宽到 >0,同一格只涨到 1.25 倍(**c1≈c2**),说明不是采样混叠也不是阈值把墨算丢了,格子是**真的薄**;
463
+ - 提高穿越次数不是出路:要求每格 ≥**4** 条高度带时,32 次重抽有 **39%** 的加载触顶、只能落回兜底布局(正是重抽机制要避免的那种失败);
464
+ - 提高「墨」才是出路,而且很便宜。
465
+
466
+ 所以校验器现在**同时**给每格算一个廉价的墨量代理:遍历该区域的场四边形,数每个四边形的四角值域里落进了多少条被绘制的高度带。一次这样的命中就意味着等值线穿过该四边形,约等于 `step` 像素的描边——不必跑 marching squares、也不必在构建期做提取。在 1406×756 与 1440×757 两个视口、各 400 个随机布局上对着**真实提取出的每格描边长度**标定:
467
+
468
+ | 墨量代理 | 最薄区域的真实描边长度 |
469
+ | --- | --- |
470
+ | 47–52(加门槛前的最差) | **182–201px** |
471
+ | 80(现行门槛) | **~310px** |
472
+ | 92(中位布局) | ~437px |
473
+
474
+ 于是 `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% 的病态布局不再可能出现。
475
+
476
+ 去掉分层只留校验:实测 8 次加载有 4 次耗尽 12 次重抽上限并落回兜底布局。分层让「一次过」成为常态(平均 2.5 个候选,最多 6,从未触顶),校验把它变成保证。一次性构建耗时 18.7ms,且只发生在挂载 / resize,动画稳态仍有 76% 余量。
477
+
478
+ ### 视口密度自适应:小屏不再是一堆乱线
479
+
480
+ 上面所有常量——21 条高度带、22 个凸起——都是**按桌面窗口的观感**调出来的,却按「每面积」施加:窗口缩到手机宽度,同样的线数与岛数挤进一小条竖带,嵌套环密到读作乱线。375×812 实测(暗色):**62 条路径 / 23,890px 墨长**,单屏叠出 8~9 层同心环。
481
+
482
+ 杠杆选的是**层级数**:线距 = `span*2/LEVELS`,直接按比例缩放屏幕上的线间距。短边 **<640px** 时层级数从 20 向 10 线性递减(320px 触底,下限 6),凸起数从 22 向 12 同步递减(让岛屿保持分离而不是叠成靶心);**≥640px 逐字节回到出厂值**,桌面窗口输出不变。640 这个锚点取在「手机宽(360–430)与窄分屏不再主导短边」之上,320 触底保证最小可用窗口仍是一幅完整地形。
483
+
484
+ 三处消费同一组函数(`contourLevelsFor` / `contourBumpsFor`,以视口短边为锚):布隆布局 `contourBuildCandidate`、覆盖率校验 `contourCoverageScore`、抽取 `contourExtract`。其中校验器必须按「该视口**实际将画的**层级数」算墨量代理——若仍按 `CONTOUR_LEVELS` 评分,10 级的小屏会被要求 2 倍墨量,重抽循环每次都触顶。
485
+
486
+ 改造后 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 一节的教训)。
487
+
488
+ ### 重启动画的首帧曾是空转
489
+
490
+ 「关掉动态等高线再打开,图案不动了」的成因不是开关没接上,而是**重启后的第一帧必定无效**:重启把「上一帧时刻」置为 `-1`,而帧函数在这种情况下取 `dt = 0`,于是按原样重新提取并重绘了同一个相位——花掉约 4.4ms 产出逐字节相同的像素。
491
+
492
+ `dt = 0` 的本意是防「传送」:开关可能关了几分钟,把这段真实间隔当作 `dt` 灌进去会让地形瞬移。但代价是那一帧完全没有推进。实测(插桩构建):重新开启后 **700ms 内变化像素为 0**,因为渲染器在该窗口里只投递了一次 rAF 回调,而它正好被这个空转帧吃掉。
493
+
494
+ 改为按**一个标称帧**(`1 / CONTOUR_FIELD_FPS`)推进:既保留防瞬移的钳制,又让每一帧都做真正的工作。修复后同一断言实测变化像素 **78450**。
495
+
496
+ ### 关于「光点移动」
497
+
498
+ 早前版本的文档与测试里写着第三个「光点移动」开关(光点沿等高线流动)。**该功能从未实现过**(`git log -S` 查无任何提交),相关文档与测试断言属于描述未落地的设计——`settings-rows` 测试因此在每次全新签出时都必然失败。这些断言已删掉;一个默认就是红的测试提供不了任何信息。
499
+
500
+ 设计阶段留下的一条结论仍有价值,记录在此备将来参考:**光点若要落地,抽搐会是索引失配而不是缺动画。** 等值线提取每帧**整个重建**折线数组,而随着场变形环线会合并 / 分裂——折线的**顺序和数量都不稳定**。实测 120 帧里数量在 81–87 之间跳动、有 27 帧发生变化;一旦光点靠数组下标记住「自己在哪条线上」,那个下标很快就指向另一条曲线。正确做法有两条:每帧按**最近几何**重新认领曲线,以及万一接不上就在 **alpha 为 0 时**换位重生。
501
+
502
+ ---
503
+
504
+ ## 输出滚动动画:为什么不能改 scrollTop 的写后回读语义
505
+
506
+ ### 成因:应用用「写后回读」判断自己还要不要跟随
507
+
508
+ DSH 流式输出时不是滚动,而是**直接赋值钉底**(`useScrollFollow.jump`):
509
+
510
+ ```js
511
+ element.scrollTop = metrics.floor
512
+ const landed = { ...metrics, top: element.scrollTop }
513
+ this.following = this.nearBottom(landed) // 25px 阈值
514
+ ```
515
+
516
+ 第二次读取**不是**出于好奇——`follow intent` 就是从它算出来的。因此凡是改变「写完 `scrollTop` 之后立刻读回什么」的方案,都在动应用的跟随状态机。
517
+
518
+ 平台基线实测(用发布版 ChatView / ChatReading 的代码搭的夹具):一次增长步进把端口移动恰好 **+60px,且没有任何中间帧**、0 次 smooth `scrollTo`。这就是「硬生生滚动」的全部:内容一整段瞬移,眼睛看到的是一格一格跳。
519
+
520
+ ### 那条否决:`scroll-behavior: smooth`
521
+
522
+ 这是最显眼的一行式写法,也是这里**最危险**的一行。实测:smooth 下应用写完 `scrollTop` 后立刻回读,拿到的是**旧位置**(`403`,而目标是 `4409`),因为只有动画的**起点**被同步提交。这个回读喂进 `nearBottom()` 后,`floor - oldTop > 25` 让应用认定「读者已经离开底部」,于是**放弃跟随**——输出会静默地不再跟随自己,没有任何报错。
523
+
524
+ **任何改变「写后回读语义」的机制都被否决**,无论它在 CSS 里看起来多诱人。
525
+
526
+ ### 修法:不写端口,只在 scroll 事件里补偿对话栏
527
+
528
+ 新机制**从不写端口**。它监听端口的 `scroll` 事件,只对对话栏(`[data-chat-flow]`)施加一个补偿性 `translateY`,再逐帧把它衰减归零:
529
+
530
+ ```js
531
+ offset += (top_after - top_before)
532
+ column.style.transform = 'translateY(' + offset + 'px)'
533
+ ```
534
+
535
+ 补偿量正好抵消应用刚做的那一步,所以内容在屏幕上**停在原地**,随后几帧里滑回应用给的位置——眼睛看到的是滑行,而应用执行的仍是跳转。实测(同一页面、96px 步进 / 60ms,功能关 vs 功能开):
536
+
537
+ | | 功能关(控制组) | 功能开 |
538
+ | --- | --- | --- |
539
+ | 最差单帧屏上位移 | 96px | 约 20–38px(多次运行区间) |
540
+ | 应用 `scrollTop` 回读误差 | 0px | **0px**(跟随意图不受影响) |
541
+ | 静止时 transform | — | 已清空 |
542
+ | 端口相对应用自己的 floor 的残差 | 0 | **0** |
543
+
544
+ 控制组这一列就是负向对照,它证明夹具**真的**能复现那个硬跳:关掉功能时 `maxStep == rawStride`(96px)且零 transform。没有这一列,「平滑了」只是夹具的自证。
545
+
546
+ 同页另一次实测确认了第二条不变式:`translateY(240px)` **不会**改变端口的 `scrollHeight`,也不改变 floor——应用自己那条 `overflow: visible clip` 链把位移兜住了,因此补偿永远不可能反馈进应用的算术。
547
+
548
+ ### 为什么是 `scroll` 事件,而不是 patch `scrollTop` 访问器
549
+
550
+ 直觉上遮蔽访问器更「精确」,但同一循环下实测:
551
+
552
+ | 应用的写入口 | `scroll` 事件 | patch 访问器 |
553
+ | --- | --- | --- |
554
+ | `scrollTop = v` | 21.88px | 21.87px(持平) |
555
+ | `scrollTo({ behavior:'instant' })` | **21.88px** | 32.0px(**完全看不到**) |
556
+
557
+ 差距的来源是时序:Chrome 在渲染步骤里、**rAF 与绘制之前**派发 `scroll`,所以处理函数里的写能赶上这一帧;而 patch 只能看到走 setter 的那一条路径。除此之外,监听还有两个非性能的理由:它**不遮蔽任何 DOM 访问器**(避免把「应用自己的读写」也变成我们的责任),并且能**跨 React 重建元素存活**。
558
+
559
+ 同理,补偿必须**写在处理函数里**,不能挪进 `requestAnimationFrame` 或任何调度回调:第一版就是在下一帧才写 transform,实测 `maxStep` 等于原始步进——等于没有动画。
560
+
561
+ ### 衰减常数:`tau` 是「顺滑换滞后」的唯一旋钮
562
+
563
+ `offset *= exp(-dt/tau)` 里的 `tau` 同时决定两件事:越大越顺滑,也越大越滞后。稳态滞后有闭式解
564
+
565
+ ```text
566
+ lag = stride / (1 - e^(-interval / tau))
567
+ ```
568
+
569
+ 所以这不是一个可以「调到既顺滑又不滞后」的参数——档位之间的差别就是这条取舍线上的位置。
570
+
571
+ `tau` 必须**远大于一帧**(约 16.7ms),否则 offset 在下次绘制之前就已经归零,补偿静默地退化成空操作。
572
+
573
+ `standard`(`tau` 70ms)是默认值。实测与闭式解吻合,因此可以放心引用**两个**速率(只测一个会误导,这正是本节留下的一条教训):
574
+
575
+ | 速率 | 闭式解 | 实测 |
576
+ | --- | --- | --- |
577
+ | 真实流式(每 250ms 一行 24px,约 96px/s) | 6.7px | **3.1px**(不足一行行高) |
578
+ | 刻意夸张的合成速率(每 60ms 一步 96px,约 1600px/s) | 167px | **163px** |
579
+
580
+ 也就是说:真实速率下滞后在「一行以内」,夸张速率下才到 ~110–160px。功能关闭时两个速率都是 **0px**。
581
+
582
+ > **量滞后本身就是个陷阱**(改那条断言前值得先读)。滞后必须读成「尾部相对端口下缘的**正值**距离」,且基线要在**屏幕上没有 offset 时**取。取早了——`scroll` 事件在渲染步骤里投递,不与写入同步——就会把那一次钉底的 offset 留在基线里,让**之后每个样本都偏低**,低到可以报出**负滞后**(内容跑到视口上方,物理上不可能)。本仓库真的踩过:基线早了 9.6px,指标读到 -12px。现在测试先等两帧再排空,并断言 `minLag >= -1`,让这个指标不能再往「看起来更安全」的方向说谎。
583
+
584
+
585
+ ### 0.5px epsilon:让静止态**可达**,而不只是「趋近」
586
+
587
+ 指数衰减是渐近的,永远到不了零。加 epsilon 之前实测:offset 卡在 `translateY(0.09px)`,端口**一直停在 `active` 状态**、transform 永远不清除——一个看不见的位移,却永久持有合成层。
588
+
589
+ 因此衰减到 `0.5px` 以下就**直接归零并清空 transform**。取 0.5 是 CSS 像素而不是设备像素:任何 DPR ≥ 1 时它至多半个设备像素,且就在到达它的那一帧被移除,所以静止几何与应用自己的几何**完全相同**,而不是「差不多」。
590
+
591
+ ### 为什么不做上限截断(clamp),而是拒绝过大的步进
592
+
593
+ 第一版把 `offset` 钳到上限,听起来更安全,实测却更糟:**被 clamp 的变体 `stepMax` 等于原始步进(192/192,即完全没有平滑)**,而未 clamp 的曲线是 **43.9px**。原因是钳制只让内容「少走一点」,那点缺口会在**下一帧变成一次瞬时跳**——把要消除的东西原样还回来。
594
+
595
+ 所以上限不是用来塑形的旋钮,而是**安全检查**:超过该档 `cap` 的步进被整条拒绝、瞬时呈现。`cap` 远高于任何真实内容块(一个落地的工具结果或代码围栏是几百 px),所以那些——本功能真正存在的理由——仍然会滑,只有恢复会话、跳转到某个回合这类荒谬的大步进绕过动画。
596
+
597
+ ### 门控:几何 + 布局测量,且刻意不读上游属性
598
+
599
+ 一个步进会被柔化,当且仅当:**步进前端口已在底部**(应用自己的 25px 容差,再加一点余量到 40px,好让晚到一帧的步进仍算数)、**方向向下**、**不超过该档 `cap`**、**且这一步是纯增长而不是布局重排**(最后一条见下一节,它是后补的,因为前三条曾经不够)。
600
+
601
+ 这个判据不需要任何上游钩子。前三条能排除掉大多数情形:加载更早的历史、恢复会话、跳到某个回合都**不是**从底部出发;向上移动永远不满足;读者自己的滚轮位移也不可能「从底部出发并向下」,因为底下已经没有可滚的东西了。
602
+
603
+ > **但前三条并不充分——这是被用户实测推翻的一个结论,值得留在这里。** 原本这里写的是「从底部出发、向下的位移只可能是应用在钉新长出来的内容」。**展开思考块**就是反例:它同样从底部出发、同样向下、同样可能小于 `cap`,但它是**布局重排**而不是增长。当时因此把一个 159px 的重排当成增长去补偿,用户看到的就是「展开时明显抽搐」。判据缺了一条,详见 [布局重排的补偿](#布局重排展开思考块为什么抽搐)。
604
+
605
+ 应用确实有一个语义相同的 `data-chat-following-tail` 属性,但**刻意不用**:我们自己加的无哈希属性加几何判据,在上游改名后仍然工作;而依赖上游契约会把一次改名变成「功能静默死掉」。
606
+
607
+ 其余一律保持瞬时,且这条清单是**行为契约**而不是实现细节:向上滚动、从阅读位置跳转、超大步进、`wheel` / `touchstart` / `pointerdown` / `keydown` 手势、`prefers-reduced-motion`、页面隐藏(页面隐藏这一条同时也防止冻结的 rAF 把一大段 offset 晾在那里、等回来时再补算)。
608
+
609
+ ### 布局重排:展开思考块为什么抽搐
610
+
611
+ **现象**(用户报告):展开思考(reasoning)时对话区明显抽搐一下。
612
+
613
+ **成因**:补偿量的数学前提错了。真实被补偿的量是**眼睛看到的位移**:
614
+
615
+ ```text
616
+ visual = layoutDelta - delta
617
+ ```
618
+
619
+ `delta` 是应用钉底的位移,`layoutDelta` 是**布局自身**在同一步里移动既有内容的距离。纯流式追加时 `layoutDelta` 恒为 0,所以 `visual == -delta`,补偿 `+delta` 正好把画面按回原位、再衰减滑到新位置。但展开不是追加:`ReasoningRow` 折叠时是一个固定 24px 的盒子(`contain:size layout`),展开时**整段 markdown body 一次性挂载**,列在数百 px 的量级上变高,而这个增长发生在**尾部内容之上**——布局把尾部往下推了 `H`,应用又把它往上钉了差不多的量,两者几乎抵消,**屏幕上内容根本没动**。此时再补 `+delta`,等于凭空造出 `H` 的位移,然后再花 0.6 秒滑回来。
620
+
621
+ 实测(默认档位 `standard`,展开 8 行思考):
622
+
623
+ | | 关闭功能 | 修复前 | 修复后 |
624
+ | --- | --- | --- | --- |
625
+ | 尾部最大位移 | 39.5px(一帧掠过) | **159.5px** | 39.5px |
626
+ | 之后的滑行 | 无 | **659ms** | 无 |
627
+ | 最大 transform | 0 | 159px | **0** |
628
+
629
+ **修法**:不再假设「`scrollTop` 的增量就是视觉位移」,而是**测量**布局自己贡献了多少:
630
+
631
+ ```js
632
+ const layout = anchor.getBoundingClientRect().top - column.getBoundingClientRect().top
633
+ const layoutDelta = layout - entry.lastLayout // 同一节点才相减
634
+ const visual = delta - layoutDelta
635
+ ```
636
+
637
+ `anchor` 取**列的最后子节点**(流式就是在它这一段追加,所以底部追加不能移动它的顶边)。两个 rect 都包含我们自己写的 transform,相减时**恰好抵消**,所以量到的只有布局位移。
638
+
639
+ **踩过的三个坑**(都是测量纠正的,不是推理出来的,所以写在这里):
640
+
641
+ 1. **锚点不能用「端口」做参照。** 端口不随 transform 移动,于是两次 stride 之间**我们自己 offset 的衰减**(`tau=70ms` 下每步几十 px)被当成布局重排,动画被中途拆掉——测试立刻抓到:`maxStep` 156px 而原始步进只有 96px。改为相对**列自身顶边**后,transform 在两者中相等而抵消。
642
+ 2. **锚点不能用最深的最后一个后代。** 那正是展开时自己会变形的节点(markdown body),实测对 159px 的重排只报 0.5px——完全瞎。
643
+ 3. **空列不能回退成「列自己」。** 列相对自己的偏移恒为 0,于是基线被永久钉在 0,第一次大重排就会被读成 `layoutDelta 0` 而照旧动画——正是这个 gate 要防的场景。
644
+
645
+ **护栏**:`test/scroll-anim.test.js` 里有一条专门的展开回归断言——用真实 `ReasoningRow` 的形状(折叠 24px 盒子 + 展开挂载 body)、用应用自己的 `ResizeObserver` 触发重钉底,断言展开时**不得比关闭功能时多位移**、且**不得产生任何 transform**;同时保留「流式必须仍然动画」那一半,因为一个「见到 resize 就整个关掉」的 gate 也能通过前一半。变异验证里 **M4 就是把补偿改回 `visual = delta`**(即修复前的代码),该断言报 `151.5px vs 0.5px` 并变红。
646
+
647
+
648
+ ### 副作用审计:transform 的包含块与滚动溢出
649
+
650
+ 在对话栏上加 `transform` 不是零成本的原语,落地前对着发布版 bundle 核过两件事:
651
+
652
+ - **包含块**:`transform` 会给 `position: fixed` 后代创建包含块。对话子树里**没有**这样的后代(chat / conversation 两个 bundle 里的 `position: fixed` 属于 portal 出去的统计对话框,渲染在这棵树之外;唯一一处 JS `style.transform` 属于回合轨的虚拟化器,它的元素是对话栏的**兄弟**),而粘性元素(输入区座位、回合轨、分组标题)同样都是兄弟而不是后代,因此都不受影响。
653
+ - **滚动溢出**:如上所述,`translateY` 不改变 `scrollHeight` 与 floor,应用测到的仍是它预期的那个 floor。
654
+
655
+ ### 护栏
656
+
657
+ `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。
658
+
659
+ `scroll-behavior: smooth` 那条否决因此有一个可执行的形式:**回读误差必须是精确的 0**。一旦有人把机制换成任何「写后回读语义变了」的写法,这条断言会直接红。
660
+
661
+ ---
662
+
663
+ ## 启动加载屏
664
+
665
+ ### 定位用「锚右边距」而非「锚左百分比」
666
+
667
+ 早前用 `--edge-rail: 73%` 定左边缘,实测暴露两个问题:左百分比只决定文字**从哪开始**,其宽度自由地向右伸展,于是「离右边缘多远」——也就是肉眼真正读到的那段留白——从来不是被控制的量;且纯 `vh` 取值在**宽而矮**的窗口里会把字标缩小。
668
+
669
+ 现在改为 `right: var(--edge-gap)`,把右侧留白变成显式声明值,节奏线由最宽的一行自然决定。
670
+
671
+ | 项目 | 参考图 | 修改前 | 修改后 |
672
+ |---|---|---|---|
673
+ | 字标 cap(占画面高) | 4.23% | **3.01%**(偏小) | 3.72% |
674
+ | 右侧留白 | 7.4% | **10.5%**(且不受控) | 12%(显式声明) |
675
+ | 行距 / cap | 1.10 | 1.10 | 1.10 |
676
+ | 右侧溢出 | — | 无 | 无 |
677
+
678
+ 字标尺寸 `--edge-word: clamp(26px, min(5.2vh, 4.8vw), 64px)`:
679
+
680
+ - `min(5.2vh, 4.8vw)` 取**较小轴**,因此无论窗口「矮」还是「窄」,品牌块都不会挤进左侧进度轨;
681
+ - `26px` 下限保证辅助小字在极小画面下仍可读;
682
+ - `64px` 上限是实测结论——不加它时 2560×1440 下 cap 占比会掉到 **2.69%**(大屏上字标反而变小),加上后稳定在 3.2% 以上。
683
+
684
+ 跨视口验证:520×900 到 2560×1440 共 **13 种视口**逐一实测(用精确尺寸的 iframe,`vh`/`vw` 与媒体查询按真实视口解析),全部无任何方向溢出,cap 占比维持 3.1–3.9%,与进度轨 / 读数的水平间距 ≥150px。字标仍**刻意略小于参考图**:参考图是整幅出血海报,而这里是一闪而过、盖在应用之上的遮罩。
685
+
686
+ ### 三处易错的排版细节
687
+
688
+ 1. 字标行 `margin-left: -0.055em` 抵消 Arial 左侧字身空隙,让**字形墨边**(而非字盒)落在节奏线上;
689
+ 2. `line-height` 取 **0.80** 而非 1.10——参考图的 1.10 是**墨边间距**,而 `line-height` 覆盖整个 em 盒(Arial-900 约为 cap 的 1.38 倍),直接写 1.10 会渲染成松散的 1.52;
690
+ 3. 品牌块**不能加 `max-width`**:各行均为 `nowrap`,宽度上限只会裁字而不换行;要收窄应改 `--edge-gap`。
691
+
692
+ ### 两个只有量像素才发现的问题
693
+
694
+ - **小字号必须有 px 下限。** 各辅助行原先只写 `em` 比例,缩小整块后实测只剩 **3px 高、峰值亮度 94** 的灰糊——肉眼是一道模糊而非文字。现在统一改为 `max(8~10px, …em)`;实测每行笔画分组数 16~37(模糊时仅个位数),确认为可读字形。
695
+ - **标语用 Arial Narrow 压缩字体。** 早前判断「Arial 下标语无法满足参考比例」只对了一半:探测渲染器后发现 Arial Narrow 确实可用(同一探测串 245px vs Arial 299px);在缺少该字体的机器上会回退到 Arial,因尺寸是比例值而非固定 px,仍然不会溢出。
696
+
697
+ ### 收尾动画不能用 CSS transition
698
+
699
+ 最初「铺满 + 淡出」写成 `transition: width …`,在验证渲染器里**完全不执行**——`transitionrun` / `transitionstart` / `transitionend` 一个都不触发,`width` 过了时长仍停在 10px。做过对照实验(transition 写在状态选择器里 / 预先写在基础规则里,两种写法都不动),确认是渲染器不跑 transition,与写法无关。
700
+
701
+ 若照此发布,用户会看到左边一条 10px 黄条僵在原地。改为 JS 按墙钟时间逐帧写 `width` / `opacity` 后,实测同一渲染器下取到 17 个不同宽度、10px → 500px,并在真实速度截图中抓到中途帧。
702
+
703
+ ### 胶囊收起:transition 不能和状态选择器同生共死
704
+
705
+ 顶部余额胶囊的开场 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 的高盒子里两套文字叠印了约五分之一秒——用户看到的"动画不连贯"就是这一帧。
706
+
707
+ 修法是拆开两个半边:**「按住」的 `opacity: 0` 留在 pose 选择器里,能跑的 `transition` 挪到不依赖 pose 的基础规则** `[data-endfield-balance] > :not(brand)`,并给它 90ms 延迟。延迟是交叉淡化的关键——品牌(220ms)淡出大半时数字(170ms)才爬到三分之一,两者都远早于盒子的 420ms 收缩结束,整段收起读成一个动作。复测同一采样器得到 `0 → 0.16 → 0.36 → 0.65 → 0.94 → 1` 的平滑爬升,与品牌淡出完全交叠。
708
+
709
+ 这与上面加载屏那条是同一课的两面:那条说**有的渲染器根本不跑 transition**(要 JS 逐帧兜底);这条说即便在跑 transition 的渲染器里,**声明的存续期也是动画的一部分**——把 transition 挂在会被移除的状态属性下,等于亲手在动画起始帧掐死它。`test/balance-capsule.test.js` 因此钉住三条:基础选择器带 transition、pose 选择器**不带**、且 transition 带延迟。
710
+
711
+ ### 胶囊两端不圆:形状不能依赖"收起时写回"
712
+
713
+ 第一版修法(挂载只写常量 `999px !important`、pose 不再借用 14px、收起不再写回)在夹具里完美,但用户重载后**仍然**看到椭圆端头——第二张截图证明:那张图没有被拉伸(同图里 27px 表盘实测 27×27 正圆、药丸 300×32 分毫不差),端头弧深却仍是 ~14px。结论只剩一个:**那个渲染器对 999px 的钳制本身就坏**(渲染出 squircle 而非半圆),任何依赖 `border-radius` 计算终值的方案在它上面都不可信;而同一帧里表盘那圈 `conic-gradient` + `radial mask` 是**完美圆形**——渐变是这个渲染器从未失手的形状原语。
714
+
715
+ 最终修法即用户所言「直接在 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 圆弧上。
716
+
717
+ 教训合并成一句话:**凡是"最终必须成立"的视觉状态,都不能只靠动画、状态切换或某个几何属性的钳制去达成**——transition 可能不跑、声明可能随选择器消失、内联写回可能被吞、border-radius 的超大值钳制本身可能渲染成 squircle;把不变式压到最原始的绘制原语上(这里:渐变),让坏不了的东西替你维持形状。`balance-capsule.test.js` 钉住:三层渐变轮廓、@property 注册 + 16/36 两值 + transition 补间、挂载写 `0 !important` 且源码不得再出现任何 999px/14px 的半径写入。
718
+
719
+ ### 启动读取不能只信 `apply()` 那一刻
720
+
721
+ 加载屏是唯一一个**必须在启动瞬间做决定**的表面:它在 `apply()` 里同步读一次 `loader`,读到就播。而设置节的实际到达顺序是异步的——`settingsScope` 的首次快照是 `{ status:'loading', value: undefined }`(`dsh-client-ui-settings` 的 `SettingsScopeSnapshot` 契约明写了这一点),Host 那份 section 走线上回来时 `apply()` 早已跑完。于是那次读落在 schema 默认值 `'0'` 上:用户即使存着 `loader:"1"`,也什么都不播。
722
+
723
+ 其余每个开关都从这场竞态里活了下来,因为它们**各自都有稍后重推的路径**:主开关 / 圆角 / 配色 / 水印随 `reconcileFromPrefs` 重画,等高线由水印的 `MutationObserver` 反复重试,雷霆大字在每次节变化时重订阅。**只有加载屏被显式排除在重放之外**(它是一次页面加载只播一次的片子,不能让运行期改动静悄悄盖上全屏),所以它没有第二次机会——这就是「开关是开的、动画却再也不出现」的全部原因。
724
+
725
+ 修法不是放开重放,而是把「首次权威节」做成一个独立信号:存储层读到一个 `status:'ready'` 的节、并跑完解析与旧拼写迁移之后,回调一次 `onPrefsSettled`;加载屏挂在这个信号上,且仍然只播一次。后续的节变化根本不经过它,「每次页面加载」的语义没有被稀释。
726
+
727
+ 这个 bug 能穿过整套测试,是因为既有用例的假 scope 都**同步答 `ready`**:`apply()` 那一刻读到的已经是真值。`test/loader-late-prefs.test.js` 因此给假 scope 加上 `{ readyDelayMs }`——先答 `loading`、再翻 `ready`,只有这个形状能复现线上。
728
+
729
+ ---
730
+
731
+ ## 设置页国际化
732
+
733
+ 设置页此前是**硬编码中文**的:把 DSH 切到 English,这一页仍然整页中文。现在全部文案走应用自己的 `locale` 服务(`@deepseek-ai/dsh-client-locale`),随语言设置即时切换。
734
+
735
+ - **不自己猜语言。** 不读 `navigator.language`、也不另存一份偏好——那会和用户在设置里真正选的语言漂移。`locale` 服务已经持有偏好、durable 存储与重渲染通道。
736
+ - **词典命名空间** `settings.theme-endfield`,通过 `ctx.effect(() => locale.register(ns, { zh, en }))` 注册,随 run 释放;否则重复挂载会撞上服务自身的 `(ns, locale)` 去重而抛错。
737
+ - **`label` 用 thunk 而非字符串**(`label: () => t('nav')`):槽位契约会按读取次数重新求值,导航行标题才能跟着语言变,而不必重新注册。
738
+ - **zh 是键集的事实来源,en 必须与之完全对齐。** 少一个键不会崩,而是把**原始键名**渲染到界面上——静默且丑。因此测试直接对比两份词典的键集。
739
+ - **分隔符也是词典键。** 行读数原本写成 `label + ':' + value`,全角冒号是硬编码的,于是每一行英文都带着一个中文冒号。现在 `sep` 由词典给出(zh 用 `:`,en 用 `: `),并有「英文页面不允许出现任何中日韩标点」的断言。
740
+ - **分组标题在英文下不重复打印拉丁行。** 中文下是「04 娱乐 / ENTERTAINMENT」这种编辑式双行;英文下两行会 collapse 成同一个词,所以第二行直接省掉。
741
+ - **没有 locale 服务也能用**:`t` 回退到 zh 词典(最后才回退到键名本身),设置页照常渲染为中文——进程内测试就是以这种最小 ctx 挂载主题的。
742
+
743
+ ### `locale:` 只在服务存在时才声明
744
+
745
+ 直觉上应该无条件写上 `locale: NS`,但 `dsh-client-ui-renderer` 对「声明了命名空间却没有安装 locale face」的条目会**抛 `SlotAssemblyError`**。
746
+
747
+ 而重渲染其实并不依赖这个声明:renderer 的 `useLocaleRevision` 把**每一个** outlet 都订阅到 locale revision 上,所以本页在任何情况下都会随语言切换重渲染,`t` 也是从 apply 闭包里取的、不走注入的 seat。
748
+
749
+ 无条件声明的唯一后果,是把「没装 locale 插件」从「设置页显示中文」变成「设置页直接崩」。所以该键只在服务确实存在时才并入注册项,并且两个方向都写了断言。
750
+
751
+ ---
752
+
753
+ ## 峰谷定价窗口与法定节假日
754
+
755
+ 顶部余额胶囊要在本地算出「现在是高峰还是低谷」——Host 没有任何服务携带这份排班表,主题只能自己推算、自己保管日历。规则的两次修订都必须进代码:
756
+
757
+ - **2026-09-10 起** flash 系列改为峰谷分时计费:**北京时间周一至周五 9:00-12:00、14:00-18:00 为高峰**,价格是空闲时段的 2 倍(api-docs.deepseek.com pricing)。
758
+ - **2026-09-19 的「API 峰谷时间补充说明」** 补上了两条从主规则里读不出来的边界:中国**法定节假日全天**按空闲计费;**调休上班的周末仍按周末计费**(「只要是周末,就按空闲价执行」)。
759
+
760
+ 第二条推论出本节最重要的决定:既然官方把调休日直接归进周末规则,代码就**不需要**任何调休例外表——周末分支天然把它们算成低谷。代价是这条推理必须被机器守住:`check.js` 第 7 段要求**每一条 `makeup` 日期都真的是周六或周日**,一旦将来某份通知把普通工作日调去上班,守卫立刻失败,逼着重新审视模型,而不是继续默默按高峰计价。
761
+
762
+ ### 日历是数据,不是逻辑
763
+
764
+ `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`。
765
+
766
+ **没有收录的年份回退到普通工作日规则**,也就是一个未录入的节假日会被算成高峰。这是刻意的:宁可把假期显示成高峰(用户读到的只是「现在贵」),也不猜一个假的半价读数。代价是这份数据会随时间失效,而且失效是**静默**的——所以 `check.js` 在「当前北京年份不在表里」时响亮失败,作为每年 11 月(通知发布月)的提醒。
767
+
768
+ ### 窗口是「最长同价连续段」,不是日历天
769
+
770
+ 旧实现把窗口限制在**当天 00:00-24:00**,于是周五 18:00 到周一 09:00 这段连续 63 小时的低谷被切成三块,周六上午的读数变成「剩余 14:20:00(到周日 24:00)」——一个与价格无关的数字。午夜不是价格边界,现在窗口是**同价连续块的最长游程**:7 天国庆读成一段 183 小时,跨节、跨周末自然合并。
771
+
772
+ 实现上 `blocksAt(day)` 给出当天的块序列(整天谷 / 工作日五块),非高峰时从当前块向两侧逐块走,遇到高峰块即停;步数上限 `BALANCE_WINDOW_WALK_CAP = 4096` 只作防呆(9 天春节连周末约 20 块,实际不可达)。顺带把 `BALANCE_PEAK_WINDOWS` 从**死常量**改造成块序列的推导来源:此前它在声明后从未被使用,函数里另写死了一份 `edges`,两者随时可能漂移。
773
+
774
+ ### 节假日名只出现在开场 pose
775
+ 胶囊宽 300px,窄屏断点 316px 正是按它推出的,所以 **settled 行的文案一个字都不能加**。「今天是国庆节」只在开场品牌 pose 的标题里体现(`DeepSeek 国庆节低谷`,普通日子仍是 `DeepSeek 当前低谷`);`test/balance-capsule.test.js` 直接断言 `win.holiday` 只被**一行**读取、且那一行在 brand-title 块内,防止它以后爬进 settled 行把胶囊撑宽。
776
+
777
+ ### 窄屏是两级阶梯,不是一刀切
778
+ 316px 那一级只保护桌面布局——手机(390–480px)根本到不了它,「低谷时段剩余42:08:13」照样把胶囊撑到表头标签之上。所以拆成两级:**≤480px** 隐藏「时段剩余hh:mm:ss」(`data-endfield-balance-remain-run`,秒级倒计时是手机上最没人看的读数,也是最宽的 token 串),高峰/低谷 标签保留;**≤316px** 再把整段灰色读数(含标签)删掉,守住 300px 药丸的原始契约。要做到只砍尾巴,静态胶水文案「时段剩余」必须有自己的元素——文本节点没有可隐藏的 selector,因此它在标记里被包进 `remain-run`,`phase` 留在外面。
779
+
780
+ 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)` 自行封顶)。
781
+
782
+
783
+
784
+ 算术本身(节假日 / 周末 / 工作日各读到什么窗口、倒计时多少)钉在 `test/balance-window.test.js`;守卫与注入用例见 [testing.md](testing.md#顶部余额胶囊)。
785
+
786
+ ---
787
+
788
+ ## 已修问题归档
789
+
790
+ 按成因分类。共同点:**都是量出来的,不是读代码读出来的。**
791
+
792
+ ### 一类:底色归主题、文字归应用的裂缝
793
+
794
+ 主题把某个背景令牌映射成实心强调色,却没有接管前景,于是应用自己声明的 `color` 直接落在强调底上。
795
+
796
+ 以设置 › 模型 的 `编辑` 按钮(类名 `<hash>_secondaryButton`,0.1.1 bundle 里哈希为 `zGbnIq_`)为例,上游声明:
797
+
798
+ ```css
799
+ color: var(--dsw-alias-label-primary);
800
+ background: var(--dsw-alias-interactive-bg-hover-solid); /* :hover */
801
+ ```
802
+
803
+ 暗色模式下 `label-primary`(`#f5f5f0`)就直接落在信号黄上。从反馈截图里逐像素量到:**63 个 `#f5f5f0` 像素压在 `#fff500` 上 = 1.05:1**,等于不可见。
804
+
805
+ 早前的 hover 反色规则没兜住,是因为那条规则**刻意排除了普通 `button`**,好让「黄底黑字的开关」和「深底白图标的发送键」各自保住配色。而这类按钮恰好是「底色来自主题、文字来自应用」的,只能点名修。
806
+
807
+ 顺着这个模式审计安装态 bundle,发现**同样的缺陷共 6 处**(类名中的哈希随构建变化,现按语义后缀匹配;0.1.1 bundle 中的哈希备查):
808
+
809
+ | 元素(语义后缀) | 0.1.1 哈希 | 位置 | 修复前(暗色·谷地黄) | 修复后 |
810
+ | --- | --- | --- | --- | --- |
811
+ | `_secondaryButton` | `.zGbnIq_…` | 设置 › 模型 行操作(`编辑`) | **1.05:1** | 16.50:1 |
812
+ | `_inspectButton` | `.gNWCoW_…` | Cordis 检查面板 | 1.05:1 | 16.50:1 |
813
+ | `_inspectButton` | `.iWrAna_…` | 技能检查面板 | 1.05:1 | 16.50:1 |
814
+ | `_inspectButton` | `.o3BgMG_…` | 工具检查面板 | 1.05:1 | 16.50:1 |
815
+ | `_arrow`(限输入区容器内) | `.JVDQca_…` | 附件轮播箭头 | 1.05:1 | 16.50:1 |
816
+ | `_add`(限定输入区容器内) | `.uV2eYG_…` | 输入区 `+` | 早前已修;0.1.7 因裸子串命中容器而复发,见八类 | 18.31:1 |
817
+
818
+ 武陵青下同一处是 2.61:1——也不合格,只是没那么刺眼,这正是它一直没被发现的原因。
819
+
820
+ **选择器匹配的四条铁律**(前三条来自 0.1.2-rc.1 全量重哈希、33 个哈希选择器同日全灭,见 issue #17;第四条来自 0.1.5-rc.2 的 header 重构,见七类):
821
+
822
+ 1. **禁止把模块哈希写进选择器。** CSS Module 类名是 `<hash>_<语义后缀>`,每次上游重新构建哈希全变,钉哈希的选择器**静默失效**。`test/selector-guard.test.js` 会在哈希重新出现时报警。
823
+ 2. **复合状态用子串匹配,不用 `[class$=]`。** 属性后缀选择器要求**整个 class 属性**以该串结尾,而元素常常还带第二个类(实测 `[class$='_inspectButton']` 在 `class="gNWCoW_inspectButton HOVERPROBE"` 上直接漏掉)。`[class*='_语义名']` 对拼接免疫;`_unselected` 因下划线断词不会误中 `_selected`。
824
+ 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-已跟进))。这类改名不会报错,只会让规则静默不命中。
825
+ 4. **子树位置不是语义,不要用 `>` 把中间层数写死。** 语义后缀能扛住重新哈希,却扛不住上游**插入一层包裹元素**:`A > B` 在 `A > C > B` 上直接失配,同样静默。这条 bug 在一次会话里被犯了**两次**(见七类):先是把徽章当成 header 的直系子节点,改成 `_headerActions >` 之后又漏掉了**插槽自己那层没有 class 的包裹 div**。层级要么用后代组合器表达,要么更好——**改用元素自身的特征**把目标锁定(不依赖任何一层的位置)。新增或调整这类选择器时,必须对着**真实 DOM**(浏览器里量出来,或从上游渲染代码读出来)验一遍,而不是对着测试夹具。
826
+
827
+ ### 二类:前景与背景被映射成同一个值
828
+
829
+ 提问卡片的「推荐」徽标在亮 / 暗两种模式下都完全不可见。上游把 `--dsw-alias-button-info-fill` 当**前景色**用,底色用 `--dsw-specific-sidebar-nav-item-active-accent`;而本主题为了中和残留蓝色,把这两个令牌映射成了**同一个值**(亮 `#101110` / 暗 `#fff500`),于是标签把自己画在自己的底色上。
830
+
831
+ 实测「有文字 / 无文字」两版渲染**逐像素差为 0**——DOM 里有字,画面上一个像素都没有。修复不去动这两个令牌(它们在别处确实被当作背景填充使用),而是**只在选项行内**把这对前景 / 背景显式钉死。
832
+
833
+ 选项编号是相邻的一处:既有的暗色选中行反色规则只把后代**文字**改黑,改不到后代**自己的背景**,所以编号仍保留 `--dsw-alias-bg-overlay`(`#1c1e1c`)的底色,实测 1.25:1。改为给编号加一层半透明墨色**淡底**(而非填死),让数字落在强调底上仍读得出,同时保留「小方块」的形态。
834
+
835
+ | 用例 | 修复前 | 修复后 |
836
+ |---|---|---|
837
+ | 「推荐」徽标 · 暗色 · 普通行 | **0 px(不可见)** | 1275 px,16.50:1 |
838
+ | 「推荐」徽标 · 亮色 · 普通行 | **0 px(不可见)** | 1275 px,16.50:1 |
839
+ | 「推荐」徽标 · 暗色 · 选中行 | 1274 px,18.31:1 | 1296 px,16.50:1 |
840
+ | 选项编号 · 暗色 · 选中行 | 207 px,**1.25:1** | 225 px,11.69:1 |
841
+
842
+ ### 三类:色值对某一种模式不成立
843
+
844
+ - **亮色模式「移除」按钮。** `--dsw-alias-state-error-primary` 是 `#ff3b30`,在设置面板底色 `#f2f2ec` 上只有 **3.16:1**。iOS 风格的红是为「白字红底」调的,不是为「红字纸底」。只压暗**文字颜色**(令牌本身在别处仍作填充 / 状态点使用)后为 5.16:1。
845
+
846
+ - **雷霆大字的压暗底 alpha。** 初版取 `rgba(16,17,16,0.28)`:暗色模式下 18.92:1 毫无问题,**亮色模式下白字只有 2.29:1**(`bg-layer-1` 上更低,2.11:1)。只读 CSS 看不出来,是渲染出来量像素才抓到的:
847
+
848
+ | alpha | `bg-base #e8e8e2` | `bg-layer-1 #f2f2ec` | 暗色 `#101110` |
849
+ |---|---|---|---|
850
+ | 0.28(初版) | **2.29:1** | **2.11:1** | 18.92:1 |
851
+ | 0.40 | 3.13:1 | 2.90:1 | 18.92:1 |
852
+ | 0.50 | 4.17:1 | 3.89:1 | 18.92:1 |
853
+ | **0.55(现行)** | **4.85:1** | **4.55:1** | 18.92:1 |
854
+
855
+ 取 0.55——两个亮色表面同时越过 4.5:1,对一个只需满足 3:1 大字号下限的词来说是刻意留的余量。暗色模式无论取哪个值都不变,所以这个数字是由**亮色表面**定的。
856
+
857
+ - **大字的白色必须写字面量 `#fff`,不能用令牌。** `--dsw-alias-label-primary` 在亮色模式下是**墨黑** `#101110`,用令牌会把「白色大字」在奶油纸底上渲染成近黑字。
858
+
859
+ - **深色水印过响。** 在近黑底上**叠加**亮度,比在纸底上**减去**亮度显得响得多,同样的 alpha 在深色下更吵。故深色从 0.13/0.16 降到 `0.085`(1.215:1),亮色保留 `0.13`(1.310:1)。
860
+
861
+ ### 四类:回合状态标签
862
+
863
+ 该标签在 **0.1.x** 上是 `Md3f7G_turnStatus`,是**渐变文字**而非普通着色文字:上游画了一层 `linear-gradient` 背景,再用 `-webkit-text-fill-color: transparent` + `background-clip: text` 把字「镂空」,并以 `background-position` 做流光动画。由此两个结论:
864
+
865
+ 1. **写 `color:` 完全无效**——透明文字填充优先,字仍由渐变决定;改色必须改渐变本身。
866
+ 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` 与流光动画保持不变。
867
+
868
+ **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-*` 色标,两代共享同一份对比度结论。
869
+
870
+ 色标取法见 [design-language.md](design-language.md#为什么亮色模式的强调色要下沉)。
871
+
872
+ ### 五类:生命周期与竞态
873
+
874
+ - **`sessions` 服务迟到导致功能永久失效。** Web 启动是 `Promise.all` 并发挂载所有插件行,而本主题**不声明 `inject`**,所以 `apply()` 完全可能跑在 `dsh-client-runtime` 提供 `sessions` 之前。初版在 `apply()` 里缓存了一次 `ctx.get('sessions')`,于是在这类加载顺序下雷霆大字会**永久失效**——只在慢速 / 冷启动时偶发。现在改为**惰性解析 + 120ms 重试**。
875
+
876
+ 之所以不用 `inject: ['sessions']`:那会让**整个主题**进入 cordis 的 pending 态,把令牌与样式表的挂载一起推迟——主题必须先能上色,即使这个娱乐功能永远拿不到服务。
877
+
878
+ - **列表快照里的 `current` 字段消失,把「暂时没有当前会话」写成了终态。** 上一个 bug 修的是**服务迟到**,这一个修的是**契约变化**:上游把 `SessionListState` 收窄成 `{ ids, byId, phase, projectionsBySession }`,`current` 整个没了(view selection 早在 `service.d.ts` 里就被注明「remains outside the Controller」)。旧代码 `snap.current` 于是恒为 `undefined`,而它落进的分支是 `thunderDetach(); return`——**没有订阅、没有定时器、没有任何后续推送能再进来**,功能在新版本上静默失效,而测试全绿:假 `sessions` 服务当时也按同一份旧形状造形,等于把 bug 一起固化了。
879
+
880
+ 现在的权威答案是 **mainView retention**,而不是某个字段:workspace 面板用 `this.sessions.retain(target, { source: 'mainView' })`(`@deepseek-ai/dsh-client-ui-workspace`)标记「用户正在看这个会话」,行上体现为 `retainedBy.mainView > 0`;全应用统一读法是
881
+
882
+ ```js
883
+ Object.values(state.byId).find((row) => (row.retainedBy?.mainView ?? 0) > 0)?.id
884
+ ```
885
+
886
+ 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 穷举。
887
+
888
+ 修法分三层:`thunderCurrentId(snap)` 先扫 `byId` 的 mainView 保留、再退回旧的 `snap.current`(兼容旧宿主);列表快照读不到时**保留旧 watch**,不把「读不到」当「用户离开了」;id 解析不到时不再当终态——`list.subscribe` 还在就等下一次 publish(`publishRetention()` 会把新的 `retainedBy` 推进列表,所以切换会话照样能驱动重绑),只有连 `list.subscribe` 都拿不到才走 120ms 有界重试。上一版的教训在这里再次成立:**主题不声明 `inject`,所以「服务/字段暂时拿不到」必须永远留在等待态,而不是终态。**
889
+
890
+ - **子开关在已挂载时失效。** 为避免每个流式 token 触发重排而加的快速返回,把开关协调代码一起跳过了。
891
+
892
+ - **TDZ 崩溃隐患。** 拆除函数赋值的 `let` 声明在它下方,而该函数经 `unmount()` 可达,`typeof` 也挡不住这种 `ReferenceError`。
893
+
894
+ ### 六类:写法统一
895
+
896
+ 强调色此前存在两种写法(CSS 里是 hex,设置行文案与画布描边是 `rgb()`/`rgba()`),这是「同一个颜色的两份拼写各自漂移」的温床。现已统一为十六进制:
897
+
898
+ - 画布描边改用 **8 位十六进制** `#RRGGBBAA`。这一步先在真实浏览器里验证过再落地:canvas 会把 `#14d0d045` 规范化为完全相同的 `rgba(20, 208, 208, 0.267)`,实测绘制像素 alpha 为 68/255;
899
+ - 设置行文案改为标注 `#14d0d0`;
900
+ - 仅保留 `--edge-accent-rgb` 这一个通道列表,因为约 30 处半透明色块用的是 `rgba(var(--edge-accent-rgb), α)`。
901
+
902
+ 试过用 `color-mix()` 把它也去掉:数值上完全等价(实测 `color(srgb 0.0784314 0.815686 0.815686 / 0.16)` 即 `rgba(20, 208, 208, 0.16)`),但**序列化形式不同**,会改变 30 多处色块的计算值字符串。为删掉一个派生量去动 30 处、并让现有断言全部跟着改,是纯风险无收益,故保留——它与 hex 紧邻声明,不会各自漂移。
903
+
904
+ 等高线描边的 alpha **不是照抄黄色的**:相同 alpha ≠ 相同存在感。初版青色按黄色的 0.20 叠在 `#101110` 上实测只有 1.332:1,而黄色是 1.734:1——低 23%,观感上像功能变弱了。因此按「合成后对比度对齐」分别调参:
905
+
906
+ | | 描边 | 合成对比度 | 相差 |
907
+ | --- | --- | --- | --- |
908
+ | 谷地黄 暗色 | `#fff50033`(α 0.20) | 1.734:1 | — |
909
+ | 武陵青 暗色 | `#14d0d045`(α 0.27) | 1.758:1 | **+1.4%** |
910
+ | 谷地黄 亮色 | `#beaf006b`(α 0.42) | 1.290:1 | — |
911
+ | 武陵青 亮色 | `#14d0d07a`(α 0.48) | 1.288:1 | **−0.2%** |
912
+
913
+ 亮色青色无需像黄色那样另配一个压暗值(`#beaf00`),因为 `#14d0d0` 并不接近纸白。
914
+
915
+ ### 七类:语义后缀对了,子树位置却变了(同一个 bug 犯了两次)
916
+
917
+ 会话头部的 agent-preset 徽章(「创造模式」)在 0.1.5-rc.2 上悄悄退回上游的灰色胶囊——没有任何报错,因为**匹配不到任何元素的选择器不会报错**。
918
+
919
+ 旧规则 `[class$='_centerCol'] [class$='_header'] > [class*='_label']` 的后缀全对、哈希也没钉,前三条铁律条条满足,它错在**位置**:`>` 要求这个标签是 header 行的直系子节点,而真实 DOM 里它深在四层之下。第一次修复把 `>` 下移一层到 `_headerActions`——**还是错的**,因为 header 渲染插槽时,插槽会给**每一个条目再包一层没有 class 的 div**:
920
+
921
+ ```
922
+ div.pI_x6G_centerCol
923
+ > header.wSkVaW_header
924
+ > div.wSkVaW_titleRow
925
+ > div.wSkVaW_titleCluster
926
+ > div.wSkVaW_headerActions
927
+ > div ← 插槽的条目包裹层,className 为空字符串
928
+ > span.SVAs4q_label ← 徽章
929
+ > svg.SVAs4q_icon
930
+ ```
931
+
932
+ 也就是说:**凡是靠 `>` 数层数的写法,都在赌上游渲染几层包裹;上游这一版加了两层。** 而包裹层本身没有 class,连"点它的名"这条退路都没有。
933
+
934
+ **为什么第一次"修完"看起来像没生效。** 在浏览器控制台里量了一次真实 DOM,得到的三个值把三种可能一次分清了:`HASNEW true`(我改的规则**已经到页面**,所以不是缓存/没刷新)、`ACCENT #fff500`(变量有值,不是变量问题)、`BG rgb(36, 38, 36)`(最终仍是上游灰底)+祖先链里那个 `DIV.`。**一条"已经生效但什么都没匹配到"的规则,和一条"根本没送到浏览器"的规则,症状完全一样**;不先在页面里量这三个值,只会在"是不是缓存"和"选择器又写错了"之间来回猜——本次就是这样多花了一整轮。
935
+
936
+ **为什么整套验证都没发现。** `test/shoot.js` 的截图夹具先后把标签直接挂在 `.wSkVaW_header` 和 `.wSkVaW_headerActions` 底下,**两次都是照着当时的选择器搭的**;`test/preset-chip.test.js` 的夹具也一样漏了那层无 class 包裹层。于是选择器在夹具上命中、截图正常、测试全绿,而真实页面一个元素都没匹配到。这是本次最值得记的教训:**夹具一旦是为了"让选择器通过"而搭的,它就从验证退化成了同义反复。**
937
+
938
+ **最终修法:不再数层数,改用徽章自身的特征。**
939
+
940
+ ```
941
+ [class$='_centerCol'] [class$='_header'] [class$='_headerActions'] [class*='_label']:has(> svg)
942
+ ```
943
+
944
+ 作用域停在 `_headerActions`,目标由徽章**自己**的性质确定——它是该插槽里唯一带图标的 `_label`。这条判据是有依据的:同一插槽里 jobs 渲染出的是 `_root` / `_trigger` 根,它那些 `_label` 出现在下拉菜单**深处**且**只有文字**;schedule 模块里根本没有 `_label` 这个局部名。**把裸后代匹配当反例做了变异验证**(去掉图标判据)——jobs 的行标签立刻被刷成强调色,说明这条判据不是装饰。
945
+
946
+ 另外一处不是选择器问题,而是**越界**:主题在这里的职责是**配色**,不是几何。第一次修好选择器时,我顺手按旧注释里的"撑满动作行"意图把包裹层压平(`display:contents`)并让 `_headerActions` 增长(`flex:1 1 auto`),结果在真实头部上变成一条**横贯整个会话列的黄色长条**(实测 976px 的行里占了 923px),被原样反馈回来("现在变成一个长条了")。那套几何是 0.1.2-rc.1 时代为"徽章是 header 行直系子节点"写的,骨架变了以后照搬只会得到另一个坏结果。**结论:只改颜色,尺寸与命中区域沿上游**(高度、180px 上限、超长省略号全部保留)。这条也写进了代码注释,防止下次又被人按旧注释"还原"回去。
947
+
948
+ **变异验证 5 类,全部报错**:换回第一次修完的那版选择器(漏包裹层)→ 2 条断言红(背景正是线上看到的灰底);把图标判据换成裸后代 → jobs 标签与"容器外 `_label`"两条反向断言红;重新加回压平 + 增长 → 两条"变成长条"断言红。
949
+
950
+ `test/preset-chip.test.js` 现在按**量出来的**骨架搭夹具(含那层无 class 包裹层,以及一个"根带 class、`_label` 藏在菜单里"的 jobs 式兄弟条目),断言**结果**而不是选择器文本,并且**同时守住两侧**:既不能没上色,也不能被拉成长条。
951
+
952
+ ### 八类:子串选择器命中了**容器**(`_add` → 设置 › 模型的包裹层)
953
+
954
+ 反馈:设置 › 模型中「添加模型提供商」被鼠标指上去时,整条虚线按钮变成实心信号黄,文字近乎不可见(截图像素:底色 `#fff500`、文字 `#f5f5f0`)。
955
+
956
+ 根因不是"底色归主题、文字归应用"(一类),而是**规则命中了不该命中的元素**:输入区 `+` 的钩子是裸子串
957
+
958
+ ```css
959
+ body[data-ds-dark-theme] [class*='_add']:not([class*='_addButton']) { color: var(--edge-accent) !important; }
960
+ body[data-ds-dark-theme] [class*='_add']:not([class*='_addButton']):hover { color:#000 !important; background: var(--edge-accent) !important; }
961
+ ```
962
+
963
+ 而"添加模型提供商"的 `<button>` 上游用**一个普通 `<div>` 包着**(0.1.7 bundle 里 `_3nPmjq_addActions`,按钮是 `_3nPmjq_addButton`)。`_addActions` 不含 `_addButton`,于是它**通过**了 `:not()`;它又是 `div`,`:disabled` 永不成立;**指针停在按钮上时包裹层同样处于 `:hover`**。实测(真实鼠标 hover,暗色·谷地黄):
964
+
965
+ | 元素 | 属性 | 修复前 | 修复后 |
966
+ | --- | --- | --- | --- |
967
+ | `div._3nPmjq_addActions`(包裹层) | background | **rgb(255,245,0)** | rgba(0,0,0,0) |
968
+ | `button._3nPmjq_addButton`(按钮) | color / background | rgb(245,245,240) / rgba(255,245,0,.18) | 不变(上游值) |
969
+ | 合成后字形对比度 | — | **1.05:1**(夹具里量到 1.06:1) | 9.65:1 |
970
+
971
+ 按钮自己的半透明淡底(α 0.18)盖在包裹层的实心黄上,等于没盖;虚线边框是**按钮自己**的,所以画在黄底之上——这正是截图的样子。`:not()` 挡不住它:**包裹层不是按钮**,而且每出现一个含 `_add` 的新容器,就得再补一条 `:not()`,一个反馈补一条。
972
+
973
+ 顺着审计安装态 bundle 里所有含 `_add` 的类名,命中面是:
974
+
975
+ ```
976
+ RlGAzG_add 输入区 + 按钮 ← 目标
977
+ _3nPmjq_addActions 设置 › 模型 添加块包裹层 <div> ← 本次反馈
978
+ _3nPmjq_addBlock / _addCard / _addModes / _addPanel / _addModelButton
979
+ _0SbxAa_add deliverables 文件 diff 的"新增行"
980
+ LFNH1G_added / kuvljq_added / pFy1Ka_diffAdded / haSm5q_promptDiffLineadded
981
+ qWvkEq_address* 侧栏浏览器的地址栏
982
+ fO69Vq_addButton / _3nPmjq_addButton (旧 `:not()` 排除的两个)
983
+ ```
984
+
985
+ **修法:按第三条铁律加作用域**,与 `_arrow` 完全同款——把 `_add` 限定在输入区容器内:
986
+
987
+ ```
988
+ :is([data-composer-seat], [class$='_composerSeat'], [class$='_composerHero']) [class*='_add']
989
+ ```
990
+
991
+ 作用域同时写上 `[data-composer-seat]`(上游给这个座位声明的 e2e 锚点)与既有的 `_composerSeat`/`_composerHero` 类钩子:`[class$=]` 只要上游给该元素追加第二个类就会**静默失效**,两个钩子取并集可以多扛一次重构。输入区内只有 `+` 一个类名含 `_add`,所以作用域不会误伤设置页、diff 行或地址栏。
992
+
993
+ **反向对照**(把 `client.js` 换回修复前那版跑同一脚本)——修复前如实报错:
994
+
995
+ | 用例 | 修复前 | 修复后 |
996
+ | --- | --- | --- |
997
+ | 谷地黄 · 暗色 · 模型页「添加模型提供商」hover | **1.06:1** + 外层容器 `rgb(255,244,0) ≈ accent` | 9.65:1,容器未被填充 |
998
+ | 武陵青 · 暗色 · 同上 | **1.75:1** | 11.16:1 |
999
+ | 输入区 `+` hover(暗色) | 18.31:1(本来正常) | 18.31:1(**未因收敛作用域而丢失**) |
1000
+ | 输入区 `+` 常态墨色 | `#fff500` | `#fff500`(作用域仍命中) |
1001
+
1002
+ `test/hover-check.js` 现在同时守这三面(编辑按钮 / 模型页添加按钮及其容器 / 输入区 `+`),`test/selector-guard.test.js` 新增第三条断言:**任何裸的 `[class*='_add']` 都要报错**,把这条钩子钉在输入区作用域里。
1003
+
1004
+ > 亮色模式本来就不受影响(该规则是 `body[data-ds-dark-theme]` 限定),测试也如实只在暗色下报红——这本身就是"作用域写对了"的证据。
1005
+
1006
+ ---
1007
+
1008
+ ## 验证方法论
1009
+
1010
+ 这些是**测试自己**踩过的坑。它们比被测代码的 bug 更值得记录,因为一个不可能失败的断言比没有断言更危险。
1011
+
1012
+ ### 稀疏掩码的一致率必须先算零假设基线
1013
+
1014
+ 视觉模型曾断言等高线图案存在「明显的左右镜像对称」。逐像素验证为**假**——镜像一致率 89.99%,而在 5.405% 墨迹覆盖率下,**纯随机的期望一致率就是 89.77%**(两侧同时为空即算一致)。真正的镜像会接近 100%。
1015
+
1016
+ ### 视觉模型的文字描述不能作为颜色结论的依据
1017
+
1018
+ 它曾断言某候选在亮色面板下「金色更亮」,而两个候选的亮色值完全相同;靠逐像素取色才纠正。
1019
+
1020
+ 同样,第一版取色脚本误测到灰色的计时数字而非标签本身(计时器有自己的不透明颜色,在亮底上比浅黄更「显眼」),改为量「无计时器」状态才得到正确数据。
1021
+
1022
+ ### 命中测试判断不了 `pointer-events: none` 的层叠
1023
+
1024
+ 水印带 `pointer-events:none !important`,`document.elementsFromPoint()` **永远看不到它**,该断言在结构上不可能失败——第一版测试因此对着确凿有 bug 的构建报「ok」。
1025
+
1026
+ 改为对比「水印可见 / 不可见」两版**真实截图**:下拉菜单是不透明的,若水印正确地在其后方,菜单内每个像素都必须逐位相同。
1027
+
1028
+ ### 两版渲染必须只差一个变量
1029
+
1030
+ 用开关增删水印会改变 DOM,实测导致文字位移、出现 148/255 的假差异,且菜单位置整体偏移 74px。改为「同一 DOM,仅把 alpha 置 0」。
1031
+
1032
+ 同理,菜单矩形不能取自另一次浏览器运行的 `getBoundingClientRect()`(实测偏差 74px),而是**在截图里按菜单自己的不透明底色定位**。测试还会先断言字形与菜单**确有重叠**,否则「无越界」只是一句空话——早期版本重叠仅 1984 px²,属于假通过。
1033
+
1034
+ ### 计算样式触发不了 `:hover`
1035
+
1036
+ `settings-buttons.test.js` 读计算样式,所以它用同特异度的 `.HOVERPROBE` 类替代 `:hover`(类与伪类特异度同为 0,1,0,层叠结果不变)。这是合理的层叠等价,但**反向对照暴露了它的边界**:把主题规则里真正的 `:hover` 那一半删掉,该测试**照样全绿**——因为 `.HOVERPROBE` 那一半独自扛住了。
1037
+
1038
+ 因此补了 `test/hover-check.js`:通过 DevTools 协议**真的移动鼠标**到按钮上,再截图量字形与填充的对比度。同样的反向对照下,它如实报出 1.05:1 / 2.61:1,与反馈截图逐位吻合。
1039
+
1040
+ ### 做「有字 / 无字」差分时要保留一个零宽空格
1041
+
1042
+ `inline-block` 里没有任何在流内容时高度会塌成 0(`line-height` 只作用于由内容产生的行盒),空文字那版会连**背景**都不画,差出来的是背景而不是字形——第一次就是这样量出「假可见」的。
1043
+
1044
+ ### 虚拟时间会快进过要拍的那一帧
1045
+
1046
+ `--virtual-time-budget=4000` 会快进过雷霆大字的 3 秒自动隐藏,于是四张截图全是空页面,而初版脚本只检查「PNG 文件存在」,照样报成功。现在既把预算压到 1400ms,又去解码像素验证。
1047
+
1048
+ 而 `--force-prefers-reduced-motion` **后来必须撤掉**:系统偏好与「动画关闭」走的是同一条静态分支,继续强制它就意味着「静态标记根本没打上」这种默认态回归也照样拍得完美,测试再也分辨不出好坏。
1049
+
1050
+ ### 计数断言不能用同一个集合既当预期又当计数器
1051
+
1052
+ 设置页行数断言原先以 `ROW_KEYS` 既当预期集合又当计数器,于是**未登记的新行会被静默忽略**——加入「大字入场动画」后屏幕上已是 10 行,断言却仍以 9 通过。现在额外按「分组容器的直接子节点」独立数一遍并交叉比对。
1053
+
1054
+ ### 定时器泄漏要让两个到期时刻错开才观测得到
1055
+
1056
+ 「提前关闭会取消 3 秒定时器」原写法是「关闭后立刻再播报一次」,但那样两块遮罩的到期时刻会重合,泄漏的定时器销毁第二块时它本来也该消失了。必须把两个到期时刻错开:t≈0 关闭、t≈1.5s 再播报、t≈3.2s 采样——此时旧定时器若还活着就会把新大字提前 1.3 秒抹掉。
1057
+
1058
+ 而「监听器泄漏」在 DOM 上完全不可见(多余的副本只是重复调用同一个幂等销毁函数),只能靠给 `document.addEventListener` / `removeEventListener` 计数来核账。
1059
+
1060
+ ### 沙箱对象展开会带上幂等标志
1061
+
1062
+ `{ ...sandbox }` 会把 `__dshThemeEndfieldApplied` 一起复制,导致两个「新沙箱」用例的 `apply()` 其实在第一行就返回了。现已显式清除该标志。
1063
+
1064
+ ### 按固定字节长度截取源码的断言会悄悄失去覆盖
1065
+
1066
+ 一条样式契约断言用**固定字节长度**截取样式表,在该节注释变长后就再也覆盖不到 `prefers-reduced-motion` 那一段,报了「规则缺失」而它其实好好地在下面两行。现已改为截到模板串结束。
1067
+
1068
+ ### 注入式自检必须断言注入本身生效
1069
+
1070
+ `selftest.js` 会把真实 bug 注入 `client.js` 的**副本**并断言 `check.js` 确实失败——同时断言注入本身生效,避免「测试其实什么都没改」的空跑。
1071
+
1072
+ 这条护栏当场发挥过作用:调色板重构把渐变色标从字面量换成了 `var(--edge-status-*)`,于是针对 `#6b5d00` / `#fff500` 的注入不再匹配、变成空跑,脚本立刻报「INJECTION DID NOT APPLY (test is vacuous)」而不是假装通过。另外两个坑也是这样暴露的:`--edge-accent-deep` 两套配色各定义一次,只删一处不算删掉;本仓库是 **CRLF**,注入模式里写字面 `\n` 永远匹配不上(改用 `\r?\n`)。
1073
+
1074
+ ### 上游把菜单改成半透明材质后,「不透明」就成了主题的责任
1075
+
1076
+ 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 种颜色),面板就读成「没有背景」——用户报的是「菜单的背景没了」。
1077
+
1078
+ 这类失效最难定性:不报错、不写日志,主题里也没有任何「写错」的选择器——**变的是上游默认值的语义**。修法是认领令牌(`--dsw-menu-surface-fill` = 本主题的 `--dsw-alias-bg-overlay`,一个令牌同时覆盖 `--dsw-specific-menu` 与全部菜单),而不是给上游的哈希类名补一条选择器(那也过不了 `selector-guard`)。
1079
+
1080
+ 反方向的坑同样要防:把令牌钉成页面底色(`--dsw-alias-bg-base`)也是「没有背景」,只是换成了看不见的实心块——应用自己在 macOS 上的菜单 backing 用的就是 `--dsw-alias-bg-base`,所以这不是假想。测试因此**同时**断言「不透明」与「与页面底色不同」,并用像素证明菜单矩形内没有任何东西透出来。
1081
+
1082
+ ### JS 块注释里出现 `*/` 会让整个 bundle 静默不注册
1083
+
1084
+ 给令牌写说明时顺手把平台默认值写成 CSS 风格的行内注释(`/* light, 58% alpha */`),其中的 `*/` 提前闭合了外层 JS 块注释,于是剩下的文字落进代码——`client.js` 抛 `SyntaxError: Unexpected token '--'`,整个模块无法注册(`__ModuleLoader__` 拿到的模块是 `undefined`),而主题只是「没生效」,页面看起来像主题关着。`check.js` 的 `vm.Script` 编译检查能在进程内抓到它,所以**任何一次 `client.js` 改动之后都要先跑 `node check.js`**,再去跑浏览器测试。
1085
+
1086
+ ### 无头环境的 rAF 不能用来采样性能
1087
+
1088
+ headless 会挂起 / 合并 rAF,导致无论怎么设虚拟时钟都只采到 **n=1**,而没有分布支撑的数字不算测量。`contour-perf.test.js` 因此按函数名把算法源码从 `client.js` 里**原样切出**后在紧循环里计时,并丢弃前两次采样(冷启动含 JIT 预热,否则会把启动成本报成稳态成本)。
1089
+
1090
+ ### 幂等标志会把「改了代码但页面没变」变成一次静默空转
1091
+
1092
+ `apply()` 顶部的 `window.__dshThemeEndfieldApplied` 布尔守卫是为重复挂载写的(boot loader + cordis 组合都会挂一次)。但 `dsh-client-modules` 的 `rebuilt()` / HMR 会把新 bundle 送进**已经跑过旧 build** 的长驻标签页,而这条路**不会走 dispose**:页面同时持有旧样式表和旧构建写下的布尔标志,于是新代码的 `apply()` 在第一行返回,旧 CSS 一直挂到标签页关闭。不报错、不写日志,用户看到的就是「弹窗还是没变」,而源码、门禁、`node check.js` 全是绿的。
1093
+
1094
+ 修法是给标志配一个 build marker(`SHEET_MARKER`,同时以注释形式落在样式表首行):同 build 重复挂载照旧短路,而「标志在、marker 缺失或不等」正是重建换装的签名,`apply()` 会先摘掉旧样式表再正常安装。marker 写在样式表里还有个副作用是好事——挂的是哪一版可以直接读出来:
1095
+
1096
+ ```js
1097
+ document.querySelector('style[data-plugin="dsh-theme-endfield"]').textContent.slice(0, 80)
1098
+ ```
1099
+
1100
+ `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。
1101
+
1102
+ 同源的投递事实一并记下:combo 的 `rev` 随内容变(本次 `b0a8114dfd13` → `94f9c40e7ee8`),所以**普通重载**就能拿到新代码;但**旧 rev 的 URL 是 `immutable, max-age=31536000`**,只要标签页还捧着旧 URL,浏览器永远不会自己去取新的——这也是「服务端明明是新代码、页面却是旧样式」的一半原因(另一半就是上面那个标志)。
1103
+
1104
+ ### 形状结论只能来自像素,命中测试量不出 border-radius
1105
+
1106
+ 「两端到底是半圆还是圆角矩形」这件事上,`document.elementFromPoint` 逐行扫左边界给出的是**假象**:同一套例程对准一个显式 `border-radius: 999px` 的纯 `div`,得到的 profile 与真按钮**逐字节相同**(`maxOffset=10`、`firstMaxIndex=35`)。该几何下命中测试根本分辨不出半径,第一版据此报出的「radius 6px / ROUNDED RECTANGLE」是测量假象。
1107
+
1108
+ 可用的判据是截图逐行找最左深色像素(自己用 `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)。**两条教训**:形状类断言不要走命中测试;对照实验里被测变量之外的每一维都必须相同。
1109
+
1110
+ ### 「渲染器不可靠」是个昂贵的结论,先查投递和挂载的是哪一版
1111
+
1112
+ 余额胶囊曾经把「两端被渲染成 squircle」归因为**该渲染器不可靠**(`border-radius` 的 14px → 999px 写回被判定为不可信),于是改用两只 `radial-gradient` 圆盘加中间渐变带来画端点。径向渐变实现本身没问题(它不依赖半径命中,端点半径随高度补间也更好控),但**那个理由站不住**:本轮像素复核显示同一个渲染器把 999px 和渐变端点都渲染成了真体育场(轮廓一致,首行偏移恰好等于半径),而当时更可能的真相是 round 模式下主题的 blanket 直角化整段不生效、上游 `--dsw-radius-md` 的小圆角露了出来;再往后「已提交版本里根本没有那几条胶囊规则」又叠了一层。
1113
+
1114
+ 归因顺序因此固定为:**① 页面挂的是哪一版样式表**(marker / 与工作区逐字节比对)→ **② 投递层**(combo `rev` / 缓存头 / 是否走了 dispose)→ **③ 才轮到渲染器**。前两步都能拿到硬证据,而「渲染器不可靠」既不能证伪,又会劝退后来人对 `border-radius` 的使用。
1115
+
1116
+
1117
+ ### 「渲染器不可靠」后来真的抓到了:是 app 根上的 `corner-shape: superellipse(1.5)`
1118
+
1119
+ 上一节的结论(「同一个渲染器把 999px 渲染成了真体育场」)只在**当时的渲染器**上成立。2026-10-06 在用户的浏览器(Chrome 154)上复核「确认启用完全权限?」胶囊时拿到了完整的因果链,两个渲染器都对,分歧在于 **CSS `corner-shape`(Chrome 139+ 实现)**:
1120
+
1121
+ - 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` 找补;
1122
+ - 所以 computed `border-radius: 999px`(钳到 h/2=18px)**没有任何异常**,但画出来是 superellipse 曲线:36px 按钮的圆等效端头只有 ~14px,逐行 inset 与用户截图逐点吻合(9,6,5,4,3,2);
1123
+ - 同页对照实验:`999px`、`18px`、`9999px` 三个纯 div 画出来**逐字节相同**(拟合 r≈12.5),只有 `14px` 分离——说明钳制正确、是曲线形状被换了;补上 `corner-shape: round` 后同一按钮拟合 r=17.8,恢复真体育场;
1124
+ - 桌面端的 Chromium 若未实现 `corner-shape`,`@supports` 整段跳过, stadium 依旧为真——这就是两轮测量「都对」而结论相反的原因。
1125
+
1126
+ 主题侧的修法(本次 `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`,余额胶囊家族按继承豁免)。
1127
+
1128
+ 余额胶囊「用渐变画端点」的方案因此依然成立但理由更新:它不是绕开「不可靠的渲染器」,而是绕开「宿主把圆角形状全局改成了 superellipse」这一**按规范实现的特性**。
1129
+
1130
+ ### 确认框第二轮:白条两端全宽、上下遮罩、胶囊骑在白条下沿上(2026-10-06)
1131
+
1132
+ 用户对着 Endfield 参考图复核确认框,落定三条事实:白条只横向通宽;上下是遮罩不是全屏;**胶囊对骑在白条的下沿上——上半在白条里、下半落在遮罩上**;且**深色模式下白条也得是白的**。
1133
+
1134
+ 实现上的三个关键点,都来自对真实 DOM/CSS 的核对:
1135
+
1136
+ - **遮罩的真实来源**:Modal 的变暗不是 `_mask_o6lrb_18` 元素本身(它只出 `backdrop-filter`),而是它的 `::after` 画 `var(--dsw-alias-bg-mask-1)`(实测 `#00000080`)。胶囊下半的「落在遮罩上」因此不需要主题画任何东西——对话框盒子下缘以下的部分只要**透明**,遮罩自然透出来。
1137
+ - **骑缝的做法**:白条是背景不是伪元素——`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。
1138
+ - **深色模式白底黑字**:白条直接钉死字面量 `#f5f5f0`(Endfield 白;浅色 paper `#e8e8e2` 略灰,两档统一取参考图的亮白)。墨色靠**在对话框作用域内重声明 label tokens**(`body[data-ds-dark-theme] [role='dialog'][class*='_confirmation'] { --dsw-alias-label-primary: #101110; … }`)——应用文案全是 var() 引用,作用域内换 token 一次翻全所有引用,而自带颜色的元素(危险红警告行)不受影响;标题/正文/关闭按钮再补显式 `!important` 兜底。
1139
+
1140
+ 上一轮的灰白格纹角标(`--endfield-check` + 对话框 `::before/::after`)按用户要求整体移除,对话框的伪元素随之空闲。
1141
+
1142
+ ### 确认框第三轮:深色遮罩压到黑、禁用胶囊按部件减淡(2026-10-06)
1143
+
1144
+ 用户复核深色模式,报了两条:遮罩发灰;未勾选时确认胶囊「变灰而不是半透明」。两条的根都在**半透明叠了两种底**上:
1145
+
1146
+ - **遮罩灰**: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%)不受影响。
1147
+ - **禁用胶囊灰**: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` 减淡,阴影去掉。两截从此同色,黄也不再变灰。