Files
crearte-monorepo/docs/specs/2026-10-03-p13-dark-mode-design.md
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

45 KiB
Raw Permalink Blame History

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:

.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 + 全局正则:

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():

@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):

/* 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):

@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)

-        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)

-    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 容错范式(配额/隐私模式写失败静默、损坏值读回默认)。

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)

   <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:

-    <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">
-      <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> 之前):

      <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> 内:

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 了五处下游)。