Skip to content

ADR-153: 无障碍(a11y)支持总体方案

  • 状态: ✅ 全部完成(2026-07-21)— 三阶段全部落地:焦点环、aria-live、focus trap/restore、canvas ARIA(P1);prefers-* 媒体查询、Android 返回键、3D 键盘轨道控制、模型 alt(P2);aria-label 补全、aria-keyshortcuts、axe E2E 扫描、ui-keyboard-nav.ts 公共工具、Go 系统主题桥接(P3)。
    • 2026-07-28 全大统一收编menu.ts 已接入 createKeyboardNav。此前判定的「语义不兼容」实为公共工具参数表达力缺口,非语义无法调和。通过给 createKeyboardNav 增补三项能力边界解决:perKeySkip(按键相关的差异化跳过:↑↓ 仅跳 slider/tablist,→←/Enter 还跳原生 button)、getActiveIndex/setActiveIndex(焦点真相源抽象,menu 用 .slide-focused + focusIndex 而非原生 :focus)、arrowRightActivate(→ = 层级激活而非平级移动)。返回手感(→/Enter 激活、← pop)完整保留,ADR-196 tablist/fullscreen-overlay 两处调用向后兼容。新增 ui-keyboard-nav.test.ts(11 例)覆盖默认路径 + roving + 增强路径。ui-advanced-rows.ts 的 mode-slider 仍为 cycle 逻辑,非列表导航,确认不适用。
  • 日期: 2026-07-20
  • 相关: ADR-017(Android 适配,A2-02 返回键待实施)、ADR-036(快捷键注册表)、ADR-059(i18n 框架)、ADR-060(E2E 测试策略)、ADR-140(DragSliderController 统一方向键步进)

背景与问题

用户反馈:"无障碍的支持情况如何,刚修好安卓的四区分段,需要看看其他操作的缺失情况。"

项目无 a11y 架构,所有可访问性代码都随业务零散写入。最近修复的 ADR-140 DragSliderController 把 4 个 builder 的方向键步进统一到一处,是滑块键盘导航层面的收敛,但更广的 a11y 维度(焦点环、屏幕阅读器、focus trap、3D 场景语义、高对比度)存在系统性缺口。

调研覆盖 10 个维度(详见"现状速览"),核心结论:

  • 亮点已有:菜单四方向导航、滑块/开关完整 ARIA、模态对话框 role="dialog"、底部导航按钮 aria-controls/expanded、shortcut-registry Arrow 冲突规避、<html lang> 同步
  • 系统性缺失app.css 主动 outline: none 移除焦点环、无 aria-live / role="status"、对话框无 focus trap/restore、canvas 无 ARIA、无 prefers-contrast/reduced-motion/color-scheme 适配、i18n 无 a11y 命名空间、E2E 无 axe 扫描

本 ADR 给出分批实施路线图,避免一次性大改引入回归风险。

现状速览

已实现亮点(可作扩展基础)

维度实现文件:行号
菜单四方向导航 + Enter + ← 返回menu.ts focusIndex 状态机frontend/src/menus/menu.ts:113-137, 544-594
滑块完整 ARIArole="slider" + valuemin/max/now/labelledbyfrontend/src/core/ui-rows.ts:199-221ui-advanced-rows.ts:62-66, 221-226, 334-337
开关 role=switcharia-checked 双向同步frontend/src/core/ui-rows.ts:64-98
模态对话框role="dialog" + aria-modal="true" + Escapefrontend/src/core/dialog.ts:42, 124-137
底部导航按钮aria-label + aria-controls + aria-expandedfrontend/index.html:73-98events.ts:82-84
DragSliderController 统一方向键ADR-140frontend/src/core/ui-slider-controller.ts:52-60
shortcut-registry Arrow 规避ADR-036frontend/src/core/shortcut-registry.ts:267-269
AR 视频元素隐藏aria-hidden="true"frontend/src/scene/ar/ar-camera.ts:79
<html lang> 同步i18n a11y 基础frontend/src/core/i18n/locale.ts:59-71
快捷键 UI 可视化Ctrl 按下显示徽标frontend/src/core/events.ts:201-211

系统性缺口(均已解决)

✅ 以下所有缺口已在 Phase 1-3 中全部落地。保留此表供历史追溯。

