# 更新日志

本文件记录 `dsh-custom-css` 的用户可见变更。版本号跟随 `package.json`。

## 0.13.0 — 预览块 v4 重写、拾取后面板打不开的修复、预览画布、元素诊断、问题检测页、查找跳转修复

### 修复：拾取元素之后，规则面板不再自动出现

现象：用「拾取元素」在界面上点一个元素、按「插入规则」，规则确实写进了表里，但规则面板没有打开 ——
如果点的是设置对话框**外面**的元素（对话框会因此关掉），那就连设置都不会再回来，看起来像什么都没发生。

根因不在拾取：拾取把规则写好之后会调 `openSettingsSurface()` 把设置重新拉起来，而它只找一枚
**名字叫「设置」**的按钮，点一下就当成功了、从不回头看。这一代 DSH 的侧栏设置入口在一层
`data-slot="sidebar.settings"` 的包装里（盒子 0×0、文字写着「更多」），那颗按钮根本不存在，
于是设置没被重新打开，行没挂回来，面板自然也不会出现。

现在按用户能走的路径依次尝试，并且**每一条都回头看结果**（轮询「行是否回来了」，最多 500–900ms）：

1. `data-slot="sidebar.settings"` / `settings.launcher` 里的可点元素（可能先弹一个「更多」菜单，
   再点菜单里的「设置」）；
2. 真的叫「设置 / Settings / 偏好 / Preferences」的控件；
3. 菜单里写着的快捷键 **Ctrl + Alt + ,**（实测合成 `keydown` 就能开；没有 `KeyboardEvent` 的组合直接放弃，不抛）。

回归测试：`tests/loader-smoke.cjs` 新增三条（slot 入口 / 名字入口 / 一条都没有时返回 `false` 不抛），
并做过反向验证（把 slot 那条去掉，测试立刻红）。真机复现与验收见
[docs/preview-v4.md](./docs/preview-v4.md) 里记录的那条链路。

### 预览块 v4：组件化 + 控件全部来自组件库

这一版不是「再修一条」，是把**结构**换掉（设计见 [docs/preview-v4.md](./docs/preview-v4.md)）。

- **预览块现在是一个组件**（`PreviewBlock`）：视图 / 命中索引 / 状态芯片 / 背景 / 适应 / 全屏 /
  平移量都在它里面，行只交三样数据（哪条规则、现在的表、排除名单）。以前这 8 个 state、5 个 ref、
  10 个处理器住在行里，通过 30 多个 props 灌进面板 —— 每修一条就多一处耦合。
- **每个控件都来自 `ui.*`**：视图是 `ui.segments`（单屏/对比），芯片与全屏是
  `ui.button({ size:'sm', pressed })`，背景与「第几个命中」是 `ui.choice({ size:'sm' })`，
  归位是 `ui.button({ size:'sm', icon:true })`，提示是 `ui.status`/`ui.hint`。
  预览块里所有控件都是 28px 那一档 —— 上一版的 22 / 18 / 17px 三个自创高度没有了。
  `tests/audit-preview-kit.mjs` 守着这条（手里写 `createElement('button')` 就失败）。
- **行内与全屏是同一棵树**：`dialog` 里的画布与行内那块由同一个函数渲染，不再是两份 JSX。
- **状态芯片按选择器推导**：选择器里没有 `:hover` 就不提供「悬停」—— 上一版固定摆五枚，
  其中四枚点下去是空操作（改写后的选择器根本不存在）。`:focus-visible` / `:focus-within`
  现在也能正确强制（上一版改写成 `.dshCcStateFocus-visible`，永远不匹配）。
- **伪元素规则预览宿主元素**：`a::before` 以前整条掉进「命中 0 个」的合成样本分支。
- **拖动有边界**：夹取保证样本至少露 48px；「适应」改由 `ResizeObserver` 驱动，窗口/容器变宽
  变窄都会重算（以前只在开关切换时量一次）。
- **预览块固定在面板顶部，只有正文滚**：改一个值不用先滚回去看效果。面板上限 520、正文下限 84px
  （真机量的：1440×900 下量到过正文被压在 42px 且裁在可视区外）。
- **全屏焦点**：打开后焦点落在关闭钮，关掉后还给「全屏」按钮。
- 去掉：「列表」按钮与卡片导航（命中切换改成工具条上那枚下拉）、`.dshCc_previewPicks` 常驻行、
  预览块的 4 条类名覆盖规则。

- **预览块打磨**（四批一起收口）：
  - 头部按「管什么」分三组：`.dshCc_previewCanvasTools`（背景）→ 规则原文 → `.dshCc_previewTools`
    （全屏/对比/列表，左侧竖线分隔）。原来四颗挤成一排，最左那颗下拉看起来和右边三颗是一类。
  - 画布可聚焦：`tabIndex=0` + `role=group`，方向键 8px（Shift 32px）、`Home`/`0` 归位；
    归位按钮**没拖过时禁用**；换规则自动归位（拖动之外的第二条路，也是键盘用户唯一的一条）。
  - 常驻只有一排（21px）：状态芯片 + 「演示中」+ **命中游标** `‹ 2 / 5 ›`；卡片导航回到
    「点开才展开」的语义。原来三排把本来不高的画布一路往下推。
  - 提示区收成一块：`data-tone="warn"` 只留给会坏的诊断，`data-tone="info"` 是解释 + 出路；
    **行内与全屏共用同一份**（全屏看不到诊断＝把秘密留在另一个视图里）。
  - **全屏左右分开拖**：平移量从「一个」改成「每个窗格一个」（`data-pane`），拖左边只动左边、
    拖右边只动右边；按在两块之间的空白处不拖。归位仍然一次清掉所有窗格。

- **预览画布**：样本现在坐在一块可拖动的画布里（拖动的对象是窗格，见上），右下角「归位」把偏移清零
  （双击画布同效）；工具栏最左边多了一个背景下拉 —— **页面（默认）** / 棋盘 / 方格 / 斜纹 / 点阵 /
  渐变 / 纯色，选择记在 `localStorage`，不进设置记录。
  画布是三层：页面底（总是有，`bg-layer-1`）→ 图案墨（判读用的几档，`--dshCc-canvas-ink`）→ 样本。
  **窗格就是画布**：`.dshCc_previewBox` 在画布里让开底色，图案（或默认的页面色）直接铺进窗格 ——
  面板上那块就是画布的可见部分，不是一张自带底色的卡片（这是「修改后的卡片不应该是画布吗」那条）。
- **组件不再被压缩**：样本层的宽度按元素在页面上的**实测宽度**写死，窗格 `overflow:hidden` 只负责
  裁掉超出的部分，拖动把看不见的地方挪进视野。以前窗格是 `overflow:auto` + `max-width:100%`，
  一个 774px 的卡片被塞进 476px 的盒子里压成窄条 —— 看到的是缩略图，不是画布。
  拖动也随之改成移动**样本层**：窗格是固定的窗口，它的描边与「当前/修改后」标签不再跟着飘。
- **「适应」与选中**：组件按真实尺寸排版，比窗口宽就必然被裁 —— 工具栏最右边多了一颗「适应」，
  `scale(k)` 只缩显示、不改尺寸（再点回到 100%）；同一颗按钮在拖到超宽组件时等于「看全」的开关。
  画布加 `user-select:none`，拖组件不会顺手选中里面的文字。
- **预览块 v3**：默认**居中**（`.dshCc_previewViewport` 用 flex 摆位，比窗口大的样本向两边对称溢出）；
  拖动**两块一起走**（一份平移量写进每个 `[data-pane]`，「拖动一个另一个也移动」，左右才对得上）；
  `page` 档的底色改成「组件在页面上的那层底」——沿祖先链找第一个不透明色（`--dshCc-page-backdrop`），
  修掉「明明在对话里有背景、预览里没有」（组件自己的 `background` 常常是 `transparent`）。
- **毛玻璃不再是特例**：底板让开之后，`blur()` 采样的就是窗格背后那块图案。默认「页面」档没有图案，
  所以给一行提示 + 一个「换成棋盘看」的按钮，而不是让人以为规则没生效。
- **问题检测页**（原「选择器体检」）：元素诊断的同一套判据现在也跑在**整张表**上 —— 每条规则取前 3 个
  命中元素诊断，有问题的规则单列一节（徽标写「⚠ N 处」，点一行跳到那条规则）；「命不中的规则」那一节
  保持原样（点一行整条注释掉）。
- **动效与性能**一节：只看文本的三条判据 —— `transition: all`、`@keyframes` 在动布局/重绘属性
  （`width` / `height` / `top` / `left` / `margin` / `padding` / `border-radius` / `box-shadow` 等）、
  `blur(≥20px)` 的模糊半径。这类的共同点是「生效但贵」，所以单列一节、只跳不改。
- **元素诊断**：预览着的元素跑三个检查 —— 毛玻璃多层叠加、`opacity < 1` 把 `backdrop-filter` 废掉、
  `position: fixed` 被带 `transform` 的祖先捕获（上一轮「菜单点不了」的根因）。命中时预览块多一行提示。
- **查找：跳转不再抢焦点**：按 Enter 跳命中时不再把焦点从输入框抢走；命中不变时靠 `searchTick` 也重新
  落光标；编辑器里的 Esc 直接关掉查找条。

## 0.12.0 — 全屏视图重新设计、菜单进 top layer、探针 `--metrics`

这一段（0.11.1 → 0.12.0）里只有一条是用户报的问题（大纲点不了），其余是把 0.8 之后加的界面
（预览块、状态芯片、全屏视图、令牌页、排除名单）与既有界面统一，并补上量测工具。

### 探针加 `--metrics`；aurora-nexus 主题的性能优化

**探针 `--metrics <ms>`**：采样一段时间内的 `TaskDuration` / `LayoutDuration` / `RecalcStyleDuration`
与计数，用来 A/B 样式表的开销。样式优化只能这样量 —— 截图看不出「每帧多花了多少」。

用它量了 `~/.dsh/custom-css/aurora-nexus.css`（用户主题）静置 4 秒的开销：

| 状态 | TaskDuration | RecalcStyleCount |
| --- | --- | --- |
| 空样式表（只跑 DSH） | 0.298s | 21 |
| 原主题 | 0.974s | 703 |
| 优化后 | **0.420s** | **56** |

三处改动（都在主题里，不是插件）：

1. 画布渐变从 `body` 挪到固定伪元素 `body::after`，漂移动画从 `background-position` 改成
   `transform`。原来的写法每帧重绘整页 —— 这也是 703 次样式重算的大头。
2. `[data-composer-card]::before` 的描边光晕动画原本 `:focus-within` 就在跑，而输入框常年有焦点；
   改成只在 `:hover` 时跑（静态渐变仍在）。自定义属性动画不合成，每帧重算整棵子树。
3. 玻璃效果收敛：`[role='menu']` / `[role='tooltip']` 不再 `backdrop-filter`（两者底色本来就 ~90%
   不透明），对话框与输入卡从 24px 降到 14px。

验证：105 个选择器的命中数**逐一相同**（0 处差异）；`body::after` 的渐变、`will-change`、
对话框的 blur 值都在真机上量过；原文件备份在 `~/.dsh/custom-css/aurora-nexus.css.backup`。

### 下拉菜单进 top layer（修「大纲点不了」）

用户反馈：`aurora-nexus.css` 的**大纲点不了**。

真机量到的原因不是大纲本身：设置对话框的卡片带 `transform`（进场动画），菜单是 `position:fixed`，
于是它变成「相对那个祖先定位」，再被编辑器外壳的 `overflow:hidden` 整块裁掉 ——
菜单 rect 364–624、外壳 720–952，`aria-expanded` 是 true、89 行都在 DOM 里，屏幕上一个像素都没有。

- `ui.choice` 的菜单改成 **`popover="manual"` + `showPopover()`**：进 top layer，不受祖先 transform
  与 overflow 影响。老浏览器没有这个方法时静默跳过（菜单行为不变）。
- `[popover]` 的 UA 默认样式（`inset:0`/`margin:auto`/边框）在菜单规则里清掉，否则盒子会被拉伸。
- 关闭逻辑不变（`manual` 不 light-dismiss）：点外面、Esc、选中一行都由组件自己做。

测试：新增一节直接渲染 `Choice`（原先的测试走 `Dropdown.props.onPick`，把组件跳过了，所以这类
「画不出来」一次都没被抓住）——断言菜单带 `popover="manual"`、假 DOM 没有 `showPopover` 也不崩、
点一行把值交给 `onPick`。

真机（3081，`aurora-nexus.css`，1440×1000）：点「大纲」→ 菜单可见、`popover-open`、
rect 887,464 320×260；点第三行 → 光标到 14091、面板打开且名字是 `body`。

### 全屏视图：顶栏补齐上下文 + 窄窗口堆叠

继续 `/ux-polish` 全屏视图（只推 git，不发版）：

- **顶栏**：选择器 + `N 个命中 · 第 i 个`（行内面板有、全屏原先没有）+ `‹ ›` 切换命中元素
  （全屏里没有卡片导航，这是唯一入口）+ `Esc 退出` + `×`。
  切换钮与 `×` 都是 **28px 图标按钮**（同一规格），不是 21px 小胶囊 —— 一行混两种控件高度像两条工具栏。
- **窄窗口（≤900px）** 两张视口上下堆叠，内边距收到 14、`Esc 退出` 让位。
  这条媒体查询必须排在基础规则**之后**：媒体查询不加特异性，写在前面会被后面的
  `flex-wrap:nowrap` 覆盖 —— 实测 760px 下仍是并排（两块视口 x=34/384、y 相同）。
- 全屏两块视口的 `当前` / `修改后` 标签补上 `dshCc_previewBoxCurrent/Modified` 修饰类，
  与行内面板的增删点缀底色一致（之前只有行内那两块有）。
- `previewMatchCount` 从行传到面板：`previewMatches` 是**卡片列表**（最多 6 条），不是数量。

测试：新增「全屏头」一节（命中数 + 序号 + Esc 提示 + 三个命中时出现 prev/next、点 › 走到第 2 个）。
真机（3081）：顶栏读数 `80 个命中 · 第 1 个`、`‹ ›` 28×28、`Esc 退出`；注入表里
`@media (max-width:900px)` 的下标（26181）确实在基础规则（13915）之后。

### 全屏界面的设计：更大的窗口 + 毛玻璃背景

`/ux-polish` 一遍全屏视图（只推 git，不发版）：

- **窗口**：`min(1280px,96vw) × min(94vh,960px)`（原 920×760），内边距 20、间距 16；舞台至少 260px 并吃掉
  多出来的高度，正文留在下半屏（最多 42%）自己滚。
- **背景改毛玻璃**：`::backdrop` 上 `backdrop-filter: blur(18px) saturate(1.15)`，底色用
  `--dsw-alias-bg-overlay` 混 55% 透明。**这里的坑是量出来的**：那个令牌本身是**不透明**的 `#e9ecf2`，
  直接铺上去把页面盖死、模糊了什么都看不见（第一版就是这样，截图里背景一片平灰）。
  `prefers-reduced-transparency:reduce` 时退回不透明遮罩并去掉滤镜。
- **进场**：200ms ease-out + 位移 12px；`prefers-reduced-motion` 时直接出现。
- **两块视口补描边**（`0.5px border-l2`，naming §G 里早就写了「视口描边」但一直没画）——
  没有框时，全屏的空白读起来像没做完。
- **短样本居中**：用 `margin:auto` 而不是 `justify-content:center` —— 后者在内容溢出时会让头几行滚不到。
- **探针加 `--eval`**：`::backdrop` 这种选不中的东西、以及令牌取值，只能靠页面内表达式量。
### 新功能的样式与既有界面统一

0.8 之后加的那几块（预览块、状态芯片、合成样本、全屏视图、令牌页、排除名单）当初是各写各的，
字号、内边距、表面圆角与既有界面不是一套。这次按 `docs/design-tokens.md` 与 `docs/naming.md §G`
把它们对齐（**只推 git，不发版**）：

| 项 | 之前 | 现在（与既有界面对齐） |
| --- | --- | --- |
| 字号 | 手写 `11px/16px`、`11px/17px` | 字体阶梯令牌：说明/提示 `--dsw-font-xxs-12`（12/18），标签与令牌名 `--dsw-font-xxxs-11`（11/14） |
| 预览块表面 | `radius-sm(8)` + `padding 8/gap 6` | 与其它块一致：`--dsw-radius-md(12)` + `padding 10` / `gap 8`（与 `.dshCc_panel` 同一节奏） |
| 视口 | `radius-xs(4)` + `padding 6` | `--dsw-radius-sm(8)` + `padding 8`（内层比外层小一档） |
| 全屏卡片 | 模块底色 + 通用描边 | `--dsw-alias-settings-card-fill` / `-stroke` + `--dsw-elevation-prominent`（§G 指定的设置卡片面） |
| 预览里的按下态 | 共用「强调显示」的**警告色** | 中性 `--dsw-alias-interactive-bg-active`（对比/芯片/卡片是「选中」，不是「实况高亮」） |
| 对比两块的标签 | 只有 caption 灰 | 用**增删点缀色作底色**的小标签（左「当前」红、右「修改后」绿） |
| 令牌页 | `font-size:11px` 手写 | 与面板其它行列同一套阶梯令牌 |
| 令牌页搜索框 | 组件库默认字段（36px、自己的边框与底色） | 与查找栏同规格：**28px**、透明底、`--dsw-font-xxs-12`、`radius-sm`；`flex:none`（量到过：组件库字段的 `flex:1` 让它在列里被压成 18px） |
| 令牌覆盖值输入框 | 组件库默认字段（36px） | 紧凑行里 24px、`radius-xs`、`--dsw-font-xxxs-11` + 等宽字体 |

真机上量到的两件事（都不是「看起来差不多」）：

1. **增删点缀色是底色，不是文字色**：`--dsw-alias-code-diff-added/-deleted` 的 alpha 只有 **0.08**
   —— 当文字色用时，实测标签颜色是 `rgba(236,19,19,0.08)`，等于隐身。现在它们只做小标签的底色。
2. **探针现在也报 `color` / `fontSize` / `lineHeight`**：样式统一这种事只能量，不能看 —— 上一条就是
   量出来的。

`tests/visual-probe.mjs` 加了这三个字段；`npm run audit`（圆角/字体/皮肤）与全部单测照跑通过。

## 0.11.1 — 排除名单只看元素自己（修 0.11.0 的回归）

0.11.0 的名单**连祖先一起判**了，于是 `:root` 命中页面上每一个元素（每个节点都有 `html` 当祖先），
结果**所有**规则都退化成「合成样本（不是页面上的元素）」。这是我把范围做宽了：名单的用途是
「别碰整页」，不是「别碰整页里的东西」。

- `isExcludedNode` 现在只看元素**自己**（`matches`），不再 `closest()`。
- 后果：普通元素的规则照常克隆预览（真机验过 `button` 规则 79 个命中 → 真实样本）；
  只有 `:root` / `html` / `body` 这类**整页**元素才落到合成样本。
- 提示文案与文档同步改掉「含祖先」那句话。

回归测试：一个祖先在名单里的普通元素，面板**不能**出现排除提示、样本**必须**是真克隆。

## 0.11.0 — 排除名单：根级元素不预览、不拾取

一个可改的**排除名单**，默认 `:root, html, body`。

- **为什么默认是它们**：写一条作用在 `body` 上的规则等于给整个界面换皮肤 —— 那是「皮肤」的事，
  不是「一条规则」的事；而把整页克隆进预览框既没意义，又会把页面自己的样式带进来。
- **预览**：命中的元素不当样本，改用**合成样本**，并在面板里写明「这个元素在排除名单里（…）」。
  命中数照常显示（页面上的匹配是事实，不能因为不预览就假装它没命中）。
- **拾取器**：爬到名单里的元素时，读数说明原因、**不给候选** —— 静默不响应会让人以为拾取器坏了。
- **可改**：插件详情页 → 壁纸设置 → **排除名单**（逗号分隔）。留空就是什么都不排除。
  名单逐条求值：一条写坏的选择器只会跳过它自己，不会让预览或拾取失效。

顺手修掉一个会让设置**根本存不下来**的问题：`normalizeSettings` 是白名单，加进设置记录的键
如果不在里面，写盘时会被丢掉 —— 实测「填了名单、写盘、读回来没有」。现在 `exclude` 在白名单里。

测试：新增一节 —— 默认名单生效（`body` 规则 → 合成样本 + 说明）、设置里那一行默认值正确、
改完写回 `settings.json.css`；另外修了两处测试床的形状问题（假节点没有父级会被当成 `:root`；
独立渲染一个 section 之前要清空 hook 槽，否则会借到别人的状态）。

## 0.10.1 — 预览不再命中自己；样本放不下的问题

截图里那两条，一起修：

- **规则会命中预览框内部的内容**。预览的样本是页面元素的**克隆**，用户写 `[class*='_tableRow']`
  这类宽选择器时，克隆出来的那几行也被命中 —— 样本一画出来，计数就自己被自己喂大，看起来像「规则
  匹配了不存在的东西」。两块样本容器现在带 `data-dshcc-owned`：`queryOwned` 会把它们**和里面所有
  克隆**从任何匹配 / 计数里剔掉（这和插件自己的高亮框、拾取框用的是同一条规则）。
  真机验证：`[class*="dshCc_previewAfter"]` 这条规则现在显示 **无命中**（以前它命中自己的预览容器）。

- **表格行克隆进预览是一片的空白**：`<tr>` / `<td>` / `<th>` 直接塞进 `div` 没有布局（浏览器是靠
  解析器补 `table > tbody` 包装的）。样本现在自己补这层包装，表格行才真的能看见。

- **空间不够**：样本容器 160px → **260px**（自己滚），整块面板 260px → **340px**。原来是「声明区
  把预览挤成一条缝」，现在预览能看清一个卡片、一行表格的高度。

测试：新增一节 —— 样本容器必须带 `data-dshcc-owned`（否则宽选择器会自喂），以及 `tr` 目标必须被
包进 `table` 才能布局。

## 0.10.0 — 预览块做完：状态芯片 / 合成样本 / 全屏视图

`docs/preview.md` 里剩下的四件一次做完 —— 预览块的设计现在**全部实现**。

