Files
crearte-monorepo/docs/specs/2026-10-03-p13-dark-mode-design.md
T
XingfenD 14d029e61b docs: register P13 dark mode (crearte 0.26.0) + server micro-batch (0.17.2)
Brings the P13 batch into this wrapper's version control: spec and plan enter
the repo, ROADMAP gains the P13 row and its document-index entry, and the four
stale 'C dark mode' backlog mentions carried since P9/P10/P9-B/P12 are marked
cleared in place — following the P12 precedent of annotating the historical row
rather than rewriting it, since those entries were true when written.

Wrapper CHANGELOG 0.3.6 records the crearte-only dark-mode delivery
(983e7c4, merge-base e59a171), the parallel server micro-batch delivered during
the survey phase while the implementer owned crearte exclusively (dbf7fe5,
0.17.2: a real uploads.go error-fallthrough defect with zero prior coverage,
plus a decision-record comment on the bundle-key route after grep overturned the
initial suspicion of a hole), and three lessons:

- max(a,b) >= k is a vacuous-assertion hot zone: when the two sides are
  complementary the max has a non-trivial lower bound (here sqrt(16.50) =
  4.0621), so any threshold below it can never fail. The controller's own first
  correction to the scrim guard shipped exactly that, and the same
  one-directional verification recurred in the alpha fallback. Fixing a guard
  now requires proving both no false-red on legal values and no false-green
  under mutation.
- Tailwind v4 scans every source file including test files, so a class-name
  literal in a test comment burns a dead utility into the artifact.
- A universal claim needs a universal grep: 'accent is the only background use'
  was false because both the spike-0 grep and the new guard covered app/ while
  runtime/ sits beside it. That blind spot cost a false spec fact and hid a real
  pre-existing WCAG violation (paper on accent = 3.2590 in light, since P9-B).

ROADMAP's P13 row cites merge commit 983e7c4 per the P12 convention, with the
bookkeeping commits named separately so the reference cannot be mistaken for
them.
2026-10-03 07:30:12 +08:00

