dsh-theme-endfield 1.2.2 → 1.2.4

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.
@@ -103,7 +103,7 @@ node scripts/build-sounds.js --check # 校验生成物与定义一致
103
103
 
104
104
  | 平台 | 播放器 |
105
105
  | --- | --- |
106
- | Windows | `pwsh.exe` → `powershell.exe`(`-EncodedCommand` + `Media.SoundPlayer`) |
106
+ | Windows | `powershell.exe`(5.1)→ `pwsh.exe`(`-EncodedCommand` + `Media.SoundPlayer`) |
107
107
  | macOS | `afplay` |
108
108
  | Linux | `paplay` → `aplay` |
109
109
 
@@ -188,7 +188,7 @@ body:not(.theme-endfield-round) [class$='_iconButton'] { border-radius: 50% !imp
188
188
 
189
189
  唯一需要 JS 介入的表面是**等高线画布**——canvas 描边不能是 CSS 变量,所以它由一个监听 `<body>` class 的 `MutationObserver` 触发重绘(顺带让「别的标签页改了配色」也能跟着变)。
190
190
 
191
- 配色切换后的实测像素占比(`test/verify-shots.js` 解码真实截图统计):
191
+ 配色切换后的实测像素占比(`test/tools/verify-shots.js` 解码真实截图统计):
192
192
 
193
193
  | 截图 | 黄色像素 | 青色像素 |
194
194
  | --- | --- | --- |
@@ -53,17 +53,18 @@
53
53
 
54
54
  关键差异,逐条都有对应的防护:
55
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` 会逐字段断言这一点(漏一个就是「设置保存不了」的现代写法)。
56
+ - **只有 `.volatile()` 字段可编辑。** `ctx.settings` 只把 volatile 路径投影成表单,写非 volatile 路径会直接报 `Config field "x" is not volatile`;整份 schema 一个 volatile 字段都没有时该条目**根本不出现**在设置里。所以 `index.js` 的 `Config` **34 个字段全部 `.volatile()`**,`test/settings-config-forms.test.js` 会逐字段断言这一点(漏一个就是「设置保存不了」的现代写法)。
57
57
  - **两个 schemastery 必须区分。** DSH 同时装了带 `.volatile()` 的 `@deepseek-ai/schemastery`(3.18.4)和不带它的旧 `schemastery`(3.18.0)。`index.js` 的 `loadSchemastery(true)` 会逐个候选检查 `.volatile` 是否真的存在,找不到就返回 `undefined`(本插件退化成无需配置,而不是挂一个假表单)。
58
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
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`),所以除了「取句柄」和「写返回」之外整条存储逻辑是同一份。
60
+ - **旧宿主仍然可用。** client 在找不到 `configForms` 时回落到 `ctx.settingsScope`(`binder.bind({ namespace: 'dsh-theme-endfield', decode })`),Host 在 `settings.register` 存在时才注册旧命名空间。两代 transport 的快照字段基本同名(`status` / `value` / `writable`,0.1.7 另有可选 `mode`),所以除了「取句柄」和「写返回」之外整条存储逻辑是同一份。
61
+ - **三代设置快照的 `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 判定复用同一个谓词,避免在健康页面上误报。
61
62
 
62
63
  ### Host ↔ Client 数据流
63
64
 
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)` 现读本段,天然随值变化。
65
+ 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 改动。
66
+ 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 拒绝/跳过)时同样重新标脏等待下次回相,而不是假装写成功。
67
+ 3. **读 / 生效**:transport 的 `getSnapshot().value` 就是已由 schema 校验并合并默认值的整段(client 再经 `prefsResolveSection()` 归一化到 34 个声明字段);主题层的 `isEnabled()` 与其它 getter 每次调用都 `prefsGet(field)` 现读本段,天然随值变化。
67
68
  4. **订阅同步**:`form.subscribe(...)` / `scope.subscribe(...)` 在每次落盘/镜像变化时唤醒,client 再跑一遍 `reconcileFromPrefs()`,把主题开关(enabled → mount/unmount token+样式表)、圆角/配色 class、水印、等高线、雷霆大字重新对齐。这样同 profile 里**另一个窗口/设备**编辑落盘文件(或本轮写入被 Host 回相确认)都不需要刷新即可热生效。订阅返回的 disposer 现在会被保存并在 run 拆除时调用:`ConfigForm` 是 provider 拥有、跨插件共享的实例,漏掉它会把这一个监听器泄漏给同页面的下一次 run。
68
69
  5. **启动恢复**:`apply()` 早于 transport 就绪时,读 schema 默认值(内存镜像),一旦 `status:'ready'` 的第一个真值镜像到达就切换到持久值——即使 Desktop 在随机端口上启动,也能立刻恢复到上次的设置。
69
70
 
@@ -101,7 +102,7 @@ v1.1.4 之前的判据是「**必须**找到带 `.volatile()` 的 schemastery,
101
102
  - **判据写进自检报告**:新增 `schemaMode` 记录走了哪条路;`resolution` 每一行现在除 `resolved` / `error` 外还带 `loaded` / `loadError` / `volatile` / `marker`。「解析得到但加载失败」以前在报告里读起来像自相矛盾,现在是一行结论。
102
103
  - **不再有「默认跳过」的断言。** `settings-config-forms.test.js` 的 Host 侧断言在拿不到 schemastery 时**整段跳过**,而拿不到 schemastery 恰恰是它要守的那个场景——于是在唯一要紧的环境里它什么都没验。新的 `settings-config-fallback.test.js` 不依赖本机 schemastery:它把**显式 builder**(含「只有 `.extra()`」这一形状)交给 `buildSchemaWith()`,逐字段断言默认值与 `meta.volatile`,并断言选择顺序(native 优先、`.extra()` 兜底、两者皆无则不导出)。
103
104
 
104
- ### 宿主入口为什么必须是 ESM:`require(esm)` 的启动竞态
105
+ ### 求值期 `require(esm)` 的启动竞态与负缓存
105
106
 
106
107
  v1.1.5 解决的是「副本没有 `.volatile()`」,这一段解决的是**同一句 `require` 本身会失败**——两者症状一模一样(`Config === undefined`、无表单、刷新复位),但成因完全不同,且后者在**全新启动、只装本插件**的 profile 上同样复现。
107
108
 
@@ -119,23 +120,13 @@ because it is not yet fully loaded.
119
120
  - **与第三方插件数量无关**:`dsh-base` / `dsh-web-app` 自己就 import cosmokit,所以把 profile 精简到只剩本插件也照样中招。缩减插件不是解法。
120
121
  - 与「dev-link 解析不到」也无关:那条路径下 `require.resolve` 会失败并留下 `error: MODULE_NOT_FOUND`;这里是**解析得到、require 抛错**。
121
122
 