- **状态芯片**：悬停 / 聚焦 / 占位符 / `::before` / `::after` 五个芯片，只在单屏模式出现。
  - 前三个**动真页面**：把规则选择器里的 `:hover` 改写成 `.dshCcStateHover`（`:focus`、`:placeholder-shown`
    同理），连同规则正文注入一张临时表（`#dsh-custom-css-preview-force`，追加到 head 末尾），
    再把那个类加到**正在预览的元素**上 —— 于是真页面进入该状态，样本读到的是**被强制之后**的计算样式。
    旁边挂一个「演示中」标记，因为页面真的变了。
  - 后两个**不动页面**：伪元素不是节点、克隆带不走它，所以读它的计算样式 + 把 `content` 当文本，
    造一个 `span.dshCc_samplePseudo` 合成进样本；`content: url(...)` 这类不是文本时不合成。
  - 关芯片 / 换规则 / 关面板 / 切对比或全屏 / 行卸载都会 `clearPreviewForcing()`（删表 + 摘类）。
  - **一条必要的修正**：`button:hover` 在静止页面上一个元素都匹配不到，而预览正是要看它的效果 ——
    所以找宿主元素时用**去掉状态伪类的选择器**（`button:hover` → `button`），否则这类规则会掉进
    「命中 0 个」的合成样本。

- **合成样本**：规则命中 0 个时，用这条规则**自己的声明**拼一个方框（宽高取规则里的，没有给 240×120），
  里面写着「合成样本（不是页面上的元素）」。标注是硬要求 —— 否则用户会去页面上找那个并不存在的东西。

- **全屏视图**：右上角第一个按钮现在真的能开。用的是**原生 `<dialog>` + `showModal()`**：
  浮层会被任何带 `transform` 的祖先变成「相对它定位」，设置对话框就是一例，那样「全屏」只有对话框
  那么大；`showModal()` 进 top layer，不受影响（实测 920×760）。里面是**左「当前」/ 右「修改后」**
  （强制并排）+ **同一份规则面板正文**——面板正文抽成了 `renderBody()`，内联与全屏共用，不是副本。
  `Esc` / × 退出。

- **可见性**：打开规则时把面板滚进视野一次（`scrollIntoView({block:'nearest'})`）—— 预览块在声明区
  上方，长样式表里它常在折叠之下，之前得自己往下找。

真机验证（3081）：`button:hover` 规则点「悬停」→ 强制表内容 `button.dshCcStateHover {color: lime;}`，
目标元素真的带上了这个类；全屏 → `dialog.dshCc_full` 实测 920×760；`.nothing-here-at-all` 规则 →
240×120 的合成样本带「合成样本」字样。

测试：新增三节（芯片 / 合成样本 / 全屏），并把 `paintPreview` 第一次真正跑进单测（把行的 ref 换成
假面板，断言克隆带冻结样式、伪元素被合成）；英文巡检也扫了全屏视图里的文案。顺带修了一个测试基建
问题：假盒子把 `textContent` 当普通属性，赋值不清空 children，导致每次重画都叠一份克隆。

## 0.9.1 — 预览块那三个按钮要有名字；以及本地 `npm run audit` 现在和 CI 一致

- **无障碍名**：预览块的全屏 / 对比 / 列表三个按钮，文字来自 `t(...)`，而 CI 的名字巡检读不到
  `t(...)` 的结果，于是判定「名字只来自 `title`」。给三个按钮补上显式 `aria-label`，`title`
  继续承担那句更长的解释。
- **本地审计漏报的根因**：CI 跑的是 `audit-accessible-names.mjs --enforce` 与
  `audit-docs.mjs --enforce`，而 `npm run audit` 里这两个**没带 `--enforce`** —— 带 `--enforce`
  才是「有问题就退出 1」，不带只是打印。所以本地一路绿灯、CI 直接红。现在本地这两个也带
  `--enforce`，两边口径一致。

## 0.9.0 — 预览块：卡片导航

预览块右上角第三个按钮 **「列表」** 现在真的能用了：点开是一条**卡片导航**，一张卡对应一个
**命中元素**（写 `button · 2/5` 这样），点哪张，上面预览就切到哪个元素 —— 单屏与对比都跟着切。

- **卡里放标签，不放缩略**：一次渲染里再克隆 6 份、每份三百多条内联声明，代价比它带来的信息多；
  而「长什么样」上面那块大样本已经给了。所以卡片复用 `.dshCc_hlToggle`（选中态 `aria-pressed`），
  显示元素类型 + 序号。
- **一次最多 6 张**，多了一行内滚；命中 0 个时没有卡（预览块自己会说不命中）。
- 三个按钮**不是互斥的模式**：对比与卡片可以同时开 —— 上面一排卡选元素，下面左右两块比改动。

真机验证（3081）：`button { color: red }` 命中 75 个 → 点「列表」出现 6 张卡
（`button · 1/75` … `6/75`），卡片排高 46px，不挤占样本区。

## 0.8.0 — 预览块（第一步）：改完能看见，还能和打开时比

打开一条规则后，代码区与规则面板之间多出一块 **预览块**：右上角三个按钮
**全屏 / 对比 / 列表**（前两个之外的两个这一版只占位，禁用并在 tooltip 里说「下一步」），
下面是样本，底部一行小字写清它冻结了什么。

- **样本 = 克隆 + 冻结的计算样式**。把元素 `cloneNode(true)` 进来，把此刻 `getComputedStyle`
  的结果写成内联 —— 内联优先级最高，所以样本只受这条规则影响，不与页面里别的规则打架。
- **对比模式**：左边「当前」= **你打开这条规则那一刻**的样式表下的样子（临时把表换回去、读一次、
  再换回来；这一步与编辑器每次保存等价），右边「修改后」= 现在的样子。**左右只差你打开之后的改动**。
- **还没改过时**左块不摆空盒子，而是写「还没改动：改一个值，左右就会分开」。
- **底部口径说明**（一直在）：样本冻结的是此刻的计算样式 —— 这条规则的声明是真的，**改完之后的重排
  与悬停态不在其中**。预览的价值是「改动与效果的距离」，不是替代浏览器。
- 三个按钮**复用「强调显示」的胶囊按钮** `.dshCc_hlToggle`，不再为新功能发明按钮样式。

真机上修掉的一个布局问题：样本容器没有自己的高度上限，命中 `body` 这类整页元素时把预览块撑到
900px 高（看起来像布局坏了）。给两块样本容器补上 `max-height:160px` + 自己滚。

机制上有一个刻意的选择：**效果放在行里的 effect**，而不是面板里。面板是被**当普通函数调用**的
（`openRule === null ? null : RulePanel({…})`），在它里面用 hook 就是条件 hook —— 那正是
React 明令禁止、而这个文件里已经踩过一次的写法。

## 0.7.0 — 效果预设（亚克力 / 毛玻璃 / 云母 / 细描边卡片）

规则面板顶部新增 **效果预设** 一排：点一下，把一组声明写进**当前这条规则**。

- **写的是令牌**：`background-color: var(--dsh-material-mica-bg)`、
  `backdrop-filter: blur(var(--dsh-material-mica-blur)) saturate(…)` —— 于是「更多操作 → 令牌」里
  调一次材质，所有用了它的规则一起变。
- **一次编辑**：几条声明在内存里连起来写完，最后只写盘一次 → **一次 `Ctrl+Z` 整体撤回**（与面板里
  其它写入同一个口径）。
- **不写 `border` / `background` 简写**：简写会把 `border-radius`、`background-image` 一起重置
  （`examples/acrylic.css` 文件头原则 3，是踩出来的）。
- **先检查，再写入**，三种结论：命中了插件自己的界面 → 拒绝；规则一个元素都没命中 → 拒绝并说明；
  透明底 / 元素太小 → **照写**，但在下面留一行提醒（一个点了没反应的按钮比一个报错的按钮更糟）。

**组件样式统一**（这次一并做掉）：

| 新功能原来 | 现在 |
| --- | --- |
| 令牌页的搜索框自绘 `.dshCc_tokenSearch`（边框、底色、圆角全写一遍） | 走共享字段组件 `ui.field`（`.dshUi_field`），本地类只剩布局 |
| 「接管 / 撤销」自绘 `.dshCc_tokenBtn` | 复用「强调显示」那个胶囊按钮 `.dshCc_hlToggle`（撤销态带 `aria-pressed`） |
| 覆盖值输入框自绘 | 同样走 `ui.field` |
| 预设那排按钮（原设计为 `.dshCc_presets`） | **直接复用声明模板那排**：`.dshCc_panelSection` + `.dshCc_tplGrid` + `.dshCc_tpl`；说明那行复用 `.dshCc_versionsNote` |

结果：这一批新功能**没有新增一套按钮 / 字段样式**，删掉了两条自绘 CSS 规则，面板里同一套按钮
（4 预设 + 14 模板）共用同一个类。

顺带修了两个只有真机/测试床才暴露的问题：预设的「是不是插件自己的控件」判断写成
`target.closest?.(…) !== null`（`closest` 不存在时 `undefined !== null` 为真 → 永远拒绝）；
`getComputedStyle` / `getBoundingClientRect` 在拿不到时没有兜底（现在检查项退化成「判断不了」而不是抛错）。

真机验证（3081）：`.demo-bubble`（命中 0）→ 点亚克力给出「这条规则当前一个元素都没命中」；
`body`（命中）→ 点亚克力，六条声明一次写进去，全部引用材质令牌。

## 0.6.1 — 材质令牌：云母 / 亚克力 / 毛玻璃的参数

三套材质的参数作为**令牌**随插件发布，出现在「更多操作 → 令牌」最上面的一组
**材质参数（插件默认）**里，可以直接接管覆盖（0.6.0 那套机制）：

| 材质 | 令牌 | 默认值 |
| --- | --- | --- |
| 亚克力 | `--dsh-material-acrylic-{bg,blur,saturate,border}` | 72% / 24px / 150% / `border-l2` |
| 毛玻璃 | `--dsh-material-frost-{bg,blur,saturate}` | 58% / 36px / 120% |
| 云母 | `--dsh-material-mica-{bg,blur,saturate,grain}` | 86% / 18px / 115% / 0.035 |

- **为什么默认值必须由插件给**：预设（下一版）只写 `var(--dsh-material-mica-bg)` 这类引用，而 `var()`
  没有回退值时，令牌缺失会让**整条声明被丢弃**（不报错）—— 等用户先填，就等于预设点了没反应。
- **底色是 `light-dark()` 对**：一套值在深浅两种主题下都对，不用维护两份。
- **注入位置**：紧随插件自己的样式，在用户表之前 —— 你仍然可以用自己的表覆盖它。
- 令牌页把它们置顶成单独一组，免得在被 523 条 DSH 令牌淹没。

顺带修一处只有真机才看得出的布局问题：令牌列表在 flex 列里被压成 **6px 高**（`flex-shrink` 默认 1，
而它的容器有高度约束），看起来像「材质那一组没有内容」。加上 `flex:none` 后恢复成 260px 自己滚。

## 0.6.0 — 令牌管理（第二步）：接管

令牌页每行多了一个 **「接管」** 按钮：点一下，这条令牌的当前值就写进插件自己的 `tokens.css`，
并立刻生效；值就地可改（回车保存、Esc 取消），**「撤销」** 把这一条删掉、回到来源的值。

- **写的是普通的 CSS**，不是私有格式：
  ```css
  body,
  body[data-ds-dark-theme] {
    --card-border-stops: var(--dsw-gradient-onboarding-cyan-stops);
  }
  ```
  选择器必须是这一对：DSH 把令牌定义在 `body` 上，深色那套在 `body[data-ds-dark-theme]`
  （特异性 (0,1,1)）—— 只写 `body`（(0,0,1)）在浅色下赢、**深色下输**。
- **注入位置**：`tokens.css` 紧贴在你的 `custom.css` **之前**，在 DSH 与其它扩展**之后**。
  所以它压得过往它们，压不过你自己的样式表 —— 你的表永远有最终否决权。
- **只写改过的那几条**：文件里不会出现「全部 523 个令牌的副本」（那等于把 DSH 的令牌冻结在今天，
  下次换代不会跟着变）。
- **撤销最后一条 = 删掉文件**，不留一个空表；注入的那份 `<style>` 也清空（留着空标签，
  因为「摘节点」失败过一次：撤销看起来没生效）。
- `tokens.css` 不出现在文件选择器里 —— 它由令牌页维护，混进「样式表」列表会被当成一张可编辑的表。

两个真机上才暴露的问题，都修了：

1. **第一次写永远落不了盘**：写入前要先问「磁盘上那份被别处改过吗」，而这要文件先存在 ——
   第一次的回答是 404。现象是「注入成功了、文件没生成、行里报保存失败」。现在文件不存在时直接写。
2. **空标签 vs 摘节点**：撤销最后一条时如果依赖 `remove()` 把标签摘干净，一次摘不干净就会让撤销
   看起来没生效。改成「保留一个空标签、清空内容」：代价是零，行为确定。

## 0.5.0 — 令牌管理（第一步）：看清你在改哪个令牌

设置 → 通用 → 自定义 CSS →「更多操作」里多了一页 **令牌**。它把当前页面上的 `--*` 令牌列出来，
每条标三件事：**叫什么、现在是什么、从哪来**，外加**被多少条规则用到**。

- **为什么值得有这一页**：DSH 的背景几乎全是令牌驱动的（`examples/acrylic.css` 里量过：45 条设背景的
  规则里 44 条用 `var(--dsw-…)`，`--dsw-alias-bg-base` 一个令牌被 31 条规则使用）。所以改一个令牌 ≈
  改整片界面 —— 前提是知道该改哪一个。以前要知道，得开着控制台翻几百个令牌自己试。
- **实测**：开发实例上列出 **523 条令牌**（DSH 自己的 + 各扩展的 + 本插件的），带来源表名与用量。
- **读不到的会说出来**：跨域样式表 `cssRules` 会抛 `SecurityError`，跳过并计数显示在页头，
  而不是静默当作「那几张表里没有令牌」。
- **「被多少条规则用到」怎么算**：把所有可读样式表的 `cssText` 拼成一个大字符串，**一次**扫完所有
  `var(--name`。每个令牌扫一遍是 418 × 全表，卡主线程。
- **限高自己滚**：500 多条不会把整行撑到看不见编辑器（列表 260px 内滚）。
- 这一版是**只读**的。接管（写 `tokens.css`）、浅色/深色两栏、材质令牌（云母/亚克力/毛玻璃）
  是下一步，设计见 [令牌管理](./docs/token-manager.md)。

顺带：这一页也在「英文界面里不许出现汉字」那条检查里（切到英文再逐字找汉字）。

## 0.4.10 — 补 17 处漏翻，并加了一条「英文界面里不许出现汉字」的闸门

0.4.9 之后我加了一个自动检查：**把设置行能展开的每个面（行本体 / 更多操作菜单 / 历史版本页 /
选择器体检页 / 规则面板 / 查找栏）都用英文渲染一遍，逐字找汉字**。第一次跑就抓到 17 处漏翻 ——
全是声明模板的名字与提示（`横向排列`、`一行排开`、`细线 + 圆角`……）：翻译表里只收了属性字典和
模板的「名字」，把模板的「提示」漏了。

这批补上了，现在这条检查是绿的，并且**留在测试里**：

- 它同时守住两种漏法：界面词条 `t('key')` 漏键、内容对照 `content()` 漏条。
- 漏翻在中文界面下肉眼完全看不见，所以这条闸门比「再仔细检查一遍」可靠得多。
- 插件的两个详情页（壁纸面板 / 壁纸设置）不在这条检查里：它们要一份完整的壁纸记录才能渲染，
  那两节由视觉探针在真机上按英文采集。

界面的表现没有变化，只是英文界面里不再夹中文。

## 0.4.9 — 界面的最后一块也双语了：属性字典、校验提示、拾取器

0.4.7 / 0.4.8 把界面翻完了，这一版补上「内容」—— 也就是写 CSS 时真正在看的那些字：

| 面 | 内容 |
| --- | --- |
| 属性字典 | 112 个属性名 + 约 90 个下拉值名（`Text color`、`Border box`、`Space between`……）+ 10 个分组 |
| 声明模板 | 14 个模板名与提示（`Container` / `Column stack`、`Truncate` / `One-line ellipsis`……） |
| 格式校验与变量体检 | `Line 3: Invalid value for prefers: notacolor`、`Missing 1 closing brace(s)`、`Unclosed comment`、变量读不到 |
| 元素拾取器 | 面板文案、快捷键四条、层级滑块、五种候选类型与说明、命中数（`the only one` / `N match(es)` / `match count unknown`） |
| 图片上传与重命名页的其余提示 | 不是图片、超过 32 MB、宿主拒绝、上传失败 |

**两层词典，有意分开**：界面文案走 `t(key)`（注册进 DSH 的 locale 服务，将来加语言包也吃得到）；
属性字典这类「内容」走 `content('中文原文')` —— 一张中文优先的 CSS 教学表不需要 242 个 key，
它需要的是同一张表的两种语言。代价写在 [国际化](./docs/i18n.md)：这一层只服务英文。

**顺手修掉一个只有翻译之后才会暴露的 bug**：历史版本页原来的「成功 / 失败」配色是靠
`versionsNote.startsWith('已恢复')` 猜的 —— 译成英文后每条提示都会被当成失败。现在改用独立的
`versionsNoteBad` 标志。

唯一仍是中文的是 `window.dshCss` 的控制台帮助（开发者用，且 `docs/authoring-api.md` 有对应说明）。

真机验证（3081，profile 设 en）：打开一条规则后面板显示 `Text color` / `Border radius` /
`Top left…` / `Padding` / `＋ Add property…` / `Insert a template` / `Container`（截图见 temp/probe）。

## 0.4.8 — 壁纸面板也说英文了：界面全部双语

0.4.7 先把设置行那一块双语的，这一版补上插件详情页的两个节 —— 到现在**界面上的字全都是双语的**：

| 面 | 内容 |
| --- | --- |
| 壁纸面板 | 预览、图片 / 更换 / 移除、图片 URL、填充方式（四种）、界面浓度、明暗、用过的、文件夹、空状态、皮肤提示 |
| 壁纸设置节 | 轮换（五种间隔）/ 遮罩 / 模糊 / 角落时间 / 保存延迟（四档）/ 校验（三档）/ 行号 / Tab 宽度 / 控制台写盘，三组说明，备份与重置（含二次确认），关于 |
| 数据操作 | 导出 / 归档 / 重置的结果与失败提示（含「N 项被占用没能移动」这种带数字的） |

两个实现上的细节：

- **下拉选项的标签也走词典**：四张 `[值, 标签]` 表原来在模块顶端定义，那里还调不到 `t`，所以把标签改成
  键，在渲染时 `map(([value, key]) => [value, t(key)])` —— 表格本身仍然只认值（`pick()` 只比较值）。
- **几处多行拼出来的长句**换成一条带 `{占位符}` 的词条：`已归档 N 项到 X；有 M 项被占用…，页面即将刷新。`
  拆成 `data.archived` + `data.archivedSkipped` 两条，比在代码里拼字符串更好译。

**仍是中文的只有「内容」**：112 条属性字典、声明模板提示、格式校验与变量体检的提示（约 460 条）。
它们是教人写 CSS 的术语，译错比不译更糟，所以单独一轮做 —— 清单见 [国际化](./docs/i18n.md)。

真机验证（3081 开发实例，profile 的 `locale.preference` 设 en）：插件详情页渲染为
`Custom wallpaper` / `Change image` / `Cover · crop the overflow` / `Interface opacity` / `Brightness`，
壁纸设置节为 `Wallpaper settings` / `Rotation` / `Scrim` / `Blur` / `Corner clock` / `Save delay` /
`Validation` / `Line numbers` / `Tab width` / `Console writes`（截图见 temp/probe）。验完已改回 zh。

## 0.4.7 — 跟着 DSH 的语言走：设置行在英文界面里说英文

插件原来只有中文。DSH 自己在设置 → 通用里就能切中文 / English，插件却不动 —— 英文界面里它是一块
中文飞地。现在它把两份词典注册进 DSH 的 locale 服务，跟着当前语言走，**切换即时生效、不用刷新**。

**已经双语的部分**（界面）：

| 面 | 内容 |
| --- | --- |
| 设置行 | 标题、描述、拾取 / 查找 / 更多操作、状态行、占位符，以及全部 `aria-label` 与 tooltip |
| 更多操作菜单 | 九项动作的标签与说明、删除的二次确认、重命名的整页文案 |
| 历史版本 / 选择器体检 | 两页全部文案，含 `第 N 行`、`N 条 · 一次 Ctrl+Z 可撤回` 这类带数字的 |
| 查找 / 替换栏 | 按钮名、计数（`无命中` / `3 / 12`）、工具提示 |
| 规则面板 | 选择器字段、命中计数与三种说明、添加属性、插入模板 |
| 宿主错误与槽位告警 | 保存 / 读取 / 重命名 / 删除失败提示，以及控制台那条 warn |

**还没翻的部分**（内容，仍是中文）：壁纸面板的长句（~108 条）、112 条属性字典与模板提示（~230 条）、
格式校验提示（~230 条）。界面双语、教学文案中文，这是有意分两次做 —— 下一轮先做壁纸面板。
详见 [国际化](./docs/i18n.md)，那里也写了加一条文案的正确姿势。

三条设计约定：

- **没有 locale 服务的组合（含测试床）行为不变**：`t` 退回只查中文表，输出与加国际化之前逐字一致 ——
  所以这一版之前的所有断言一条都没改（只改了三条「按源码字符串断言」的，改成了断言 `t` 的键）。
- **服务是可选依赖**：`ctx.get('locale')` 优先，没有就 `ctx.inject(['locale'], …)` 等它激活；两条都没有
  就保持中文。**服务激活晚于插件挂载**这件事在 `clientModules.release` 上踩过一次，这里直接按那个教训写。
- **未登记的键显示中文原文，不显示 key**：翻译是增量的，漏一条不该让界面冒出 `status.saved`。

换字不需要重新挂载：`t` 在调用时才读当前语言，语言切换时把两个 store 各推进一步，挂在
`useSyncExternalStore` 上的组件重渲染一次即可。测试守住三件事：英文界面里不漏中文、两份词典键集完全
一致、切回中文同一份组件即可换字。

## 0.4.6 — 选择器体检：换代后「没生效的那几条」现在能逐条找出来

DSH 换代时构建哈希类名会变（`hHd-Xa_footArea` 这种）。靠它们写的规则不报错、也不消失，只是
**永远不再命中** —— 症状是「我明明写了却没生效」，而且没有任何提示。表头「更多操作 → 选择器体检」
现在把这件事变成一张清单：

- 列出当前页面上**匹配数为 0** 的规则，每条带行号与选择器原文（最多 40 条，其余条数写在末尾）；
- 点一行 → 把那条**整条注释掉**，原文留在 `/* … */` 里，随时改回来；
- 「全部注释掉」→ 一次改完，**一次 `Ctrl+Z` 可整体撤回**；
- 注释完列表自己缩短：每渲染一次都按当前文本重算。

