# 设计说明

逐组件的取舍与标定过程。更新记录见 [../CHANGELOG.md](../CHANGELOG.md)。

## 深色模式

深色这一侧是单独调过的，四件事：

1. **层与底错开明度**（`darkLift` 0.006，三套统一）：让三套在深色下分得开，
   同时给每套一个可辨的「夜色」（暖砂更深、苔绿更亮）。
2. **色度按色相归一化**（`darkC` 0.010 / 0.019 / 0.010）：三套统一基准，深正文按色域归一（×2.4 后 ≈0.0127）。
3. **层内色度恒定**（`ROLE` 深色权重 1.0 起）：卡片与模块不再比画布灰，
   层间只差明度（ΔE 5.9 / 3.9 / 3.7），像「同一材质的不同高度」。
4. **正文单独一条色度**（`paperC` 0.0053，三套统一）：近白但不透亮，
   且不会顶到 8-bit 天花板（L 0.953 处蓝的可用色度只有 0.0235）。

正文对比度：浅色承诺面（`00`/`60`）13.50–15.57（目标 12:1），深色承诺面（`950`/`875`）10.13–12.05（目标 10:1）。
深色 `800`（模块面）与 `850`（次级深面）**不为正文兜底** —— 报表把它们按「政策外」单列（既不算达标也不算失败）：
实测 6.20–9.70，物理上限 10.22–15.45，**AA 底线与 APCA 底线仍须过**（见下节）。
三级与链接的浅色承诺目标是 **4.85 / 4.95**（HC 7；4.62 的旧值在色觉缺陷模拟下会掉到 AA 以下，见「文字对比度在 CVD 下也守 AA」），深色分别是 **6.4**（HC 7.6）与 **4.62**（HC 7）。
链接另加一道 APCA 门（深色 Lc ≥60 / HC ≥75）—— 它常在 `800` 模块面上出现，而 4.62 在深色 `800` 上
只相当于 Lc≈48，见「链接」一节。

## 区块底色：左侧栏

`--dsw-specific-sidebar-fill` 内置是 `bluish-50/-900`，与画布几乎同色，左栏和会话区看不出边界。
本插件把它解耦，三档（默认「更深」）：

| 档位 | 浅色：画布↔侧栏 / 悬停 / 选中 | 深色：画布↔侧栏 / 悬停 / 选中 |
|---|---|---|
| 更浅 | 0.73–1.12 / **1.19** / **2.39** | **4.66–4.96** / 2.83–2.92 / 6.07–6.12 |
| 同色 | **0.00** / 2.39–2.41 / 4.80–4.84 | **0.00** / 2.96–3.04 / 5.92–5.98 |
| **更深（默认）** | 2.02–2.13 / 2.40–2.43 / 4.82–4.87 | **4.12–4.35** / 3.05–3.16 / 5.95–6.12 |

**「同色」是逐字节相等**（ΔE 0.00，回归断言在 `tools/check-audit.mjs` 第 17 组）。1.9.9 之前它只是「接近」：
侧栏底色走一遍 `lift()` 后再补一次色度，而补色度固定用 `scheme.hue` —— 苔绿的 `scheme.hue` 是 162（青绿），
画布 00 的色相却是 121（橄榄），于是「同色」被染成薄荷绿 `#defaeb`（ΔE 2.49），靛蓝也差 0.50。
现在同色直接取画布本身，另两档的补偿只补「不够的」色度并沿用画布自身色相。

深色侧原来沿用浅色步长（×0.6），画布↔侧栏只有 ΔE 1.9–2.3 —— 正好卡在 JND 上，于是「左栏和中栏
看不出界线」。v0.13.0 起深色模式用**独立的 `darkLift`**（0.048 / 0 / −0.042），三套配色在深色下都到
ΔE 4 以上；侧栏内正文对比 9.71–13.23，仍满足 AAA（7:1）。

ΔE 为 OKLab 欧氏距离 ×100，并排大色块的 JND ≈ 2。

**默认给「更深」是因为几何**：侧栏在画布之上时，悬停/选中只能往下走，两档就会穿过画布
（选中项像在页面里挖了个洞）。「更浅」这一档在参考实现的近白纸面上几乎没有余量（暖砂画布 L 已经是 0.9888，
比旧上限 0.985 还高），所以 1.9.9 给它两道处理：① 明度上限抬到 0.995，保证「更浅」在数值上**确实更亮**
（此前会出现反向压暗：`#fdfbf8 → #fafaf9`）；② 余量不足时改为削色度（0.25×）+ 顶到上限，
读作「更白的一张纸」，并且悬停/选中有 0.012 的步进地板（ΔE≈1.2），不会出现「点了没反应」。

深色侧的步长与浅色**解耦**（见上表 `darkLift`），不再按比例缩：
旧写法把 `-0.020×0.6` 夹成 `-0.004`，深色导轨与画布只剩 ΔE 0.49 —— 边界直接消失，
而同一档在浅色侧是 2.10。现在两侧都是 1.9–2.1，同一个档位在明暗两模式下强度一致。

## 三套预置的浅色画布：回归参考实现（1.9.9 二次定）

1.9.8 及以前，浅色画布是暖砂 `#fdfbf7`、靛蓝 `#f8fafc`、苔绿 `#f9fbf7`，两两 ΔE 只有 **0.5–1.0**（JND ≈ 2）
—— 这是 1.9.0「surface token 对齐参考实现」的直接结果：参考实现的纸面就是近中性白。

1.9.9 中途曾按审计建议把画布拉开到 ΔE 3.1–4.4（`#fff8ec` / `#eef7fd` / `#eff4e4`），
但用户对照参考实现（web.yily.top 的苔绿）指出：**纸面不该被染成黄绿**，配色身份本来就不在纸上。
于是回到参考值，只做两件事：

| | 取值 | 说明 |
|---|---|---|
| 00 画布 | 参考 `surface` | 暖砂 `#fdfbf7`、靛蓝 `#f8fafc`、苔绿 `#f9fbf7` |
| 60 卡片 | 参考 `surface-subtle` | 暖砂 `#faf6ef`、靛蓝 `#f3f6fb`、苔绿 `#f4f8f0` |
| 150 内容面 | 参考 `surface-strong` | 暖砂 `#e9dfd0`、靛蓝 `#e0e7f0`、苔绿 `#dee8d8` |
| 边框 | 参考 `border` | 暖砂 `#e6dccb`、靛蓝 `#dbe3ee`、苔绿 `#dbe5d4` |

于是**同一套内部**仍然有层次（00↔150 有 6–8 ΔE），而**跨套的区分**挪到了次层与强调色：

| 对比（标准档） | 00 画布 | 150 内容面 |
|---|---|---|
| 暖砂 ↔ 靛蓝 | 1.00 | **4.13** |
| 暖砂 ↔ 苔绿 | 0.52 | **2.46** |
| 靛蓝 ↔ 苔绿 | 0.80 | **3.42** |

这与参考实现的行为一致：切配色时纸面几乎不动，动的是一片浅色区块、边框与强调色。
（1.9.9 中途那版「三张不同的纸」不再采用；若日后仍想要纸面分离，改的是 `SURFACE_TARGETS`，一处即可。）

