23 KiB
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亦相等。故本批不引入汉堡菜单——没有需要修的问题,加菜单只会增加键盘陷阱面。
实测出的真实缺口(本批范围):
--color-success: #2fa46a对 paper 仅 2.83,对 surface 3.16 —— 作为成功 toast 的底色配text-paper(12px 粗体,非大字号)实测 3.16,AA 正文需 4.5,FAIL。- 错误 toast 用
bg-accent(#e8552f)配text-paper实测 3.64,FAIL(AA 正文需 4.5)。 - 三个管理视图共 19 个
<th>零scope,且两个「操作」列的表头是空<th class="px-3 py-2" />(无可访问名);全仓scope="row"出现 0 次。屏幕阅读器读表格时无法把单元格与列头关联。 - 五星评分按钮的可访问名就是字形本身「★」/「☆」——读屏逐个念「星 星 星」,无法知道这是几分、当前评了几分、点了会怎样。
- 全站无 skip-link:键盘用户每个页面都要 Tab 过页头(logo + 3 条导航 + 贴纸 + 登录/用户菜单 4 项)才能到正文。
- SPA 导航后无焦点管理:
router的afterEach只设 title/description,焦点留在旧位置(通常是 body),读屏用户点链接后听不到任何新页面上下文。 - (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} 星`",内层字形 |
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 块内,唯一改动:
--color-success: #1f7a4d;
app/components/ToastHost.vue 的 KIND_CLASS,唯一改动(error 行):
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 非文本)
亮度公式(逐字,别自创):
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):
<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 签名内的回调:
// 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" />)改为:
<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 的星标分组:
<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> + 模板):
<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 而非长度:
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 一字不动:
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:数字以实跑为准。中英双语,同条目英中相邻行、条目间空行。