Compare commits
4
Commits
9226926ebb
...
0e63dc2448
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e63dc2448 | ||
|
|
015c2b9696 | ||
|
|
35b50539c6 | ||
|
|
0a7eba2682 |
@@ -6,6 +6,13 @@ All notable changes to this repository should be documented in this file.
|
||||
The format loosely follows Keep a Changelog and can be adapted to the team's habits.
|
||||
本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。
|
||||
|
||||
## [0.3.3] - 2026-10-01
|
||||
|
||||
### Done / 完成
|
||||
|
||||
- P9-B UX batch-2 (accessibility & keyboard-efficiency pack) delivered: crearte `338acbf` (0.24.0, merge-base `ee4edf7`) — five tasks closing seven measured a11y gaps, T1-T4 dispatched to subagents and T5 written by the controller, each through implement → task-level review → adjudication (four reviews, all PASS), plus a whole-branch final review on the strongest available model cross-checking spec §1-§6 in full, the cross-task integration seams, and mutation-testing three guards. **Contrast to WCAG AA**: `--color-success` deepened `#2fa46a` → `#1f7a4d` (paper-on 2.83 → 4.76; the success toast is 12px bold, not large text) and the error toast switched `bg-accent` → `bg-accent-ink` (3.64 → 4.87, reusing an existing token rather than adding a colour). **Toast announcer restructured** (closing P10 final-review item T1(d), an interactive control living inside a live region): visual and announcement layers split the way Radix Toast / Headless UI do it — one persistent `sr-only p[role=status][aria-live=polite]` whose text a watch drives, no announcement semantics on the visible toasts, so the dismiss button is a sibling of the live region rather than a descendant. **Skip link** as the root div's first child before `<AppHeader />`, `sr-only` until focused then `focus:not-sr-only` with `focus:z-[80]` above the toast layer. **Focus management after SPA navigation**: `<main id="main" tabindex="-1">` plus an exported `focusMain()` called from the router's `afterEach` only when `to.path !== from.path` — query-only changes (P10's clickable tags, `/games?tag=x`) deliberately do not steal focus, `preventScroll` avoids double-scrolling against `scrollBehavior`, and a missing `#main` no-ops via the optional chain. **Table column headers**: all 24 `<th>` elements across the three admin views get `scope="col"`, and the two previously empty 操作 headers gain `<span class="sr-only">操作</span>` (they were unnamed columns to a screen reader). **Rating accessible names**: each star button gets `aria-label="评 N 星"` with its glyph `aria-hidden`, and the existing group span becomes `role="group"` with a dynamic label — deliberately not `radiogroup`/`aria-checked` (the control supports click-current-star-to-unrate, which radio cannot express) and not per-star `aria-pressed` (lighting is a continuous run, so pressed would announce four stars as all-pressed). **Two source-level guards** following the existing `no-gradient.test.ts` pattern: `lib/contrast.test.ts` parses `@theme` tokens out of `main.css` and asserts WCAG 2.1 ratios for eleven pairs (ten ≥4.5 plus the focus ring ≥3 per 1.4.11), `lib/tableScope.test.ts` scans every `app/**/*.vue` asserting each `<th` opening tag carries `scope=` (word-boundary match so the five `<thead>` occurrences are not flagged; opening-tag slice taken across newlines). **New e2e** `a11y.spec.ts`: real keyboard Tab path, admin `columnheader` names, star accessible names via `toHaveAccessibleName`. Zero server/deploy changes. Acceptance (five legs, green on merge tip, independently re-run six-for-six by the final reviewer): vitest **626** (baseline 601, +25) / vue-tsc clean / build + build-runtime OK / main e2e **80+1skip** (baseline 77+1skip) / noauth 4. Final verdict APPROVE with notes — zero critical, zero important. Two spec-layer corrections landed mid-wave: ① the T1 implementer caught a real defect in the spec's initial D-I design (a `role=status` mounted *together with* its text is not reliably announced by several screen-reader/browser combinations, trading button noise for total silence) → re-pinned to D-I′, fixed by the controller in `f01baae`, which also pinned the saturation trap (the watch must key on the last entry's **id**, not the array length — at `MAX_VISIBLE=3` a push evicts and appends in one assignment so the length stays 3 while a fourth message still has to be announced). ② Two quantity slips in the controller's own briefs (contrast 3.16 vs the real 2.83 for paper; "19 `<th>`" was a line count, the element count is 24) were both corrected by implementers deferring to the spec and to measurement; spec §1 was amended to match. Lesson recorded: quantities quoted in dispatch briefs must be verified at element level, never inferred from `grep -c` or from an adjacent number in the spec. Four items parked in the polish batch (re-check a weak router-test assertion on vue-router upgrade; surface unrate semantics in the accessible name; the announcer re-reads an older message when the newest toast is manually dismissed; spec wording already fixed). C (dark mode), OG social cards, play count, mobile header and later batches remain parked pending owner go-ahead.
|
||||
- P9-B UX 第二批(可访问与键盘效率包)交付:crearte `338acbf`(0.24.0,merge-base `ee4edf7`)——五任务闭合七处实测可访问缺口,T1-T4 派发子代理、T5 由控制者本人写,每任务经实现→任务级审查→裁定(四次审查全 PASS),另加最强可用模型的全分支终审,对 spec §1-§6 全量对照、跨任务集成缝隙、并对三处守卫做 mutation 式抽查。**色彩对比达 WCAG AA**:`--color-success` 由 `#2fa46a` 加深为 `#1f7a4d`(paper-on 2.83 → 4.76;成功 toast 是 12px 粗体,不属大字号),错误 toast 由 `bg-accent` 改为 `bg-accent-ink`(3.64 → 4.87,复用既有令牌而非新增颜色)。**toast 播报区重构**(收口 P10 终审转办项 T1(d):交互控件位于实时区内):按 Radix Toast / Headless UI 的做法把视觉层与播报层解耦——一个常驻的 `sr-only p[role=status][aria-live=polite]` 由 watch 驱动其文字,可见 toast 不带任何播报语义,于是关闭按钮成为实时区的兄弟而非后代。**跳转链接**作根 div 首子、在 `<AppHeader />` 之前,未聚焦时 `sr-only`、聚焦后 `focus:not-sr-only` 且 `focus:z-[80]` 压过 toast 层。**SPA 导航后焦点管理**:`<main id="main" tabindex="-1">` + 导出的 `focusMain()`,仅当 `to.path !== from.path` 时由路由 `afterEach` 调用——只改 query(P10 的可点标签 `/games?tag=x`)有意不抢焦点,`preventScroll` 避免与 `scrollBehavior` 双重滚动,`#main` 缺失时由可选链静默 no-op。**表格列头**:三个管理视图全部 24 个 `<th>` 元素加 `scope="col"`,两个此前为空的「操作」表头补上 `<span class="sr-only">操作</span>`(它们在读屏里曾是无名列)。**评分可访问名**:每颗星 `aria-label="评 N 星"` 且字形 `aria-hidden`,既有分组 span 成为 `role="group"` 并带动态标签——有意不用 `radiogroup`/`aria-checked`(本控件支持点当前分撤评,radio 无法表达取消选中),也不用逐星 `aria-pressed`(点亮是连续区间,pressed 会把四分播报成四颗星都按下)。**两个源码级守卫**沿用既有 `no-gradient.test.ts` 范式:`lib/contrast.test.ts` 从 `main.css` 解析 `@theme` 令牌并对十一个配对断言 WCAG 2.1 比值(十个 ≥4.5 加焦点环 ≥3,依 1.4.11),`lib/tableScope.test.ts` 扫描全部 `app/**/*.vue` 断言每个 `<th` 起始标签都带 `scope=`(词界匹配故五处 `<thead>` 不被误判;起始标签片段跨换行截取)。**新增 e2e** `a11y.spec.ts`:真实键盘 Tab 路径、管理端 `columnheader` 可访问名、经 `toHaveAccessibleName` 验证的星标可访问名。零后端/部署改动。验收(五腿,合并 tip 全绿,终审者六腿独立实跑逐条一致):vitest **626**(基线 601,+25)/ vue-tsc 零错 / build+build-runtime OK / 主 e2e **80+1skip**(基线 77+1skip)/ noauth 4。终审裁定 APPROVE with notes——零关键、零重要。波次内落两处 spec 层修正:① T1 实现者抓出 spec 初稿 D-I 的真缺陷(与文字**同时创建**的 `role=status` 在多个读屏+浏览器组合下不被可靠播报,等于用按钮噪音换来彻底静默)→ re-pin 为 D-I′,控制者于 `f01baae` 直修,并顺带钉死饱和态陷阱(watch 必须以末尾条目的 **id** 而非数组长度为源——`MAX_VISIBLE=3` 时 push 会「逐最旧+append」一次赋值完成,长度停在 3 而第四条消息仍需播报)。② 控制者自己简报里的两处数量笔误(对比度 3.16 与 paper 的真实值 2.83、「19 个 `<th>`」是行计数而元素实为 24)均由实现者以 spec 与实测为准纠正,spec §1 已同步修正。教训已入账:派发简报里引用的数量必须元素级核实,不得由 `grep -c` 或 spec 中相邻数字推断。四条转入打磨批(vue-router 升级时复看一处弱断言、撤评语义进可访问名、手动关掉最新 toast 时播报区会重念一条旧消息、spec 口径已修)。C(暗色模式)、OG 社交卡片、播放计数、移动端页头及后续批仍挂账待 owner 启动。
|
||||
|
||||
## [0.3.2] - 2026-10-01
|
||||
|
||||
### Done / 完成
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
| P8 | 长尾打包 | 权限开关 UI 全量(`inlineStyle/wasm/coop/fullscreen/gamepad`)、后端 triage 小项(`games.Detail` 400 细分、admin 路由 slug 校验、approve 同名竞态测试)、账号注销、静态兜底目录、CHANGELOG 模板文案 | **第一批完成**(②⑤①,2026-10-01 合并推送:server `921443a` 0.14.0 / crearte `70ba10c` 0.20.0。容器四连+空库 `-count=1` 集成全绿(新竞态测试含,skip 守卫 0)+`-race` 竞态腿 PASS;curl 冒烟全中:detail 400 细分 user:/slug: 且双非法 user 优先、admin 四路非法 400 `invalid work id`、合法格式不存在仍 404;FE vitest 499/typecheck/admin-flow 7/主套件 70+1skip;双审 PASS(server PASS with notes—唯一 note 双非法优先级缺钉桩,已以 `ee5b960` 测试断言补上);deploy 零改动。**第二批完成(③账号注销+④静态兜底目录)**(2026-10-01 合并推送:server `12b8ffb` 0.15.0(迁移 0011 墓碑化删行、DELETE /api/auth/account 对密码注销、唯一 admin 409、user delete CLI、catalog export 出 schemaVersion=2 目录)+ crearte `7d5f338` 0.21.0(/account 危险区+deleteAccount 客户端+AdminUsersView 三码中文化钉桩)+。真库注销链集成+分支真产物 curl 冒烟 14 项全 PASS(墓碑行/邮箱复用/409/旧 token 作废/CLI delete/export schema);FE vitest 512/typecheck/主套件 71+1skip/noauth 4,双审 PASS with notes(前端 2 条次要已钉桩 `7a54152`,server 裁决项:LOG_LEVEL 大小写不一致—deferred)。余 ④的 render 消费端接入按需(feed 已交付)**,CHANGELOG 头部去模板腔两仓已随第一批完成、wrapper 头部不在 spec 范围 |
|
||||
| P9 | UX 增强(第一批:浏览器反馈包) | 路由级 `document.title` 两段式(router `afterEach` 按 19 个 name 全表设静态基线 + 四个数据页 watch 数据后精化)+ 作品页 `meta description`(截 120 码点、默认回落)+ `StatePanel` 骨架对齐(cards 3→6 张、新增 `lines` 变体六页八实例)。零后端/部署改动 | **第一批完成**(2026-10-01 合并推送:crearte `26cd625` 0.22.0(`a196603`+`3f1de38`+审查补钉 `e9e4c56`)。五腿全绿:vitest **551**(基线 512+新增 39)/ vue-tsc 零错 / build OK / 主套件 72+1skip(含新增标题 e2e 腿,`landing.spec.ts` 首条兼容钉一字未动)/ noauth 4。独立审查 PASS with notes:ISSUE-1(离开作品页 description 未回落默认,spec D-C 子项)与 ISSUE-2(「七页」实为六页文案)均已在合主前补钉+补测。B 效率可访问 / C 暗色模式两批挂账待启动) |
|
||||
| P10 | UX 增强(第一批:日常交互包) | toast 体系(useToast 单例 store + ToastHost 挂 App.vue)+ 投稿表单脏数据离开守卫(onBeforeRouteLeave confirm + beforeunload)+ 作品标签可点(GameView/GameCard tags → /games?tag= RouterLink)+ 作品页复制链接分享(clipboard + toast)+ 最近玩过条带(recent.ts localStorage + GameHost ready 记录 + LandingView 条带)+ 页头下拉外点/Esc 关闭。零后端/部署改动 | **第一批完成**(2026-10-01 合并推送:crearte `ee4edf7` 0.23.0,merge-base `26cd625`。六任务各经实现→任务级审查→裁定,另加全分支终审(spec §3/§4/§5 全量对照)。五腿全绿:vitest **601**(基线 551+新增 50)/ vue-tsc 零错 / build OK / 主套件 **77+1skip**(基线 72+1skip)/ noauth 4。审查补钉四处:`fc0270e`(AppHeader 卸载清理 spy)、`dbf4bf0`(hosted 重开 sync-flush 再记,spec D-H re-pin)、`bb9bb35`(e2e 夹具修正——spec 测试计划自身误选 external-runtime 夹具 2048/a-dark-room,换 abs-paths/storage)、`836b2fb`(AppHeader 路由收起回归,spec §5 明列项)。终审裁定「需修复后合并」两条件均已闭合。B 效率可访问 / C 暗色模式 / OG 卡片 / 播放计数等后续批挂账待启动) |
|
||||
| P9-B | UX 增强(第二批:可访问与键盘效率包) | WCAG AA 色彩对比达标(`--color-success` 加深 + error toast 换 `bg-accent-ink`)+ toast 播报区重构(常驻 sr-only 播报层与视觉层解耦,收口 P10 终审转办项 T1(d)「实时区嵌交互按钮」)+ skip-link(`App.vue` 根首子,`focus:z-[80]`)+ SPA 导航后焦点管理(`focusMain()`,仅路径变化触发、`preventScroll`)+ 管理端表格 24 个 `<th>` 补 `scope="col"`(两处空表头补 sr-only「操作」)+ 五星评分可访问名(逐星「评 N 星」+ 分组 `role="group"` 动态标签 + 字形 `aria-hidden`)。另加两个源码级守卫(`contrast.test.ts` 十一配对对比度、`tableScope.test.ts` 全仓 `<th` 必带 scope)与 `e2e/a11y.spec.ts`。零后端/部署改动 | **第二批完成**(2026-10-01 合并推送:crearte `338acbf` 0.24.0,merge-base `ee4edf7`。五任务(T1-T4 派发 + T5 控制者本人)各经实现→任务级审查→裁定,四次任务级审查全 PASS,另加全分支终审(spec §1-§6 全量对照 + 跨任务集成缝隙 + mutation 抽查)。五腿全绿:vitest **626**(基线 601+新增 25)/ vue-tsc 零错 / build+build-runtime OK / 主套件 **80+1skip**(基线 77+1skip)/ noauth 4。终审独立实跑六腿与控制者数字逐条一致,裁定 APPROVE with notes(零关键/零重要)。波次内两次 spec 层修正:① T1 实现者抓出 spec 初稿 D-I 缺陷(per-toast `role=status` 与文字同时创建,部分读屏不播报首条)→ re-pin D-I′ 为常驻播报区,控制者直修 `f01baae` 并附带钉死饱和态 watch 陷阱(源必须是末尾 id 而非长度,`MAX_VISIBLE=3` 时 3→3 不触发);② 控制者简报两处数量笔误(对比度 3.16 应为 2.83、「19 个 th」是行计数、元素实为 24)均由实现者以 spec/实测为准纠正,spec §1 已同步。打磨批挂账四条(router 升级复看弱断言、撤评语义进可访问名、手动关最新 toast 的播报重念、spec 口径已修)。C 暗色模式 / OG 卡片 / 播放计数 / 移动端页头等后续批挂账待启动) |
|
||||
|
||||
## 排序理由
|
||||
|
||||
@@ -64,3 +65,4 @@
|
||||
| P8 长尾打包(第二批:③④) | `docs/specs/2026-10-01-p8b2-deletion-catalog-design.md` | `docs/plans/2026-10-01-p8b2-account-catalog.md`(已执行,2026-10-01:server 0.15.0 / crearte 0.21.0) |
|
||||
| P9 UX 第一批(浏览器反馈包) | `docs/specs/2026-10-01-p9-ux-browser-feedback-design.md` | `docs/plans/2026-10-01-p9-ux-browser-feedback.md`(第一批已执行,2026-10-01:crearte 0.22.0) |
|
||||
| P10 UX 第一批(日常交互包) | `docs/specs/2026-10-01-p10-ux-daily-interaction-design.md` | `docs/plans/2026-10-01-p10-ux-daily-interaction.md`(第一批已执行,2026-10-01:crearte 0.23.0) |
|
||||
| P9-B UX 第二批(可访问与键盘效率包) | `docs/specs/2026-10-01-p9b-a11y-keyboard-design.md` | `docs/plans/2026-10-01-p9b-a11y-keyboard.md`(第二批已执行,2026-10-01:crearte 0.24.0) |
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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 项是否真达成(不是「看起来改了」)。
|
||||
@@ -0,0 +1,321 @@
|
||||
# P9-B UX 第二批设计:可访问与键盘效率(a11y / keyboard)
|
||||
|
||||
日期:2026-10-01 | 决策链:owner UX 摸底三包(A 浏览器反馈 / B 效率可访问 / C 暗色模式)——A 已作 P9 第一批交付(crearte 0.22.0),第一梯队日常交互包已作 P10 交付(crearte 0.23.0)。本批 = 摸底 **B 包**,并收口 P10 终审转办的 T1(d)(实时区内嵌交互控件)。总纲仍为「继续增强该项目,从用户体验方面」。
|
||||
|
||||
仓库面:纯 `crearte`(前端)。零后端/部署改动。
|
||||
|
||||
## §1 现状勘查结论(已实测,不是推测)
|
||||
|
||||
已经就位、本批**不动**的面(避免重复劳动与无谓回归):
|
||||
|
||||
- `:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 2px }` 全局焦点环已在 `main.css:46`,`accent` 对 paper 3.26 / 对 surface 3.64,满足 WCAG 1.4.11 非文本对比 3:1。
|
||||
- `prefers-reduced-motion: reduce` 全局归零分支已在 `main.css:95-101`,覆盖 toast/lift/骨架动画。
|
||||
- 播放 iframe 已有 `:title="game.name"`(`GameHost.vue:105`);封面 `<img :alt="game.name">`(`GameCover.vue`);投稿封面预览 `alt="封面预览"`。
|
||||
- 页头三条主导航已有 `:aria-current="onX ? 'page' : undefined"`。
|
||||
- `FilterDrawer` 用原生 `<dialog>.showModal()`,焦点圈闭与 Esc 关闭由浏览器负责;关闭按钮有 `aria-label="关闭筛选"` 且 44px 触达面(`min-h-11 min-w-11`)。
|
||||
- `BaseSelect` 自绘 listbox 的键盘面已完整:trigger 上 ArrowDown/Up/Enter/Space 开面板,面板内 Esc 关并回焦、Arrow 移动、Enter/Space 选中、Tab 关不回焦,`role=listbox`/`role=option`/`aria-selected`/`aria-expanded`/`roving tabindex` 齐备。
|
||||
- 表单控件均有可见或 `sr-only` 的 `<label for>`(CatalogView / AdminUsersView 搜索、SubmitFormView 全字段)。
|
||||
- 登录/注册/账号三页的错误提示已做 `id` + `:aria-describedby` 字段级关联。
|
||||
- 收藏按钮已有 `:aria-pressed="favorited"`,心形字形已 `aria-hidden`。
|
||||
- **移动端页头无溢出**:实测 360px 与 320px 视口下 `documentElement.scrollWidth` 恰等于视口宽(320/360),页头内层 `scrollWidth` 亦相等。故本批**不引入汉堡菜单**——没有需要修的问题,加菜单只会增加键盘陷阱面。
|
||||
|
||||
实测出的真实缺口(本批范围):
|
||||
|
||||
1. `--color-success: #2fa46a` 对 paper 仅 **2.83**,对 surface 3.16 —— 作为成功 toast 的底色配 `text-paper`(12px 粗体,非大字号)实测 **3.16**,AA 正文需 4.5,**FAIL**。
|
||||
2. 错误 toast 用 `bg-accent`(#e8552f)配 `text-paper` 实测 **3.64**,**FAIL**(AA 正文需 4.5)。
|
||||
3. 三个管理视图共 **24 个 `<th>` 元素零 `scope`**(`grep -c '<th'` 的行计数为 19——AdminView 三张表把多个 `<th>` 塞在同一 `<tr>` 行内,故行数 < 元素数;以词界 `<th\b` 的元素计数 24 为准),且两个「操作」列的表头是空 `<th class="px-3 py-2" />`(无可访问名);全仓 `scope="row"` 出现 0 次。屏幕阅读器读表格时无法把单元格与列头关联。
|
||||
4. 五星评分按钮的可访问名就是字形本身「★」/「☆」——读屏逐个念「星 星 星」,无法知道这是几分、当前评了几分、点了会怎样。
|
||||
5. 全站**无 skip-link**:键盘用户每个页面都要 Tab 过页头(logo + 3 条导航 + 贴纸 + 登录/用户菜单 4 项)才能到正文。
|
||||
6. SPA 导航后**无焦点管理**:`router` 的 `afterEach` 只设 title/description,焦点留在旧位置(通常是 body),读屏用户点链接后听不到任何新页面上下文。
|
||||
7. (P10 T1(d) 转办)toast 容器整体是 `role="status" aria-live="polite"`,而每条 toast 内含可交互的「关闭提示」按钮——实时区内嵌交互控件是公认反模式:读屏会在轮询实时区时把按钮一并播报,且用户无法可靠地把焦点停在该控件上。
|
||||
|
||||
## §2 决策表
|
||||
|
||||
| # | 决策点 | 定案 | 理由 / 放弃的替代 |
|
||||
|---|---|---|---|
|
||||
| D-A | `success` 色令牌加深 | `--color-success: #1f7a4d`(`text-paper` 在其上 **4.76**、白 **5.32**;作文字色对 paper 4.76 / 对 surface 5.32) | 唯一消费者是成功 toast 底色,改令牌即全站生效且不留分叉。放弃「只改 toast 用别的绿」——会让令牌与实际用色脱节,下一个消费者重踩。放弃 #186039(6.79):过暗,脱离新粗野主义的高饱和海报感。 |
|
||||
| D-B | 错误 toast 底色 | `error: 'bg-accent-ink text-paper'`(**4.87**) | `accent-ink` 已是既有令牌且已被 AdminUsersView 用作 `bg-accent-ink text-paper` 底色,复用即一致,不新增色。放弃新增 `--color-error` 令牌:与 `accent-ink` 同值同义,纯冗余。放弃给错误 toast 加白字:`text-paper` 本就是纸白,无需动。 |
|
||||
| D-C | 对比度守卫测试 | 新增 `app/lib/contrast.test.ts`:解析 `main.css` 的 `@theme` 令牌,按 WCAG 2.1 相对亮度算比值,钉死关键配色对 ≥4.5(焦点环 ≥3) | 仿既有 `app/lib/no-gradient.test.ts` 的源码级守卫范式,让「不许再引入低对比配色」成为可执行约束而非口头纪律。放弃逐组件视觉回归:成本高且测不到令牌层。 |
|
||||
| D-D | skip-link | `App.vue` 根 div 首子元素插 `<a href="#main" class="skip-link …">跳到主内容</a>`;`<main id="main" tabindex="-1">`;样式走 Tailwind `sr-only` + `focus:not-sr-only` 组合,**不新增 CSS 规则** | 原生锚点跳转会同时移动焦点(href 指向带 tabindex=-1 的 main),无需 JS。`focus:z-[80]` 压过 toast 的 `z-[70]`。放弃自绘 `.skip-link` CSS:工具类已足够,少一处需维护的样式。 |
|
||||
| D-E | 路由后焦点管理 | `attachTitleHook` 的 `afterEach` 改为 `(to, from)`,当 `to.path !== from.path` 时对 `#main` 调 `focus({ preventScroll: true })`;`#main` 不存在时静默 no-op | 只在**路径**变化时移焦:`/games → /games?tag=数字`(P10 T3 的标签点击)属同页筛选,抢焦点会打断用户;路径变化才是真正的「换页」。`preventScroll` 因为 `scrollBehavior` 已负责滚动,双重滚动会抖。放弃聚焦各页 h1:17 个视图有 2 个含双 h1(GameView / OutboundView 的 notFound 分支),聚焦目标不唯一,且 h1 加 tabindex 会污染 Tab 序。 |
|
||||
| D-F | 表格列头语义 | 三个管理视图全部 `<th>` 加 `scope="col"`(元素级 24 个:AdminView 13 + AdminUsersView 6 + AdminAuditView 5);两个空的操作列表头改为 `<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>` | `scope="col"` 是最小且正确的修复。**不**改 `<td>` → `<th scope="row">`:那要把每行首格的排版结构(含 `<span>` 嵌套、font-bold 混排)重写成表头单元格,视觉回归风险大而收益有限(列头关联已解决读屏的主要痛点)。空表头补 sr-only 文本,否则操作列在读屏里是「无名第 5 列」。 |
|
||||
| D-G | 表格守卫测试 | 新增 `app/lib/tableScope.test.ts`:扫描 `app/**/*.vue`,断言每个 `<th` 都带 `scope=` | 与 D-C 同为源码级守卫,覆盖现有 5 张表与将来新增的表,比逐视图单测便宜且不随表格增删失效。放弃只测两个有单测的视图:AdminView 无测试文件,逐视图补测是 3 份重复劳动。 |
|
||||
| D-H | 五星评分可访问名 | 分组 `<span role="group" :aria-label="rated ? \`评分:${score} 星\` : '评分'">`;每颗星 `:aria-label="\`评 ${i} 星\`"`,内层字形 `<span aria-hidden="true">` | `aria-label` 覆盖字形成为可访问名,读屏念「评 3 星」而非「星」。分组标签携带当前值(已评几分),补齐「点了会怎样/现在是什么」。**不**用 `radiogroup`/`radio` + `aria-checked`:本控件支持「点当前分 = 撤评」,radio 无法表达取消选中;也**不**用逐星 `aria-pressed`——点亮是 `i <= litStars` 的连续区间,逐星 pressed 会把「4 分」播报成「1、2、3、4 星都按下」,语义反而更糊。 |
|
||||
| D-I | toast 实时区重构(收口 P10 T1(d))**D-I′ re-pin,见 §3.5** | 容器与每条 toast 均**不带**任何播报语义;另设一个**常驻**的 `sr-only` 播报区 `<p role="status" aria-live="polite">`,其文字在挂载后由 watch 驱动变更 | 播报区必须**先存在**再变更文字,读屏才会播报。初稿方案(把 `role=status` 挂在每条 toast 的文本 `<p>` 上)被 T1 实现者指出为缺陷:**播报区与文字同时创建**,在多个读屏+浏览器组合下不播报首条——等于把「按钮噪音」问题换成「彻底静默」问题。改为 Radix Toast / Headless UI 等无障碍组件库的标准做法:视觉层与播报层解耦。放弃「容器保留 role、按钮 aria-hidden」:按钮对读屏消失,鼠标用户能关、读屏用户关不掉,更糟。放弃「每条 toast 各自 role=status」:即初稿,见上。 |
|
||||
| D-J | e2e 覆盖 | 新增 `e2e/a11y.spec.ts` 三条:① Tab 首站是 skip-link、回车后焦点落 `#main`;② `/admin/users` 的 columnheader 可访问名齐备(含「操作」);③ 作品页 star 按钮可访问名为「评 N 星」 | skip-link 是纯键盘时序行为,happy-dom 单测测不出真实 Tab 序;管理页需登录态(`helpers.seedSession(page,'admin')`)。既有 e2e 文件一字不动,只新增。 |
|
||||
| D-K | 移动端页头 | **本批不做**(实测无溢出,见 §1) | 没有问题就不修。引入汉堡菜单会新增焦点圈闭、Esc、aria-expanded 三处需维护的状态机,纯负收益。 |
|
||||
|
||||
## §3 设计细节(实现须逐字采用)
|
||||
|
||||
### 3.1 D-A / D-B / D-C:色彩对比
|
||||
|
||||
`app/styles/main.css` 的 `@theme` 块内,唯一改动:
|
||||
|
||||
```css
|
||||
--color-success: #1f7a4d;
|
||||
```
|
||||
|
||||
`app/components/ToastHost.vue` 的 `KIND_CLASS`,唯一改动(`error` 行):
|
||||
|
||||
```ts
|
||||
const KIND_CLASS: Record<ToastKind, string> = {
|
||||
success: 'bg-success text-paper',
|
||||
error: 'bg-accent-ink text-paper',
|
||||
info: 'bg-highlight text-ink'
|
||||
}
|
||||
```
|
||||
|
||||
新增 `app/lib/contrast.test.ts`:从 `main.css` 正则提取 `--color-<name>: #rrggbb`,实现 WCAG 相对亮度与对比度,断言下列配对(`fg on bg`):
|
||||
|
||||
- ≥ 4.5:`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`(焦点环,1.4.11 非文本)
|
||||
|
||||
亮度公式(逐字,别自创):
|
||||
|
||||
```ts
|
||||
function channel(c: number): number {
|
||||
const s = c / 255
|
||||
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
function luminance(hex: string): number {
|
||||
const h = hex.replace('#', '')
|
||||
const [r, g, b] = [0, 2, 4].map((i) => channel(parseInt(h.slice(i, i + 2), 16)))
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
export function contrast(a: string, b: string): number {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 D-D / D-E:skip-link 与路由后焦点
|
||||
|
||||
`app/App.vue` 模板(`<AppHeader />` 之前插 skip-link,`<main>` 加 `id` 与 `tabindex`):
|
||||
|
||||
```html
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<a
|
||||
href="#main"
|
||||
class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[80] focus:border-2 focus:border-ink focus:bg-highlight focus:px-3 focus:py-2 focus:text-sm focus:font-extrabold focus:shadow-hard"
|
||||
>跳到主内容</a>
|
||||
<AppHeader />
|
||||
<main id="main" tabindex="-1" class="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
|
||||
<RouterView />
|
||||
</main>
|
||||
<AppFooter />
|
||||
<ToastHost />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
`app/router/index.ts`:新增导出函数 + 改 `attachTitleHook` 签名内的回调:
|
||||
|
||||
```ts
|
||||
// SPA 导航后把焦点交给主内容区:读屏用户据此获得新页面上下文(D-E)。
|
||||
// 仅路径变化时移动——同路径改 query(如目录 /games?tag=x 筛选)不打断用户焦点。
|
||||
// preventScroll:滚动由 router.scrollBehavior 负责,避免双重滚动抖动。
|
||||
export function focusMain(): void {
|
||||
document.getElementById('main')?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
export function attachTitleHook(r: Router): void {
|
||||
r.afterEach((to, from) => {
|
||||
setPageTitle(joinTitle(sectionTitleOf(to.name)))
|
||||
setPageDescription('')
|
||||
if (to.path !== from.path) focusMain()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
注意:`to.path !== from.path` 在首次导航时 `from` 是 `START_LOCATION`(path `/`),从 `/` 进 `/` 不会误触;从 `/` 进 `/games` 会正确触发。`#main` 尚未挂载(router 早于 app mount 就绪的边界)时 `getElementById` 返回 null,可选链静默 no-op。
|
||||
|
||||
### 3.3 D-F / D-G:表格列头
|
||||
|
||||
三个文件的每个 `<th>` 加 `scope="col"`,类名与文本一字不动。两处空表头(`AdminView.vue:152` 队列表的 `<th class="px-3 py-2" />`、`AdminUsersView.vue:123` 的 `<th class="px-3 py-2" />`)改为:
|
||||
|
||||
```html
|
||||
<th scope="col" class="px-3 py-2"><span class="sr-only">操作</span></th>
|
||||
```
|
||||
|
||||
新增 `app/lib/tableScope.test.ts`:递归扫 `app/**/*.vue`(排除 `*.test.ts`),对每行匹配 `<th\b`,断言该 `<th` 起始标签内含 `scope=`。多行书写的 `<th` 需读到闭合 `>` 为止再判定(AdminUsersView 的空表头是单行,但守卫要对将来健壮)。违规时报告 `相对路径:行号: 行内容`,断言 `toEqual([])`——与 `no-gradient.test.ts` 同风格。
|
||||
|
||||
### 3.4 D-H:五星评分
|
||||
|
||||
`app/components/GameReactions.vue` 的星标分组:
|
||||
|
||||
```html
|
||||
<span
|
||||
class="inline-flex items-center"
|
||||
role="group"
|
||||
:aria-label="rated ? `评分:${score} 星` : '评分'"
|
||||
>
|
||||
<button
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
type="button"
|
||||
:data-testid="`star-${i}`"
|
||||
:aria-label="`评 ${i} 星`"
|
||||
:disabled="busy"
|
||||
class="px-0.5 font-mono text-base leading-none disabled:opacity-60"
|
||||
@click="pickStar(i)"
|
||||
><span aria-hidden="true" :class="i <= litStars ? 'text-accent-ink' : 'text-ink-soft'">{{ i <= litStars ? '★' : '☆' }}</span></button>
|
||||
</span>
|
||||
```
|
||||
|
||||
`data-testid`、`disabled`、类名、点击语义(点当前分撤评)全部不动;既有测试对 `.text()` 的断言不受影响(`aria-label` 不改文本内容)。
|
||||
|
||||
### 3.5 D-I:toast 实时区(D-I′ re-pin)
|
||||
|
||||
**为什么 re-pin**:初稿把 `role="status"` 挂在每条 toast 的文本 `<p>` 上。播报区与文字**同时被创建**——屏幕阅读器只可靠播报「已存在于页面上的播报区」内的变更,新出现的播报区在多个读屏+浏览器组合下不播报首条,结果是把「按钮噪音」换成了「彻底静默」。修正为视觉层与播报层解耦:常驻一个 `sr-only` 播报区,文字在挂载**之后**由 watch 驱动变更。
|
||||
|
||||
`app/components/ToastHost.vue` 全文(`<script setup>` + 模板):
|
||||
|
||||
```html
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { PhX } from '@phosphor-icons/vue'
|
||||
import { useToast, type ToastKind } from '@/composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
|
||||
// 各提示类型配色(新粗野主义:实底 + 描边 + 硬阴影)
|
||||
const KIND_CLASS: Record<ToastKind, string> = {
|
||||
success: 'bg-success text-paper',
|
||||
error: 'bg-accent-ink text-paper',
|
||||
info: 'bg-highlight text-ink'
|
||||
}
|
||||
|
||||
// 常驻播报区(D-I′):屏幕阅读器只播报「已存在于页面上的 live region」内的变更,
|
||||
// 故播报节点必须随组件挂载即存在、之后只改文字。可见 toast 不带任何播报语义,
|
||||
// 使「关闭提示」按钮不再是 live region 的后代(P10 T1(d) 收口)。
|
||||
// 取 toasts 末尾一条即最新推入者(useToast 把新条 append 到尾部)。
|
||||
const announcement = ref('')
|
||||
watch(
|
||||
() => toasts.value.length,
|
||||
(n) => {
|
||||
announcement.value = n > 0 ? (toasts.value[n - 1]?.text ?? '') : ''
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 不 Teleport:容器常驻,屏幕阅读器语义稳定 -->
|
||||
<div
|
||||
data-testid="toast-host"
|
||||
class="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2"
|
||||
>
|
||||
<!-- 播报层:sr-only 常驻节点,只由 watch 改文字,不含任何交互控件 -->
|
||||
<p data-testid="toast-announce" role="status" aria-live="polite" class="sr-only">{{ announcement }}</p>
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
data-testid="toast"
|
||||
:class="[
|
||||
'pointer-events-auto flex items-start justify-between gap-2 border-2 border-ink px-3 py-2 text-xs font-bold shadow-hard',
|
||||
KIND_CLASS[t.kind]
|
||||
]"
|
||||
>
|
||||
<span class="min-w-0 flex-1">{{ t.text }}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭提示"
|
||||
class="-mr-1 shrink-0 cursor-pointer"
|
||||
@click="dismiss(t.id)"
|
||||
>
|
||||
<PhX :size="12" weight="bold" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- 播报节点 `data-testid="toast-announce"` 在容器**内首子位置**、`TransitionGroup` 之外,故它不参与进出场动画,也不被 `v-for` 重建。
|
||||
- watch 只观察 `toasts.value.length`:新消息推入时长度必增(上限 3 条时 `push` 会同时移最旧再 append,长度仍是 3→3,但此时 `toasts.value[n-1]` 已换成新条——见下方陷阱)。
|
||||
- 可见 toast 的文本节点用 `<span class="min-w-0 flex-1">`(不是 `<p>`):它在 `<div data-testid="toast">` 内,用块级 `<p>` 也可以,但 span 保持与 P10 原版一致的最小结构变更;`min-w-0 flex-1` 负责长文案换行时按钮仍右贴。
|
||||
|
||||
**陷阱**:`MAX_VISIBLE=3` 且已满 3 条时,`push` 的实现是 `toasts.value = [...slice(-(MAX_VISIBLE-1)), newItem]`——长度 3→3 不变,只观察 `length` 的 watch **不会触发**,第 4 条消息将不被播报。故 watch 源必须是**末尾元素的 id** 而非长度:
|
||||
|
||||
```ts
|
||||
watch(
|
||||
() => toasts.value[toasts.value.length - 1]?.id,
|
||||
(id) => {
|
||||
announcement.value = id === undefined ? '' : (toasts.value[toasts.value.length - 1]?.text ?? '')
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
`id` 由 `useToast` 单调递增(`nextId++`),故「有新消息」当且仅当「末尾 id 变化」,饱和态同样成立。全部 toast 消失时末尾 id 为 `undefined`,播报文字清空。
|
||||
|
||||
`useToast.ts` 一字不动(store 语义与本批无关)。
|
||||
|
||||
### 3.6 D-J:e2e
|
||||
|
||||
新增 `src/e2e/a11y.spec.ts`,三条用例,既有 e2e 文件与 `helpers.ts` 一字不动:
|
||||
|
||||
```ts
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { seedSession } from './helpers'
|
||||
|
||||
test('skip-link:Tab 首站可见,回车后焦点落在主内容区', async ({ page }) => {
|
||||
await page.goto('http://localhost:4173/')
|
||||
await page.keyboard.press('Tab')
|
||||
const skip = page.getByRole('link', { name: '跳到主内容' })
|
||||
await expect(skip).toBeVisible()
|
||||
await expect(skip).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('main#main')).toBeFocused()
|
||||
})
|
||||
|
||||
test('管理端表格列头具备可访问名(含 sr-only 的操作列)', async ({ page }) => {
|
||||
await seedSession(page, 'admin')
|
||||
await page.goto('http://localhost:4173/admin/users')
|
||||
const headers = page.getByRole('columnheader')
|
||||
await expect(headers).toHaveCount(6)
|
||||
await expect(headers.nth(0)).toHaveAccessibleName('用户名')
|
||||
await expect(headers.nth(5)).toHaveAccessibleName('操作')
|
||||
})
|
||||
|
||||
test('评分按钮的可访问名是「评 N 星」而非字形', async ({ page }) => {
|
||||
await seedSession(page, 'user')
|
||||
await page.goto('http://localhost:4173/games/fixture/2048')
|
||||
await expect(page.getByTestId('star-3')).toHaveAccessibleName('评 3 星')
|
||||
await expect(page.locator('[role=group][aria-label^="评分"]')).toHaveCount(1)
|
||||
})
|
||||
```
|
||||
|
||||
## §4 边界与不做的事
|
||||
|
||||
- 禁触:`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`、`scripts/`、`package.json`。
|
||||
- `e2e/ux.spec.ts`(禁触)靠 `[data-testid=toast]` 定位并断言 `toContainText('链接已复制')`——`data-testid="toast"` 与可见文本内容必须原样保留(文本搬进 `<span>` 后 `toContainText` 仍成立,子孙文本匹配)。
|
||||
- `runtime/` 本批**完全不动**(含 GameHost)——播放区可访问性(iframe title、全屏按钮语义)已在既有代码达标。
|
||||
- 不新增依赖、不改 `version`(恒 0.1.0)、不动 CHANGELOG(控制者统一写)。
|
||||
- 不改任何视觉设计:平面海报新粗野主义(无渐变/无圆角/硬阴影)必须保持,`no-gradient.test.ts` 守卫须继续绿。除 D-A/D-B 两处配色外,不动任何既有颜色与排版。
|
||||
- 不做移动端汉堡菜单(D-K,实测无溢出)。
|
||||
- 不做 `<td>` → `<th scope="row">` 的行头重构(D-F 理由)。
|
||||
- 不做暗色模式(C 包,另行立项)。
|
||||
|
||||
## §5 测试计划
|
||||
|
||||
vitest(新增/扩展):
|
||||
|
||||
- 新 `app/lib/contrast.test.ts`:令牌解析成功(能取到 paper/ink/success/accent-ink/highlight/accent/ink-soft/ink-faint/surface);§3.1 列出的 ≥4.5 配对逐条断言;焦点环 `accent on paper` ≥3。
|
||||
- 新 `app/lib/tableScope.test.ts`:守卫断言 `violations === []`(即全仓每个 `<th` 都带 scope)。
|
||||
- 扩 `app/components/ToastHost.test.ts`:① 容器与每条 toast 均**不含** `role`/`aria-live`(断言属性不存在);② 播报节点 `[data-testid=toast-announce][role=status][aria-live=polite]` 在 store 为空时**已存在**(常驻前提)且文字为空;③ push 后播报文字等于该消息;④ **饱和态播报**(D-I′ 陷阱钉桩):连推 4 条,第 4 条时 store 仍是 3 条(最旧被逐出)而播报文字必须是第 4 条的文本;⑤ 关闭按钮不是播报节点的后代,且播报节点内不含任何 `button`;⑥ kind 配色断言中 `error` 由 `bg-accent` 改 `bg-accent-ink`;⑦ 既有的「点关闭 → store 少一条」保留。
|
||||
- 扩 `app/router/index.test.ts`:新 describe「SPA 导航后焦点(D-E)」——先在 `document.body` 注入 `<main id="main" tabindex="-1">`,push `/` → `/games` 后断言 `document.activeElement.id === 'main'`;`/games` → `/games?tag=x` 后断言焦点**未**被 main 抢走(先 blur 或先聚焦别的元素再验);`#main` 不存在时 push 不抛错。
|
||||
- 扩 `app/components/GameReactions.test.ts`:star-1..5 的 `aria-label` 为「评 N 星」;分组 `role=group` 且未评时 `aria-label === '评分'`、已评(`rated: true, score: 4`)时为「评分:4 星」;内层字形 `aria-hidden="true"`;既有 `.text()` 为 ★/☆ 的断言全部保留且仍绿。
|
||||
|
||||
e2e:
|
||||
|
||||
- 新 `e2e/a11y.spec.ts` 三条(§3.6 逐字)。
|
||||
- 既有全部 e2e 文件一字不动,且必须继续全绿(`landing.spec.ts` 的 `getByRole('heading', { level: 1 })`、`locator('main')`、`ux.spec.ts` 的 `locator('main').click({position:{x:5,y:5}})` 与 toast 断言均须不受影响——skip-link 在 main 之外、toast 的 `data-testid` 与文本内容不变)。
|
||||
|
||||
验收腿:vitest 全绿(基线 601 + 新增)、`vue-tsc` 零错、`npm run build` OK、主 e2e(基线 77+1skip + 新增 3)、noauth 4。
|
||||
|
||||
## §6 CHANGELOG 口径(控制者写)
|
||||
|
||||
版本 `0.24.0`。Added:skip-link、路由后焦点管理、表格列头语义、评分可访问名、对比度/表格守卫测试、a11y e2e。Changed:success 令牌加深、错误 toast 底色换 accent-ink、toast 播报改为常驻 sr-only 播报区(视觉层与播报层解耦,交互控件移出播报区)。Tests:数字以实跑为准。中英双语,同条目英中相邻行、条目间空行。
|
||||
Reference in New Issue
Block a user