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

322 lines
23 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 第二批设计:可访问与键盘效率(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:数字以实跑为准。中英双语,同条目英中相邻行、条目间空行。