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:
@@ -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 / 字形精度
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user