diff --git a/apps/lumbridge/src/main.rs b/apps/lumbridge/src/main.rs index 7bcce3f..75d7505 100644 --- a/apps/lumbridge/src/main.rs +++ b/apps/lumbridge/src/main.rs @@ -1,3 +1,4 @@ +mod a11y; mod attention; mod geometry; mod input; @@ -33,6 +34,7 @@ use lumbridge_ui_fixture::{ SurfaceKind, }; +use a11y::{A11y as _, PaneSemantics, Role}; use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource}; use input::{terminal_key_from_parts, terminal_scroll_from_parts}; use surface::{LiveRuntimeStatus, PanelView, SurfaceTab}; @@ -110,6 +112,12 @@ impl CellMetrics { } } } +/// The add-panel chooser's heading, and the name it announces. +/// +/// One constant rather than two literals: an overlay whose accessible name and +/// visible heading disagree is a dialog that is called one thing and looks like +/// another. +const ADD_PANEL_TITLE: &str = "Add workspace panel"; /// How close to the seam a press has to land to start a resize. const SIDEBAR_GRAB_RADIUS: f32 = 4.0; const TERMINAL_ROW_STEP: u16 = 2; @@ -1526,6 +1534,14 @@ impl LumbridgeShell { }) .collect::>(); div() + .id(("terminal-surface", pane.get())) + // The terminal's *contents* are deliberately not exposed as + // elements. A screen reader reaches terminal text through the + // platform's terminal support; publishing 80×24 nodes a frame would + // be a tree nobody can navigate and a cost on every repaint. + .a11y_id(format!("lumbridge.pane.{}.terminal", pane.get())) + .a11y_role(Role::Terminal) + .a11y_label("Terminal output") .flex() .flex_col() .size_full() @@ -1564,24 +1580,22 @@ impl LumbridgeShell { .into_any_element() } - #[allow( - clippy::too_many_lines, - reason = "a single declarative element tree, not a sequence of steps" - )] - fn pane_context( - &self, - pane: &PanelView, - selected: bool, - cx: &mut Context, - ) -> gpui::AnyElement { - let theme = self.theme.colors; - let pane_id = pane.id; - let can_detach = self.panels.attached_count() > 1; + /// The two lines a pane header prints about itself: where it runs, and what + /// its process is doing. + /// + /// Lifted out of `pane_context` because the accessibility tree needs the + /// same two strings and is built one level up, in `workspace_panel`. + /// Recomputing them there would have meant two expressions that agree today + /// — a pane's declared target is "local shell" while its attached PTY + /// reports "local · runtime session 7 · pid 4321" — and disagree the first + /// time either is edited, which is a screen reader announcing a machine the + /// screen is not showing. + fn pane_header_lines(&self, pane: &PanelView) -> (String, String) { let external = pane.output_source == OutputSource::External; // A pane can be marked external and still have no runtime: a failed // spawn, a terminated session, a snapshot restored past its process. let live_terminal = external - .then(|| self.live_terminals.get(&pane_id)) + .then(|| self.live_terminals.get(&pane.id)) .flatten(); let status = live_terminal.map_or(pane.badge.as_str(), |terminal| terminal.status.badge()); let detail = @@ -1608,6 +1622,23 @@ impl LumbridgeShell { } }, ); + (detail, surface_status) + } + + #[allow( + clippy::too_many_lines, + reason = "a single declarative element tree, not a sequence of steps" + )] + fn pane_context( + &self, + pane: &PanelView, + selected: bool, + cx: &mut Context, + ) -> gpui::AnyElement { + let theme = self.theme.colors; + let pane_id = pane.id; + let can_detach = self.panels.attached_count() > 1; + let (detail, surface_status) = self.pane_header_lines(pane); let native = SurfaceTab::native_for(pane.kind); let shown = self.surface_for(pane.id, pane.kind); let tabs = SurfaceTab::ALL @@ -1755,13 +1786,9 @@ impl LumbridgeShell { .text_xs() .text_color(theme.muted) .child("TOOLS · CONTEXT · GOAL") - .child(if pane.needs_input() { - "NEEDS INPUT" - } else if selected { - "KEYBOARD OWNER" - } else { - "RUNNING" - }), + // The same three words the pane's accessibility description + // leads with, from the same function, so they cannot drift. + .child(a11y::pane_standing(pane.needs_input(), selected)), ) .into_any_element() } @@ -2080,8 +2107,19 @@ impl LumbridgeShell { ) -> gpui::AnyElement { let theme = self.theme.colors; let pane_id = pane.id; + // The four facts UX_VERTICAL_SLICE.md's hard gate names — which pane, + // whether it is selected, where it runs, whether it is waiting — hang + // here, on the element that is the whole pane, rather than on the three + // regions inside it. + let (detail, status) = self.pane_header_lines(pane); + let semantics = PaneSemantics::derive(pane, selected, &detail, &status); div() .id(("workspace-panel", pane_id.get())) + .a11y_id(semantics.accessibility_id) + .a11y_role(Role::Pane) + .a11y_label(semantics.label) + .a11y_description(semantics.description) + .a11y_selected(semantics.selected) .cursor_pointer() .on_click(cx.listener(move |shell, _, window, cx| { shell.select_pane(pane_id, window, cx); @@ -2135,6 +2173,10 @@ impl LumbridgeShell { }; div() + .id("command-palette") + .a11y_id("lumbridge.command-palette") + .a11y_role(Role::Dialog) + .a11y_label("Commands") .absolute() .inset_0() .flex() @@ -2311,6 +2353,12 @@ impl LumbridgeShell { .collect::>(); div() + .id("add-panel-chooser") + .a11y_id("lumbridge.add-panel-chooser") + .a11y_role(Role::Dialog) + // The heading the dialog prints, from the constant the heading is + // printed from, so the name announced is the name shown. + .a11y_label(ADD_PANEL_TITLE) .absolute() .inset_0() .flex() @@ -2342,7 +2390,7 @@ impl LumbridgeShell { div() .text_lg() .text_color(theme.text) - .child("Add workspace panel"), + .child(ADD_PANEL_TITLE), ) .child( div() @@ -2568,6 +2616,9 @@ impl LumbridgeShell { } = parts; div() .id("lumbridge-shell") + .a11y_id("lumbridge.application") + .a11y_role(Role::Application) + .a11y_label("Lumbridge") .relative() .track_focus(&self.root_focus) .key_context("LumbridgeShell") @@ -2662,6 +2713,10 @@ impl LumbridgeShell { .child( div().flex().flex_1().min_h_0().children(sidebar).child( div() + .id("workspace") + .a11y_id("lumbridge.workspace") + .a11y_role(Role::Region) + .a11y_label("Workspace") .flex() .flex_col() .flex_1() @@ -2836,6 +2891,10 @@ impl LumbridgeShell { let focused = self.sidebar_has_focus; let cursor = self.sidebar.cursor.clone(); div() + .id("sidebar") + .a11y_id("lumbridge.sidebar") + .a11y_role(Role::Region) + .a11y_label("Sidebar") .relative() .flex() .flex_col() @@ -2846,14 +2905,30 @@ impl LumbridgeShell { .border_color(theme.border_quiet) .child( div() + .id("sidebar-rows") + .a11y_id("lumbridge.sidebar.rows") + // One flat list, which is what it is on screen: the sections + // are headers within one row sequence, not nested subtrees, + // so a tree with groups would describe a structure the + // keyboard cursor does not move through. + .a11y_role(Role::List) + .a11y_label("Sidebar rows") .flex_1() .min_h_0() .overflow_hidden() .children(rows.into_iter().map(|row| { let cursored = cursor.as_ref() == Some(&row.key); + // The sentence `sidebar::model::describe` produces, via + // the adapter. Decision 0017 wrote that function and + // called it from nowhere; this is the call. + let semantics = a11y::sidebar_row_semantics(&row); let key = row.key.clone(); div() .id(sidebar::view::element_id(&key)) + .a11y_id(semantics.accessibility_id) + .a11y_role(Role::ListItem) + .a11y_label(semantics.label) + .a11y_selected(semantics.selected) .cursor_pointer() .hover(|view| view.bg(theme.surface_raised)) .on_click(cx.listener(move |shell, _, window, cx| { @@ -2979,6 +3054,13 @@ impl LumbridgeShell { let segments = self.usage.strip(); let orphan = active.is_none(); div() + .id("footer-usage") + .a11y_id("lumbridge.footer.usage") + // A status, not a region: the numbers in this strip change under a + // reader that is not looking at them, which is the distinction the + // role exists to make. + .a11y_role(Role::Status) + .a11y_label("Harness usage") .flex() .items_center() .gap_4() diff --git a/apps/lumbridge/src/sidebar/model.rs b/apps/lumbridge/src/sidebar/model.rs index 98cee24..0689594 100644 --- a/apps/lumbridge/src/sidebar/model.rs +++ b/apps/lumbridge/src/sidebar/model.rs @@ -474,20 +474,17 @@ pub(crate) fn move_cursor( /// The screen-reader sentence for a row. /// -/// Not attached to anything yet: published `gpui 0.2.2` has no AccessKit, so -/// there is no accessibility node to hang it on. Decision 0017 stages that -/// behind an adapter, and this is the text the adapter will announce. It is -/// written and tested here so the words a sighted user reads and the words an -/// assistive technology speaks come from one place rather than drifting apart -/// once there are two. -#[allow( - dead_code, - reason = "wired up with the accessibility adapter, decision 0017" -)] +/// Written here rather than in the view so it is testable without a window, and +/// so the words a sighted user reads and the words an assistive technology +/// announces come from one place and cannot drift apart. /// -/// Written here rather than in the view so it is testable, and so the words a -/// sighted user reads and the words an assistive technology announces come from -/// one place and cannot drift apart. +/// Decision 0017 wrote this and then called it from nowhere, behind an +/// `#[allow(dead_code)]` promising an accessibility adapter that was never +/// written. `crate::a11y::sidebar_row_semantics` is that caller, and it is the +/// function `render_sidebar` asks for every row it builds — so the allow is +/// deleted rather than carried, as decision 0023 requires. The sentence still +/// reaches no assistive technology: the adapter's bodies are no-ops until the +/// GPUI dependency moves. pub(crate) fn describe(row: &SidebarRow) -> String { let mut text = String::new(); match &row.body { diff --git a/docs/UI_SPIKE_SCORECARD.md b/docs/UI_SPIKE_SCORECARD.md index 3fc9ba4..4bddff9 100644 --- a/docs/UI_SPIKE_SCORECARD.md +++ b/docs/UI_SPIKE_SCORECARD.md @@ -10,7 +10,7 @@ from macOS and Linux and the hard gates pass. | Builds on Ubuntu | yes | conditional | pass | | Builds on Omarchy/Arch | yes | pending | pending | | Dependency/license closure permits Apache-2.0 distribution | yes | pending | pending | -| Keyboard navigation + AccessKit tree | yes | keyboard pass; current-GPUI semantics compile; platform AT pending | fail: no AccessKit integration at pinned revision | +| Keyboard navigation + AccessKit tree | yes | fail: keyboard passes and the call sites are wired, but the adapter no-ops on 0.2.2, so no accessibility tree is produced | fail: no AccessKit integration at pinned revision | | IME and composed Unicode input | yes | framework API exists; end-to-end pending | editor API exists; end-to-end pending | | Isolated system browser child | yes | pending | pending | | Cold startup, p50/p95 | record | pending | pending | @@ -66,6 +66,28 @@ accent, and a multi-codepoint emoji without splitting UTF-8. Three deterministic tests pass. AT-SPI/OS IME on Linux and VoiceOver/IME on macOS remain end-to-end gates. +### Where the adapter has got to, and what it does not do + +`apps/lumbridge/src/a11y.rs` is the adapter decision 0017 promised and never +wrote, landed as the first stage of decision 0023. Ten elements now declare what +they mean — the shell root, the sidebar and its row list, every sidebar row, +the workspace region, every workspace pane, every live terminal surface, the +command palette, the add-panel chooser, and the footer's usage strip — using the +role, label, description, selected-state and stable-identity vocabulary the probe +proves exists at Zed `ce48461e`. `sidebar::model::describe`, written and tested +under decision 0017 and called by nothing for the whole life of that record, is +now what labels a sidebar row, and its `#[allow(dead_code)]` is deleted. + +**The gate is still failed, and this work does not move it.** The adapter's +bodies take the value they are given and drop it, because published `gpui 0.2.2` +has no AccessKit dependency to hand it to. Nothing reaches AT-SPI or VoiceOver; +there is no accessibility tree to inspect. What has changed is that the four +facts `UX_VERTICAL_SLICE.md` requires — which pane, its selected state, its +execution target, its waiting state — are now derived in one tested place +instead of being absent from the code entirely, so stage 5 of decision 0023 is a +change to one file rather than a pass over the renderer. Eight unit tests cover +the derivation. None of them is an assistive-technology claim. + The pinned Floem revision has keyboard focus and editor IME plumbing but no AccessKit dependency or semantic tree. That fails Lumbridge's accessibility gate without a maintained framework fork or adapter. The provisional direction