Files
crearte-monorepo/docs/plans/2026-10-01-p9b-a11y-keyboard.md

13 KiB
Raw Permalink Blame History

P9-B UX 第二批(可访问与键盘效率)· 实现计划

spec:docs/specs/2026-10-01-p9b-a11y-keyboard-design.md(决策 D-A…D-K 以其为准;本计划与 spec 冲突时 spec 赢)。 仓库:仅 crearte(crearte-monorepo/crearte),分支 feat/p9b-a11y-keyboard。server/deploy 零改动。 npm 根:crearte/src(package.json 在此)。命令一律 cd crearte/src 后跑。

全局约束(逐字,进每个任务简报)

  1. 零新依赖:不得改 package.json dependencies/devDependencies;version 字段恒 0.1.0 不动。
  2. 禁触文件:e2e/landing.spec.ts、e2e/noauth.spec.ts、e2e/author-page.noauth.spec.ts、e2e/merge-repo.spec.ts、e2e/ux.spec.ts、e2e/submit-flow.spec.ts、e2e/admin-flow.spec.ts、e2e/helpers.ts、playwright.config.ts、playwright.noauth.config.ts、vitest.config.ts、vite.config.ts、vite.config.test.ts、scripts/、runtime/(本批完全不动,含 GameHost.vue)。
  3. 样式语言:新粗野主义既有 tokens(border-2 border-ink、shadow-hard*、bg-paper/surface/ink/highlight/accent/accent-ink/success、btn-ink/btn-surface/lift、font-mono text-[0.6875rem]、sr-only)。注释中文、标识符英文(仓内惯例)。app/lib/no-gradient.test.ts 守卫(禁渐变/禁圆角工具类/禁非零 border-radius)必须继续绿——本批不得引入 rounded*、gradient、非零 radius。
  4. TDD:先写/改 vitest 跑红,再实现跑绿;每个任务在自己报告里贴 RED/GREEN 的命令与输出摘要。
  5. 每任务独立 commit(前缀 feat(a11y): 或 fix(a11y): + 任务号);CHANGELOG 不由任务写——控制者在波次末统一补 0.24.0。
  6. 验收命令(每任务收尾自跑并贴输出摘要):npm test -- <本任务改动面的测试文件> + npm run typecheck。全量 npm test / npm run build / npm run e2e / npm run e2e:noauth 由控制者在波次末跑。
  7. 分支纪律:commit 前 git branch --show-current 必须是 feat/p9b-a11y-keyboard;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。
  8. 基线不回退:vitest 601 全绿、主 e2e 77+1skip、noauth 4。既有测试断言只在 spec/本计划明确要求时才可改(本批有两处:ToastHost.test.ts 的 bg-accent→bg-accent-ink 与容器 role 断言反转)。
  9. 精确使用 spec 给定值:skip-link 文案 跳到主内容、href="#main"、main 的 id="main" tabindex="-1";操作列表头 sr-only 文本 操作;星标 aria-label 模板 评 ${i} 星、分组 aria-label 模板(未评 评分,已评 评分:${score} 星);--color-success: #1f7a4d;error toast 类 bg-accent-ink text-paper;toast 文本节点 <p role="status" aria-live="polite" class="min-w-0 flex-1">。
  10. 本批不做移动端汉堡菜单(spec D-K:实测 360px/320px 视口下 documentElement.scrollWidth 恰等于视口宽,无溢出);不做 <td>→<th scope="row"> 行头重构(spec D-F);不做暗色模式(C 包另行立项)。

Task 1: 色彩对比达标 + toast 实时区重构(D-A / D-B / D-C / D-I)

改动文件:app/styles/main.css、app/components/ToastHost.vue、新 app/lib/contrast.test.ts、扩 app/components/ToastHost.test.ts。