**不报什么，比报什么更重要**：`:hover` / `:focus` 这类状态伪类、`.active` / `.selected` 这类状态类、
`::before` 这类伪元素、`@media` 里嵌套的规则，以及插件自己的控件，全部跳过。第一版把这些算成死规则，
一次真实运行报了 **49 条**、绝大多数是悬停样式 —— 那种列表只会教人忽略它。判据与
`window.dshCss.css.deadSelectors()` 是同一个函数，区别只是体检页还知道每条在源码里的位置。

一个容易踩的细节：规则块里如果本来就有注释（`color:red;/* keep */`），直接外包会让注释**提前结束**、
后半条规则继续生效 —— 比不注释更糟。所以内部的结束标记会先改写成 `* /`（它已经在注释里，少一个斜杠
没有语义）；内部的 `/*` 不处理，因为 CSS 注释不嵌套。这条有专门的用例守着。

### 工程侧（不进插件行为）

- **DSH 有新版就自动补一张视觉证据**：`compat-watch` 发现新版本（它本来就会提交 compat.json）后
  `gh workflow run visual.yml`。换代那一刻才是最容易出事的时刻，契约检查只能证明「槽位还在」，
  证明不了「界面还能用」；而每日那次会漂移好几个小时。
- loader-smoke 新增 `selector audit verified`；菜单顺序断言同步加入「选择器体检」（在「格式化」之后、
  「历史版本」之前 —— 顺着一张表被清理的顺序排）。

## 0.4.5 — 插件「安静消失」时，现在会留下一条能搜索的日志

**问题不是它坏了，而是它坏了没人知道。** 插件靠 `settings.general.item` 这个席位上桌；DSH 换代
把这个槽位改名之后，`slots.inject` 的回调永远不会被调用 —— 插件不报错，只是**什么都不出现**。
用户看到的是「设置里没有『自定义 CSS』这一行」，然后以为插件没装好；作者这边零信号。

现在启动后 **3 秒**还没拿到席位，控制台会 warn 一次：

```
[dsh-custom-css] 3 秒内没有拿到设置槽位 settings.general.item —— 「设置 → 通用」里很可能看不到「自定义 CSS」这一行。
原因通常是 DSH 换代改了槽位名，不是配置写错。
版本：插件 0.4.5 · DSH 0.2.1-rc.2（332895e）
已知问题与处置：https://github.com/FOX4096/dsh-custom-css/blob/main/docs/known-issues.md
```

- **3 秒是量出来的**：0.2.1-rc.2 上席位在页面加载后约 **470ms** 就到位了（探针实测），所以慢机器
  上也不会误报；席位一到，定时器立刻撤掉。
- **版本号那一行来之不易**：`/about` 现在返回 `dsh` 与 `dshCommit`（运行中的 DSH 发行版，来自
  `clientModules.release`）。这两个值必须**按请求现问**：第一版在 `apply` 里问一次，结果服务激活
  晚于插件挂载，拿到的是 `null` —— 在真机上实测才发现，单测（假 ctx 立即返回）根本抓不到。
- [已知坑](./docs/known-issues.md) 新增「设置里没有这一行」的排查顺序：先看控制台，再确认插件启用，
  再对兼容矩阵，报 issue 时带 warn 全文 + `/about` + 截图。

**探针的失败也分级了**（`.github/workflows/visual.yml` 用它做每日留证）：设置面板**根本没打开** →
退出码 **2（环境）**，多半是 DSH 又改了入口；面板开着但找不到本插件那行 → 退出码 **1（回归）**。
两种红以前在邮件里长得一样，处置却相反。分级是纯函数 `classifyRun`，由
`tests/probe-strict-smoke.mjs` 覆盖。

## 0.4.4 — 图片按钮补丁会自己退休；行组件再瘦两段

**1. 上游修好就停手（真问题）。** 从某个版本起，`dsh-smooth-stream` 会把消息里带嵌套内容的按钮压扁
—— 实测图片按钮只剩一条。扩展为此注入过一条补丁：

```css
[class*="_imageButton_"] { display: block; width: fit-content; max-width: 100% }
```

问题在于它是**无条件**注入的：上游 `0.8.0-rc.1` 已经修了（`text-box-trim` 只作用于叶子元素，
`:not(:has(img, video, canvas, iframe, object, embed, code))`），插件升级后补丁仍在，等于永远盖着
别人的样式。现在注入前先看证据：任一样式表里出现带 `:not(:has(img` 的规则即认定上游已修，不注入；
晚到的（样式表按需加载）在 1500ms / 4000ms 两个复查点发现后自行撤掉。

- 判断依据是**样式表里写了什么**，不是版本号：分不清「用户装了 rc」还是「用户改了自己的表」时，
  看规则本身最实在。
- 补丁仍然是独立的一张表（`data-plugin-css="dsh-custom-css/host-fix.css"`），撤掉时整张移除。
- 上游 issue：Laplace-bit/dsh-smooth-stream#45（另有 #34 下的实测评论）。

**2. 行组件再瘦两段（重构，无行为变化）。** 查找/替换栏 → `FindBar`（约 140 行 / 14 个 props），
单条规则的属性面板 → `RulePanel`（199 行 / 13 个 props）。`CustomCssRow` **2,340 → 1,908 行**。

两处都是整体搬移 + 函数内解构，JSX 里的标识符一个都不改 —— 名单是脚本按声明缩进扫出来的
（模块级的 `React` / `Dropdown` / `PropertyInput` 等不进 props，行内闭包才进），所以出错面是「一张
名单」而不是「几十处替换」。假 React 不展开函数组件，两处都用 `Xxx({…})` 直接调用，返回同一棵树，
40 项 loader-smoke 断言**一行都没改**仍然全过。

**3. CI 视觉回归。** 新增 `.github/workflows/visual.yml`：手动触发 + 每日 03:00 UTC，在 windows-latest
上装 DSH、起开发实例、打开设置页量几何并截图、上传 artifact。**目前只测量不判定** —— 断言等阈值
稳定后再加，否则等于用今天的排版约束明天的设计。

> 本机数据（不在包里，记在这里免得下次找不到原因）：`~/.dsh/custom-css/custom.css` 删掉了对
> `--dsw-alias-label-primary` 的 `:root` 覆盖（深色主题下黑底黑字的根因），哈希类名选择器改成
> `[class*="_footArea"]` 这类属性选择器，死规则清掉；备份 `custom.css.bak-20261019-001844`。

## 0.4.3 — 扩展自己的样式抽公共层：Base.module.css

插件的样式原来是一张 `ROW_CSS` 混合了控件（`dshUi_*`）与编辑器（`dshCc_*`）。现在拆成：

| 表 | 内容 | 何时注入 |
| --- | --- | --- |
| `shared-base.css` | `--dsl-g-*` 令牌 + 三个零特异性原语 | 启动最先 |
| **`Base.module.css`** | **控件与行布局（`dshUi_*`）** —— 两个界面共用 | 其次 |
| `CustomCssRow.module.css` | 只剩编辑器自己的 `dshCc_*` | 再次 |
| `Wallpaper.module.css` | 面板的 `dshWp_*` 与 `dshUi_*` 覆盖 | 最后 |

拆分在 **CSS 文本层面**做（按花括号深度切规则、按选择器前缀分组），并断言「拆分前后规则集合逐条相等」
—— 不是按源码行猜的（第一版按行切，丢了 3 个花括号，测试当场抓到）。每条规则内部的顺序保持不变，
因为组内顺序就是层叠顺序。

- 实测（开发实例）：六张表注入顺序为 shared-base → Base → Row → host-fix → Wallpaper → 用户壁纸；
  `.dshUi_button` 仍是 36px / 12px / 14px，`.dshUi_choice` 同规格，`.dsl-card` 拿到 12px 圆角。
- 测试：原先读「行表」的 17 处断言改为读「扩展自己的 CSS（Base + Row）」整体，并新增
  「Base 表恰好注入一次、且带 `.dshUi_button`」；每张表仍各自检查花括号平衡。
- 没有动 tav2，也没有改用户数据 `custom.css` 的结构。

## 0.4.2 — CSS 公共层：把跨扩展共用的令牌与原语抽成一份基座

本机自定义样式里，描边 / 卡片阴影 / 圆角在多个扩展的覆盖中反复出现。现在抽成随插件发布的
`shared/base.css`，启动时注入成独立样式表（`data-plugin-css="dsh-custom-css/shared-base.css"`），
**先于用户样式表** —— 用户规则天然赢过它，不需要 `!important`。

- 令牌：`--dsl-g-border-surface` / `--dsl-g-shadow-card` / `--dsl-g-radius-surface`，
  外加间距与字号步进；用户表里重复的那三个已删除（值不变）。
- 原语（`:where()`，特异性 0）：`.dsl-card` / `.dsl-surface`、`.dsl-ellipsis`、`.dsl-note`。
- **不碰 DSH 的 `--dsw-*` 令牌**：覆盖它们会连带打断深色主题（实测过黑底黑字）。
- 无构建：`shared/sync.mjs` 把 CSS 写进 `lib/client.js` 的标记块；`--check` 已接进
  `npm run audit`，漂移即失败。
- 测试：断言基座样式表在启动时装上、带令牌、且排在用户表之前。

修掉的两个自造问题（记在 `docs/shared-layer.md`）：块首标记漏 `*/` 把内容吞进注释；
同步不幂等（第二次跑多一行空白，`--check` 永远报漂移）。

## 0.4.1 — 修「界面浓度」：0 不再是把文字糊在照片上

用户报告「界面浓度设置有问题，文字看不清」。两处真问题，都在同一个控件上：

**1. 0 的时候界面并不是不透明的。** 旧公式把每层底色的**自身地板值**（14 / 30 / 42 / 34%）
再乘一个滑块系数，于是 `浓度 = 0` 时外壳仍只有 **14%** 的颜色 —— 壁纸整张透出来，正文直接
叠在照片上。现在 0 → **100%**（完全遮住），60 → 各层地板值，中间线性：

| 浓度 | 底色的自身颜色（bg-base） |
| --- | --- |
| 0 | **100%**（不透明） |
| 26（默认） | **78%** |
| 60 | 48% |

**2. 文案方向是反的。** 滑块提示写着「越小壁纸越明显」，而行为（以及文档里「0 时界面完全
遮住壁纸」）正好相反。已改为「0 = 完全遮住壁纸，越大壁纸越明显」，`aria-label` 同步；
`docs/wallpaper.md` 与 `docs/naming.md` 的口径一起改。

顺带：**预览与真实界面共用同一个函数**（`interfaceOpacity`）。此前预览用 `100 − 浓度`、
真实层还叠了地板值，两者在最大透明处差 8 个百分点 —— 预览看不出来的差别，调到头就看得出来了。

测试：新增三条断言（0 → 100%、默认 ≥70%、最大仍 >0%）。其余设置逐项复核过一遍：
`transparent 0–60`、`scrim 0–80`、`blur 0–24`、`brightness −60–60`、保存延迟 / 校验 / 行号 /
Tab 宽度 / 控制台写盘 的滑块范围与归一化 clamp 全部一致，未发现第二个反向或越界的控件。

## 0.4.0 — 写 CSS 用的页面 API（window.dshCss）

页面里多了一个对象，把插件已有的能力摆到控制台：读/写样式表、查命中数与规则归属、
找死选择器、反查令牌、点选元素。文档见 [docs/authoring-api.md](./docs/authoring-api.md)。

```js
dshCss.css.selectorHits('[class*="_panelRow"]')   // 7
dshCss.css.deadSelectors()                        // 当前页面匹配 0 个元素的选择器
await dshCss.sheets.read('custom.css')
await dshCss.pick()                               // { selector, declarations }
```

- 六组接口：`sheets`（11 个方法）、`css`（apply/remove/list + 三个查询）、`tokens`（all/get/find）、
  `inspect`、`pick`、`on/off`，外加 `host` 裸接口与 `ui` 组件库；`apiVersion = 1`。
- `deadSelectors` 的口径：只报「静止页面上也匹配不到」的 —— 状态伪类（`:hover`…）、状态类
  （`.active`…）、`@media`/`@supports` 内的选择器、伪元素全部跳过。实测同一天的手工审计是 49 条，
  修好口径后剩 14 条（都是别的标签页里才出现的元素）。
- 拾取器改成可注入结果：面板还是插进编辑器，`dshCss.pick()` 只是把结果交回去。
- 新增设置项「开发者 · 控制台写盘」（默认允许）：关掉后写盘类方法抛错，查询与临时表照旧。
- 测试：loader-smoke 新增一组 —— 入口存在且版本正确、方法齐全、临时表 apply/list/remove 往返、
  `deadSelectors` 的解析与跳过规则；真机（3080）实测命中数、令牌、读写与死选择器审计。

## 0.3.12 — 弹出菜单跟随触发器宽度（侧栏「更多」按钮）

侧栏左下「更多」的按钮宽度随侧栏走，但它的下拉菜单是挂到 body 的 **portal**：inline 只给
`left` / `top`，没有 CSS 锚点定位（`position-anchor: normal`、触发器没有 `anchor-name`），
所以 CSS 量不到按钮宽度 —— 只能写死像素，侧栏一变就错位。

现在由插件补：`installMenuWidthFollower()` 用 MutationObserver 盯着新出现的
`[role="menu"]`，量一下设置区里那个已展开的触发器，把菜单的 `width` / `min-width` 设成同一个
值；窗口 resize 时如果菜单还开着，再量一次。只认设置区里的触发器，不碰别的弹出层。

- 实测：默认侧栏触发器和菜单都是 252px；把触发器临时改成 300px，菜单跟着 300px（inline width）
- 不做成纯 CSS 的原因：`anchor-size(width)` 要 `anchor-name` + `position-anchor` 成对出现，
  而菜单的定位是 JS 算的 left/top，不参与锚点定位 —— 那段 JS 不归我们改
- 测试：loader-smoke 新增一组（展开时复制宽度 / 收起时不复制 / 零宽忽略 / 空菜单与空触发器）
- 用户的 custom.css 里那条写死的 `[role=menu]{width:252px}` 已换成说明注释

## 0.3.11 — 安静：全新安装不再在控制台报两个 404

装了插件、但**还没设过壁纸 / 改过设置**时，每次打开页面控制台都会出现两条：

```
GET /dsh-custom-css/read?name=wallpaper.json.css 404
GET /dsh-custom-css/read?name=settings.json.css 404
  bootstrapWallpaper → readSheet → api
```

功能上没问题（读不到就用默认值），但这两条是浏览器对失败请求的日志，会让人以为插件坏了。

改法：`/read` 接受 `optional=1`，文件不存在时回答 `{ok:true, absent:true, css:""}` 而不是 404；
壁纸与设置的两个 bootstrap 都带上这个参数。编辑器不传，所以「打开一个不存在的样式表」仍然是硬 404。

- host：`/read` 新增 `optional` 参数（`lib/index.js`）
- 浏览器：`readSheet(name, { optional })` 返回 `{css, rev, absent}`；两个记录读取都走 optional
- 兼容旧 host：旧版不认这个参数仍会 404，客户端把 optional 读的 404 当作 absent（功能不受影响，
  只有控制台那两条日志要等重启 host 才消失）
- 验证：dev 实例（新 host）上 `read?…&optional=1` → 200 `{absent:true}`；页面加载的三条 read
  请求全部 200（wallpaper / settings 带 optional，custom.css 不带）
- 测试：host-api-smoke 新增 4 条断言（无参数仍 404 / 有参数 200 absent / css 为空串 / 非记录名同样适用）；
  loader-smoke 断言两个记录都以 `optional=1` 读取

## 0.3.10 — 修：聊天里的图片压住后面内容（扩展冲突的补丁）

**归因**：不是 DSH 本身，是另一个扩展 **`dsh-smooth-stream` 0.7.0** 的样式表。

判别方法（3080 真机，逐张样式表二分，本插件补丁关闭）：以 217 张扩展样式表为集合，
每步只量「图片外层 `<p>` 的高度」。

| 打开的样式表 | `<p>` 高 |
| --- | --- |
| 全部关闭 | 256px（DSH 自己是对的） |
| 只开 `dsh-smooth-stream/TypewriterAssistantNodeView.module.css` | 19px（行高，图 256px 溢出） |
| off/on 交替 ×3 | 256 / 19 稳定复现 |

机制（已定位到具体规则）：smooth-stream 的
`@supports (text-box-trim: trim-both){ .root :is(p,h1…){text-box-trim:trim-both;text-box-edge:text} }`
会按文字边裁掉首末行的半行距 —— 段落里只有一个高 inline-block 图片时，`<p>` 被压到
文字行高 19px，而图仍按 256px 绘制，于是画到盒子外面盖住下文。删掉这条规则或把它加上
`:not(:has(img, …))`，`<p>` 立刻回到 256px（实测）。

上游状态：作者已在 main（1f199b7）与 0.8.0-rc.1（npm tag `next`）修好，但 `latest` 仍是 0.7.0。

**补丁**（独立 `<style data-plugin-css="dsh-custom-css/host-fix.css">`）：

```css
[class*="_imageButton_"] { display: block; width: fit-content; max-width: 100% }
```

让包图片的按钮成为块级元素，那个 `<p>` 的高度只能由图片决定 —— 上游哪个扩展布局出岔子都不再溢出。
`width: fit-content` 不能省，否则按钮撑满整行。真正修法在 smooth-stream 那边；它修好后这段可整块删除。

> 更正：0.3.10 最初把原因写成「DSH 的 inline-block 不贡献高度」，那是错的 ——
> 全部扩展关掉时同一个 inline-block 的 `<p>` 是 256px。详见 [已知坑](./docs/known-issues.md)。

## 0.3.9 — UI 组件库（`ui`）+ 状态补齐

界面元素全部收进 `lib/client.js` 里的一个 `ui` 对象，类名统一成 `dshUi_*`。加功能只调用
`ui.*`，不再手写控件结构 —— `tests/audit-ui.mjs` 会拒绝绕过。

**组件**（详见 [docs/ui-kit.md](./docs/ui-kit.md)）：

| 类别 | 组件 |
| --- | --- |
| 控件 | `ui.button` · `ui.switch` · `ui.choice` · `ui.field` · `ui.slider` · `ui.link` · `ui.badge` |
| 行布局 | `ui.row` · `ui.group` · `ui.hint` · `ui.value` · `ui.facts` |
| 反馈 | `ui.status`（idle/busy/ok/error）· `ui.empty` |
| 小节 | `ui.section`（标题 + 徽标 + 说明 + 内容） |

**新补的状态**：

- **busy**：按钮转圈（纯 CSS、`prefers-reduced-motion` 下停转）+ 禁用 + `aria-busy`；
  「导出全部 / 重置插件 / 更换图片」在对应操作进行时用它。
- **error / ok**：状态行四态化，带 `role=status` + `aria-live=polite` —— 变化会被念出来。
- **empty**：虚线框空态，替代「把空状态写进状态行」的做法（壁纸面板那句「还没有壁纸」）。
- **invalid**：字段的 `aria-invalid` 与错误说明由同一个布尔决定（`ui.field({ invalid, message })`）。
- **slider**：`onInput`（拖动预览）+ `onCommit`（手势结束落盘）分离，读数由 `ui.value` 统一。

原来散在面板里的 61 处手写结构（12 处设置行、7 处按钮、8 处开关、7 处下拉……）都改走它；
`audit-ui` 检查 30 个组件类，组件库之外出现一处即失败。

## 0.3.8 — 控件撤回自绘一套（撤掉 0.3.2 的组件库依赖）

0.3.2 把插件详情页的按钮/开关/标签换成了 DSH 组件库（`primitives.Button` 等），而设置界面里
那一行一直是自绘的 `dshCc_*`。结果同一个扩展有了**两套控件**：同一个开关在两处长得不一样，
同一个按钮在两处一套来自库、一套来自自己。这一版撤回组件库那一路，统一到**设置行那套**：

| 控件 | 现在用的 | 定义位置 |
| --- | --- | --- |
| 按钮 | `.dshCc_btn`（`rowButton`） | `ROW_CSS` |
| 开关 | `.dshCc_switch` + Track/Thumb/Label（`rowSwitch`） | `ROW_CSS` |
| 徽标 | `.dshCc_languageBadge`（`rowBadge`） | `ROW_CSS` |
| 下拉 | `Dropdown` + `.dshCc_select`（`rowChoice`） | `ROW_CSS` |
| 文本框 | `.dshCc_input` | `ROW_CSS` |

**这是往回退一步、但方向更对的一次**：这个插件不再 require 任何 `@deepseek-ai/*` 模块（回到
「运行时零 require」，那是它跨 34 个版本不坏的原因之一），而「像 DSH」靠的是把 DSH 的规格值抄进
自己的样式表（36px 按钮、`bg-module-platform` 填充、`radius-sm` 徽标、`l4` 边框字段）。

下拉从原生 `<select>` 换成行里的 `Dropdown`：`<select>` 的弹层是系统的方形调色板，改不了样式，
在行的圆角菜单旁边像另一个产品 —— 行自己的注释早就这么写过，现在两处一致了。

实测：面板里 `select` 元素 **0 个**，5 个选择项都是 `.dshCc_select` 触发钮；2 个开关都是
`.dshCc_switch`（`aria-checked` 上色）；按钮 `.dshCc_btn`；URL 输入框 `.dshCc_input`；徽标
`.dshCc_languageBadge`；零横向溢出。

## 0.3.7 — 关于一节

插件详情页的设置里多了「关于」一组，四行事实（与壁纸节同一形状：`dt` + `dd`）：

| 行 | 内容 |
| --- | --- |
| **版本** | 宿主半实际装的是哪一版 + 许可（读的是盘上那份 `package.json`） |
| **目录** | 样式表在哪（`~/.dsh/custom-css`） |
| **宿主** | 平台 + Node 版本 —— 报 bug 时最常被问的两项 |
| **链接** | 「仓库」「文档」两个外链 |

新端点 `GET /about`。版本**不是**源码里写死的字符串，而是读磁盘上那份包清单 —— 写死的字符串会
在有人改 `package.json` 却忘了改字符串的那一次开始说谎，而这一行的全部意义就是别撒谎。

`repository` 在包清单里是 `git+https://…`（npm 要的形状），浏览器打不开，所以在渲染前剥掉
`git+` 与 `.git`；清单里保持 npm 要的形状 —— 一个值两个消费者，就在读的那一侧修。

## 0.3.6 — 数据：导出全部 / 重置（归档式）

插件详情页的设置里多了「数据」一组：

| 动作 | 端点 | 行为 |
| --- | --- | --- |
| **导出全部** | `GET /export` | 一个 JSON 文件：每张表的正文**与历史版本**、两份记录、壁纸（base64）。文件名带时间戳 |
| **重置插件** | `POST /archive-and-reset` | 把样式表 / 历史 / 壁纸 / 记录**挪进** `.backup/<时间戳>/`，再写一张 `custom.css` 设为 active |

