# 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`(虚拟模式游戏的加载屏,`游戏加载中…` + 进度条 + 状态行 + 错误区)把整套配色**硬编码为亮色值**,且 `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(() => resolveRuntimeTargets(props.game, { baseDomain: config.baseDomain, protocol: location.protocol, config })) ``` 而 `useTheme.ts:51` 是模块级响应式单例 `const theme: Ref = 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 腿 4),否则下一个贡献者"顺手改成响应式"就会引入静默的游戏重载缺陷。 **色值映射(实测;`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` 覆盖了达标的样式表值)**: | 元素 | 样式表值(`