**深色侧不取参考的 `surface-dark`**：本插件的侧栏在画布**下方**，画布若压到 `#0d1310`，
「更深」的侧栏会掉到近黑 `#050a07`。深色四层仍按 `subtle / muted / raised / strong` 落位。

## 当前标定值（真源：`client.js`）

> 下面各节的推演过程保留原样（它们是当时的推理轨迹），但**具体数值以本表为准**。
> 2026-10-22 视觉审计发现旧表格仍写着 1.8 / 2.6 与「中性 / 跟随 / 浓一些」，与实现已不一致。

| 档位（UI 文案） | id | textScale | paperScale | textLift | inkC / inkDarkC（1.9.9） |
|---|---|---|---|---|---|
| 静 | `neutral` | 1 | 1 | 0 | −0.026 / −0.018 |
| **标准（默认）** | `tinted` | 3.0 | 2.4 | +0.055 | 0 / 0 |
| 浓 | `deep` | 4.6 | 3.6 | +0.085 | +0.021 / +0.016 |

`inkC` / `inkDarkC` 是 1.9.9 加的：色度增量直接加在**参考墨色的色度**上（明度与色相仍取参考值），
所以预置配色的三档终于有差别 —— 见「文字着色」。
（2026-10-29 审计对账：实现一直是 `+0.021 / +0.016`，本表此前写的 `+0.022 / +0.014` 是草稿值；
`client.js` 的 `TEXT_MODES` 是唯一真源，本表与它逐位一致。）

高对比档（`HC_MODES`，默认「跟随系统」= `prefers-contrast: more` 开启时自动切高对比）把文字下限整体抬到正文 ≥15 / 次文 ≥9.6（浅色 9）/ 三级 ≥7.6（浅色 7）/ 链接 ≥7，
并把求解器位移上限从 0.13 放宽到 0.30（低档位下 4.6 → 7 需要 ΔL≈0.2，原上限够不着）。

> **2026-10-23 反哺修正 → 2026-10-24 口径重写（1.9.8）**：旧记法把深色模块面 `800` 说成「物理不可达」，
> 因为它拿正文目标去比最深的一面。1.9.8 把三件事分开：**声明目标**（深色正文 10:1）、
> **物理上限**（该承载面上黑/白前景能达到的对比度，纯计算得出 —— `800` 约 10.22:1、`850` 约 15.27:1）、
> **政策豁免**（`800`/`850` 不为正文兜底，报表单列，既不算达标也不算失败）。
> 求解器里的 0.93 只是**求解余量**，仅用于判断「目标是否高于上限」，不再混进上限本身。
> 深色正文以 `875`/`950` 为承诺面求解到 ≥ 10:1（宁可亮一点也不失约，压过头就保持原色）；
> `800`/`850` 上的三级、链接仍须过 AA 4.5，caption 也不再沿用未求解的 500。

## 对比度承诺口径（1.9.8）

报表把三件事分开，缺一不可（真源：`client.js` 的 `CONTRAST_TARGETS` / `CONTRAST_EXEMPT` / `CONTRAST_FLOOR`）：

| 概念 | 含义 | 来源 |
|---|---|---|
| **声明目标** | 我们承诺正文 / 次级 / 三级 / 链接在各承载面上达到的对比度 | 手工标定 |
| **物理上限** | 该承载面上黑或白前景能达到的对比度 | 纯计算，**不含**求解余量 |
| **政策豁免** | 我们不承诺该面承载正文（深色 `800` / `850`） | 产品策略 |

- 承诺目标（浅色）：正文 **12:1**、次级 **6.2**、三级 **4.85**、链接 **4.95**（高对比 15 / 9 / 7 / 7；后两项比 1.9.x 各抬了一档，见 CVD 一节）。
- 承诺目标（深色）：正文 **10:1**、次级 **8.0**、三级 **6.4**、链接 **4.62**（高对比 15 / 9.6 / 7.6 / 7）。
  深色侧单独一套更高的下限，理由见「为什么深色要更高的比值」。
- 承诺面：浅色 **00/60**，深色 **950/875**。
- 政策外：深色 **800**（模块面）与 **850**（次级深面）**不为正文兜底** —— 报表单列，既不算达标也不算失败，
  也不再标「物理上限不足」。
- 物理上限只由计算得出。求解器里的 **0.93** 只是**求解余量**，仅用于判断「目标是否高于上限」，不混进上限本身。
- **底线是硬的**：政策外的面也不许掉到 AA 4.5 以下；深色侧还要同时满足 APCA 的 Lc 下限
  （正文 60 / 次级 60 / 三级 45，实测已在 61.9–68.5）。
- **注释层（`label-caption`，1.11.0）**：不在承诺模型的角色表里，但深色侧同样过 Lc 闸 ——
  标准档 **Lc 60**（与链接同线）、HC 档 **70**（HC 三级文字 Lc 落在 72–77，注释套链接的 75 会反超三级、
  破坏「注释弱于三级」的层级）。抬完标准档注释 WCAG 约 5.6–6.0，仍低于三级的 6.4；`tools/check-audit.mjs` 第 4 组守着。
- **装饰层（`label-dimmed` / `label-document-preview`）**：按「装饰性文字」解到 **3.0**，刻意低于所有可读承诺 ——
  宿主把它们用于占位/预览这类辅助场景；不在 `CONTRAST_ROLES` 承诺模型内，也不进报表（有意的政策外）。
- 三个出口共用同一份数据：设置页 UI、报表 CLI（`palette-dev.mjs`）、审计断言（`tools/check-audit.mjs`）。
  单元格公开 `measured` / `declaredTarget` / `physicalCeiling` / `policyApplicable` / `limitedByPhysics` /
  `meetsFloor` / `apca` / `pass`；政策外的 `pass` 为 `null`（不算达标也不算失败）。

## 为什么深色要更高的比值（1.9.9）

WCAG 2.x 的相对亮度比在暗底上**系统性高估**可读性。同一个 4.62：落在浅色模块面 `#f5eee3` 上相当于 APCA **Lc≈71**，
落在深色模块面 `#4a3f36` 上只有 **Lc≈48**。所以「比值过 AA」在深色一侧是纸面达标 —— 1.9.8 之前的深色
三级就是这样：比值 4.62，读起来发灰。

1.9.9 给深色侧一套独立下限：次级 **8.0**（HC 9.6）、三级 **6.4**（HC 7.6）。实测效果（深色模块面对 APCA Lc）：
三级从 **44.7–47.7 → 61.9–68.5**；承诺面 48 组（3 套预置配色 × 四个角色 × 深浅各两面）里 `Lc < 60` 的从 **11 → 2**。
剩下两组是**自定义配色的深色链接**（52.7 / 55.0）：链接比值下限 4.62 未动，在 APCA 里属于「大字号可用」档 ——
要把它推到 60 就得改品牌链接的取值（1.9.0 的契约），这是产品决定。
浅色侧不动 —— 它的 6.2 / 4.62 换算成 Lc 是 79 / 71，本来就够。