**重置是归档，不是删除。** 按错一次代价是多一层目录，不是作品没了；归档目录就在
`~/.dsh/custom-css/.backup/<时间戳>/`，随时自己取回。重置也要按两次。

浏览器里的两份 `localStorage` 副本会一起清掉 —— 只搬文件的话，下一次打开页面会被浏览器里那份
记录复活。

### 顺手修掉的两个真问题

- **重置按条数上限走**：归档用 `/list` 的列表（有 `MAX_FILES` 上限），所以超过上限的那些表会被
  留在原处。实测 200+ 张表的目录重置后还剩两张。现在归档与删除都看**全部**文件。
- **Windows 上被占用的目录改不了名**（`EPERM`：本插件自己的 watcher 打开着它）。这会让整个
  重置直接失败；现在这一项记进 `skipped` 并继续，界面提示「重启 dsh 后再试」。

## 0.3.5 — 样式表管理：重命名 / 删除 / 打开目录

「更多操作」菜单里多了三项，配合三个新的 host 端点：

| 菜单项 | 端点 | 行为 |
| --- | --- | --- |
| **重命名** | `POST /rename` | 改名字；**历史快照一起搬**，`active.json` 里的 active / disabled 跟着改；目标已存在 → 409，如实说明而不是覆盖 |
| **删除** | `POST /delete` | 删之前先留一份快照（所以可逆）；**最后一张拒绝删除**，菜单里那一项也是灰的 |
| **打开目录** | `POST /open-dir` | 用系统文件管理器打开样式表目录 |

**删除要按两次**：第一下把菜单项变成「再按一次删除 / 不可撤销」，第二下才真删。这是整行里唯一
一个「文件从目录里消失」的动作，一次点击不该把它做完。

### 顺手修掉的一个真问题：记录被当成样式表

`wallpaper.json.css` / `settings.json.css` 以 `.css` 结尾，于是 **`/list` 把它们当成样式表列出了**
—— 文件下拉里能选到「壁纸记录」去编辑，而新加的重命名 / 删除正好可以改掉它。现在
`isSheetName` 拒绝 `*.json.css`：不进 `/list`、不能改名、不能删；`/read` 与 `/write` 另走
`safeRecordPath`，所以插件自己照旧读写。

还有一条同类的：`/delete` 原本用 `/list` 判断文件是否存在，而 `/list` 有条数上限（`MAX_FILES`）
—— 目录里表一多，自己的尾巴就会被判成不存在。实测 200+ 张时重命名后的合法文件删除得到 404。
现在直接问文件本身。

## 0.3.4 — 壁纸可以直接贴 URL

壁纸面板多了一行「或 URL」：图已经在别处时（`https://…`、`file:///…`、`data:image/…`、站内路径），
贴地址即可，不必先下载再上传。

- 地址**按下按钮时**才校验，被拒时那一行下面直接说明原因，**当前壁纸不动** —— 打错一个字符
  不会把已经调好的背景弄没。
- 校验规则与写入时用的**同一条** `wallpaperUrlAllowed`：`javascript:`、非图片的 `data:`、
  协议相对的 `//host/…`、含控制字符或 `</style` 的都拒绝。这些在面板里就能看到原因。
- 输入框**刻意不回填**当前图片。上传来的记录是 `data:` 或站内路径，把一兆字节的 base64 塞进
  文本框不是草稿，是渲染事故。

实测：`javascript:alert(1)` → 面板出说明、记录不变；`https://example.com/a.jpg` → 页面上的
`body::before` 立刻变成 `url("https://example.com/a.jpg")`。

## 0.3.3 — 编辑器偏好（保存延迟 / 校验 / 行号 / Tab 宽度）

「壁纸设置」这一节多了「编辑器」一组，四项都只影响 **设置 → 通用** 里那一个编辑器本身：

| 设置 | 默认 | 作用 |
| --- | --- | --- |
| **保存延迟** | 400ms | 打字停下来多久才写盘。几乎立刻 / 400ms / 1s / 3s |
| **校验** | 语法 + 变量体检 | 状态行报什么：全查 / 只查语法 / 关闭校验 |
| **行号** | 开 | 关掉后行号列**隐藏**而不是被移除 |
| **Tab 宽度** | 2 空格 | Tab 键与「格式化」都按它缩进 |

四个值都是**白名单**，和轮换间隔同样的理由：手改 `settings.json.css` 也 arm 不出一个界面里
没有开关能停掉的行为。

两处实现上的取舍：

- **行号是隐藏不是卸载**。编辑器的着色层、光标揭示与滚动镜像都按那根列量过位置，拆掉一列会
  改变其它部件赖以定位的几何；所以设置拿掉的是**数字**，不是那个盒子（`.dshCc_noGutter` 里
  `display:none`）。实测：关掉后 `.dshCc_gutter` 的 `display` 是 `none`，而 1145 个行号节点
  仍在 DOM 里。
- **校验关掉时，状态行不会整条消失**。写盘失败那类消息是这个插件自己的状态，不是 linter 的
  意见，所以「关闭校验」只关掉格式与变量检查。

## 0.3.2 — 控件改用 DSH 自己的组件

界面上那几处控件不再是这个插件画的：按钮、开关、标签换成 **DSH 组件库**
（`@deepseek-ai/dsh-client-ui-primitives`）里的 `Button` / `Switch` / `Tag`，图标也换成库里那套
（刷新、垃圾桶、图片）。插件声明这个模块为依赖，样式与行为从此与 DSH 自己一致，插件不再维护
一份「看起来差不多」的按钮。

**实测过的两件事**：

- 组件库**能**在运行时的模块表里被取到（这是它第一次不再是「零 require」的插件）；
  实测导出里有 `Button`/`Checkbox`/`Input`/`Switch`/`Tag`/`Tooltip`/`StateDot`/`Menu`/`Toast`
  与 98 个 `Icon*Regular`。
- 但**没有** `Select` / `Slider` —— 所以「填充」「轮换」这两个下拉仍是原生 `<select>`（按 DSH
  字段规格着色），滑块仍是原生 `<input type=range>`。这是查出来的事实，不是猜的。

组件库万一缺席也不会坏：取不到时退化成普通元素，控件照旧能用、只是不再来自组件库。

## 0.3.1 — 插件图标，以及「壁纸设置」一节

### 插件页有图标了

`package.json` 里加了一行 `"icon": "./assets/icon.svg"`。图标本来就在仓库里（README 头部那张），
只是从来没**声明**过 —— 宿主从包根的 `icon` 字段或 `<specifier>/icon` 导出里读，编成 `data:` URL
交给界面，格式限 SVG/PNG/JPEG/WebP 且 ≤ 256 KiB。**不用重启**：实测加完字段刷新页面就出现了。
更早的 DSH 不读这个字段，那时插件页只有默认图形，也不会报错。

### 「壁纸设置」一节（插件详情页）

插件详情页现在有两节：**自定义壁纸**（换图、填充、界面浓度、明暗、用过的）与**壁纸设置**
（下面这四项）。设置存在宿主磁盘上的 `settings.json.css` —— **与壁纸记录分开**：壁纸记录是
「哪张图」的答案，偏好不是它的一部分。

| 设置 | 效果 |
| --- | --- |
| **轮换** | 关 / 5 分钟 / 30 分钟 / 每小时 / 每天，从「用过的」里换下一张；库里不足两张会说明 |
| **遮罩** | 0–80% 的一层暗色，压在**图片**上（与「界面浓度」是两件事：那个改的是界面自己留多少底色） |
| **模糊** | 0–24px 的 `blur()`，作用在图片上 |
| **角落时间** | 右下角一个半透明时钟；只在这个页面**有壁纸**时出现，关掉即移除 |

四项都在插件自己这一侧生效：改完立刻写进注入的样式表，**不需要刷新、不需要重启宿主**。

两处实现上的坑，都实测过并写进了注释：

- **一个 `filter` 声明**。亮度与模糊各写一条时，后一条会**替换**前一条而不是叠加 —— 现象是
  拖着模糊滑块，图还是清晰的（实测计算值只剩 `brightness(0.45)`）。现在两者拼成一条。
- **时钟的可见性判据在 store 更新之后才读**。先同步再更新 store，会留下「壁纸移除了、时钟还在」
  的状态；现在提交顺序是「写 store → 同步时钟」。

## 0.3.0 — 自定义壁纸

### 不写 CSS 也能换背景图

选一张本机图片就换掉界面背景，四个控件：**填充**（铺满 / 完整 / 拉伸 / 平铺）、
**界面浓度**（0–60%，越低图越明显）、**明暗**（±60% 的 `brightness()`）、
以及**用过的**（宿主上还留着的图，点一下就换回去，不用重新上传）。

图片是宿主磁盘上的真文件（`~/.dsh/custom-css/.wallpapers/wallpaper-<sha256>.<ext>`），
**名字就是内容的哈希** —— 同一张图传两次只落一个文件、一个缓存项，而这样的 URL 可以永久缓存。
浏览器只保存一个 URL 加四个设置（另有一份 `wallpaper.json.css` 存在宿主上，是一张普通样式表
文件、内容恰好是 JSON），所以图不进 `localStorage`，换回来也不用再传。

### 面板在插件详情页（只有这一处）

**插件详情页**：左侧「插件」→ `dsh-custom-css` → 最下面的「自定义壁纸」一节。

这一节按 `plugins.detail.section` 的官方约定做：**自己画 section chrome**（标题 / 说明 /
事实列表），并且对它没有话可说的 subject **返回 `null`** —— 那个座位是**每个插件都渲染**的，
所以组件检查传下来的 `subject`：`kind` 必须是 `bundle`、`pkg.name` 必须是本插件，
否则不出现（别的插件页面上不会多出一块无关的东西，同名的一个内建插件也是另一页）。
section 的取值是从插件管理器自己的产物里逐值抄的（一节 `gap:12`、标题 `14px/500`、事实用
`dl` + `dt`/`dd`），抄值不抄那个会随构建变的类名哈希。

**设置行里只留一个入口，不是一个面板**：**设置 → 通用 → 自定义 CSS** 的最下面是一行链接
「**自定义壁纸…**（在插件详情页里设置）」，点它跳到上面那一节。它走的是插件管理器提供的
`pluginNavigation.openBundle()`，**在点击时才去读**（`ctx.get`），不进 `exports.inject` ——
否则一个没有插件管理器的组合里，这行链接会把整个插件都拖下水。服务不在时退回「点侧边栏的
『插件』入口」，认不出来就什么都不做。

> 那一行曾经塞的是**整个壁纸面板**（约 535px 高，而这一列表里别的行都是一行）。
> 行高还是次要的：那一行的问题本来就只有一个（样式表），多摆一套控件等于让它同时回答两件事。


### 它自己负责「让界面透出来」

DSH 用不透明的 `--dsw-alias-bg-base` 画外壳，所以只把图放到后面是看不见的。这个功能同时
把界面自己的几个背景令牌改成「自身颜色 × 浓度」。**实测踩过两回，都记进文档了**：

- 与自带壁纸的皮肤（亚克力）**特异性打平**时皮肤赢 —— 图根本没画上，面板却写着「已保存」。
  现在图片层的选择器高一档（`html body::before,html body[data-ds-dark-theme]::before`）
  且关键几何带 `!important`；用户带 `!important` 写自己的规则依然能压过它。
- 界面底色那几个令牌**不带** `!important`：皮肤在这件事上表达的是更具体的意图，皮肤赢，
  面板会多出一行说明。想让面板说了算，把皮肤里那几个 `--dsw-alias-bg-*` 删掉。

窄栏里也不再靠 `flex-wrap` 碰运气：`container-type: inline-size` + `@container` 在 600px
处切换并排 / 堆叠。这里有一条值得记的教训 —— **`@container` 不产生特异性**，所以覆写基本
规则的容器查询必须写在**它要压过的规则之后**：写在前面时会「被解析、会被匹配、仍然输」，
表现是预览列算出来还是 `width:100%`、图片跑到控件上方。两块容器查询现在都在样式表**末尾**。
实测 870px 一栏与 422px 一栏：并排时 260px 预览 + 598px 控件，堆叠时各行零溢出。

### host 半新增三个端点

`GET /gallery`、`GET /wallpaper?name=…`（直接回图片字节，`immutable` 缓存）、
`POST /wallpaper-image`（收 `data:image/*;base64,…`）。名字形状钉死为
`wallpaper-<64 位十六进制>.<ext>`，另有「请求体上限」与「解码后 32 MiB」两个不同的上限。

