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

116 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)`
---
### Task 2: skip-link + SPA 导航后焦点管理(D-D / D-E)
改动文件:`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 项是否真达成(不是「看起来改了」)。