122
- **为什么不能留在 CJS 里修。** `require()` 没有等待能力——必须在模块求值期同步拿到 builder(见上文 `Entry._init` 那条),而 cosmokit 的 job 还在飞就无法等待。ESM 加载器则**会排队**:同一句 `import` 会等 cosmokit 结束。
123
+ **为什么没有留在 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` 白名单里拿掉(那次提交只解除引用,文件本体仍留在工作树里,之后才被单独删除)。
123
124
 
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 都加载不了。
125
+ 因此**求值期这道竞态并没有被消除,只是被接受**:极少数启动里 `Config` 仍是 `undefined`(无表单、刷新复位),靠下面的负缓存修复把损失限制在单次求值内,靠整进程重启恢复。只有当将来又必须在求值期拿到 builder、而 DSH 的挂载顺序对这道竞态零容忍时,才需要重新评估加回 ESM 入口。
135
126
 
136
127
  **同一分支顺带修掉的第二个缺陷:负缓存。** `SCHEMA_SCAN_CACHE` 是进程级、以 registry Symbol 为键、**永不失效**,而且连失败结果也缓存。于是一次竞态失败被永久记成「本进程没有 schemastery」:插件重新挂载(模块重新求值)也救不回来——这正是「重启后的热挂载同样 `absent`」的原因。现在只有**可用**的扫描结果才入缓存(`cacheableScan`),`take()` 也拒收建不出 schema 的半求值模块(`exports` 还是 `{}` 时是 truthy,否则会被当成候选记下来)。实测:竞态后同进程重新求值仍失败 → 改为「可用才缓存」后,重新求值即可自愈。
137
128
 
138
- **验证(真实启动内的对照)。** 同一次 `dsh web` 启动里同时观察两条路径:
129
+ **验证(真实启动内的对照)。** 同一次 `dsh web` 启动里同时观察三种取用方式,这是记录当时现象的证据,不是当前入口形态的描述(当前包只有 `index.js` 一个入口):
139
130
 
140
131
  | 探针动作 | 结果 |
141
132
  | --- | --- |
@@ -143,7 +134,7 @@ because it is not yet fully loaded.
143
134
  | `import('@deepseek-ai/schemastery')`(ESM) | ✅ 成功,`.volatile()` 可用 |
144
135
  | 预热**之后**再 `require` 本插件 `index.js` | ✅ `Config` 建出(`nativeMarker: true`) |
145
136
 
146
- `test/host-esm-entry.test.js` 把入口的转出面(`default` 上的 `Config`/`apply`/`name`)与「没有 schemastery 时仍能加载」的降级路径固化成断言;真正的竞态只能在真实宿主启动里观察,所以那段对照留在本节作为判据。
137
+ 那段对照只能在真实宿主启动里观察到,所以留在这里作为判据;当时用它固化入口行为的 `test/host-esm-entry.test.js` 已随 `index.mjs` 一起删除。
147
138
 
148
139
  ### DSH 0.2.0-rc.2 上失效的三处应用侧钩子(v1.1.6 已跟进)
149
140
 
@@ -292,7 +283,7 @@ body{font-family:var( --dsw-font-family, -apple-system, … )}
292
283
 
293
284
  ### 等高线 worker 是特例
294
285
 
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` 就必须重新生成。
286
+ `src/contour/contour-worker.js` + `src/contour/contour-webgl.js` 由 `npm run build:worker` 把 9 个 kernel 函数抠出来,嵌成 `src/client/21-contour-worker.embed.js` —— **那是生成物,不要手改**。它整块是一行 `JSON.stringify` 出来的字符串,所以 `check.js` 的样式表护栏看不见它;改了 `src/contour/contour-worker.js` 就必须重新生成。
296
287
 
297
288
  ### 门禁与报错
298
289
 
@@ -479,11 +470,11 @@ client.js is stale (first difference at line 4096); run npm run build:client
479
470
 
480
471
  上面所有常量——21 条高度带、22 个凸起——都是**按桌面窗口的观感**调出来的,却按「每面积」施加:窗口缩到手机宽度,同样的线数与岛数挤进一小条竖带,嵌套环密到读作乱线。375×812 实测(暗色):**62 条路径 / 23,890px 墨长**,单屏叠出 8~9 层同心环。
481
472
 
482
- 杠杆选的是**层级数**:线距 = `span*2/LEVELS`,直接按比例缩放屏幕上的线间距。短边 **<640px** 时层级数从 20 向 10 线性递减(320px 触底,下限 6),凸起数从 22 向 12 同步递减(让岛屿保持分离而不是叠成靶心);**≥640px 逐字节回到出厂值**,桌面窗口输出不变。640 这个锚点取在「手机宽(360–430)与窄分屏不再主导短边」之上,320 触底保证最小可用窗口仍是一幅完整地形。
473
+ 杠杆选的是**层级数**:线距 = `span*2/LEVELS`,直接按比例缩放屏幕上的线间距。短边 **<640px** 时层级数从 20 向 10 线性递减(320px 触底,下限 6),凸起数从 22 向 10 同步递减(让岛屿保持分离而不是叠成靶心);**≥640px 逐字节回到出厂值**,桌面窗口输出不变。640 这个锚点取在「手机宽(360–430)与窄分屏不再主导短边」之上,320 触底保证最小可用窗口仍是一幅完整地形。
483
474
 
484
475
  三处消费同一组函数(`contourLevelsFor` / `contourBumpsFor`,以视口短边为锚):布隆布局 `contourBuildCandidate`、覆盖率校验 `contourCoverageScore`、抽取 `contourExtract`。其中校验器必须按「该视口**实际将画的**层级数」算墨量代理——若仍按 `CONTOUR_LEVELS` 评分,10 级的小屏会被要求 2 倍墨量,重抽循环每次都触顶。
485
476
 
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 一节的教训)。
477
+ 改造后 375×812 实测:路径数降到 ~54(另有种子随机性),最薄分区墨迹 **3.56%**(8×5 网格,0 空白格),嵌套环从 8~9 层降到 3~4 层。回归锁在 `test/contour-density.test.js`(≥640px 断言出厂值逐值相等;小屏断言按公式递减且能完整成图)。注意 worker 内核里 `src/contour/contour-worker.js` 有一份**手工镜像**的同名函数——构建脚本只抠函数不抠常量,闭包名缺失会在 worker 首帧抛 ReferenceError(见 worker 一节的教训)。
487
478
 
488
479
  ### 重启动画的首帧曾是空转
489
480
 
@@ -933,7 +924,7 @@ div.pI_x6G_centerCol
933
924
 
934
925
  **为什么第一次"修完"看起来像没生效。** 在浏览器控制台里量了一次真实 DOM,得到的三个值把三种可能一次分清了:`HASNEW true`(我改的规则**已经到页面**,所以不是缓存/没刷新)、`ACCENT #fff500`(变量有值,不是变量问题)、`BG rgb(36, 38, 36)`(最终仍是上游灰底)+祖先链里那个 `DIV.`。**一条"已经生效但什么都没匹配到"的规则,和一条"根本没送到浏览器"的规则,症状完全一样**;不先在页面里量这三个值,只会在"是不是缓存"和"选择器又写错了"之间来回猜——本次就是这样多花了一整轮。
935
926
 
936
- **为什么整套验证都没发现。** `test/shoot.js` 的截图夹具先后把标签直接挂在 `.wSkVaW_header` 和 `.wSkVaW_headerActions` 底下,**两次都是照着当时的选择器搭的**;`test/preset-chip.test.js` 的夹具也一样漏了那层无 class 包裹层。于是选择器在夹具上命中、截图正常、测试全绿,而真实页面一个元素都没匹配到。这是本次最值得记的教训:**夹具一旦是为了"让选择器通过"而搭的,它就从验证退化成了同义反复。**
927
+ **为什么整套验证都没发现。** `test/tools/shoot.js` 的截图夹具先后把标签直接挂在 `.wSkVaW_header` 和 `.wSkVaW_headerActions` 底下,**两次都是照着当时的选择器搭的**;`test/preset-chip.test.js` 的夹具也一样漏了那层无 class 包裹层。于是选择器在夹具上命中、截图正常、测试全绿,而真实页面一个元素都没匹配到。这是本次最值得记的教训:**夹具一旦是为了"让选择器通过"而搭的,它就从验证退化成了同义反复。**
937
928
 
938
929
  **最终修法:不再数层数,改用徽章自身的特征。**
939
930
 
@@ -999,10 +990,37 @@ fO69Vq_addButton / _3nPmjq_addButton (旧 `:not()` 排除的两个)
999
990
  | 输入区 `+` hover(暗色) | 18.31:1(本来正常) | 18.31:1(**未因收敛作用域而丢失**) |
1000
991
  | 输入区 `+` 常态墨色 | `#fff500` | `#fff500`(作用域仍命中) |
1001
992
 
1002
- `test/hover-check.js` 现在同时守这三面(编辑按钮 / 模型页添加按钮及其容器 / 输入区 `+`),`test/selector-guard.test.js` 新增第三条断言:**任何裸的 `[class*='_add']` 都要报错**,把这条钩子钉在输入区作用域里。
993
+ `test/tools/hover-check.js` 现在同时守这三面(编辑按钮 / 模型页添加按钮及其容器 / 输入区 `+`),`test/selector-guard.test.js` 新增第三条断言:**任何裸的 `[class*='_add']` 都要报错**,把这条钩子钉在输入区作用域里。
1003
994
 
