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

@@ -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)