报表因此多一列 **APCA Lc** 与「APCA 参考档」（正文 ≥75 / 次级 ≥60 / 三级 ≥45）。这一列**不设门**：
它只是把「过了 AA 但读起来费劲」变成看得见的数字，门仍然只有 WCAG 底线与上面的深色 Lc 底线。

**为什么 850 不是「做不到」**：它的物理上限约 **15.27:1**（暖砂），远高于 10:1 目标 —— 它只是不在承诺范围内。

1.9.7 的 CHANGELOG 既写「以 950/875/850 定标」又写「800/850 标为政策外」，自相矛盾；
1.9.8 统一为「**850 政策外**」——深色 850 不为正文兜底，承诺面是 950/875。

## 文字着色（1.9.9 起对预置也有效）

正文只给 0.004 的色度（近中性）时，视觉上就是「黑字 / 白字」。第三行「文字着色」按档位调**正文色度**（浅色 `textC` / 深色 `paperC`），底色不动，对比度仍由求解器兜底。

**1.9.0–1.9.8 的这段是坏的**：预置配色用固定参考墨色（`TEXT_TARGETS`）把色阶整体覆写，三档算出的色度差在覆写那一刻就没了 —— 实测静↔浓 ΔE **0**，只有自定义配色有效。1.9.9 把参考墨色降级成**起点**：明度与色相仍对齐参考值，色度改由档位驱动（`TEXT_MODES` 的 `inkC` / `inkDarkC`，静 −0.026 / 标准 0 / 浓 +0.021），旧的三条路 `fit()` / `boost()` / `vivid()` 合并成同一个 `solveOn()`。

标准档（屏幕上真正渲染的值）：

| 套 | 浅色正文 | 深色正文 | 次级（浅 / 深） | 三级（浅 / 深） | 正文承诺面对比度（浅 00 / 60，深 950 / 875） |
|---|---|---|---|---|---|
| 暖砂 | `#26211d` | `#cdc9c4` | `#564d46` / `#eee2d5` | `#6a6057` / `#d9cbbd` | 15.43 / 13.79 · 10.91 / 10.13 |
| 靛蓝 | `#1a2330` | `#cbcdd0` | `#455367` / `#cbd9ed` | `#58667c` / `#b1c3db` | 15.11 / 14.07 · 11.08 / 10.17 |
| 苔绿 | `#1d2820` | `#cad0cb` | `#46554b` / `#cfdfd3` | `#59695d` / `#b4caba` | 14.70 / 13.50 · 11.20 / 10.19 |

表里的次级 / 三级是**插件覆盖后屏幕上真正渲染的值**（`label-secondary` / `label-tertiary`），**不是色阶步进** —— 详见「报表量的是屏幕上的字（1.9.8）」。

静 / 标准 / 浓三档（浅色正文对 `00`，深色正文对 `950`）：

| 套 | 静 | 标准（默认） | 浓 | ΔE 静↔浓（正文 / 次级 / 三级） |
|---|---|---|---|---|
| 暖砂 | `#222222` 15.39 | `#26211d` 15.43 | `#2d1e12` 15.57 | 3.2 / 3.8 / 4.1 |
| 靛蓝 | `#222223` 15.19 | `#1a2330` 15.11 | `#13233a` 15.07 | 4.7 / 4.8 / 4.7 |
| 苔绿 | `#252525` 14.72 | `#1d2820` 14.70 | `#142b1c` 14.53 | 4.1 / 4.5 / 4.6 |
| 自定义 | `#13110e` 16.61 | `#251b12` 14.88 | `#302112` 13.69 | 8.8 / 2.6 / 3.3 |

**加的是色味不是明度**，所以不牺牲可读性：三套预置的正文比值 15.39 / 15.43 / 15.57 只差 0.2，档位改的是色度。
并排大色块的 JND ≈ 2，静↔浓的正文 ΔE 3.2–4.7 看得出来，静↔标准 1.1–2.6 是「细看才分得出」的一档。

**深色侧三档是「温度」而不是「强度」**：近白处的色度台阶受 sRGB 上限约束（L 0.953 时蓝最多 0.0235、黄 0.038），
所以深色正文静↔浓的 ΔE 只有 0.5–1.6、次级与三级 3.4 —— 这一侧调档是「同一张纸上的冷暖」。
`paperC` 就是按这个约束定的：**三套预置共用 0.0053**（`client.js` 的 `SCHEMES` 里三套同值），
乘上档位的 `paperScale`（标准 2.4）后有效色度 ≈0.0127 —— 仍在近白处的可用色域内，不会被 8-bit 夹取。
早期版本按套给 0.014 / 0.009 / 0.0125 的**逐套**取值，结果是三套的「浓一些」一个在色域内、一个被夹掉，
档位效果因配色而异；1.9.9 改成统一基数、由档位缩放，三套才在同一尺度上。早期还给到 0.015–0.016，
靛蓝的中性档就已经吃掉 66% 可用色域、「浓一些」请求 0.033 实得 0.0241 —— 第三档等于不存在。

次级与三级跟着同一档位走，但它们的色度基数更低（`midC 0.006`），所以是「带一点味的灰」，不会变成彩色字 ——
正文的层级靠明度建立，不靠饱和度。

## 色阶单调性（1.9.9）

色阶是「编号越大越深」的一维顺序，界面靠它表达层级。1.9.8 有两处会把它弄乱：

1. `compressL` 是个硬阈值 —— `L ≥ 0.92 → L-0.044`，跨过阈值的相邻两档会撞成同色（浅色 `150` / `200` 实测 ΔE **0.36**）。
   现在压缩是连续的（阶跃处误差 < 0.001）。
2. `tuneRamps` 按承载面逐个反解明度，却不看顺序：`950` 被表面目标钉住、`1000` 为了过 12:1 被拉亮，于是出现
   **编号大的反而更亮**。现在收尾跑一次 `enforceRampOrder`：按档位序号排序，强制 `L[i+1] ≤ L[i] - 0.006`
   （`RAMP_MIN_GAP`），越界时按比例回压（不是硬截）。

「墨色档」（浅 `700` / `1000`，深 `50` / `300` / `400`）**不参与阶梯**：它们在深色里就是字色，天生要比面亮。
实测 4 套配色 × 两档，**12 个面档全部单调**。

> **2026-10-29 审计补漏（P1-7）**：`RAMP_ORDER` 里少了 `'900'` —— 而 `BASE.bluish` 有 `'900'`、色阶也构建它。
> 于是 900 既不参与递减比较也不被钳制（实测 875→900 的 ΔL 只有 0.00549，低于 `RAMP_MIN_GAP`）。
> 已补进顺序表，并加了「凡是构建出来的档位都必须在 `RAMP_ORDER` 里」的断言（`check-audit` 第 25 组），
> 下一档再漏会红。补完后视觉无变化：钳制量（约 0.0005 L）小于一个 8-bit 量化步长。

## 边框与组件边界（1.9.9）

