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.
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
# 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 了五处下游)。
|
||||
Reference in New Issue
Block a user