TDD 顺序:

  1. 先写 app/lib/contrast.test.ts(RED:success 当前 #2fa46a,paper on success 只有 3.16,断言 ≥4.5 必红)。逐字实现 spec §3.1 的 channel/luminance/contrast 三个函数(放测试文件内即可,不必新建 lib 模块——它是守卫而非生产代码)。令牌解析:从 app/styles/main.css 读文本,正则 /--color-([\w-]+):\s*(#[0-9a-fA-F]{6})/g 建 map;先断言 map 至少含 paper/surface/ink/ink-soft/ink-faint/accent/accent-ink/highlight/success 九个键(解析失败要红得明确,不要静默跳过配对断言)。
  2. 断言 ≥4.5 的配对(fg on bg,逐字用 spec §3.1 清单):paper on success、paper on accent-ink、ink on highlight、ink on accent、ink-soft on paper、ink-faint on paper、ink-soft on surface、ink-faint on surface、paper on ink、accent-ink on paper。断言 ≥3 的:accent on paper(焦点环,WCAG 1.4.11 非文本)。断言时给出实测值以便失败可读(例如 expect(contrast(paper, success), 'paper on success').toBeGreaterThanOrEqual(4.5))。
  3. 改 --color-success: #1f7a4d → 跑 GREEN(预期 paper on success 4.76)。
  4. 改 ToastHost.test.ts:① 容器断言反转为不含 role/aria-live(expect(host.attributes('role')).toBeUndefined()、aria-live 同);② 新断言每条 toast 内 p[role=status][aria-live=polite] 存在且 text() 等于消息;③ 新断言关闭按钮是实时区兄弟:button.element.parentElement === p.element.parentElement 且 p.element.contains(button.element) === false;④ kind 配色断言里 error 的 bg-accent 改 bg-accent-ink(success/info 两行不动);⑤ 既有「点关闭 → store 少一条」用例保留。先跑 RED。
  5. 按 spec §3.5 逐字重写 ToastHost.vue 模板 + 按 spec §3.1 改 KIND_CLASS 的 error 行 → GREEN。<script setup> 里除 KIND_CLASS 外一字不动(PhX import、useToast 解构都保留)。

陷阱:容器去掉 role="status" 后,data-testid="toast-host" 与全部定位类(pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2)必须原样保留——ux.spec.ts(禁触)与既有单测靠 data-testid 定位。useToast.ts 一字不动。

commit:feat(a11y): P9B-T1 WCAG contrast tokens + toast live-region restructure (D-A/D-B/D-C/D-I)


改动文件:app/App.vue、app/router/index.ts、扩 app/router/index.test.ts。

TDD 顺序:

  1. 先扩 app/router/index.test.ts,新增 describe「SPA 导航后焦点(D-E)」,RED:
    • beforeEach 里往 document.body 注入 <main id="main" tabindex="-1"></main>(happy-dom 需要元素在文档里 focus() 才生效——P10 T5 已验证过这条),afterEach 移除它并 document.activeElement?.blur?.(),避免污染同文件既有标题用例。
    • 用例①:makeRouter() → push('/') → push('/games') → 断言 document.activeElement?.id === 'main'。
    • 用例②(同路径改 query 不抢焦点):push('/games') 后把焦点挪到别的元素(注入 <button id="probe"> 并 focus),再 push('/games?tag=数字') → 断言 document.activeElement?.id === 'probe'(不是 main)。
    • 用例③(#main 缺失时静默):移除注入的 main → push('/docs') 不抛错(await expect(router.push('/docs')).resolves.not.toThrow() 或包 try 后断言 document.title 仍被设置,证明 afterEach 其余职责照常执行)。
  2. 按 spec §3.2 逐字加 focusMain() 导出 + 改 attachTitleHook 回调为 (to, from) → GREEN。既有 attachTitleHook 的 title/description 两行与 r.afterEach 结构不动,只在末尾加 if (to.path !== from.path) focusMain()。
  3. 按 spec §3.2 逐字改 App.vue 模板(skip-link 作根 div 首子元素、<main id="main" tabindex="-1">)。<script setup> 一字不动。

陷阱:router/index.ts 的 routes/scrollBehavior/beforeEach/attachTitleHook(router) 末行全部不动;pageTitle.test.ts 与 router/index.test.ts 既有用例(标题基线、description 复位、game 复合 id)必须继续绿。skip-link 用 Tailwind sr-only focus:not-sr-only 组合,不要在 main.css 新增 .skip-link 规则(spec D-D)。

commit:feat(a11y): P9B-T2 skip-link + focus main on SPA path navigation (D-D/D-E)


Task 3: 管理端表格列头语义 + 守卫(D-F / D-G)

改动文件:app/views/AdminView.vue、app/views/AdminUsersView.vue、app/views/AdminAuditView.vue、新 app/lib/tableScope.test.ts。

TDD 顺序:

  1. 先写 app/lib/tableScope.test.ts(RED:当前全仓 19 个 <th> 零 scope)。仿 app/lib/no-gradient.test.ts 的结构逐字复用其 walk/candidates 思路:递归扫 app/**/*.vue(跳过 *.test.ts),对每个文件把内容按 <th 出现处切分,取从 <th 起到其后第一个 > 止的片段作为「起始标签」,断言其中含 scope=(这样多行书写的 <th\n class=…\n> 也能正确判定)。违规收集为 相对路径:行号: 行内容 并 expect(violations).toEqual([])。
  2. 给三个视图的每个 <th> 加 scope="col"(共 19 个),类名/文本/顺序一字不动。两处空表头(AdminView.vue 队列表 <th class="px-3 py-2" />、AdminUsersView.vue <th class="px-3 py-2" />)改为 spec §3.3 的 <th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>。→ GREEN。

陷阱:AdminView.vue 有 3 张表(队列/已通过/作品管理),AdminUsersView.vue 1 张、AdminAuditView.vue 1 张,全部要覆盖;不得改 <td>、<thead>、<tbody>、data-testid、任何列文本。AdminUsersView.test.ts / AdminAuditView.test.ts 既有用例(listAdminUsers 调用形状、user-row-*、audit-status-*)必须继续绿。AdminView.vue 无测试文件,不必新建(守卫测试已覆盖其表格语义)。

commit:feat(a11y): P9B-T3 table column-header scope on admin views + guard test (D-F/D-G)


Task 4: 五星评分可访问名(D-H)

改动文件:app/components/GameReactions.vue、扩 app/components/GameReactions.test.ts。

TDD 顺序:

  1. 先扩 GameReactions.test.ts,RED:
    • star-1..5 各自 attributes('aria-label') 为 评 1 星…评 5 星。
    • 分组容器(星标外层 span)role="group";rated: false 的 VIEW 下 aria-label === '评分';构造 fetchMine 返回 ratings: { '<user>/<slug>': 4 }(沿用该文件既有的 mock 范式,见其 h.fetchMine.mockResolvedValue 用例)使 rated === true,断言 aria-label === '评分:4 星'。
    • 每颗星内层字形 span 有 aria-hidden="true"。
    • 既有用例全部保留且必须继续绿:.text() 为 ★/☆ 的断言(aria-label 不改文本内容,故兼容)、4.5 · 2 人评分 · 3 收藏、fav-btn 的 aria-pressed、点 ♥ 调 setFavorite、点星调 setRating/unrate。
  2. 按 spec §3.4 逐字改 GameReactions.vue 的星标分组 → GREEN。data-testid、:disabled="busy"、类名、@click="pickStar(i)"、litStars 逻辑、撤评语义(点当前分撤评)全部不动。

陷阱:role="group" 加在既有的 <span class="inline-flex items-center"> 上,不要新增包裹层(会改排版)。rated/score 是该组件既有 ref(GameReactions.vue:15-16),直接用,不要新造状态。

commit:feat(a11y): P9B-T4 accessible names for the five-star rating control (D-H)


Task 5(控制者本人执行,不派发): e2e a11y.spec.ts(D-J)

新增 src/e2e/a11y.spec.ts 三条用例(spec §3.6 逐字),既有 e2e 文件与 helpers.ts 一字不动。

已核实的运行期前提(避免重蹈 P10 T6 的夹具错误):

  • e2e 构建(npm run build:e2e)注入 VITE_API_BASE_URL=http://localhost:4173 → authEnabled 与 reactionsEnabled 均为 true,故 seedSession 可用、GameReactions 会渲染。
  • /admin/users 的表格需接口有数据才渲染 <tbody> 行,但 <thead> 的 columnheader 在 StatePanel 插槽内、加载完成后即存在;e2e/admin-flow.spec.ts 的 installAdminApi 未导出,故本用例自带最小 page.route mock:${API}/api/admin/users** 返回 { users: [<一个 AdminUser>], total: 1 },AdminUser 形状逐字为 { id, email, username, display_name, role, created_at }(app/data/types.ts:102-109)。toHaveCount(6) 依赖 6 个 <th>(用户名/邮箱/显示名/角色/注册时间/操作)。
  • 星标用例走 fixture/2048:其源夹具 runtime 为 external,但本用例只断言 star-3 的可访问名,不调 openGame(不等 iframe ready),故 external 无碍;GameReactions 由 v-if="game" 门控,作品页加载完成即渲染。
  • skip-link 用例:page.keyboard.press('Tab') 的首站必须是 skip-link——它是根 div 首子元素,页头在其后。Enter 后原生锚点跳转把焦点交给 #main(tabindex="-1")。

波次末由控制者跑五腿:npm test / npm run typecheck / npm run build / npm run e2e / npm run e2e:noauth。

commit:test(e2e): P9B-T5 a11y spec — skip-link keyboard path, admin column headers, star accessible names (D-J)


执行顺序与并行性

T1/T2/T3/T4 改动文件两两不相交(T1: main.css+ToastHost+contrast.test;T2: App.vue+router;T3: 三 admin 视图+tableScope.test;T4: GameReactions)。但四者共用同一工作树与同一分支索引,并行 commit 会争 index.lock 并交叉 git add,故串行派发,每任务完成并过任务级审查后再派下一个。T5 由控制者在四者之后自己写并跑五腿。

每任务审查重点(任务级审查者的核查项):禁触清单零触碰、no-gradient 守卫仍绿、既有测试断言除授权两处外未被改动、spec 逐字代码与文案的偏差、以及各任务的 D 项是否真达成(不是「看起来改了」)。