Files
crearte-monorepo/docs/specs/2026-10-03-p15b-bootstrap-dark-design.md
T
XingfenD 51bed55704 docs(spec): close the P15-B branch review's document-side findings
The P15-B branch review returned "needs fixing before merge" on a critical gap
in the guard layer. The code side is with a second fix-forward; this commit
closes the document side, which is the controller's.

F-FINAL-4 - the leg-8 cell carried a one-line regex that the controller wrote
into the spec during re-pin. It was worse than the version in the adjudication
it copied: 4 of 7 attribute forms fail rather than 2, because markdown escaping
turned the alternation bar into a literal bar and `'[^']*'` was mistyped as
`'[^'*]`. The controller had checked that every table row has the same number of
pipes as the header, but never checked that the escaped code was still the code
it came from. The cell now describes the implemented approach (extract every
style attribute value in all three quoting forms, strip quotes, test each for
color:) and deliberately carries no regex literal, with the reason stated: an
escaped bar inside a table cell is a literal character in the regex. Read the
code entity for the pattern.

§6-1 - leg 9 was never re-pinned: the spec still said "length > 500" while the
code says 4000 plus three structural preconditions. Corrected, with the unit
spelled out (source characters 4423, not the built artifact's 7056/7588).

§6-2 - "the only discriminating grid" survived re-pin in four places; the
review measured two legs reddening on a real decision-order defect. Narrowed to
"the only discriminating grid on the child-side direct-visit path" here and in
the plan. The two occurrences in code comments belong to the fix-forward.

§6-3/§6-4/§6-5 - the §1.2 count now states which tree it came from (base 66,
HEAD 67, the extra one being this batch's own note text quoting the figure); the
§4 boundary table lists the e2e spec it had omitted; D-G says "form aligns"
rather than "aligns verbatim", since the script bodies differ by design (the
main app has one more level, 16 token-level differences).

F-FINAL-1 - two claims already committed are corrected rather than deleted. The
§3.1(a) annotation and the §7 risk row both rested on "maskSourceComments()
neutralises the trap comment, so the guard does not depend on the implementer's
wording discipline". That is true for the file in question, but only because the
trap comment happens to use `//`: the function's HTML-comment branch compared a
2-character slice against a 4-character literal and never ran. Merely rewording
a comment reddened up to four legs while the e2e suite stayed green.

§8 gains the census case for discipline 15 (an unlanded census script is why the
48-versus-47 discrepancy is still untraceable), plus disciplines 16 and 17:
every evidence file a report cites must be ls-checked before delivery, and
executable details in a spec must be grepped from the code entity or recomputed,
never hand-written. Discipline 17 records this batch's three instances, one of
them a contrast ratio cited twice by two documents and recomputed by nobody
(3.0269; the actual value is 2.5519, which no grey background produces).

Post-write checks: 25/25 assertions, 11 table blocks with 0 column anomalies,
16 code fences paired, 25 headings with no duplicates, copyable-block cleanliness
scanned across 8 blocks.
2026-10-04 02:53:53 +08:00

430 lines
55 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.
# P15-B 设计:bootstrap 加载屏暗色 + 对比度守卫补钉 + 死令牌清理(crearte)
- **日期**:2026-10-03
- **仓**:`crearte`(前端;`package.json` 与全部源码在 **`src/`**,不是仓根)
- **分支**:`feat/p15b-bootstrap-dark-and-guard-pins`(AGENTS.md 只允许 `{feat|fix|docs|chore}/`;本批含用户可感知的暗色改动,故用 `feat/`)
- **base**:`f823195`(master,P13 交付后的 AGENTS.md 不变量 commit)
- **维度**:用户体验 / UI 交互 + 代码优雅(USER.md 六维度循环)
- **并行**:P15-A 在 `crearte-server` 仓,两仓各自单写者 → **可同时派发**(`sdd-parallel-dispatch` §1)
---
## §0 基线(已实测,2026-10-03 13:07)
**vitest**:`Test Files 79 passed (79)` · `Tests 688 passed (688)` · Duration 44.83s。
命令:`cd crearte/src && npx vitest run`。**与 P13 交付时报的 688/79 逐字相同** → P14 未触及前端,且此后无新增测试。这是本批的比对基线。
`bootstrap/host-origin.test.ts (3 tests)` 已在 79 个文件里 → **`bootstrap/` 目录已有测试先例**,新增测试文件不属破例。
**vitest 环境**:`src/vite.config.ts` 的 `test` 段只有 `exclude: [...configDefaults.exclude, 'e2e/**']`,**无 `environment` 键** → 默认 **node**。这是 `contrast.test.ts` / `noHardcodedColor.test.ts` / `themeBootstrap.test.ts` 能用 `fileURLToPath(new URL('../..', import.meta.url))` 读源码文件的前提(**P13 教训:happy-dom 下会抛 `ERR_INVALID_URL_SCHEME`**)。本批新增的守卫**必须沿用同款写法**,不得引入需要 DOM 的断言。
**e2e**:`src/e2e/`,`playwright.config.ts` / `.noauth.` / `.stack.` 三份配置都是 `testDir: './e2e'`。`dark.spec.ts` **10 腿**(P13 交付),其中 `:100` 是 **pre-paint 归因腿**:「Vue 包被拦截时 `data-theme` 仍为 dark(证明是内联脚本而非挂载后设置)」。
---
## §1 三项改动的取证
### 1.1 改动①:bootstrap 游戏加载屏对暗色用户「白闪」
**现象**:`src/bootstrap/index.html`(虚拟模式游戏的加载屏,`<title>游戏加载中…</title>` + 进度条 + 状态行 + 错误区)把整套配色**硬编码为亮色值**,且 `localStorage` / `prefers-color-scheme` / `data-theme` / `@media` **四项全部零命中**。暗色用户启动虚拟游戏时,会在暗色页面里看到一块亮色矩形。
**它确实是用户面**(非死代码):
- `src/vite.config.ts:21` 把 `bootstrap/index.html` 注册为名为 `bootstrap` 的**第二个 Vite 构建入口**
- `runtime/sw/router.ts:1` `export const BOOTSTRAP_PATH = '/__bootstrap'`;`sw/index.ts:211,218` 有 `redirect-bootstrap` 与离线兜底
- `runtime/host/adapters.ts:38`:`targets.push({ mode: 'virtual', url: \`${origin}/__bootstrap#${hash.toString()}\`, origin })`
- `useGameFrame.test.ts`:virtual 模式 url 形如 `http://demo.localhost:4173/__bootstrap#v=1`
- 游戏跑在**独立子域**:`runtime/host/config.ts:18` `derivePlayOrigin → ${protocol}//${16hex}.${baseDomain}`,由 `GameHost.vue` 以 iframe 嵌入
**根因不是漏改,是 P13 的令牌化只覆盖了单入口**:P13 改的是 `src/index.html`(主应用入口)+ `app/styles/main.css`(12 令牌)+ `app/` 与 `runtime/` 的 `.vue`。`bootstrap/` 是**第二个 HTML 入口**,不在任何一处覆盖范围内。
→ **教训(已入 ROADMAP 挂账):换肤/令牌化类改动须先 `grep -n 'input' src/vite.config.ts` 枚举所有构建入口,逐个确认覆盖。单入口假设会漏掉多入口应用。**
**跨源约束(决定修法的上限)**:
- **`localStorage` 按源隔离** → bootstrap 跑在游戏子域,**读不到**主应用存在主域 `localStorage['crearte.theme.v1']` 的显式选择。主应用 `src/index.html:15-31` 的 pre-paint 脚本判定序是 `stored → prefers-color-scheme → light`(由 `themeBootstrap.test.ts` 7 腿钉住),**在子域上拿不到 `stored` 那一级**。
- **`prefers-color-scheme` 是浏览器级、不按源隔离** → 可用。
- **hash 通道已存在**:`bootstrap/main.ts:4` 就是 `const params = new URLSearchParams(location.hash.replace(/^#/, ''))`,且 `main.ts:97` 有 `fail('启动参数完整', location.href)` 说明它是必需参数集 → **主题可搭车现有 hash 通道传入,不必新建 postMessage 协议**(P15 账本 §7 原估「完整修法须 postMessage,成本更高」,**这条被证伪**)。
**🔑 架构陷阱(本批最重要的设计约束)**:`GameHost.vue:27-31` 的 `targets` 是 **`computed`**:
```js
const targets = computed<RuntimeTarget[]>(() => resolveRuntimeTargets(props.game, {
baseDomain: config.baseDomain, protocol: location.protocol, config
}))
```
而 `useTheme.ts:51` 是模块级响应式单例 `const theme: Ref<Theme> = ref(effectiveTheme())`。
| 注入方式 | 响应式追踪 | 后果 |
|---|---|---|
| ❌ `theme: useTheme().theme.value` | **是**(读 `ref` 的 getter → computed 建立依赖) | 用户在游戏页切主题 → `targets` 重算 → **iframe `src` 变化 → 正在运行的游戏被重载**(存档/进度丢失) |
| ✅ `theme: effectiveTheme()` | **否**(`effectiveTheme()` 内部读 `localStorage` 与 `matchMedia`,都不是响应式源) | computed 不依赖主题;iframe 不重载。代价:切主题后已打开的加载屏保持旧主题,直到下次导航 |
→ **裁定用 `effectiveTheme()`**(`useTheme.ts:39` 已导出)。"游戏运行中不重载" 比 "加载屏实时跟随主题切换" 重要得多:加载屏只在**启动瞬间**可见,而重载会毁掉正在进行的游戏。**这条必须有测试钉住**(§5-T1 **腿 5**;⚠️ RE-PIN 2026-10-03:原写「腿 4」,而腿 4 是 IIFE/var、注入是**腿 5**——spec 内部自相矛盾,§5-T1 表自身与 §5 mutation (d)、§7 风险表都写腿 5),否则下一个贡献者"顺手改成响应式"就会引入静默的游戏重载缺陷。
**色值映射(实测;`bootstrap/index.html` 里 8 个硬编码 hex 的令牌归属)**:
| hex | 令牌归属 | 暗色对应值 |
|---|---|---|
| `#f7f2e7` | 亮 `paper` **且** 暗 `ink`(翻转对称点) | `#17140f`(暗 paper) |
| `#141414` | 亮 `ink` | `#f7f2e7` |
| `#ffffff` | 亮 `surface` | `#221e18` |
| `#5c584d` | 亮 `ink-soft` | `#cbc4b5` |
| `#e8552f` | 亮 `accent` | `#ff7a4d` |
| `#c03a1b` | 亮 `accent-ink` | `#ffb59a` |
| `#a3a3a3` | ❌ **不对应任何令牌** | 见下 |
| `#f87171` | ❌ **不对应任何令牌** | 见下 |
**🔴 同批发现两条 pre-existing WCAG 违规(inline `style` 覆盖了达标的样式表值)**:
| 元素 | 样式表值(`<style>` 块) | inline `style=""` 值 | 谁生效 | 对比度(on `#f7f2e7`) |
|---|---|---|---|---|
| `#status`(`:25`) | `#5c584d` | **`#a3a3a3`** | **inline 胜**(CSS 特异性) | 样式表 **6.3586 ✅** / inline **2.2594 ❌** |
| `#error-message`(`:27`) | `#c03a1b` | **`#f87171`** | **inline 胜** | 样式表 **4.8700 ✅** / inline **2.4776 ❌** |
→ **inline 是冗余重复且用了更低的对比度**:样式表里同元素**已有达标值**,inline 属性把它覆盖成不达标的。**删掉 inline 的 `color` 声明即修复**(保留 `font-size`),达标值自动生效。这是有实测对比度支撑的一行改,**不是设计决策**(与 GameHost 亮色徽标那条不同——那条的显而易见修法已被证伪,属撞色设计决策,仍挂账)。
**暗色候选配色(8 项全部实算通过)**:
| 用途 | 暗色值 | on | 对比度 | 需 | 判定 |
|---|---|---|---|---|---|
| body 正文 | `#f7f2e7`(ink) | `#17140f`(paper) | **16.4507** | 4.5 | ✅ |
| `#status` / `pre` | `#cbc4b5`(ink-soft) | `#17140f` | **10.5847** | 4.5 | ✅ |
| `#error-message` | `#ffb59a`(accent-ink) | `#17140f` | **10.7821** | 4.5 | ✅ |
| 按钮文字 | `#17140f`(paper) | `#f7f2e7`(ink) | **16.4507** | 4.5 | ✅ |
| 进度条值 | `#ff7a4d`(accent) | `#221e18`(surface) | **6.4274** | 3.0 | ✅ |
| 进度条边框/阴影 | `#f7f2e7`(ink) | `#221e18` | **14.8468** | 3.0 | ✅ |
亮色现状(**须逐字保持不变**):body 正文 16.5010 ✅ · `#status` 样式表 6.3586 ✅ · `#error-message` 样式表 4.8700 ✅ · 按钮 16.5010 ✅ · 进度条值 `#e8552f` on `#ffffff` = 3.6385 ✅(需 3.0)。
### 1.2 改动②:`AA_PAIRS` 补钉 `['ink', 'surface']`
**`AA_PAIRS` 现状**(`app/lib/contrast.test.ts:102`,类型 `Array<[fg: string, bg: string, note: string]>`,**10 对**,断言循环在 `:124`):
```
['ink','paper','正文全站 亮16.50/暗16.45'] ['paper','accent-ink','badge/按钮/error toast 亮4.87/暗10.78']
['ink-soft','paper','次要文 亮6.36/暗10.58'] ['paper','success','success toast 亮4.76/暗5.81']
['ink-faint','paper','::placeholder+弱文 亮4.83/暗6.99'] ['paper','ink','.btn-ink/markdown th 亮16.50/暗16.45']
['ink-soft','surface','卡片次要文 亮7.10/暗9.55'] ['accent-ink','paper','链接 text-accent-ink 亮4.87/暗10.78']
['ink-faint','surface','卡片弱文 亮5.40/暗6.31']
['ink','highlight','alert×8/strong/选中态/::selection 亮11.30/暗4.81']
```
**缺口**:`ink on surface` **未钉**,而它是**所有未钉配对里使用面最大的**:
- `bg-surface` **`.vue` 模板内 62 处**(`app/` + `runtime/`);⚠️ 整个 `src` 是 **66 处**(另 4 处在 `.test.ts`:BaseInput/BaseTabs/BaseTextarea/FileInput 各 1)→ 原写「全仓 62 处」与 66 冲突(RE-PIN 2026-10-03)。**不影响结论**:Tailwind 只从 `.vue` 模板生成 utility,`.test.ts` 里的字面量属 §8-8 的另一回事(⚠️ RE-PIN 2026-10-04 补口径,闭合终审 §6-3:以上是 **base 树 `f823195`** 的计数;HEAD 树上整个 `src` 是 **67** 处、`.test.ts` 是 **5** 处——多的 1 处正是本批在 `contrast.test.ts` 的 AA_PAIRS note 文案里引用了这个数字。**结论不受影响**:Tailwind 只从 `.vue` 模板生成 utility,`.test.ts` 里的字面量属 §8-8 的另一回事)
- 其中只有 **4 处**显式写 `text-ink-soft`(已钉)、**2 处**的 `text-paper` 属三元表达式**另一支**(P15 账本 §1 第 19 次错误:把互斥分支当同元素共现,是假阳性)
- **58 处靠继承**:全局文字色源是 `src/index.html:34` `<body class="bg-paper text-ink font-sans antialiased">` → 继承到 **`ink`**
- 实测对比度:亮 **18.4225** / 暗 **14.8468** → **必然通过 4.5**
**为什么值得钉(不是"必然通过就不用钉")**:已钉配对里使用面最大的是 `paper on accent-ink`(14 处)。`ink on surface` 有 62 处却无守卫 → 若将来调 `--color-surface`(如暗色 `#221e18` 提亮)或 `--color-ink`,**58 处继承文字会静默回归而零拦截**。这正是 P13/P14 反复出现的形态:**守卫钉住了容易想到的,漏了使用面最大的**。
**note 文案**(体例同现有 10 条,须含用途 + 亮/暗实测值):
```
['ink', 'surface', '卡片/表格/表单底(62 处,58 靠 body 继承)亮18.42/暗14.85'],
```
### 1.3 改动③:`--color-info` 死令牌清理
**定义**:`app/styles/main.css:12`(亮 `#2b62cc`)、`:53`(暗 `#7aa7f0`)。
**零使用**(实测):全仓 `bg-info` / `text-info` / `border-info` / `ring-info` **零命中**;`color-info` 在整个 `src/` + `e2e/` 里**只有那两行定义**(命中数 = 2,即定义本身)。`main.css` 内 `info` 字样也只出现在那两行。
**矛盾点**:它在 `REQUIRED_KEYS`(`contrast.test.ts:75-88`,**12 项**,`info` 在**第 9 位**(第 8 是 `highlight`;⚠️ RE-PIN 2026-10-03:原写第 8 位))里 → `it(`${zh} 12 令牌齐备(spec §3.1(b))`)`(`:119`)会**在删除时变红**。**守卫在强制一个零使用的令牌存在。**
**连带面(实测清点,5 个文件,不是"顺手删两行")**:
| 文件 | 行 | 改什么 |
|---|---|---|
| `crearte/src/app/styles/main.css` | 12, 53 | 删两行定义 |
| `crearte/src/app/lib/contrast.test.ts` | 84 | 从 `REQUIRED_KEYS` 删 `'info',` |
| 同上 | 47 | 注释「由「12 令牌齐备」断言明确报「暗色 12 令牌缺失」」→ 改 11 |
| 同上 | 74 | 注释「P13 全量 12 令牌(spec §3.1(b))」→ 改 11 |
| 同上 | 119 | `it` 标题 `${zh} 12 令牌齐备(spec §3.1(b))` → 改 11 |
| 同上 | 121 | 断言消息 `${zh} 12 令牌缺失: …` → 改 11 |
| `crearte/docs/CHANGELOG.md` | 14(0.26.0 段) | 「每主题 **12 个设计令牌**」→ 须说明 0.19.0 起为 11(**历史条目不改写**,改为在新条目里说明;见 D-C) |
| `wrapper/docs/specs/2026-10-03-p13-dark-mode-design.md` | 109, 155, 185, 427 | P13 spec 的 D-B「全量 12 令牌」+ 两处 `--color-info` 代码块 + §「暗色块缺失 → 「暗色 12 令牌齐备」红」→ **加 RE-PIN 标注**(同 P14 手法:保留历史决策 + 标注增订),不改写原文 |
| `wrapper/docs/ROADMAP.md` | 42(P13 行) | P13 行的「每主题 **12 个设计令牌**」→ 保留原文(历史记录),挂账里删掉「死令牌 `--color-info`」这一项(已处置) |
**✅ `LEGACY_KEYS` 不受影响**(实测):`contrast.test.ts:197` 的九键反面钉桩是 `['paper','surface','ink','ink-soft','ink-faint','accent','accent-ink','highlight','success']`,**不含 `info`** → 删令牌**不波及** P13 那条"防后人简化回去"的钉桩。这条必须实测确认过再动手,否则会误改一处刻意保留的历史锚点。
**为什么不选"启用它"**(处置 (b)):`success` 与 `highlight` 已覆盖 toast 与 alert 的语义需求(`paper on success` 是 success toast、`ink on highlight` 覆盖 alert×8/strong/选中态/`::selection`),`info` **无对应 UI 位置**。启用它需要新造一个 info toast 变体 = 改视觉 + 加功能,超出"代码优雅"范围,且会为一个新的 UI 元素引入新的对比度配对需要钉。**删比造便宜,且删掉的是零使用面。**
---
## §2 设计决策
| # | 决策点 | 裁定 | 依据 |
|---|---|---|---|
| **D-A** | bootstrap 主题来源 | **hash 参数 `theme` 优先 → `prefers-color-scheme` → `light`**(三级,比主应用少 `stored` 一级) | §1.1:`localStorage` 按源隔离读不到;hash 通道已存在(`main.ts:4`);`prefers-color-scheme` 不受源隔离。**判定序必须与主应用 `useTheme.effectiveTheme()` 的后两级逐字一致**,否则同一用户在主应用与游戏加载屏看到不同主题 |
| **D-B** | 父侧注入方式 | **`effectiveTheme()` 非响应式快照**,**不得**用 `useTheme().theme.value` | §1.1 的架构陷阱:响应式会让主题切换重算 `targets` → iframe `src` 变 → **运行中的游戏被重载**。必须有测试钉住(§5-T1 **腿 5**;RE-PIN:原写腿 4) |
| **D-C** | 死令牌清理的历史条目处理 | **不改写 P13 的 CHANGELOG 条目与 spec 原文**,改为:新条目(0.27.0)说明变更 + P13 spec 加 **RE-PIN 标注块** | CHANGELOG 是发布历史,改写会让"当时交付了什么"失真。P14 已建立正确手法:保留历史决策原文 + 紧随带 ⚠️ RE-PIN 标记的增订块(全分支终审判定这是"正确的 re-pin 手法"而非自相矛盾) |
| **D-D** | 新守卫的形态 | **新增 `app/lib/bootstrapTheme.test.ts`**(源码级、node 环境、读文件文本),**不扩展 `noHardcodedColor.test.ts`** | §1.4:`noHardcodedColor` 的 `candidates()` 只收 `.vue`(`if (file.endsWith('.vue'))`),且 `maskNonTemplate()` 把 `<style>` 块整体等长空白掉 → **它从设计上无法覆盖 `bootstrap/index.html`**(HTML 文件 + 颜色全在 `<style>` 里)。扩扫描根不会有任何效果 |
| **D-E** | inline `style` 的两处违规 | **删掉 inline 的 `color` 声明**(保留 `font-size`),让样式表的达标值生效 | §1.1 实测:inline 是冗余重复且对比度更低(2.2594 / 2.4776 vs 样式表 6.3586 / 4.8700)。删 inline 即修复,**同时消除"两处定义同一元素颜色"的分歧源** |
| **D-F** | 暗色实现方式 | 在 `bootstrap/index.html` 的 `<style>` 块里用 **`html[data-theme="dark"]` 覆盖**(同主应用 `main.css:44` 的手法),**不用 `@media (prefers-color-scheme)`** | 主应用用 `data-theme` 属性驱动(`color-scheme` 也由它驱动,D-L),使"显式选择能压过系统偏好"。bootstrap 若用 `@media` 就只能跟随系统、无法接收 hash 传入的显式主题 → **两套机制会让判定序无法一致**(违反 D-A) |
| **D-G** | pre-paint 脚本形态 | **IIFE + 仅 `var`,置于 `<head>` 内、任何 CSS 与 `<body>` 之前**,**形态**逐字对齐主应用 `src/index.html:15-31`(IIFE / 仅 `var` / 位置 / catch 兜底 / 媒体查询字面量五项)——⚠️ **脚本体本身不同**:主应用多 stored 一级(D-A 明列),token 级差异 16 处;RE-PIN 2026-10-04 闭合终审 §6-5,原措辞「逐字对齐…的写法」会被读成脚本体也相同 | 主应用该形态由 `themeBootstrap.test.ts` 腿 4("脚本在 `<head>` 内、`<body>` 之前")与腿 5(IIFE + var)钉住,理由是 CSP 落地前不能用 module/let。**同款形态让两处可被同一套守卫断言** |
| **D-H** | 派发形态 | **一个实现者子代理**做完 T1→T4(同动 crearte 单一工作树与 git index) | AGENTS.md / `sdd-parallel-dispatch` §1「一仓一写者」;P12/P13/P14 同款 |
### §2.1 被否方案
| 方案 | 否决理由 |
|---|---|
| **A:用 `@media (prefers-color-scheme: dark)` 实现 bootstrap 暗色** | 只跟随系统偏好,**无法接收 hash 传入的用户显式选择** → 显式设了暗色但系统是亮色的用户仍看到亮色加载屏。且与主应用的 `data-theme` 机制分叉,判定序无法逐字一致(违反 D-A)。这是 P15 账本 §7 原估的"廉价修法",**取证后判定不够** |
| **B:postMessage 传主题** | hash 通道已存在(`main.ts:4`),无需新协议。postMessage 是**异步**的,而加载屏是 pre-paint → 会先绘亮色再翻暗色,**正是要消除的白闪**。`host-origin.ts` 的信令通道用于安装进度上报,时序上不适合主题 |
| **C:给 bootstrap 也读 `localStorage`** | 源隔离,物理上读不到主域的值(`derivePlayOrigin` → 独立子域)。若为绕过而放宽子域隔离,会破坏 P11 的安全模型 |
| **D:扩展 `noHardcodedColor` 的扫描根到 `bootstrap/`** | §1.4:`candidates()` 只收 `.vue`、`maskNonTemplate()` 会空白掉 `<style>` 块 → 扩根**扫不到任何东西**,且会给人"已覆盖"的假信心(P13 审查者 M5 的同族形态:扫描根改坏后"扫 0 文件也报 0 违规") |
| **E:把 bootstrap 的配色改成引用 `var(--color-*)` 令牌** | 令牌定义在 `app/styles/main.css`,**bootstrap 是独立入口、不加载主应用 CSS**(它只有自己的 `<style>` 块)。要引令牌就得把整个 `@theme` 块复制进 bootstrap = 两处调色板需要同步维护,比硬编码更糟。故 bootstrap **刻意**保持自包含的 hex 值,由新守卫钉住两主题的值与主应用调色板一致 |
| **F:一并修 GameHost 亮色徽标 WCAG 3.26** | 属**设计决策**而非一行改:亮色 `accent` vs `accent-ink` 仅 **1.4943**,徽标与「加载失败」状态点同屏共存于展柜标题栏,改成 `bg-accent-ink` 会把两个语义压成一个视觉信号(已实测证伪显而易见修法,ROADMAP `9965fd7` 已登记约束)。仍需挂账 |
### §1.4 `noHardcodedColor` 为何无法覆盖 bootstrap(D-D 的依据,逐字取证)
```js
// app/lib/noHardcodedColor.test.ts:23-56(⚠️ RE-PIN 2026-10-03:原标 :23-45,但下方引文一直引到 :56 的 maskNonTemplate 结束——而 maskNonTemplate 恰是 D-D 论证的**关键一半**「<style> 块整体空白掉」,旧标注把最有说服力的部分排除在外。另:引文块内的 ← 旁注是**控制者批注**,不在原文里)
const SRC_ROOT = fileURLToPath(new URL('../..', import.meta.url))
// 终审 N4:扫描根必须含 `runtime/`。…实测 runtime/ 当前零硬编码 hex,扩根不会立刻红。
const SCAN_ROOTS = [join(SRC_ROOT, 'app'), join(SRC_ROOT, 'runtime')]
function candidates(): string[] {
const files: string[] = []
for (const root of SCAN_ROOTS) {
for (const file of walk(root)) {
if (file.endsWith('.test.ts')) continue
if (file.endsWith('.vue')) files.push(file) // ← 只收 .vue
}
}
return files
}
function maskNonTemplate(content: string): string {
const blank = (block: string): string => block.replace(/[^\n]/g, ' ')
return content
.replace(/<!--[\s\S]*?-->/g, blank)
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, blank) // ← <style> 块整体空白
.replace(/<(textarea|title)\b[^>]*>[\s\S]*?<\/\1>/gi, blank)
}
```
→ 两道过滤各自都足以排除 `bootstrap/index.html`:**扩展名不是 `.vue`**,且**颜色全在 `<style>` 块内**(会被 `blank` 掉)。该守卫的设计目标是"禁止在 `.vue` **模板**里用 `bg-[#…]` 形态的硬编码 hex"(文件头注释原文),HTML 入口的 `<style>` 块本就不在其范围内。
---
## §3 实现要求
### 3.1 改动① bootstrap 暗色(`src/bootstrap/index.html` + `src/runtime/host/adapters.ts` + `src/runtime/host/GameHost.vue`)
**(a) `bootstrap/index.html` 的 `<head>`**:在 `<style>` **之前**插入 pre-paint 脚本,逐字对齐主应用形态(D-G):
```html
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#F7F2E7" />
<!-- P15-B D-A/D-G:加载屏 pre-paint 主题脚本,须在样式与文档体之前执行,否则暗色用户每次启动游戏都闪白。
判定序比主应用少 stored 一级(游戏跑在独立子域,浏览器存储按源隔离,读不到主域的 crearte.theme.v1):
hash theme → prefers-color-scheme → light。后两级与 useTheme.effectiveTheme() 逐字一致,
一致性由 app/lib/bootstrapTheme.test.ts 钉住(改一处必须改另一处)。 -->
<script>
(function () {
try {
var params = new URLSearchParams(location.hash.replace(/^#/, ''))
var hashTheme = params.get('theme')
var theme =
hashTheme === 'light' || hashTheme === 'dark'
? hashTheme
: 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>
```
> ⚠️ **RE-PIN 2026-10-03(本段是控制者批注,不属于上面可复制的代码块)**:上面注释的措辞**刻意避开** `<style`、`<body`、`localStorage` 三个字面量。实现者与审查者**双双实测**:原措辞「必须在 `<style>` 与 `<body>` 之前」会让**腿 1 对正确实现假红**(注释自身在脚本之前 → `indexOf('<style')` 先命中注释;审查者复现 styleOpen=145 < scriptPos=811),而注释里的存储键字样会让 §5 指标「bootstrap 内该项 = 0」**字面不达标**。**对照**:主应用 `src/index.html` 的注释刻意写「必须在 **CSS 与 Vue** 之前」正是为了避开这两个字面量,所以 `themeBootstrap.test.ts` 的 naive indexOf 才不误红 —— 原措辞违反了这一既有范式。**照抄上面代码块时不要把这批注抄进去。** 🔴 **RE-PIN 2026-10-04 更正(终审 F-FINAL-1)**:本批注描述的「避开三个字面量」范式**仍然成立且必要**,但当时三方都以为 `maskSourceComments()` 已经把注释这一整类载体中和了——**实测并非如此**:该函数的 HTML 注释分支用 2 字符切片去和 4 字符的 `<!--` 比较,恒 false = **死代码**,故它此前只屏蔽 `//` 与 CSS 块注释两种载体。后果是「守卫不依赖措辞自律」这句声称不成立:仅仅把注释措辞改成含 `<style` / `<body` 就会让腿 1–4 假红(同变异跑 e2e 9/9 全绿,证明红的是守卫不是实现)。修法(1 行新增 + 1 处比较对象)已由第二轮 fix-forward 执行并验牙。
⚠️ **白名单校验必须与主应用同款**(`=== 'light' || === 'dark'`),不得放宽成"任意 truthy"——`themeBootstrap.test.ts` 腿 3 正是为此存在(P13 finding #2 / N2:垃圾 `stored=neon` 必须被忽略)。
**(b) `<style>` 块**:加暗色覆盖块(D-F),值取 §1.1 的暗色调色板;`color-scheme: dark` 随块声明一次(同 `main.css:57` 的手法与理由)。**亮色色值逐字不变、渲染结果等价**(⚠️ RE-PIN 2026-10-03:裁定**接受**偏离 4——亮色值改由具名 `--bs-*` 变量供给、暗色块覆盖同名变量。理由三条:① 审查者独立写 CSS 解析器验证亮色态 **15 条颜色相关声明零差异**(含 4 条简写内嵌 `var()` 的 border/box-shadow)② 手法与主应用 `main.css` 同构(`@theme` 定义令牌 + `html[data-theme="dark"]` 覆盖),符合 D-F ③ 严格守「文本形态逐字不变」则暗色块须重写 **12 条规则**,同步维护面从 6 个值涨到 12 条规则)。
**(c) 两处 inline `style`**(D-E):`:25` 删 `color:#a3a3a3`、`:27` 删 `color:#f87171`,各保留 `font-size`。删后由 `<style>` 块的 `#status`(`#5c584d`) 与 `#error-message`(`#c03a1b`) 生效 → 对比度 6.3586 / 4.8700 达标。
**(d) `adapters.ts:10,37-38`**:`resolveRuntimeTargets` 的 `opts` 加**可选**键 `theme?: 'light' | 'dark'`;仅当 `opts.theme` 存在时 `fragment.theme = opts.theme`(**不加就不写这个键**,保持 external/hosted 模式的 url 完全不变)。
```ts
export function resolveRuntimeTargets(game: Game, opts: {
baseDomain: string; protocol: string; config?: ReturnType<typeof runtimeConfig>; theme?: 'light' | 'dark'
}): RuntimeTarget[] {
```
⚠️ **必须可选**:`adapters.test.ts` 有 **11 处**调用不带 `theme`(8 处裸 `opts` + 3 处 `{...opts, config}`;⚠️ RE-PIN 2026-10-03:原写「4 处」,实测 `resolveRuntimeTargets(` 出现 **11** 次、含 `theme:` **0** 次、按 `test(` 而非 `it(` 组织 → 没有任何口径得出 4),且 `useGameFrame.test.ts` 也 import 了 `RuntimeTarget` 类型 → 加必填键会破既有测试(违反"既有测试修改数 = 0")。
**(e) `GameHost.vue:27-31`**:注入非响应式快照(D-B):
```js
import { effectiveTheme } from '../../app/composables/useTheme'
const targets = computed<RuntimeTarget[]>(() => resolveRuntimeTargets(props.game, {
baseDomain: config.baseDomain, protocol: location.protocol, config, theme: effectiveTheme()
}))
```
⚠️ **不得写成 `useTheme().theme.value`**(会让 computed 依赖 theme ref → 主题切换重载 iframe)。注释须写明这个陷阱,否则下一个贡献者会"顺手改成响应式"。
### 3.2 改动② `AA_PAIRS` 补钉(`src/app/lib/contrast.test.ts:102-113`)
在 `['ink','highlight',…]` 之后(或按现有分组习惯置于 `surface` 两对之后)插入:
```ts
['ink', 'surface', '卡片/表格/表单底(bg-surface 62 处,58 靠 body 继承 text-ink)亮18.42/暗14.85'],
```
**不得改其它 10 对**(它们的 note 里带实测值,是 P13 交付的一部分)。
### 3.3 改动③ 死令牌清理(5 文件,见 §1.3 连带面表)
- `main.css:12`(亮)与 `:53`(暗)删两行 `--color-info`
- `contrast.test.ts`:`REQUIRED_KEYS` 删 `'info',`(`:84`)+ 四处 "12 令牌" 文案改 "11 令牌"(`:47,74,119,121`)
- ⚠️ **`LEGACY_KEYS`(`:197`)不动**(九键不含 `info`,已实测)
- P13 spec 加 RE-PIN 标注块(D-C,不改写原文)
- crearte CHANGELOG **不改写 0.26.0 条目**,在 0.27.0 新条目里说明
### 3.4 不得触碰
- `themeBootstrap.test.ts`(主应用 pre-paint 守卫,7 腿)——除非某腿因本批变红,那时**停下来报告**
- `noHardcodedColor.test.ts`(D-D:扩它无用)
- `useTheme.ts`(`effectiveTheme()` 已够用,改它会波及主应用 10 腿 e2e)
- `src/index.html`(主应用入口,P13 交付)
- 任何既有 `*.test.ts` / `e2e/*.spec.ts`
- `crearte-server`、`crearte-deploy`、wrapper 的 `docs/`(实现者红线;wrapper 的 spec/CHANGELOG/ROADMAP 由**控制者**记账)
---
## §4 边界
| 项 | 在范围内 | 不在范围内 |
|---|---|---|
| 文件 | `bootstrap/index.html`、`runtime/host/adapters.ts`、`runtime/host/GameHost.vue`、`app/lib/contrast.test.ts`、`app/styles/main.css`、**新增** `app/lib/bootstrapTheme.test.ts`、**新增** `e2e/bootstrapTheme.spec.ts`(RE-PIN 2026-10-04,闭合终审 §6-4:该文件 294 行 / 9 腿、fix 轮 +162 −13,§5-T4.3 有它的完整测试计划而 §4 的范围内清单漏列) | 其它 `.vue` / `useTheme.ts` / `themeBootstrap.test.ts` / `noHardcodedColor.test.ts` |
| 主题 | bootstrap 加载屏的两态 | GameHost 展柜框架本身(它吃主应用令牌,已正确跟随暗色;其亮色徽标 3.26 属设计决策,挂账) |
| 守卫 | 新增 bootstrap 主题一致性守卫 + `AA_PAIRS` 补一对 | 改既有守卫的断言语义 |
| 令牌 | 删 `--color-info` | 新增任何令牌 |
| 行为 | 加载屏配色 + hash 多一个可选键 | 任何游戏运行时行为、SW 路由、bundle 解密 |
**挂账**(本批发现但刻意不做):GameHost 亮色徽标 WCAG 3.26(设计决策,撞色约束已登记 ROADMAP `9965fd7`)· 第三态"恢复跟随系统"(P13 spike 3 已证伪:header 在 @320px 最坏格无像素容纳带标签控件)· CSP 落地时 bootstrap 内联脚本也需 `nonce`(与主应用同批处理)· SFC `<style>` 块内硬编码 hex 守卫不覆盖(P13 accepted:`app/` 无 `<style>` 块)。
---
## §5 测试计划
### T1 —— 守卫先行(**RED 先行**):新增 `app/lib/bootstrapTheme.test.ts`
源码级守卫,**node 环境**(§0:vitest 默认 node,沿用 `fileURLToPath(new URL('../..', import.meta.url))` 读文件,与 `contrast.test.ts` / `themeBootstrap.test.ts` / `noHardcodedColor.test.ts` 同款)。
读三个文件:`bootstrap/index.html`、`app/composables/useTheme.ts`、`app/styles/main.css`。
**腿清单**(每条都要有 mutation 证明有牙):
| # | 断言 | 钉什么 | mutation(应变红) |
|---|---|---|---|
| 1 | bootstrap 的 `<head>` 内有 pre-paint 脚本,且在 `<style>` 与 `<body>` **之前** | D-G 的 pre-paint 位置(否则暗色用户每次启动闪白) | 把 `<script>` 移到 `<style>` 之后 |
| 2 | bootstrap 判定序 = **hash theme → prefers-color-scheme → light**,且用 `indexOf` 比较三者位置 | D-A 的判定序 | 交换 hash 与 prefers 的先后;或删掉 light 兜底 |
| 3 | bootstrap 用**白名单**校验 hash theme(只接受 `light`/`dark`) | 防"任意 truthy"放宽(P13 finding #2 / N2 的同族) | 改成 `hashTheme ? hashTheme : …` |
| 4 | 脚本是 **IIFE 且只用 `var`**(无 `let`/`const`/箭头函数/`import`) | D-G 的 CSP 前形态 | 把 `var` 改成 `const` |
| 5 | **`GameHost.vue` 注入的是 `effectiveTheme()` 而非 `useTheme().theme.value`** | 🔴 **D-B 的架构陷阱**(响应式会让主题切换重载运行中的游戏) | 改成 `theme.value` → 必须红 |
| 6 | bootstrap 暗色块的 hex 值与 `main.css` 的 `html[data-theme="dark"]` 调色板**逐值一致**(`paper`/`surface`/`ink`/`ink-soft`/`accent`/`accent-ink` 六个) | D-E 方案否决理由:bootstrap 自包含 hex,故须钉"两处调色板不分叉" | 把 bootstrap 暗色 `--paper` 改成别的值 |
| 7 | bootstrap **亮色调色板的值**未被改动 —— ⚠️ **须从 `:root` 块提取 `--bs-*` 再逐值比对(与腿 6 对称),不得用全文子串搜索**(RE-PIN 2026-10-03:审查者 F-4 实测 `#f7f2e7` 在文件里出现 **3 次**(`:root` 的 `--bs-paper`、暗色块的 `--bs-ink`、脚本的 theme-color meta)→ 全文搜索**必然命中**;把 `:root` 亮色 paper 改成 `#eeeeee` 时 `RED=[]`,且**四个守卫合跑 38 passed / 0 failed 全绿** = 本腿的守卫目的「防顺手把亮色也改了」**无断言覆盖**,是 P14「spec D-J 守卫目的无断言覆盖」的同族形态。改为按 scope 提取后**同时消除「暗色块 `--bs-ink` 与亮色 `--bs-paper` 同值」造成的耦合**,即 P13 `LEGACY_KEYS` 钉桩里「scrim 两主题同值 → 后写覆盖不可观测,故排除」的同一类推理) | 防"顺手把亮色也改了"(本批只加暗色) | 改 `:root` 的亮色 paper |
| 8 | `bootstrap/index.html` 内**不存在 inline `style` 里的 `color:`**(D-E 的回归钉桩)—— ⚠️ **修法必须覆盖双引号 / 单引号 / 无引号三种属性形态**(RE-PIN 2026-10-04,闭合终审 F-FINAL-4):**提取所有 `style=` 属性值、去引号后逐条查 `color:`**,即已实现的 `inlineStyleValues()`。🔴 **正则字面量刻意不写进本表格**——markdown 表格单元格里的竖线必须转义,而转义后的竖线在正则里是**字面量字符**而非「或」;控制者上一轮正是这样把一条本来正确的正则抄成 **4/7 形态失配**(比裁定原文的 2/7 更错),而它检查了「表格每行竖线数 == 表头」、**没检查转义后的代码是否还是原来那段代码**。要看正则请读代码实体 `src/app/lib/bootstrapTheme.test.ts` 的 `inlineStyleValues()`。⚠️ **不要写成「先匹配 style 属性、再往后找 `color:`」的一行正则**:实测该形态在单引号与无引号上失配——贪婪的引号内匹配会吃掉整个属性值(含内部的 `color:`),闭引号之后剩余文本以尖括号开头 → 失配。与 P13「`bg-[#…]` 手写反斜杠转义对 minified 假阴性」、控制者「带引号 grep 对 minified 假阴性」**同族:模式只覆盖了自己见过的那种写法** | 防 inline 违规复发(本批刚删两处 2.2594 / 2.4776) | 加回 `style="color:#a3a3a3"`、**或单引号形态、或无引号形态** |
| 9 | **前提检查**:扫描到的文件数 ≥ 3、bootstrap html 含 `<head>` / `<style` / `<body` **三个结构性标记**、且长度 **> 4000**(⚠️ 单位是**源码字符 4423**,不是产物 7056 字符 / 7588 字节) | 防"读 0 文件也报 0 违规"的假信心(P13 审查者 M5/M6、P14 终审者 R0 的同族教训)。**RE-PIN 2026-10-04**(闭合终审 §6-1):原写「长度 > 500」是 F-8 修**前**的旧值——500 对 4423 而言宽松到截断至 501 字符仍绿(mutation K4 实测),代码已改为 4000 并补了三个结构性前提 | 把路径改成不存在的文件;**或截断到 501 字符**(腿9 应红——修前它仍绿) |
| 10 | `AA_PAIRS` 含 `['ink','surface']` 且 `REQUIRED_KEYS` **不含** `info`、长度为 **11** | 改动②③ 的回归钉桩 | 删掉补钉的那一对;或把 `info` 加回 |
**RED 相位要求**:T1 在 T2/T3/T4 之前提交时,`npx vitest run app/lib/bootstrapTheme.test.ts` 必须**红**,且**至少腿 1/2/5/6/8/10 红**(bootstrap 还没有脚本、没有暗色块、GameHost 还没注入、AA_PAIRS 还没补、info 还在)。腿 7/9 应当**已绿**(亮色值本来就在、文件本来可读)——**这是有意的**:它们是本批的"不得回退"钉桩,不是新功能的断言。实现者须把 RED 输出**逐字存证**到 `.superpowers/sdd-p15b/impl-evidence/t1-red.txt`,并**逐腿标注红/绿**。
⚠️ **恒真/恒假审视(P13/P14 累计教训,每条腿都要过)**:
- 腿 2 用 `indexOf` 比较位置时,**若某个 `indexOf` 返回 -1(未找到),`-1 < 任何正数` 会让顺序断言恒真** → 必须先断言三者都 `> -1`(`themeBootstrap.test.ts:42-44` 正是这么写的,照抄这个手法)
- 腿 6 的"逐值一致"**不得写成 `max(a,b) ≥ k` 形态**(P13 的恒真高发区:互补两侧有非平凡下界);应写成**字符串相等**
- 腿 10 的长度断言须是 `toBe(11)` 而非 `toBeGreaterThanOrEqual(…)`(后者在删令牌后仍绿 = 无牙)
- 🔴 **(⚠️ RE-PIN 2026-10-03 补:原三条遗漏的第四类恒真形态)循环变量集合自身可被清空 → `for` 执行零次 = 恒真空绿**。审查者 **H4** 实测:清空 `DARK_TOKENS` 后腿 6 的 `for` 零次执行、`RED=[]`,**而退化是真的**——这是 P13 审查者 **M5**(扫描根改 e2e → 扫 0 个 `.vue`)/ **M6**(`HEX_RE` 改恒不匹配)的**精确同族**。**修法:每条以集合驱动的腿都须加 `expect(LIST.length).toBe(N)`**(腿 6 加 `DARK_TOKENS.length === 6`、腿 7 加 `LIGHT_HEX.length === 6`,与腿 10 的 `toBe(11)` 同款手法)
### T2 —— 改动②③(守卫腿 10 由红转绿)
按 §3.2 / §3.3 执行。**完成后 `npx vitest run` 必须 79 文件 / 688+N 测试全绿**(N = T1 新增的腿数),且**既有测试文件零修改**。
⚠️ 改动③会让 `contrast.test.ts` 的 `it` 标题从 "12 令牌齐备" 变 "11 令牌齐备" → **测试名变了但文件是"修改"而非"新增"**。这是**唯一被允许的既有测试文件修改**,且必须在报告里显式声明(同 P14 的 `architecture_test.go` 例外处理)。除此之外 `--diff-filter=M -- '*_test.ts'` 必须为空。
### T3 —— 改动①(bootstrap 暗色 + hash 注入)
按 §3.1 执行。完成后:
- `npx vitest run` 全绿
- `npm run typecheck`(`vue-tsc --noEmit`)零错
- `adapters.test.ts` 的既有 **11 处**调用**零修改**仍绿(验证 `theme` 是可选键)
### T4 —— e2e 与构建产物验证
1. `npm run build`(含 `vue-tsc --noEmit` + `vite build` + `build-runtime.mjs`)exit 0
⚠️ **`npm run build` 会类型检查 `e2e/*.spec.ts`**(P13 spike 6 曾因此 `BUILD_EXIT=2`)
2. `npm run e2e` 主套件 **102+1skip** 基线不回退(P13 交付值;`dark.spec.ts` 10 腿须全绿)
3. **新增 e2e 腿**(⚠️ RE-PIN 2026-10-03:**偏离 5 已被裁定驳回**,本项按原意执行,不接受「fixture 不支持所以不测」):(a) **端到端腿**——暗色 + 虚拟游戏页,用 **`context.route`(不是 `page.route`)** + inert sw.js 冻住加载屏,断言 **`snap.url === 父侧 iframe src`**(**因果链闭合**:子文档读到的就是父侧算出的那个 url,不是测试手搓的 hash)、`attr=dark`、`bg=rgb(23,20,15)`、h1 是加载屏、`theme-color` meta = `#17140f`、**+8s 后 `path` 仍是 `/__bootstrap`**;另加亮色格(`stored=light` 压过系统暗色)。🔴 **根因纠正**:实现者四种拦截手法**全用 `page.route`**,审查者 PROBE-6A 实测**三个 route 命中数全为 0**(SW 注册请求、SW 发起的请求、被 SW `respondWith` 合成的导航都**不经 page 级路由**)→ 「挂起从未生效,真 SW 照常安装 → `runtime:ready` → `location.replace('/')`」被误归因为「加载屏本质瞬态」。改 `context.route` 后 PROBE-7A `sw.js hits = 1`、子文档稳定停在 `/__bootstrap`;PROBE-7B **T+0/T+10s/T+25s 三次全 `attr=dark`**,T+25s 因 Playwright 自身 `timeout: 45_000` 中断而**不是被顶掉**。(b) **行为腿**——游戏页切主题 → iframe `src` **不变**(同一 inert-SW 手法下可确定性断言):这是 D-B 那个 critical 性质的**唯一行为级防线**(腿 5 改回全文断言后仍有残留局限,见 §5-T1 腿 5)。(c) **反方向格**——子侧 `[系统暗色] × [hash=light]` → 期望 **`light`**(6 行):审查者实测 **H1**(白名单提取成变量 + prefers 优先)与 **H3**(拆中间变量的嵌套三元)都让腿 2 全绿,而**缺陷是真的**(显式选亮色的暗色系统用户看到暗色加载屏,违反 D-A「hash 优先」);且 **H1 同时逃过源码守卫(10 passed)与全部 5 条 e2e 腿(5 passed)**。缺失的正是这一格,它是 H1/H3 在**子侧直访**路径上的唯一鉴别格(⚠️ RE-PIN 2026-10-04,闭合终审 §6-2:实测判定序真缺陷会让**两条**腿红——本格**与端到端亮色格**,故「唯一」只在子侧直访这一类里成立;原措辞是纪律 #4 惩罚的全称声明)。对照:主应用 `dark.spec.ts:169` **有**这条反方向腿(「stored 压过 system(反方向)」),bootstrap 侧缺
4. **产物验证**(P13 教训:源码级守卫不等于产物正确):⚠️ **必须在 e2e 之后重跑生产 `npm run build` 之上做**——`npm run e2e` 内部跑 `build:e2e`(`playwright.config.ts:12` 的 `webServer.command`)会**覆盖 `dist/`**,并用 `grep -c -F 'localhost:4173' src/dist/bootstrap/index.html` = **0** 证明量的是生产构建而非 e2e 残留(⚠️ RE-PIN 2026-10-03:控制者当日正是量了 e2e 残留,导致它的 7069 与实现者的 7056 本就不该相等而被当成矛盾;实现者报告 §2 已明写这个顺序陷阱)。判据:`dist/bootstrap/index.html` **含暗色块**、**不含** `#a3a3a3` 与 `#f87171`;主应用 CSS 里 `--color-info` **零命中**、两个色值(`2b62cc`/`7aa7f0`)**零命中**;**定义侧**令牌数 **12→11**(源码 `@theme` 与 `html[data-theme="dark"]` 各 11、产物暗色块 11);**usage 侧** `var(--color-*)` 计数与 P13 交付值 **75 保持不变**——`info` 是**零 usage 的死令牌**(7 种 utility 后缀 `bg-`/`text-`/`border-`/`ring-`/`fill-`/`stroke-`/`outline-` 全部零命中),删它**必然**不改变 usage 计数,这是预期行为而非缺陷。🔴 **判据方向**:若 usage 计数**发生变化**,才说明存在对 `info` 的引用,与 §1.3 的「零使用」取证前提矛盾,须停下来重新取证。**定义侧与 usage 侧是两个口径,不得混用**(⚠️ RE-PIN:原判据「删一个令牌应使计数变化」方向写反了,由实现者发现、审查者独立复现成立)。⚠️ **产物 grep 用 `grep -F`**,不手写转义、不用带引号模式(minifier 会去属性引号:产物是 `data-theme=dark` 而非 `data-theme="dark"`;CSS 类名是**转义**形态 `.bg-\[\#…\]`)。⚠️ **报数字必须同时报口径**:字节用 `wc -c`(**不要用 `len(str)` 标注成 B**,那是字符数,差值精确等于 UTF-8 多字节贡献)、计数**同时给 raw 与屏蔽注释后两个数**并声明大小写敏感性、行数用 `wc -l`
⚠️ **产物 grep 用 `grep -F` 不手写转义**(P13 教训:`.backdrop\:bg-scrim` 手写反斜杠转义对 minified 产物假阴性;AGENTS.md 已立不变量)
### mutation 自查(实现者必做并附输出,每条测"预期红 + 其余绿 + 恢复证明")
- (a) 把 bootstrap 的 pre-paint `<script>` 移到 `<style>` 之后 → 腿 1 红
- (b) 交换 hash theme 与 `prefers-color-scheme` 的判定先后 → 腿 2 红
- (c) 把 hash theme 校验放宽成 `hashTheme ? hashTheme : …` → 腿 3 红
- (d) `GameHost.vue` 改用 `useTheme().theme.value` → 腿 5 红(**这条是 D-B 的牙**)
- (e) 改 bootstrap 暗色的一个 hex → 腿 6 红
- (f) 加回 `style="color:#a3a3a3"` → 腿 8 红
- (g) 从 `AA_PAIRS` 删掉补钉的 `['ink','surface']` → 腿 10 红
- (h) 把 `info` 加回 `REQUIRED_KEYS` → 腿 10 红
- (i) **把守卫的文件路径改成不存在的文件** → 腿 9 红(防"读 0 文件报 0 违规")
- (j) **删掉 `main.css` 的暗色块**(模拟"P13 成果回退")→ 腿 6 或 `contrast.test.ts` 的暗色腿红
每次 mutation 后**只回滚触及的文件**(`git checkout -- <file>`,**绝不用 `git checkout -- .`**——P14 控制者第 24 次自伤:全量回滚抹掉了未提交的守卫编辑,导致后续各轮全在测没有修复的树),并核 `git diff` 空 + 守卫文件腿数不变。
### 结构指标(交付时须报实测数字)
| 指标 | 基线 | 目标 |
|---|---|---|
| vitest 文件数 / 测试数 | **79 / 688** | **80 / 688+N**(N = T1 腿数,≥10) |
| 既有 `*.test.ts` 修改文件数 | — | **1**(仅 `contrast.test.ts`,且仅因 "12 令牌"→"11 令牌" 文案;须在报告显式声明) |
| `AA_PAIRS` 对数 | 10 | **11** |
| `REQUIRED_KEYS` 项数 | 12 | **11** |
| `LEGACY_KEYS` 项数 | 9 | **9**(不动) |
| `--color-info` 全仓命中 | 2(两处定义) | **0** |
| bootstrap 内 `localStorage`/`prefers-color-scheme`/`data-theme` 命中 | **0 / 0 / 0** | **0 / ≥1 / ≥1**(`localStorage` 仍须为 0:源隔离,D-A) |
| bootstrap 内 inline `style` 的 `color:` 数 | 2 | **0** |
| `typecheck` / `build` | 0 / 0 | **0 / 0** |
| 主 e2e | 102+1skip | **≥102+1skip**(新增腿另计) |
---
## §6 记账
- `crearte/docs/CHANGELOG.md` 新增 **`## [0.27.0] - 2026-10-03`**(插 `## [0.26.0]` 前)。格式:**同条目英文行紧跟中文行、无空行**;不同条目**空一行**;小节标题双语。
- wrapper `docs/CHANGELOG.md` 新增 **`## [0.3.8] - 2026-10-03`**(插 `## [0.3.7]` 前),小节 `### Done / 完成`。**与 P15-A 合并为一条还是分两条由控制者按合并时序定**(若两批同时合并,写一条含两仓的条目更清楚)。
- `docs/ROADMAP.md`:第三波表新增 **P15-B 行** + 文档索引表新增一行。**哈希引 merge commit**。挂账里删掉「死令牌 `--color-info`」(已处置)、新增「bootstrap 内联脚本的 CSP nonce」(与主应用同批)。
- P13 spec 加 **RE-PIN 标注块**(D-C:12→11 令牌),不改写原文。
- **纪律**:显式 `git add <file>`,**禁 `git add -A`**。wrapper 的 `IDENTITY.md`/`SOUL.md`/`USER.md` **绝不 stage**。
---
## §7 风险与缓解
| 风险 | 缓解 |
|---|---|
| **`theme` 键写成必填 → 破既有测试** | §3.1(d) 明令可选;`adapters.test.ts` **11 处**调用零修改仍绿是验收项 |
| **🔴 注入改成响应式 → 主题切换重载运行中的游戏** | D-B + §3.1(e) 的 ⚠️ 注释 + **T1 腿 5 与 mutation (d) 专门钉这条** + **§5-T4.3(b) 的行为腿**(RE-PIN 2026-10-03)。⚠️ **腿 5 只钉字面形态,不够**:审查者 F-1(critical)实测 **G1**(调用点字面量与交付版**逐字相同**、依赖在 computed 体内别处建立)与 **G2**(import 别名 + 本地同名响应式包装)**双双绕过**(10 腿全绿、`vue-tsc` exit 0),而用 `effectScope` + `computed` 求值计数**证明缺陷语义上真实**(基准 `evals=1`/url 不变;G1/G2 均 `evals=2`、url 从 `…theme=light` 翻成 `…theme=dark` → **iframe `src` 变化 → 运行中的游戏被重载、存档丢失**)。**修法(一行 ×2)**:腿 5 的两条负向断言从 `args`(调用点内)改回 `gameHostMasked`(已屏蔽注释的**全文**)——审查者逐字验证 `maskSourceComments()` **本身已解决**「陷阱注释必然写出被禁字面量」的冲突(🔴 **RE-PIN 2026-10-04 限定适用范围,终审 F-FINAL-1**:这句对**当前这个文件**是真的,但成立原因是「陷阱注释恰好是 `//` 行注释形态」,**不是**「屏蔽了注释」这一整类——该函数的 HTML 注释分支当时是死代码。故 F-1 的修法方向正确、必须做,而它的**安全论证**依赖一个只对 `//` 成立的泛化,那个泛化已被 C5/G1x 实测推翻)(屏蔽后 `useTheme()` 与 `theme.value` 都从文件消失、`effectiveTheme` 真实代码保留、陷阱注释 28–33 行屏蔽后全空)→ **缩小断言范围不必要,第二道防线反而制造了缺口**(与 P14 终审「在第一轮修复自身里找到洞」完全同构)。已验五种组合:交付形态+G1/G2 = 绿(绕过);改法+G1/G2 = **红(抓到)**;改法+合法树 = **10 腿全绿(不误红)**。⚠️ **残留局限须写进守卫注释、不得声称「唯一手段」**(纪律 #4):改后仍抓不住「在 computed 之外的**模块级作用域**读 ref 再传值」,但该形态**无害**(快照在模块加载时求值一次、无依赖 → 不会重载);真正的解药是行为腿 |
| **判定序与主应用分叉 → 同一用户两处不同主题** | T1 腿 2 钉 bootstrap 内部顺序;**另需一条腿比对 `useTheme.effectiveTheme()` 的后两级顺序**(`themeBootstrap.test.ts` 腿 2 的同款手法:用 `indexOf` 比较,且先断言三者都 `> -1`) |
| **bootstrap 与主应用调色板分叉**(bootstrap 自包含 hex) | T1 腿 6 逐值比对六个暗色 hex + 腿 7 钉亮色值不变。**这是 D-E 方案(引令牌)被否后的必要补偿** |
| **新守卫写成恒真** | §5-T1 的 ⚠️ 三条(`indexOf` 返 -1 时顺序断言恒真、`max(a,b) ≥ k` 形态、`toBeGreaterThanOrEqual` 无牙)+ mutation (i) 腿 9 前提检查 + (a)–(h) 逐条验牙 |
| **inline 违规复发** | T1 腿 8 + mutation (f)。**本批发现的两处是 pre-existing(P13 之前就存在),修完须有钉桩**,否则下一次改 bootstrap 又会加回来 |
| **产物与源码不一致**(源码级守卫绿但产物错) | §5-T4.4 产物验证。P13 教训:`APPLY.toString()` + `new Function` 序列化注入验证构建期烘焙是**无效手法**;必须验真实 `dist/` |
| **e2e fixture 无虚拟游戏 → hash 注入无覆盖** | ⚠️ RE-PIN 2026-10-03:**该风险的前提被证伪**——审查者实测加载屏**可**确定性冻结(`context.route` + inert sw.js,稳定 ≥25s),并已写出跑绿 2 条端到端腿(8.9s + 0.6s)。原「退路」(改 vitest 腿挂 `GameHost`)**删除**;§5-T4.3 的「必须有一腿覆盖」按原意执行。**快照存档不能替代断言**(快照是一次性观测、不会自己变红)——与 P13 教训同一枚硬币的两面:那边是「产物验证不能替代源码守卫」,这边是「**存档观测不能替代 CI 断言**」 |
---
## §8 实现纪律(P11–P14 累计教训,与 P15-A spec §8 同源,前端部分加粗)
1. **不推断,只实测。** P13 控制者在勘查期栽两次(**用运行时注入验证构建期烘焙**、**用 `APPLY.toString()`+`new Function` 序列化注入**),复核期写错断言/grep/锚点 13 次。
2. **断言或 grep 返回意外值时,先怀疑自己的模式,别先宣布缺陷。** P14 任务级审查者的第一版比对脚本误报 `CoverURL` 为 CHANGED(字符串字面量里的 `//` 被当注释起点);控制者六次因这条纪律避免假指控(含 `bg-surface=0`、`main.css 无全局色`、`七类断言=0`)。
3. **修守卫必须两方向都验**:对合法值不误红 + mutation 下不误绿。P13 控制者只验前者,交付了数学恒真的断言(`max(填充侧,边框侧) ≥ 3`,互补两侧下界 = √cr(paper,ink) = **4.0621 > 3**,50653 采样暴力验证)。
4. **全称声明需要全称范围的证据。** P14 控制者写"断言 5 是唯一能抓遮蔽的手段"被终审推翻(匿名接收者 `func (*ContentService) M()` 绕过)。**写"唯一/全部/任何"之前先枚举形态。**
5. **恒假断言与恒真断言同样无价值,且恒假更容易因"看起来严格"而通过审查。** P14 被否决的 `NumMethod()==0`(嵌入 `*T` 使方法进值+指针两个方法集,合法树上已是 22)。
6. **产物 CSS 选择器用 `grep -F`,不手写转义。** P13:`.backdrop\:bg-scrim` 手写反斜杠转义对 minified 产物假阴性;**带引号的 grep 模式对 minified 产物也假阴性**(`grep 'bg-[#]'` 匹配不到 `bg-[#123456]` 的压缩形态)。已立为 crearte AGENTS.md 不变量。
7. **源码级守卫跑 node 不跑 happy-dom。** `fileURLToPath(new URL('../..', import.meta.url))` 在 happy-dom 下抛 `ERR_INVALID_URL_SCHEME`。已立为 AGENTS.md 不变量;§0 已确认本仓 vitest 无 `environment` 键 → 默认 node。
8. **Tailwind v4 扫描全部源文件含 `.test.ts`/`.spec.ts`**:测试注释里的类名字面量会被当候选、烧死 utility 进产物。P13 实现者用拼接修对一处,控制者八分钟后在自己裁定 commit 里重新引入。已立为 AGENTS.md 不变量 §8-5b。**本批写守卫时,注释里不要出现完整的 `bg-[#…]` / `text-[#…]` 字面量**(用字符串拼接或省略号)。
9. **管道会吞掉真退出码**;**`grep -c` 输出 0 时 exit 1**,放 `&&` 链里会静默中断后续步骤(含 `git commit`)。
10. **`restore()` 只回滚 mutation 触及的文件,绝不用 `git checkout -- .`**;每轮恢复后核验守卫编辑仍在,否则 FATAL 终止。
11. **`|| echo "(zero)"` 这类兜底文本在 glob 失败时会伪装成"测量结果为零"** → 任何"零命中"结论都要先证明扫描范围非空(T1 腿 9 就是这条的机械化)。
12. **测试被迫修改 = 设计失败的信号**,除非 spec 明列例外(本批唯一例外:`contrast.test.ts` 的 "12→11 令牌" 文案)。若发现必须改其它测试,**停下来报告**。
13. 🔴 **(RE-PIN 2026-10-03,审查者 F-2)手法的适用范围没验证就当已穷尽**:`page.route` **拦不到** SW 注册请求、SW 发起的请求、以及被 SW `respondWith` 合成的导航 → 冻住 SW 驱动的加载屏须用 **`context.route`**。**用错层级会让「挂起」静默失效,并把结果误归因为「被测对象本质瞬态」**——实现者四种手法全用 `page.route`(实测命中 0),据此得出「加载屏无法冻结、端到端腿必然 flaky」并撤掉了腿,而归因是错的。与第 6 条(带引号 grep 对 minified 假阴性)**同族**。**换一层再试一次,再下结论。**
14. **(RE-PIN 2026-10-03,审查者 F-9)报数字必须同时报口径**:字节用 `wc -c`(**不要用 `len(str)` 标注成 B**——那是字符数,差值精确等于 UTF-8 多字节贡献:CSS 12、html 532);计数**同时给 raw 与屏蔽注释后两个数**并声明大小写敏感性(控制者用 raw、实现者用屏蔽注释+大小写不敏感 → 同一份产物两份互斥数字、第三方无法判定谁错);行数用 `wc -l`(不要 `split('\n')`);**产物验证须在 e2e 之后重跑生产 build**(`npm run e2e` 内部跑 `build:e2e` 会覆盖 `dist/`)。**没有口径的数字无法交叉核对,等于没报。**
15. **一次性验证脚本一律落盘存证**(控制者的 CSS 解析器没落盘 → 结论虽被审查者独立复现,但**实现不可审计**)。**适用例补全(RE-PIN 2026-10-04,终审 §8.2):census / 统计类脚本同样必须落盘** —— 前任的 arbitrary-utility census 脚本没落盘,导致「48 vs 47」这个差 1 的口径争议**至今无法对齐**(终审用三份口径独立数都得 48,判据「真 hex 色 = 0」三方一致,故结论不受影响,但差异本身不可追溯)。
16. 🔴 **(RE-PIN 2026-10-04,终审 §8.7)报告里引用的每一份证据文件,交付前须逐个 `ls` 核在盘上;引用别处输出须给出该输出的落盘路径,路径不存在即视为未验证。** 这条纪律本轮**两次生效**:fix 者靠它抓到自己的假取证(把审查者的 PROBE 数字写成自己的实测结论——**是靠核「文件在不在盘上」抓到的,不是靠核结论对不对,因为结论恰好是对的**);终审者靠同一手法抓到控制者 `verify-fix-v4.py` 的 §③ 是 21 条无条件 `print('[OK]')`、且它引用的 v2 输出从未归档 → 「0 FAIL」结论虽真却不自足。**落盘纪律的价值不在结论正确性,而在可审计性。**
17. 🔴 **(RE-PIN 2026-10-04)spec 与裁定里凡可执行的技术细节(正则、函数签名、字面量、行号区间、对比度数字)必须从代码实体 `grep` / `git show` 出来粘贴,或自己按公式复算,不得凭记忆手写。** 本批控制者因此错了三处:① 把审查者建议的一行正则抄进 §5-T1 腿8,而 markdown 表格的竖线转义让它变成 **4/7 形态失配**(比裁定原文的 2/7 更错)② 漏 re-pin 腿9(spec 仍写 > 500 而代码已是 4000)③ 引用的对比度 `3.0269` 从未被任何人复核,自算实为 **2.5519**(反查任何灰底都得不到 3.0269 → 那是个孤值)。与 P15-A 的同族错误(形参顺序、阶段区间行号)合并为一条纪律,两份 spec §8 同源。