116 lines
13 KiB
Markdown
116 lines
13 KiB
Markdown
# 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 项是否真达成(不是「看起来改了」)。
|