Files
crearte-monorepo/docs/plans/2026-10-03-p13-dark-mode.md
T
XingfenD 14d029e61b 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.
2026-10-03 07:30:12 +08:00

14 KiB
Raw Blame History

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 实现者正独占该仓,本批不动)。