` | `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` 播报区 ``,其文字在挂载后由 watch 驱动变更 | 播报区必须**先存在**再变更文字,读屏才会播报。初稿方案(把 `role=status` 挂在每条 toast 的文本 `
` 上)被 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 = {
success: 'bg-success text-paper',
error: 'bg-accent-ink text-paper',
info: 'bg-highlight text-ink'
}
```
新增 `app/lib/contrast.test.ts`:从 `main.css` 正则提取 `--color-: #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` 模板(`` 之前插 skip-link,`` 加 `id` 与 `tabindex`):
```html
```
`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:表格列头
三个文件的每个 `| ` 加 `scope="col"`,类名与文本一字不动。两处空表头(`AdminView.vue:152` 队列表的 ` | | `、`AdminUsersView.vue:123` 的 ` | `)改为:
```html
操作 |
```
新增 `app/lib/tableScope.test.ts`:递归扫 `app/**/*.vue`(排除 `*.test.ts`),对每行匹配 `` 为止再判定(AdminUsersView 的空表头是单行,但守卫要对将来健壮)。违规时报告 `相对路径:行号: 行内容`,断言 `toEqual([])`——与 `no-gradient.test.ts` 同风格。
### 3.4 D-H:五星评分
`app/components/GameReactions.vue` 的星标分组:
```html
```
`data-testid`、`disabled`、类名、点击语义(点当前分撤评)全部不动;既有测试对 `.text()` 的断言不受影响(`aria-label` 不改文本内容)。
### 3.5 D-I:toast 实时区(D-I′ re-pin)
**为什么 re-pin**:初稿把 `role="status"` 挂在每条 toast 的文本 ` ` 上。播报区与文字**同时被创建**——屏幕阅读器只可靠播报「已存在于页面上的播报区」内的变更,新出现的播报区在多个读屏+浏览器组合下不播报首条,结果是把「按钮噪音」换成了「彻底静默」。修正为视觉层与播报层解耦:常驻一个 `sr-only` 播报区,文字在挂载**之后**由 watch 驱动变更。
`app/components/ToastHost.vue` 全文(`
```
要点:
- 播报节点 `data-testid="toast-announce"` 在容器**内首子位置**、`TransitionGroup` 之外,故它不参与进出场动画,也不被 `v-for` 重建。
- watch 只观察 `toasts.value.length`:新消息推入时长度必增(上限 3 条时 `push` 会同时移最旧再 append,长度仍是 3→3,但此时 `toasts.value[n-1]` 已换成新条——见下方陷阱)。
- 可见 toast 的文本节点用 ``(不是 ``):它在 ` ` 内,用块级 ` ` 也可以,但 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"` 与可见文本内容必须原样保留(文本搬进 `` 后 `toContainText` 仍成立,子孙文本匹配)。
- `runtime/` 本批**完全不动**(含 GameHost)——播放区可访问性(iframe title、全屏按钮语义)已在既有代码达标。
- 不新增依赖、不改 `version`(恒 0.1.0)、不动 CHANGELOG(控制者统一写)。
- 不改任何视觉设计:平面海报新粗野主义(无渐变/无圆角/硬阴影)必须保持,`no-gradient.test.ts` 守卫须继续绿。除 D-A/D-B 两处配色外,不动任何既有颜色与排版。
- 不做移动端汉堡菜单(D-K,实测无溢出)。
- 不做 ` ` → ` | ` 的行头重构(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 === []`(即全仓每个 ` | `,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:数字以实跑为准。中英双语,同条目英中相邻行、条目间空行。
| |