文档：[自定义壁纸](./docs/wallpaper.md) · [host 侧文件接口](./docs/api-host.md#壁纸的三个端点)

## 0.2.2 — 属性字段会说值写错了，分组标题会黏住

### 值写错了会有提示

属性面板的值字段现在会在**失焦时**判断这个值是不是合法 CSS：不合法就描红（DSH 的
`state-error-primary`），并把鼠标提示换成一句说明（「这个值 notacolor 不是合法的 color 值」）；
写对了就恢复。样式规则（`[aria-invalid="true"]`）在面板建起来时就写好了，一直没有东西去设置它。

**判据取失焦时的值，不是边打边判** —— 这不是细节而是关键：半输入的值按定义就是非法的。
实测 `CSS.supports` 对 `re`、`rgb(`、`calc(` 和一个光秃秃的 `8` 全部判否，边打边判会让字段在
几乎每一次按键时变红、并且一直红到那个值碰巧变合法。平台自己也是这个口径：`:user-invalid`
存在，正是因为校验一个没写完的字段只是噪音。

**空值不算错**：实测 `CSS.supports` 对空字符串一律返回 false（`color`、`gap`、`width` 都是），
所以清空一个字段不能变红。这条规则由插件自己持有 —— 它不委托给引擎，因为引擎在这里答非所问。

### 添加属性菜单的分组标题会黏住

属性菜单的内容约 4000px、放在 260px 的滚动框里，分十组 —— 所以滚到某一组时，那个组的标题
确实会压在下面的行上，需要一块不透明底色。

`position: sticky` 与底色规则在做这一行与 DSH 对齐时就写好了，但**从来没有东西设置那个
`data-stuck` 属性**，所以底色永远不画。现在用 `IntersectionObserver`（root 是这个列表本身，
和 DSH 的做法一致）：标题越过列表上边缘时回调触发，属性随之切换。实测滚到 900px 时标题
黏在距列表顶 4px 处、底色变成不透明的 `rgba(248,249,250,.94)`。

没有这个 API 的环境里退化为普通的黏顶标题 —— 那是正确的降级，不是错误。

## 0.2.1 — 修两个会自己咬自己的地方

这一版只修 bug，没有新功能。它存在的直接原因：**npm 上的 0.2.0 是在更早的提交发布的**，
那时下面两处修复都还没有 —— 而发布出去就不能覆盖，所以只能往上加一个版本。

顺便记一笔：这两个问题的共同形态是「这一行做的东西被自己影响」—— 一个浮层改变了它自己
生成的 `nth-child`，一个读数描述了一个已经不存在的页面。

## 0.2.1 · 拾取器的层级滑块补上可访问名

`<input type="range">` 没有内容，所以它的可访问名**只来自 `title`** —— 而 `title` 不可靠地
充当可访问名（助手技术可能念成「滑块」并把提示丢掉）。补 `aria-label`。

这是「在真浏览器里数一遍可访问名」查出来的：拾取器面板上 5 个按钮都有名，唯一没有的是滑块 ——
静态快筛当时还漏了它，因为那个脚本只扫 `button`。

顺带补上了那个快筛脚本的两处盲区：它此前只匹配 `React.createElement('button', …)`，
而拾取器那一整块（面板四个按钮、候选行、令牌 chip）是用 `chrome('button', …)` 建的，
**从来没被扫过**；窗口固定 18 行也会把下一个控件的 `title` 算到按钮头上。

## 0.2.1 · 插件自己的元素不再被自己的选择器选中

用户报的问题，查下来比表面更宽：**所有做选择器匹配的地方都把自己的 UI 算了进去**。

**① 计数自指**：选择器字段里打 `[class]`，强调关闭时页面答 336 个命中，打开后答 **653** ——
多出来的 317 就是插件自己画的高亮框（它们也是 `div[class]`，对 `querySelectorAll` 而言与
页面内容没有区别）。

**② 会改变自己生成的选择器**（更要紧）：那些框原本是 `body` 的直接子元素，而拾取器提供的
结构路径候选是 `body > div:nth-child(N)`，N 来自 `siblings.indexOf(node)`。
**往 body 插节点就会移动 N** —— 一个浮层把它自己那个功能给出的选择器改坏了。

**③ 读数是陈旧的**：面板关着时 292，面板一开就变 336（面板本身是 44 个带 class 的元素），
而开关**继续显示 292** —— 因为"打开面板"不改变选择器文本，那个数被缓存在只认选择器文本的
计算里，于是它描述的是一个已经不存在的页面。

修法：新增 `data-dshcc-owned` 标记，插件自己的 chrome 全部带它；所有匹配点统一走
`queryOwned()`（高亮计数、大纲的「n 个命中」列、拾取器的候选排序、令牌采样）——
一处包装而不是四处过滤，因为需要记住四遍的规则一定会漏掉一遍。所有 chrome 收进**一个**
标记容器并追加在 body 末尾，所以 `#root` 及更早元素的 `nth-child` 编号不变。
计数改为每次提交实测的状态。

本文件记录 `dsh-custom-css` 的用户可见变更。版本号跟随 `package.json`。

## 0.2.0 — 强调显示，以及一轮界面统一

这一版有两件大事，各自的细节在下面各节里。

**新的「强调显示」开关**：规则面板的选择器字段右侧多了一个带命中计数的小胶囊，点一下
就把页面上所有匹配该选择器的元素框出来。**面板关掉后强调继续留着** —— 这个功能的目的
就是看页面，而面板正压在被看的东西上面。

**一轮界面统一**：这一行的控件此前普遍长成了自己一套尺寸，与同一个设置面板里 DSH 自己的
控件不一致。这一轮逐项对齐到 DSH 的组件规格与令牌 —— 圆角、字号、输入框、菜单项、菜单材质、
开关、状态点、徽章、控件高度，以及**等宽字体**（我们一直在用一份私有的字体栈，导致代码区的
字形与 DSH 别处代码的字形是两套字）。换来的不只是"看起来统一"：**只用令牌意味着两套主题都
自动正确** —— 实测品牌填色与前景色会随主题翻转，而写死的值不会跟随，这一轮里就有两处因此
出问题（一个不存在的令牌、一处对比度不达标的文字色）。

同一轮里修掉的还有：窄对话框下的横向溢出、下拉菜单的键盘契约、以及几处凭空写下的尺寸值。

下面按主题分节，每一节都记着**依据**（来自 DSH 自己的样式表与类型定义）与**实测数字**。

## 0.2.0 · 窄对话框下这一行不再横向溢出

**诊断**：420px 视口下行的横向溢出 291px，其中**几乎全部来自 `.dshCc_head`**（文件栏只占
37px）。表头是「自定义 CSS」标题 + 一排带文字的按钮，而 `.dshCc_actions` 写的是 `flex:none`
—— `flex:none` 加上没有 `min-width`，意味着这个 flex 项的最小宽度**等于它的内容宽度**，
于是没东西可让，只能撑出对话框。这一行往下 200 行处本来就有 640/540/520px 三档断点，
说明窄宽度是设计考虑过的，只是表头没跟上。

**修法（两处，缺一不可）**：表头 `flex-wrap:wrap` 让动作行掉到第二行；动作行把 `flex:none`
换成 `min-width:0`。**第二条才是真正起作用的那个** —— 只加 wrap 的话整体宽度一点没变
（420px 下仍溢出 256px），因为那个 flex 项的最小宽度还是它自己的内容。"让子元素换行"和
"让父元素能变窄"是两件事。

**取舍**：窄视口下行会变高（420px 时 495 → 627px，480px 时 441 → 529px）。这是有意的 ——
横向溢出会把这一行推出对话框，纵向延伸只是变长，而这么窄的页面本来就在纵向滚动。
宽屏（≥760px）行高完全不变。

**实测**（11 档宽度，1440 → 420px）：改前 640px 溢出 36 / 560px 116 / 480px 196 / 420px 256，
改后**全部 0**，控制台错误全程 0。

仍然存在的一处：420px 下文件栏自身溢出 37px（「CSS 徽章 + 大纲 + 开关」约 178px 的内容塞进
129px），属内容硬下限；480px 及以上已为 0。

## 0.2.0 · 选择器字段的「强调显示」开关

规则面板的选择器字段右侧多了一个带**命中计数**的小胶囊（靶心图标 + 「3 个命中」）：
点一下，页面上所有匹配该选择器的元素都被框出来。按钮上直接写命中数，因为那正是看这个
字段的人想知道的事 —— 选择器写对了吗。

### 形状与行为

- 框复用拾取器那套（`position:fixed`、最上层、`pointer-events:none`、不压暗、不拦截点击），
  只有颜色不同：拾取器的指针跟随框是品牌蓝，这些是琥珀色 —— 两者同时出现时一眼能分辨。
- **单个匹配**用 2px 描边且**不填充**：只有一个的时候没有"彼此区分"的需要，而一整块色会
  盖住用户正在看的内容。多个匹配用 1px + 10% 填充。
- 零尺寸元素（`display:none` 量出来 0x0）不画框 —— 它在视口角落会变成一个指向无物的点。
  实测：`button` 选择器命中 63 个元素，画了 61 个框，跳过的那两个正是隐藏的。
- 滚动与缩放时**实时重测**（`position:fixed` 不跟随页面）。实测滚动后框与目标仍对齐同一像素。
- **面板关掉后强调继续留着**：这个功能的目的就是看页面，而面板正压在被看的东西上面。
  关掉整个设置则清干净 —— 不留没人能解释的框。

### 三种"看起来都像 0"的情况分开报

那个数字是用来判断选择器写没写对的，所以三种情况不能混成一句「0 个命中」：

| 情况 | 例子 | 显示 |
| --- | --- | --- |
| 合法但页面上没有 | `.cm-peak-strip` | `无命中`（禁用） |
| 根本不是选择器 | `@media (…)` 抛 SyntaxError，不是返回空列表 | `不是选择器`（禁用） |
| 规则写在伪元素上 | `a::before` | `伪元素`（禁用） |

伪元素那一条是**判断**而不是查表：`a::before` 匹配不到任何元素，报"0 个命中"技术上没错，
但等于告诉用户选择器写坏了 —— 实际是它无法被框选，而且这个说法还藏掉了有用的那半答案
（宿主元素是命中的）。

> 第二种实测下来**经面板不可达**：解析器根本不为 at-rule 开面板。保留在函数里，是因为
> 函数该对自己的输入负责，而不是指望调用方先过滤干净。

### 颜色不用红（这一处我提了反对意见）

原本要的是红色描边。DSH 只有一套红，语义是"出错了"，而一个命中 3 个元素的选择器没有出错 ——
用错误色会让一条正常工作的规则读起来像故障。改用 `state-warn-primary`（琥珀）。

按下态的文字色也因此**不能**用琥珀：琥珀文字放在 12% 琥珀底上实测 **1.96:1**，小字阈值是 4.5。
根因是把**图形色当成了文字色**（`state-*` 是给边框/圆点/填充用的，文字有专门的 `label-*`）。
现在文字用 `label-primary`，琥珀色留给描边与填充：文字 vs 底 **浅色 17.22 / 深色 9.41**，
描边 vs 底深色 4.58 —— 两套主题都通过。

## 0.2.0 · 自由值清扫（"文件下拉为什么这么宽"引出的）

一句提问牵出的一类问题：**凭空写下来的数值**。它们不像"忘了用令牌"那样能被令牌审计抓到 ——
因为任何令牌都对不上，审计自然放过。判据是：不在任何档位上 + 只出现一次 + 找不到理由。

### 最要紧的一处：等宽字体用错了栈

DSH 有官方的代码字体令牌，而我们自己维护了**三份**字体栈（还写得不一样）：

  DSH   --ds-font-family-code : "SF Mono","JetBrains Mono","Fira Code",Consolas,…,"PingFang SC","Microsoft YaHei"
  我们  --dshCc-mono          : ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace

通用 `ui-monospace` 会赢下每一次竞争，也就是说 DSH 点名的那些设计字体**一个都没机会被选中**：
同一块界面里，代码区的字形和 DSH 别处代码的字形是两套字。这是"元素和 dsh 不统一"最直白的
一种表现，而它躲过了前面所有审计 —— 值本身没问题，问题是**用错了字体栈**。
现在全部走令牌（实测：编辑器、行号槽、高亮层、选择器字段、面板标题都解析为 `SF Mono`）。

### 凭空写的尺寸

| 位置 | 原来 | 依据 |
| --- | --- | --- |
| 文件下拉宽度 | `min-width:218px`，内容只需 106px（空白 112px） | DSH 的菜单列表是 144px；改成 `min-width:100%`（跟触发器）+ `width:max-content`（跟内容），实测短名 121px、长名 282px 且不截断 |
| 冲突提示的按钮 | `height:22px` —— 比 DSH 任何一档都小 | DSH 的 Button 只有 36px 与 28px 两档 |
| 拾取器的"插入规则" | `height:30px`，卡在两档之间；同排次要按钮是 28px | 同上 |
| 部位标签 | `padding:1px 7px` + 6px 圆角 | DSH 的 Tag 是 `1px 8px` + 999px 胶囊 + 字重 500；我们的底色本来就用的 tag 令牌，所以这是个"差一档"的错位 |
| `.dshCc_candidate` | 圆角 6px | 内边距 `6px 8px` 本来就与菜单一致，只有圆角差 2px |
| 查找命中层 / 可点选择器 | 圆角 2px、3px | 统一到 `radius-xs`（**这是判断，不是查表**：DSH 没有覆盖这么小尺度的组件可抄） |
| 圆角 8px × 3 | 字面量 | 值恰好等于 `radius-sm`，所以看不出问题 —— 但主题调档位时它们不会跟着动 |

保留 4 处 `999px`：**DSH 自己的 Tag 与 Pill 就是直接写 999px**，没有胶囊令牌。

### 补全列表（一行里第三个浮起的面）

- 容器内边距 2px → **4px**，与两个菜单一致；布局里 `items*26+8` 的锚点公式依赖它，已同步
  （实测 1 项 → 34px，吻合）。
- 补上 `backdrop-filter`：底色 58% 不透明，没有模糊时下面的代码会原样透上来。
- **保留**两处刻意不同并写明理由：行高 26px / `radius-sm`（自动补全要紧凑，是扫读不是阅读）、
  宽度用绝对值（它锚在光标上，不锚在容器边缘）。

### 查过但**没动**的（有理由的硬编码）

- `--dshCc-line:19px` 与 JS 的 `LINE_HEIGHT` 是跨语言耦合，必须两处一致。
- `.dshCc_editorWrap` 的 `height:140px` + `resize:vertical`：是**默认值**，用户可以拖高。
- `.dshCc_fileBar` 的 `7px 10px 7px 11px`：左边比右边多 1px，看起来是给图标的视觉配重，
  没有证据说明它是错的。
- 11px/16px 那几处：与 DSH 自己的菜单分组标题一致。
- `pickInsert` 的前景色 `label-primary-foreground` 经核对**本来就用对了**（DSH 的 `.primary`
  就是这一对），只是填色令牌从"品牌色"换成了语义更准的 `button-primary-fill`（两者同值）。

### 测试

新增四条**通用**断言（比逐个举例更结实）：所有圆角必须是档位或胶囊、所有控件高度必须在
按钮档位上、样式表里不许再有私有等宽字体栈、补全列表的内边距与锚点公式必须配套。
每条都反向打过补丁确认会红 —— 其中字体那条**当场抓到我漏掉的一处**：
`.dshCc_candidateSel` 那份字体栈逗号后带空格，替换模式没命中。

回归：5 档宽度文件栏溢出 0、控制台 0 错误；窄宽度下行会变高（640px 时 405px、520px 时
423px）是**固有换行**，与改动前的数字完全相同。

## 0.2.0 · 界面统一（批次 0 · B · C · D · F · G · H · I）

一轮把这一行与 DSH 对齐的工作，原则是**用它的组件规格与令牌，而不是照抄数值**。起因是
一句反馈：「元素和 dsh 不统一」。查下来不是个别元素的问题 —— 是这一行的控件普遍长成了
自己一套尺寸，而同一个设置面板里 DSH 自己的控件是另一套。

下面是这一轮改了什么、以及每一处**依据**（都来自 DSH 自己的样式表与类型定义，不是看截图猜的）。

### 令牌化的部分（改了来源，没改外观）

| 改了什么 | 依据 | 之前 |
| --- | --- | --- |
| 圆角全部读 `--dsw-radius-*` | `Button.module.css`：「Button sizes share the global control-radius scale」，36px 控件用 md、28px 用 sm | 一律写死 18px，抄的是一个**已经不存在**的类 `_button_cfgyt_4` |
| 字号与行高读 `--dsw-font-*` | 主题里的字体档位（11/12/13/14px，各自带行高与字族） | 50 处写死的 `font-size`、29 处写死的 `line-height`，字体令牌用量为 0 |
| 填色底上的文字读 `--dsw-alias-label-primary-foreground` | `Button` 的 `.primary` 用它 | 一处写死 `#fff`，一处用了 **`--dsw-alias-label-inverse` —— 这个令牌在 DSH 里根本不存在**，实际一直是回退值在画 |

### 按组件规格改的部分（可见变化）

- **文本输入框**（新文件名 / 选择器字段 / 属性值框）→ DSH 设置表单的 `.input`：34px 高、
  `0 12px`、`0.5px` 的 `border-l4` 描边、`bg-layer-3` 底、13px 字；**聚焦改为描边变色**
  （`state-business-primary`）而不是画外环。原来它们长成"和旁边按钮一样"：36px / 1px
  `border-l2` / 14px 字 / 外环。
- **菜单项** → `Menu.module.css` 的 `.item`：34px 高、`6px 8px`、`gap:6px`、13px/20px；
  选中勾改 `label-primary`；危险项补专门的危险悬停底色；列表 `gap:0`；分组标题补字重 500。
  原来是 40px / 14px —— 同一行里点开两个菜单会像两个不同插件给的。
- **菜单材质** → 补上 `--dsw-menu-backdrop-filter`（`blur(40px) saturate(150%)`）。
  底色令牌本来就和原来那个解析成同一个值，所以"换底色"是无效功；真正的差别是这个模糊 ——
  底色只有 58% 不透明，没有模糊时页面内容是**穿过**菜单滚过去的。
- **开关** → `Switch.module.css`：轨道 30×16 → **36×20**、滑块 12×12 → **16×16**；
  关态轨道 `border-l3`；滑块底色改读 `--dsw-alias-switch-thumb`；**状态改由 `aria-checked`
  驱动**，删掉了那个平行的 `dshCc_switchOn` 类（DSH 在自己样式表里写明了理由：视觉状态
  不能与读屏器读到的状态不一致）；聚焦偏移 1px → **2px**。
- **状态点** → `StateDot.module.css`：7px 实心圆 → **10px 槽 + 6px 心**；三态取色改到
  DSH 的状态族（on → `state-success-primary`，原来是品牌色，读起来像装饰而不像状态）。
- **`CSS` 徽章** → `Tag` 的 outline 色调：4px 方角 → **胶囊**、描边 `border-l4`、
  11px / 字重 500。

### 行为与无障碍

- **下拉菜单的键盘契约**（依据 `Menu.d.ts` 的说明）：打开时焦点进入列表、方向键与
  Home/End 在行间走动、`aria-activedescendant` 指向当前行、Escape 关闭并把焦点还给触发器。
  另外**Escape 必须 `stopPropagation`** —— 这条文档里没有，是实测出来的：不拦的话一次
  Escape 会把整块设置面板一起关掉（DSH 的模态层也是"最内层先处理"的口径）。
- **状态变化会被播报**：状态行以前**没有任何 live region**，所以"保存中"变"已保存"、
  或变成一条校验失败，读屏器用户完全收不到。现在普通变化 polite、失败 assertive + `role=alert`。
- **写盘期间禁用开关**（`Switch.d.ts` 明说这是使用方的责任）：开关写 `active.json` 走的是
  编辑器防抖写盘同一条路由，途中再点一次是竞态。
- **图标按钮补可访问名**：查找栏的两个 chevron 原本只有 `title`，而 **`title` 不是可访问名**。
- **空样式表时禁用大纲**：菜单里一行都没有，点开只会显得坏掉。

### 测试

- 新增守卫：圆角只许来自令牌（白名单每一条都写了理由）、字体令牌必须被用到且档位不许写成
  字面量、聚焦环只提供 style 与 offset（颜色与宽度是 DSH 的事）、菜单规格、
  开关/状态点/徽章的尺寸、菜单材质按数量配对、键盘契约、live region、
  每个控件都有可访问名。**每一条都反向打过补丁确认会红。**
- 测试床补上 `useLayoutEffect`（插件用它做锚点测量，而测试床原来只提供四个 hook）。
- 真机验证用开发实例 + 探针：10 档宽度（1440 → 480px）全部 0 横向溢出、0 控制台错误；
  在窄于 520px 时开关隐藏文字标签（与查找栏在 540px 下把「替换」收成 `↦` 同一种做法），
  否则文件栏会在 480px 处多出 9px 溢出 —— 那 9px 是开关变宽带来的，已消除。

### 这一轮留下的两件事（已知，未必是缺陷）

- ~~**宽度低于约 640px 时行内会横向溢出**（420px 时约 291px）~~ —— **已修**，见下面
  「窄对话框下这一行不再横向溢出」一节。当初判定"这是既有的"没有错（用改动前的代码测过，
  数字一模一样），但它是真缺陷，而且根因很小：表头里的 `.dshCc_actions` 写着 `flex:none`，
  没有任何东西可让。
- **菜单分组标题的黏顶底色**（`[data-stuck]`）只是样式钩子：DSH 那个属性由它自己的
  IntersectionObserver 设置，这一行没有观察器，所以永远不会画出来。规则与注释都留着了。

## 0.2.0 · 空样式表时禁用大纲（界面统一·状态）

### 报的问题是对的：有些控件在「它什么也做不了」的时候仍然可点

用户报的是「文本框没有内容时，大纲应该不可点」。查下来**只有大纲这一处**是缺口，但
它属于一整类：**一个控件在它的动作不会产生任何结果时，应该禁用，而不是可点但点下去
没反应**。这类缺陷在截图里看不出来 —— 禁用态与正常态只差一点透明度。

修的是 `Dropdown`：它**原来根本没有 `disabled` 这个 prop**，所以任何用它做的下拉都
永远可点。现在加了 prop，触发器上真的设 `disabled`，样式与行内其它禁用控件同一套
（`opacity:.4` + `cursor:not-allowed`，且悬停不改描边 —— 一个禁用控件不该对鼠标有反应）。
调用点只有大纲传它，条件是 `rules.length === 0`。

### 顺手把这一整类查完（结论：只有这一处）

拿空样式表当状态，逐个看每个控件该不该禁用：

| 控件 | 空表时 | 判定 |
| --- | --- | --- |
| 大纲下拉 | **原来可点**（菜单里一行都没有） | **改成禁用** ✓ |
| 更多操作 → 格式化 | 已禁用 | 对（没东西可格式化） |
| 更多操作 → 重置 | 已禁用 | 对（空表没什么可清） |
| 更多操作 → 打开文件 / 导入 / 导出 / 历史版本 | 可用 | 对（文件级动作与内容无关） |
| 拾取元素 | 可用 —— **实测点了真的能用**：`aria-pressed` 变 true、面板出现，因为它自己会创建规则 | 对，**不该禁用** |
| 查找 | 可用 | 对（查找栏本身要能打开） |
| 文件下拉 / 启用开关 / 编辑器 | 可用 | 对 |

### 测试

新增用例：空样式表时大纲**必须有 `disabled`**，且有规则时**必须没有**（两边都断言，
免得将来有人把它写成常量）。另加一条样式守卫：禁用态必须存在。
反向打补丁（去掉 `disabled` prop）确认会红。

## 0.2.0 · 聚焦环交还给 DSH（界面统一·批次 E）

### 我们一直在自己画聚焦环，而 DSH 本来就有一份全局的

这一批的方向查到最后**反了过来**。原计划是「把 9 处硬编码的聚焦环改成读 DSH 的令牌」，
做到一半去真浏览器验证，发现 DSH 的 `ui-theme` 里本来就有一份**全局**聚焦环：

```css
:root { --dsw-focus-ring-width: 2px }

:focus-visible {
  outline-color: var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
  outline-width: var(--dsw-focus-ring-width);
}

html[data-input-modality=pointer] body :focus-visible:not(:read-write) {
  --dsw-focus-ring-color: transparent;
  outline-color: #0000;
}
```

**颜色、宽度、以及「鼠标操作时不画环」这三件事，DSH 全都做完了。** 实测（键盘 Tab 走进
这一行）：`outline: 2px solid rgb(65,118,230)` —— 那个颜色正是它的回退值，形状才是我们的。

所以问题不只是「重复」，更是**有害**：我们原来那 9 条 `outline:2px solid brand-primary`
把颜色**钉死成品牌色**，盖住了 DSH 按输入模态自动决定的那套 —— 鼠标点进来的环本该是透明，
被我们画了出来。

现在只留全局规则没有的两件：

```css
outline-style: solid;    /* 全局规则没设 style，默认 none —— 不写就什么都画不出来 */
outline-offset: 1px;     /* 全局规则没设偏移，默认 0 —— 环会贴着边框 */
```

颜色与宽度一律交给 DSH：主题换配色、改环宽、改模态策略，这一行自动跟上。

### 中途踩的两个坑（都是我的）

1. **把常量名写进了字符串。** 用脚本替换 9 处时，`outline:2px solid …;outline-offset:1px`
   被换成了字符串里的 `+ FOCUS_RING +` —— 它成了 CSS 文本（`{+ FOCUS_RING +}`），浏览器
   看不懂就**整条丢弃**。而**两套测试全部通过**：断言查的是源码文本里有没有某个子串，
   占位符照样让它过。是真浏览器探针发现注入的样式表里一处聚焦环都没有。
2. **修它时又把引号搞坏了**：替换后残留的 `';` 提前闭合了字符串，`node --check` 直接语法错。

两条都补了守卫，但**第一条的守卫写错过一次**：第一版守卫断言「事件里出现了哪两个令牌名」，
而那句断言在**颜色被钉死**的状态下同样是绿的 —— 它锁的是实现，不是意图。现在断言的是：

```js
// 行的职责只有 style 与 offset；颜色和宽度是 DSH 的事
assert.ok(sheet.includes('outline-style:solid;outline-offset:1px'))
for (const banned of ['outline:2px solid', 'outline-color:', 'outline-width:']) assert.ok(!sheet.includes(banned))
```

外加一条更基础的守卫：**CSS 字符串里不许出现 JS 拼接语法的痕迹**（`{+`、`+}`，以及常量名
嵌在引号内）。这三条都反向打过补丁确认会红。

## 0.2.0 · 控件圆角改用 DSH 的令牌（界面统一·批次 A）

### 圆角不再写死，读 DSH 自己的那一套

这一行的每个控件原来都带着 `border-radius:18px`，那是**逐值抄**一个叫 `_button_cfgyt_4`
的类来的 —— 而那个类在当前这代 DSH 里**已经不存在了**。同一个设置面板里，其他每一个
36px 控件走的都是 `--dsw-radius-md`，只有我们是 18px。这就是「元素和 dsh 不统一」的样子。

现在圆角全部读 DSH 的令牌，ladder 一共五档：

| 令牌 | 值 | 我们把它用在 |
| --- | --- | --- |
| `--dsw-radius-sm` | 8px | 28px 的控件：大纲下拉、查找栏按钮、拾取工具栏、版本历史返回钮、冲突提示钮、插入规则钮、帮助触发 |
| `--dsw-radius-md` | 12px | 36px 的控件：文件下拉、拾取元素、查找、更多操作、属性值框、选择器字段、下拉按钮、模板 chip；以及菜单项与补全项 |
| `--dsw-radius-lg` | 16px | 浮动面：文件下拉菜单、面板下拉菜单、补全列表、拾取器面板、编辑器容器 |
| （字面量） | 2/3/4/6px | 行内文字上的装饰与小组件：命中标记、可点选择器、分量名标签、CSS 徽章、拾取目标框、候选行、令牌 chip |
| （字面量） | 50% / 999px | 圆点与胶囊：状态点、开关滑块、开关与其轨道 |

编辑器容器的圆角从 12px 变成 16px 是**有依据的**：它就相当于 DSH 的 CodeCard（带表头的
框），那个组件用的就是 `radius-lg`。里面的规则面板保持 12px —— 两者是嵌套关系，不是并列。

### 顺手抽出「惯用语」层，但**不合并选择器**

对生成的样式表做了一次重复分析：`outline:2px solid brand-primary` 写了 9 遍、`36px 高 +
圆角 + 底 + 描边` 写了 6 遍、`min-width:0` 写了 35 遍。分散的代价不是体积，是**改一处忘
一处** —— 这次改圆角就得靠手工 grep 在四个规则里找 `18px`。

现在把**值**抽成常量（`CONTROL_36` / `CONTROL_28` / `FOCUS_RING` / `ELLIPSIS` / `HINT_TEXT`），
被多处引用。**刻意不合并选择器**：两条规则共用一个声明仍然保持两条，所以特异性与源码顺序
和以前一模一样，生成的 CSS 一字未变（除了换掉的令牌）。合并选择器会悄悄动这两样东西，而
它们一旦变了，症状是「某个控件突然不生效」，且极难定位。

### 测试

- 新增圆角守卫：样式表里除白名单外不许出现圆角字面量，白名单每一条都写了理由；并要求
  `sm` / `md` / `lg` 三档都被用到。反向打补丁（把令牌改回 18px）确认会红。
- 原来那条断言 `border-radius:18px` 的守卫改成断言令牌 —— 它现在**逼着圆角必须来自 DSH**，
  比锁一个字面量更有价值。

## 0.2.0 · 开发实例、视觉探针与弹出层行为

### 弹出层行为三处修正

改的是**行为**，不是外观 —— 三处都属于"看不见但会咬人"的那类：

1. **面板下拉不再与它的触发器分家。** 那个菜单是 `position: fixed`，按触发器的视口矩形
   定位；而那个矩形在**任何滚动或缩放**之后就不再成立。现在监听三件事并重新测量：
   `resize`、`scroll`（**捕获阶段** —— 这里滚动的是设置对话框，它不是文档，冒泡阶段收不到）、
   以及菜单**自身尺寸变化**（`ResizeObserver`：菜单在打开之后长高，就是按一个它已经不再是的
   高度摆的）。
2. **菜单的高度上限是"实际有多少空间"，不再是常量。** 原来上限恒为 `MENU_MAX_HEIGHT = 260`，
   这只在窗口够高时成立；矮窗口里菜单会承诺一段它没有的高度，最后几行落在窗口外面、也够不着。
   现在按触发器上方/下方**哪边真有空间**来决定上限与翻转方向，且两件事读同一个判据 ——
   分开读时会出现「摆在一侧、却按另一侧量高度」，菜单看着anchor住了、其实一半在窗口外。
3. **键盘在菜单里走的时候能看见自己走到哪。** 两个列表界面都只给了指针 hover 的底色；
   方向键移动的是**真焦点**，所以 `:focus-visible` 要同一底色，并像 DSH 自己的菜单那样
   关掉浏览器默认焦点环（否则同一个焦点会画两道）。

**第一处的证据**（真浏览器，`npm run probe` 同款环境）：打开大纲下拉，把设置面板向下滚 60px ——
触发器上移 60px，菜单**同步上移 60px**，两者间距稳定在 4px。修正前菜单会停在原处，间距变成 64px。

**第二处没能在这里复现**：它要求触发器上下两侧的空间都不足 260px，而实测内层视口是
938px 高、设置对话框 800px 高、滚动余量约 600px —— 无论如何都留不出那种挤压。所以这条只有
单元用例（直接调 `placeMenu` 断言上限等于真实可用空间）与反向验证，**没有真机画面**。
`window.resizeTo` 在标签页里被浏览器忽略，实测四次都停在 938px 没动。

### 测试

- 新增 `placeMenu` 的高度与翻转用例，取值**直接跑实现得到**（不是手算——手算错了两次）。
- 新增三条源码守卫：`resize` / `scroll`（capture）/ `ResizeObserver` 三位一体，缺一个都红；
  以及两个列表界面必须有 `:focus-visible` 规则。每条都反向打过补丁确认会红。
- 测试床补上 `useLayoutEffect`：插件用它做锚点测量（React 在绘制前跑它，正是这条修正需要的
  时机），而测试床原来只提供 `useState` / `useEffect` / `useRef` / `useSyncExternalStore` ——
  少了它，行会直接抛错，后面所有断言都会变成"在测一个崩掉的组件"。

## 0.2.0 · 开发实例与视觉探针（开发工具，不改运行时）

这一批**不动 `lib/` 一个字节**，所以没有版本号、也不会改变插件在任何界面上的行为 ——
它是给后面的「预览块」铺验证能力的：那些改动全是排版，而排版结论不能靠读代码推出来。

### 开发实例：一个只加载本插件的 DSH

`npm run dev` 起一个独立的 DSH（`dev/home` 作 `DSH_HOME`，端口 `3081`，profile `ccdev`），
里面只有 `dsh-base` + `dsh-web-app` + 本插件，不碰你正在用的那个 3080。

**第一版方案是复制真机的 `profiles/web`，那条路是错的。** 根因在真机 profile 的
`cordis.patch.yml`：

```yaml
- id: webserver
  config:
    port: 3080          # ← 写死
```

而 DSH 出厂配置里同一个字段是 `port: !!js ctx.webStartup.port ?? 3080` —— **跟着 `--port` 走**。
写死的那份会把 `--port` 这个入口掐掉，于是复制出来的开发实例会去抢真机的端口：bind 撞上
不过是起不来，更坏的是有人把真机停了再起开发实例，真机就被顶掉了，而**没有任何地方会提示**。

现在走 DSH 官方给的路子：`--from-default-profile web`，它按 `dsh-app-boot` 的
`PROFILE_TEMPLATES` **只复制模板的 bundle 列表**，同名内置 profile 的本地状态一个字节都不读。
产出的是出厂默认值，而不是「被二十多个插件改过的运行副本」。顺带三个好处：`dsh-base` /
`dsh-web-app` 直接从 dsh 安装目录解析（不用装依赖）、不需要任何端口相关的 YAML、
profile 只有本仓库一个依赖。

### 视觉探针：在真浏览器里量，而不是看

`npm run probe` 用无头 Edge 打开开发实例，按选择器量几何、截图、收控制台错误 / 页面异常 /
失败请求，结果写 `temp/probe/last-probe.json`。**零依赖** —— Node 24 自带 `WebSocket`，
直连 CDP 就够，不必为此拉一个浏览器自动化框架进来。

它**不是** UI 测试套件：只测量、不断言。断言是下一步（把现有测试床搬进真浏览器）。

### 第一轮实测（0.2.0-rc.2 · Edge 155）

- 设置 → 通用里那一行正常出现：标题、文件下拉、[拾取元素] [查找] [更多操作]、说明、
  代码区（140px）、「已启用」开关、状态行；**控制台 0 错误 0 警告 0 异常**。
- 行宽 559、编辑容器 232、文件栏 43、代码区 541×140，**横竖溢出都是 0**。
- **两个实例确实互不影响**：开发实例的 `custom.css` 是 0 字节（当次新建），
  真机那份 33995 字节、修改时间是 9-28，一个字节没动。
- 顺手量到一个之前不知道的：**设置对话框自己有固定宽度，行宽不跟视口走** ——
  把视口从 1440 缩到 1024，行宽仍是 559。所以「侧栏打开时约 440px」那条窄行状态
  用缩小视口复现不出来，得把侧栏打开，或直接改行宽。

### 第二轮实测：在真浏览器里走完整条链路

用 ego（一个真窗口，AI 与用户看同一个界面）打开开发实例，往编辑器里真打一条规则：

```
.dshCc_self-test { border-left: 4px solid rgb(255, 0, 0); }
```

- 注入的 `<style id="dsh-custom-css-user-style">` 里出现了这条规则；
- 把类加到那一行之后，`border-left-width` 从 **0px 变 4px** —— 打字 → 防抖 → 写盘 →
  注入 → 重绘，整条链路在真 Chromium 里通了；
- 状态行从「保存中 · 1 行 · 59 字符」变成「已保存 · 1 行 · 59 字符」；
- 磁盘上 `dev/home/custom-css/custom.css` 是 59 字节，**真机那份 33995 字节、改于 9-28，没被碰**；
- 清空编辑器后注入的样式标签被移除、边框回 0px，而 `.history/` 里留了两份快照
  （`0 字节` 与 `59 字节`）—— **恢复可逆这条也在真浏览器里验过了**。

**给 ego 用的取件窗口**（`dev/ego.mjs`）：ego 有自己的 profile 与 cookie 罐，必须带着令牌进一次
才能换到 cookie，而那个令牌不该出现在对话、终端或日志里。所以这个脚本在 `127.0.0.1` 上临时开
一个取件窗口，把带令牌的 URL 放在 `/go` 的响应里，页面自己 `fetch` 到它再 `location.replace`
跳转 —— 令牌从「磁盘上的文件」直接到「浏览器的地址栏」，中间不经过任何会被记录的地方，
连浏览器历史里都不留那条地址。交出去之后窗口立刻关闭。

### 踩到的四个坑（都是探针自己的，记下来免得重犯）

1. **`npm run dev` 起完不退出**：第一版把子进程的 stdout/stderr 接成管道再自己转发，而管道是
   父进程持有的句柄 —— 即使 `child.unref()` 了，那两个管道还开着，事件循环就一直有活儿干；
   而 Ctrl+C 掐掉父进程时管道断开、子进程跟着死。一个「后台实例」被前台命令的生死绑住，
   正好是这套东西最不该有的样子。现在日志**由子进程直接写文件**，父子之间没有任何连接。
2. **探针在 Windows 上留下孤儿进程**：`child.kill()` 只杀掉 Edge 的主进程，而 Chromium 是
   「主进程 + 一堆子进程」，它们变成孤儿后继续活着、还占着临时用户目录，于是 `rmSync` 也
   跟着失败（实测一次留下 20 个 `msedge` 与 7 个 `dsh-probe-*` 目录）。现在用
   `taskkill /T`（整棵进程树）结束它，删目录则带重试 —— 内核收回文件句柄要一小会儿。
3. **嵌套会话的 WebSocket 在 Edge 155 上连不上**：`ws://host/devtools/page/<id>?sessionId=<id>`
   这个形态已经不工作了，只能走扁平会话。
4. **扁平会话的 `sessionId` 是外层消息字段**，塞进 `params` 会得到一个
   `'Runtime.enable' was not found (-32601)` —— 那个报错看着像协议版本不对，其实只是参数
   放错了一层。同理，事件里的 `sessionId` 也在外层，不在 `params` 里。

> 第 2 条之外还有一条是我自己排查时犯的、值得单独记下来的：为了清孤儿，我按「启动时间近」
> 去认 `msedge` 进程 —— 那是错的。Edge 的**子进程命令行里不带** `--user-data-dir`，只有主进程带，
> 所以按时间筛出来的很可能有一半是用户自己开着的浏览器的进程树。正确的判据只有一个：
> **只杀我们自己 `spawn` 出来的那个 PID 的进程树**，判据之外一律不碰。

## 0.1.8 — 外部改动自动同步

### 文本编辑器里保存，插件跟着变（外部改动自动同步）

以前外部改动只是**被发现**：写盘被拦下，页脚问你「重新载入 / 覆盖它」。现在不一样了 —— **在文本编辑器里保存，编辑器里立刻跟着变**，不用点任何东西。

规则只有一条，但分成了两种情形：

| 你在插件里有没有没保存的改动 | 行为 |
| --- | --- |
| 没有（编辑器是干净的） | **直接采用磁盘那一份**，不打扰你 —— 没什么可丢的，而另一种选择是让你对着一份落后三次保存的文件发呆 |
| 有（防抖还没落盘，或写失败了） | **不覆盖，弹提问**，和以前一样。你的输入永远优先 |

为此宿主和浏览器两边都改了：

- **宿主侧新增「文件改了」这个信号**：每次**读**和每次**写**都带回一个**修订号**（`size/mtime` 的形式）。浏览器比的是修订号，不是文本内容 —— 比文本有两个毛病：编辑器把同一份内容重写一遍（改行尾、重新保存）会被当成冲突，而「文件被 touch 了一下」又看不出来。
- **宿主监听样式表目录**（`fs.watch`，看的是**目录**不是单个文件：VS Code 默认「写临时文件再改名覆盖」，而改名会把文件换掉，盯着旧文件的监听从此什么都看不到 —— 这一点是实测出来的，见 `%TEMP%\css-probe\watch-editor-saves.mjs`）。一次保存往往连着来好几条通知（三次 Ctrl+S = 六条），所以宿主自己合并成一条。
- **浏览器侧每秒半问一次**（`WATCH_POLL_MS = 1500`，只在页面可见时问）。宿主那个监听是**门铃**、不是必经之路：没有它，靠轮询也一样会同步，只是慢一点。所以某个文件系统上监听不起来（网络盘、没有 inotify），功能不受影响。
- **插件自己的写不会被误认成「别人的改动」**：宿主回写修订号，浏览器把基线推到自己刚写完的那个值 —— 否则每次打字之后都会自己跟自己冲突一次。

两条**故意**的保留：

- **页面从后台切回前台时不会静默换掉你正在看的文本**（那条路径只发现、不采用）。你可能正好读到一半，而文件是你在别的窗口里改的。
- **采用磁盘副本时会清空撤销栈、关掉规则面板**：它们的坐标指的是**刚被替换掉的那段文本**，Ctrl+Z 退回一个文件里已经没有的版本，比丢掉撤销栈更糟。

## 0.1.7 — 查找替换、格式化、编辑器快捷键、变量体检与历史版本

### 查找 / 替换

表头多了一个**「查找」**按钮（Ctrl+F 等效），点开是一条**查找栏**：查找、替换为、命中计数、区分大小写、上一个 / 下一个、替换、全部替换、关闭。

- **Ctrl+F 会把编辑器里选中的那段文字当作查询**，光标同时落到栏指向的那条命中上（从表中间打开栏，给你的是眼前那条，不是文件顶部）。
- **命中在彩色层里被圈出来**，当前那一条更重；计数写作 `3 / 12`，一条都没有时整块变红写「无命中」。
- **Enter / Shift+Enter 走命中**：在查找框里、在编辑器里都行（栏打开时 Enter 不再往表里插换行 —— 刚敲完查询的那一下最可能是想跳到下一条）。
- **替换**换掉当前那条并前进一条，**全部替换**一次换完。两者都是**一次编辑**，所以 Ctrl+Z 一次就全撤回。
- 查询是**纯文本**，不是正则：`a.b` 找的就是 `a.b`。`Aa` 关掉时大小写不敏感。
- **`Aa` 按下去看得出是按下的**：品牌色文字 + 按下态底色 + 去掉描边 + 加粗（实测两态在底色 / 文字色 / 描边 / 字重四项上都不同）。第一版只带了 `aria-pressed`、没带任何样式 —— 用户一眼就报了出来：「点击之后和之前按钮没有区别，这么看有没有点击呢？」。**一个看不出状态的开关等于没有开关**，`aria-pressed` 是给读屏器的，不是给人的。
- 命中是**文本偏移**，而彩色层是转义过的标记 —— 这两套坐标只在「这一段渲染出来和原文一样长」时对得上。落不准的命中**宁可不标**（栏照旧数它、textarea 照旧选中它），也不标偏几个字符把标签切两半。大小写折叠**可能改变长度**（`İ` 折成两个码元），所以折叠串里的命中要走一张回映射表落回原表，否则那个字符之后的每一条都偏一位。两条都补了用例。
- 表头的「查找」是常驻按钮，不是收进「更多操作」：查找的价值在于**光标在文里时随手能用**，而菜单到不了光标所在的每一种状态。

> **查找栏的位置是量出来的，不是看出来的。** 栏里两个输入框加六个控件，内容固定 **610px**；设置行正常 600px、侧栏打开时 440px —— 所以第一版**在任何宽度下都换行**（实测 600 / 520 / 440 / 380 全部两行）。现在两级让步：**640px 以下隐去命中计数**（命中已经高亮，数字是冗余的），**540px 以下 `替换` / `全部替换` 只留 ↦ / ⇉**（tooltip 与 aria-label 都在，控件绝不会变成不可达）。阈值取在**仍有富余**的地方，而不是取在已经绷紧的宽度上。改完 900 / 700 / 641 / 640 / 600 / 541 / 540 / 520 / 440 / 380px 全部单行、0 溢出、没有控件被压成 0。探针：`%TEMP%\css-probe\findbar-fit.mjs`（读真实 ROW_CSS，视口宽 = 行宽）。

### 格式化

「更多操作 → 格式化」把整张表重排：**一条规则一行、每层缩进两空格、去掉空行**。

它**只动行的排布，不动一行里写的东西**：缺的分号、`{` 前少的空格都留给校验器去报，格式化器去改写声明就等于替用户改代码。花括号在字符串里不算括号（`content: "}"` 是字符串的一个字符），括号不配对的表**原样返回** —— 该报错的是校验器，把坏表重排一遍只会把坏的地方挪到别处。已经是格式化过的样子时菜单项**置灰**（点了什么都不会发生的菜单项读起来像坏了）。

### 「更多操作」的顺序

六项按**它们是什么**排，不按加进来的先后：三个文件动作（打开文件 / 导入 / 导出）连着，然后是整张表的动作（格式化 / 历史版本），**「重置」放到最底下** —— 那是唯一会毁掉工作的入口，让它离鼠标远一格、别在去别处的路上被点到。

### 编辑器有撤销了（以及 Tab、Ctrl+S）

受控的 `<textarea>` **没有可用的撤销栈**：React 每敲一个键就写一次 `value`，而这个赋值会清掉引擎自己记下的历史 —— 所以在这个编辑器里按 Ctrl+Z 一直是什么都不发生。现在插件自己记：

- **Ctrl/Cmd+Z 撤销、Ctrl/Cmd+Shift+Z 或 Ctrl+Y 重做**。连续敲键算**一步**（在自然停顿处分段），所以撤回一整段输入不用按十几次；面板改属性、拾取器插入规则这类结构性改动各自算一步。
- 撤销会连同**光标位置**一起回到当时的位置（并像插入规则那样滚到可见处）。
- **Ctrl/Cmd+S** 立即写盘，不等那 400ms 的防抖 —— 也顺手挡掉浏览器自己的保存对话框。
- **Tab 缩进两格、Shift+Tab 退两格**。补全列表打开时 Tab 仍是「采用建议」（那是它一直的行为）。

历史按**每张表**分开，最多 200 步；切换表不会把上一张表的撤销带过来。

### 规则大纲：表里有哪些规则，以及它们到底命中什么

文件栏多了一个**「大纲」**下拉：整张表的**顶层规则按顺序列出来**，点一下跳到那条规则并打开它的面板（光标落在选择器上，编辑器滚到可见处）。表越长越省事 —— 以前只能在几百行里用肉眼看。

每条后面跟着这个选择器**在当前页面上命中了几个元素**：

```
.a · 命中 2      .b · 命中 1      .gone · 无命中
```

这个数字是它另一半的价值：**命中 0 个的规则就是什么都没做的规则**，而它看起来和正常的规则一模一样。结构性路径尤其如此 —— 熬过了一次改版、没熬过下一次，然后就一直安静地躺在那儿。选择器被引擎拒绝时写「选择器无效」，计数还没算出来时留空（它在编辑器停顿之后才算，见下）。

> 往文件栏加控件有个坑，记在这里免得下次再踩：下拉用的那套外壳（`.dshCc_selectWrap`）是给规则面板的**网格单元**做的，它写着 `width:100%` —— 放进文件栏就会要整行宽度，把右边的开关挤出去（实测溢出 75px、开关文字右边缘越过栏右边缘 65px）。文件栏里的副本必须显式 `width:auto`，并跟栏内其它控件一样高（28px），而不是跟设置行的字段一样高（36px）。修完横条溢出 0px，440px 窄宽度下同样为 0。

> 菜单本身也有两个坑，同一个根子：大纲的触发按钮在**文件栏最右端**。原来的菜单一律 `left: 触发按钮的左边缘` 往右展开，于是 320px 的菜单有 250px 跑到窗口外面；而未给确定宽度时菜单会跟着内容长（条目是 `white-space:nowrap` 的选择器），长选择器把框越撑越宽。现在定位交给 `placeMenu()`：右边缘放不下就改成**靠右对齐**（向左展开），宽度取「想要的宽度」与「窗口可用宽度」的较小值，内容驱动的菜单也套一个 `max-width` 上限。菜单条目被省略号截断时，**悬停可以看全文**。

> 同一轮顺手查了**文件下拉的菜单**（它在行头，不是文件栏）：左边缘没问题（它按 `right:0` 向左展开），但它**没有高度上限**——表一多就长出屏幕底部，而且滚不动（实测 24 张表 = 968px 高的菜单，800px 窗口里 236px 在屏幕外，700px 窗口里 362px）。现在统一 `max-height:min(60vh,320px)` + 可滚动，并加了一条审计：**插件里任何写 `overflow-y:auto` 的选择器都必须声明 `max-height`**（没有上限的滚动容器就是会长出窗口的列表）。

> 滚动条不要再自己写样式了：**DSH 有一份全局样式表**统一给所有元素画滚动条（8px 宽、圆角 4px 的拇指，颜色取 `--dsh-scrollbar-thumb` / `--dsh-scrollbar-thumb-hover`）。插件里原先给菜单和规则面板写过 `scrollbar-width: thin` —— 在 Chromium 里，元素上一旦出现这种**标准属性**，该元素的 `::-webkit-scrollbar` 样式就会被忽略，于是全应用只有这两处掉回平台默认的灰条。现在这两句都去掉了，只补上一处 DSH 自己的可滚动面板也在用的**轨道内缩**（`::-webkit-scrollbar-track{margin:6px}`，菜单 / 拾取器面板 / 规则面板），否则拇指的行程顶到两端、末端会从面板的圆角外面露出去。审计（`scrollbar-width` / `scrollbar-color` 只能是 `none` 或位于 webkit 特性查询内）守着这两句不回来。

### 外部改动不再被静默覆盖

样式表是磁盘上的真文件，而「打开文件」这个动作存在的意义就是让你在**别的编辑器**里改它 —— 那么这份编辑器手里的副本就可能悄悄过期，而下一次防抖写盘会把别人的改动**直接盖掉，还不报错**。这是这一行里唯一会**丢数据**的失败方式，所以现在写盘前先读一次：内容对不上就**不写**，在页脚问你要哪一份：

- **重新载入** —— 以磁盘上的那一份为准（编辑器内容与应用中的样式一起换过去）；
- **覆盖它** —— 明确用编辑器里这份盖掉磁盘（此时才写）。

另外，窗口重新回到前台时也会检查一次（`visibilitychange`）—— 你在 VS Code 里改完切回来，问题会在你下次打字**之前**就摆出来。整条路径只有写盘前多一次读，且**写盘只走一个出口**（`writeSheet()`）：编辑器的防抖、拾取器插入、都从这里过，所以「地盘有没有动过」只在一个地方问。

### 历史版本：每次覆盖写盘前留一份，随时回得去

防抖写盘意味着**每一次停顿都在覆盖上一次的内容**。文件本身是不可变的过去 —— 一旦写完，前一份就没了。所以现在每次覆盖之前，host 先把**被替换掉的那份**存下来：

```
~/.dsh/custom-css/.history/custom.css/1758793204123.css
```

「更多操作 → 历史版本」列出当前这张表的全部版本（**本地时间 + 字节数**，时间倒序），点一条就把它**恢复进编辑器并落盘**。刚才那份不会被牺牲掉：恢复前同样先留档，所以**恢复本身也是可逆的** —— 点错了再点回去就行。

几条刻意的取舍：

- **内容与最新一份相同时不留档**。自动保存会反复写同一份内容，每次都留的话，历史里很快全是同一份表的副本，真正有价值的版本被挤出去 —— 留档按**内容**判断，不按次数。
- **每张表最多 20 份**，超出按时间从旧的开始剪。一个写一天的样式表不能无上限长下去。
- **恢复会清空编辑器的撤销栈**（就是上面那套 Ctrl+Z 的历史）。不清的话，Ctrl+Z 会把用户刚刚决定离开的那一版又变回来 —— 恢复是一个明确的选择，不是一次输入。
- **恢复走 host 写盘**，写完页面重读一次表，而不是把请求里的文本直接塞进编辑器：恢复是一次写盘，编辑器该显示的是盘上真正的东西。
- 快照放在 `.history/` 子目录里，而 `/list` 只读顶层 —— **快照永远不会被当成一张样式表列出来**。

### 顺手修掉的两个缺陷

**菜单变成了空壳。** 重构「更多操作」的条目时，菜单那层 `createElement` 的 children 参数漏了逗号 —— 菜单节点本身还在，里面是空的。这个缺陷测试**没抓住**：那条断言查的是 `renderedText`（渲染过程中收集到的文本），而文本是在按钮被创建时就收集的，跟这些按钮有没有真的挂到菜单上无关。现在这条断言改成**按节点找按钮**（`buttonWith`），菜单里没有 button 节点就会红。

**同一类问题的第二个面**：`rememberEdit`（撤销栈）只在**真的发生改动**时才记录。一次「内容与盘上完全相同」的编辑不会留下撤销点 —— 写测试时正好踩到这点：那次编辑的内容和磁盘一模一样，于是「恢复后 Ctrl+Z 不该撤销恢复」这条断言成立的原因是**栈本来就是空的**，而不是恢复清过它。测试改成先做一次真正改变内容的编辑（并让它落盘），断言才真的在测这件事。

### 变量体检：不起作用的声明现在会说话

自定义属性是**继承**的，所以「这个变量有没有值」是个关于**元素**的问题，不是关于文本的问题。别的插件在自己的卡片上定义了 `--dsl-terminal-line-height`，那么在卡片外面用它的声明会被引擎**整条丢掉**——没有报错，没有提示，什么都没发生。

现在编辑器在停顿之后（600ms）**去问文档**：把规则的选择器在页面里匹配一遍，再从命中的元素上读这个属性。读不到就进状态行的问题列表，和括号、值错误放在一起：

```
第 49 行 变量 --dsl-g-shadow-card 在这条规则命中的元素上读不到
```

两种情况**故意不报**：规则没命中任何元素（没得问 —— 样式表本来就会写那些当前不在屏幕上的状态），以及 `var(--x, 备用值)`（写了备用值就没有可丢的东西）。一个会为这两种情况乱叫的检查，一天之内就会被人关掉，然后什么都抓不到。

（这条是拿一份真实在用的 764 行样式表验的：它有两处用了另一个插件的卡片变量，正是这条检查报出来的。）

## 0.1.6 — 元素拾取器

在界面里**点选一个元素**，自动给出一条跨版本尽量稳的选择器，并把该元素实际生效的 DSH 令牌一起摆出来。

这一版还修了一批缺陷，其中几条是复审与用户实测报出来的：拾取之后状态栏卡在「保存中」、复用已有规则时面板不打开、坏选择器因为「查不出来」而胜出、点击后高亮框左上角多一条横线、插入规则后编辑器不滚动、以及令牌建议会给出别种类的令牌。下面每一节都写了根因，并给出实测量到的数据（这一版的结论基本都不用「看起来」来支撑）。

### 为什么选择器要「挑」而不是「算」

设计系统用的是 CSS Modules：`_card_1fywu_26`、`_boot_1fywu_3` —— 哈希随构建变化。所以拾取器**不会**输出 `._card_1fywu_26`（下次 DSH 升级就失效），而是按「什么能活下来」排序候选：

| 优先级 | 类型 | 例 | 说明 |
| --- | --- | --- | --- |
| 1 | `[data-*]` 钩子 | `[data-dockkit-strip]` | DSH 自己也在用它查询，跨版本最稳 |
| 2 | `[aria-label]` | `div[aria-label="QQ 音乐"]` | 语义层，不会随便改 |
| 3 | 手写语义类名 | `div.dsh-music-qq-head` | 插件作者自己维护 |
| 4 | 哈希容错 | `div[class*="_card_"]` | 不接哈希，但依赖命名习惯 |
| 5 | 结构路径 | `body > div:nth-child(2)` | 兜底，界面改版会失效 |

**冲突不是隐藏而是标注**：命中多个元素的候选会被降权并显示「N 个命中」，但**不会**掉到结构兜底之后 —— 一次性给一族元素写样式是正当需求，而结构路径注定会在升级时断。唯一性决定默认选项，证据层级只用来打破平局。

### 交互

- 表头动作组新增**「拾取元素」**（在「打开文件」左侧，置灰条件与文件模式一致）。
- 点它 → 设置面板自己让路：优先找 `[role="dialog"][aria-modal="true"]`，找不到就向上找覆盖视口的祖先，把它 `pointer-events:none` + 降透明度。**高亮框与提示条挂在 document 上**，否则会被一起降掉。（这条「让路」的做法后来被推翻了 —— 见下面「拾取器改成浮动面板（架构修正）」一节。）
- 浮层跟随指针，实时显示 `标签 · 宽×高`；Esc 或「取消拾取」退出；卸载时自动复原（属性、监听、浮层都不会残留）。
- 点选后弹出候选卡：三条候选（可切换）+ 该元素**实际解析到**的令牌 chips + 「插入规则」。
- 「插入规则」：表里已有同名规则就**打开它**（不重复追加），否则就地追加一条空规则并把选中的令牌写成声明、光标停在新块内。

### 令牌反查

令牌表不是手写的颜色表，而是**从 CSSOM 现读**（文档里各样式表中 `--*` 自定义属性及其取值），所以新增令牌、主题改值都会自动跟上。拾取时把元素 `getComputedStyle` 的结果反查回令牌名，插入的是 `var(--dsw-alias-bg-layer-1)` 而不是它解析出的 `#101010`。

这张表后来加了两条约束（见「令牌建议不再给出『种类不符』或『别处定义』的令牌」一节）：只有**定义规则能匹配该元素或其祖先**的令牌才算数，而且只有**名字读起来像该属性**的才会被建议 —— 否则一个 22px 的圆角会被建议成 `--dsl-terminal-line-height`。

### 动作按钮折起来了

表头原来有六个控件（文件下拉 + 拾取元素 + 打开文件 + 导入 + 导出 + 重置），一行放不下。现在只留两个常驻入口 + 一个菜单：

- **拾取元素**（新功能留在手边）、**文件下拉**（不变）、**更多操作** → 打开文件 / 导入 / 导出 / 重置。

菜单用的就是文件下拉那套 chrome（`.dshCc_trigger` + `.dshCc_menu` + `.dshCc_menuItem`，而它们是从 DSH 自己的 settings-row 下拉逐值抄来的），所以和界面里其它下拉一致；新增的只有 11px 的单行说明与危险项的 `--dsw-alias-state-error-primary`。

### 拾取器：能退出，且能调层级

- **修掉一个会让界面看起来「死掉」的缺陷**：武装状态下设置面被 `pointer-events:none`，而那条提示条自己也是 `pointer-events:none` —— 除了按 Esc，用户没有任何可点的出口。现在工具栏**可点**：上一级 / 下一级 / 上一个 / 下一个 / 取消；点击工具栏不会被当成一次拾取（否则点「取消」反而会选中工具栏本身）。
- **层级调整（AdGuard 那种手感）**：`↑`/`W` 上一级、`↓`/`S` 下一级、`←`/`A` 上一个同级、`→`/`D` 下一个同级，`Enter` 确认，`Esc` 取消。移动鼠标会重新选中指针下的元素——键盘抬高或降低层级之后，鼠标是最快的退回方式。工具栏实时显示高亮位置（如 `section > div.dsh-music-qq-head · 320×180`）。
- **一次点击不再可能什么都不发生**：真实点击前总有 `pointermove`，但触屏与合成事件没有——这种情况下回落到指针下的元素，而不是静默忽略。

### 拾取器改成浮动面板（架构修正）

反馈两条：拾取时**点不了设置页的关闭按钮**、**不能去别的页面拾取**。这不是 bug 而是设计错误：我把整个设置面 `pointer-events:none` 来给拾取让路，于是设置页的一切交互都被按死；而且会话挂在行组件上——关掉设置页，它就随组件卸载而死。

- **会话移到模块级**，不再依附设置行：关掉设置页（这是去界面其它地方的唯一办法）后拾取照常工作；**行组件卸载后**用它拾取依然能写进样式表（有测试守着）。
- **不再按死任何东西**：设置面不动、不拦截点击（`clickToPick: false`），拾取靠**悬停选择**，Enter 或面板上的「插入规则」确认。真实浏览器实测：拾取前后设置面的 `pointer-events` 都是 `auto`，属性一个没变。
- **浮动面板**（挂在 `body` 上，独立于设置页）：实时显示高亮元素的描述与尺寸、三个候选选择器（可切换）、该元素解析到的令牌 chips（可关掉不想写的）、层级滑杆、上一级 / 下一级、快捷键悬浮提示、插入与取消。
- 高亮框 `pointer-events:none`，不挡住它标出的元素（实测 `elementFromPoint` 仍返回目标）。

### 按钮和界面一致了

反馈是「按钮样式还是不对」。去查 DSH 自己的 CSS 才看清：它的按钮是 `_button_cfgyt_4` —— **无边框 + 18px 圆角 + 透明底 + 主色文字**；而 `.dshCc_btn` 是我自己发明的（8px 圆角 + 1px 边框 + 次色文字），所以和界面里任何控件都对不上。现在逐值对齐 DSH 的按钮与关闭按钮（后者 28×28 / 8px 圆角），只保留「选择器用填充胶囊」这一处差异来区分**当前值**与**动作**——三个控件的实测几何：`custom.css` 18px 填充、`拾取元素` 与 `更多操作` 18px 透明。

### 拾取之后状态栏不再卡在「保存中」

打完字 **400 ms 内**点选一个元素，状态栏会一直停在「保存中」，直到你下一次敲键盘 —— 而样式表其实早就写进磁盘了。

原因是「接管」只做了一半：拾取器写盘前会取消编辑器那条防抖中的写（否则过期的文本会盖回旧内容、把这次拾取悄悄丢掉），可取消掉的正是状态栏在等的那次写，而没有任何人接替它回报结果。现在这条交接是**双向契约**：

- `takeOver()` 取消挂起写，并让被取代的那次写**失去发言权** —— 已经发到宿主上的请求取消不了，它的响应仍会回来，不这样做它会在拾取自己的写还在途中时就宣布「已保存」。
- `settled(ok)` 在拾取这次写落盘后回报，**只有成功**才说「已保存」；失败时照旧留下「保存失败：…」而不是假装完成。

### 拾取已有规则时，面板当场打开

拾取一个**表里已经有规则**的元素，「插入规则」的承诺是「打开它」—— 但打开这一步迟迟不发生：面板不出现，而且在**下一次无关编辑**时才突然跳到那条规则、顺手把光标也挪过去。

原因和上一条同源，是回跳通道的通知方式：拾取器把「要打开的规则」留在模块级变量里，行组件靠**监听 `state.css` 变化**去消费它。复用已有规则**不写任何文本**，于是那次变化根本没发生 —— 消费者不重跑，请求就一直挂着，最后由一次无关的编辑把它消费掉。

修法是把这条通道的通知变成**它自己的状态**（`openRequest` 计数器，由拾取器在交接后递增）：行组件的消费者同时观察它与 `state.css`。顺带纠正同一处的一个错：光标原本也被请求放进**已有规则**里，而该规则不是这次手势创建的 —— 把光标丢进用户没打算编辑的文本里是惊吓而不是帮忙，现在光标只属于新追加的空规则（与本节开头写的「否则…光标停在新块内」一致）。

### 拾取器不会再把「查不出来」当成「只有一个」

选择器的好坏由**命中数**排序：只命中一个元素的候选才算「唯一」，可以当默认。问题是命中数靠 `querySelectorAll` 探针问出来，而探针**抛错时旧代码返回 1** —— 探针越查不出来（选择器越可疑），候选反而越像「唯一」，最后拿到默认位并写进样式表。

最典型的触发方式是**属性值里的换行**：`aria-label` 是自由文本，它可以带 `\n`，而 CSS 字符串里的裸换行会**结束这个字符串** —— 生成出来的选择器根本不是选择器（`div[aria-label="第一行` 到这里就断了）。浏览器对它一律抛 `SyntaxError`，于是那条坏选择器因为「查不出来」而胜出、被写进表里，然后什么都不生效。两处都修了：

- `cssString` 现在把换行与其他控制字符按 CSS 的规则转义（`\a ` 这种十六进制转义，后随空格用来收尾），一次扫描完成，不会把新加的反斜杠再转义一遍。
- `probeMatches` 把**未知**与**一个**区分开：探针抛错、或压根没有探针时返回「未知」，候选按「非唯一」罚位，永远不会因为查不出来而占优。面板上如实写「命中数未知」，而不是把失败打扮成一个数字。
- 顺带：**未知也不是 0**。「查不出来」和「一个都匹配不上」是两回事，后者是结论，前者是没结论。

### 点击选中元素后，高亮框左上方不再多一条横线

点选一个元素之后，它的高亮框**左上方会多出一条很细的横线**（12×2 像素）。它只在点击之后出现 —— 因为它是**锁定框**画出来的。

高亮框有两个：浅色的一直跟着指针（上面那条标签写着「宽×高」），实心的那个**只在你点击之后**出现、标住被选中的元素。标签是一条 CSS 规则：`content: attr(data-label)`。标签内容是拾取时写进 `data-label` 的，而**锁定框没有这个属性** —— 关键就在这里：`attr()` 取一个不存在的属性得到的是**空字符串，而不是 `none`**，所以伪元素照样生成；又因为伪元素的 `box-sizing` 是 `content-box`（它不继承），那个空盒子缩成了它自己的内边距：**宽 6+6、高 1+1 = 12×2**，位置正是 `left:0; top:-22px` —— 高亮框左上角的正上方一格。于是它就成了一条莫名其妙的横线。

实测（无头 Edge，量的是真实像素而不是样式声明）：修之前，锁定框左上方 24 个品牌色像素、外框恰是 12×2；修之后 0 个，而真标签（54×20）一点没变。

修法是一行：把标签规则**用属性做门**（`.dshCc_pickBox[data-label]::after`）—— 没有标签的框，规则不适用，伪元素根本不存在。测试里加了一条**样式审计**守着它：把拾取器自己的样式表逐条与「还没被打上标签的悬停框」「锁定框」两个元素比对，任何一条匹配得上、又用 `attr()` 去取它们没有的属性的规则都会被点名；同时断言「有标签的框照样画得出标签」，免得「什么都不画」也能过。

### 「插入规则」之后，编辑器会滚到新规则

拾取一个元素点「插入规则」，规则确实被追加到了表尾 —— 但**编辑器停在原地**，看起来像什么都没发生；表越长越明显（60 行的表里，光标落在第 62 行，而视野还停在第 1 行）。

原因有两层，都补上了：

- **没人把光标那一行带进视野**。之前只做了 `focus()` + `setSelectionRange()`：浏览器**确实会**因此把 textarea 滚到光标处（实测过），但这个编辑器把文字画了两遍 —— 透明 textarea 负责光标与滚动，**彩色层与行号 gutter 是靠 `transform` 跟着 textarea 的 `scrollTop` 走的**，那次滚动既然是我们自己造成的，就该由我们同步。现在显式把光标行滚进可视区（上方留两行余量，只在真的看不见时才动，所以不会和浏览器打架），并**在同一次就把两层同步过去**，不再依赖 `scroll` 事件。
- **文本可能还没到**。拾取器能在设置页关闭时继续工作，行组件重新挂载时样式表可能还在读 —— 这时把光标塞进一个**空** textarea 会被钳到 0，视野一动不动。现在光标请求会**等到文本里真的有这个偏移**才消费。

### 令牌建议不再给出「种类不符」或「别处定义」的令牌

拾取器会把元素实际生效的颜色反查成设计令牌，插入 `var(--dsw-…)` 而不是字面值。那张「值 → 令牌名」的表**只以值作键**，于是不同用途的令牌会挤在同一个键上 —— DSH 里 `--dsl-terminal-line-height: 22px` 就挨在所有 `22px` 旁边。旧代码在找不到「名字读起来像这个属性」的令牌时会**回退到第一个**，实测结果是：为一个 `border-radius: 22px` 的元素建议 `border-radius: var(--dsl-terminal-line-height)`。这条规则不但语义莫名其妙，而且**在用户样式表的作用域里根本解析不出来**（该令牌只定义在终端卡片自己的类上）。

现在两处都收紧了：

- **种类**：名字读起来不像这个属性就**不建议**（宁可什么都不给，也不给一个解析不出来的）—— 只保留能与属性名对上的那几类（`bg` / `label`·`text`·`fg` / `border` / `corner`·`radius`）。
- **作用域**：一个令牌只有当**定义它的规则能匹配该元素或其祖先**时才算数 —— 别的组件卡片内部的定义，对它外头的元素不成立。

实测（无头 Edge，拿 DSH 真实的编译后样式表）：修之前 `border-radius: 22px` 得到 `--dsl-terminal-line-height`，修之后不再给出任何令牌；而作用域与种类都对的令牌照旧会被建议（有断言守着这一半，免得「什么都不给」也算通过）。

### 测试

- 夹具新增一个**够用的 DOM 桩**（节点树、属性、几何、监听器），于是拾取器能在测试床里**端到端**跑：点按钮 → 武装 → 悬停报告尺寸 → 点击解析 → 候选排序 → 插入规则。
- 覆盖：冲突的 aria 候选被降权且不默认选中、哈希类名不给过、插入带令牌而非字面值、**已有规则被复用而不是重复追加**、Esc 之后监听与浮层都被清干净。
- 写通道的交接由两条用例守着：打字后 400 ms 内拾取，断言挂起的那次写被取消、**没有单独落盘**、表里是拾取后的内容、状态栏当场就是「已保存」；以及已发出的那次写先回来时，断言它不能替还在途中的拾取宣布「已保存」。两条都反向打过补丁确认会变红。
- 回跳通道同样两条：拾取**已有**规则时断言面板**当场**打开、表没被再写一次、且没有光标被丢进那条已有规则；以及交接当次就被消费掉，**下一次无关编辑既不开陈旧的面板、也不挪光标**（反向打补丁：去掉通知 → 第一条红；恢复「把光标放进已有规则」→ 第二条红）。
- 选择器两条：属性值里的换行必须转义成 `\a `、**裸换行一个字都不许进样式表**；以及一个「探针查不出来」的候选不许拿下默认位（后者反向打补丁后能复现：坏候选被写进表里）。面板的「命中数未知」也有断言守着 —— 打回旧写法时它会显示 `undefined 个命中`。
- **测试床的 DOM 桩不再忽略入参**（这一条是测试保真度，不是产品缺陷）：`elementFromPoint` 以前无视坐标、`querySelectorAll` 以前无视选择器，于是「指针命中哪个元素」「这个选择器命中几个」在测试里等于常量 —— 断言看着在守行为，其实只守了「桩被配置成了什么」。现在测试床自带一个小选择器匹配器（标签 / `.类` / `#id` / `[attr]` / `[attr*=…]` / `:nth-child()` / 后代与子选择器 / 逗号分组，且**拒绝字符串里的裸换行**，与浏览器的分词一致）与按矩形命中测试（文档序里最后一个包含该点的节点，正是浏览器的叠放规则），指针与选择器都由用例真正给进去。
  - 把这两处改回常量桩，套件**立刻变红**（第一处就死在「hovering previews the element under the pointer」）—— 这就是它以前守不住东西的证据。
  - 顺带露出两个旧洞里没有记下的：`tests/loader-smoke.cjs` 的「已有规则不重复追加」用例**从来没有真正提交过**（只 dispatch 了 `pointermove` + `Enter`，而 `Enter` 确认的是**点击锁定**的目标，悬停不算），因此它的断言一直空转；现在它先断言「悬停 + Enter 什么都不做」，再点击 + Enter 走真流程。另外「同名 aria 标签」从桩里编的 3 个变成树里**真的两个**元素（新增一个同标签的兄弟节点）。
- `tests/host-api-smoke.mjs`：`fs.symlink` 在无权限的机器上抛错时，用例以前**静默跳过**，而汇总仍打印「symlink refusal verified」。现在跳过会显式打印 `SKIPPED`、并从汇总的「已覆盖」里摘掉该项（非 Windows 或非 EPERM 的失败照旧直接抛）。列表上限那条断言原本是「回复与自己的排序副本比较」，它并非恒真（把宿主排序反写成降序，套件会红），但它说不出**回来了哪些文件**、被固定的活动文件落在哪；现在改成对照独立期望值（由用例自己写下的 200 个名字 + 被固定的那个）。
- **拾取器样式审计**（守住上面那条横线）：遍历拾取器自己的样式表，与「悬停框（未打标签）」「锁定框」两个元素逐条比对 —— 匹配得上、又用 `attr()` 取它们没有的属性的规则一律点名；并断言有标签的框照样画得出标签，防止「把规则删光」也算通过。反向打补丁（去掉 `[data-label]` 门）会红，并报出具体规则与两个变体。
- 编辑器滚动：60 行的表里拾取并插入规则，断言**光标所在行落在滚动窗口内**、`scrollTop` 真的动了、彩色层与 gutter 的 `transform` 与之一致（反向打补丁会红在「光标行在窗口内」上，报出 `line 62 at 1178, port 0..140`）。测试床的节点桩也补上了 `matches`，选择器匹配器补上了 `:root`。
- 令牌建议两条：一个与属性种类不符的令牌（`--dsl-terminal-line-height` 之于 `border-radius`）**不许出现**；一个定义在别的组件类上的令牌，对作用域外的元素**不许出现**；同时断言作用域与种类都对的令牌**照样出现**。两处反向打补丁各打红各自那条。
- 这条横线本身是**真机量出来的**：`%TEMP%\css-probe\` 下新增 `stray-label-line.mjs`（同一份 ROW_CSS 渲染两种变体、按像素数锁定框左上方的品牌色像素）与 `empty-label-isolated.mjs`（把「缺属性的 `attr()` 到底画不画」单独隔离）。两次第一次尝试都被自己证伪过：一开始只看计算样式（伪元素的 `width/height` 报 0，看着像没画），后来像素扫描又把**同色的框边**算进去了 —— 量到的东西必须能解释得通才算数。
- **死代码清理 + 孤儿规则审计**：删掉 11 条「有规则、没有任何代码去命中」的 CSS（`.dshCc_danger` 两条、`.dshCc_panelHint`、`.dshCc_pickBarInfo`、行内结果卡时代留下的 7 条 `.dshCc_picked*`），以及只定义、从未被调用的 `surfaceToMoveAside`。加了一条审计守着它：把样式表里出现的每个类与**样式表之外**的源码比对，任何无人命中的类都会被点名（它同时也是"类名拼错"的探测器 —— 那种错误同样不可见）。
  - 审计必须认得出**拼接出来的类名**：`'dshCc_tok' + kindOf()` 会拼出 `dshCc_tokProp` / `dshCc_tokVal`。第一版审计把这两个报成了死代码 —— **假警报**，而且是差点删掉正在用的语法配色；现在它把这类前缀也算作"被命中"。
- 另有一项**真浏览器 DOM 管线验证**（本地仿真页 + 真 Edge）：确认让路后面板后面的元素确实点得到、我们自己的高亮框不会挡住它（`pointer-events:none` + `position:fixed`）、会话用真实几何报告目标。
## 0.1.5 — 补全路径与最后一圈字符串盲区

0.1.4 把「字符串不是结构」铺到了解析器、校验器、高亮器、读取器和写入器。复审指出**补全这一路还没铺**，顺着这条线又扫了一遍，最后三处盲区是同一个病根。

### 补全路径（用户直接撞得上）

- **`insideBlock` 裸数花括号**：`content: "}"` 是清浮动的标准写法，字符串里的那个 `}` 被当成真的闭括号，于是**它之后的所有补全静默失灵**（在规则里打 `color: ` 什么都不弹）。触发后用户只会觉得「补全在某些规则之后就不灵了」，很难自己定位。现在与其余扫描器共用同一次结构遍历。
- **`completionsFor` 用 `lastIndexOf(';')` 找声明起点**：字符串里的分号被当成声明分隔符，光标所在的那条「当前声明」切片错位 —— 该给值的地方弹出**属性名列表**，或者干脆什么都不给。现在按结构找最后一条真实声明边界。
- **未闭合的字符串或注释里不再补全**：光标在值里面的字符串中间时，属性名列表纯属噪音。
- **「转义引号结尾」的漏判**：判断字符串是否闭合的规则原本是「停在末尾、且末字符不是引号」——`"ab\"` 这种末尾恰好是**转义**引号的文本会被判成已闭合，补全于是从字符串内部弹出值列表。现在改由 `scanString` / `scanComment` 自己报告 `closed`：**是否闭合只由做扫描的那一处决定**，调用方不再重新推断。顺带把十处手写的「注释到哪结束」收进 `scanComment`（此前每处都在 `indexOf('*/')` 上重新推导一遍）。