优先级原缺口状态
🔴 P1app.css 全局 outline: none 移除焦点环且无替代:focus-visible 已实现(2px accent 焦点环)
🔴 P1aria-live / role="status" / role="alert"✅ toast 容器 + status-bar 已接入
🔴 P1对话框/全屏覆盖层无 focus trapui-focus-trap.ts + dialog/fullscreen-overlay 已接入
🔴 P1关闭弹窗无 focus restorecreateFocusTrap 返回的 restore 函数已落地
🟠 P2canvas#renderCanvas 无 ARIArole="img" + tabindex="0" + 动态 aria-label 已落地
🟠 P2prefers-contrast / prefers-reduced-motion / prefers-color-scheme✅ 三个媒体查询已落地
🟠 P2Android 返回键与 MenuStack 冲突✅ 逐层关闭 + 双击退出已实现
🟠 P23D 模型无 alt / aria-description✅ canvas aria-label 动态加载模型名
🟡 P3i18n 无 a11y.* 命名空间✅ 按决策原则 7 不引入,复用 common.close/common.delete
🟡 P3E2E 无 axe-core 扫描frontend/e2e/a11y.spec.ts 已落地
🟡 P3快捷键无 aria-keyshortcutsshortcut-registry.ts + events.ts 已落地
🟢 P4三处方向键导航逻辑互相独立✅ 三处全部统一(2026-07-28):ui-fullscreen-overlay.ts + settings-diagnostic.ts(tablist)+ menu.ts 均接入 createKeyboardNavmenu.tsperKeySkip/getActiveIndex/setActiveIndex/arrowRightActivate 四项能力桥接,保留 →激活/←pop 手感。ui-advanced-rows.ts 为 cycle 逻辑,非列表导航,确认不适用
🟢 P4Wails Go 端无系统主题/高对比度桥接internal/app/a11y.go + a11y_windows.go + 前端接入已落地
🟢 P4Space 键激活未明确支持⚠️ 仅依赖原生 button(语义够用),不额外处理

决策

核心原则

  1. 零业务侵入:a11y 增强通过 CSS 全局规则、UI builder 增强、i18n key 扩展三种方式落地,禁止改写业务函数签名
  2. 复用现有 ARIA 模式:滑块/开关的 ARIA 模式已成熟,新控件沿用同套范式
  3. 不引入重型框架:不引入 @axe-core/playwright 之外的 a11y 运行时库(如 aria-live-poller),保持零运行时依赖
  4. 分批推进:按 P1 止血 → P2 关键缺口 → P3 长期建设 三阶段,每阶段独立可交付、可回退
  5. 不降级现有交互:恢复焦点环不破坏现有视觉,仅在 :focus-visible 触发;高对比度模式独立 CSS 块,不污染默认主题
  6. 不重复造 accessible name:WCAG 的 Accessible Name and Description 计算规则会自动取按钮可见文字、aria-labelledbyaria-labeltitle 依次回退。只在「无可见文字控件」(图标按钮、canvas 等)才补 aria-label,文字按钮复用现有 label 即可,避免「同一控件两个 accessible name」语义冲突
  7. i18n 沿用现有 key:不新建 a11y.* 命名空间。toast / 状态栏的播报文本就是现有 i18n 文本,加 role="status" + aria-live 让屏幕阅读器读取即可;关闭/删除等图标按钮的 aria-label 复用现有 common.close / common.delete 类 key,缺失时再就近补

Phase 1:P1 止血(一次性提交)

目标:恢复键盘用户基础感知能力。改动面最小、收益最大。

1.1 恢复焦点视觉指示

app.css 现有 6 处 outline: none 替换为:

css
:focus { outline: none; }
:focus-visible {
    outline: calc(2px * var(--ui-scale)) solid var(--accent);
    outline-offset: calc(1px * var(--ui-scale));
}

outline: none 仅在 :focus(鼠标点击)时生效,:focus-visible(键盘聚焦)显示焦点环。复用现有 --accent--ui-scale CSS 变量,与 .slide-focused 视觉一致。

1.2 toast / 状态栏接入 aria-live

  • toast.ts 容器加 role="status" + aria-live="polite" + aria-atomic="true"
  • status-bar.ts 主文本节点加 role="status" + aria-live="polite"
  • 加载完成、错误提示等急切消息用 role="alert" + aria-live="assertive"(仅在错误 toast 路径)

1.3 对话框 focus trap + restore

dialog.ts 扩展:

typescript
// 打开时记录触发元素
let previousFocus: HTMLElement | null = null;
function openDialog(...) {
    previousFocus = document.activeElement as HTMLElement;
    // ... 现有逻辑
    // trap: Tab 在 dialog 内循环
    container.addEventListener('keydown', trapFocus);
}
function closeDialog() {
    container.removeEventListener('keydown', trapFocus);
    previousFocus?.focus();
    previousFocus = null;
}
function trapFocus(e: KeyboardEvent) {
    if (e.key !== 'Tab') return;
    const focusable = container.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    if (focusable.length === 0) return;
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
    if (e.shiftKey && document.activeElement === first) {
        e.preventDefault(); last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault(); first.focus();
    }
}