1004
995
  > 亮色模式本来就不受影响(该规则是 `body[data-ds-dark-theme]` 限定),测试也如实只在暗色下报红——这本身就是"作用域写对了"的证据。
1005
996
 
997
+ ### 九类:`will-change` 也建立 containing block(审批白条塌成一条线)
998
+
999
+ 审批白条是 `position: fixed; inset: 0` 的全屏遮罩 + 通栏纸带,靠「遮罩只在纸带上下露出」还原参考图。这条设计有一个从未写进铁律的前提:**面板到视口之间,没有祖先构成 containing block**。旧注释其实意识到了这件事,但结论写反了——它说「若将来有祖先带 transform/filter,纸带只是退回文档流,位置降级,不会坏」。
1000
+
1001
+ DSH 0.2.2-rc.1 给 composer seat 加了 `will-change: transform`。按 CSS Will Change 的规定,**会让元素生成 containing block 的属性出现在 `will-change` 里时,元素必须真的生成 containing block**——所以它与真 transform 等价。审批面板替换输入框、正好住在这个 seat 内部,于是 fixed 的参照系从视口变成 seat 的 padding box;fixed 又让面板脱离文档流,seat 唯一在流内的子元素随之消失、高度塌成 0:
1002
+
1003
+ | 量 | 修复前 | 修复后 |
1004
+ | --- | --- | --- |
1005
+ | seat 的计算值 `will-change` | `transform` | `auto` |
1006
+ | 白条(fixed 层)rect | `720×0`(列宽 × 0 高,贴在窗口底边) | `1200×800`(整个视口) |
1007
+ | 卡片 rect | `706..895`,下半截被视口裁掉 | `306..495`,居中 |
1008
+ | 胶囊 rect | 落在视口外,点不到 | `190×41`,`y=430` |
1009
+
1010
+ 「退化而不是坏掉」错在**以为 fixed 会失效退回静态定位**。fixed 不会退回:它照旧按 containing block 布局,只是那个盒子刚被自己清空。于是症状不是「位置偏了一点」,而是「整个对话框只剩一行字」。现场为此多花了 152 秒才点下审批(主题关闭时同样的操作 1–2 秒)。
1011
+
1012
+ 修法的方向也只能反过来:**CSS 无法从 fixed 后代内部逃出 containing block**,补丁必须打在祖先上,而且只在需要时打——
1013
+
1014
+ ```css
1015
+ body:has([data-approval-key]) :is([data-composer-seat], [class$='_composerSeat']) {
1016
+ will-change: auto !important;
1017
+ }
1018
+ ```
1019
+
1020
+ `!important` 不是保险,是必需的:上游那条是 `.<hash>_root[data-phase=active] .<hash>_composerSeat`(0,3,0),本规则(0,2,1)压不过。`:has()` 让上游的合成提示只在「没有审批在屏幕上」的帧里失效,代价压到最小。两个钩子取并集的理由同八类:`[class$=]` 在上游给该元素追加第二个类时静默失效,`[data-composer-seat]` 是上游自己声明的 e2e 锚点。
1021
+
1022
+ 这同时解释了为什么**报告者给的补丁能修好、但会退化**:把 root 改成 `position: relative` + 透明背景,面板回到文档流,功能立刻恢复,但通栏纸带被收进聊天列宽、上下遮罩一并消失——那是「把设计也一起删了」,不是等价修复。修在祖先上,两样都留着。
1023
+
1006
1024
  ---
1007
1025
 
1008
1026
  ## 验证方法论
@@ -1035,7 +1053,7 @@ fO69Vq_addButton / _3nPmjq_addButton (旧 `:not()` 排除的两个)
1035
1053
 
1036
1054
  `settings-buttons.test.js` 读计算样式,所以它用同特异度的 `.HOVERPROBE` 类替代 `:hover`(类与伪类特异度同为 0,1,0,层叠结果不变)。这是合理的层叠等价,但**反向对照暴露了它的边界**:把主题规则里真正的 `:hover` 那一半删掉,该测试**照样全绿**——因为 `.HOVERPROBE` 那一半独自扛住了。
1037
1055
 
1038
- 因此补了 `test/hover-check.js`:通过 DevTools 协议**真的移动鼠标**到按钮上,再截图量字形与填充的对比度。同样的反向对照下,它如实报出 1.05:1 / 2.61:1,与反馈截图逐位吻合。
1056
+ 因此补了 `test/tools/hover-check.js`:通过 DevTools 协议**真的移动鼠标**到按钮上,再截图量字形与填充的对比度。同样的反向对照下,它如实报出 1.05:1 / 2.61:1,与反馈截图逐位吻合。
1039
1057
 
1040
1058
  ### 做「有字 / 无字」差分时要保留一个零宽空格
1041
1059
 
package/docs/features.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  历史内存风格:这些开关最初存浏览器 `localStorage`。由于浏览器存储按「协议 + 主机 + 端口」的 origin 隔离,而 DSH Desktop 每次启动都在 127.0.0.1 绑定一个**随机临时端口**,端口一变 origin 就变,上次保存的设置永远读不到,表现为「重启后恢复默认」。已改用 DSH 官方的用户设置命名空间:
10
10
 