### 最后三处盲区（同一病根）

- **normaliser 往注释里插分号**：`normaliseBlock` 的正则扫整段 body，注释里「上一行以字结尾、下一行看起来像属性名」会被当成缺分号边界 —— 于是改任意一条声明都会**改到注释的文字**（注释里凭空多一个 `;`，对页面无害，但那是用户没写的东西）。现在只处理字符串、注释、`url()`、嵌套块之间的结构间隙。
- **删除声明时把字符串当注释复活**：`content: "/* x */"` 被删掉后，字符串内容被当作「与它同块的注释」保留下来，样式表里凭空出现一条注释。现在只保留真实注释。
- **`color/* x */: red` 被判成两条无法解析的声明**：注释是词法空白，不是语句边界。校验器现在把注释按等长空白并入缓冲区。

### 测试床（评审指出的两个偏差）

- `useEffect` 现在**认 deps**：给了数组只在某项变化时重跑，不给数组则每次提交都跑 —— 与 React 一致。此前每次渲染都会重跑所有 effect，测试可能在依赖 React 根本不会产生的行为。
- 「新一轮渲染」的信号不再假设 `useSyncExternalStore` 是第一个 hook：**任何类型**的第一个 hook 都会标记新的一轮。行内 hook 顺序是它自己的事，在它前面加一个 hook 就会静默错位。