1.9.8 的高对比档只改文字：`border-l1..l4` 的 alpha 一个字没动（实测与普通档完全相同，1.23–1.39:1），
滚动条压根不在 HC 分支里。1.9.9 把 HC 的 `border-l2 / l3 / l4` 按 SC 1.4.11 对承载面**定量**求解到
**3.0 / 3.4 / 4.0:1**（实测 3.26–3.39），滚动手柄 **3.01–3.07:1**。

**必须定量，不能只求解**：`solveForeground` 从墨色起步，把墨色混进面里再算对比度 —— 发丝线本来就在阈值以下，
它会一步不挪地判定「已达标」并返回墨色，实测 16.9:1，界面直接变成黑框。新增的 `mixToContrast()` 在「面 → 墨」
的直线上二分混合比，命中目标值本身。

**深色侧改对中间两层定标（1.9.10）**。深色有四层面（`950` 侧栏 / `875` / `850` / `800` 模块面），
一根线不可能对四层等强，而「对比度」在暗面上是单调的：面越暗读数越高。1.9.9 取**最亮的 800** 定标
（追求「模块面上的边界也够 3:1」），结果同一根线在 `950` 上读 **5.3 / 6.1 / 7.1:1** —— 比浅色侧还硬，
用户实测反馈「一开高对比边框全变了、扎眼」。1.9.10 改成对**中间两层 `875` / `850`** 定标，目标同时抬到
**3.2 / 3.6 / 4.1:1**：

| 档 | 对 850（定标面） | 对 950（最深，读数最高） | 对 800（模块面） |
|---|---|---|---|
| l2 | 3.24 | **3.81**（原 5.32） | 2.17 |
| l3 | 3.63 | **4.27**（原 6.05） | 2.43 |
| l4 | 4.15 | **4.88**（原 7.10） | 2.77 |

取舍写在明面上：`950` 不再比浅色侧硬，代价是 `800` 从 3.0 降到 **2.2–2.8** —— 模块面上的分界仍然看得见
（≥2:1 是可见边界），但不再满足 1.4.11 的 3:1。选 800 还是选 950 没有客观最优解，这里是按「用户实际盯着看的是
侧栏与画布」取的。`tools/check-audit.mjs` 第 19 组把这个决定钉住：浅色对 00/60 ≥ 3.0/3.4/4.0、深色对 875/850
≥ 3.2/3.6/4.1、950 ≤ 5.0、800 ≥ 2.0。

**普通档保持发丝线**（HC 1.08、普通 1.39–1.47）：装饰性分隔线按 SC 1.4.11 是豁免的，一律提到 3:1 会把界面做成表格。
`border-l1` 在两档里都不变 —— 它是纯装饰的发丝线，提到实色会让界面变成表格。

**组件边界另开一档** `--dshtp-border-control`（3.0:1）：宿主的 `border-l1..l4` 是四层递进的发丝线，
没有「装饰线」与「控件边界」的区分，而 1.4.11 只管后者。插件自己的设置页输入框用这一档实色；
宿主控件的边框仍由宿主负责 —— 插件不替它做这个决定。

## 色觉无障碍（1.9.9）

预置之间的差别主要靠**强调色**与深色档，不靠色相细分：强调色对画布在 protan / deutan / tritan / achromat
四种视觉下 ΔE 都 ≥ **37.8**，这条通道是安全的。

风险集中在两处：

- **暖砂与苔绿在 deutan 下几乎同色**（深色画布 ΔE **0.84**）。也就是说红绿色盲用户在深色侧几乎分不出这两套 ——
  好在它只是审美选择，不承载信息。
- **红/绿语义色在 protan 下只剩 ΔE 5.7**。语义色由宿主提供（`--dsw-alias-state-*`），
  1.13.5 起插件会给它们**做温度调和**（`harmonizeSemantic`：色相旋转封顶 8°、色度压到 88–92%，
  明度一律沿用宿主）—— 但"只是换冷暖"，不是把「成功 / 失败」染成用户主色：
  色觉缺陷下这一层仍然需要靠**图标或文字**区分，不能只靠颜色。

三套预置在 achromat（全色盲）下的层间明度差仍保留（ΔE 3.7–5.9），层级不依赖色度。

### 文字对比度在 CVD 下也守 AA（1.10.0）

文字与承载面的对比度由**明度差**决定，配色只改色度与色相 —— 所以色觉缺陷理论上不该影响读数。
`tools/check-audit.mjs` 第 20 组把这个「理论上」变成实测：四套配色 × 明暗 × 正文/次级/三级/链接 ×
三个承载面 × deutan / protan / tritan（Machado 2009 线性矩阵），断言最低值 ≥ 4.5。

这条断言当初**没过**，而且原因不在 CVD 本身：三级与链接的浅色下限原本就压在 **4.62**（刚过 AA、
一点余量不留），而模拟会把同一对前景/背景的比值上下推动 5–9% —— 链接实测跌到 **4.22–4.34**，
比 AA 还低。修法是把浅色下限抬到三级 **4.85**、链接 **4.95**，把这段浮动留给模型误差；
抬完三种模拟下的最低值是 **4.61**。代价只是浅色链接与三级文字比 1.9.x 略深一档。

## 三项「少让人做决定」的机制（1.10.0）

### 高对比：跟随系统（`auto`）

高对比档原有「关 / 开」两态，现在加 `auto` 并设为默认：系统开了「提高对比度」
（`prefers-contrast: more`）就自动用高对比档，关掉就回标准档。`matchMedia` 的 `change` 事件一响
就重算（`applyAll('prefers-contrast')`），不用刷新页面。
手动选过「关 / 开」的用户读回的是自己的选择 —— 升级不会把他们的档位改掉；
只有从没碰过这一项的用户落到 `auto`。

### 自定义配色：由主色派生底色与文字

用户往往只有一个「想要的品牌色」，让他自己配纸面与墨色既难也容易把对比度配坏。
设置页的自定义面板多了一个「由主色派生」：拿当前强调色的**色相**、色度的 15%–22%（夹上限）
推出纸面与墨色，明度按当前明暗模式取（亮 0.968 / 0.235，暗 0.165 / 0.935）——
这几个值是引擎已验证过的区间，派生结果直接过全部下限（实测正文 14.6–15.0、次级 6.8–6.9、
三级 5.3–5.4、链接 5.4–5.5）。主色无彩（C < 0.004）时派生中性纸面，
避免「黑底 + 白字 + 黄强调派生出一套淡粉」那类老问题。

### 底色明度也参与（1.13.5 补丁）

1.13.5 之前，三色里的「底色」只贡献**色相与色度**，画布明度由引擎按模式写死（深色 ≈ 0.226）。
用户把底色设成 `#070b11`（L≈0.148）时屏幕上是 `#191c21` 一档的蓝灰 —— 用户读成"底色没变"，
而且这个读法是对的：**控件叫「底色」，那它就该是底色**。

现在的规则只有三条：

| 底色明度 | 行为 |
|---|---|
| L ≤ 0.35（明显偏暗） | 它成为**深色模式的画布明度**，夹到 0.14–0.30 |
| L ≥ 0.75（明显偏亮） | 它成为**浅色模式的画布明度**，夹到 0.90–0.985 |
| 0.35 < L < 0.75（中间灰） | 不表达明暗意图，两侧都沿用引擎值 |

