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.
96 lines
14 KiB
Markdown
96 lines
14 KiB
Markdown
# P13 实现计划:暗色模式
|
||
|
||
- **spec**:`docs/specs/2026-10-03-p13-dark-mode-design.md`(权威依据,冲突时以 spec 为准)
|
||
- **证据归档**:`crearte/.superpowers/sdd-p13/FINDINGS-survey.md`(7 轮 spike 实测)
|
||
- **分支**:`feat/p13-dark-mode`(已建,从 crearte `e59a171` 起)
|
||
- **范围**:仅 `crearte`。**`crearte-server` 与 `crearte-deploy` 零改动**(spec §4 边界)。
|
||
- **版本**:crearte 0.26.0 / wrapper 0.3.6(记账由控制者做,实现者**禁动** ROADMAP、CHANGELOG、`docs/`)。
|
||
|
||
---
|
||
|
||
## 任务切分与波次
|
||
|
||
全部任务都在 `crearte` 单一工作树与 git index 上 → 按 `sdd-parallel-dispatch` §1「一仓一写者」,**交给一个子代理串行完成**,不拆并行(拆了就是四路争用同一个 `.git/index.lock`,P12 已验证这条纪律)。
|
||
|
||
**TDD 顺序:守卫先行**。T1 写守卫并**看它红**(RED 输出必须复现 spec 引用的实测数字),再 T2 让它绿。P12 的经验:这个顺序使 RED 输出本身成为「守卫有牙」的证明,免去事后 revert 自证那一轮。
|
||
|
||
| 序 | 任务 | 文件 | 完成判据 |
|
||
|---|---|---|---|
|
||
| **T1** | 守卫重构为双主题(**RED 先行**) | `app/lib/contrast.test.ts` | 按块解析(亮/暗各自 Map);两块各断言 12 令牌齐备;双主题各断言 spec §1.3/§7 的 12 条真实配对(阈值:正文 4.5 / wordmark 大字号 3.0 / 焦点环非文本 3.0);`scrim` 分离**按主题钉实际分离侧**(D-F):亮色钉填充侧 `paper vs scrim ≥3`(17.60)、暗色钉边框侧 `ink vs scrim ≥3`(17.60)——**不得写成 `max(两侧) ≥3`**,两侧互补使其理论下界 = √16.50 = 4.0621 > 3 → **数学恒真、永不可能失败**(审查 finding #1,控制者暂力重算证实;实证:亮 scrim 改纯白得填充侧 1.1165 但 max()=18.42 → 仍绿)。也不得只钉 `ink vs scrim` 于两主题(亮色仅 1.07 → 误红);含「旧单 Map 解析器对含暗色块的 CSS 会得出错误结果」的反面钉桩(防后人简化回去)。**此时暗色块尚不存在 → 必须 RED**,红输出须显示「暗色 12 令牌缺失」 |
|
||
| **T2** | 令牌层落地 | `app/styles/main.css` | `@theme` 新增 `--color-skeleton: #efe9da` / `--color-scrim: #0d0b08`;五个 `--shadow-hard*` 内联 hex → `var(--color-ink)` / `var(--color-accent)`(D-D);追加 `html[data-theme="dark"] { … }` 全量 12 令牌 + `color-scheme: dark`(D-B/D-C,**必须** `html[data-theme=dark]` 特异性 (0,1,1),禁裸属性选择器、禁 `!important`、禁 `@apply` 绕过);`::selection`/`strong`/`.wordmark-label` **零改动**(D-A 核心收益)。**T1 由红转绿** |
|
||
| **T3** | 三处 usage 迁移 + 硬编码色守卫 | `ResultMeta.vue:52`、`StatePanel.vue` ×7、`FilterDrawer.vue:32`、新守卫文件 | ① `bg-accent text-ink` → `bg-accent-ink text-paper`(唯一把 accent 当背景的地方,迁移后 accent 纯非文本);② `bg-[#EFE9DA]` ×7 → `bg-skeleton`;③ `backdrop:bg-ink/60` → `backdrop:bg-scrim/80`(D-E)。新增源码级守卫扫描 `app/**/*.vue` **禁止** `bg-[#`/`text-[#`/`border-[#` 硬编码 hex,**RED 先行**(迁移前应报 7 处违规),且**必须沿用 P12 `tableOverflow.test.ts` 的屏蔽范式**(`<script>`/`<style>`/HTML 注释/`<textarea>`/`<title>` → 等长空白),否则注释里的示例会误报(P12 FE-1 假绿同源教训) |
|
||
| **T4** | 主题机制 | `app/composables/useTheme.ts`(新)、`index.html`、`app/App.vue`、`app/components/AppHeader.vue`、`useTheme.test.ts`(新)、`AppHeader.test.ts`(扩展) | 按 spec §3.5–§3.8 逐字落地。**关键约束**:开关**必须** `h-8 w-8`(32px;spike 3 实测 28px 在最坏格 margin 仅 5px);容器行 `gap-6`→`gap-2 sm:gap-6`、nav `gap-4`→`gap-2 sm:gap-4`(候选 B,最坏格 margin=16);**不得**改 `<summary>` 的 `max-w-[6rem] min-w-0` 与两 span 结构(P12 F3 钉桩);**不得**改 logo 文字/字号;`index.html` 内联 pre-paint 脚本的判定优先级须与 `useTheme.ts` **逐字一致**(stored → `prefers-color-scheme` → light);`useTheme` 若做模块级单例**必须**提供 `__resetTheme()`(P9-B D-I 跨测试污染教训) |
|
||
| **T5** | e2e | `e2e/dark.spec.ts`(新) | spec §5-T5 的 7 条最低覆盖:默认跟随系统(**且在 Vue 挂载前就已设置** = 验 D-K 无 FOUC)、开关切换(`data-theme` + `theme-color` meta + localStorage 三者同步)、reload 持久化、**计算值实证**(body `rgb(23,20,15)`/`rgb(247,242,231)`,且某 `.shadow-hard` 的 `box-shadow` 含 `rgb(247,242,231)` = spike 6 传导在真产物上复验)、遮罩不泛白、开关 `toHaveAccessibleName` 含当前态、暗色下 @320/@375 登录态 ascii60 零横向溢出 |
|
||
|
||
### 全量回归(实现者交付前必跑,`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**(含 P12 的 12 条 responsive 腿) |
|
||
| noauth e2e | `npm run e2e:noauth` | **4 passed** |
|
||
|
||
新增测试后数字应**上升**;任何既有腿下降必须解释。**P12 的 12 条 responsive 腿与 `AppHeader.test.ts` 的 F3 形态钉桩属不得回退项**——若 gap 变更导致某腿数值变化,**不得改断言迁就**,先查明是否真溢出。
|
||
|
||
### 构建产物核验(T2 后必做,grep dist CSS)
|
||
|
||
- `var(--color-*)` 引用数 **≥ 64**(不得下降)
|
||
- 内联 `#141414` 从 **11 降到 ≤ 3**,且逐处说明剩余来源
|
||
- ⚠️ **测试/注释里禁写已迁移掉的 Tailwind 类名字面量**(实现者 T2 期发现,spec 与 FINDINGS 均未预料)。Tailwind v4 的自动内容探测**扫描全部源文件,含 `.test.ts`**:`FilterDrawer.test.ts` 里一句 `not.toContain('backdrop:bg-ink/60')` 断言(及其注释)让 Tailwind 把这个类名当候选、**重新生成 utility 及 `#14141499` fallback 烧回产物**,使该计数停在 **3** 而非 2。修法:用**字符串拼接**构造字面量(`'backdrop:bg-i' + 'nk/60'`)。与 P12 屏蔽范式同源,方向相反——那里防「守卫扫到注释里的 table」,这里栽在「Tailwind 扫到测试里的死 utility」。任何「某 utility 不得再出现」的回退钉桩都必须拼接。
|
||
- 暗色块出现在产物中且未被 Tailwind 层吞掉
|
||
- ⚠️ **grep 模式必须容忍 minifier 去引号**(实现者发现、控制者独立复现 2026-10-03):本 plan 与 spec 原先写 `grep -c 'html\[data-theme="dark"\]'`(带双引号),但 lightningcss **会去掉属性选择器里非必需的引号**,产物实际是 `html[data-theme=dark]` → 带引号的字面模式**返回 0,会被误判为「暗色块被吞掉」**。正确:`grep -c 'html\[data-theme="\?dark"\?\]' "$CSS"` 或去引号版,须 ≥ 1。**源码里仍须写带引号的 `html[data-theme="dark"]`**(CSS 源码选择器)——错的只是产物核验的 grep。
|
||
- `.shadow-hard` 的 `--tw-shadow` 含 `var(--color-ink)`(spike 6 传导前提)
|
||
|
||
---
|
||
|
||
## 验收口径
|
||
|
||
实现者报告须含:六腿实跑输出摘录(数字,不是「应该没问题」)、构建产物四项 grep 结果、T1/T3 的 **RED 输出原文**(须复现 spec 引用的实测数字,如暗色 `ink on highlight` = 1.46)、**mutation 自查输出**(见下)、throwaway 清理证明(`porcelain=0`)。
|
||
|
||
### mutation 自查(实现者必做并附输出)
|
||
|
||
1. **亮色守卫仍被守**(D-H 的核心风险 = 重构把亮色守卫静默弄丢):把亮色 `--color-success` 临时改成低对比值 → **亮色腿必须红**;改回 → 绿。
|
||
2. **暗色守卫有牙**:把暗色 `--color-highlight` 临时改成 `#f5c518`(亮黄)→ `ink on highlight` 暗色 = **1.46 红**。
|
||
3. **硬编码色守卫有牙**:临时在某 `.vue` 加 `bg-[#123456]` → 守卫红;删除 → 绿。
|
||
4. **P12 成果未回退**:`npx vitest run app/lib/tableOverflow.test.ts app/lib/tableScope.test.ts` 仍绿;`responsive.spec.ts` 12 腿仍绿。
|
||
|
||
每次 mutation 后 `git checkout --` 恢复并核对 `git diff` 为空。
|
||
|
||
---
|
||
|
||
## 纪律(延续 P9-B / P11 / P12 教训,spec §8 全文适用)
|
||
|
||
1. **不推断 CSS/Vue/构建行为,只实测。** 本批已有两次栽在推断上:spike 5 用运行时注入验证**构建期烘焙**(失败:令牌已变而 `box-shadow` 仍 `rgb(20,20,20)`)、spike 2 第一版用 `APPLY.toString()` + `new Function` 序列化注入(`ReferenceError: APPLY is not defined`)。
|
||
2. **守卫先写、先看它红**;红输出须复现 spec 数字,否则守卫可能无牙。
|
||
3. **守卫自身要受同等审视**(P12 FE-1:注释掉包裹层守卫仍绿 = 假信心)。
|
||
4. **P12/P9-B 成果不得回退**:`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` 单调语义、`scope="col"` 计数 13/6/5。
|
||
5. **throwaway 跑完即删**;注意 `npm run build` 含 `vue-tsc --noEmit` 会检查 `e2e/*.spec.ts` 类型(spike 6 曾因此 `BUILD_EXIT=2`)——spike 期间可用 `npm run build:e2e` 绕开,但**交付前 `npm run build` 必须真过**。
|
||
6. **禁动**:`docs/`(ROADMAP、CHANGELOG、spec、plan)、`.superpowers/`(只写自己的报告)、`crearte-server/`、`crearte-deploy/`、`playwright.config.ts`、`.gitignore`。
|
||
7. **发现 spec 有误就纠正 spec 并在报告里说明**,不要迁就实现(P12 实现者纠正了 768px 归因,控制者用 spike 17 独立定案其成立并 re-pin 五处下游)。
|
||
8. commit 信息含任务号与决策号(`P13-T<n>` / `D-<X>`);**禁 `git add -A`**(`.superpowers/`、`src/test-results/` 不得入库)。
|
||
|
||
---
|
||
|
||
## 挂账处置(随本批记账,由控制者执行)
|
||
|
||
- **清偿**:ROADMAP P9-B 行尾「C 暗色模式」→ 标注已由 P13 清偿。
|
||
- **新增挂账**:
|
||
- 第三态「恢复跟随系统」(spec §4:header 无像素容纳带标签控件;三态循环在 32px 无标签按钮上不可发现)
|
||
- CSP 设计批(内联 pre-paint 脚本届时需 `nonce` 或外置 —— spec §3.6 已留注释提示)
|
||
- OG 社交卡片、播放计数、TLS+HSTS、D4 nav 逐字换行设计决策、备份恢复演练实证(owner 手动尾巴)
|
||
- 上传面一处低危 fallthrough(`uploads.go:30-36`:`ParseMultipartForm` 返回非 `MaxBytesError` 时不 return,落到 switch 给出误导性的 400「kind must be bundle or cover」而非真实原因)—— 本轮证据扫描发现,属 server 侧打磨候选
|
||
- `--color-info` 为**死令牌**(`bg-info` / `text-info` 实测均 0 实例,仅 1 处 token 定义)—— 清理或启用,属代码优雅候选
|
||
- **bundle-key 端点的公开信任边界无决策记录 + 一条误导性死管道**(server 侧,零行为变更的微批候选)。P13 勘查期的只读审计发现,**非缺陷,是文档缺口**:
|
||
- 事实:`router.go:104` 的 `engine.GET(RouteBundleKey, limiter.Middleware(), deps.BundleKey.Get)` 是路由表里**唯一没有 `RequireUser` 的数据端点**。
|
||
- **定性为有意设计的证据(共 7 处钉桩,非顺带覆盖)**:server 侧 **6 处**显式无鉴权断言 —— `router_test.go:62`(用**完全不带 `Authorization` 头**的 `httptest.NewRequest` 断言 **200** + `Cache-Control: no-store`)、`router_test.go:85`(无头 → 第 61 次 **429** + `Retry-After`,即限流是边界而非鉴权)、`admin_test.go:121`(`doRequest(..., "", "")` token 为空 → 200)、`integration_test.go:128`(同 → 200)、`integration_test.go:153`(同 → **410**)、`admin_works_test.go:120`(同 → 200 后吊销变 410);前端侧 `e2e/full-loop.spec.ts:112-118` 从**全新无 token 浏览器上下文** `request.get` 断言 **410**。若该路由挂了 `RequireUser`,这些请求会先返 **401**、永远到不了吊销/限流分支 —— 故「无鉴权可取钥」是被**多点钉死**的既有行为。
|
||
- 支撑链:MinIO 桶 `mc anonymous set download`(deploy README:26/41,密文公开可下载)→ `content.go:202` 经 `PublicURL(ObjectKey)` 把 bundle URL 交给**公开的** game detail 端点 → `noauth.spec.ts`(4 条)证明站点支持全匿名模式。即密文与 CEK 双双公开,**已发布作品对任何人可下载**——这与产品定位(公开托管社区)一致。
|
||
- 真实信任边界 = **60/min 限流 + 管理员吊销(410)**,不是鉴权。加密在此的作用是**可吊销的交付机制**(`bundle.UnwrapCEK` + `row.RevokedAt`),不是访问控制。
|
||
- **误导性死管道**:`runtime/sw/keyfetch.ts:6` 有 `token?: string`,`runtime/sw/index.ts:82` 一路透传,`keyfetch.test.ts:28-33` 断言 Authorization 头透传,`cors.go:31` 的 ACAH 也允许 `Authorization`——但服务端**从不要求、从不校验**它。
|
||
- 风险:将来有人「修好」缺失的 `RequireUser` → **匿名访客将无法游玩任何作品**(站点必须支持登出态游玩)。上述 7 处钉桩会以 401≠200/410/429 立即抓住,**故静默破坏风险实际很低**。真正的缺口只剩一个:`router.go:102-105` 处**没有任何注释说明这个端点为何故意不挂 `RequireUser`**,而它是路由表里唯一没有鉴权的数据端点——视觉上极像遗漏。
|
||
- 处置(P11「大声留痕」哲学的同款应用,零行为变更):**仅需① 在 `router.go` 该处加决策记录注释**,写明「有意公开:匿名访客必须能取钥游玩;信任边界是 60/min 限流 + 管理员吊销(410),不是鉴权;密文本身亦经 MinIO 匿名 download 公开,加密在此是可吊销的交付机制而非访问控制;加 `RequireUser` 会破坏登出态游玩并被 `router_test.go:62`/`integration_test.go:153`/`full-loop.spec.ts` 抓住」。
|
||
- ~~② 补反面钉桩测试~~ —— **撤销此建议**:`router_test.go:62` 已经就是那条测试(不带 Authorization 头断言 200)。我先前写「e2e 顺带覆盖」是**未核实就下的判断**,实际有 6 处 server 侧显式钉桩。教训:登记挂账前必须先 grep 既有测试,否则会开出「补一条已存在的测试」这种伪工作。
|
||
- ③ `keyfetch.ts:6` 的 `token?: string` 管道待查清后再定(属 crearte,P13 实现者正独占该仓,本批不动)。
|