### 测试

- 新增 7 条用例：`content: "}"` 之后补全仍然工作、字符串里的分号不当作声明边界、未闭合字符串里不补全、转义引号结尾仍算未闭合（且**正常闭合的字符串不会连带压掉它后面的补全**，避免修成单向门）、属性名与冒号之间的注释合法、注释不被当成缺分号边界、删除声明不复活字符串内容。每条都用「先把补丁反向打回去、确认测试会红」验证过。
- 复审之外的独立探针又跑了一遍：20 条刁钻输入、真实样式表的 251 次声明写回往返（0 报错、结构不变量全成立）。

## 0.1.4 — 写入器、扫描器与宿主接口修复

外部评审提了四条，逐条到代码里核对过 —— **全部属实**，已修；并补上了它们赖以躲过测试的盲区。

### 会改坏用户样式表的两条（优先）

- **写入偏移错位**：`writeDeclaration` 的快速路径在 `normaliseBlock` **之后**用规范化后的偏移去切**原始**文本。规范化会插入 `;` 让 body 变长，偏移因此整体前移，切掉的区间变成目标声明**前面**的一段字符 —— 结果不是写错值，而是把后面的内容（包括收尾的 `}`）吃掉，样式表结构直接断。触发条件是「块内有一条缺分号的声明排在目标之前」。现在整条路径只在**规范化后的 body** 上算偏移并把该 body 写回，写入时的规范化成为唯一口径。
- **注释让声明认不出来**：分块按 `;` 切、正则要求块首就是属性名，`/* note */ display: block` 这种同处一块的写法块首是 `/`，匹配失败 → 原声明没被替换、又追加一条 → 出现重复声明（页面表现随之改变）。现在分块前把注释**按长度空白化**再匹配（注释本身仍留在原文里）；被替换的声明若与注释同块，注释保留为独立一行。
- 顺带修了读数侧的不对称：`readDeclarations` 此前**不做**规范化，于是「缺分号的块」在面板里显示为**一条声明都没有**（写侧会规范化，两侧口径不一致，也正因如此上一条 bug 在 UI 上难以触达）。现在两侧都走 `normaliseBlock`。