只锚**画布这一档**：800–950 四层面按同一 delta 平移（层间距 0.03 L 不变），浅色侧同理平移
00–200。文字色不在这次平移里，它们随后由 `tuneRamps` 在新承载面上重解对比度 ——
所以承诺下限一条没松（该用户配色实测正文对画布 11.33:1、对 875 面 10.16:1）。

下限 0.14 是**层次余量**，不是审美：深色侧还有三层更亮的面，左栏「更深」档再往下 -0.042 L；
画布压到 0.10 时左栏会掉进 `#020407` 一档的近纯黑，整体塌成一块。

同一次回执里还有一条：**线条色**过去取的是文字色的 alpha。文字一旦近中性
（`#e1eced`，C≈0.012），发丝线就是一片中性灰，用户读作"线条还是默认的"。
自定义配色下线条改成**配色色相 + 色度下限 0.02**（明度与 alpha 不变，对比关系不变）；
预置仍走墨/纸原色，不受影响。


### 运行时覆盖报告（`getCoverageReport`）

诊断导出（设置页「复制诊断信息」）多一份 `coverage`：遍历宿主样式表里所有 `--dsw-*` 自定义属性，
按四类分账 —— `ours`（本插件覆盖层接管）、`derived`（值是 `var(--dsw-…)` 引用，跟着基础 token 走）、
`literalChromatic`（字面带彩色值）、`literalGray`（字面灰值，是「跟配色」的候选）。
1.13.5 起语义色（red / green / amber）进入 `ours`，剩下的 `literalChromatic` 只有首次引导页强调色这类
真正够不到的字面量。它回答的是「宿主还有多少 token 没跟上配色」，只读、尽力而为：
拿不到 `styleSheets`（沙箱、跨域）就返回 `{ ok: false }`，不影响其它诊断。

## 宽色域（display-P3）：本期不做（1.10.0）

sRGB 之外还有 P3 的色度余量，问题是「同一个 L/h 下 P3 能多给多少色度」以及「这些色度用不用得上」。
用 OKLCH → XYZ → sRGB / P3 的数值求逆量了各档位（脚本在审计目录 `probe-p3-headroom-v2.mjs`）：

| 档位 | 现用色度 C | sRGB 上限 | P3 上限 | 可加 ΔC | ΔE |
|---|---|---|---|---|---|
| 画布 00（暖砂 / 靛蓝 / 苔绿） | 0.0045 / 0.0034 / 0.0061 | 0.0099 / 0.0080 / 0.0289 | 0.0123 / 0.0088 / 0.0317 | 0.0025 / 0.0008 / 0.0028 | 0.25 / 0.08 / 0.28 |
| 卡片 60 | 0.0107 / 0.0074 / 0.0114 | 0.0209 / 0.0132 / 0.0605 | 0.0262 / 0.0145 / 0.0658 | 0.0053 / 0.0013 / 0.0052 | 0.53 / 0.13 / 0.52 |
| 内容面 150 | 0.0236 / 0.0139 / 0.0249 | 0.0898 / 0.0385 / 0.1697 | 0.1115 / 0.0422 / 0.1880 | 0.0217 / 0.0037 / 0.0183 | 2.17 / 0.37 / 1.83 |
| 强调 500 | 0.1013 / 0.1071 / 0.0962 | 0.1369 / 0.2001 / 0.1684 | 0.1566 / 0.2530 / 0.1980 | 0.0196 / 0.0529 / 0.0296 | 1.96 / 5.29 / 2.96 |
| 正文 1000 | 0.0127 / 0.0120 / 0.0105 | 0.0532 / 0.1548 / 0.0529 | 0.0616 / 0.1558 / 0.0727 | 0.0084 / 0.0009 / 0.0198 | 0.84 / 0.09 / 1.98 |

结论：**纸面层（00/60）的 P3 余量在 JND 以下**（ΔE 0.08–0.53），用不用看不出来；
真正能看出差别的是强调色（indigo ΔE 5.29、moss 2.96）。但强调色同时是链接、info 填充与勾选支点的来源，
一旦按 P3 抬色度，就得为 sRGB 回退再标定一套（色域裁切会改色相，链接的 AA 读数要重算）。
这一版的取舍是**不做**，把余量记在这里：将来若要做，只值得给强调色加
`color(display-p3 …)` 一层并在 sRGB 里保持现值的双口径，纸面层不必动。

## 报表量的是屏幕上的字（1.9.8）

报表要回答的是「屏幕上这条字看不看得清」，所以量的必须是**真正渲染出来的那个 token**，
而不是「如果没人管、它会是什么颜色」。

**角色 → 实际渲染 token 的对应**：

| 角色 | 实际渲染 token | 说明 |
|---|---|---|
| 正文 | 色阶 `1000` / `50` | 插件**不覆盖** `label-primary`，落回宿主色阶 |
| 次级 | `label-secondary` | 宿主指向色阶 `700` / `300`，插件**覆盖为 vivid 色** |
| 三级 | `label-tertiary` | 宿主指向色阶 `600` / `400`，插件**覆盖**（与次级同一套） |
| 链接 | alias-link | 链接色单独求解 |

黑白配色不覆盖这些别名 → 全部回落色阶步进（本来就没有可测读数，报表整格记「未测量」）。

**为什么必须这么量**：按步进值报，自定义配色下三级文字会被报成 **4.21**（低于 AA 4.5 的假警报），
而实际渲染的覆盖值已被求解器兜到 ≥ **4.62**。1.9.8 改完之后，全部组合配置
（5 套配色 × 216 组设置 = **1080** 组，216 已含标准/高对比两档；1.9.10 撤右栏档前是 2160 组）里，
低于 AA 4.5 底线的格子 = **0**，硬失败（pass=false）也是 **0**。

**深色压暗面的修正**：只按 `950`/`875` 压暗时，暖砂标准档的三级文字在 `800`（模块面）上只有 **3.79**、
高对比档 **4.29**（都低于 AA 4.5），次级只有 **5.06**（低于 6.2 的目标）。
1.9.8 改成在 `950`/`875`/`850`/`800` 四个承载面上**同时**守住下限 ——
代价是深色次级 / 三级字色比 1.9.7 亮一档（暖砂标准档三级 `#b7987a` → `#c9a98b`，
高对比档 `#c0a384` → `#f0d1b1`）。

## 对话条目 / 正文的文字着色

左栏的**会话条目**（标题、描述、时间戳）与正文用的是同一组 token ——
`label-primary` / `-secondary` / `-tertiary` → 色阶 `1000 / 700 / 600`（深色 `50 / 300 / 400`），
所以「文字着色」那一行同时管它们，不存在“正文变了、条目没变”。
（`label-secondary` / `label-tertiary` 会被本插件**再覆盖**成 vivid 色 ——
屏幕上看到的是覆盖值，不是这段色阶步进，见「报表量的是屏幕上的字（1.9.8）」）