531 lines
45 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P13 设计:暗色模式(Dark Mode)
- **状态**:已定稿,待实现
- **日期**:2026-10-03
- **批次**:P13(UX 第三批 / 挂账清偿)
- **范围**:仅 `crearte`(前端)。**零后端改动、零部署改动**——主题纯粹是客户端表现层。
- **依据**:`.superpowers/sdd-p13/FINDINGS-survey.md`(7 轮 spike 实测归档,gitignored)。本 spec 的**每个数字都出自该文件**,不得凭推断增删。
- **前置**:P12 已闭合(crearte `e59a171`)。本批**必须保持 P12 的 12 条 responsive 守卫腿与 `AppHeader.test.ts` 的 F3 形态钉桩全绿**。
---
## 0. 目标与非目标
**目标**:为全站提供暗色主题,满足 WCAG 2.1 AA(正文 ≥4.5、大字号 ≥3.0、非文本 1.4.11 ≥3.0),保留 neo-brutalist 的「墨与纸 + 硬阴影」设计身份,并把配色约束从口头纪律变成**双主题可执行守卫**。
**非目标**:不做 SSR/预渲染、不做 per-route 主题、不做用户自定义配色、不改品牌标识(logo 与 wordmark 的形态与配色语义)、不动 `COVER_COLORS` 生成色板(实测主题无关,见 FINDINGS 第 0 轮)。
---
## 1. 现状(全部实测,非推断)
### 1.1 令牌架构支持运行时翻转(方案前提,spike 0)
Tailwind v4 把色令牌输出到 `:root,:host{--color-paper:#f7f2e7;…}`,而 utility **引用 var 而非内联 hex**:
```css
.bg-paper{background-color:var(--color-paper)}
.text-ink{color:var(--color-ink)}
.border-ink{border-color:var(--color-ink)}
```
构建产物 `dist/assets/main-*.css`(32726 B)实测:`var(--color-*)` 引用 **64 处**;内联 hex 仅 `#141414` **11 处**(5 个 `--shadow-hard*` 令牌 + wordmark 关键帧)、`#e8552f` 3 处、`#f7f2e7` 2 处、`#f5c518` 1 处、`#ffffff` 0 处。
→ **运行时令牌翻转方案成立**;需要处理的是那 11+3 处内联 hex(阴影令牌,见 D-D)与三处组件级硬编码(见 §1.3)。
### 1.2 硬编码破面(暗色下不会翻转的地方)
| # | 位置 | 现状 | 暗色下的后果 |
|---|---|---|---|
| 1 | `main.css:19-23` 五个 `--shadow-hard*` | 内联 `#141414` ×3 / `#e8552f` ×2 | 硬阴影在深底上**隐形** → neo-brutalist 身份丢失 |
| 2 | `main.css:44` `html { color-scheme: light }` | 硬编码 light | 原生控件(`<select>`、`<input>`、滚动条)不翻转 |
| 3 | `StatePanel.vue:19,20,21,30,32,33,34` | `bg-[#EFE9DA]` ×7 | 骨架屏在暗色下是**七块亮色斑** |
| 4 | `FilterDrawer.vue:32` | `backdrop:bg-ink/60` | 见 §1.4,遮罩**泛白** |
| 5 | `index.html:7,8` | `color-scheme` / `theme-color` meta 硬编码 light / `#F7F2E7` | 浏览器 UI 与首屏不跟随 |
### 1.3 配色配对的真实站点分布(决定方案选型)
grep 实证的配对用量(**这是方案 B 胜出的依据**):
| 配对 | 站点数 | 说明 |
|---|---|---|
| `bg-highlight` + 文字为 `ink`(显式或继承) | **29** | 8 处 error alert、markdown `strong`、`::selection`、toast info、FilterSidebar/DocSidebar/BaseSelect/BaseTabs 选中态、GameCard 角标、AppHeader sticker、skip-link |
| `bg-accent-ink` + `text-paper` | **11** | badge / 按钮 / error toast |
| `bg-success` + `text-paper` | 1 | toast success |
| `bg-ink` + `text-paper` | 多处 | `.btn-ink`、markdown `th`、logo |
| `text-accent-ink` on paper | 多处 | 链接 |
| `.wordmark-label` = `accent-ink` on `highlight` | 1 | 品牌标签 |
| `bg-accent` + `text-ink` | **1** | `ResultMeta.vue:52` 折叠角标(`app/` 下唯一把 accent 当背景用的地方) |
| `bg-accent` + `text-paper` | **1** | ⚠️ **终审 N1 补录(2026-10-03)**:`runtime/host/GameHost.vue:39` 的「已降级外链」徒标(渲染于 `:90`,11px bold)。spike 0 的 grep 只扫了 `app/`、**漏了 `runtime/`**(GameHost 与 `app` 同级,经 `app/views/GameView.vue:120 <GameHost>` 在 SPA 路由内、受暗色块作用域)。亮色 `paper on accent` = **3.2590 < 4.5 FAIL**(暗色 7.1218 PASS)——**P9-B 以来就存在的 pre-existing 违规,P13 未使其变差**,且不在 P13 范围(P13 是暗色主题,非修所有既有亮色 WCAG)。已登记挂账(可访问打磨批);`contrast.test.ts` 的 AA_PAIRS **不含** `paper on accent`,故该配对目前无守卫 |
| `bg-info` | **0** | info 令牌无背景用途(`text-info` 亦 0) |
### 1.4 暗色下的两个**必然**缺陷(若照抄亮色语义)
**① 亮色块上的深字失效。** 暗色下 `ink` 变浅(`#f7f2e7`),若 `highlight` 仍是亮黄 `#f5c518`,则 `ink on highlight` = **1.46 ❌**(29 处站点全废)。实测反面:`ink on accent` 暗色 = **2.31 ❌**(亮色侥幸 5.06 ✅)。
**② 遮罩泛白。** `backdrop:bg-ink/60` 的产物是 `color-mix(in oklab, var(--color-ink) 60%, transparent)`,暗色下实测解析为 **`oklab(0.962022 0.00101227 0.0155178 / 0.6)`** —— L≈0.96 即近白,遮罩失去遮罩功能。
### 1.5 header 像素预算(主题开关的落点约束,spike 1–3)
P12 spike 17 已测得 @320px 登录态 summary 右缘 = 311px。本轮实测 baseline 几何(@320px 登录态 ascii60 `/account`):
```
inner=288 a(logo)=77 + nav=74 + details=96 + gaps(24×2)=48 = 295 > 288 slack = −7
```
即**该格已无收缩余量**。注入 32px 开关后 `docOverflow = +47` ❌(P12 的 12 条守卫腿会全红)。四格实测:
| 格 | 注入 32px 开关后 docOverflow |
|---|---|
| @320 登出态 | ✅ 0(flex 收缩吸收) |
| @320 登录态 ascii60 | **❌ +47** |
| @375 登出态 | ✅ 0 |
| @375 登录态 ascii60 | ✅ 0 |
**唯一失败格 = @320px + 登录态 + 60 字长名**(正是 P12 守卫矩阵的最坏格)。
**关键约束**:`AppFooter.vue` 实测**无任何导航**(全文只有两行文字 span)。→「移动端隐藏 header nav」会让手机用户彻底失去导航,**不可行**。
### 1.6 守卫的既有缺陷(P12「守卫自身要受同等审视」同类)
`app/lib/contrast.test.ts` 的 `parseTokens()` 用**单个全局 Map + 全局正则**:
```ts
const re = /--color-([\w-]+):\s*(#[0-9a-fA-F]{6})/g
for (const m of css.matchAll(re)) map.set(m[1], m[2])
```
加入暗色块后 `matchAll` 会同时命中 `@theme`(亮)与 `html[data-theme="dark"]`(暗),`map.set` **后写覆盖** → 九个 `REQUIRED_KEYS` 全解析成暗色值,而断言标签仍写「light palette」→ 守卫**静默变成只守暗色、完全不守亮色**。
这是**假信心**,与 P12 的「注释掉包裹层守卫仍绿」、P11-N6 的「恒不匹配的死断言」同一类。必须在加暗色块**之前**重构(见 T1)。
---
## 2. 决策表
| # | 决策 | 选型 | 依据(FINDINGS 轮次) |
|---|---|---|---|
| **D-A** | 配色策略 | **方案 B:亮块反向翻转**。文字恒用 `ink`/`paper`(随主题翻),语义块反向翻转以维持对比 | 第 7 轮。迁移 **3 处** usage vs 方案 A(新增 `on-bright` 令牌)的 **32+ 处**;29 处 `bg-highlight`、12 处 `bg-accent-ink text-paper`、wordmark、`::selection`、`strong`、toast、`.btn-ink` **全部零改动** |
| **D-B** | 暗色调色板 | 见 §3.1 全量 12 令牌 | 第 7 轮:双主题 **12 条真实配对全 PASS**,零 FAIL(⚠️ 原写 14 为计数笔误,终审 N5:§5-T1.3、FINDINGS 第 7 轮配对表、代码 AA_PAIRS 三方一致为 **12**) |
| **D-C** | 暗色块选择器 | **必须 `html[data-theme="dark"]`**(特异性 (0,1,1))。**禁用**裸 `[data-theme="dark"]` | 第 4 轮:Tailwind 输出到 `:root,:host`((0,1,0))。裸属性选择器同特异性,实测虽生效但**靠源码顺序取胜**,Tailwind 层顺序一变即失效 |
| **D-D** | 阴影令牌 | 五个 `--shadow-hard*` 的内联 hex → `var(--color-ink)` / `var(--color-accent)`。**无需** per-utility 暗色覆盖规则 | 第 6 轮决定性实测:源码改 var + **真重建**后,暗色下 `.shadow-hard` 的 `box-shadow` 实测变 `rgb(247,242,231)`、`shadow-hard-accent` 变 `rgb(255,122,77)` → `--tw-shadow` 保留 var 引用,硬阴影自动跟随 |
| **D-E** | scrim | 新增 `--color-scrim: #0d0b08`,**两主题同值、不参与翻转**;`FilterDrawer` 的 `backdrop:bg-ink/60` → `backdrop:bg-scrim/80` | 第 4 轮:暗色下 `color-mix(...var(--color-ink)...)` 解析为 `oklab(L=0.962)` **泛白** |
| **D-F** | scrim 分离判据 | 分离由哪一侧提供是**主题相关**的,故守卫**按主题钉实际分离侧**:亮色 = **填充侧** `paper`(17.60;边框侧 `ink` 仅 1.07)、暗色 = **边框侧** `ink`(17.60;填充侧 `paper` 仅 1.07,分离靠 `border-t-[3px] border-ink`)。两侧阀值均 ≥ 3 | 第 7 轮 + **审查 re-pin**。暗色钉边框侧与本设计语言一致(实测亮色 `surface(#ffffff) vs paper(#f7f2e7)` 仅 **1.1165**、暗色 `surface(#221e18) vs paper(#17140f)` 仅 **1.1080**,而卡片边界仍清晰,靠 2px ink 边框)。⚠️ 原写「P12 已实测…仅 **1.06**」不可复现且 P12 文档查无出处(终审 N7)——控制者全仓 `grep -F '1.06'` 仅命中本 spec 与 FINDINGS 自己,实测值实为 1.1165,故已纠正;该数字不影响 D-F 决策方向(1.06 还是 1.12 都 <3,都靠边框分离)。⚠️ **不得写成 `max(填充侧, 边框侧) ≥ 3`**:两侧互补,对任意 scrim 色 c 都有 `max(cr(paper,c), cr(ink,c)) ≥ √cr(paper,ink)` = √16.50 = **4.0621 > 3** → **数学恒真、永不可能失败**(node 暂力验证 50653 个采样色,亮色最小 4.0621 @#eb0541、暗色 4.0560 @#0f78d2)。实证:把亮色 scrim 改成纯白(正是 D-E 要防的泛白),填充侧 `paper vs 白` = **1.1165**(面板与遮罩真的不可辨)但 max() = 18.42 → 守卫仍绿 |
| **D-G** | skeleton | 新增 `--color-skeleton`(亮 `#EFE9DA` / 暗 `#2c2721`),`StatePanel.vue` 7 处硬编码 → `bg-skeleton` | 第 0/7 轮。装饰性(无 WCAG 要求)但须可见:vs paper 亮 1.08 / 暗 1.24 |
| **D-H** | 守卫重构 | `contrast.test.ts` 改为**按块解析**(亮/暗各自 Map、各自断言),RED 先行 | §1.6。不重构则守卫静默失效 |
| **D-I** | 开关落点 | header 行 `gap-6` → **`gap-2 sm:gap-6`**,nav `gap-4` → **`gap-2 sm:gap-4`**,开关 32px(`h-8 w-8`)追加在行尾 | 第 3 轮候选 B:最坏格 **margin=16**(= 容器 `px-4` 右内边距,内容恰好填满 inner box)。**不触碰 `max-w-[6rem]`** → P12 F3 钉桩与 12 条守卫腿原样保绿;不动 logo、不动 nav 结构;`sm:`(640px) 以上零视觉变化 |
| **D-J** | 主题状态模型 | **两态**(`light` ↔ `dark`);**首次访问跟随 `prefers-color-scheme`**;用户显式点击后持久化到 `localStorage['crearte.theme.v1']` | §4 边界:第三态「恢复跟随系统」本批**有意不做**(spike 3 证明 header 无像素容纳带标签的控件;放 footer 会把同一控件拆到两处) |
| **D-K** | 防 FOUC | `index.html` `<head>` 内**内联 pre-paint 脚本**,在 CSS 之前设置 `data-theme` 与 `theme-color` meta | 第 4 轮:SPA 首屏在 Vue 挂载前就会绘制,若等 composable 则暗色用户每次刷新闪白 |
| **D-L** | `color-scheme` | `main.css` 的 `html { color-scheme: light }` → 由 `[data-theme]` 驱动(亮 `light` / 暗 `dark`) | §1.2-2。原生控件与滚动条须随主题 |
### 2.1 被否方案(附实测理由,防止后人重提)
| 方案 | 否决理由 |
|---|---|
| **方案 A:新增 `on-bright` 令牌**(亮块两主题都保持亮,文字恒深) | 须迁移 **29 处** `bg-highlight` + wordmark + `::selection` + `strong`(第 7 轮)。方案 B 只需 3 处,且 B 的语义更简单(文字恒 `ink`/`paper`) |
| **暗色 highlight 深化到 `#5c430c` / `#4d380a`** | `ink/hl` 高达 8.31/9.98,但 `hl vs paper` 仅 1.98/1.65 → **选中态与页面底色几乎同色**,FilterSidebar/DocSidebar/BaseSelect/BaseTabs 的选中态视觉消失(第 7 轮) |
| **暗色 highlight 取 `#96701a`** | `ink/hl` = **4.06 ❌** < 4.5,8 处 error alert 正文不达标(第 7 轮) |
| **开关放 `S4`:移动端隐藏 nav** | `AppFooter.vue` 实测**无导航** → 手机用户彻底失去导航(第 1 轮) |
| **开关放 `S6`:塞进用户下拉菜单** | **登出态用户无法切换主题**(`<details>` 仅在 `user` 存在时渲染)(第 2 轮) |
| **开关放 `S5`/`S7`/`S8`:瘦身 logo** | @320 实测 `S5` **+38 ❌**;且去掉「创艺」副标损毁品牌标识(第 2 轮) |
| **`S1`:仅 `gap-6`→`gap-2`** | 达标但 **margin 仅 1px**(sumRight=319 vs 视口 320)。P12 正是因 768px 只剩 ~9px 余量引发一整轮归因调查 —— 1px 不可接受(第 2/3 轮) |
| **`S3`/`A`/`D`/`E`/`G`:缩 `max-w-[6rem]`** | 全部 margin=16 达标,但**触碰 P12 的 F3 形态钉桩**(`AppHeader.test.ts` 三处断言 + spec §5 T1 钉的形态)。候选 B 同样 margin=16 且不动钉桩 → 选 B(第 3 轮) |
| **wordmark 暗色改用 `on-bright` / 深化 highlight** | `.wordmark-label` 不声明 font-size,继承 `<h1 class="text-4xl sm:text-5xl md:text-6xl font-black">`(`LandingView.vue:47`)= 36px+/900 → **WCAG 大字号,阈值 3.0**。实测亮 3.34 ✅ / 暗 3.15 ✅(accent-ink on highlight),**零改动**。且 3.34 是现网既有值、P9-B 守卫从未钉它 → 要求 4.5 等于越界改品牌标识(第 7 轮) |
| **构建期双 CSS(两份产物切换)** | spike 0 已证 utility 全走 `var()`,运行时翻转即可;双产物会使缓存与首屏逻辑复杂化,无收益 |
| **运行时注入改阴影令牌**(我曾试过) | **方法错误**:Tailwind 把 shadow 编译成 `--tw-shadow:<构建期烘焙值>`,运行时改令牌无效(第 5 轮实测:令牌已变 `#f7f2e7` 而 `box-shadow` 仍 `rgb(20,20,20)`)。必须改源码 + 真重建(第 6 轮) |
---
## 3. 实现
### 3.1 `crearte/src/app/styles/main.css`(D-B / D-D / D-E / D-G / D-L)
**(a) `@theme` 内新增两个令牌**(亮色值),并把五个阴影令牌改为 `var()`:
```css
@theme {
--color-paper: #f7f2e7;
--color-surface: #ffffff;
--color-ink: #141414;
--color-ink-soft: #5c584d;
--color-ink-faint: #6f6a5c;
--color-accent: #e8552f;
--color-accent-ink: #c03a1b;
--color-highlight: #f5c518;
--color-info: #2b62cc;
--color-success: #1f7a4d;
/* P13 新增 */
--color-skeleton: #efe9da; /* 原 StatePanel 硬编码 bg-[#EFE9DA] */
--color-scrim: #0d0b08; /* 遮罩专用,两主题同值,不参与翻转(D-E) */
/* P13:内联 hex → var(),使硬阴影随主题翻转(D-D,spike 6 实测传导成立) */
--shadow-hard-sm: 3px 3px 0 var(--color-ink);
--shadow-hard: 4px 4px 0 var(--color-ink);
--shadow-hard-lg: 6px 6px 0 var(--color-ink);
--shadow-hard-accent: 4px 4px 0 var(--color-accent);
--shadow-hard-accent-lg: 6px 6px 0 var(--color-accent);
/* 其余(--font-*、--animate-skeleton、@keyframes)原样不动 */
}
```
**(b) 暗色令牌块**(追加在 `@theme` 之后、`@font-face` 前后均可,但**必须**用 `html[data-theme="dark"]`,D-C):
```css
/* P13 暗色调色板(D-B)。选择器特异性 (0,1,1) 必须压过 Tailwind 的 :root,:host (0,1,0)——
写成裸 [data-theme="dark"] 虽也能生效,但特异性相同、靠源码顺序取胜,脆弱(spike 4 实测)。 */
html[data-theme="dark"] {
--color-paper: #17140f;
--color-surface: #221e18;
--color-ink: #f7f2e7;
--color-ink-soft: #cbc4b5;
--color-ink-faint: #a79f8e;
--color-accent: #ff7a4d;
--color-accent-ink: #ffb59a;
--color-highlight: #8a6412;
--color-info: #7aa7f0;
--color-success: #2fa46a;
--color-skeleton: #2c2721;
--color-scrim: #0d0b08; /* 与亮色同值:遮罩不翻转(D-E) */
color-scheme: dark; /* D-L:原生控件与滚动条随主题 */
}
```
**(c) `@layer base` 的 `color-scheme` 改为属性驱动**(D-L):
```css
@layer base {
html { color-scheme: light; } /* 保留:默认亮色 */
html[data-theme="dark"] { color-scheme: dark; } /* 新增;若已写在暗色块内则此处不必重复,二选一,实现者按实测择一并在注释说明 */
/* 其余(border-radius 归零、::placeholder、::selection、:focus-visible、dialog overflow)原样不动 */
}
```
> **注意**:`::selection { background: var(--color-highlight); color: var(--color-ink); }` 与 `.markdown-body strong`(`main.css:169` 附近)、`.wordmark-label`(`:107` 附近)**全部零改动** —— 方案 B 的核心收益(D-A)。实测双主题:`ink on highlight` 亮 11.30 / 暗 4.81 ✅;`accent-ink on highlight`(wordmark)亮 3.34 / 暗 3.15 ✅(大字号阈值 3.0)。
**(d) wordmark 关键帧里的 `rgb(20 20 20 / 0)`**(`box-shadow: 0 0 0 0 rgb(20 20 20 / 0)`):这是**全透明**起始值,颜色不可见,**无需改动**。若实现者改为 `var()` 亦可,但不得改变其透明语义。
### 3.2 `crearte/src/app/components/ResultMeta.vue:52`(D-A 迁移 1/3)
```diff
- class="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center bg-accent px-1 font-mono text-[0.625rem] text-ink"
+ class="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center bg-accent-ink px-1 font-mono text-[0.625rem] text-paper"
```
**理由**:这是 `app/` 下**唯一**把 `accent` 当背景用的地方(spike 0 grep:`bg-accent` 排除 `bg-accent-ink` 后仅 1 处)。迁移后 `accent` 在 `app/` 内成为**纯非文本令牌**(焦点环 + h3 左边框 + li 圆点),语义干净。
> ⚠️ **本节原文有误,已由全分支终审 N1 发现并经控制者独立重算证实(2026-10-03)**:原文写「全仓**唯一**」「迁移后 `accent` 成为**纯非文本令牌**」——**两句均为假**。`runtime/host/GameHost.vue:39/47/122` 有 **3 处 `bg-accent` 活引用**(base `e59a171` 既有、P13 未触碰),其中 `:39→:90` 是 `bg-accent text-paper` 的**文本徒标**(11px bold < 18.66px → 阈值 4.5)。根因:spike 0 的 grep 与 `noHardcodedColor` 守卫都只覆盖 `app/`,**漏了与 `app` 同级的 `runtime/`**。
> **后果**:① accent 迁移后**并非**纯非文本令牌;② 一条真实 WCAG 违规无守卫覆盖:亮色 `paper on accent` = **3.2590 < 4.5 FAIL**(暗色 7.1218 PASS)。该违规自 P9-B 就存在(旧 AA_PAIRS 钉的是 `ink on accent`,也非 `paper on accent`),**P13 未使其变差**,不属 P13 范围。
> **已处置**:① 本节与 §1.3 配对表已纠正(见上);② GameHost 亮色徒标 3.26 已登记为挂账(pre-existing WCAG 违规,归可访问打磨批,**不在 P13 合并前修**以免扩大已审 diff);③ `noHardcodedColor` 扫描根已扩到 `app/` + `runtime/`(commit `bb2cb42`,N4)。
> **教训**:「全仓唯一」这类全称声明必须用**全仓范围**的 grep 支撑,不能用子目录的 grep 得出。spike 0 的盲区直接导致了 spec 事实错误 + 守卫覆盖缺口两层后果。反面实测:若不迁移,暗色 `ink on accent` = **2.31 ❌**(亮色 5.06 侥幸过)。改用 `accent-ink`/`paper` 后与其他 11 处 badge 统一,双主题 `paper on accent-ink` = 亮 4.87 / 暗 10.78 ✅。
**注意**:`ResultMeta.vue:22` 的 `relative min-w-0`(P12 T3 的缺口 C 修复)**不得改动**。
### 3.3 `crearte/src/app/components/StatePanel.vue`(D-G 迁移 2/3)
7 处 `bg-[#EFE9DA]` → `bg-skeleton`(行 19/20/21/30/32/33/34)。**其余(`border-2 border-ink`、`animate-skeleton`、`aspect-video`、`shadow-hard`)原样不动。**
### 3.4 `crearte/src/app/components/FilterDrawer.vue:32`(D-E 迁移 3/3)
```diff
- class="m-0 mt-auto max-h-[85vh] w-full max-w-none overflow-y-auto border-t-[3px] border-ink bg-paper backdrop:bg-ink/60"
+ class="m-0 mt-auto max-h-[85vh] w-full max-w-none overflow-y-auto border-t-[3px] border-ink bg-paper backdrop:bg-scrim/80"
```
**理由**:spike 4 实测暗色下 `color-mix(in oklab, var(--color-ink) 60%, transparent)` → `oklab(L=0.962)` **泛白**,遮罩失效。`scrim` 是固定深色(两主题同值),故两主题都正常遮罩。**透明度从 60% 提到 80%**:`scrim`(#0d0b08) 比 `ink`(#141414) 更深,80% 保持亮色下的既有观感强度(spike 4:亮色 `paper vs scrim` = 17.60,比原 60% ink 叠纸更深,层次更强,非回归)。
> ⚠️ **原引数字已纠正(终审 N6,2026-10-03)**:原文写「近似实色 **5.12**」,但该值**不可复现**——控制者用三种混合法独立重算 `60% ink(#141414) over paper(#f7f2e7)` 的 `cr(paper, ·)`:gamma 空间线性混合 = **4.6292**、线性光(物理正确)= **2.2842**、`color-mix(in oklab, ink 60%, paper)`(Tailwind 实际产出)= **5.3691**,无一种得 5.12。原数字未注明算法且无法复现,故删除具体值、只保留方向结论(scrim 80% 混合后 cr=11.64 确实比 ink 60% 更深,非回归,该结论不受影响)。**教训:spec 里每个实测数字都要么注明算法、要么可被守卫/测试复现;不可复现的孤立数字会被终审当缺陷抓出来。**
> 若实现者实测认为 80% 在亮色下过重,可回调至 70%,但**必须在报告里附两主题的实测截图或计算值**,不得凭感觉。
### 3.5 `crearte/src/app/composables/useTheme.ts`(D-J,新增)
沿用 `app/lib/recent.ts` 的 localStorage 容错范式(配额/隐私模式写失败静默、损坏值读回默认)。
```ts
import { ref, type Ref } from 'vue'
export type Theme = 'light' | 'dark'
export const THEME_KEY = 'crearte.theme.v1'
/** 读持久化选择;缺失或损坏 → null(表示「跟随系统」)。 */
export function readStoredTheme(): Theme | null {
try {
const v = localStorage.getItem(THEME_KEY)
return v === 'light' || v === 'dark' ? v : null
} catch {
return null
}
}
/** 系统偏好;不支持 matchMedia 的环境(含 happy-dom 部分场景)→ 'light'。 */
export function systemTheme(): Theme {
try {
return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
} catch {
return 'light'
}
}
/** 把主题落到 <html data-theme> 与 theme-color meta(D-K 的内联脚本做首屏,本函数做后续切换)。 */
export function applyTheme(theme: Theme): void {
document.documentElement.setAttribute('data-theme', theme)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', theme === 'dark' ? '#17140f' : '#f7f2e7')
}
// 模块级单例(与 useToast 同范式)。⚠️ P9-B D-I 教训:单例须配 __resetTheme() 供测试隔离。
const theme: Ref<Theme> = ref(readStoredTheme() ?? systemTheme())
export function useTheme() {
function toggle(): void {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
try {
localStorage.setItem(THEME_KEY, theme.value)
} catch {
/* 配额/隐私模式:静默,主题本次会话内仍生效 */
}
applyTheme(theme.value)
}
/** 当前是否为用户显式选择(false = 仍在跟随系统)。供 aria-label 措辞与测试用。 */
const isExplicit = () => readStoredTheme() !== null
return { theme, toggle, isExplicit }
}
export function __resetTheme(): void {
theme.value = readStoredTheme() ?? systemTheme()
}
```
**要求**:
- `theme` 的**初始值**必须与 `index.html` 内联脚本的判定逻辑一致(同一优先级:stored → system → light),否则首屏与挂载后会闪一下。
- `applyTheme` 须在 composable 首次被使用时调用一次(`App.vue` 的 `onMounted` 或 `useTheme()` 内),以覆盖内联脚本未执行的场景(如 e2e 直接注入 DOM)。
- 不做 `matchMedia` 的 `change` 监听(用户已显式选择后系统切换不应覆盖;未显式选择时刷新即跟随)——**在代码注释里写明这是有意的**。
### 3.6 `crearte/src/index.html`(D-K)
```diff
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <meta name="color-scheme" content="light" />
- <meta name="theme-color" content="#F7F2E7" />
+ <meta name="color-scheme" content="light dark" />
+ <meta name="theme-color" content="#F7F2E7" />
<meta name="description" content="crearte 创艺——互动小说与浏览器小游戏托管社区,收录可直接游玩的作品目录。" />
<title>crearte 创艺</title>
+ <!-- P13 D-K:pre-paint 主题脚本,必须在 CSS 与 Vue 之前执行,否则暗色用户每次刷新闪白。
+ 判定优先级与 useTheme.ts 逐字一致:stored → prefers-color-scheme → light。 -->
+ <script>
+ (function () {
+ try {
+ var stored = localStorage.getItem('crearte.theme.v1')
+ var theme =
+ stored === 'light' || stored === 'dark'
+ ? stored
+ : window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light'
+ document.documentElement.setAttribute('data-theme', theme)
+ var meta = document.querySelector('meta[name="theme-color"]')
+ if (meta) meta.setAttribute('content', theme === 'dark' ? '#17140f' : '#f7f2e7')
+ } catch (e) {
+ document.documentElement.setAttribute('data-theme', 'light')
+ }
+ })()
+ </script>
</head>
```
> `<script>` 必须放在 `<head>` 内、且在 `<body>` 之前(现结构已满足:body 里才有 `<script type="module">`)。用 `var` 与 IIFE,不依赖 ES module 时序。**CSP 注意**:本批不引入 CSP(P11 已判定需独立设计批),内联脚本当前可用;若将来上 CSP,此脚本需 `nonce` 或外置——**在脚本注释里留一句提示**。
### 3.7 `crearte/src/app/components/AppHeader.vue`(D-I)
**(a) 容器行与 nav 的 gap**:
```diff
- <div class="mx-auto flex h-14 w-full max-w-6xl items-center gap-6 px-4">
+ <div class="mx-auto flex h-14 w-full max-w-6xl items-center gap-2 px-4 sm:gap-6">
```
```diff
- <nav class="flex gap-4 text-sm font-bold">
+ <nav class="flex gap-2 text-sm font-bold sm:gap-4">
```
**(b) 主题开关**:追加在 header 行**末尾**(`<template v-if="authEnabled">` 之后,即 `</div>` 之前):
```vue
<button
type="button"
data-testid="theme-toggle"
class="flex h-8 w-8 shrink-0 items-center justify-center border-2 border-ink bg-surface text-sm font-bold"
:aria-label="themeLabel"
:title="themeLabel"
@click="toggle"
>
<span aria-hidden="true">{{ theme === 'dark' ? '☀' : '☾' }}</span>
</button>
```
`<script setup>` 内:
```ts
import { useTheme } from '@/composables/useTheme'
const { theme, toggle } = useTheme()
const themeLabel = computed(() =>
theme.value === 'dark' ? '切换为亮色主题(当前:暗色)' : '切换为暗色主题(当前:亮色)'
)
```
**约束(必须逐条满足,均有 spike 依据)**:
- 开关尺寸**必须** `h-8 w-8`(32px)。spike 3 实测 28px(`h-7`)在最坏格 margin 仅 5px(过紧),32px 为 16px。
- **不得**改动 `<summary>` 的 `max-w-[6rem] min-w-0` 与内部两 span 结构(P12 F3 钉桩 + `AppHeader.test.ts` 三处断言 + `responsive.spec.ts` 的 caret/accname 腿)。
- **不得**改动 logo 的文字与字号(spike 2 的 S5/S7/S8 瘦身方案已否)。
- `shrink-0` 必须有,否则开关会被 flex 压缩。
- 图标字形(☀/☾)包 `aria-hidden`,可访问名由 `aria-label` 提供 —— 与 P9-B 的星形按钮、P12 的 caret 同一范式。**`aria-label` 必须同时说明当前态与动作**(两态开关无可见标签,AT 用户须能听到状态)。
**(c) 布局余量核对**(实现后必须实测,不得凭算术):spike 3 已测 @320px 登录态 ascii60 在此方案下 `margin=16`、`docOverflow=0`;@375/@320-short/@375-short 全 `margin=14~16`。**实现后由 `responsive.spec.ts` 既有 12 腿 + T5 新增暗色腿复验。**
### 3.8 `crearte/src/app/App.vue`
在 `<script setup>` 中调用一次 `useTheme()` 并 `applyTheme(theme.value)`(或 `onMounted`),确保内联脚本未生效的场景(e2e 直接操作 DOM、SSR-less 的极端时序)也能落地主题。**不改模板结构**(skip-link 仍是根 div 首子,P9-B 钉桩)。
---
## 4. 边界(不做的事,附理由)
| 不做 | 理由 |
|---|---|
| **第三态「恢复跟随系统」** | spike 3 证明 header 在 @320px 最坏格无像素容纳带标签的控件;放进 footer 会把同一控件拆到两处;三态循环(light→dark→system)在 32px 无标签图标按钮上不可发现,且「暗色回亮色要点两次」是已知反模式。**登记为打磨批候选**。 |
| **per-route / per-组件主题** | 无需求;令牌是全局的,局部覆盖会破坏对比守卫的可判定性 |
| **用户自定义配色 / 色板选择器** | 超出本批;且自定义色无法保证 WCAG,等于拆掉守卫 |
| **SSR / 预渲染 / OG 卡片** | OG 是独立挂账批(需服务端注入或预渲染,nginx 当前无 `try_files` 配置面) |
| **改 `COVER_COLORS` 或 `GameCover` 的 `text-white`** | spike 0 实测:inline style + 8 色固定表,`text-white on` 全部 ≥4.72(且字为 `text-4xl/6xl font-black` 大字号,阈值 3.0)→ **主题无关,零改动** |
| **改品牌标识(logo 文字/字号、wordmark 形态与配色)** | wordmark 是 WCAG 大字号(阈值 3.0),实测亮 3.34 / 暗 3.15 双达标;3.34 是现网既有值且 P9-B 守卫从未钉它 → 改它属越界 |
| **移动端隐藏 header nav / 汉堡菜单** | `AppFooter.vue` 实测无导航 → 隐藏即失去导航。P12 已证伪汉堡菜单的必要性(nav 内容宽 74–158px、320–1280px 零溢出) |
| **D4:nav 链接逐字换行** | P12 已 park 待设计决策(纯外观、无溢出)。本批的 `gap-2 sm:gap-4` 会让 @320px 的 nav 从 74px 降到 59px(换行数变化),**这是有意的**:spike 3 实测该形态 margin=16 且不触碰 P12 钉桩。逐字换行本身仍属外观问题,不在本批处理 |
| **CSP / HSTS** | P11 已判定:CSP 需独立设计批(游玩子域经 iframe+SW 加载用户作品),HSTS 待 TLS 批 |
| **后端 / 部署改动** | 主题纯客户端表现层。`crearte-server` 与 `crearte-deploy` **零改动** |
---
## 5. 测试计划
### T1(守卫重构,**必须第一个做,RED 先行**)—— `app/lib/contrast.test.ts`(D-H)
1. **重构 `parseTokens()` 为按块解析**:返回 `{ light: Map, dark: Map }`。亮色块 = `@theme { … }` 内的 `--color-*`;暗色块 = `html[data-theme="dark"] { … }` 内的 `--color-*`。**解析失败要红得明确**(沿用现有 `throw new Error(...)` 范式,不许静默跳过)。
2. **两块各自断言** §3.1(b) 的 12 个令牌齐备(`paper`/`surface`/`ink`/`ink-soft`/`ink-faint`/`accent`/`accent-ink`/`highlight`/`info`/`success`/`skeleton`/`scrim`)。
3. **双主题各断言 §1.3/§7 的真实配对表**(12 条),阈值按 WCAG 正确取值:正文 4.5、大字号 3.0(wordmark)、非文本 3.0(焦点环)。
4. **`scrim` 的分离判据 = 按主题钉实际分离侧**(D-F):亮色钉 **`paper vs scrim ≥ 3`**(填充侧,实测 17.60)、暗色钉 **`ink vs scrim ≥ 3`**(边框侧,实测 17.60)。
> **⚠️ 本节经两次纠正(2026-10-03),第二次是对第一次的修正。**
> **原文(错)**:「`ink vs scrim ≥ 3` **两主题**」。但亮色 `ink(#141414) vs scrim(#0d0b08)` 实测只有 **1.07**(两个深色互不分离)——照字面写死会让**亮色腿误红**。由实现者发现、控制者独立复现。
> **第一次纠正(仍错)**:改为 `max(填充侧, 边框侧) ≥ 3`。控制者当时**只验了「会不会误红」(不会),没验「有没有牙」**——而它恒真:两侧互补,`max` 的理论下界 = `√cr(paper,ink)` = **4.0621 > 3**,对**任意** scrim 取值都不可能失败。实证:亮色 scrim 改纯白(= D-E 要防的泛白缺陷)时填充侧得 1.1165、守卫仍绿。由**任务级审查 finding #1** 发现(M1:亮 scrim 改 `#ffffff` → 绿 32/32),控制者用暂力重算独立证实。
> **第二次纠正(现行)**:拆成按主题钉**具体那一侧**。白 scrim 得 1.1165 < 3 → 红,牙口恢复(mutation 已实测)。
> **教训(两层,均源于 P12「守卫自身要受同等审视」)**:① 控制者给守卫写了不成立的断言;② **纠正时只验了一个失效方向**——守卫有两种失效:「误红」(假阳性)与「恒绿」(假阴性)。第一次纠正只排除了前者。**修守卫时必须两个方向都验:对合法值不误红 + 对非法值必红(mutation)。** 另:`max(a,b) ≥ k` 形态的断言是恒真高发区——当 a、b 互补时 max 有非平凡下界,阀值低于该下界就永远不失败。
5. **RED 证明牙口**(在加暗色块**之前**先写测试):
- 暗色块缺失 → 「暗色 12 令牌齐备」红
- 把暗色 `highlight` 临时改成 `#f5c518`(亮黄)→ `ink on highlight` 暗色 = **1.46** 红
- 把暗色 `accent` 改回 `#e8552f` → 焦点环仍过,但**反面**:若同时把 ResultMeta 迁移回退,`ink on accent` 暗色 = 2.31(此条由 T3 的组件测试覆盖,守卫层记录数值即可)
- **旧解析器回归证明**:用重构前的单 Map 逻辑跑含暗色块的 CSS,断言其结果**错误**(九键全为暗色值)—— 这条测试本身就是 D-H 缺陷的钉桩,防止后人「简化」回单 Map
6. **mutation 自查**(实现者必做并附输出):把亮色 `success`(现行 `#1f7a4d`,`paper on success` = **4.7628**)改成任意低对比值 → 亮色腿必须红。**证明重构后亮色仍被守**(这是 D-H 的核心风险)。实现者实际用 `#cccccc` → 实测 **1.4384 红**;控制者复跑同值同结果。
> ⚠️ **本条原文有误(终审 N8,2026-10-03 纠正)**:原文写「把亮色 `success` 改回 P9-B 前的 `#2fa46a` 之前的值 `#2fa46a` → 应仍过(4.76)」——**措辞自相矛盾**(同一值既是「之前的值」又是它自己),且 **`#2fa46a` 是暗色** success 令牌值;把**亮色** success 改成它会得 `paper(#f7f2e7) on #2fa46a` = **2.8331 < 4.5 → 红**,不是「仍过」;4.76 实属现行亮色 `#1f7a4d`。实现者未受影响(它用的是 `#cccccc`),仅本说明文字错。
### T2(令牌层)—— `main.css`(D-B/D-D/D-E/D-G/D-L)
- 按 §3.1 落地。T1 的守卫应从红转绿。
- **构建产物核验**(`npm run build` 后 grep dist CSS):
- `var(--color-*)` 引用数 **≥ 64**(不得下降)
- 内联 `#141414` 从 **11 降到 ≤ 3**(余下的是 wordmark 关键帧透明值等非令牌处;实现者须逐处说明剩余来源)
- 暗色块出现在产物中且**未被 Tailwind 层吞掉**
- `.shadow-hard` 的 `--tw-shadow` 含 `var(--color-ink)`(spike 6 的传导前提)
> **⚠️ grep 命令必须容忍 minifier 去引号(实现者发现,控制者独立复现,2026-10-03)**:本 spec 与 plan 原先写 `grep -c 'html\[data-theme="dark"\]'`(带双引号)。但 lightningcss 的 minifier **会去掉属性选择器里非必需的引号**,实际产物是 `html[data-theme=dark]`。用带引号的字面命令 grep 产物 → **返回 0,会被误判为「暗色块被 Tailwind 吞掉」**。
> 正确核验:`grep -c 'html\[data-theme="\?dark"\?\]' "$CSS"` 或去引号版,须 ≥ 1。**源码里必须写带引号的 `html[data-theme="dark"]`(CSS 源码选择器)是对的;错的只是产物核验的 grep 模式。** 实测:带引号 0 / 去引号 1,暗色块逐字完整 `html[data-theme=dark]{--color-paper:#17140f;…;color-scheme:dark}`。
- **禁止**用 `@apply` 或 `!important` 绕过特异性问题(D-C)。
### T3(三处 usage 迁移)—— §3.2 / §3.3 / §3.4
- `ResultMeta.vue:52`:`bg-accent text-ink` → `bg-accent-ink text-paper`。**须新增/更新组件测试**断言该角标的类名(防止回退;spike 反面:暗色 2.31 ❌)。
- `StatePanel.vue` ×7:`bg-[#EFE9DA]` → `bg-skeleton`。新增源码级断言(可加进 `tableOverflow.test.ts` 同族的守卫,或新建 `app/lib/noHardcodedColor.test.ts`):**扫描 `app/**/*.vue`,禁止 `bg-[#`/`text-[#`/`border-[#` 形态的硬编码 hex**。RED 先行(迁移前应报 7 处违规)。
- ⚠️ 该守卫须沿用 P12 `tableOverflow.test.ts` 的**屏蔽范式**:`<script>`/`<style>`/HTML 注释/`<textarea>`/`<title>` 内容替换为等长空白后再扫描,否则注释里的示例会误报(P12 FE-1 的假绿教训同源)。
- `FilterDrawer.vue:32`:`backdrop:bg-ink/60` → `backdrop:bg-scrim/80`。
### T4(主题机制)—— `useTheme.ts` + `index.html` + `App.vue` + `AppHeader.vue`
- **`useTheme.test.ts`**(新增,沿用 `useToast.test.ts` 的隔离范式,用 `__resetTheme()`):
- stored=`dark` → 初始 `dark`;stored=`light` → `light`;stored 缺失 → 跟随 `matchMedia`;stored 为垃圾值(`'neon'`)→ 跟随系统
- `toggle()` 翻转、写 localStorage、调 `applyTheme`(`document.documentElement.dataset.theme` 与 `meta[name=theme-color]` 都变)
- localStorage 抛错(配额/隐私模式,`vi.spyOn(...).mockImplementation(() => { throw … })`)→ **静默**,主题仍在会话内生效
- `matchMedia` 不存在 → 回落 `light`,不抛错
- `isExplicit()`:未点击过 → false;点击后 → true
- **`AppHeader.test.ts`**(扩展,**既有断言零删除**):
- 开关存在、`data-testid="theme-toggle"`、`aria-label` 含当前态与动作、图标 span 为 `aria-hidden`
- 点击 → `useTheme().theme` 翻转(mock 或真实 store + `__resetTheme()`)
- **P12 的 F3 形态钉桩必须仍绿**:`max-w-[6rem]` + `flex` + `min-w-0` + 两 span 结构 + `:title`
- gap 变更钉桩:容器行含 `gap-2 sm:gap-6`、nav 含 `gap-2 sm:gap-4`
- **`index.html`**:内联脚本的判定优先级须与 `useTheme.ts` **逐字一致**。加一条源码级守卫(可并入 T3 的新守卫文件或 `contrast.test.ts` 同族)断言两处优先级串一致,或至少在测试里注释指明「改一处必须改另一处」并加断言比对 `THEME_KEY` 字面量出现在 `index.html` 中。
- **`App.vue`**:调 `applyTheme`;**模板结构不得改**(skip-link 仍首子,P9-B 钉桩)。
### T5(e2e)—— `e2e/dark.spec.ts`(新增)+ `e2e/responsive.spec.ts`(**既有 12 腿必须保持绿**)
`dark.spec.ts` 最低覆盖(复用 `helpers.ts` 的 `seedSession(page, role, displayName)`):
1. **默认跟随系统**:`page.emulateMedia({ colorScheme: 'dark' })` + 清空 localStorage → 首屏 `html[data-theme]` = `dark`(**且必须在 Vue 挂载前就已设置**:用 `addInitScript` 或 `goto` 后立刻断言,验证 D-K 的 pre-paint 无 FOUC)
2. **开关切换**:点击 → `data-theme` 翻转、`meta[name=theme-color]` 同步、`localStorage['crearte.theme.v1']` 写入
3. **持久化**:切换后 reload → 主题保持(不闪回)
4. **计算值实证**(不只查属性):暗色下 `getComputedStyle(document.body).backgroundColor` = `rgb(23, 20, 15)`、`color` = `rgb(247, 242, 231)`;**硬阴影跟随**:某 `.shadow-hard` 元素的 `box-shadow` 含 `rgb(247, 242, 231)`(spike 6 的传导在真产物上复验)
5. **遮罩不泛白**:打开 FilterDrawer,`::backdrop` 的 `background-color` 在暗色下**不是**近白(断言其解析值的亮度低于阈值,或直接断言 `scrim` 令牌值生效)
> **⚠️ 补充(2026-10-03 审查期):只断言亮度不够,必须同时钉 alpha。** 控制者实测:兜底分支若只看最大通道,全透明的 `rgba(0,0,0,0)`(= 遮罩功能完全失效)会通过。D-E 钉的是 `backdrop:bg-scrim/80`,故 **亮度(oklab L < 0.4,失败形态 L=0.962)与透明度(alpha ≈ 0.8)两条都要断言**,且**主分支与兜底分支同等严格**——兜底分支正是为「未来浏览器改变序列化形态」而存在,它比主分支松就等于那时守卫静默失效。
> 实现期实测:Tailwind 产出 `color-mix(in oklab, var(--color-scrim) 80%, transparent)`,Chromium 对 `::backdrop` 的 `getComputedStyle` **保留 oklab 形态**(`oklab(0.150853 0.00139775 0.00721639 / 0.8)`)而非 rgb,故须按色彩空间解析。
6. **可访问名**:开关的 `toHaveAccessibleName` 含当前态(与 `a11y.spec.ts` 同一 API)
7. **零横向溢出(暗色)**:@320/@375 × 登录态 ascii60,`scrollWidth ≤ clientWidth + 1`(暗色下 header gap 变更的回归钉桩)
`responsive.spec.ts`:**既有 12 腿一字不改、必须全绿**(P12 成果)。若 gap 变更导致某腿数值变化,**不得改断言迁就**——先查明是否真溢出,是则修实现。
### 全量回归(合并前必跑,`set -o pipefail`,**禁 `--if-present`**)
| 腿 | 命令 | 基线(P12 合并态) |
|---|---|---|
| vitest | `npm run test` | **635 passed / 74 files** |
| typecheck | `npm run typecheck` | exit 0 |
| build | `npm run build` | exit 0(含 `vue-tsc --noEmit`) |
| build:runtime | `npm run build:runtime` | exit 0 |
| 主 e2e | `npm run e2e` | **92 passed + 1 skipped** |
| noauth e2e | `npm run e2e:noauth` | **4 passed** |
新增测试后数字应上升;**任何既有腿下降都必须解释**。P12 的 12 条 responsive 腿与 `AppHeader.test.ts` 的 F3 钉桩属**不得回退**项。
---
## 6. CHANGELOG 与版本
- `crearte` → **0.26.0**(minor:新特性 = 暗色主题 + 两层守卫扩展)。条目分 `Added`(暗色主题、`useTheme`、`dark.spec.ts`、硬编码色守卫)、`Changed`(阴影令牌 var 化、header gap、三处 usage 迁移)、`Tests`、`Context`。
- `crearte-server` / `crearte-deploy` → **无改动,不发版**。
- wrapper → **0.3.6**(记账:ROADMAP P13 行 + 文档索引注册本 spec 与 plan + P9-B/P11 行尾「C 暗色模式」挂账标注为已清偿)。
条目格式:同条目英文行紧跟中文行(**无空行**),不同条目间**空一行**。
---
## 7. 证据存档
`crearte/.superpowers/sdd-p13/`(`.gitignore` 已含 `.superpowers/`,不入库):
- `FINDINGS-survey.md` — **7 轮 spike 的实测输出固化**:Tailwind v4 令牌形态(运行时翻转可行性)、header 余量四格实测与唯一失败格、9 候选粗筛、以 margin≥8px 为判据的细化(候选 B 胜出)、机制验证(特异性/var 传导/color-mix 泛白)、**阴影传导的方法错误与纠正**(第 5 轮失败 → 第 6 轮源码+重建成功)、调色板锁定(highlight 深度搜索 6 候选、双主题 **12** 配对全 PASS、两个自身错误的修正)、迁移清单、守卫重构必要性。每个数字都有出处。
- `spike1-header-slack.txt` … `spike6-shadow-source.txt` — 六轮原始输出(第 5 轮的**失败**输出也保留,它是方法错误的证据)。
- spike 源文件(`e2e/_spike-p13-*.spec.ts`)跑完即删,工作树 `porcelain=0` 已核。
---
## 8. 实现者必读的纪律(延续 P9-B / P11 / P12 教训)
1. **不推断 CSS/Vue/构建行为,只实测。** 本批已有两次栽在推断上:spike 5 用运行时注入验证构建期烘焙(失败)、spike 1 第一版用 `APPLY.toString()` + `new Function` 序列化注入(`ReferenceError: APPLY is not defined`)。
2. **守卫先写、先看它红。** RED 输出必须复现本 spec 引用的实测数字(如暗色 `ink on highlight` = 1.46),否则守卫可能无牙。
3. **守卫自身要受同等审视。** 写完守卫后做 mutation:故意破坏被测属性,确认守卫变红;再确认**亮色腿在重构后仍然守得住**(D-H 的核心风险是重构把亮色守卫静默弄丢)。
4. **P12 成果不得回退。** `max-w-[6rem]`、五个表格包裹层的 `relative overflow-x-auto pr-1 pb-1`、`ResultMeta.vue:22` 的 `relative min-w-0`、`AccountView` 的 `min-w-0 wrap-anywhere`、skip-link 首子位置、`useToast` 的 `announcedId` 单调语义 —— 全部原样保留。
5. **throwaway 文件跑完即删**,且注意 `npm run build` 含 `vue-tsc --noEmit` 会检查 `e2e/*.spec.ts` 的类型(spike 6 曾因此 `BUILD_EXIT=2`);spike 期间用 `npm run build:e2e` 绕开,但**交付前 `npm run build` 必须真过**。
5b. **⚠️ 测试与注释里禁写 Tailwind 类名字面量**(实现者 T2 期发现,spec 与 FINDINGS 均未预料)。Tailwind v4 的自动内容探测**扫描全部源文件,含 `.test.ts`**:`FilterDrawer.test.ts` 里一句 `not.toContain('backdrop:bg-ink/60')` 断言(及其注释)让 Tailwind 把这个已迁移掉的类名当候选、**重新生成 utility 及其 `#14141499` fallback 烧回产物**——内联 `#141414` 因此停在 3 而非 2。修法是**用字符串拼接构造字面量**(`'backdrop:bg-i' + 'nk/60'`),使其不出现在任何源文件里。
与 P12 的 `noHardcodedColor`/`tableOverflow` 屏蔽范式**同源**(注释里的示例会污染扫描),只是方向相反:那里防「守卫扫到注释里的 table」,这里栽在「Tailwind 扫到测试里的死 utility」。**任何「某 utility 不得再出现」的回退钉桩,都必须用拼接而非字面量。**
6. **报数字要报实跑输出**,不报「应该没问题」。
7. **发现 spec 有误就纠正 spec**,不要迁就实现(P12 的实现者纠正了 768px 归因,控制者用 spike 17 独立定案其成立并 re-pin 了五处下游)。