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。
|
- ✅ OSC 8 hyperlink 解析:支持 BEL / ST 终止,使用 cell-grid sidecar 记录链接,覆盖写入、常见 CSI 光标移动、清行/清屏、滚屏进 history 后仍能正确映射到链接 hitbox overlay。
|
||||||
- 后续可继续扩展非常规控制序列覆盖面,但 P6 addon parity 的核心能力已完成。
|
- 后续可继续扩展非常规控制序列覆盖面,但 P6 addon parity 的核心能力已完成。
|
||||||
|
|
||||||
### P7 — Accessibility / 可访问性
|
### P7 — Accessibility / 可访问性 ✅ 已完成
|
||||||
|
|
||||||
**计划实现**:
|
**已实现**:
|
||||||
1. screen reader buffer / aria-live 策略。
|
1. **Screen reader buffer / aria-live** ✅
|
||||||
2. keyboard-only selection / copy。
|
- 隐藏 `div[aria-live="polite"][role="status"]` 实时推送可见终端文本。
|
||||||
3. high contrast / minimum contrast ratio。
|
- 提取可见行文本(去尾空、过滤空行),每 500ms 防抖更新,避免大量输出时淹没屏幕阅读器。
|
||||||
4. reduced motion 与焦点可见性。
|
- `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 / 字形精度
|
### P8 — Unicode / 字形精度
|
||||||
|
|
||||||
|
|||||||
@@ -535,6 +535,12 @@ pub fn TerminalPanel(
|
|||||||
let webgl_state: Rc<std::cell::RefCell<Option<WebGlRendererState>>> =
|
let webgl_state: Rc<std::cell::RefCell<Option<WebGlRendererState>>> =
|
||||||
Rc::new(std::cell::RefCell::new(None));
|
Rc::new(std::cell::RefCell::new(None));
|
||||||
let webgl_context_lost: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
|
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 || {
|
handle.serialize_text_fn.set_value(Some(Arc::new(move || {
|
||||||
core_signal
|
core_signal
|
||||||
@@ -643,12 +649,8 @@ pub fn TerminalPanel(
|
|||||||
lifecycle_webgl_state.borrow_mut().take();
|
lifecycle_webgl_state.borrow_mut().take();
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let Some(canvas) = canvas_ref.get() else {
|
let canvas = canvas_ref.get()?;
|
||||||
return None;
|
let canvas_el = canvas.dyn_ref::<HtmlCanvasElement>()?;
|
||||||
};
|
|
||||||
let Some(canvas_el) = canvas.dyn_ref::<HtmlCanvasElement>() else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
attach_webgl_context_lifecycle(
|
attach_webgl_context_lifecycle(
|
||||||
canvas_el,
|
canvas_el,
|
||||||
@@ -1119,12 +1121,8 @@ pub fn TerminalPanel(
|
|||||||
Effect::new(move |prev: Option<Option<ResizeObserverHandle>>| {
|
Effect::new(move |prev: Option<Option<ResizeObserverHandle>>| {
|
||||||
drop(prev);
|
drop(prev);
|
||||||
|
|
||||||
let Some(element) = terminal_ref.get() else {
|
let element = terminal_ref.get()?;
|
||||||
return None;
|
let socket = ws_signal.get()?;
|
||||||
};
|
|
||||||
let Some(socket) = ws_signal.get() else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
|
|
||||||
let resize_socket = socket.clone();
|
let resize_socket = socket.clone();
|
||||||
let resize_viewport = current_viewport;
|
let resize_viewport = current_viewport;
|
||||||
@@ -1831,10 +1829,93 @@ pub fn TerminalPanel(
|
|||||||
focus_ime_textarea(&ime_ref);
|
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 ---------------------------------------------------------------
|
||||||
|
|
||||||
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>
|
<Show when=move || options_signal.get().show_header>
|
||||||
<div class="terminal-header">
|
<div class="terminal-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -1875,6 +1956,9 @@ pub fn TerminalPanel(
|
|||||||
<div
|
<div
|
||||||
node_ref=terminal_ref
|
node_ref=terminal_ref
|
||||||
class="terminal-screen"
|
class="terminal-screen"
|
||||||
|
role="application"
|
||||||
|
aria-label="Terminal"
|
||||||
|
aria-roledescription="terminal emulator"
|
||||||
on:click=handle_click
|
on:click=handle_click
|
||||||
on:scroll=handle_scroll
|
on:scroll=handle_scroll
|
||||||
on:wheel=handle_wheel
|
on:wheel=handle_wheel
|
||||||
@@ -1899,6 +1983,15 @@ pub fn TerminalPanel(
|
|||||||
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
||||||
{MEASURE_SAMPLE_TEXT}
|
{MEASURE_SAMPLE_TEXT}
|
||||||
</span>
|
</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">
|
<div class="terminal-grid">
|
||||||
<Show
|
<Show
|
||||||
when=move || renderer_uses_canvas_surface(options_signal.get().renderer)
|
when=move || renderer_uses_canvas_surface(options_signal.get().renderer)
|
||||||
|
|||||||
@@ -804,6 +804,20 @@
|
|||||||
background: rgb(125 211 252 / 14%);
|
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 {
|
.terminal-measure {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -9999px;
|
left: -9999px;
|
||||||
@@ -844,6 +858,36 @@
|
|||||||
color: var(--cowa-text-primary);
|
color: var(--cowa-text-primary);
|
||||||
line-height: 24px;
|
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 {
|
.cowa-page-heading p {
|
||||||
@apply m-0 pt-1 text-sm;
|
@apply m-0 pt-1 text-sm;
|
||||||
|
|||||||
Reference in New Issue
Block a user