v0.8.0 那版三档**实际是失效的**：实测 ΔE 只有 0.35–1.66，而并排色块的 JND ≈ 2 —— 看不见。
原因是物理限制：浅色模式正文落在近黑区（OKLab L≈0.19），那里 sRGB 色域极窄，
只加色度到 ΔE 1.6 就顶到 8-bit 量化上限。

v0.12.0 因此给文字着色加了**明度**这一维（`textLift`），把正文从近黑抬到能显出颜色的位置
（下表是当时的推演轨迹，当前档位参数以「当前标定值」一节为准）：

| 档位 | textScale / paperScale / textLift | 标题 ΔE（暖砂 / 靛蓝 / 苔绿） | 正文对比度 |
|---|---|---|---|
| 中性 | 1 / 1 / 0 | 0 / 0 / 0 | 17.2–17.3 |
| **跟随（默认）** | 2.2 / 1.8 / +0.030 | **2.77 / 3.15 / 3.25** | 16.2–16.3 |
| 浓一些 | 3.6 / 3.0 / +0.065 | **6.41 / 6.68 / 6.79** | 14.9–16.3 |

落色（暖砂，v0.12.0 当时）：中性 `#12110f` → 跟随 `#191713` → 浓一些 `#231f18`。

> **1.9.0–1.9.8 这段实际是失效的**：预置配色用固定参考墨色覆盖了色阶，三档 ΔE 实测为 0；
> 1.9.9 才把参考墨色降级为起点、让档位驱动色度（现在静↔浓正文 ΔE 3.2–4.7）。
> 当前的落色值与实测见「文字着色（1.9.9 起对预置也有效）」一节。
> 次级与三级仍比正文淡一档 —— 层级靠明度建立，着色只做「温度」。

MIN_MAIN 从 15.5 放宽到 12，1.9.7 起深色正文再降到 10：给“带色正文”留出明度空间，仍远高于 AAA 的 7:1。

## 右侧栏为什么不再染色

宿主的三栏布局里，左右是不对称的：

| 列 | 背景 |
|---|---|
| 左栏 `.sidebarCol` | `var(--dsw-specific-sidebar-fill)` —— 有官方 token，三档底色可靠 |
| 整框 `.frame` | 基样式 `bg-base`；Windows 变体再覆盖成 `--dsw-specific-sidebar-fill`（所以左栏四周是栏色） |
| 中栏 `.centerCol` | `var(--dsw-alias-bg-base)` = 会话区画布色 |
| 右栏列 `.rightbarCol` | **也是 `var(--dsw-alias-bg-base)`** —— 和主栏同一个 token，所以天生同色 |
| 右栏面板（`data-sidebar-right-panel`） | **没有背景**，是 `position:absolute; right:0; top:0; bottom:0` 的整高覆盖层，透出的是中栏的 `bg-base` |

## 为什么这一版删掉了「右侧栏底色」

0.9.0 起设置页有一行「右侧栏底色：同左栏 / 跟随画布」，用一条
`[data-rightbar-col]{background:…}` 给右栏列上色。1.9.10 撤掉了它，理由是被实测证伪的：

- **只染网格列**：真正的面板是那个整高透明覆盖层，它盖在列上、自己不上底色（透明才露出主栏画布）。
  染列在视觉上几乎看不出差别 —— 用户反馈「设了像没设」。
- **连面板本体一起染**：覆盖层的范围大于右栏列宽，一上色就盖住主栏一大块，连右栏开关按钮都被压在它下面
  （用户实测：主栏被遮了一半、按钮看不见）。

两条路一个「看不见」、一个「盖住主栏」，且都不是调参能救的。**一个只有这两种结果的设置不该存在**，
所以整条链路在 1.9.10 一并删除：设置项、`rightbarMode` 状态与订阅、`readRightbarStored` /
`writeRightbarStored`、分享码里的 `r` 字段、四条右栏染色规则、`--dshtp-rightbar-fill` /
`--dshtp-canvas-fill` 两个 token、`buildTokens` 的第九个参数、palette-dev 的右栏循环、
以及 `tools/check-host-contract.mjs` 里针对 `data-rightbar-col` / `data-sidebar-right-panel` 的契约断言。
插件不再对右栏做任何假设；宿主将来若给右栏提供官方底色 token（像左栏的
`--dsw-specific-sidebar-fill` 那样），再加回来才有意义。

**兼容性只做一件事**：旧分享码里的 `r` 字段、旧 `localStorage` 里的键，读到只是忽略、不报错
（`decodeConfig` 不认识它就跳过了）。`tools/check-audit.mjs` 第 18 组断言把它们钉住：
样式表不碰右栏任何元素、不产出右栏 token、旧码仍可解析、新码不再带 `r` —— 防止哪天又有人加回来。

## 滚动条等中性组件

宿主的四条滚动条 token 全指向 `neutral` 色阶：

| token | 浅色 | 深色 |
|---|---|---|
| `--dsw-alias-scrollbar-bg-l1 / l2` | `neutral-200` | `neutral-700 / 600` |
| `--dsw-alias-scrollbar-hover-l1 / l2` | `neutral-300` | `neutral-600 / 550` |

我本来就在染那条色阶，但它的色度只有 0.006 —— 肉眼看仍是灰。所以 v0.10.0 起设置页多一行
「**滚动条：中性 / 跟随 / 强调**」（默认跟随），**只抬色度、明度完全沿用宿主**：

| 档位 | 暖砂（浅 / 深） |
|---|---|
| 中性 | 不覆盖，走宿主中性色阶 |
| 跟随 | `#e2d4c5` / `#453a2d`（配色色相，色度 0.026）；悬停 `#dfd2c2` / `#5e5345` |
| 强调 | `#f3cfb3` / `#51351d`（强调色色相，色度 0.055）；悬停 `#f0ccb0` / `#6c4e35` |

因为明度没动，滚动条与背景的明暗关系不变，不会出现"滚动条比内容还抢眼"。

黑白配色没有色相可跟随，那一行的说明文字会自己变成「当前是黑白配色，没有色相可跟随」。

**本来就跟随配色的中性组件**：边框（用色阶两端的墨/纸做 alpha）、分隔线、骨架屏、菜单表面、
`interactive-bg-*` 悬停层、`bg-document-selection`（选择高亮）、`markdown-code-segment-*`。
**故意不跟**：遮罩 `bg-mask-*`（保持中性黑）、首次引导页那几条字面包彩/渐变（只在 onboarding 出现）。
**1.13.5 起改为「跟温度、不跟语义」**：代码 diff 的红绿（`code-diff-*`）、`state-{error,success,warn}-*`
—— 它们此前完全不跟，是"好多颜色还是默认色"的主因；现在色相往配色色相拉但**封顶 8°**，
红仍是红、绿仍是绿、琥珀仍是琥珀，`check-audit` 第 29 组守着这个上限与三者之间的可辨距离。

## 开关 / 勾选 / 单选

宿主把这些控件全部压在 **`--dsw-alias-brand-primary`** 上，而它是 `bluish-1000 / 50` ——
也就是近黑 / 近白：