ui-fullscreen-overlay.ts 同样接入(复用同套 trapFocus helper)。

抽出 core/ui-focus-trap.ts(≤80 行)避免 dialog/overlay 重复实现。

1.4 canvas 基础 ARIA

index.html:64renderCanvasrole="img" + tabindex="0"aria-label 在场景模块加载/卸载模型时动态更新(拼接模型名,模型名本身已是业务数据,不需要 i18n key 包裹)。初始态沿用现有 menu.canvasLabel 类 key(若不存在,按核心原则 7 就近补一个,不引入 a11y.* 命名空间):

typescript
// 加载模型时
renderCanvas.setAttribute('aria-label', `${t('menu.canvasLabel')}:${model.name}`);
// 卸载时
renderCanvas.setAttribute('aria-label', t('menu.canvasLabel'));

tabindex="0" 允许键盘用户聚焦画布触发 freefly 快捷键。

Phase 1 验收标准

  • [x] Tab 在对话框内循环不跳出
  • [x] 关闭对话框后焦点回到触发按钮
  • [x] toast 出现时 NVDA / Narrator 能朗读
  • [x] 键盘 Tab 到任意按钮可见 2px accent 焦点环
  • [x] canvas 可被 Tab 聚焦,aria-label 描述当前场景
  • [x] tsc --noEmit 0 错误
  • [x] frontend && npm run test 全绿
  • [x] 现有 E2E 不回归

Phase 2:P2 关键缺口

2.1 系统偏好媒体查询

app.css 末尾追加三个媒体块:

css
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        transition-duration: 0.01ms !important;
    }
    /* 关闭 Babylon.js 之外的 CSS 动画(slide-in / fade) */
}

@media (prefers-contrast: more) {
    :root {
        --accent: #ffff00;
        --bg-panel: #000000;
        --text-primary: #ffffff;
    }
    .slide-item { border: 1px solid var(--text-primary); }
}

@media (prefers-color-scheme: dark) {
    /* 仅在用户未自定义主题时生效(通过 :root[data-theme="auto"] 守卫) */
}

prefers-color-scheme 仅做兜底,主题系统(settings-appearance.ts)扩展一个 "auto" 选项,落盘到 settings.theme = 'auto' | 'light' | 'dark' | custom

2.2 Android 返回键拦截

实施 ADR-017 A2-02:在 events.ts 注册 popstate / backbutton 监听,按 MenuStack 深度逐层关闭,最外层才退出 App。Wails Go 端通过 runtime.OnBackPress(Android)转 JS 事件。

2.3 3D 场景键盘相机控制

events.ts 扩展:orbit 模式下启用 WSAD 环绕控制(与 freefly WSAD 统一相机键位):

行为
A/D相机环绕 alpha ±5°
W/S相机仰角 beta ±5°
+ / -缩放 ±10%
Shift+键步长 ×3

2026-07-28 修订(相机键位统一):初版用方向键 + activeElement === canvas 门,但用户难以聚焦 canvas,且与 freefly 的 WSAD 割裂、与播放 seek(←→)潜在抢键 —— 实测表现为「轨道模式下 WSAD 无效果」的困惑。现改为:相机控制统一用 WSAD(orbit=环绕、freefly=平移),方向键从相机彻底让出(菜单开=列表导航、菜单关=播放 seek)。触发门从「canvas 聚焦」改为与 freefly 一致的模式守卫 + 焦点不在菜单/输入框;orbit 相机工厂同步清空 Babylon 内置方向键输入(keysUp/Down/Left/Right = [])避免双路。

2.4 模型 alt text

model-detail.ts 在加载模型时设置 renderCanvas.setAttribute('aria-label', t('a11y.canvasWithModel', { name: model.name }));卸载模型时回退到 t('a11y.canvasEmpty')

Phase 2 验收标准

  • [x] 系统开启"减少动态效果"后 CSS 过渡近乎瞬时
  • [x] 系统开启高对比度后界面文字清晰可读
  • [x] Android 按返回键先关闭面板,不直接退出
  • [x] canvas 聚焦后方向键可旋转相机
  • [x] 加载模型后 aria-label 包含模型名
  • [x] tsc --noEmit 0 错误,E2E 全绿

Phase 3:P3 长期建设

3.1 图标按钮 accessible name 补全(不新建 a11y.* 命名空间)

