# 开发日志 / Development Log `dsh-yizi-themes` 的开发过程记录:关键决策、踩坑与原理。按时间倒序。 > **版本号说明**:下面标注 v0.5.5–v0.6.5 的条目,是 0.5.4 之后同一轮改造期间的**本地测试迭代**(每验证一步就加一个号)。它们从未对外发布,最终统一发布为 **v0.5.6**,见 [CHANGELOG](../CHANGELOG.md)。日志保留当时的编号以忠实记录排查过程。 --- ## 2026-10-28 · v0.6.5:一个未声明的变量炸掉了整张样式表(用户报"样式全变、控件变形、logo 变形、主题点不动") ### 怎么找到的:答案是"看不见" 连续两轮修复都没命中,因为**真实错误从未进入任何日志文件**: - 桌面应用的崩溃日志只捕获 renderer 的 **error 级 console**; - cordis 在 fiber 失败时走的是**它自己的 logger**(`vendor/cordis/src/fiber.ts:661` 的 `ctx.logger.error(reason)`)。 于是我做了 v0.6.4:把每个 apply 阶段的 `ok:<阶段>` / `FAIL:<阶段>: <错误>` 面包屑写进 **localStorage**,未预期异常记 `FATAL: <堆栈>`。这一招的关键是——**即使页面根本起不来,记录也已经落在磁盘上**,事后可以从 Electron 的 LevelDB 里读出来。 重启后应用起来了、控制模块也回来了,但样式全崩。读 LevelDB 拿到: ``` apply:entered → stage:sheet-start → FAIL:emit sheets: inkL is not defined → 品牌座位 / 头部控件 / 浮动控件 / core 注册 / 主题状态采纳 / 行字典 / 行注册 / 观察器 全部 ok ``` ### 根因:两处错误叠加 1. `deriveDarkPalette()` 在"中性墨描边"分支引用 `inkL`,**但该函数从未声明它**(亮色版 `deriveLightPalette` 声明了)。用户把自定义主题的"高级调色 → 描边"设成**中性墨**,三元表达式才真的去取它 → `ReferenceError`。 2. 更要命的是失败的位置:`refreshSheets()` 原本**整张表拼好再一次性赋值**。`customThemesCss()` 抛错 ⇒ 赋值从未发生 ⇒ **基础表也没写进去**。19 个预设主题的样式、设置行的布局 CSS、品牌/logo 覆盖规则全部消失,浏览器退回默认排版。 **四个症状(样式全变、控件变形、logo 变形、主题点不动)是同一个根因。** 这也解释了"为什么之前好好的":0.6.1 时描边是默认的品牌色,不走该分支;改成中性墨之后,每次启动都炸在同一行。 ### 修复 1. 补 `inkL` 声明(与亮色版同一套 clamp)。 2. **发射分块隔离**:基础表永远先写,自定义主题单独 try/catch——坏掉的主题只能丢掉自己那一段。 3. 保留 bootlog(限制最近 60 条)作为常驻自诊断。 ### 沉淀 - **别让"整体赋值"成为单点故障**:多个独立产物(基础表 / 自定义块)必须分别构建并分别兜底,否则一个坏输入会让全部输出消失。 - **esbuild 不做类型检查**:未声明标识符可以一路打包发版。`tsc --noEmit` 只用于查 `TS2304`(未声明标识符)这类致命问题也值得加进构建。 - **日志路由也是可观测性**:第三方 logger 会把关键错误从 console 里带走。写一条**落盘**的轨迹(localStorage)比在 console 里大海捞针可靠得多——而且它在启动失败时依然有效。 --- ## 2026-10-28 · v0.6.3:apply 抛异常 = 整个应用拒绝启动——插件必须"炸不死宿主"(用户报"启动失败 + 控制模块全消失") ### 事故链条 用户重启后:`web boot: 1 entry did not activate` / `dsh-yizi-themes: failed`,应用启动中止;禁用插件才恢复;手动启用后主题控制模块全部不见。 读 rc.3 `packages/client/web/src/boot-client.ts` 的 `assertEntriesActive`:fiber 状态非 active 即视为启动失败。`apply()`(及其**立即执行**的 effect)一抛异常,fiber 就是 failed。 ### 定位方法:用真实 SlotCore 做最小复现 之前所有 jsdom 复现都用桩 slots,恰好把真实校验全绕过了——**唯一没被真实覆盖的服务就是本次的凶器**。改用真实 `packages/client/ui-slots/src/index.ts` 的 `SlotCore`(零运行时依赖,可直接打包)加一层忠实的最小 slots 服务(声明表 + inject 观察者),跑四种组合: | 场景 | 结果 | |---|---| | 单次 apply,槽先声明(boot 顺序) | 0 错误,行 1 份、品牌座位 1 份 | | 单次 apply,槽后声明 | 0 错误,同上 | | **双次 apply,槽先声明** | `single slot "sidebar.brand.mark" already has a registration at priority -100` | | **双次 apply,槽后声明** | `list slot "settings.general.item" already has an entry with id "yizi-appearance"` | **结论**:单次 apply 干净;第二次 apply 才会撞注册。品牌座位注册在 apply 早期(第 260 行)、行注册在尾部(第 426 行)——**一次 throw 直接吞掉后面的行注册**,"启动失败"与"控制模块消失"是同一根因的两个症状。 ### 修复:guarded 阶段 + 幂等注册 - `apply` 内每个阶段 `guarded(label, run)`:失败只记录,不外抛。 - 品牌三座位 + 两处行注册:已被占用 → 告警并跳过(返回空 disposer),第二次 apply 无害。 - `push`/`sync` 改为函数声明(提升),订阅回调内再包 try/catch,消除早触发 TDZ。 实测(真实 SlotCore):单次 0 错误;双次 0 错误,行/座位各保留 1 份。 ### 沉淀 - **第三方插件对宿主的契约是"永不抛"**:一个外观插件没有资格让整个客户端启动失败。启动审计是零容忍的。 - **桩服务会隐藏真实校验**:本次若不是把真实 `SlotCore` 接进来,永远只能猜。最贵的服务最该用真的。 - 幂等注册顺带覆盖了真实场景:HMR 热重载、组合重载、重复装载。 --- ## 2026-10-28 · v0.6.2:内联样式遮蔽——自定义主题「全黑没生效」的真正根因(用户报"海之蓝没有一点蓝") ### 踩坑:三层"看起来都对",级联层输了 用户报应用自定义主题后 UI 全黑无主题色,且添加/删除/保存均无即时反馈。诊断走了大弯路: 1. **怀疑响应链**。通读 cordis `effect`(vendor 源码确认 "execute runs immediately")、ui-renderer 的 `standardKit/storeOf/observableHook`、store 引擎(sync flush、revision 单调)——静态层全对。 2. **headless 实证**。esbuild 打包 vendor cordis + dsh-client-store 源码 + 插件 themes-store,复刻 `ctx.effect(() => themesStore.subscribe(...))` 接线:监听触发、store 通知、主题数组全部到位——链路正常。 3. **jsdom 实车**。用 `new Function` 加载**真实 dist/client.js**(模块加载器 shim + React 18.3.1 + store 源码别名打包),伪造 slots/locale/theme 服务,走完整用户流(填名称、选色、点添加、点删除):**sheet 更新 ✓、store 通知 ✓、网格与列表在下一帧 flush 后正确出现新主题 ✓**。响应链在真实 bundle + 真实 React 下也没有问题。 ### 真根因:rc.3 ThemePresenter 的内联 token 读 `git show dsh-v0.2.1-rc.3:packages/client/ui-layout/src/client/theme-presenter.ts`:核心把**活动主题的全部 token 以 `body.style.setProperty` 写成内联样式**。CSS 级联里内联样式压过一切普通类选择器——`body.theme-custom-x { --vars }` 永远输给上一主题的内联 token。于是应用自定义主题后外观纹丝不动(用户停在旧主题的暗色观感上,"没有一点蓝,都是黑色的");添加/删除/保存的"无反馈"本质是**外观从不变化,反馈无从观察**。 **修复**:`customThemeCss` 每条声明加 `!important`(author-important 是唯一高于内联的层叠来源)。类挂上即赢、类移除即还(核心主题的内联 token 原样保留),不需要清写 presenter 的 DOM。 ### 方法论沉淀 - **工作树 ≠ 运行版本**:harness 检出在 alpha.1 分支,app 跑 rc.3——框架层源码必须 `git show dsh-v0.2.1-rc.3:` 核对(本次两者恰好一致,但不能靠运气)。 - **"响应式坏了"先验证级联**:样式不生效时,先查最终计算值的输赢(谁的选择器/层叠来源在起作用),再查状态链。这次三层状态链全对,输的是 CSS。 - **jsdom 实车可复刻 Electron 插件环境**:真实 bundle + 模块表 shim + 真实 React + 真实 store,比 mock 组件测试多抓住了整个接线层;受控输入要用 `el.value=` 直写(React value tracker 拦截 prototype setter 路径)。 --- ## 2026-10-28 · v0.6.1:派生引擎 v3——预设语法五声部(用户问"三个颜色够吗") ### 方法:先把 19 套预设拆成"独立设计决策",再决定暴露几个输入 用户问三输入是否太少。正确顺序不是直接加输入框,而是先回答"**做一套和预设同水准的主题,到底需要几个独立决策**"。逐套对读 7 套个性迥异的主题(academic/typewriter/vaporwave/morandi/matrix/minimal/palace),答案收敛到 **5 个**:品牌、墨色(第二色相)、明暗底色相分族、描边声部、语义融合。最有价值的发现是**墨色是独立于品牌的第二色相**(学术紫墨 306° vs 品牌蓝 263°;vaporwave 紫墨 vs 粉品牌),以及**亮底跟品牌、暗底跟墨**(vaporwave 亮粉暗紫)——这两条此前完全没进派生模型。 ### 两个实现层教训 - **OKLCH 色相环 ≠ HSL 色相环**:第一版墨色分支按 HSL 直觉写(粉≈330°),实测 vaporwave 粉在 OKLCH 里是 **345°**,分支根本没触发。修正前先跑测量脚本(#ff6ec7→345、#b5372a→30、#008a2e→147),并发现**学术墨 = 学术蓝 + 43°** 这个可写死的精确漂移量。 - **语义融合的守门条件是"饱和度+色距"双门槛**:单看色距会误伤(typewriter 暗金 C 只有 0.104,色相 84° 离红锚 25° 有 69°,但单色距规则在别的暖色上仍可能误融合)——加 C>0.14 饱和度门槛后,palace/matrix/vaporwave 正确融合、typewriter 正确豁免,无一行特判。 产品形态上取的平衡:基础流程仍是**一键**(只选主题色,墨色自动 = 蓝品牌 +43° 漂紫),预设手工调的另外三个自由度进「高级调色」折叠区(墨色+自动复位、底色浓度三档、描边声部两档),第五个(语义融合)做成自动规则不设输入。 --- ## 2026-10-28 · v0.6.0:Theme Studio 自定义主题 + mock 卡片预览 + 排版跟字号 ### 字号轴:核心自己的设置行也是固定 px,插件跟"delta"而不是跟"设置值" 用户反馈诊断文字偏大,怀疑插件没读字号设置。查实:核心 0.2.1 的字号轴写 `--dsh-content-font-size`(10–22px 默认 14)到 body 内联,`gradient-shadow-text.css` 派生 `--dsh-content-font-delta`(设置−14)推全 UI 阶梯;但 **ui-settings-general 自己的模块 CSS 也是写死 14/12/13px**——设置面板的 chrome 本来就不跟轴,跟轴的是会话内容/气泡/Markdown。插件行此前沿用固定 px,用户调小字号后自然显得偏大。修法取中:行内排版全部改 `calc(原px + var(--dsh-content-font-delta, 0px))`——默认 14 时 delta=0 逐像素等于旧值(零回归),调小时整行同步缩,0px 回退保老核心。教训:**"跟随设置"跟的是派生 delta,不是设置值本身**;带 fallback 的 calc 让新旧行为在默认点精确重合。 ### 派生引擎 v2:RGB 混色换 OKLCH 按角色推导(用户实测反馈"不好看") 0.6.0 首发的 mix 引擎被用户否了:RGB 线性插值两个致命伤——(1) 饱和色与白/黑的中点发灰发闷(红 accent 的次级文字混出脏粉);(2) "多浅"和"多艳"耦合在一个插值系数里,无法表达"表面要淡但文字要沉"。重写为 **OKLCH(Ottosson 参考矩阵,sRGB↔OKLab 手写往返 ~60 行)按角色配方推导**:表面层只承 accent 色相、chroma 钳到 ≤0.035(永不出腻粉底);文字亮度阶梯(0.26→0.76)与 accent 鲜艳度**解耦**(textC = clamp(C×0.55, 0.02, 0.13),霓虹 accent 也出沉静次级文字);品牌色明度钳制(亮 0.40–0.62 / 暗 0.62–0.85)让粉黄霓虹自动压深、深海军自动提亮;灰 accent(C<0.02)色相不可靠,回落 265° 中性蓝。验证方式:把 academic 的 #002fa7 喂进引擎,esbuild 临时打包跑 node 对表——暗色品牌 #487ff4 / 次级文字 #5e7fc0 / layer-2 #eff4ff,与手调的 #5a7ce3 / #5a7ce3 / #edf2ff 同族。教训:**配色派生的正解不是"更像目标色的插值",而是把设计师的角色分工(中性表面 / 解耦文字 / 唯一饱和的品牌声部)编码成感知空间里的钳制区间**。 ### 自定义主题:别发明派生配方,反推内置主题的配色关系 Theme Studio 的核心风险是"自动生成的配色难看"。做法是把 `academic` 亮/暗两块的 62 变量当成**关系式**而非数值表来反推:暗色品牌 = accent + 42% 白(#002fa7 → #5a7ce3 逐位一致,验证通过);亮色层阶梯 = 底色向 accent 7%/13% 混入;边框 = accent 的 .10–.28 alpha 阶;滚动条 = 20%–42% 混入。全部用 JS 小型 mix 函数在生成时算成**字面色值**(不用 CSS color-mix 输出),与内置 sheet 同构产出 `body.theme-custom-*` 两块——brand-overrides 的别名级联自动补全其余令牌,这是"同构复用"而非"另起炉灶"。暗色语义色(error/success/warn)保持内置的固定红绿琥珀,不从主题色派生——语义色不该随品牌漂移。 ### 两个工程小点 - `themes-store` 的 `all()` 必须返回**缓存快照**(set 时重建)——`useSyncExternalStore` 的 getSnapshot 若每次返回新数组会无限重渲染。 - HeaderControls 里那份手抄的 19 主题列表是 0.4.x 时代的重复源,0.6.0 借 themesStore 一并消灭;`/theme` 命令的 options 是"每次打开现答",自定义主题无需重注册命令。 --- ## 2026-10-28 · v0.5.5:`/theme` 斜杠命令 + 运行时自检(两个纯官方面的新功能) ### cordis 的 `ctx.inject(['service'], cb)` 是可选服务的安全挂载点 给插件接 `/theme` 命令前最大的顾虑:老核心/异构核心没有 `commandUi` 服务时,注入声明会不会拖垮整个插件激活(引导是全有或全无)。读了 `vendor/cordis/src/fiber.ts` 才放心:`ctx.inject(deps, cb)` 会 spawn 一个**独立的子 fiber**,状态机里有专门的 `PENDING — waiting for required services` 态——必需服务永不出现,子 fiber 就永远 PENDING、回调永不执行,**外层插件照常 ACTIVE**。结论:想让插件吃"可能不存在的核心服务",一律走 `ctx.inject` 子 fiber,绝不要把服务名加进插件顶层的 `inject = [...]`(那里缺失才会挂)。虽然核实过 ui-commands 从 `v0.1.2-alpha.1`(最早支持代)就存在,防御仍是免费的。 ### CommandContribution 的文案是"每次开菜单现问"的 `label()` / `description()` / `searchLabels()` 都是回调、每次候选合成时现调——所以文案必须从绑定的 translate 闭包现读,不能在注册时捕获字符串,否则换语言要等重注册。`options(session, signal)` 同理,每次打开现答,插件借此返回实时主题状态(`active` 行即弹层高亮落点)。 ### Doctor 的令牌覆盖扫描:把审计做成面板 COMPAT-0.2.1-rc.3.md 那次 381 个 `var()` 引用求差集是纯手工;0.5.5 把它变成 `document.styleSheets` 上的在线扫描:插件自己的 `