```css
._switch[aria-checked=true]{background:var(--dsw-alias-brand-primary)}
.cardCheckbox input:checked+.indicator{border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-brand-primary)}
.registryOption input[type=radio]{accent-color:var(--dsw-alias-brand-primary)}   /* 原生 radio/checkbox */
.primary{background:var(--dsw-alias-brand-primary)}                               /* 插件页主按钮 */
```

所以开态一直是「黑白拨杆」，跟配色无关。这个 token 共 13 处引用，覆盖开关、复选框、单选
（含原生 `accent-color`）、插件页 `.primary` 按钮、两处动作文本与表单焦点边 —— 一个支点全带过来。

设置页新增一行「**开关 / 勾选：主题色 / 黑白**」（默认主题色）。改的只有这个 token：

| | 浅色 | 深色 | 对画布（非文本要求 3:1） |
|---|---|---|---|
| 暖砂 | `#a66b37` | `#cba88d` | 4.25 / 8.15 |
| 靛蓝 | `#3f66a3` | `#95acd0` | 5.51 / 7.64 |
| 苔绿 | `#4f7a42` | `#9ab393` | 4.81 / 7.73 |

**按钮本来就跟**：`button-info-fill`（信息按钮）= 强调色，`button-primary/-ghost/-floating`、
`button-elevated/contrast`、`button-tool-bar-*` 全部走色阶或已被覆盖。

## 交互态

内置的交互态是蓝灰墨（`#263148`）与冷灰写死的，配色一换就露馅。已按配色重算，
alpha **浅色与内置一致、深色压到 0.6 倍**（见下）：

| token | 内置 | 暖砂（浅 / 深） |
|---|---|---|
| `interactive-bg-hover` | `#2631480f` | `#211d170f` / `#fef6ef0d` |
| `interactive-bg-active` | `#2631481a` | `#211d171a` / `#fef6ef17` |
| `interactive-bg-hover-accent` | 无（自定） | `#211d1724` / `#fef6ef24` |
| `menu-surface-fill` | `#f8f9fa94` / `#43454a73` | `#fef6ef94` / `#49443f73` |
| `button-tool-bar-hover` | `#54555799` | `#5c575199` |

**为什么深色 alpha 要压**：深色叠的是近白纸色，8% 的纸色在 L 0.22 上抬起的明度，约等于 6% 的墨色
在 L 0.93 上压下的两倍。旧值（`0.08 / 0.14 / 0.24`）实测深色 hover / active / accent
ΔE `7.05 / 12.19 / 20.08`，是浅色（`3.92 / 6.76 / 9.51`）的 1.8–2.1 倍 —— 深色下悬停一行会「闪」，
按下像换了一块面。收到 `0.05 / 0.09 / 0.14` 后是 `4.6 / 7.9 / 12.2`，两个模式的手感对齐。

本来就跟（官方是 `var()` 引用）：`interactive-bg-hover-solid`、`button-ghost-active-*`、
`button-primary-hover`、滚动条、`markdown-code-segment-*`、`bg-document-selection`、`state-business-*`（焦点环）。
不跟的：`interactive-bg-hover-danger`（红）、`bg-mask-*`（遮罩中性黑）、代码 diff 红绿。

## 品牌基准色与链接

强调色由三要素定，设计稿给的是**基准色**，对应色阶中段 `500`：

| 套 | 基准色 | rgb | OKLCH（L / C / h） | 白字对比 | 在浅色 `150` 上 |
|---|---|---|---|---|---|
| 暖砂 | `#a66b37` | 166, 107, 55 | 0.581 / 0.101 / 60.0° | 4.39 | 3.33 |
| 靛蓝 | `#3f66a3` | 63, 102, 163 | 0.511 / 0.107 / 259.0° | 5.77 | 4.63 |
| 苔绿 | `#4f7a42` | 79, 122, 66 | 0.533 / 0.096 / 138.7° | 5.01 | 3.97 |

（上表是三套预置在引擎里的实际 `500`；自定义配色的默认强调色是设计稿那个 `#a9683e`。）

`500` 这一档的明度**被定死在设计值**（`accentL` 直接给 L，不乘 `accentScale`）：宿主和本插件
都拿它当品牌面 —— `--dsw-alias-button-info-fill`、`--dsw-alias-state-business-primary`（焦点环）、
`label-deep-diving` 的混色基色、以及设置页每张卡片的预览色。所以它必须是设计稿那个数，
不能被求解器按承载面改写。

用 `accentScale` 也能把 500 压到设计明度，但那会连带把 `deepseek-50/100/200`（宿主的选中态软底）
整条压暗 13%，软底就不软了 —— 这是 `accentL` 单独存在的理由。

**但基准色不能当正文链接**（上表最后一列）：暖砂 3.33、苔绿 3.97 都低于 AA 4.62，靛蓝 4.63 也只是压线。
链接是当文字用的，所以 `--dsw-alias-link` 由基准色**派生**：同色相、同色度、明度往远处收到达标
（最多 0.13，与其它求解同一上限）。

| 套 | 链接（浅） | 对 `00` / `60` / `150` | 链接（深） | 对 `950` / `875` / `850` / `800` |
|---|---|---|---|---|
| 暖砂 | `#8a5233` | 6.09 / 5.46 / 4.78 | `#e6c4a0` | 10.93 / 10.15 / 9.29 / 6.21 |
| 靛蓝 | `#345286` | 7.44 / 6.93 / 6.25 | `#a7c0e4` | 9.50 / 8.72 / 8.32 / 6.20 |
| 苔绿 | `#406236` | 6.68 / 6.16 / 5.51 | `#aed0a2` | 10.32 / 9.39 / 8.78 / 6.56 |
| 自定义 | `#8e5025` | 5.57 / 5.13 / 4.67 | `#e0b9a1` | 9.40 / 7.91 / 6.94 / 5.96 |

链接角色的承诺目标是 4.62（HC 7）。`850`/`800` 虽然是「政策外」的**正文**承载面，链接角色本身仍受
4.62 约束 —— 实测四套最低 5.96，全部过线。

**1.9.9 加的一道门：链接按 APCA 收口**。比值 4.62 在深色 `800` 上只相当于 Lc≈48，而链接又常在
`800`（模块面）上出现。现在深色链接另要求 **Lc ≥60**（HC ≥75），比值下限不动 —— 直接把比值抬到 8.0
会把自定义配色的链接求解成 `#ffffff`（纯白），品牌色就没了。实测 Lc：950/875/800 分别为
暖砂 73/72/64、靛蓝 66/65/60、苔绿 71/70/64、自定义 67/65/60。

这是本插件唯一一条「不是标准值、而是从标准值派生」的颜色。所以报表里的 `link500` 读的是
`--dsw-alias-link`，不是 `--dsw-static-deepseek-500` —— 后者是基准色，界面上只当填充/焦点环用。

## 为什么不用「注册新主题」