项目里实际缺 accessible name 的控件仅 3 处纯图标 按钮,复用现有 i18n key 即可,不引入 a11y.* 命名空间:

文件:行号现状复用 key(若已有)或就近补
core/toast.ts:150closeBtn.textContent = '✕' 无 aria-labelcommon.close(若缺则在该 key 同分组补 5 语种)
core/ui-fullscreen-overlay.ts:184closeBtn.textContent = '✕' 无 aria-label同上 common.close
menus/preset-list-viewer.ts:112delBtn.textContent = '✕' 无 aria-labelcommon.delete(若缺则同上补)

禁止给以下控件加 aria-label(已有 accessible name,重复设置会语义冲突):

  • 滑块 bar — 已有 aria-label + aria-labelledbyui-rows.ts:201 / ui-advanced-rows.ts:62,221,334
  • 开关 toggle — 已有 aria-label + aria-labelledbyui-rows.ts:65,67
  • 底部导航按钮 — 已有 aria-labelindex.html:73-98
  • dialog 取消/确认按钮 — 有可见文字填充(dialog.ts:47-48,文字来自现有 i18n)
  • 所有菜单 .slide-item — 有可见 label 文字,屏幕阅读器自动读
  • toast / 状态栏文本本身 — 加 role="status" + aria-live 让屏幕阅读器读现有 i18n 文本即可,不需要「toastError」「toastInfo」这类包裹 key

唯一需要就近补的 i18n key(最多 2 个:common.close / common.delete),按 ADR-059 i18n 框架的 5 语种同步规范补充。

3.2 快捷键 aria-keyshortcuts

shortcut-registry.ts 注册时同步设置元素 aria-keyshortcuts

typescript
button.setAttribute('aria-keyshortcuts', 'Control+1');

5 语种 shortcuts.label.* 已有,复用即可。

3.3 E2E a11y 扫描

frontend/e2e/ 引入 @axe-core/playwright

typescript
import AxeBuilder from '@axe-core/playwright';
// smoke.spec.ts 末尾
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);

首批只扫描 critical / serious 级别,避免历史警告阻塞 CI。

3.4 三处方向键导航抽公共工具

core/ui-keyboard-nav.ts 提供 createArrowNavigator(opts),menu / fullscreen-overlay / mode-slider 三处改为调用同套实现。

3.5 Wails Go 端系统主题桥接

internal/app/ 新增 system_a11y.go(Windows: 监听 WM_SETTINGCHANGE 的高对比度消息;macOS/Linux 后续):通过 runtime.EventsEmit 推送 system:a11y-change 事件,前端订阅后切换 CSS 媒体查询手动模式。

Phase 3 验收标准

  • [x] npm run test:e2e 包含 a11y 扫描,无 critical 违规
  • [x] 所有快捷键按钮有 aria-keyshortcuts
  • [x] 3 处 按钮有 aria-label(复用 common.close / common.delete
  • [x] 三处方向键导航共用同一工具
    • menu.ts 已接入 createKeyboardNav(2026-07-28),通过 perKeySkip + getActiveIndex/setActiveIndex + arrowRightActivate + onArrowBack 桥接,保留 ArrowRight→activate / ArrowLeft→pop 语义
    • ⚠️ ui-advanced-rows.ts 为 cycle 逻辑,非列表导航,不适用
    • ui-fullscreen-overlay.ts + settings-diagnostic.ts 均使用 createKeyboardNav
  • [x] Windows 高对比度切换时应用主题跟随

风险与回退

风险缓解
:focus-visible 在 WebView2 旧版本支持不全WebView2 已基于 Chromium 90+,:focus-visible 自 Chromium 80 起原生支持,无风险
focus trap 误拦截非 Tab 操作仅监听 Tab/Shift+Tab,其他键透传
prefers-reduced-motion 关闭 CSS 动画影响视觉一致性仅影响过渡时长,不影响布局;用户主动开启此选项即预期降级
Android 返回键拦截误吞系统返回仅在 MenuStack 非空时拦截,最外层透传
axe-core 扫描结果包含历史违规阻塞 CIPhase 3.3 首批仅扫描 critical/serious,warning/caution 不阻塞
主题 auto 模式与用户自定义冲突auto 仅在用户未显式选择主题时生效;显式选择后写盘 theme: custom

实施路径

阶段范围完成情况
Phase 1P1 止血:焦点环 + aria-live + focus trap/restore + canvas ARIA✅ 全部完成
Phase 2P2 关键:prefers-* 媒体查询 + Android 返回键 + 3D 键盘控制 + 模型 alt✅ 全部完成
Phase 3P3 长期:图标按钮 aria-label 补全 + aria-keyshortcuts + axe E2E + 方向键工具 + Go 桥接✅ 全部完成(详见验收标准中的 ⚠️ 例外项)

每阶段独立可交付、可回退(git revert 单阶段不影响其他阶段)。

键盘导航范式演进(2026-07-28 系列)

Phase 3.4 完成后,围绕“键盘/Tab 大统一”的实际使用又满了三轮演进,一并记录如下。

1. 菜单导航纳入控件行(滑块/开关/模式切换)

之前方向键仅遍历 .slide-item/.collapsible-header,滑块行/开关行/模式切换行只能 Tab 聚焦。现纳入导航:

  • ↑↓:在所有行(含控件行)间移焦
  • 停在滑块/模式切换行:←→ 调值(让给控件自身,见 ADR-140 行为变更记录二)
  • 停在开关行:Enter/→ 切换
  • 停在普通行:→/Enter 激活、← pop(原手感不变)

2. 契约制取代类名枚举(核心架构改进)

开发中发现“逐个补 selector”会反复遗漏(mode-slider、type-row 先后漏掉),根因是 menu.ts 靠控件类名枚举识别导航项,新控件需回改三处(selector/聚焦目标/调值让位)。因此转为能力契约制ui-nav-item.ts叶子模块):

  • data-nav-item:标记“我是方向键导航项”
  • data-nav-focus:内部聚焦目标 selector(缺省行本身)
  • data-nav-adjust="horizontal":←→ 让给控件自身调值