### 状态与交互两条

- **「已保存」会说假话**：定时器已触发后 `clearTimeout` 无效，旧的 `flushSave().then(setSaved(true))` 会在用户又输入（`setSaved(false)`）之后把状态改回「已保存」，直到新写回完成为止。现在每次编辑递增 `saveEpoch`，写回完成时只有**自己那一代**才允许改状态。
- **清空选择器后无法恢复**：面板靠选择器定位规则，选择器被改成空串后规则不可解析 → `openRule` 为 null → 面板连同它的输入框一起消失，代码区留下一个没有选择器的 `{ … }` 且点无可点，只能去 textarea 手动补。现在输入框保留**本地草稿**（清空时显示为空，但**不把空选择器写进样式表**），失焦或回车后回落到已提交的选择器。

### 测试

评审指出根因是测试盲区：假 React 的 `useEffect` 是空实现、`setTimeout` 直接返回 0，所以 debounce、`flushSave`、卸载 flush 与 `saved` 状态机**从未真正执行**。测试床已升级：

- `useEffect` 记录回调与 cleanup，可按需执行或模拟卸载（`runEffects` / `unmount`）
- `setTimeout` 变成本地队列，测试用 `runTimers()` 手动触发，`clearTimeout` 会标记取消
- 新增四个复现用例：快速路径不吞 `}`（且缺分号被补上）、注释下的声明只被替换一次且注释保留、慢写回不冒充「已保存」、清空选择器后面板仍在且表中选择器不变

### 自查：扫描器要认得字符串

- **四个扫描器此前都不认字符串**：`parseRules`、`validateCss`、`highlightCss` 与声明分块都按裸字符判断结构，于是 `content: "}"` 里的括号被当成真括号（校验报「第 1 行 多余的 }」，规则偏移全部往后错位、点到的是别的块），`url("data:…;base64,…")` 里的分号被当成声明分隔符（值被截成两半）。现在共用 `skipString` / `blankComments` / `splitDeclarations`，四处口径一致。
- **测试床自己也在骗人**：`hookSlots` 跨 `boot()` 复用，新实例拿到的是上一个实例的 `useRef` 对象和**残留 cleanup**，于是「卸载时补齐未保存的编辑」这条用例真正执行的是**上一个 boot 的 cleanup**（它读共享 ref 拿到新文本、却用旧 sandbox 的 fetch 发写请求），断言必然数到 0 次写入。现在每个 boot 重置 hook 槽，新提交的 effect 会清掉旧 cleanup，用例才跑在它自己的组件上。

### 二轮：两路对抗式代码复审

客户端 10 条、宿主与流水线 14 条，逐条到代码里手工复核（每条修复都用「把补丁反向打回去、确认测试变红」验证过测试有效性）。

**会改坏用户样式表的（优先）**

- **`url()` 不算不透明**：未加引号的 `url(data:image/svg+xml;charset=utf8,%3Csvg/%3E)` 里的分号被当作声明分隔符 —— 面板显示半截值，校验对完全合法的写法报「无法解析的声明」，慢路径重建时还会把碎块焊成一条废声明。现在 `url(...)`（引号内引号外）整体跳过。
- **嵌套块不是声明区**：CSS 嵌套（`.a{ color: red; &:hover{ … } }`）的内层声明会被面板当成外层声明列出来，删掉那一行会把内层的 `}` 一起带走 → 大括号失衡、浏览器从此丢弃后面所有规则。现在分块遇到顶层 `{` 就把整个嵌套块切成不透明的一块，面板只列外层声明。
- **`@import "x";` 之后的第一条规则点不动**：前奏缓冲没在顶层 `;` 处收尾 —— `@font-face` 被当成普通规则判（对合法描述符报错），下一条规则的选择器里仍然带着 `@import`、以 `@` 开头被当作 at-rule 跳过（编辑器里点不动、面板打不开）。校验与解析现在都按语句边界收尾。
- **未闭合的块少一个字符**：`.a { color: red`（正在输入时就是这个状态）的 body 末尾被判成文本末字符，面板显示 `re`，写回还把那漏掉的字符粘到新值后面变成废值。现在找不到闭合括号时 body 一直到文本末尾。
- **同名规则会绑错面板**：两张 `.a` 的样式表里点第二条，面板（以及之后每一次写入）都落在第一条上，且看不出任何异常。现在面板按**规则序号**记住自己属于哪一条。
- **补全与光标脱节**：补全列表不会因为「在同一个 textarea 里点了一下」而关闭，按回车时用**旧单词**配**新光标**去切字符串 → 直接删掉几个字符、再把补全插到错的位置，样式表被改坏且无声。现在接受补全前按当前光标重算单词，对不上就丢弃。
- **伪类选择器点不动**：高亮按标点切分，`.a:hover` 被拆成 `.a` / `:` / `hover`，而只有「正好结束于选择器末尾」的那一段是可点区域 → 规则名点不动、`hover` 反而能点。现在选择器范围内每一段都是该规则的可点区域。
- **冒号后的注释会被删掉**：值是从「注释已空白化」的副本里读的，面板显示的是文件里并不存在的文本；写回时头部正则又匹配不到冒号后的注释，注释静默消失。现在属性名仍用空白化副本匹配，**值取自原文**。
- **写失败也报「已保存」**：写回失败时状态行会同时出现「已保存」和它的失败提示；随后切换文件还会顺手把失败提示清掉，丢了编辑却什么都不显示。现在只有写成功才允许改状态，切换 / 导入不再抹掉失败提示。

**宿主、安全与流水线**

- **超限请求体会销毁连接**：提前 `return` 会触发 `for await` 迭代器的 `return()`，Node 据此 tear down 请求 —— 浏览器看到的是网络错误（`Failed to fetch`）而不是 400。现在读满即丢弃并继续排空，回包仍落在活着的 socket 上。
- **`active.json` 改为临时文件 + rename**：直接写会先截断，中途断电留下的半截文件会被读成「没有当前文件、没有关闭项」，下一次改动就把这个空状态固化；改动也改为**串行执行**，两个标签页同时开关不再互相覆盖。
- **幽灵关闭项**：指向已删除文件的关闭项在每次写入时清理，因此「关掉 → 删文件 → 重建同名文件」不会再一出生就是关闭状态；`/create` / `/import` 之后该文件是启用状态。
- **文件名判定统一为一个 `isSheetName`**（长度上限、`..`、斜杠、Windows 设备名）：此前目录扫描比路由宽松 —— 手工放进目录的超长文件名会被列出、却在每条路由上都 400（`切换失败：bad-name`），`nul.css` 这类设备名甚至能「写入成功」但什么也没落盘。现在「列出来的名字一定可用」有一条断言盯着。
- **符号链接拒绝**：字面量包含性校验看不穿软链，目录内一个指向外部的 `link.css` 就能让写入落到目录之外、让读取返回非样式表文件。读、写、创建、导入、切换、打开之前都先 `lstat`。
- **「名字非法」与「文件不存在」分开**：`/active`、`/open` 缺文件现在是 404 `not-found`（以前是 400 `bad-name`，客户端于是显示「切换失败：bad-name」）。
- **`/list` 的上限不会吞掉当前文件**：200 个文件的上限之外仍**必定列出当前文件**，否则行会退回到列表第一个、每次打开页面都悄悄换掉用户选的样式表。
- **发布流水线**：新增「tag 必须等于 `package.json` 版本」的门禁（不一致时：tag 落后会 npm 报错而 Release 建不出来，版本超前则 registry 与 Release 各说一个版本）；预发布版本改用 `--tag next` 发布，不再把 `latest` 指给候选版。
- README 里指向 `.github/workflows/ci.yml` 的相对链接在 npm 页面上是死链（`files` 不含 `.github/`），改为仓库绝对地址。

**测试**

- 客户端新增用例：不透明 `url()`、嵌套块、`@import` 语句边界、未闭合块、同名规则面板绑定、补全与光标一致性、伪类选择器整体可点、冒号后注释保留、写失败不冒充「已保存」。
- 宿主新增：`/active` 的三条路径（命中 / 缺失 / 保留开关）、每条 POST 路由的穿越拒绝、Windows 设备名、错误方法、超限请求体（并确认文件未被改动、连接随后仍可用）、围栏拦住写请求、`active.json` 损坏后的恢复、删文件后关闭项清理、列表上限不吞当前文件、符号链接拒绝，以及「列表里每个文件都能读」的一致性断言。

## 0.1.3 — 编辑器面板重做 · 已发布 2026-09-13

这一版把「设置 → 通用 → 自定义 CSS」这一行的形态定下来：一个容器、三段布局（表头 / 正文 / 状态行），属性面板移到代码下方并以网格铺开，同时补上 112 条属性字典与简写属性的分量输入。

### 编辑器行

- 新增**文件条**：`#` 图标 + 文件名 + `CSS` 徽章 + 状态点 + **开关胶囊**。
- **开关语义是临时停用当前样式表**：样式不再注入页面，而文件内容、当前选择、校验状态全都不动。状态写进 `active.json` 的 `disabled` 列表，多个浏览器与重启后表现一致；host 半新增 `POST /toggle {name, enabled}`、`GET /list` 增加 `disabled` 字段（旧的 `{"active": …}` 文件仍可读，缺失即视为全部启用）。旧 host 半（尚未重启 dsh）会让开关回退到本浏览器 `localStorage`，功能不失效。
- **整块编辑器收进一个容器**：表头 / 正文（代码 + 属性面板）/ 状态行三段同框。容器是**单列 grid**（`grid-template-columns:minmax(0,1fr)`），每一块都写明 `width:100%` + `box-sizing:border-box` + `min-width:0`，不再依赖 flex 默认的 `align-items:stretch`。

### 规则面板

- 面板从代码右侧移到**代码下方**，声明与模板按自适应网格铺开（声明 `minmax(200px,1fr)`、模板 `minmax(124px,1fr)`），面板限高 `260px` 内滚动；代码区成为容器内独立的一层表面。
- **属性字典 14 → 112 条**，分十组（布局 / 弹性与对齐 / 网格 / 尺寸 / 间距 / 文本 / 背景 / 描边与圆角 / 效果与动效 / 交互）；"＋ 添加属性…" 菜单用 `<optgroup>` 分组，整组设满时该组自动隐藏。
- **字典支持三种形态**：
  - **枚举型** → 中文值下拉（写入的是 CSS 值本身）；
  - **自由型**（`gap`、`margin-top`、`font-size`、`box-shadow`、`grid-template-columns` …）→ 中文标签 + 自由输入框，添加时写入字典里的种子值，占位文本即取值提示；
  - **分量型** → `flex`（放大 / 收缩 / 基准尺寸）、`gap`（行 / 列）、`margin` / `padding` / `inset` / `border-radius`（上 / 右 / 下 / 左）、`aspect-ratio`（宽 / 高）。读取按 CSS 简写展开规则拆分（`margin: 8px 12px` → 8px / 12px / 8px / 12px），写回收敛为最短等价形式（四值相同 → `margin: 8px`）；分量名是浅底小标签（`--dsw-alias-markdown-tag` + `label-secondary`），与值成对排在同一行；`border-radius` 的斜杠形式保留自由输入框。
- **面板里的下拉全部改为自绘菜单**：原生 `<select>` 的弹出层由操作系统绘制、CSS 控制不到（方角 + 系统配色），现在用与文件下拉同一套的 DSH 菜单（`--dsw-specific-menu` 底 + `--dsw-elevation-prominent` 阴影 + 20px 圆角 + 10px 圆角菜单项）。菜单以 `position: fixed` 按触发按钮定位，既不会被容器的 `overflow:hidden` 裁掉、也不会被面板自身的滚动区截断；视口下方不足 240px 时自动向上展开。
- **控件规格对齐 DSH 自身的设置行**：36px 高 / 18px 圆角胶囊 / 14px 字号 —— 属性值输入框、下拉触发按钮、选择器字段、行内文件名输入框、同行动作按钮统一 36px（字段为胶囊、动作为 8px 矩形，保持层级）；菜单项 36px 高、模板 chip 36px 高。
- **选择器字段与 `×` 合为一个控件**：`×` 从输入框右侧的独立按钮改为**框内的圆形按钮**（26px），输入框因此铺满面板宽度；聚焦环移到外层字段、输入框本身无边框。「＋ 添加属性…」位于**属性列表下方**，铺满面板宽度、文案靠左、箭头贴右端。

### 修复

- **孤儿声明让样式表后半段全部失效**：删 `.dshCc_propValue` 规则时只删掉了选择器行、留下两行声明，使注入的样式表大括号失衡（`{` 112 个、`}` 113 个）—— 浏览器会从失衡处**丢弃后面所有规则**，面板输入框因此退回浏览器默认外观。测试新增**大括号必须平衡**的断言：文本断言看不出解析错误，这条能。
- **控件被容器压缩**：面板是 `max-height:260px` 的纵向 flex 容器，子元素默认 `flex-shrink:1`，内容一超高就把直接子元素挤扁（选择器字段实测被压到 **26px**，而样式表写的是 36px）。现在 `.dshCc_panel>*{flex:none}`，行高由样式表决定、超出部分由面板滚动。
- **网格项 / flex 项的固有宽度溢出**：两者默认 `min-width:auto`（等于内容的 max-content），而 `<select>` 会把最宽选项报成固有宽度，一个长选项就能撑破面板。整条链路补 `min-width:0` 与 `max-width:100%`，面板另加 `overflow-x:hidden` 兜底。
- **容器内的 16px 溢出**：正文行设了 `padding:8px` 却没写 `box-sizing:border-box`，盒宽 = 容器宽 + 16px，把代码区与属性面板一起顶出右边界。
- 代码编辑区最小高度恢复 **140px**（过程中曾误改为 120px，拖拽手柄因此能把编辑区缩到比自身高度还小）。

### 测试与验证

- 浏览器半新增：开关（渲染 → 关闭后样式标签被移除 → 恢复后样式回到页面 → host 无该路由时仍可用并落到 localStorage）、简写分量（`flex` 三分量、`margin` 两值展开成四框、`gap` 两值收敛回单值、圆角斜杠回退）、结构断言（容器三段互为直接子节点、面板在代码下方、添加属性控件在网格下方）、样式守卫（大括号平衡、盒子与收缩规则）。
- host 半新增：`/toggle` 的持久化、遍历名 400、缺失文件 404、非布尔值 400。
- 用无头 Edge + puppeteer-core 做真实排版测量（容器 600 / 420px 两档）：控件统一 36px、编辑区 140px、分量行与网格同宽、`scrollWidth` 等于容器宽、无元素越界。
- 兼容性未变：`0.1.2-rc.1` 与 `0.1.5-rc.1` 已真机验证，`0.0.1-rc.5` / `0.1.0-rc.7` / `0.1.1-rc.2` 接口断言通过（`npm run compat`）。

## 0.1.2 — 兼容性证据与换代门禁 · 已发布 2026-09-13

- 新增 [`tests/compat-matrix.mjs`](./tests/compat-matrix.mjs) 与 [`compat.json`](./compat.json)：逐个 DSH 版本断言插件依赖的平台契约（槽位 `settings.general.item` 与 `dsh.client.inject` 的两个 id）。`npm run compat` 本地与 CI 均可复跑；CI 新增该步骤（单档）——**DSH 换代改名时会先在这里红**，而不是等用户看到模块表报错。
- 真机验证扩到 `0.1.2-rc.1`：独立 `DSH_HOME` 起实例，`/dsh-custom-css/list`、`POST /write`、`GET /read` 全部 200，文件确实落盘；`0.0.1-rc.5` / `0.1.0-rc.7` / `0.1.1-rc.2` 通过接口契约断言。README 新增「兼容性」一节（含验证矩阵与两种失效模式的判别）。
- README「已知坑」补一条：跨代插件的 `missed the module table` 报错成因与判别方法。
- **无运行时改动**：`lib/client.js` 与 `lib/index.js` 与 0.1.1 逐字节相同，本版只增加兼容性证据、门禁与文档。

## 0.1.1 — 发布通道与文档 · 已发布 2026-09-13

- README：新增 npm 徽章（version / unpacked size）；安装章节改为「**npm 优先**」，GitHub 装法与 `link:` 开发安装顺延为方式二、方式三；顶部元信息补 npm 包链接。
- README：注释里记下下载量徽章暂缺的原因（npm downloads API 对刚发布的包要等约一天才有数据），届时补 `img.shields.io/npm/dm/dsh-custom-css`。
- `publish.yml`：打开 `push: tags: ['v*']` 触发（OIDC 受信发布 + provenance 签名），并把唯一前置写进注释 —— 先在 npmjs.com 给这个包配置 Trusted Publisher。
- **无运行时改动**：`lib/client.js` 与 `lib/index.js` 与 0.1.0 逐字节相同，本版只为刷新 npm 页面上的 README 并走通自动发版流水线。

## 0.1.0 — 首个公开版本 · 已发布 2026-09-13

设置行 / 编辑器

- 在 **设置 → 通用 → 外观** 下方新增一行「自定义 CSS」（`settings.general.item`，`order: 12`）。
- 样式表以普通 `.css` 文件存放在 `~/.dsh/custom-css/`，当前文件记在 `active.json`；编辑**即时生效**（400ms 防抖）。
- 编辑器照 DevTools Styles 标签页做：行号 gutter、shiki 同色语法高亮、键入时补全。
- 补全数据取自浏览器自身：属性名枚举自 `CSSStyleDeclaration.prototype`，值的候选逐个过 `CSS.supports()`。
- 补全只在真正键入时出现（看 `InputEvent.inputType`），且一行里有多条声明时只认光标所在的那条。
- **规则面板**：点选择器就地展开 —— 可重命名选择器、逐条列出已有声明（枚举型给中文名 + 中文值下拉）、未设置的属性收进「＋ 添加属性…」、底部是 14 个声明模板。
- 改属性是**替换**而非叠加：写入走逐条声明的扫描 + 块规范化，不会堆出 `display: flex; display: grid;`。

这一行上的控件

- 文件下拉（DSH 原生样式，末项「＋ 新建…」）、打开文件（调起系统默认程序）、导入、导出（另存为 / 回退下载）、重置（错误色，语义破坏性）。

host 侧

- `/dsh-custom-css` 前缀下的 JSON 接口：`/list`、`/read`、`/write`、`/create`、`/import`、`/active`、`/open`。
- 全部走 Connection 服务的请求围栏；拿不到围栏时 503 fail-closed。
- 文件名限定单个路径段 + `.css`（≤64 字符），目录外路径二次校验。
- 围栏不可达时浏览器半自动降级到 `localStorage`，编辑器仍可用。

校验

- 每次渲染扫一遍样式表：括号与注释闭合、声明可解析、值被引擎接受；状态行给出**行首问题 + 总数**（最多 5 处）。
- **描述符块单独处理**：`@property` 的 `syntax` / `inherits` / `initial-value` 是 descriptor 而非 CSS 属性，`CSS.supports()` 一律不认 —— 早期实现把它们当声明判，合法的 `@property` 会被报成 3 处错误。现在 `@property` 按描述符规则校（`syntax` 必须带引号、`inherits` 只能布尔），`@font-face` / `@page` / `@counter-style` / `@viewport` / `@font-palette-values` / `@font-feature-values` 整块交给引擎。
- 回归测试覆盖：合法 `@property` **零报错**、非法描述符必须报出、`!important` 不算值的一部分。

文档

- README 补齐：从 npm / GitHub 安装（`dsh plugin --profile web add` + `dsh.profile.bundles` 两步）、本机 `link:` 安装、仓库结构、以及一节**已知坑**（注入顺序与 `!important`、类名哈希、`:has()` 不可嵌套、描述符不是属性、host 半需重启、自定义属性动画需 `@property`）。

发布

- `0.1.0` 于 2026-09-13 发布到 npm（`npm i dsh-custom-css` / `registry.npmjs.org`，10 个文件，带 registry 签名）。首次发布走本机 `npm publish` + 浏览器 OTP；此后由 `.github/workflows/publish.yml` 以受信发布（OIDC + provenance）完成，不需要长期令牌，也不会再弹动态码。
