# 工程笔记 实现决策与它们背后的实测数据。本文回答「为什么是这样写的」,不重复功能说明(见 [features.md](features.md))或色值取法(见 [design-language.md](design-language.md))。 排在前面的几条是**结构性约束**——不了解就会重新踩进去。 --- ## 目录 - [变量必须声明在 body 而不是 :root](#变量必须声明在-body-而不是-root) - [字体令牌是应用的公共接口,不是主题的开关](#字体令牌是应用的公共接口不是主题的开关) - [样式表是一整个模板字符串](#样式表是一整个模板字符串) - [层叠与挂载点](#层叠与挂载点) - [等高线背景](#等高线背景) - [启动加载屏](#启动加载屏) - [设置页国际化](#设置页国际化) - [峰谷定价窗口与法定节假日](#峰谷定价窗口与法定节假日) - [为什么设置必须落在 Host 的设置命名空间(不再用 localStorage)](#为什么设置必须落在-host-的设置命名空间不再用-localstorage) - [已修问题归档](#已修问题归档) - [验证方法论](#验证方法论) --- ## 为什么设置必须落在 Host 的设置命名空间(不再用 localStorage) 最初这些开关用浏览器 `localStorage` 持久化。**这对 DSH Desktop 是错的**:浏览器存储的作用域是「协议 + 主机 + 端口」的同源维度(origin),而 DSH Desktop 每次启动都在 `127.0.0.1` 上绑定一个**随机临时端口**。端口一变,origin 就变,于是上次保存的设置永远读不到,表现就是「**重启后设置恢复默认**」;localStorage 也因此从来没真正持久过跨会话。 ### 选哪条存储 原生浏览器侧的**三个候选**都治标不治本,因为它们的键空间同样挂在 origin 上: - `localStorage`——origin 维度,random port 一换就清空(本 bug 的根源)。 - `sessionStorage`——更糟,连同一会话的标签页都不共享。 - IndexedDB——仍按 origin 分库,与 Desktop 随机端口的组合同样无法根治。 正解是 DSH 自己的用户设置服务:**解析与落盘都由 Host 决定**,与页面 origin、端口完全无关。因此 **dsh web(浏览器、固定/默认端口)** 和 **DSH Desktop(随机临时端口)** 走同一条路径:它们都是 `127.0.0.1` loopback 页面,DSH 把连接解析成 `host` 持久化模式,值写进 profile 落盘位置,换端口也能读回。 ### DSH 0.1.7-rc.1 换掉了整套 settings API(v1.1.0 已跟进) 0.1.7-rc.1 移除了 `ctx.settings.register` / `settings.get` / `settings.watch`、`@deepseek-ai/dsh-settings-file` 这个包,以及浏览器侧 `ctx.settingsScope` 服务: | | ≤ 0.1.5-rc.2(旧) | 0.1.7-rc.1(新) | | --- | --- | --- | | Host 声明 | `ctx.settings.register('dsh-theme-endfield', schema)` | 模块导出 schemastery `Config`,每个字段 `.volatile()` | | 命名空间 | 插件自定义字符串 `dsh-theme-endfield` | **profile entry id** = 本包 `cordis.patch.yml` 中该行的 `id: theme-endfield`(`index.js` 的 `SETTINGS_ENTRY`) | | 落盘 | `@deepseek-ai/dsh-settings-file` → `/settings.yaml` | DSH 设置服务 → profile patch `/cordis.patch.yml`(`ctx.configEditor` 加文件锁 + 原子写 + 热重载) | | Client 读写 | `ctx.settingsScope` 的 `bind({ namespace, decode })` | `ctx.configForms.get()` | | `set()` 返回 | 无(fire-and-forget) | `Promise`;`false` = Host 拒绝或**跳过**(非 loopback 页面是 memory 模式,永不下盘) | 关键差异,逐条都有对应的防护: - **只有 `.volatile()` 字段可编辑。** `ctx.settings` 只把 volatile 路径投影成表单,写非 volatile 路径会直接报 `Config field "x" is not volatile`;整份 schema 一个 volatile 字段都没有时该条目**根本不出现**在设置里。所以 `index.js` 的 `Config` **27 个字段全部 `.volatile()`**,`test/settings-config-forms.test.js` 会逐字段断言这一点(漏一个就是「设置保存不了」的现代写法)。 - **两个 schemastery 必须区分。** DSH 同时装了带 `.volatile()` 的 `@deepseek-ai/schemastery`(3.18.4)和不带它的旧 `schemastery`(3.18.0)。`index.js` 的 `loadSchemastery(true)` 会逐个候选检查 `.volatile` 是否真的存在,找不到就返回 `undefined`(本插件退化成无需配置,而不是挂一个假表单)。 - **命名空间是 entry id,不是包名。** `theme-endfield` 这个串同时出现在 `cordis.patch.yml` 的 `id:`、`index.js` 的 `SETTINGS_ENTRY` 和 client 的 `PREFS_ENTRY`;三者由新测试交叉校验。client 另外会依次尝试 `include:` 前缀等几种安装别名,优先选真正被 Host served(`status:'ready'`)的那个拼写;一个都没 served 时先绑定首选拼写(表单只是共享镜像的懒视图,早绑定才能等到迟到的 section)。此后每次镜像重载都会让每个表单重新派生,`unavailable`→`ready` 的转变会把「另一个拼写被 served」通知过来,此时自动切过去;万一镜像只更新却不通知(表单快照存储丢弃等价快照),还有**有界 settle watch**(20 × 500ms)自己轮询兜底。两种情况下切换期间 held 的编辑都会补写到新拼写上。 - **`settings.yaml` 已废弃。** DSH 启动时把已有的 `settings.yaml` 改名为 `settings.yaml.imported`,并只迁移 `LEGACY_SECTION_ENTRIES` 里那几段。旧的主题段落名 `dsh-theme-endfield` 不等于 entry id `theme-endfield`,因此**不在迁移之列**,需要用户在设置页重设一次;这是 DSH 侧的行为,不是本插件丢的。旧值仍留在 `settings.yaml.imported` 里可手工对照。 - **旧宿主仍然可用。** client 在找不到 `configForms` 时回落到 `ctx.settingsScope`(`binder.bind({ namespace: 'dsh-theme-endfield', decode })`),Host 在 `settings.register` 存在时才注册旧命名空间。两代 transport 的快照字段同名(`status` / `value` / `writable` / `mode`),所以除了「取句柄」和「写返回」之外整条存储逻辑是同一份。 ### Host ↔ Client 数据流 1. **声明**(Host `index.js`):0.1.7-rc.1 导出 `Config`(`z.object({ 每个字段: z.string().default(...).volatile() })`);同时用 `ctx.inject(['settings'], sctx => sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber)))` 关掉自动生成的设置页(本插件自带四组页面)。旧宿主则在 `settings.register` 存在时执行 `register('dsh-theme-endfield', schema, { applies:'live' })`。schema 每个字段都是**字符串**字段并带 `.default(...)`:default-ON 存 `'1'`(读作 `!== '0'`),default-OFF 存 `'0'`(读作 `=== '1'`);palette/radius/fps/speed 各存一个文档里写明的字面量。字符串化让三代存盘点位的值模型完全一致,client 的键表、面板与测试都不必随 transport 改动。 2. **写**(浏览器 `client.js`):设置面板每个 toggle 调用内部 `prefsSet(field, value)` → 只有当快照是 **durably served**(`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 拒绝/跳过)时同样重新标脏等待下次回相,而不是假装写成功。 3. **读 / 生效**:transport 的 `getSnapshot().value` 就是已由 schema 校验并合并默认值的整段(client 再经 `prefsResolveSection()` 归一化到 27 个声明字段);主题层的 `isEnabled()` 与其它 getter 每次调用都 `prefsGet(field)` 现读本段,天然随值变化。 4. **订阅同步**:`form.subscribe(...)` / `scope.subscribe(...)` 在每次落盘/镜像变化时唤醒,client 再跑一遍 `reconcileFromPrefs()`,把主题开关(enabled → mount/unmount token+样式表)、圆角/配色 class、水印、等高线、雷霆大字重新对齐。这样同 profile 里**另一个窗口/设备**编辑落盘文件(或本轮写入被 Host 回相确认)都不需要刷新即可热生效。订阅返回的 disposer 现在会被保存并在 run 拆除时调用:`ConfigForm` 是 provider 拥有、跨插件共享的实例,漏掉它会把这一个监听器泄漏给同页面的下一次 run。 5. **启动恢复**:`apply()` 早于 transport 就绪时,读 schema 默认值(内存镜像),一旦 `status:'ready'` 的第一个真值镜像到达就切换到持久值——即使 Desktop 在随机端口上启动,也能立刻恢复到上次的设置。 防御性:若 ctx 里既没有 `configForms` 也没有 `settingsScope`(纯独立页/测试桩),则回落内存默认值 + 会话内本地覆盖,写不过盘但**不引入 localStorage**,也绝不 throw。 ### 加载期解析 schemastery:dev-link 安装必须显式去找(v1.1.1 起加固,v1.1.2 加自检报告,v1.1.3 改为结构化扫描) - **为什么只能在顶层解析。** loader 导入模块后立刻 `runtime.Config = plugin.Config`(`@deepseek-ai/cordis-plugin-loader` 的 `Entry._init` → `registry.plugin`),这发生在任何插件体运行之前,`Config` 因此必须是模块求值期的静态导出——不能延迟到 `apply()` 里再做(那里能拿到 `ctx`,但已经太晚了)。 - **dev-link 安装为什么解析不到。** 本包在 profile 里是软链/junction(`/node_modules/dsh-theme-endfield` → 本仓库)。Node 的 ESM/CJS 解析都**先取真实路径**,于是本文件的 `require` 链从 `E:\…` 开始,profile 的 `node_modules` 根本不在链上:一句 `require('@deepseek-ai/schemastery')` 必然 `MODULE_NOT_FOUND`。发布版(真实目录安装)才走普通 require 链。 - **怎么找。** `resolutionRoots()` 显式列出所有可能持有 schemastery 的目录,再用 `require.resolve(spec, { paths: [dir] })`(语义是「当作从该目录发起」)逐个尝试,按 **spec 分组、scoped 优先**(只要存在 `@deepseek-ai/schemastery`,那份一定胜出,不会被兄弟插件留在更早路径上的旧 `schemastery` 抢走)。候选根依次是:本模块自身目录 → `require.main` 的目录与 `require.main.path` → `process.cwd()` → 本模块的各级祖先目录 → `DSH_HOME` 与 `~/.dsh` 的布局 → **结构化扫描**(对 `__dirname` / cwd / `require.main` / `process.argv[1]` / `process.execPath` 的每一级祖先调用 `pushLayout`,即把 `<祖先>`、`<祖先>/node_modules`、`<祖先>/profiles/*` 及其 `node_modules` 全部纳入)→ `%APPDATA%\npm\node_modules` → `PATH` 的每个目录(上限 40 条)。最后两道兜底:`require.main.require(spec)`,以及**扫描本进程 `require.cache` 里已加载过的 schemastery**(DSH 自己的设置服务就 import 它,所以只要它在本进程里,就能按对象身份复用)。 **结构化扫描是这里的关键**:它不依赖 `DSH_HOME`、`homedir()` 或 cwd 取到任何特定值——只要进程还能说出自己从哪儿启动,就能顺着祖先目录找到 `/node_modules`。v1.1.1/1.1.2 只按环境变量拼路径,一旦启动器把 `DSH_HOME` 设成 profile 自身(或设成别的值)就会整条落空,这正是「Host 侧依旧 `absent`」的成因。 - **失败长什么样。** 找不到时 `Config` 是 `undefined`:`Config.listConfigs`(cordis_inspect 的 host provider)对该 entry 报 **`absent`**,DSH 不为它投影任何表单,client 于是永远停在 session-local —— **面板照常打开、开关照常能拨、刷新即复位**。这条症状与主题其它功能是否正常无关,所以极易被误判成「主题坏了」或「存储又变了」。注意 `absent` 只表示「没有 Config」;有 Config 但字段不 volatile 会以 `unsupported`/整条不出现的形式表现,含义不同。 - **不再静默。** Host 侧 `Config === undefined` 时 `apply()` 打 `ctx.logger.warn`(成功则 `debug` 一行写明从哪个根解析到);client 侧把 Host 真正 served 的命名空间列表并进既有的 `dbg` 诊断(`hostServes=`),`settle watch` 放弃时也明确打一行(`boundNs=` / `status=` / `hostServes=`)。有这一行就能立刻区分「Host 半没导出 Config」与「client 绑错了 entry 拼写」——两者现象完全一样。 - **自检报告(v1.1.2 起)。** 构建不出 `Config` 时,除日志外还会把一份 JSON 写到 **profile 目录**(`ctx.baseUrl` 可解析时)或 `$DSH_HOME`:`theme-endfield-diagnostic.json`。内容包含 `dshHomeDirs` / `homedir` / `cwd` / `mainModule` / `argv` / `baseUrl` / `loaderStartedAt`(本进程启动时刻,用来判断「到底有没有真的重启过」)/ `cachedSchemasteryModules`,以及**每个候选根 × 每个包名**的 `require.resolve` 结果(成功路径或错误码)。`Config` 一旦构建成功该文件会被自动删除(`clearStaleDiagnostic`):所以它**存在**=host 侧仍拿不到 schemastery,**不存在**=host 侧已正常、问题在别处。它只是诊断,不参与任何运行逻辑。 - **改 Host 半必须重启 DSH 进程。** 浏览器刷新只重新拉 `client.js`(client 模块由 host 按请求从磁盘读取,所以改了就生效);而 **Host 半的模块只在 profile 启动时 import 一次**(`EntryTree.import` 走 Node 内部 ESM loader,命中 ESM 缓存,不做 cache-busting),`dsh-hmr` 的 watch glob 默认忽略 `**/node_modules`,软链到仓库的插件文件不在它的观察范围内。因此「改完 `index.js` → 刷新页面 → 设置仍然不保存」是预期现象:进程里跑的还是启动时那份旧模块(旧版没有 `Config` 导出 → `absent`)。排查持久化问题前,先整进程重启一次再看——重启后若 `theme-endfield-diagnostic.json` 仍出现,才说明是解析问题;若它消失且 `Config.listConfigs` 变成 `schema`,说明已修好。 - **`Config` 必须是字面量属性(v1.1.4)。** 这条独立于「找不找得到 schemastery」:`index.js` 是 **CommonJS**,而 loader 用 Node 内部 **ESM** loader 导入 entry,CJS→ESM 的命名空间由 `cjs-module-lexer` 的**静态分析**决定。写成 `const exported = {...}; module.exports = exported; exported.Config = Config;`(赋值在字面量之外)时,lexer 可能只认得出字面量里的 `apply`/`name`,于是 `unwrapExports()` 拿到的对象**有 `apply`(entry 照常 active)却没有 `Config`** —— 表现与「解析不到 schemastery」完全一样(`absent`、无表单、刷新复位),但日志里既不会出现解析失败的 warn,也不会留下自检报告,因为那份 reporting 代码根本没被跑到。 **判据**:在普通 node 里 `require()` 这个包得到 `Config = true`,而宿主进程里 `Config.listConfigs` 仍报 `absent`,且重启后 `theme-endfield-diagnostic.json` **不出现** —— 三点同时成立就说明是这里,而不是路径解析。修法是把 `Config` 写成导出字面量的**静态属性**(值为 `undefined` 时表示「本机确实没有 schemastery,无需配置」,是合法状态而非法 schema)。 ### 找不到 `.volatile()` 也必须能存(v1.1.5) v1.1.4 之前的判据是「**必须**找到带 `.volatile()` 的 schemastery,否则不导出 `Config`」。这条判据把一个**可降级**的情况升级成了**完全不可用**:没有 `Config` 不是「主题少了个功能」,而是 DSH 不为该 entry 投影任何表单,于是**用户拨的每一个开关都只活在页面里,刷新即丢**——正是本文件反复记录的那个症状。 实测成因(本机 web profile):`@deepseek-ai/schemastery@3.18.4`(全机唯一带 `.volatile()` 的副本)**能 `require.resolve` 到,却 require 不进来**——自检报告 `resolution` 里那一行报 `resolved`,而 `cachedSchemasteryModules` 里根本没有它;同一轮扫描反而成功加载了 `xiaofeishu` profile 的 `3.18.1` 与无 scope 的 `3.18.0`,这两个都**没有** `.volatile()`。于是候选列表非空、却没有一个能过旧判据,`Config` 落到 `undefined`。 关键事实是:**`.volatile()` 就是 `this.extra('volatile', true)`**(3.18.4 源码里只有这一行),而 `.extra()` 在 3.18.0 / 3.18.1 里都有。DSH 侧读的是**投影结果**(`meta.volatile`),不是那个方法本身,所以标记可以在没有该方法时**合成**: | 能力 | 3.18.4(DSH 自带) | 无 scope 3.18.0 | 3.18.1 | | --- | --- | --- | --- | | `.extra(key, value)` | ✓ | ✓ | ✓ | | `.volatile()` | ✓ | ✗ | ✗ | | 能否加载(本机 web profile) | **✗** | ✓ | ✓ | 现在 `selectBuilder()` 分两轮挑:先要 `native`(真的 `.volatile()`,保真度最高),没有才退到只有 `.extra()` 的副本并**合成标记**(`mode: 'synthesized'`);两者都没有才不导出 `Config`。两种模式下每个字段最终都带 `meta.volatile === true`,DSH 都投影出可编辑表单。 - **判据写进自检报告**:新增 `schemaMode` 记录走了哪条路;`resolution` 每一行现在除 `resolved` / `error` 外还带 `loaded` / `loadError` / `volatile` / `marker`。「解析得到但加载失败」以前在报告里读起来像自相矛盾,现在是一行结论。 - **不再有「默认跳过」的断言。** `settings-config-forms.test.js` 的 Host 侧断言在拿不到 schemastery 时**整段跳过**,而拿不到 schemastery 恰恰是它要守的那个场景——于是在唯一要紧的环境里它什么都没验。新的 `settings-config-fallback.test.js` 不依赖本机 schemastery:它把**显式 builder**(含「只有 `.extra()`」这一形状)交给 `buildSchemaWith()`,逐字段断言默认值与 `meta.volatile`,并断言选择顺序(native 优先、`.extra()` 兜底、两者皆无则不导出)。 ### DSH 0.2.0-rc.2 上失效的三处应用侧钩子(v1.1.6 已跟进) 0.2 **没有再动 settings API**:`Config` + `ctx.configForms` 那套接缝与 0.1.7 完全一致(唯一新增的语义是 `set()` 用**布尔值**回答而不是 reject,client 的写账本两种都认),所以本插件的 Host/Client 两半都不需要改。0.2 换掉的是三个**应用侧**细节,而它们的共同点是**旧写法不报错、只静默失效**——所以三处都在真实 0.2.0-rc.2 页面上实测过(scratch profile + 真运行时 + CDP 探针): | 钩子 | 0.1.x | 0.2 | 旧写法的后果(实测) | | --- | --- | --- | --- | | 回合状态标签 | `@deepseek-ai/dsh-client-ui-conversation` 的 `_turnStatus`,**渐变文字**(`background-image` + `background-clip:text`) | `@deepseek-ai/dsh-client-ui-chat` 的 `_running`,**遮罩扫光文字**,着色只认 `--dsw-alias-label-deep-diving` / `-shimmer` | 规则永不命中;该令牌仍是应用自带值 `color-mix(in srgb, #101110 70%, #172554)`,标签完全没有主题色 | | 右侧栏列 | `_detailsCol`,不透明底色在其内部 `*_root` 上 | `_rightbarCol`,不透明底色**在列元素本身** | 选择器与目标元素**同时**变了:右侧栏一打开就整块盖住等高线图层 | | `_heroGlow` | 0.1.2-rc.1 起已无该模块 | 0.2 仍无 | 规则保持 self-healing 空钩子,不改行为 | 因此回合状态标签的换色**从 CSS 移到 `theme.overrideTokens` 层**:给 `--dsw-alias-label-deep-diving` / `-shimmer` 各写一对 light/dark 值,复用既有的 `--edge-status-*` 色标(对比度结论完全不变,见[§ 四类:回合状态标签](#四类回合状态标签)与 [design-language.md](design-language.md#为什么亮色模式的强调色要下沉)),并用 `var()` 引用让配色切换依旧零 JS 重绘。护栏同步跟上:`check.js` 的第 4 条改成检查这两个令牌的四个值、并**禁止** `[class*='turnStatus']` 复活(那会是一条永远匹配不到的「假修复」),`test/selector-guard.test.js` 同时钉住 `_rightbarCol` 与两个令牌名。 插件卡片的展示文案在 0.2 由 `locale/<语言>.json` 的 `meta.title` / `meta.description` 提供(`dsh.client` 声明本身不变),本版补上 `locale/en.json` 与 `locale/zh.json`。`dsh.client.inject` 里那两个 0.2 已不存在的包名(`@deepseek-ai/dsh-client-runtime`、`@deepseek-ai/dsh-client-ui-slots`)换成了 0.2 真正的提供方(`dsh-client-ui-theme` / `-ui-renderer` / `-ui-settings` / `-locale` / `-api-session-controller`)。这里要分清两件事:`dsh.client.inject` 是**浏览器包预载列表**(只影响加载顺序,指向不存在的包会被静默跳过,所以旧列表在 0.2 上「没坏」但也不再表达任何意图),而 `theme` / `configForms` 这类**服务**依赖由 client bundle 自己的 `exports.inject` 声明——`theme` 从来不是包名,写在这里本来就是无效项。 ### 存储字段名必须来自 schema,不能用「去掉前缀」推出来(issue #15) **曾经的写法(错误)**:客户端要往命名空间里写一个字段时,把 UI 键的前缀切掉当作字段名: ```js const field = PREFS_KEY_TO_FIELD[rawKey] || rawKey.slice(PREFS_NS.length + 1) // 'dsh-theme-endfield-thunder-anim' -> 'thunder-anim' ``` 而 Host 端的 schema(`index.js` 的 `FIELD_DEFAULTS`)声明的是 **camelCase**:`thunderAnim`。**只有单个词的字段两者才碰巧相同**,一切复合字段都不同: | UI 键(客户端) | 客户端推出的字段 | schema 声明的字段 | | --- | --- | --- | | `dsh-theme-endfield-thunder-anim` | `thunder-anim` ✗ | `thunderAnim` | | `dsh-theme-endfield-contour-anim` | `contour-anim` ✗ | `contourAnim` | | `dsh-theme-endfield-contour-fps` | `contour-fps` ✗ | `contourFps` | | `dsh-theme-endfield-contour-speed` | `contour-speed` ✗ | `contourSpeed` | | `dsh-theme-endfield-contour-scroll-pause` | `contour-scroll-pause` ✗ | `contourScrollPause` | | `dsh-theme-endfield-watermark-persist` | `watermark-persist` ✗ | `watermarkPersist` | **为什么表现是「开关刷新后复位」,而且是静默的。** schemastery 只校验它声明过的字段,**多出来的键原样带过去**。所以这一写并不是「没写进去」:`settings.yaml` 里真的多了一行 `thunder-anim: "1"`,而 `thunderAnim` 仍是默认值 `'0'`。于是: - 页面内开关正常工作(`prefsLocal` 里有本会话的值); - 刷新后 schema 合并出 `thunderAnim: '0'`,客户端只复制**声明字段**,那行 `thunder-anim` 谁都不读; - 又一次回到默认值。用户观察到的正是「打开后刷新又变回关闭」。 配套的坑是**测试桩复述了同一个错误**:`test/fixtures/settings-scope.js` 当时也用 `slice` 去前缀,于是「客户端读 `contour-fps`」与「测试桩写 `contour-fps`」两边对上,全绿——而生产环境里那个名字根本没人认。**测试桩必须按生产 schema 的形状(camelCase 字段名)造数据,否则它证明的是自己跟自己的约定。** 现在的写法:`PREFS_KEY_TO_FIELD` 把每个键**显式**映射到字段名,读写两侧都走 `prefsFieldOf()`,所以「读的字段」与「写的字段」不可能再是两条路径。`test/settings-namespace.test.js` 同时对着三份东西交叉验证:`client.js` 的表、Host 的 `FIELD_DEFAULTS`、以及设置面板真实渲染出来的每个开关。 ### 已经写坏的存档:把旧拼写里的值搬回声明字段 用户的 `settings.yaml` 里已经躺着 `thunder-anim` / `contour-anim` / … 这些**未声明键**。只修字段名的话,这些值会被忽略、字段回到默认——对老用户来说还是「又复位了一次」。所以 `prefsMigrateLegacy()` 在拿到第一段 served section 时把这些值**重新提交到声明字段上**(走的是 prefsSet,所以未就绪时照样会被 held + 补写)。判断「声明字段是不是用户自己的值」只有**一个**可靠信号: - 声明字段缺失或等于出厂默认 → 没人在这上面留下选择,旧拼写的键就是唯一的痕迹,**采纳**; - 声明字段是别的值 → 那是会正确写盘的版本存下来的,**让位**,旧键不得覆盖它。 不能用 `hasOwnProperty` 判断:拿到的 section 是 schema **已经合并默认值**之后的视图,每个声明字段都在,分不出「存过这个默认值」和「schema 补的默认值」。 `test/settings-namespace.test.js` 覆盖了这三种情形,并对四类改坏方式做过反向对照(见 [testing.md](testing.md))。 ### 读路径:本会话的编辑排在已取回的 section 之上 `prefsGetValue()` 曾经**优先**返回已取回的 section(`prefsFieldValue`),而 `prefsSet()` 只写 `prefsLocal`。宿主回相是异步的,所以「点一下开关,面板显示新状态、主题却还是旧状态」在回相到达前是真实存在的。现在只把 `prefsLocalEdited`(本会话真正被用户改过的字段)叠加在最上层:其余字段仍然以宿主为准,不会把服务端刚给的值盖掉。 ### 脏标记只能由「写成功」或「宿主已有该值」清除 `prefsReplayDirty()` 曾在本地值**等于出厂默认**时就直接清掉脏标记。若宿主仍存着非默认值,用户「改回默认」这一操作就永远不会落盘,下次刷新旧的(非默认)值又回来了。同步地,本地值等于**宿主当前值**也不能直接清:宿主视图可能还是旧的,而用户刚刚把它改成同一个值——那也是一次真实编辑。现在只有两种情况清标记:写出去了,或者宿主取回的 section 确实已经持有该值。 ## 变量必须声明在 body 而不是 :root **应用把主题令牌写成 `` 的行内样式**(`dsh-client-ui-layout` 对每个令牌调用 `body.style.setProperty`)。 推论一:任何**引用 `--dsw-*` 令牌**的自定义属性都必须声明在 `body` 上。`:root` 是 `body` 的父元素,在那里替换一个不存在的变量属于 *guaranteed-invalid*,计算值为**空**。 实测(真实浏览器中逐个读取计算值): | 变量 | 声明位置 | 计算值 | | --- | --- | --- | | `--edge-signal` | `:root`(不引用令牌) | `#fff500` ✓ | | `--edge-line` / `--edge-paper` / `--edge-soft` | `:root`(引用令牌) | **`""` 空** ✗ | | 同上三者 | `body` | `#d8d9d5` / `#e8e8e2` / `#dcddd6` ✓ | 这曾经是一个真实缺陷:`scrollbar-color: var(--edge-line) transparent` 里的变量为空,整条声明被丢弃,主题滚动条一直没生效(实测 `scrollbar-color` 计算为 `auto`)。现已全部移到 `body`,并由 `check.js` 做**结构化检查**(遍历每个 `:root` 块,查找引用 `--dsw-*` 的 `--edge-*` 声明)。 推论二:**令牌值自身可以是 `var()` 引用**。主题把 `--dsw-alias-brand-primary` 的暗色值声明为 `var(--edge-accent)`,而调色板变量也在 `body` 上,所以引用在同一元素解析,并随 class 翻转自动重解析——这是「换配色不需要重新注册令牌层」的全部原因。 --- ## 字体令牌是应用的公共接口,不是主题的开关 **曾经的写法(错误)**:主题在自己的样式表顶部声明 ```css :root { --dsw-font-family: Arial, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif; --ds-font-family-code: 'SF Mono', …; } ``` **为什么错**:`--dsw-font-family` 不是主题私有变量,而是**应用的 UI 根字体令牌**——`dsh-web-frontend` 里就是这一条: ```css body{font-family:var( --dsw-font-family, -apple-system, … )} ``` 而应用把该令牌声明在 `:root`。主题再声明一次就是在**同一个元素上后写覆盖**,于是整个界面根字体变成 Arial。任何注入到应用根节点、写着 `font-family:inherit` 的第三方挂件(如 DeepSeek-Balance-Whale-Widget)都会**继承**这个字体,余额数字原有的字形随之丢失。实测(本机真实浏览器):挂件计算字体是主题的 Arial,而应用自己的字体栈已经消失。 同类漏出还有挂在 `body` 上的全局文本属性:`font-feature-settings:"tnum" 1,"ss01" 1` 与 `font-variant-ligatures:no-common-ligatures` 同样会被挂件继承。 **现在怎么做**: 1. 两个字体令牌**一个都不再声明**(连 `--ds-font-family-code` 也去掉:它与应用的列表几乎一致,覆盖它对非主题自有元素是视觉惰性的)。`check.js` 新增结构检查——样式表里只要出现 `--dsw-font-family:` / `--ds-font-family-code:` 就判失败,`selftest.js` 有一个注入用例证明这条检查真的会红。 2. 主题自己的字体族抽成 `--edge-font`,声明在 `body` 上(原因同上:引用令牌的自定义属性不能在 `:root`),**只施加在主题自己创建/拥有的节点**上:启动加载屏 `[data-endfield-loader]`、水印字标 `[data-endfield-watermark]`、设置面板根 `.endfield-settings`。三者都是主题自己注入的节点,第三方挂件既不会是它们的祖先也不会是后代。 3. **主题字体栈在前,`--dsw-font-family` 作为末尾兜底**。这个顺序是量出来的,不是猜的:本机同一串文字在应用令牌下渲染 **Segoe UI**(81.688px),在主题字体栈下渲染 **Arial**(84.516px)。若把令牌放在前面,加载屏、水印、设置面板会全部换脸,品牌块那套按 Arial 量出来的比例(见「启动加载屏」)随之失效。放在末尾既保住主题自有表面的现有观感,又保留「应用侧配置了根字体仍能到达主题元素」的协议含义;宿主没有 Arial / Narrow / PingFang / YaHei 时也仍然落在应用字体栈上,而不是某个任意 generic。 4. 加载屏与水印的 `tnum` / `ss01` **跟着字体一起下移到元素自身**(不能再挂 `body`),否则挂件会被继承,加载屏也会失去它排版时依赖的等宽数字与变体字形。 5. 回归测试 `test/font-scope.test.js`:在同一页里同时放一个 `font-family:inherit` 的第三方挂件与主题的全部自有表面,断言挂件拿回应用字体、`font-feature-settings` / `font-variant-ligatures` 为默认值,而加载屏与水印仍在主题字体(Arial)上且各自带着 `tnum` + `ss01`。把旧写法注回去,这条测试与 `check.js` 会同时变红。 --- ## 样式表是一整个模板字符串 整份样式表是一个传给 `insertCss()` 的模板字符串(反引号包裹)。有三类改动会在「文件仍能解析」的情况下静默破坏效果,因此固化为 `check.js`: 1. **反引号**。字符串里出现一个反引号(哪怕写在 CSS 注释里)会提前结束模板字符串,整个 client bundle 解析失败。注释里要引用代码请用单引号。 2. **`${...}`**。在模板字符串里那是插值,不是 CSS。 3. **CSS 注释提前闭合**。这是最隐蔽的一类:注释提前收束后**注释本身仍是配平的**,真正的破坏是残留的说明文字落到顶层、与下一条选择器黏在一起,整条规则被丢弃。曾因此导致 `--edge-word` 未定义、加载屏品牌块整块塌回左上角。`node --check` 查不出这类问题——文件照样能解析。 `check.js` 的「顶层漏进散文」判据经过一次修正:第一版用「含逗号或英文单词」,在合法选择器上产生了 33 个误报(`:is([role='tab'], …)`、`input, textarea`、`tbody tr:hover`)。可靠信号窄得多——**CSS 选择器里不会出现「跟在字母后的句点 + 空格」,而散文会**。 --- ## 层叠与挂载点 三个图层各有各的挂载点,都是实测定下来的。 ### 等高线:应用外框内部 从应用自身 CSS 实测:**三个元素会用不透明的 `--dsw-alias-bg-base` 盖住任何 body 级图层**——应用外框、对话列、右侧栏列(0.1.x 叫详情列)。所以图层挂进外框内部,并在挂载期间把这几处底色置为透明(`:has()` 守卫使功能关闭时全部规则失效)。 外框本身已是 `position: relative` 且**不产生层叠上下文**,因此 `inset:0; z-index:0` 的子元素正好落在「外框底色之上、所有定位子元素之下」。 侧栏底色也一并透明:本主题里 `--dsw-specific-sidebar-fill` 与 `--dsw-alias-bg-base` **本就是同一个值**,所以这不改变任何像素,只是让整片地形连续穿过侧栏。 ### hero 水印:body,`z-index: 0` 水印曾画在下拉菜单**之上**——暗色模式下模型选择菜单内部能看到横穿而过的大字母。 **成因是一次 `z-index` 平局,不是「层级不够高」。** hero 容器带 `position:relative; z-index:1`,它**本身就是一个层叠上下文**,因此下拉菜单自己的 `z-index:20` 被**关在里面**,根本不参与 body 层的比较;而水印当时也是 `z-index:1`,与 hero 容器**同级**——平局按 DOM 顺序决定,水印作为最后追加到 `` 的兄弟节点赢下每一次平局,于是盖住了整个 composer 子树。 改为 `z-index: 0`:既输给 hero 容器(1)、标签页(1)、输入区(7),又仍然盖在应用外框自身的不透明底色之上——外框是 `position:relative` 且 `z-index:auto`,不产生层叠上下文,两者在同一绘制步骤里按 DOM 顺序排列。 实测(1440×900,逐像素): | | 下拉菜单内被水印改动的像素 | 菜单外仍绘制的像素 | |---|---|---| | 修复前 | **9945 px**(越界) | 37000 px | | 修复后 | **0 px** | 36084 px | 真实截图里量到的越界像素为 11002 px(合成色 `#464844` = `#f5f5f0` 以 α0.13 叠在菜单底色 `#2c2e2a` 上,逐位吻合),与复现值同量级。 ### 保持显示的水印:会话列内部,`z-index: -1` 非 hero 页面没有标题可跟随,而一个 fixed 的 body 子节点会画在消息文字**之上**(它没有 `z-index` 竞争者可输给)。所以改为挂进会话列内部并取 `z-index: -1`,需要该列同时具备两个属性(仅在水印挂载期间,由 `:has()` 守卫): - `isolation: isolate` —— 没有层叠上下文时 `z-index:-1` 会逃到最近的祖先上下文,画到该列自己的不透明底色**后面**,即完全不可见; - `position: relative` —— 该列原生是 `position:static`,绝对定位子元素会对着应用外框解析,横跨侧栏溢出。 ### 两块全屏遮罩的层级 | 表面 | `z-index` | | --- | --- | | 启动加载屏 | `2147483000` | | 雷霆大字 | `2147482000` | 加载屏是唯一有资格独占整屏的表面,所以大字必须低一档。两个数字都写成了断言。 --- ## 等高线背景 ### 四个实测决定的设计 1. **有界散射(1.9× 提速)。** 最初让每个高斯凸起在每个网格点上求值;高斯在 2.6σ 之外数值上已归零,改成每个凸起只写自己包围盒内的格点后,1440×900 实测 **8.30ms → 4.40ms**。这是本功能能开在背景里的前提。 2. **补一层长波起伏。** 高斯之和在岛屿之间**恰好衰减为零**,那里的场完全平坦、没有任何高度线穿过——首版渲染因此出现大片空白,暴露了构造痕迹。加入三道极缓的长波正弦后,间隙里仍有梯度可穿越,孤立的「靶心」才连成一整片地形。代价 1.70ms。 3. **用二次曲线画线,而不是直线段。** marching squares 每个网格边至多产出一个顶点,粗网格下折线**本身就是有棱角的**:实测线段平均 7.8px,顶点转角 p99 达 **41.7°**。`lineJoin` 救不了——1px 描边根本没有接合处可倒角。改成让每个原顶点当**控制点**、曲线穿过线段中点(C1 连续,数学上无折角),且不增加任何顶点: | 方案 | 转角 p99 | 顶点数 | 增加耗时 | | --- | --- | --- | --- | | Chaikin ×1 | 41.7° → 22.4° | 11.1k | +0.81ms | | Chaikin ×2 | 41.7° → 12.0° | 22.2k | +1.45ms | | **二次曲线(当时采用)** | **不再有折线转角** | **5.5k(不变)** | **+0.15ms** | > **后续演进**:中点二次曲线在长边上仍留下可见的「圆角多边形」弯折。现行实现改为 **Chaikin ×3 预处理 + 约束 Catmull-Rom 三次曲线**——曲线穿过原顶点、相邻段共享切向(切向系数 0.32),手柄长度上限(0.62× 较短邻段)防止窄鞍部过冲;开放路径的两个端点保持固定(0.4 系数、0.55× 手柄上限)。平滑效果由平滑/尖点测试固定,本表保留为当时的实测记录。 > > 上表的角度是在**当时的 10px 采样网格**上量的;现行 `CONTOUR_STEP` 是 **6**(网格加密后折线本身的转角更小,所以那张表是保守上界)。也正因为顶点数随面积平方增长,网格不能靠一味加密来解决棱角——见下一节。 4. **缝合成连续折线。** 线段按边 ID 缝合后,数千条散段变成约 80 条连续折线,整片地形只需一次 `stroke()` 而非数千次 `moveTo`。 ### v1.1.6:短窗口不再缠绕,同步画布路径不再压满一帧 两个独立缺陷,都是「看着像画风问题、实际是数值 / 复杂度问题」。前者影响**所有渲染路径**(它发生在场生成阶段),后者只影响**主线程 Canvas2D 回退路径**(worker + WebGL2 路径下绘制不在主线程,但同一段画线代码在 worker 里省下的分配与 GC 依然是净收益)。 **一、短 / 窄窗口下场被采样混叠(缠绕堆叠)。** 高斯凸起半径写的是 `(0.05 + rnd() * 0.09) * min(w, h)`——把地形特征尺寸绑在**较短边**上。外框一矮(或一窄),凸起半径就缩到比 6px 采样步长还小,于是相邻格点直接跨过好几条等值线,marching squares 吐出一圈圈紧贴的同心环加发夹尖刺,看起来就是线缠在一起、叠成一堆。实测:1440×130 明显缠绕,1440×60 退化成括号状碎屑,1440×900 正常。修法是给半径一个**不小于 4 个采样格**的下限(`CONTOUR_MIN_BUMPSAMPLES`);常规窗口的相对项本来就远大于该下限,所以画面逐像素不变。 **二、同步画布路径的单帧成本。** 在主线程 Canvas2D 路径上实测(1440×900,每张图): | 项 | 实测 | 处理 | | --- | --- | --- | | `closePath()` | 18 次 / 张,均值 **0.278ms**、最大 1.2ms,约占一帧的 **16%** | 它把当前子路径拼进已累积的 path,成本随 path 里已有内容增长;改成**先画环、再画开放折线**,让每次拼接只面对环几何 | | 每帧贝塞尔段 | **37,865** 条(来自约 4.7k 抽取顶点,Chaikin ×3 把点数变成 8 倍) | 按抽取顶点数自适应 Chaikin 次数:≤8000 维持原本 3 次,>8000 降到 2 次、>20000 降到 1 次 | | `tangent()` | 每段 8 个 `[x, y]` 数组 + 最多 6 次 `Math.hypot`,剖析器显示该项独占全页 CPU **14%** | 改成标量切向 + 扁平数组 + `Math.sqrt`(参数都是像素差,远离 `hypot` 的溢出区间),几何保持逐字节等价 | | `smoothPath()` | 每个源顶点约 14 个小数组(3 次 pass × 每点 2 个) | 全程扁平 scratch 数组 | 另外加了两道护栏:**格子数上限**(`CONTOUR_MAX_CELLS = 60000`,超出时按 `contourStepFor()` 加大步长;1440×900 是 36k 格,仍用 6px,普通窗口完全不变)把「一张图的成本」从随面积平方增长压成近似常数,同时让大 HiDPI 画布继续留在 worker 的显存上限之内;**占空比上限**(`CONTOUR_DUTY = 0.5`)只在同步画布路径生效——worker 绘制时一次刷新远低于 1ms,这个钳制永不生效;一旦回退到主线程且单帧超预算,就按「本次实测成本 / 0.5」拉长间隔,把线程还回去。相位仍然每次**只前进一个标称帧**(绝不按墙上时钟的间隔追赶),所以机器再忙看到的也是同一段动画放慢,而不是卡顿后跳一下。 > 判定口径要写清楚:worker 路径下**不存在**「页面被绘制卡死」这一症状(抽取与绘制都在 worker 里);上面那半张表量的是主线程 Canvas2D 路径,而 worker 拒绝超过 8M 显存像素的画布、或在 worker 不可用时,这条路径就是**实际在跑**的那条(`test/contour-loop-reentry.test.js` 专门覆盖这个回退态)。两条路径共用同一份 `contourDrawLines`,所以省下的分配在 worker 里同样成立。 ### 平滑只管中段:三处真正的锐角(issue #3) > 上面第 3 条的中点样条**只在折线内部**无折角。反馈「等高线变化时出现大量不规则锐角锯齿」时,中段确实是干净的(转角 p99 30.5° → 5.6°),锐角全部来自样条管不到的三处边界情形。实测每帧约 **127 个尖刺**,而整套测试全绿——因为它们在每一帧都存在,只是随场漂移不断换位置,所以看起来像「一变化就冒锯齿」。 1. **末段二次曲线是退化的(95/95 条折线)。** 循环最后一次迭代已经停在 `mid(v[n-2], v[n-1])`,紧接着那句 `quadraticCurveTo` 又拿 `v[n-2]` 当控制点、`v[n-1]` 当终点——控制点**落在起点后方且三点共线**。以线段长度归一化后 `Q(t)` 在 **t=1/3 处取到 0.333**(起点是 0.5),也就是曲线先**倒退** 1/6 个线段再折返:一个精确的 **180° 尖点**,拖出一根倒刺。实测倒刺平均 1.39px、最长 1.95px,与闭式解 `7.8/6 = 1.30px` 吻合。改成 `lineTo`——入射曲线本就沿 `v[n-2] → v[n-1]` 方向抵达那个中点,直接连过去不产生折角。 2. **闭合环被当成开放曲线画(32/95 条)。** `walk()` 靠**重复起点**来闭环,把这样的折线喂给开放中点样条,起点处的出射切线与终点处的入射切线毫无关系,于是每个环的接缝上都留一个角:实测中位数 **10.0°**、最大 **26.5°**。改成**循环形式**——在 `mid(u[m-1], u[0])` 起笔,每个顶点都是控制点,接缝因此落在某一段的**中间**而不是两端,整环 C1 连续,且不增加顶点。 3. **相切针刺。** 某条等高线与场**近乎相切**处,真实等值线是一个曲率极高但光滑的尖端;marching squares 在 10px 网格上线性插值表示不出来,只能吐出一根**出去又原路折回**的发夹,其**底宽比 1px 描边还窄**(最差实测:底宽 **0.831px**,却外伸 6.26px)。这种宽度下往返两笔压在同一批像素上,读不出「窄谷」,只看见那根倒刺——平滑救不了,因为几何里**真的**有这个特征,只能在提取处删掉。 判定要两个条件同时成立(24 帧 / 15.26 万顶点 / 118 万 px 墨迹实测):底宽 < 2px(描边分辨不出)**且**转角 > 90°(是折返而非拐弯)。命中率每帧 **0.7 个顶点、总墨迹 0.0037%**,是清伪影而非减密度;真实的窄特征毫发无损——转角 > 90° 的顶点底宽**中位数 4.03px**,而 8–12px 底宽整段(29562 个顶点)转角 p99 仅 21.7°。 > **只删尖端比不删更糟。** 第一版只丢弃发夹顶点,结果**底边自己变成了一条真线段**,把那次折返继承成**两个约 78° 的转角**(10.20 → 0.83 → 10.25px),全局最大转角反而从 65° 回升到 127°。因此顶点**和它对面的邻居一起消掉**,并把存活的前一个顶点拉到底边中点——位移最多 1px(2px 底宽的一半),1px 描边显示不出来,发夹处只剩一个光滑顶点。 修好后整段动画序列:**尖点 0 个、>90° 转角 0 个**,最大转角 65.2°、p99 4.7°;总墨迹比值 0.997(形状没被扭曲),24fps 预算仍有 78% 余量。 ### 小点是两类合法残渣 反馈的「莫名其妙的小点」不是渲染噪声,而是 marching squares 的两类产物: 1. **画布外的碎屑。** 网格是 `ceil(w/step)+1` 列 × `ceil(h/step)+1` 行,最后一行 / 列落在画布**边界上或之外**(1432×753 实测超出 +8px / +7px)。那里提取出的等值线被裁成短碎片,甚至完全不可见:85 条折线里 **33 条**含画布外顶点,**3 条**可见长度为 0——纯开销、零像素。 2. **山顶靶心环。** 距高斯峰顶一两格处,最内层等值线闭合成一个极小的圆。实测 4 个闭合环包围盒小于 26×26,最小 11.8×15.1px。 阈值不靠拍脑袋,而是量图案**自身的尺度**:横竖扫描 7322 个样本,等高线间距**中位数 21px**(p25 13px)。包围盒装不下一个线距的闭合环,里面放不下任何相邻环线,所以它读作「点」而不是「嵌套岛屿」。开放折线不受此限——那是延伸到画布外的线的可见一角,剪掉会在用户看得见的线上打个洞。 代价实测:丢掉全部 40px 以下的折线只损失 **0.223%** 的总描边长度,是清残渣而非减密度。 > **过滤器判定的几何和真正画出来的几何不是同一个。** 绘制时折线被改画成二次曲线,端点是线段**中点**(原顶点降为控制点),因此曲线可能比原始折线更短、包围盒更小。第一版按原始折线判定「已清干净」的种子,实际仍画出了 35.1px 的短描边与 15.4×17.8 / 2.1×20.1 的小环。故阈值留出余量(长度 ×1.35、环包围盒 ×1.5)——曲线在两端各最多缩掉半个线段,而线段平均 7.8px。 ### 随机地形与空白格 `contourRng` 的种子曾是常量,所以「随机地形」**每次打开都是同一张图**(两次独立加载实测均为 85 条折线、42497px 总长,顶点逐一相同)。改为每次加载抽一次种子(`crypto.getRandomValues`,退化时用时间 / `Math.random` 混合)。 **同一次加载内仍保持确定性**:地形会在每次 resize 重建,若每次重抽,拖动窗口就会把地形整个洗牌。实测拖走再拖回同一尺寸(含 520×900 窄窗与 2560×1100)地形完全复现。 写死的种子一直**掩盖着**一个真实缺陷:那唯一的布局恰好分布均匀。改成真随机后,8×5 覆盖网格(「近空」= 墨迹 < 0.6%)实测: | 布局策略 | 失败率 | 最差 | | --- | --- | --- | | 独立均匀采样 | **5 / 12 种子** | 3 个空格 | | 仅分层采样 | **6 / 24 种子** | 低至 0.00% | | 分层 + 校验(现行) | **0 / 20 种子** | — | 两步都不可省: - **分层采样(抖动网格)**:视口切成至少 K 格的近方形格阵,每个凸起落在自己格内的随机点。格序用 Fisher-Yates 打乱,避免「屏幕左侧」与「先抽到的尺寸」相关。 - **接受-或-重抽校验**:分层修不了真正的机制——线只出现在场**穿越** 21 条固定高度之处,局部平坦且卡在两条高度之间的区域,凸起排得再匀也是空白。而若强行加大倾斜以保证每格必有穿越,需要横跨全宽约 9.5 条平行线,那读作条纹不是地形。所以改为按测试所用的同一不变量校验候选布局,不合格就换 salt 重抽(每次仅一次粗网格场求值,不提取不绘制)。 校验门槛也是实测定的:只要求「至少 1 次穿越」时仍有 4 个空白格漏过,且**每个都含已绘制顶点**——高度只擦过格子一角,产出 0.16%~0.44% 墨迹,几何上成立、视觉上空白。故要求每格至少扫过 **3** 条高度带。 **但「几次穿越」并不等于「有多少墨」**(v1.1.6 补上的一环)。3 条高度带可以全部只擦过同一格的同一个角,格子照样几乎空白——这正是 CI 上 `contour-specks` 偶发飘红的原因:最薄的那批合格布局在 CI 的栅格化下量到 **0.47%**,低于 0.6% 的判据,而校验器认为它们合格。三处证据把这条缝隙钉死了: - 判据本身没问题:把采样步长从 2px 改成 1px、把 alpha 阈值从 >10 放宽到 >0,同一格只涨到 1.25 倍(**c1≈c2**),说明不是采样混叠也不是阈值把墨算丢了,格子是**真的薄**; - 提高穿越次数不是出路:要求每格 ≥**4** 条高度带时,32 次重抽有 **39%** 的加载触顶、只能落回兜底布局(正是重抽机制要避免的那种失败); - 提高「墨」才是出路,而且很便宜。 所以校验器现在**同时**给每格算一个廉价的墨量代理:遍历该区域的场四边形,数每个四边形的四角值域里落进了多少条被绘制的高度带。一次这样的命中就意味着等值线穿过该四边形,约等于 `step` 像素的描边——不必跑 marching squares、也不必在构建期做提取。在 1406×756 与 1440×757 两个视口、各 400 个随机布局上对着**真实提取出的每格描边长度**标定: | 墨量代理 | 最薄区域的真实描边长度 | | --- | --- | | 47–52(加门槛前的最差) | **182–201px** | | 80(现行门槛) | **~310px** | | 92(中位布局) | ~437px | 于是 `CONTOUR_MIN_INK = 80` 把「最薄区域」的保证从 ~182px 抬到 ~310px,代价是平均重抽次数 3.97 → 5.71、p95 15、最差 19(上限 32,200 个种子**从未触顶**)。实测效果:40 次加载里最薄的格子从 **1.02%** 抬到 **1.61%**,对 0.6% 判据的余量由约 1.7 倍变成 **2.7 倍**,CI 上那次 0.47% 的病态布局不再可能出现。 去掉分层只留校验:实测 8 次加载有 4 次耗尽 12 次重抽上限并落回兜底布局。分层让「一次过」成为常态(平均 2.5 个候选,最多 6,从未触顶),校验把它变成保证。一次性构建耗时 18.7ms,且只发生在挂载 / resize,动画稳态仍有 76% 余量。 ### 重启动画的首帧曾是空转 「关掉动态等高线再打开,图案不动了」的成因不是开关没接上,而是**重启后的第一帧必定无效**:重启把「上一帧时刻」置为 `-1`,而帧函数在这种情况下取 `dt = 0`,于是按原样重新提取并重绘了同一个相位——花掉约 4.4ms 产出逐字节相同的像素。 `dt = 0` 的本意是防「传送」:开关可能关了几分钟,把这段真实间隔当作 `dt` 灌进去会让地形瞬移。但代价是那一帧完全没有推进。实测(插桩构建):重新开启后 **700ms 内变化像素为 0**,因为渲染器在该窗口里只投递了一次 rAF 回调,而它正好被这个空转帧吃掉。 改为按**一个标称帧**(`1 / CONTOUR_FIELD_FPS`)推进:既保留防瞬移的钳制,又让每一帧都做真正的工作。修复后同一断言实测变化像素 **78450**。 ### 关于「光点移动」 早前版本的文档与测试里写着第三个「光点移动」开关(光点沿等高线流动)。**该功能从未实现过**(`git log -S` 查无任何提交),相关文档与测试断言属于描述未落地的设计——`settings-rows` 测试因此在每次全新签出时都必然失败。这些断言已删掉;一个默认就是红的测试提供不了任何信息。 设计阶段留下的一条结论仍有价值,记录在此备将来参考:**光点若要落地,抽搐会是索引失配而不是缺动画。** 等值线提取每帧**整个重建**折线数组,而随着场变形环线会合并 / 分裂——折线的**顺序和数量都不稳定**。实测 120 帧里数量在 81–87 之间跳动、有 27 帧发生变化;一旦光点靠数组下标记住「自己在哪条线上」,那个下标很快就指向另一条曲线。正确做法有两条:每帧按**最近几何**重新认领曲线,以及万一接不上就在 **alpha 为 0 时**换位重生。 --- ## 启动加载屏 ### 定位用「锚右边距」而非「锚左百分比」 早前用 `--edge-rail: 73%` 定左边缘,实测暴露两个问题:左百分比只决定文字**从哪开始**,其宽度自由地向右伸展,于是「离右边缘多远」——也就是肉眼真正读到的那段留白——从来不是被控制的量;且纯 `vh` 取值在**宽而矮**的窗口里会把字标缩小。 现在改为 `right: var(--edge-gap)`,把右侧留白变成显式声明值,节奏线由最宽的一行自然决定。 | 项目 | 参考图 | 修改前 | 修改后 | |---|---|---|---| | 字标 cap(占画面高) | 4.23% | **3.01%**(偏小) | 3.72% | | 右侧留白 | 7.4% | **10.5%**(且不受控) | 12%(显式声明) | | 行距 / cap | 1.10 | 1.10 | 1.10 | | 右侧溢出 | — | 无 | 无 | 字标尺寸 `--edge-word: clamp(26px, min(5.2vh, 4.8vw), 64px)`: - `min(5.2vh, 4.8vw)` 取**较小轴**,因此无论窗口「矮」还是「窄」,品牌块都不会挤进左侧进度轨; - `26px` 下限保证辅助小字在极小画面下仍可读; - `64px` 上限是实测结论——不加它时 2560×1440 下 cap 占比会掉到 **2.69%**(大屏上字标反而变小),加上后稳定在 3.2% 以上。 跨视口验证:520×900 到 2560×1440 共 **13 种视口**逐一实测(用精确尺寸的 iframe,`vh`/`vw` 与媒体查询按真实视口解析),全部无任何方向溢出,cap 占比维持 3.1–3.9%,与进度轨 / 读数的水平间距 ≥150px。字标仍**刻意略小于参考图**:参考图是整幅出血海报,而这里是一闪而过、盖在应用之上的遮罩。 ### 三处易错的排版细节 1. 字标行 `margin-left: -0.055em` 抵消 Arial 左侧字身空隙,让**字形墨边**(而非字盒)落在节奏线上; 2. `line-height` 取 **0.80** 而非 1.10——参考图的 1.10 是**墨边间距**,而 `line-height` 覆盖整个 em 盒(Arial-900 约为 cap 的 1.38 倍),直接写 1.10 会渲染成松散的 1.52; 3. 品牌块**不能加 `max-width`**:各行均为 `nowrap`,宽度上限只会裁字而不换行;要收窄应改 `--edge-gap`。 ### 两个只有量像素才发现的问题 - **小字号必须有 px 下限。** 各辅助行原先只写 `em` 比例,缩小整块后实测只剩 **3px 高、峰值亮度 94** 的灰糊——肉眼是一道模糊而非文字。现在统一改为 `max(8~10px, …em)`;实测每行笔画分组数 16~37(模糊时仅个位数),确认为可读字形。 - **标语用 Arial Narrow 压缩字体。** 早前判断「Arial 下标语无法满足参考比例」只对了一半:探测渲染器后发现 Arial Narrow 确实可用(同一探测串 245px vs Arial 299px);在缺少该字体的机器上会回退到 Arial,因尺寸是比例值而非固定 px,仍然不会溢出。 ### 收尾动画不能用 CSS transition 最初「铺满 + 淡出」写成 `transition: width …`,在验证渲染器里**完全不执行**——`transitionrun` / `transitionstart` / `transitionend` 一个都不触发,`width` 过了时长仍停在 10px。做过对照实验(transition 写在状态选择器里 / 预先写在基础规则里,两种写法都不动),确认是渲染器不跑 transition,与写法无关。 若照此发布,用户会看到左边一条 10px 黄条僵在原地。改为 JS 按墙钟时间逐帧写 `width` / `opacity` 后,实测同一渲染器下取到 17 个不同宽度、10px → 500px,并在真实速度截图中抓到中途帧。 ### 启动读取不能只信 `apply()` 那一刻 加载屏是唯一一个**必须在启动瞬间做决定**的表面:它在 `apply()` 里同步读一次 `loader`,读到就播。而设置节的实际到达顺序是异步的——`settingsScope` 的首次快照是 `{ status:'loading', value: undefined }`(`dsh-client-ui-settings` 的 `SettingsScopeSnapshot` 契约明写了这一点),Host 那份 section 走线上回来时 `apply()` 早已跑完。于是那次读落在 schema 默认值 `'0'` 上:用户即使存着 `loader:"1"`,也什么都不播。 其余每个开关都从这场竞态里活了下来,因为它们**各自都有稍后重推的路径**:主开关 / 圆角 / 配色 / 水印随 `reconcileFromPrefs` 重画,等高线由水印的 `MutationObserver` 反复重试,雷霆大字在每次节变化时重订阅。**只有加载屏被显式排除在重放之外**(它是一次页面加载只播一次的片子,不能让运行期改动静悄悄盖上全屏),所以它没有第二次机会——这就是「开关是开的、动画却再也不出现」的全部原因。 修法不是放开重放,而是把「首次权威节」做成一个独立信号:存储层读到一个 `status:'ready'` 的节、并跑完解析与旧拼写迁移之后,回调一次 `onPrefsSettled`;加载屏挂在这个信号上,且仍然只播一次。后续的节变化根本不经过它,「每次页面加载」的语义没有被稀释。 这个 bug 能穿过整套测试,是因为既有用例的假 scope 都**同步答 `ready`**:`apply()` 那一刻读到的已经是真值。`test/loader-late-prefs.test.js` 因此给假 scope 加上 `{ readyDelayMs }`——先答 `loading`、再翻 `ready`,只有这个形状能复现线上。 --- ## 设置页国际化 设置页此前是**硬编码中文**的:把 DSH 切到 English,这一页仍然整页中文。现在全部文案走应用自己的 `locale` 服务(`@deepseek-ai/dsh-client-locale`),随语言设置即时切换。 - **不自己猜语言。** 不读 `navigator.language`、也不另存一份偏好——那会和用户在设置里真正选的语言漂移。`locale` 服务已经持有偏好、durable 存储与重渲染通道。 - **词典命名空间** `settings.theme-endfield`,通过 `ctx.effect(() => locale.register(ns, { zh, en }))` 注册,随 run 释放;否则重复挂载会撞上服务自身的 `(ns, locale)` 去重而抛错。 - **`label` 用 thunk 而非字符串**(`label: () => t('nav')`):槽位契约会按读取次数重新求值,导航行标题才能跟着语言变,而不必重新注册。 - **zh 是键集的事实来源,en 必须与之完全对齐。** 少一个键不会崩,而是把**原始键名**渲染到界面上——静默且丑。因此测试直接对比两份词典的键集。 - **分隔符也是词典键。** 行读数原本写成 `label + ':' + value`,全角冒号是硬编码的,于是每一行英文都带着一个中文冒号。现在 `sep` 由词典给出(zh 用 `:`,en 用 `: `),并有「英文页面不允许出现任何中日韩标点」的断言。 - **分组标题在英文下不重复打印拉丁行。** 中文下是「04 娱乐 / ENTERTAINMENT」这种编辑式双行;英文下两行会 collapse 成同一个词,所以第二行直接省掉。 - **没有 locale 服务也能用**:`t` 回退到 zh 词典(最后才回退到键名本身),设置页照常渲染为中文——进程内测试就是以这种最小 ctx 挂载主题的。 ### `locale:` 只在服务存在时才声明 直觉上应该无条件写上 `locale: NS`,但 `dsh-client-ui-renderer` 对「声明了命名空间却没有安装 locale face」的条目会**抛 `SlotAssemblyError`**。 而重渲染其实并不依赖这个声明:renderer 的 `useLocaleRevision` 把**每一个** outlet 都订阅到 locale revision 上,所以本页在任何情况下都会随语言切换重渲染,`t` 也是从 apply 闭包里取的、不走注入的 seat。 无条件声明的唯一后果,是把「没装 locale 插件」从「设置页显示中文」变成「设置页直接崩」。所以该键只在服务确实存在时才并入注册项,并且两个方向都写了断言。 --- ## 峰谷定价窗口与法定节假日 顶部余额胶囊要在本地算出「现在是高峰还是低谷」——Host 没有任何服务携带这份排班表,主题只能自己推算、自己保管日历。规则的两次修订都必须进代码: - **2026-09-10 起** flash 系列改为峰谷分时计费:**北京时间周一至周五 9:00-12:00、14:00-18:00 为高峰**,价格是空闲时段的 2 倍(api-docs.deepseek.com pricing)。 - **2026-09-19 的「API 峰谷时间补充说明」** 补上了两条从主规则里读不出来的边界:中国**法定节假日全天**按空闲计费;**调休上班的周末仍按周末计费**(「只要是周末,就按空闲价执行」)。 第二条推论出本节最重要的决定:既然官方把调休日直接归进周末规则,代码就**不需要**任何调休例外表——周末分支天然把它们算成低谷。代价是这条推理必须被机器守住:`check.js` 第 7 段要求**每一条 `makeup` 日期都真的是周六或周日**,一旦将来某份通知把普通工作日调去上班,守卫立刻失败,逼着重新审视模型,而不是继续默默按高峰计价。 ### 日历是数据,不是逻辑 `BALANCE_HOLIDAY_NOTICES` 按年抄录国务院的放假安排通知(2026 年 = **国办发明电〔2025〕7号**,https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm ),`from` / `to` 是**含端点的 MM-DD**,`makeup` 存调休日——它不只是给人看的,也是上面那条推理的证据。启动时逐日展开成 `BALANCE_HOLIDAY_DATES`(`'YYYY-MM-DD' → 节日名`),再由此得出 `balanceHolidayName` 与 `balanceDayIsOffPeak`。 **没有收录的年份回退到普通工作日规则**,也就是一个未录入的节假日会被算成高峰。这是刻意的:宁可把假期显示成高峰(用户读到的只是「现在贵」),也不猜一个假的半价读数。代价是这份数据会随时间失效,而且失效是**静默**的——所以 `check.js` 在「当前北京年份不在表里」时响亮失败,作为每年 11 月(通知发布月)的提醒。 ### 窗口是「最长同价连续段」,不是日历天 旧实现把窗口限制在**当天 00:00-24:00**,于是周五 18:00 到周一 09:00 这段连续 63 小时的低谷被切成三块,周六上午的读数变成「剩余 14:20:00(到周日 24:00)」——一个与价格无关的数字。午夜不是价格边界,现在窗口是**同价连续块的最长游程**:7 天国庆读成一段 183 小时,跨节、跨周末自然合并。 实现上 `blocksAt(day)` 给出当天的块序列(整天谷 / 工作日五块),非高峰时从当前块向两侧逐块走,遇到高峰块即停;步数上限 `BALANCE_WINDOW_WALK_CAP = 4096` 只作防呆(9 天春节连周末约 20 块,实际不可达)。顺带把 `BALANCE_PEAK_WINDOWS` 从**死常量**改造成块序列的推导来源:此前它在声明后从未被使用,函数里另写死了一份 `edges`,两者随时可能漂移。 ### 节假日名只出现在开场 pose 胶囊宽 300px,窄屏断点 316px 正是按它推出的,所以 **settled 行的文案一个字都不能加**。「今天是国庆节」只在开场品牌 pose 的标题里体现(`DeepSeek 国庆节低谷`,普通日子仍是 `DeepSeek 当前低谷`);`test/balance-capsule.test.js` 直接断言 `win.holiday` 只被**一行**读取、且那一行在 brand-title 块内,防止它以后爬进 settled 行把胶囊撑宽。 算术本身(节假日 / 周末 / 工作日各读到什么窗口、倒计时多少)钉在 `test/balance-window.test.js`;守卫与注入用例见 [testing.md](testing.md#顶部余额胶囊)。 --- ## 已修问题归档 按成因分类。共同点:**都是量出来的,不是读代码读出来的。** ### 一类:底色归主题、文字归应用的裂缝 主题把某个背景令牌映射成实心强调色,却没有接管前景,于是应用自己声明的 `color` 直接落在强调底上。 以设置 › 模型 的 `编辑` 按钮(类名 `_secondaryButton`,0.1.1 bundle 里哈希为 `zGbnIq_`)为例,上游声明: ```css color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover-solid); /* :hover */ ``` 暗色模式下 `label-primary`(`#f5f5f0`)就直接落在信号黄上。从反馈截图里逐像素量到:**63 个 `#f5f5f0` 像素压在 `#fff500` 上 = 1.05:1**,等于不可见。 早前的 hover 反色规则没兜住,是因为那条规则**刻意排除了普通 `button`**,好让「黄底黑字的开关」和「深底白图标的发送键」各自保住配色。而这类按钮恰好是「底色来自主题、文字来自应用」的,只能点名修。 顺着这个模式审计安装态 bundle,发现**同样的缺陷共 6 处**(类名中的哈希随构建变化,现按语义后缀匹配;0.1.1 bundle 中的哈希备查): | 元素(语义后缀) | 0.1.1 哈希 | 位置 | 修复前(暗色·谷地黄) | 修复后 | | --- | --- | --- | --- | --- | | `_secondaryButton` | `.zGbnIq_…` | 设置 › 模型 行操作(`编辑`) | **1.05:1** | 16.50:1 | | `_inspectButton` | `.gNWCoW_…` | Cordis 检查面板 | 1.05:1 | 16.50:1 | | `_inspectButton` | `.iWrAna_…` | 技能检查面板 | 1.05:1 | 16.50:1 | | `_inspectButton` | `.o3BgMG_…` | 工具检查面板 | 1.05:1 | 16.50:1 | | `_arrow`(限输入区容器内) | `.JVDQca_…` | 附件轮播箭头 | 1.05:1 | 16.50:1 | | `_add`(限定输入区容器内) | `.uV2eYG_…` | 输入区 `+` | 早前已修;0.1.7 因裸子串命中容器而复发,见八类 | 18.31:1 | 武陵青下同一处是 2.61:1——也不合格,只是没那么刺眼,这正是它一直没被发现的原因。 **选择器匹配的四条铁律**(前三条来自 0.1.2-rc.1 全量重哈希、33 个哈希选择器同日全灭,见 issue #17;第四条来自 0.1.5-rc.2 的 header 重构,见七类): 1. **禁止把模块哈希写进选择器。** CSS Module 类名是 `_<语义后缀>`,每次上游重新构建哈希全变,钉哈希的选择器**静默失效**。`test/selector-guard.test.js` 会在哈希重新出现时报警。 2. **复合状态用子串匹配,不用 `[class$=]`。** 属性后缀选择器要求**整个 class 属性**以该串结尾,而元素常常还带第二个类(实测 `[class$='_inspectButton']` 在 `class="gNWCoW_inspectButton HOVERPROBE"` 上直接漏掉)。`[class*='_语义名']` 对拼接免疫;`_unselected` 因下划线断词不会误中 `_selected`。 3. **泛化后缀必须加作用域,而且 0.2 连「作用域本身」也会改名。** 轨迹与工作区也有 `*_arrow` 类但**没有 hover 填充**,裸匹配会给它们强行刷墨色(暗色下黑-on-黑)。附件箭头按输入区容器(`_composerSeat`/`_composerHero`)限定;同理清等高线背景必须用列后缀限定 `_root`——0.1.x 是 `_centerCol`/`_detailsCol`,0.2 右侧栏改成 `_rightbarCol` 且不透明底色搬到了列元素本身,所以限定词与目标元素要**同时**更新(见[§ DSH 0.2.0-rc.2 上失效的三处应用侧钩子](#dsh-020-rc2-上失效的三处应用侧钩子v116-已跟进))。这类改名不会报错,只会让规则静默不命中。 4. **子树位置不是语义,不要用 `>` 把中间层数写死。** 语义后缀能扛住重新哈希,却扛不住上游**插入一层包裹元素**:`A > B` 在 `A > C > B` 上直接失配,同样静默。这条 bug 在一次会话里被犯了**两次**(见七类):先是把徽章当成 header 的直系子节点,改成 `_headerActions >` 之后又漏掉了**插槽自己那层没有 class 的包裹 div**。层级要么用后代组合器表达,要么更好——**改用元素自身的特征**把目标锁定(不依赖任何一层的位置)。新增或调整这类选择器时,必须对着**真实 DOM**(浏览器里量出来,或从上游渲染代码读出来)验一遍,而不是对着测试夹具。 ### 二类:前景与背景被映射成同一个值 提问卡片的「推荐」徽标在亮 / 暗两种模式下都完全不可见。上游把 `--dsw-alias-button-info-fill` 当**前景色**用,底色用 `--dsw-specific-sidebar-nav-item-active-accent`;而本主题为了中和残留蓝色,把这两个令牌映射成了**同一个值**(亮 `#101110` / 暗 `#fff500`),于是标签把自己画在自己的底色上。 实测「有文字 / 无文字」两版渲染**逐像素差为 0**——DOM 里有字,画面上一个像素都没有。修复不去动这两个令牌(它们在别处确实被当作背景填充使用),而是**只在选项行内**把这对前景 / 背景显式钉死。 选项编号是相邻的一处:既有的暗色选中行反色规则只把后代**文字**改黑,改不到后代**自己的背景**,所以编号仍保留 `--dsw-alias-bg-overlay`(`#1c1e1c`)的底色,实测 1.25:1。改为给编号加一层半透明墨色**淡底**(而非填死),让数字落在强调底上仍读得出,同时保留「小方块」的形态。 | 用例 | 修复前 | 修复后 | |---|---|---| | 「推荐」徽标 · 暗色 · 普通行 | **0 px(不可见)** | 1275 px,16.50:1 | | 「推荐」徽标 · 亮色 · 普通行 | **0 px(不可见)** | 1275 px,16.50:1 | | 「推荐」徽标 · 暗色 · 选中行 | 1274 px,18.31:1 | 1296 px,16.50:1 | | 选项编号 · 暗色 · 选中行 | 207 px,**1.25:1** | 225 px,11.69:1 | ### 三类:色值对某一种模式不成立 - **亮色模式「移除」按钮。** `--dsw-alias-state-error-primary` 是 `#ff3b30`,在设置面板底色 `#f2f2ec` 上只有 **3.16:1**。iOS 风格的红是为「白字红底」调的,不是为「红字纸底」。只压暗**文字颜色**(令牌本身在别处仍作填充 / 状态点使用)后为 5.16:1。 - **雷霆大字的压暗底 alpha。** 初版取 `rgba(16,17,16,0.28)`:暗色模式下 18.92:1 毫无问题,**亮色模式下白字只有 2.29:1**(`bg-layer-1` 上更低,2.11:1)。只读 CSS 看不出来,是渲染出来量像素才抓到的: | alpha | `bg-base #e8e8e2` | `bg-layer-1 #f2f2ec` | 暗色 `#101110` | |---|---|---|---| | 0.28(初版) | **2.29:1** | **2.11:1** | 18.92:1 | | 0.40 | 3.13:1 | 2.90:1 | 18.92:1 | | 0.50 | 4.17:1 | 3.89:1 | 18.92:1 | | **0.55(现行)** | **4.85:1** | **4.55:1** | 18.92:1 | 取 0.55——两个亮色表面同时越过 4.5:1,对一个只需满足 3:1 大字号下限的词来说是刻意留的余量。暗色模式无论取哪个值都不变,所以这个数字是由**亮色表面**定的。 - **大字的白色必须写字面量 `#fff`,不能用令牌。** `--dsw-alias-label-primary` 在亮色模式下是**墨黑** `#101110`,用令牌会把「白色大字」在奶油纸底上渲染成近黑字。 - **深色水印过响。** 在近黑底上**叠加**亮度,比在纸底上**减去**亮度显得响得多,同样的 alpha 在深色下更吵。故深色从 0.13/0.16 降到 `0.085`(1.215:1),亮色保留 `0.13`(1.310:1)。 ### 四类:回合状态标签 该标签在 **0.1.x** 上是 `Md3f7G_turnStatus`,是**渐变文字**而非普通着色文字:上游画了一层 `linear-gradient` 背景,再用 `-webkit-text-fill-color: transparent` + `background-clip: text` 把字「镂空」,并以 `background-position` 做流光动画。由此两个结论: 1. **写 `color:` 完全无效**——透明文字填充优先,字仍由渐变决定;改色必须改渐变本身。 2. **不能去动 `--dsw-static-deepseek-500/200` 这两个共享令牌。** 它们同时支撑 `--dsw-alias-button-info-fill`、`--dsw-alias-state-business-primary` 与 `--dsw-specific-bubble-highlight`,本主题刻意把它们映射成墨 / 纸色。因此当时只覆盖 `background-image`,上游的 `background-size`、`background-position` 与流光动画保持不变。 **0.2 换掉了整套机制**(v1.1.6 已跟进,实测见[§ DSH 0.2.0-rc.2 上失效的三处应用侧钩子](#dsh-020-rc2-上失效的三处应用侧钩子v116-已跟进)):标签搬到 `@deepseek-ai/dsh-client-ui-chat` 并改叫 `_running`,是**遮罩扫光文字**——渐变、`background-clip` 都不存在了,上面两条结论随之失效:写 `background-image` 没有任何作用,改色只能落到 `--dsw-alias-label-deep-diving` 与 `-shimmer` 这两个令牌上。本主题因此把换色移进 `theme.overrideTokens` 层,值仍复用同一批 `--edge-status-*` 色标,两代共享同一份对比度结论。 色标取法见 [design-language.md](design-language.md#为什么亮色模式的强调色要下沉)。 ### 五类:生命周期与竞态 - **`sessions` 服务迟到导致功能永久失效。** Web 启动是 `Promise.all` 并发挂载所有插件行,而本主题**不声明 `inject`**,所以 `apply()` 完全可能跑在 `dsh-client-runtime` 提供 `sessions` 之前。初版在 `apply()` 里缓存了一次 `ctx.get('sessions')`,于是在这类加载顺序下雷霆大字会**永久失效**——只在慢速 / 冷启动时偶发。现在改为**惰性解析 + 120ms 重试**。 之所以不用 `inject: ['sessions']`:那会让**整个主题**进入 cordis 的 pending 态,把令牌与样式表的挂载一起推迟——主题必须先能上色,即使这个娱乐功能永远拿不到服务。 - **列表快照里的 `current` 字段消失,把「暂时没有当前会话」写成了终态。** 上一个 bug 修的是**服务迟到**,这一个修的是**契约变化**:上游把 `SessionListState` 收窄成 `{ ids, byId, phase, projectionsBySession }`,`current` 整个没了(view selection 早在 `service.d.ts` 里就被注明「remains outside the Controller」)。旧代码 `snap.current` 于是恒为 `undefined`,而它落进的分支是 `thunderDetach(); return`——**没有订阅、没有定时器、没有任何后续推送能再进来**,功能在新版本上静默失效,而测试全绿:假 `sessions` 服务当时也按同一份旧形状造形,等于把 bug 一起固化了。 现在的权威答案是 **mainView retention**,而不是某个字段:workspace 面板用 `this.sessions.retain(target, { source: 'mainView' })`(`@deepseek-ai/dsh-client-ui-workspace`)标记「用户正在看这个会话」,行上体现为 `retainedBy.mainView > 0`;全应用统一读法是 ```js Object.values(state.byId).find((row) => (row.retainedBy?.mainView ?? 0) > 0)?.id ``` 7 个官方包(`ui-layout` 的文档标题、`ui-cordis`、`ui-open-in-app`、`ui-settings-general`、`ui-agent-preset`、`ui-session`、`ui-workspace`)与第三方 `@nanmicoder/dsh-agent-teams` 都用的这一式。**注意 `mainView` 并不在 `SessionReferenceSourceMap` 的声明联合里**(那里只有 `controllerOperation` / `gateway`),它是运行时扩展——只能按运行时行为读,不能照 d.ts 穷举。 修法分三层:`thunderCurrentId(snap)` 先扫 `byId` 的 mainView 保留、再退回旧的 `snap.current`(兼容旧宿主);列表快照读不到时**保留旧 watch**,不把「读不到」当「用户离开了」;id 解析不到时不再当终态——`list.subscribe` 还在就等下一次 publish(`publishRetention()` 会把新的 `retainedBy` 推进列表,所以切换会话照样能驱动重绑),只有连 `list.subscribe` 都拿不到才走 120ms 有界重试。上一版的教训在这里再次成立:**主题不声明 `inject`,所以「服务/字段暂时拿不到」必须永远留在等待态,而不是终态。** - **子开关在已挂载时失效。** 为避免每个流式 token 触发重排而加的快速返回,把开关协调代码一起跳过了。 - **TDZ 崩溃隐患。** 拆除函数赋值的 `let` 声明在它下方,而该函数经 `unmount()` 可达,`typeof` 也挡不住这种 `ReferenceError`。 ### 六类:写法统一 强调色此前存在两种写法(CSS 里是 hex,设置行文案与画布描边是 `rgb()`/`rgba()`),这是「同一个颜色的两份拼写各自漂移」的温床。现已统一为十六进制: - 画布描边改用 **8 位十六进制** `#RRGGBBAA`。这一步先在真实浏览器里验证过再落地:canvas 会把 `#14d0d045` 规范化为完全相同的 `rgba(20, 208, 208, 0.267)`,实测绘制像素 alpha 为 68/255; - 设置行文案改为标注 `#14d0d0`; - 仅保留 `--edge-accent-rgb` 这一个通道列表,因为约 30 处半透明色块用的是 `rgba(var(--edge-accent-rgb), α)`。 试过用 `color-mix()` 把它也去掉:数值上完全等价(实测 `color(srgb 0.0784314 0.815686 0.815686 / 0.16)` 即 `rgba(20, 208, 208, 0.16)`),但**序列化形式不同**,会改变 30 多处色块的计算值字符串。为删掉一个派生量去动 30 处、并让现有断言全部跟着改,是纯风险无收益,故保留——它与 hex 紧邻声明,不会各自漂移。 等高线描边的 alpha **不是照抄黄色的**:相同 alpha ≠ 相同存在感。初版青色按黄色的 0.20 叠在 `#101110` 上实测只有 1.332:1,而黄色是 1.734:1——低 23%,观感上像功能变弱了。因此按「合成后对比度对齐」分别调参: | | 描边 | 合成对比度 | 相差 | | --- | --- | --- | --- | | 谷地黄 暗色 | `#fff50033`(α 0.20) | 1.734:1 | — | | 武陵青 暗色 | `#14d0d045`(α 0.27) | 1.758:1 | **+1.4%** | | 谷地黄 亮色 | `#beaf006b`(α 0.42) | 1.290:1 | — | | 武陵青 亮色 | `#14d0d07a`(α 0.48) | 1.288:1 | **−0.2%** | 亮色青色无需像黄色那样另配一个压暗值(`#beaf00`),因为 `#14d0d0` 并不接近纸白。 ### 七类:语义后缀对了,子树位置却变了(同一个 bug 犯了两次) 会话头部的 agent-preset 徽章(「创造模式」)在 0.1.5-rc.2 上悄悄退回上游的灰色胶囊——没有任何报错,因为**匹配不到任何元素的选择器不会报错**。 旧规则 `[class$='_centerCol'] [class$='_header'] > [class*='_label']` 的后缀全对、哈希也没钉,前三条铁律条条满足,它错在**位置**:`>` 要求这个标签是 header 行的直系子节点,而真实 DOM 里它深在四层之下。第一次修复把 `>` 下移一层到 `_headerActions`——**还是错的**,因为 header 渲染插槽时,插槽会给**每一个条目再包一层没有 class 的 div**: ``` div.pI_x6G_centerCol > header.wSkVaW_header > div.wSkVaW_titleRow > div.wSkVaW_titleCluster > div.wSkVaW_headerActions > div ← 插槽的条目包裹层,className 为空字符串 > span.SVAs4q_label ← 徽章 > svg.SVAs4q_icon ``` 也就是说:**凡是靠 `>` 数层数的写法,都在赌上游渲染几层包裹;上游这一版加了两层。** 而包裹层本身没有 class,连"点它的名"这条退路都没有。 **为什么第一次"修完"看起来像没生效。** 在浏览器控制台里量了一次真实 DOM,得到的三个值把三种可能一次分清了:`HASNEW true`(我改的规则**已经到页面**,所以不是缓存/没刷新)、`ACCENT #fff500`(变量有值,不是变量问题)、`BG rgb(36, 38, 36)`(最终仍是上游灰底)+祖先链里那个 `DIV.`。**一条"已经生效但什么都没匹配到"的规则,和一条"根本没送到浏览器"的规则,症状完全一样**;不先在页面里量这三个值,只会在"是不是缓存"和"选择器又写错了"之间来回猜——本次就是这样多花了一整轮。 **为什么整套验证都没发现。** `test/shoot.js` 的截图夹具先后把标签直接挂在 `.wSkVaW_header` 和 `.wSkVaW_headerActions` 底下,**两次都是照着当时的选择器搭的**;`test/preset-chip.test.js` 的夹具也一样漏了那层无 class 包裹层。于是选择器在夹具上命中、截图正常、测试全绿,而真实页面一个元素都没匹配到。这是本次最值得记的教训:**夹具一旦是为了"让选择器通过"而搭的,它就从验证退化成了同义反复。** **最终修法:不再数层数,改用徽章自身的特征。** ``` [class$='_centerCol'] [class$='_header'] [class$='_headerActions'] [class*='_label']:has(> svg) ``` 作用域停在 `_headerActions`,目标由徽章**自己**的性质确定——它是该插槽里唯一带图标的 `_label`。这条判据是有依据的:同一插槽里 jobs 渲染出的是 `_root` / `_trigger` 根,它那些 `_label` 出现在下拉菜单**深处**且**只有文字**;schedule 模块里根本没有 `_label` 这个局部名。**把裸后代匹配当反例做了变异验证**(去掉图标判据)——jobs 的行标签立刻被刷成强调色,说明这条判据不是装饰。 另外一处不是选择器问题,而是**越界**:主题在这里的职责是**配色**,不是几何。第一次修好选择器时,我顺手按旧注释里的"撑满动作行"意图把包裹层压平(`display:contents`)并让 `_headerActions` 增长(`flex:1 1 auto`),结果在真实头部上变成一条**横贯整个会话列的黄色长条**(实测 976px 的行里占了 923px),被原样反馈回来("现在变成一个长条了")。那套几何是 0.1.2-rc.1 时代为"徽章是 header 行直系子节点"写的,骨架变了以后照搬只会得到另一个坏结果。**结论:只改颜色,尺寸与命中区域沿上游**(高度、180px 上限、超长省略号全部保留)。这条也写进了代码注释,防止下次又被人按旧注释"还原"回去。 **变异验证 5 类,全部报错**:换回第一次修完的那版选择器(漏包裹层)→ 2 条断言红(背景正是线上看到的灰底);把图标判据换成裸后代 → jobs 标签与"容器外 `_label`"两条反向断言红;重新加回压平 + 增长 → 两条"变成长条"断言红。 `test/preset-chip.test.js` 现在按**量出来的**骨架搭夹具(含那层无 class 包裹层,以及一个"根带 class、`_label` 藏在菜单里"的 jobs 式兄弟条目),断言**结果**而不是选择器文本,并且**同时守住两侧**:既不能没上色,也不能被拉成长条。 ### 八类:子串选择器命中了**容器**(`_add` → 设置 › 模型的包裹层) 反馈:设置 › 模型中「添加模型提供商」被鼠标指上去时,整条虚线按钮变成实心信号黄,文字近乎不可见(截图像素:底色 `#fff500`、文字 `#f5f5f0`)。 根因不是"底色归主题、文字归应用"(一类),而是**规则命中了不该命中的元素**:输入区 `+` 的钩子是裸子串 ```css body[data-ds-dark-theme] [class*='_add']:not([class*='_addButton']) { color: var(--edge-accent) !important; } body[data-ds-dark-theme] [class*='_add']:not([class*='_addButton']):hover { color:#000 !important; background: var(--edge-accent) !important; } ``` 而"添加模型提供商"的 `