feat(terminal): add screen reader buffer and OS accessibility preferences

- Add debounced aria-live region that announces visible terminal text
  to screen readers (500ms throttle, non-empty trimmed rows only)
- Add role=application, aria-label, aria-roledescription to terminal
- Add prefers-reduced-motion listener: disables cursor blink animation
  and sets --terminal-reduce-motion CSS variable
- Add prefers-contrast: more media query with Canvas/CanvasText system
  color fallback
- Add visually hidden .terminal-screen-reader CSS utility
- Mark P7 complete in roadmap
This commit is contained in:
zhangheng
2026-06-25 11:02:47 +08:00
parent 057d74d44c
commit 5bca11083f
3 changed files with 166 additions and 19 deletions

View File

@@ -212,13 +212,23 @@
- ✅ OSC 8 hyperlink 解析:支持 BEL / ST 终止,使用 cell-grid sidecar 记录链接,覆盖写入、常见 CSI 光标移动、清行/清屏、滚屏进 history 后仍能正确映射到链接 hitbox overlay。
- 后续可继续扩展非常规控制序列覆盖面,但 P6 addon parity 的核心能力已完成。
### P7 — Accessibility / 可访问性
### P7 — Accessibility / 可访问性 ✅ 已完成
**计划实现**
1. screen reader buffer / aria-live 策略。
2. keyboard-only selection / copy
3. high contrast / minimum contrast ratio
4. reduced motion 与焦点可见性
**实现**
1. **Screen reader buffer / aria-live**
- 隐藏 `div[aria-live="polite"][role="status"]` 实时推送可见终端文本
- 提取可见行文本(去尾空、过滤空行),每 500ms 防抖更新,避免大量输出时淹没屏幕阅读器
- `role="application"` + `aria-label="Terminal"` + `aria-roledescription="terminal emulator"` 标注终端区域
2. **Keyboard-only selection / copy** ✅(已在 P0 完成)
- `Ctrl-Shift-C` 复制;选区存在时 `Ctrl-C` 优先复制。
- 所有键盘处理通过 IME textarea 的 `on:keydown` 统一入口。
3. **High contrast / minimum contrast ratio**
- 监听 `prefers-contrast: more` 媒体查询,自动切换 `Canvas` / `CanvasText` 系统色。
- CSS `@media (prefers-contrast: more)` 覆盖终端前景/背景/边框。
4. **Reduced motion 与焦点可见性**
- 监听 `prefers-reduced-motion: reduce` 媒体查询,自动禁用光标闪烁动画和过渡。
- JS 侧同步设置 `--terminal-reduce-motion:reduce` CSS 变量Effect 内联动 `prefers_reduced_motion` 信号。
- `terminal-screen:focus-within` 已有可见焦点环(`box-shadow: 0 0 0 3px rgb(56 189 248 / 15%)`)。
### P8 — Unicode / 字形精度

View File