`ctx.theme.register()` 注册的是**一个 colorScheme 固定的主题**，一套配色只能一个 id；要做
「同一套配色各自适配明暗」就得注册两个 id + 自己盯 `prefers-color-scheme` 反复 `setTheme()`，
还会撞三个官方约束（自定义 id 不持久化、`adopt()` 回灌覆盖、dispose 生效中的主题会重置 preference）。
所以用更弱的 `ctx.theme.overrideTokens(source, tokens)`：明暗归官方「外观」，插件只换色。

## 设置页的反馈与迁移（2026-10-23）

- **解释性校正**：自定义面板在强调色被归一化、或链接被压暗时显示实际数值（`{from} → {to}`、`linkRatio:1`），
  让“颜色为什么变了”可解释，而不是静默改写。
- **导入预览**：短码先解码成摘要（配色 / 侧栏 / 文字 / 高对比 / 三色），确认后才写入；预览不触碰存储。
- **升级提示**：`notice.v1` 只记录“已读版本”，与配色存储完全分离；每个版本出现一次，可关闭，不阻断操作。
- **契约测试**：`tools/check-host-contract.mjs` 断言插件依赖的 token 与 `_markdown_` / `md-code-block`
  仍存在于当前宿主构建；找不到 asar 时 SKIP。（1.9.10 起不再断言右栏的
  `data-rightbar-col` / `data-sidebar-right-panel`——这两个钩子已不再被插件使用。）

## 自愈判据与健康反馈（1.9.8）

- **判据 = 覆盖层标记 + 颜色比对**：旧判据只看关键 token 的计算值是否非空 —— 覆盖层被宿主撤销后，
  宿主的基础 token 仍然有值，于是永远报「健康」，配色却早已跳回默认（假健康）。现在两件事都要成立：
  本插件写入的标记属性 `--dshtp-layer` 还在，且关键 token 的计算值等于本层期望值（明暗任一相符即可）。
  不成立才重放，退避与停用逻辑不变。
- **「已恢复 N 次」会刷新**：同色重放后计数跟着更新（判重时把重放次数算进去），不再显示一个停在旧值的数字。
- **保存 ≠ 已应用**：「配置已保存」与「主题已应用」分开报，应用失败有明确提示
  （`配置已保存，但主题没有应用成功`），不再让用户以为换色没生效。
- **对比度表可横着看**：对比度矩阵放进**有名可聚焦的横向滚动区**，窄栏 / 移动端 / 200% 缩放下都能读到整张表。
- **HEX 草稿不覆盖新值**：草稿与外部改动冲突时保留最新值并提示，不把旧草稿写回去。

### 1.12.0 之后补上的四件事

- **可见性探针（1.12.0 P0-C）**：只证明「我们写的变量在 body 上、值正确」还不够 —— 宿主一旦改 token 名，
  变量照样在、值照样对，但页面已无规则引用它们，探针恒绿而界面毫无异样。现在额外比对 **body 实际背景色**
  与本层画布色（`--dsw-static-neutral-bluish-00` / `950`；黑白档不覆盖这两步，期望值回落宿主原值；
  `forced-colors` 下跳过）。这条判据依赖的链路由 `tools/check-host-contract.mjs` 的 3 条断言钉住
  （`--dsw-alias-bg-base` → 色阶 00/950 → `body` 背景色）。
- **自动恢复停用要说出来（1.12.0 P0-B）**：连续失败 5 次后插件会放弃自动重放，此前只有一条 `console.error`，
  设置页看不出区别、用户改设置也不复位（需刷新）。现在 `healDisabled` / `healFails` 进入健康快照，
  设置页显示提示条并提供「重新启用自动恢复」按钮（`resetHeal()`），诊断 JSON 同样带这两个字段。
- **同色重放不算「层丢了」（1.12.0 P0）**：宿主 `overrideTokens` 会**同步** emit `theme/change`，
  重入时自愈会把刚装上的层自己撤掉（换一次配色 `overrideTokens` 被调 3 次）。现在 `applyAll` 期间置
  `applying` 标志、`ensureApplied` 期间一律不判定「层丢了」，且 `prev = disposeLayer` 的取值提到
  `overrideTokens` 之前。
- **升级提示与版本联动**：`notice.v1` 只记「已读版本」，与配色存储分离；四语 notice 都必须含当前
  `PLUGIN_VERSION`，由 `check-ui` 断言（改版本忘改文案会红）。

## 国际化（i18n）

### 界面文案：4 语内联在 `client.js`

- **语种**：简体中文 `zh-CN` / 繁體中文 `zh-TW` / English `en` / 日本語 `ja`，约 70 个键内联在
  `client.js` 的 `TEXT`（界面文案）与 `LABELS`（配色/模式名）里 —— 不是 `locale/*.json`（见下）。
- **回退链**：用户手选 > 宿主语言（只认 zh / ja / en 三族）> **`en`**。
  兜底是 en 不是 zh-CN：宿主语言不在上述三族时（fr / de / es / pt…）给整页中文，对国际用户是最差的选择
  （1.11.0，`pickLocale`）。组件级回退也必须同口径 —— 1.13.2 把 `TEXT[locale] || TEXT['zh-CN']`
  改成 `|| TEXT['en']`：同一文件里两套回退口径，一旦某条 locale 缺键，界面会在这个组件里偷偷跳回中文。
- **`<section lang>` 用解析后的 `locale`**（1.13.2）：此前 `auto` 时取宿主 `documentElement.lang`，
  宿主是 fr 时面板标 `lang="fr"` 却渲染英文，屏幕阅读器按法语规则念英文。
- **色觉提示写「OKLab ΔE」**（1.13.2）：`deltaE()` 是 OKLab 欧氏距离 ×100，不是 CIE ΔE
  （纯红 vs 纯绿：本函数 51.98，CIE76 170.58）；文案照实标注，避免被当成 CIE 指标比阈值。
- **断言**（`check-ui`）：六语 README 的 `## ` 标题逐字一致、`## What it does` 要点条数一致、
  四语 notice 各一条且含当前版本号、示例短码可被生产编码器往返自等。

### `locale/*.json` 是**市场页元数据**，不是 UI 文案

7 份文件（en / zh / zh-TW / ja / es / pt / hi）只装 `meta.title` 与 `meta.description`，
供插件目录 / 市场页显示摘要；运行时没有任何代码读它们，界面文案全部内联在 `client.js`。
之所以不把 UI 文案搬进来：客户端模块是浏览器侧代码，读不了文件系统，搬过去只能变成"构建期再内联回来"的一层
中间物；而真正的收益（翻译者不碰 JS）需要一次构建步骤，1.13.2 不做。

既然留下了，就把口径钉住（`check-ui`）：① 每份只有 `meta.title` / `meta.description`；
② 每份顶层 key 完全一致；③ **每种 README 语言都有对应 locale 元数据**，且 UI 四语齐全。

### README：6 语

`README.md`（en）/ `-zh` / `-ja` / `-es` / `-pt` / `-hi`。UI 支持的每门语言（en / zh / ja）必须有 README；
es / pt / hi 是额外翻译，可以多但不能少。1.13.2 补了 `README-ja.md`（此前 UI 有日语而 README 没有，
`locale/ja.json` 倒是早就有了 —— 三套集合互不覆盖）。
