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

273 lines
20 KiB
Markdown
Raw 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. 三个管理视图共 **19 个 `<th>` 零 `scope`**,且两个「操作」列的表头是空 `<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"`;两个空的操作列表头改为 `<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)) | 容器去掉 `role`/`aria-live`,只留定位职责;每条 toast 内文本改为 `<p role="status" aria-live="polite" class="min-w-0 flex-1">{{ t.text }}</p>`,关闭按钮成为该 `<p>` 的**兄弟节点**(不再是实时区后代) | 实时区只包文本 → 读屏播报纯消息,不再夹带控件;按钮仍在 toast 内可正常 Tab 到。放弃「容器保留 role、按钮 aria-hidden」:那样按钮对读屏消失,鼠标用户能关、读屏用户关不掉,更糟。放弃把 toast 整体移出实时区:新消息就完全不被播报了,违背 toast 的存在意义。 |
| 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 实时区
`app/components/ToastHost.vue` 模板:
```html
<template>
<!-- 不 Teleport:容器常驻,屏幕阅读器语义稳定。
容器只负责定位——role/aria-live 落在每条 toast 的文本上,
使可交互的关闭按钮成为实时区的兄弟而非后代(P10 T1(d) 收口)。 -->
<div
data-testid="toast-host"
class="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2"
>
<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]
]"
>
<p role="status" aria-live="polite" class="min-w-0 flex-1">{{ t.text }}</p>
<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>
```
`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/helpers.ts`、`playwright.config.ts`、`vitest.config.ts`、`vite.config*.ts`、`scripts/`、`package.json`。
- `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`:① 容器**不再**有 `role`/`aria-live`(改为断言属性不存在);② 每条 toast 的 `p[role=status][aria-live=polite]` 存在且文本正确;③ 关闭按钮是该 `<p>` 的兄弟(`button.parentElement === p.parentElement`,且 `p.contains(button) === false`);④ 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 实时区重构(交互控件移出)。Tests:数字以实跑为准。中英双语,同条目英中相邻行、条目间空行。