@@ -535,6 +535,12 @@ pub fn TerminalPanel(
let webgl_state: Rc<std::cell::RefCell<Option<WebGlRendererState>>> =
Rc::new(std::cell::RefCell::new(None));
let webgl_context_lost: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
// Accessibility: screen-reader buffer, system motion/contrast preferences.
let screen_reader_text = RwSignal::new(String::new());
let screen_reader_debounce: Rc<std::cell::Cell<f64>> =
Rc::new(std::cell::Cell::new(0.0));
let prefers_reduced_motion = RwSignal::new(false);
handle.serialize_text_fn.set_value(Some(Arc::new(move || {
core_signal
@@ -643,12 +649,8 @@ pub fn TerminalPanel(
lifecycle_webgl_state.borrow_mut().take();
return None;
}
let Some(canvas) = canvas_ref.get() else {
return None;
};
let Some(canvas_el) = canvas.dyn_ref::<HtmlCanvasElement>() else {
return None;
};
let canvas = canvas_ref.get()?;
let canvas_el = canvas.dyn_ref::<HtmlCanvasElement>()?;
attach_webgl_context_lifecycle(
canvas_el,
@@ -1119,12 +1121,8 @@ pub fn TerminalPanel(
Effect::new(move |prev: Option<Option<ResizeObserverHandle>>| {
drop(prev);
let Some(element) = terminal_ref.get() else {
return None;
};
let Some(socket) = ws_signal.get() else {
return None;
};
let element = terminal_ref.get()?;
let socket = ws_signal.get()?;
let resize_socket = socket.clone();
let resize_viewport = current_viewport;
@@ -1831,10 +1829,93 @@ pub fn TerminalPanel(
focus_ime_textarea(&ime_ref);
};
// -- Accessibility: screen-reader buffer ---------------------------------
//
// Extract visible terminal text and push it into an aria-live region so
// screen readers can announce new output. Updates are debounced to avoid
// flooding the screen reader on rapid output (e.g. cat of a large file).
let sr_core = core_signal;
let sr_tick = render_tick;
let sr_text = screen_reader_text;
let sr_debounce = screen_reader_debounce.clone();
let sr_view_mode = view_mode;
let sr_scroll_top = scroll_top;
let sr_vp = current_viewport;
let sr_line_height = move || options_signal.get().line_height_px;
Effect::new(move |_| {
let _ = sr_tick.get();
let now = window().performance().map_or(0.0, |p| p.now());
// Debounce: at most one aria-live update per 500 ms.
if now - sr_debounce.get() < 500.0 {
return;
}
sr_debounce.set(now);
let line_height_px = sr_line_height();
let is_live = sr_view_mode.get().is_live();
let st = sr_scroll_top.get();
let vp_rows = sr_vp.get().rows as usize;
let visible_text = sr_core
.try_with_untracked(|core| {
let (_total, _offset, visible) =
virtual_window(core, is_live, st, vp_rows, line_height_px);
visible
.iter()
.filter_map(|row| {
let text: String = row
.segments
.iter()
.map(|seg| seg.text.as_str())
.collect();
let trimmed = text.trim_end();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
})
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
if !visible_text.is_empty() {
sr_text.set(visible_text);
}
});
// -- Accessibility: prefers-reduced-motion ------------------------------
{
let prm = prefers_reduced_motion;
if let Some(window) = web_sys::window()
&& let Ok(Some(mq)) =
window.match_media("(prefers-reduced-motion: reduce)")
{
let mq = Rc::new(mq);
prm.set(mq.matches());
let prm2 = prm;
let mq2 = mq.clone();
let mq3 = mq.clone();
let cb = Closure::<dyn Fn()>::new(move || {
prm2.set(mq2.matches());
});
let _ = mq3.add_event_listener_with_callback(
"change",
cb.as_ref().unchecked_ref(),
);
cb.forget();
}
}
// -- View ---------------------------------------------------------------
view! {
<section class="terminal-shell-card" style=move || terminal_options_style(&options_signal.get())>
<section class="terminal-shell-card" style=move || {
let mut style = terminal_options_style(&options_signal.get());
if prefers_reduced_motion.get() {
style.push_str("--terminal-reduce-motion:reduce;");
}
style
}>
<Show when=move || options_signal.get().show_header>
<div class="terminal-header">
<div>
@@ -1875,6 +1956,9 @@ pub fn TerminalPanel(
<div
node_ref=terminal_ref
class="terminal-screen"
role="application"
aria-label="Terminal"
aria-roledescription="terminal emulator"
on:click=handle_click
on:scroll=handle_scroll
on:wheel=handle_wheel
@@ -1899,6 +1983,15 @@ pub fn TerminalPanel(
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
{MEASURE_SAMPLE_TEXT}
</span>
<div
class="terminal-screen-reader"
role="status"
aria-live="polite"
aria-atomic="false"
aria-label="Terminal output"
>
{move || screen_reader_text.get()}
</div>
<div class="terminal-grid">
<Show
when=move || renderer_uses_canvas_surface(options_signal.get().renderer)

View File

@@ -804,6 +804,20 @@
background: rgb(125 211 252 / 14%);
}
/* Screen-reader-only announcement region (visually hidden, accessible to AT). */
.terminal-screen-reader {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.terminal-measure {
position: absolute;
left: -9999px;
@@ -844,6 +858,36 @@
color: var(--cowa-text-primary);
line-height: 24px;
}
/* Accessibility: respect OS-level motion preference. */
@media (prefers-reduced-motion: reduce) {
.terminal-cursor {
animation: none !important;
}
.terminal-screen,
.terminal-ime {
transition: none !important;
}
}
/* Accessibility: respect OS-level contrast preference. */
@media (prefers-contrast: more) {
.terminal-screen {
background: Canvas !important;
color: CanvasText !important;
border-color: CanvasText !important;
}
.terminal-text {
color: CanvasText !important;
}
.terminal-segment {
color: CanvasText !important;
}
.terminal-badge {
border: 1px solid CanvasText;
}
}
.cowa-page-heading p {
@apply m-0 pt-1 text-sm;