menu.ts 三个方法全部解耦:panelItems 只查 [data-nav-item]applyFocusnavFocusTargetperKeySkipnavHasHorizontalAdjust。“类名→契约”映射收敛到单一 _ensureNavMarkers;且该方法在 panelItems getter 里调用,保证增量渲染(patchPanel/reRenderCustom 等不走 setupFocus 的路径)新行也被纳入。mode-slider/type-row 遗漏一并修复。

3. orbit 相机 WSAD 丝滑化

Phase 2.3 初版的 orbit WSAD 是 keydown 离散步进(每次跳 5°,依赖 OS 按键重复,卡顿)。改为复用 freefly 的“状态标记 + 渲染循环积分”:orbit-state.ts 叶子模块存标记,events.ts keydown/keyup 只置位,camera-behaviors.ts 的 initOrbitUpdate 每帧按 getAnimationRatio() 帧率归一地积分 alpha/beta/radius。

测试覆盖

ui-nav-item.test.ts(5)、ui-keyboard-nav.test.ts(11)、menu.test.ts(含 panelItems 纳入滑块/开关/mode-slider + 增量 patch 回归防护)、slider-controller.test.ts(17)均全绿。

修订记录

日期修订
2026-07-20初版,三阶段路线图
2026-07-20修订:核心原则补「不重复造 accessible name」「i18n 沿用现有 key」两条;Phase 3.1 从「新建 a11y.* 命名空间 + 10 个 key」精简为「3 处 按钮复用 common.close/common.delete」;Phase 1.4 canvas aria-label 改为拼接现有 key + 模型名,不硬编码描述文字
2026-07-28全大统一收编:menu.ts 接入 createKeyboardNav。增强公共工具三项能力边界(perKeySkip / getActiveIndex+setActiveIndex / arrowRightActivate),Phase 3.4「语义不兼容」例外解除;新增 ui-keyboard-nav.test.ts 11 例。三处方向键导航真正统一
2026-07-28相机键位统一(Phase 2.3 修订):orbit 键盘控制从方向键改为 WSAD(与 freefly 统一),移除难触发的 canvas 聚焦门;方向键从相机控制彻底让出(UI 导航/播放 seek);orbit 相机工厂清空内置方向键输入。解决「轨道模式 WSAD 无效」的体验割裂
2026-07-28orbit WSAD 丝滑化:由 keydown 离散步进改为“状态标记 + 渲染循环积分”(复用 freefly 模式),新增 orbit-state.ts 叶子模块 + camera-behaviors.ts initOrbitUpdate(见键盘导航范式演进 §3)
2026-07-28菜单导航纳入滑块/开关行:↑↓ 遍历全行、滑块 ←→ 调值(ADR-140 同步 ↑↓ 让位)、开关 Enter/→ 切换(见范式演进 §1)
2026-07-28导航项改为 data-nav-item 契约制(ui-nav-item.ts),取代类名枚举;menu.ts 三方法解耦;_ensureNavMarkers 在 panelItems getter 内兵底增量渲染;修复 mode-slider/type-row 遗漏(见范式演进 §2)