From 5bca11083f053494dff60b97207cb9bbe734b0e9 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Thu, 25 Jun 2026 11:02:47 +0800 Subject: [PATCH] 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 --- TERMINAL_ROADMAP.md | 22 +++++-- app/src/terminal/component.rs | 119 ++++++++++++++++++++++++++++++---- style/tailwind.css | 44 +++++++++++++ 3 files changed, 166 insertions(+), 19 deletions(-) diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index dd45ff1..330e7ad 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -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 / 字形精度 diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index 7805f7f..904654c 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -535,6 +535,12 @@ pub fn TerminalPanel( let webgl_state: Rc>> = Rc::new(std::cell::RefCell::new(None)); let webgl_context_lost: Rc> = 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> = + 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::() else { - return None; - }; + let canvas = canvas_ref.get()?; + let canvas_el = canvas.dyn_ref::()?; attach_webgl_context_lifecycle( canvas_el, @@ -1119,12 +1121,8 @@ pub fn TerminalPanel( Effect::new(move |prev: Option>| { 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::>() + .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::::new(move || { + prm2.set(mq2.matches()); + }); + let _ = mq3.add_event_listener_with_callback( + "change", + cb.as_ref().unchecked_ref(), + ); + cb.forget(); + } + } + // -- View --------------------------------------------------------------- view! { -
+
@@ -1875,6 +1956,9 @@ pub fn TerminalPanel(