# 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 | 原生控件(``、滚动条)不翻转 | | 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 ` 在 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` `` 内**内联 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`:塞进用户下拉菜单** | **登出态用户无法切换主题**(`
` 仅在 `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,继承 `

`(`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' } } /** 把主题落到 与 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 = 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 - - + + crearte 创艺 + + ``` > `