13 KiB
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 后跑。
全局约束(逐字,进每个任务简报)
- 零新依赖:不得改
package.jsondependencies/devDependencies;version字段恒0.1.0不动。 - 禁触文件:
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)。 - 样式语言:新粗野主义既有 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。 - TDD:先写/改 vitest 跑红,再实现跑绿;每个任务在自己报告里贴 RED/GREEN 的命令与输出摘要。
- 每任务独立 commit(前缀
feat(a11y):或fix(a11y):+ 任务号);CHANGELOG 不由任务写——控制者在波次末统一补 0.24.0。 - 验收命令(每任务收尾自跑并贴输出摘要):
npm test -- <本任务改动面的测试文件>+npm run typecheck。全量npm test/npm run build/npm run e2e/npm run e2e:noauth由控制者在波次末跑。 - 分支纪律:commit 前
git branch --show-current必须是feat/p9b-a11y-keyboard;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。 - 基线不回退:vitest 601 全绿、主 e2e 77+1skip、noauth 4。既有测试断言只在 spec/本计划明确要求时才可改(本批有两处:
ToastHost.test.ts的bg-accent→bg-accent-ink与容器 role 断言反转)。 - 精确使用 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">。 - 本批不做移动端汉堡菜单(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 顺序:
- 先写
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九个键(解析失败要红得明确,不要静默跳过配对断言)。 - 断言 ≥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))。 - 改
--color-success: #1f7a4d→ 跑 GREEN(预期paper on success4.76)。 - 改
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。 - 按 spec §3.5 逐字重写
ToastHost.vue模板 + 按 spec §3.1 改KIND_CLASS的error行 → GREEN。<script setup>里除KIND_CLASS外一字不动(PhXimport、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 顺序:
- 先扩
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 其余职责照常执行)。
- 按 spec §3.2 逐字加
focusMain()导出 + 改attachTitleHook回调为(to, from)→ GREEN。既有attachTitleHook的 title/description 两行与r.afterEach结构不动,只在末尾加if (to.path !== from.path) focusMain()。 - 按 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 顺序:
- 先写
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([])。 - 给三个视图的每个
<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 顺序:
- 先扩
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。
- star-1..5 各自
- 按 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.routemock:${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 项是否真达成(不是「看起来改了」)。