11
- - **DSH 0.2.0-rc.2 / 0.1.7-rc.1 及以后(当前)**:**Host 端(index.js)** 导出 schemastery `Config`,31 个字段全部 `.volatile()`——这两代都只把 volatile 字段投影成可编辑表单;命名空间就是本插件在 `cordis.patch.yml` 里那一行的 profile entry id(`theme-endfield`)。**浏览器端(client.js)** 用 `ctx.configForms.get('theme-endfield')` 读/写/订阅,值由 DSH 的设置服务写进 profile patch `<profile>/cordis.patch.yml`。另外 Host 会调用 `ctx.settings.configure({ auto: false }, ctx.fiber)` 告诉 DSH 本插件自带设置页,不要再自动生成一份。0.2 的这套接缝与 0.1.7 完全相同,本插件侧不需要改动;0.2 真正改掉的是三个**应用侧**细节(回合状态标签的类名与着色机制、右侧栏列名、插件卡片文案来源),见 [engineering-notes.md § DSH 0.2.0-rc.2](engineering-notes.md#dsh-020-rc2-上失效的三处应用侧钩子v116-已跟进)。
11
+ - **DSH 0.2.0-rc.2 / 0.1.7-rc.1 及以后(当前)**:**Host 端(index.js)** 导出 schemastery `Config`,34 个字段全部 `.volatile()`——这两代都只把 volatile 字段投影成可编辑表单;命名空间就是本插件在 `cordis.patch.yml` 里那一行的 profile entry id(`theme-endfield`)。**浏览器端(client.js)** 用 `ctx.configForms.get('theme-endfield')` 读/写/订阅,值由 DSH 的设置服务写进 profile patch `<profile>/cordis.patch.yml`。另外 Host 会调用 `ctx.settings.configure({ auto: false }, ctx.fiber)` 告诉 DSH 本插件自带设置页,不要再自动生成一份。0.2 的这套接缝与 0.1.7 完全相同,本插件侧不需要改动;0.2 真正改掉的是三个**应用侧**细节(回合状态标签的类名与着色机制、右侧栏列名、插件卡片文案来源),见 [engineering-notes.md § DSH 0.2.0-rc.2](engineering-notes.md#dsh-020-rc2-上失效的三处应用侧钩子v116-已跟进)。
12
12
  - **≤ 0.1.5-rc.2(旧宿主,仍兼容)**:Host 通过 `ctx.settings.register('dsh-theme-endfield', schema)` 声明命名空间,由 `@deepseek-ai/dsh-settings-file` 落到 `<dshHome>/settings.yaml`;浏览器端用 `ctx.settingsScope` 的 `bind({ namespace, decode })` 读写。client 在找不到 `configForms` 时自动回落到这条路径。
13
13
 
14
14
  两代的落盘位置都由 DSH 决定(`$DSH_HOME` 或 `~/.dsh/...`),与浏览器 origin/端口无关,因此在 **dsh web(浏览器、固定/默认端口)** 和 **DSH Desktop(随机临时端口)** 两种运行方式下设置都能正确持久化——它们跑的都是 127.0.0.1 loopback 页面,DSH 会把连接解析为 `host` 持久化模式。
@@ -42,8 +42,10 @@
42
42
  | 04 娱乐 | 雷霆大字 | 关 | `thunder` |
43
43
  | | 大字入场动画 | 关 | `thunderAnim` |
44
44
  | | 顶部余额胶囊 | 关 | `balanceCapsule` |
45
-
46
- 开启后默认显示 DeepSeek API 钱包余额与峰谷时段;会话使用 dsh-codearts-auth 渠道模型时自动切换为该渠道的剩余额度与积分消耗(详见「04 娱乐 › 顶部余额胶囊 › 渠道额度模式」)。
45
+ | | 渠道额度读数 | 剩余 | `creditDisplay` |
46
+ | | 胶囊位置 | 顶部居中 | `balanceCapsulePosition` |
47
+ | | 侧栏自动隐藏 | 关 | `balanceCapsuleSidebarHide` |
48
+ | | 靠近自动隐藏 | 开 | `balanceCapsuleHoverHide` |
47
49
  | 05 音频 | 音频通知 | **关** | `audioEnabled` |
48
50
  | | 启动加载动画音 | 开 | `audioBoot` |
49
51
  | | 任务开始音 | 开 | `audioTurnStart` |
@@ -56,6 +58,8 @@
56
58
  | | 自定义音效目录 | (空) | `audioSoundDir` |
57
59
  | | 诊断日志 | 关 | `audioDiag` |
58
60
 
61
+ 开启后默认显示 DeepSeek API 钱包余额与峰谷时段;会话使用 dsh-codearts-auth 渠道模型时自动切换为该渠道的剩余额度与积分消耗(详见「04 娱乐 › 顶部余额胶囊 › 渠道额度模式」)。
62
+
59
63
  取值约定(与 localStorage 时代的极性完全一致,现在由 schema 默认值保证):**默认开启**的开关在该 schema 里默认存 `'1'`,客户端读作 `!== '0'`;**默认关闭**的存 `'0'`,读作 `=== '1'`。回滚/清除字段时客户端回到 schema 默认值;异常存储值被 schema 校验拒绝,不会悄悄改变出厂观感。
60
64
 
61
65
  ---
@@ -92,7 +96,7 @@
92
96
  - **线** = marching squares 在 21 个等间距高度上取等值线
93
97
  - **连线** = 线段按边 ID 缝合成连续折线,再以二次曲线绘制
94
98
 
95
- **密度随视口自适应**:上面的 21 级 / 22 岛是按桌面窗口调的,按「每面积」施加在窄窗口就会读作一堆乱线。短边低于 640px 时,层级数向 10、岛屿数向 12 随宽度线性递减(320px 触底),线距随之拉开;**短边 ≥640px 逐字节回到出厂密度**,桌面输出不变。覆盖率验证器(accept-or-reroll)按「该视口实际将画的层级数」评分,小窗的无空白区保证不变。见 `contourLevelsFor` / `contourBumpsFor`。
99
+ **密度随视口自适应**:上面的 21 级 / 22 岛是按桌面窗口调的,按「每面积」施加在窄窗口就会读作一堆乱线。短边低于 640px 时,层级数与岛屿数都向 10 随宽度线性递减(320px 触底),线距随之拉开;**短边 ≥640px 逐字节回到出厂密度**,桌面输出不变。覆盖率验证器(accept-or-reroll)按「该视口实际将画的层级数」评分,小窗的无空白区保证不变。见 `contourLevelsFor` / `contourBumpsFor`。
96
100
  算法细节与实测数据见 [engineering-notes.md § 等高线](engineering-notes.md#等高线背景).
97
101
 
98
102
  **图层挂在应用外框内部**,`inset:0; z-index:0`,落在「外框底色之上、所有定位子元素之下」。正文永远在图层之上,前景对比度不受影响。挂载期间会把外框、对话列、右侧栏列(0.1.x 叫详情列)与侧栏的不透明底色置为透明(`:has()` 守卫使功能关闭时全部规则失效)。
@@ -240,8 +244,7 @@
240
244
  当当前会话使用的模型来自 [dsh-codearts-auth](https://www.npmjs.com/package/dsh-codearts-auth)(Jet Hub)的某个渠道(CodeArts、CodeBuddy、WorkBuddy、LobsterAI、Qoder、Qoder CN、TRAE、Cline、Loomy、Raccoon、MiniMax、ZCode、OpenCode)时,胶囊自动切换为**渠道额度**显示,随切换渠道实时跟随:
241
245
 
242
246
  - **显示内容**:`渠道名 剩余额度 单位`——渠道名(如 `TRAE`)小字在前,剩余量沿用插件自己的格式(credit 非整数保留两位小数;token 量纲显示 `12.35K` / `12.35M`),单位尾注 `积分` 或 `Token`。多账号取第一个可用账号的 active 额度包求和。
243
- - **右侧读数可选已用或剩余**:设置面板「04 娱乐 › 渠道额度读数」可切换右侧百分比的含义——**已用**(默认,`已用xx%` = 已用/总额度,与插件徽章一致)或**剩余**(`剩余xx%` = 剩余/总额度)。右侧圆环与百分比**始终同步**:选「已用」圆环走已用份额,选「剩余」圆环走剩余份额。两种读法都需渠道报出总额度,否则右侧留空、圆环归零——没有总额度的份额无从判断,不编造读数。左侧主数字始终为剩余额度,不受此设置影响。切换在下次取数时生效(不强制额外 RPC,5 分钟取数下限保持诚实)。
244
- - **右侧进度条显示积分消耗**:圆环时钟与百分比槽位改为 `已用xx%`——`Σ已用 / Σ总额度`,与左侧数字同源同账号。没有总额度信息时百分比留空、圆环归零,不编造读数。
247
+ - **右侧读数可选已用或剩余**:设置面板「04 娱乐 › 渠道额度读数」可切换右侧百分比的含义——**已用**(`已用xx%` = 已用/总额度,与插件徽章一致)或**剩余**(默认,`剩余xx%` = 剩余/总额度)。右侧圆环与百分比**始终同步**:选「已用」圆环走已用份额,选「剩余」圆环走剩余份额。两种读法都需渠道报出总额度,否则右侧留空、圆环归零——没有总额度的份额无从判断,不编造读数。左侧主数字始终为剩余额度,不受此设置影响。切换在下次取数时生效(不强制额外 RPC,5 分钟取数下限保持诚实)。
245
248
  - **不显示峰谷时段**:高峰/低谷是 DeepSeek API 的计费概念,渠道模式下整段时段读数隐藏。
246
249
  - **取数与缓存**:走插件自己的管理 RPC(`usage.badge`),复用宿主侧 120s TTL 缓存(失败缓存 15s);主题侧另有**每渠道 5 分钟的取数下限**——余额是慢变量,胶囊是一眼可读的快照,不值得更密的请求。渠道**切换**绕过下限立即跟随;取数失败 30 秒后即允许重试。同一 `--endfield-balance-sweep` 变量在两种模式间切换含义,模式切换零重排。
247
250
  - **失败语义**:渠道未登录、插件缺失或上游失败显示 `--`,绝不变灰成 0;切回 DeepSeek 模型则恢复钱包余额显示(DeepSeek 钱包轮询始终在跑,切回即刻有数)。
package/docs/glass.md CHANGED
@@ -12,8 +12,8 @@ The composer card deliberately keeps no `backdrop-filter`: a backdrop filter
12
12
  descendants. The send-button tooltip is fixed-positioned from viewport
13
13
  coordinates, so blurring the card resolved the tooltip's coords against the
14
14
  shrunk card box — clipping it past the card's right edge and making its
15
- ResizeObserver re-measure in a loop (visible layout jitter). Reported in
16
- issue #19; keeping the opaque tint preserves the glass look at none of the cost.
15
+ ResizeObserver re-measure in a loop (visible layout jitter). Keeping the opaque
16
+ tint preserves the glass look at none of the cost.
17
17
 
18
18
  Unsupported backdrop filters use a .96 opaque fill; reduced-transparency uses
19
19
  an opaque fill and removes blur. Disabling the theme removes the material
Binary file
package/docs/testing.md CHANGED
@@ -91,6 +91,38 @@ _centerCol > _header > _titleRow > _titleCluster > _headerActions
91
91
 
92
92
  ---
93
93
 
94
+ ## 审批白条与祖先的 containing block(issue #41)
95
+
96
+ ```bash
97
+ node test/approval-band-transform.test.js # 真实浏览器:三个座位,同一份白条
98
+ ```
99
+
100
+ **`approval-band-transform.test.js`** 守的是「CSS 写对了、位置却归浏览器管」这一类失效。审批白条的设计是 `position: fixed; inset: 0` 铺满视口、卡片被压成通栏纸带,两者之差就是「上下有遮罩」;但 fixed 只在**祖先不构成 containing block** 时才落到视口。DSH 0.2.2-rc.1 给 composer seat 加了 `will-change: transform`——按 CSS Will Change 的规定,会让元素生成 containing block 的属性写进 `will-change` 就必须真的生成——而审批面板正好替换输入框、住在这个 seat 里。
101
+
102
+ 失效的样子非常安静:fixed 让面板脱离文档流 → seat 的**唯一**在流内子元素消失 → seat 高度塌成 0 → 白条解析成「列宽 × 0 高」的一条线,卡片以这条零高线居中,下半截被视口裁掉,只剩顶部「● 等待审批」。现场反馈是同一台机器上审批从 1–2 秒变成 152 秒。
103
+
104
+ 修法只能打在祖先上(CSS 无法从 fixed 后代内部逃出 containing block),且限定在面板存在时生效:
105
+
106
+ ```css
107
+ body:has([data-approval-key]) :is([data-composer-seat], [class$='_composerSeat']) {
108
+ will-change: auto !important;
109
+ }
110
+ ```
111
+
112
+ 夹具同时挂三个座位,**除座位外完全一样**——少一个,断言就会为错误的理由通过:
113
+
114
+ | 座位 | 钩子 | 用途 | 实测 |
115
+ | --- | --- | --- | --- |
116
+ | `#seatRaw` | 带 `will-change`,两个钩子都不匹配 | 反向对照:修复前的形状 | 白条 `720×0`,卡片下沿越界被裁 |
117
+ | `#seat` | `[class$='_composerSeat']` | 类名后缀钩子 | 白条 `1200×800`,胶囊 `190×41` 在视口内 |
118
+ | `#seatAttr` | `[data-composer-seat]` | 上游自己声明的 e2e 锚点 | 同上 |
119
+
120
+ 四条断言缺一不可:反向对照必须**仍然塌成一条线**(否则夹具不再复现 #41,其余断言就是空过);两个钩子都要把 `will-change` 降成 `auto` 并让白条回到视口大小;两颗胶囊必须落在视口内且可命中(`≥150×24`);遮罩层必须仍高于纸带(否则「上下有遮罩」这个形状本身没了)。
121
+
122
+ > 变异验证:把规则里的 `will-change: auto` 换成 `transform` → 第 2 段断言红(`got transform`),证明这条测试量的确实是那一条声明,不是纸张颜色或别的巧合。
123
+
124
+ ---
125
+
94
126
  ## 样式表投递与幂等
95
127
 
96
128
  ```bash
@@ -126,8 +158,8 @@ npm run test:corners # 源码级:stadium/圆形规则
126
158
  node test/palette-contrast.test.js # 两套配色每个角色的对比度(从真实 CSS 里读值)
127
159
  node test/palette-switch.test.js # 真实浏览器里切换配色,22 项断言
128
160
  node test/settings-buttons.test.js # 强调色底上的按钮文字对比度(32 项,含回归守卫)
129
- node test/hover-check.js # 用 CDP 真的移动鼠标,验证真实 :hover 规则(3 个面,24 项)
130
- node test/verify-shots.js # 解码四张截图统计强调色像素
161
+ node test/tools/hover-check.js # 用 CDP 真的移动鼠标,验证真实 :hover 规则(3 个面,24 项)
162
+ node test/tools/verify-shots.js # 解码四张截图统计强调色像素
131
163
  ```
132
164
 
133
165
  **`palette-contrast.test.js`** 从 `client.js` 的实际样式表里把变量读出来再验算。覆盖 27 项:实心底 + 墨色字达 AA、悬停底同样达标、回合状态四个色标对**两种**可能底色都达 AA、暗色强调色作图标墨色 ≥3、两配色的等高线合成对比度相差 ≤20% 且高于 1.06 感知下限、hero 光晕不比原品牌蓝更响(该 `*_heroGlow` 模块在 0.2 已不存在,规则保留为自愈钩子)、两配色确实不同、强调色写成 6 位十六进制,以及**武陵青的亮度必须落在 45%–56% 区间且留在青碧色轴上**。
@@ -251,23 +283,23 @@ node test/settings-config-fallback.test.js # Host Config 字段契约与选择
251
283
  node test/settings-namespace.test.js # 存储字段名对齐 schema + 旧拼写迁移 + 三条读/写边界
252
284
  node test/settings-off.test.js # 关闭主题后设置页仍可读
253
285
  node test/settings-locale.test.js # 跟随语言设置(zh/en 词典对齐 + 切换生效)
254
- node test/host-esm-entry.test.js # ESM 宿主入口:default 上的转出面 + 无 schemastery 时仍能加载
255
286
  ```
256
287
 
257
288
  **`settings-rows.test.js`** 不用浏览器也不用 React:以**记录型 `React` / `slots` + 假的设置 transport**(`test/fixtures/settings-scope.js`)在进程内跑一次真实 `apply()`,抓下设置面板真正的元素树。设置页是用户唯一能碰到这些开关的入口,而那里的错误(抛异常、漏 key、开关写错了 DSH 设置的字段)check.js 与画布测试都看不见。
258
289
 
259
- > 说明:这个插件从 **`localStorage` 迁移到了 DSH 的持久化设置服务**(见 features.md / engineering-notes.md)。因此设置类测试不再往浏览器存储里塞值,而是驱动假的 transport:除 `settings-config-forms.test.js` 之外的用例走旧世代 `ctx.settingsScope`(fixture 的 `settingsScopeStub`,在内存里扮演 `<settings.yaml>` 的命名字段节),新世代由 `configFormsStub` 扮演 `ctx.configForms`(命名空间 = profile entry id)。断言 31 行齐全且归入 5 个分组容器、key 唯一、分组标题(01 主题 / 02 背景 / 03 动画 / 04 娱乐 / 05 音频)与配色样式规则都在、配色行默认显示谷地黄且按钮提供「切换武陵青」、点击把 `palette` 写成 `wuling`、存了 `wuling` 时反向提供「切换谷地黄」并标注 `#14d0d0`、图层关闭时子开关为 disabled、开启后恢复可用,雷霆大字与大字入场动画均默认为关、说明文字包含「任务开始」/「任务完成」与 3 秒、**子开关只写自己的字段而不误写主开关的**,以及点击确实写入文档里那个 DSH 设置字段。
290
+ > 说明:这个插件从 **`localStorage` 迁移到了 DSH 的持久化设置服务**(见 features.md / engineering-notes.md)。因此设置类测试不再往浏览器存储里塞值,而是驱动假的 transport:除 `settings-config-forms.test.js` 之外的用例走旧世代 `ctx.settingsScope`(fixture 的 `settingsScopeStub`,在内存里扮演 `<settings.yaml>` 的命名字段节),新世代由 `configFormsStub` 扮演 `ctx.configForms`(命名空间 = profile entry id)。断言 34 行齐全且归入 5 个分组容器、key 唯一、分组标题(01 主题 / 02 背景 / 03 动画 / 04 娱乐 / 05 音频)与配色样式规则都在、配色行默认显示谷地黄且按钮提供「切换武陵青」、点击把 `palette` 写成 `wuling`、存了 `wuling` 时反向提供「切换谷地黄」并标注 `#14d0d0`、图层关闭时子开关为 disabled、开启后恢复可用,雷霆大字与大字入场动画均默认为关、说明文字包含「任务开始」/「任务完成」与 3 秒、**子开关只写自己的字段而不误写主开关的**,以及点击确实写入文档里那个 DSH 设置字段。
260
291
 
261
292
  **`settings-durable-hold.test.js`**(旧世代 `settingsScope` 路径;0.1.7 上同一份写入 gate / 补写契约由 `settings-config-forms.test.js` 覆盖)用**两阶段假 `ctx.settingsScope`** 复现那条历史告警:宿主半部 `ctx.settings.register(...)` 尚未跑、命名空间还没进 Host 的 served 列表前,scope 快照是 `{ status:'unavailable', writable:true, mode:'host' }`——单看 `writable` 会照写不误却落不到盘。它先在未就绪态切「圆角 / 武陵青」,断言**没有任何 `scope.set` 出线**(旧 bug 会打 `commit … status= unavailable` 并静默丢脏);随后模拟文档 committed、命名空间进入 served 列表、快照翻为 `status:'ready'`,断言订阅路径把两份 held 编辑**自动补写**进文档,且不会重复写两遍(replay 有 re-entrancy 护栏)。
262
293
 
263
294
  **`settings-config-forms.test.js`** 守的是 0.1.7-rc.1 换掉整套 settings API 之后最容易「看起来正常、其实没保存」的几处:它用假 `configForms` 服务(fixture 的 `configFormsStub`,只有被 `serve()` 过的命名空间才报 `status:'ready'`)驱动真实 client,断言
264
295
 
265
296
  - **两半的 entry id 一致**:`client.js` 的 `PREFS_ENTRY` == `index.js` 的 `SETTINGS_ENTRY` == `cordis.patch.yml` 里那一行的 `id`(命名空间是 entry id,任一处对不上就全程读不到);
266
- - **Host `Config` 真的可编辑**:31 个字段齐全、无多余字段、**每个字段都带 `.volatile()`** 且默认值等于 `FIELD_DEFAULTS`——漏一个 volatile 就是 0.1.7 版的「设置保存不了」(该断言在拿不到 schemastery 的环境里自动跳过,CI 无 DSH 时不会误报——**也正因为会跳过,它没能在唯一要紧的环境里发现问题**:本机 web profile 恰好就是「拿不到可用的 schemastery」这台机器,于是这一整段断言空跑,`Config` 缺失一路绿灯。字段契约与选择顺序现由 `settings-config-fallback.test.js` 无条件断言);
297
+ - **Host `Config` 真的可编辑**:34 个字段齐全、无多余字段、**每个字段都带 `.volatile()`** 且默认值等于 `FIELD_DEFAULTS`——漏一个 volatile 就是 0.1.7 版的「设置保存不了」(该断言在拿不到 schemastery 的环境里自动跳过,CI 无 DSH 时不会误报——**也正因为会跳过,它没能在唯一要紧的环境里发现问题**:本机 web profile 恰好就是「拿不到可用的 schemastery」这台机器,于是这一整段断言空跑,`Config` 缺失一路绿灯。字段契约与选择顺序现由 `settings-config-fallback.test.js` 无条件断言);
267
298
  - **被 served 的表单会被绑定并采纳**(存档里的 `palette: wuling` 直接落到 `<body>` 的 class);
268
299
  - **entry id 是探测出来的**:只 served `include:theme-endfield` 时写入也落在那个拼写上;
269
300
  - **拒写与未就绪都算 held**:`set()` 解析为 `false`、或命名空间尚未 served 时,编辑不改变文档、也不假装保存;一旦拒写解除(下一次快照)或命名空间进入 served,held 编辑被自动补写;
270
301
  - **memory 模式(非 loopback 页面)永不下盘**:一次写都不发生;
302
+ - **0.2.1 快照(没有 `mode` 字段)必须照写**:fixture 的 `mode:'none'` 让快照里**不存在** `mode` 键(`'mode' in snap === false`,与 `@deepseek-ai/dsh-client-ui-settings` 0.2.1-rc.2 删除该字段后的真实形状一致),断言 ready + writable 的编辑直接写到文档。这是 v1.2.2 在 0.2.1 桌面「改了不保存」的回归护栏——旧闸门要求 `mode:'host'`,新版恒为 `undefined`,编辑只会 held 到有界重试超时;
271
303
  - **静默 ready**:命名空间开始被服务但镜像**完全不通知**任何表单时,有界 settle watch(20 × 500ms)会自己重新读取、采纳、切到被 served 的拼写并补写——这是旧世代 250 ms binder 轮询的等价安全网;
272
304
  - **退订**:run 拆除时调用 `form.subscribe()` 返回的 disposer(`ConfigForm` 是 provider 拥有、跨插件共享的实例,漏掉就是给下一次 run 泄漏监听器)。
273
305
 
@@ -323,7 +355,7 @@ node test/loader-late-prefs.test.js # 设置节晚于 apply() 到达时,动
323
355
 
324
356
  ```bash
325
357
  node test/thunder-edges.test.js # 边沿/生命周期/样式契约
326
- node test/thunder-shot.js # 真实渲染截图 + 像素对比度断言
358
+ node test/tools/thunder-shot.js # 真实渲染截图 + 像素对比度断言
327
359
  node test/thunder-dismiss.test.js # 点击关闭:真实指针事件 + 命中测试 + 监听器核账
328
360
  ```
329
361
 
@@ -356,7 +388,8 @@ node test/contour-perf.test.js # 稳态帧成本(n=80)
356
388
  node test/contour-density.test.js # 视口密度自适应(≥640px 出厂值不变,小屏递减)
357
389
  node test/contour-bounds.test.js # 扫描边界与全扫的逐坐标等价
358
390
  node test/contour-worker.test.js # worker 内核与主线程一致
359
- node test/shoot.js # 输出亮/暗 × 两配色共四张截图供肉眼复核
391
+ node test/contour-worker-budget.test.js # worker 背板预算:超大视口缩 dpr,普通视口逐位不变
392
+ node test/tools/shoot.js # 输出亮/暗 × 两配色共四张截图供肉眼复核
360
393
  ```
361
394
 
362
395
  **`contour-render.test.js`** 覆盖:关闭时不创建节点且**不改动应用底色**;开启时画布挂进应用外框、图层确实上色、不透明底色已让位;正文颜色不变且仍可命中测试(图层在其**之下**);动画开启时像素随时间变化、关闭后**完全静止**、**重新开启后再次变化**;暗色仍上色;拆除后节点归零。
@@ -381,7 +414,7 @@ node test/shoot.js # 输出亮/暗 × 两配色共四张截
381
414
 
382
415
  该脚本对 24 / 60 / 120 fps **逐个**比预算,并在第一个超标处失败——所以真正卡住它的门槛是 **8.3ms(120fps)**,不是 41.7ms:报数时别只看「41.7ms 预算」那一行。
383
416
 
384
- > **提取到的名字必须跟着 `client.js` 走。** 这些「原样切出」的脚本按名字抓函数与常量,切出来少一个就是页面里的 `ReferenceError`,而它只会表现为 `no result` / 一行 `CONTOUR_... is not defined`。新增或重命名内核里的函数与常量时(例如 `contourStepFor`、`CONTOUR_MAX_CELLS`、`CONTOUR_MIN_BUMPSAMPLES`、`CONTOUR_SMOOTH_*`),要同步 `contour-smoothness` / `contour-cusps` / `contour-bounds` / `contour-perf` 的名单、`scripts/build-contour-worker.js` 的 `names`,以及 `src/contour-worker.js` 顶部手工镜像的常量——worker 里少一个常量同样是运行时 `ReferenceError`。
417
+ > **提取到的名字必须跟着 `client.js` 走。** 这些「原样切出」的脚本按名字抓函数与常量,切出来少一个就是页面里的 `ReferenceError`,而它只会表现为 `no result` / 一行 `CONTOUR_... is not defined`。新增或重命名内核里的函数与常量时(例如 `contourStepFor`、`CONTOUR_MAX_CELLS`、`CONTOUR_MIN_BUMPSAMPLES`、`CONTOUR_SMOOTH_*`),要同步 `contour-smoothness` / `contour-cusps` / `contour-bounds` / `contour-perf` 的名单、`scripts/build-contour-worker.js` 的 `names`,以及 `src/contour/contour-worker.js` 顶部手工镜像的常量——worker 里少一个常量同样是运行时 `ReferenceError`。
385
418
 
386
419
  ---
387
420
 
package/index.js CHANGED
@@ -884,6 +884,13 @@ function installAudio(ctx, settingsScope) {
884
884
  const key = `${sessionId}:${data.turn}`;
885
885
  if (hasVisibleText(data.message)) terminals.set(key, true);
886
886
  else if (!terminals.has(key)) terminals.set(key, false);
887
+ /* The map is only reaped by agent/turn-stopping, and a turn that ends any
888
+ other way (agent/error, a destroyed session) never fires it — in a host
889
+ process that runs for days those entries would accumulate forever. Bound
890
+ it FIFO like playedDone below: the entries evicted earliest belong to the
891
+ oldest turns, and by the time 512 newer turns have been recorded, a
892
+ turn-stopping for the evicted one is not a live question any more. */
893
+ if (terminals.size > 512) terminals.delete(terminals.keys().next().value);
887
894
  });
888
895
 
889
896
  /* The two "a human has to do something" triggers, and the ONLY things that
package/lib/audio.js CHANGED
@@ -82,6 +82,20 @@ function pathHash(value) {
82
82
  return h.toString(16);
83
83
  }
84
84
 
85
+ /** Short stable hash of file CONTENT for the cache-file name: the same FNV-1a
86
+ * over the bytes. Content, not just size, has to be in the key — a replaced
87
+ * sound with an identical duration (e.g. a re-rendered bundled wav) is
88
+ * byte-length-identical, and a name-only key would serve the previous file's
89
+ * scaled copy forever. The wavs are a few hundred KB, so this is microseconds. */
90
+ function bytesHash(bytes) {
91
+ let h = 0x811c9dc5;
92
+ for (let i = 0; i < bytes.length; i++) {
93
+ h ^= bytes[i];
94
+ h = Math.imul(h, 0x01000193) >>> 0;
95
+ }
96
+ return h.toString(16);
97
+ }
98
+
85
99
  /**
86
100
  * The audio runtime: preference view, sound resolution, volume cache, playback
87
101
  * and the diagnostic ring the settings page reads.
@@ -105,6 +119,8 @@ class AudioRuntime {
105
119
  /** Bounded diagnostic ring: what the plugin saw and what it did about it. */
106
120
  this.diag = [];
107
121
  this.warnedNoSubprocess = false;
122
+ /** One-shot guard so an unwritable volume cache says so once, not per play. */
123
+ this.warnedCacheDir = false;
108
124
  }
109
125
 
110
126
  /** Record one diagnostic line and echo it to the host console. */
@@ -255,14 +271,23 @@ class AudioRuntime {
255
271
  if (scaled === bytes) return file; // non-rescalable format: original level
256
272
  try {
257
273
  fs.mkdirSync(CACHE_DIR, { recursive: true });
258
- /* The cache key covers the SOURCE DIRECTORY too: basename + size alone
259
- would let a turn-done.wav from the custom dir collide with an unrelated
260
- same-named, same-length file from the desktop, and the second user
261
- would hear the first one's sound. */
262
- const target = path.join(CACHE_DIR, `${path.basename(file, path.extname(file))}-v${volume}-${bytes.length}-${pathHash(file)}.wav`);
274
+ /* The cache key covers the SOURCE DIRECTORY (basename + size alone would
275
+ let a turn-done.wav from the custom dir collide with an unrelated
276
+ same-named, same-length file from the desktop) AND the source content
277
+ (a replaced file with an identical duration is byte-length-identical,
278
+ and only the content distinguishes its scaled copy from the old one). */
279
+ const target = path.join(CACHE_DIR,
280
+ `${path.basename(file, path.extname(file))}-v${volume}-${bytes.length}-${bytesHash(bytes)}-${pathHash(file)}.wav`);
263
281
  if (!fs.existsSync(target)) fs.writeFileSync(target, scaled);
264
282
  return target;
265
283
  } catch (error) {
284
+ /* An unwritable cache dir (shared/stale tmp on POSIX) must not disable
285
+ volume scaling SILENTLY — say so once, then keep degrading to the
286
+ unscaled file like every other failure here. */
287
+ if (!this.warnedCacheDir) {
288
+ this.warnedCacheDir = true;
289
+ this.note('volume-cache-unwritable', `serving sounds unscaled; cache dir issue: ${error && error.message ? error.message : error}`);
290
+ }
266
291
  return file;
267
292
  }
268
293
  }
package/lib/slots.js CHANGED
@@ -63,9 +63,10 @@ const SLOTS = {
63
63
  },
64
64
 
65
65
  /**
66
- * 需要你回应 (reserved) —— human attention required. Descending minor second
66
+ * 需要你回应 —— human attention required. Descending minor second
67
67
  * with a detuned partial beats against the fundamental: unresolved on
68
- * purpose, so it does not read as "done".
68
+ * purpose, so it does not read as "done". Wired to approval/request and
69
+ * user-questions/request by the host half.
69
70
  */
70
71
  attention: {
71
72
  duration: 1.0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-theme-endfield",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "DeepSeek Harness web theme: the industrial editorial look of the Arknights: Endfield official site — cream paper, ink text, signal-yellow accents, zero radius. Settings persist through the DSH volatile Config + configForms transport (0.2.0-rc.2 and 0.1.7+), with a ctx.settingsScope fallback for older hosts.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -32,13 +32,13 @@
32
32
  "test:contour": "node test/contour-render.test.js",
33
33
  "test:settings": "node test/settings-rows.test.js && node test/settings-durable-hold.test.js && node test/settings-config-forms.test.js && node test/settings-config-fallback.test.js && node test/settings-namespace.test.js && node test/settings-off.test.js && node test/settings-locale.test.js",
34
34
  "test:font": "node test/font-scope.test.js",
35
- "test:thunder": "node test/thunder-edges.test.js && node test/thunder-shot.js && node test/thunder-dismiss.test.js",
35
+ "test:thunder": "node test/thunder-edges.test.js && node test/tools/thunder-shot.js && node test/thunder-dismiss.test.js",
36
36
  "test:palette": "node test/palette-contrast.test.js && node test/palette-switch.test.js",
37
37
  "test:menu": "node test/menu-surface.test.js dark && node test/menu-surface.test.js light",
38
38
  "test:selector": "node test/selector-guard.test.js && node test/preset-chip.test.js",
39
- "test:buttons": "node test/settings-buttons.test.js && node test/hover-check.js",
39
+ "test:buttons": "node test/settings-buttons.test.js && node test/tools/hover-check.js",
40
40
  "test:balance": "node test/balance-window.test.js && node test/balance-capsule.test.js && node test/credit-capsule.test.js",
41
- "test:live": "node test/live-check.js",
41
+ "test:live": "node test/tools/live-check.js",
42
42
  "test:specks": "node test/contour-specks.test.js",
43
43
  "test:watermark": "node test/watermark-stacking.test.js",
44
44
  "test:smoothness": "node test/contour-smoothness.test.js",
@@ -47,10 +47,10 @@
47
47
  "test:coverage": "node test/contour-coverage.test.js",
48
48
  "test:perf": "node test/contour-perf.test.js",
49
49
  "test:loader": "node test/loader-performance.test.js && node test/loader-late-prefs.test.js",
50
- "test": "node check.js && node selftest.js && node test/font-scope.test.js && node test/palette-contrast.test.js && node test/palette-switch.test.js && node test/menu-surface.test.js dark && node test/menu-surface.test.js light && node test/settings-buttons.test.js && node test/hover-check.js && node test/selector-guard.test.js && node test/preset-chip.test.js && node test/balance-window.test.js && node test/balance-capsule.test.js && node test/credit-capsule.test.js && node test/settings-rows.test.js && node test/settings-durable-hold.test.js && node test/settings-config-forms.test.js && node test/settings-config-fallback.test.js && node test/settings-namespace.test.js && node test/settings-off.test.js && node test/settings-locale.test.js && node test/audio-notify.test.js && node test/audio-attention-watch.test.js && node test/host-no-inject.test.js && node scripts/build-sounds.js --check && node test/thunder-edges.test.js && node test/thunder-shot.js && node test/thunder-dismiss.test.js && node test/contour-render.test.js && node test/loader-performance.test.js && node test/loader-late-prefs.test.js && node test/contour-specks.test.js && node test/watermark-stacking.test.js && node test/contour-smoothness.test.js && node test/contour-cusps.test.js && node test/contour-a11y.test.js && node test/contour-coverage.test.js && node test/contour-perf.test.js && node test/contour-density.test.js && node test/contour-bounds.test.js && node scripts/build-host.js --check && node scripts/build-client.js --check && node scripts/build-contour-worker.js --check && node test/worker-eol.test.js && node test/contour-worker.test.js && node test/theme-sheet-refresh.test.js && node test/titlebar-probe.test.js && node test/corner-shape.test.js && node test/glass.test.js && node test/contour-trail.test.js && node test/contour-trail-browser.test.js && node test/scroll-anim.test.js && node test/contour-loop-reentry.test.js",
51
- "test:ci": "node check.js && node selftest.js && node test/font-scope.test.js && node test/palette-contrast.test.js && node test/palette-switch.test.js && node test/menu-surface.test.js dark && node test/menu-surface.test.js light && node test/settings-buttons.test.js && node test/hover-check.js && node test/selector-guard.test.js && node test/preset-chip.test.js && node test/balance-window.test.js && node test/balance-capsule.test.js && node test/credit-capsule.test.js && node test/settings-rows.test.js && node test/settings-durable-hold.test.js && node test/settings-config-forms.test.js && node test/settings-config-fallback.test.js && node test/settings-namespace.test.js && node test/settings-off.test.js && node test/settings-locale.test.js && node test/audio-notify.test.js && node test/audio-attention-watch.test.js && node test/host-no-inject.test.js && node scripts/build-sounds.js --check && node test/thunder-edges.test.js && node test/thunder-shot.js && node test/thunder-dismiss.test.js && node test/contour-render.test.js && node test/loader-performance.test.js && node test/loader-late-prefs.test.js && node test/contour-specks.test.js && node test/watermark-stacking.test.js && node test/contour-smoothness.test.js && node test/contour-cusps.test.js && node test/contour-a11y.test.js && node test/contour-bounds.test.js && node scripts/build-host.js --check && node scripts/build-client.js --check && node scripts/build-contour-worker.js --check && node test/worker-eol.test.js && node test/contour-worker.test.js && node test/theme-sheet-refresh.test.js && node test/titlebar-probe.test.js && node test/corner-shape.test.js && node test/glass.test.js && node test/contour-trail.test.js && node test/contour-trail-browser.test.js && node test/scroll-anim.test.js && node test/contour-loop-reentry.test.js",
52
- "shots": "node test/shoot.js",
53
- "shots:verify": "node test/shoot.js && node test/verify-shots.js",
50
+ "test": "node check.js && node selftest.js && node test/import-probe.test.js && node test/font-scope.test.js && node test/palette-contrast.test.js && node test/palette-switch.test.js && node test/menu-surface.test.js dark && node test/menu-surface.test.js light && node test/settings-buttons.test.js && node test/tools/hover-check.js && node test/selector-guard.test.js && node test/preset-chip.test.js && node test/balance-window.test.js && node test/balance-capsule.test.js && node test/credit-capsule.test.js && node test/settings-rows.test.js && node test/settings-durable-hold.test.js && node test/settings-config-forms.test.js && node test/settings-config-fallback.test.js && node test/settings-namespace.test.js && node test/settings-off.test.js && node test/settings-locale.test.js && node test/audio-notify.test.js && node test/audio-attention-watch.test.js && node test/host-no-inject.test.js && node scripts/build-sounds.js --check && node test/thunder-edges.test.js && node test/tools/thunder-shot.js && node test/thunder-dismiss.test.js && node test/contour-render.test.js && node test/loader-performance.test.js && node test/loader-late-prefs.test.js && node test/contour-specks.test.js && node test/watermark-stacking.test.js && node test/contour-smoothness.test.js && node test/contour-cusps.test.js && node test/contour-a11y.test.js && node test/contour-coverage.test.js && node test/contour-perf.test.js && node test/contour-density.test.js && node test/contour-bounds.test.js && node scripts/build-host.js --check && node scripts/build-client.js --check && node scripts/build-contour-worker.js --check && node test/worker-eol.test.js && node test/contour-worker.test.js && node test/contour-worker-budget.test.js && node test/theme-sheet-refresh.test.js && node test/titlebar-probe.test.js && node test/corner-shape.test.js && node test/approval-band-transform.test.js && node test/glass.test.js && node test/contour-trail.test.js && node test/contour-trail-browser.test.js && node test/scroll-anim.test.js && node test/contour-loop-reentry.test.js",
51
+ "test:ci": "node check.js && node selftest.js && node test/import-probe.test.js && node test/font-scope.test.js && node test/palette-contrast.test.js && node test/palette-switch.test.js && node test/menu-surface.test.js dark && node test/menu-surface.test.js light && node test/settings-buttons.test.js && node test/tools/hover-check.js && node test/selector-guard.test.js && node test/preset-chip.test.js && node test/balance-window.test.js && node test/balance-capsule.test.js && node test/credit-capsule.test.js && node test/settings-rows.test.js && node test/settings-durable-hold.test.js && node test/settings-config-forms.test.js && node test/settings-config-fallback.test.js && node test/settings-namespace.test.js && node test/settings-off.test.js && node test/settings-locale.test.js && node test/audio-notify.test.js && node test/audio-attention-watch.test.js && node test/host-no-inject.test.js && node scripts/build-sounds.js --check && node test/thunder-edges.test.js && node test/tools/thunder-shot.js && node test/thunder-dismiss.test.js && node test/contour-render.test.js && node test/loader-performance.test.js && node test/loader-late-prefs.test.js && node test/contour-specks.test.js && node test/watermark-stacking.test.js && node test/contour-smoothness.test.js && node test/contour-cusps.test.js && node test/contour-a11y.test.js && node test/contour-density.test.js && node test/contour-bounds.test.js && node scripts/build-host.js --check && node scripts/build-client.js --check && node scripts/build-contour-worker.js --check && node test/worker-eol.test.js && node test/contour-worker.test.js && node test/contour-worker-budget.test.js && node test/theme-sheet-refresh.test.js && node test/titlebar-probe.test.js && node test/corner-shape.test.js && node test/approval-band-transform.test.js && node test/glass.test.js && node test/contour-trail.test.js && node test/contour-trail-browser.test.js && node test/scroll-anim.test.js && node test/contour-loop-reentry.test.js",
52
+ "shots": "node test/tools/shoot.js",
53
+ "shots:verify": "node test/tools/shoot.js && node test/tools/verify-shots.js",
54
54
  "test:bounds": "node test/contour-bounds.test.js",
55
55
  "build:host": "node scripts/build-host.js",
56
56
  "check:host": "node scripts/build-host.js --check",