docs: P9-B spec D-I re-pinned — persistent sr-only announcer (live region must pre-exist its text; per-toast role=status would mute the first message)

This commit is contained in:
2026-10-01 21:43:31 +08:00
parent 0a7eba2682
commit 35b50539c6
@@ -41,7 +41,7 @@
| 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-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 三处需维护的状态机,纯负收益。 |
@@ -165,19 +165,48 @@ export function attachTitleHook(r: Router): void {
`data-testid`、`disabled`、类名、点击语义(点当前分撤评)全部不动;既有测试对 `.text()` 的断言不受影响(`aria-label` 不改文本内容)。
### 3.5 D-I:toast 实时区
### 3.5 D-I:toast 实时区(D-I′ re-pin)
`app/components/ToastHost.vue` 模板:
**为什么 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:容器常驻,屏幕阅读器语义稳定。
容器只负责定位——role/aria-live 落在每条 toast 的文本上,
使可交互的关闭按钮成为实时区的兄弟而非后代(P10 T1(d) 收口)。 -->
<!-- 不 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"
@@ -188,7 +217,7 @@ export function attachTitleHook(r: Router): void {
KIND_CLASS[t.kind]
]"
>
<p role="status" aria-live="polite" class="min-w-0 flex-1">{{ t.text }}</p>
<span class="min-w-0 flex-1">{{ t.text }}</span>
<button
type="button"
aria-label="关闭提示"
@@ -203,6 +232,25 @@ export function attachTitleHook(r: Router): void {
</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
@@ -242,7 +290,8 @@ test('评分按钮的可访问名是「评 N 星」而非字形', async ({ page
## §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`。
- 禁触:`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 两处配色外,不动任何既有颜色与排版。
@@ -256,7 +305,7 @@ 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/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()` 为 ★/☆ 的断言全部保留且仍绿。
@@ -269,4 +318,4 @@ e2e:
## §6 CHANGELOG 口径(控制者写)
版本 `0.24.0`。Added:skip-link、路由后焦点管理、表格列头语义、评分可访问名、对比度/表格守卫测试、a11y e2e。Changed:success 令牌加深、错误 toast 底色换 accent-ink、toast 实时区重构(交互控件移出)。Tests:数字以实跑为准。中英双语,同条目英中相邻行、条目间空行。
版本 `0.24.0`。Added:skip-link、路由后焦点管理、表格列头语义、评分可访问名、对比度/表格守卫测试、a11y e2e。Changed:success 令牌加深、错误 toast 底色换 accent-ink、toast 播报改为常驻 sr-only 播报区(视觉层与播报层解耦,交互控件移出播报区)。Tests:数字以实跑为准。中英双语,同条目英中相邻行、条目间空行。