Give the elements that carry meaning something to say

The adapter is only worth having if the call sites exist, so this wires the
ten elements `UX_VERTICAL_SLICE.md` actually names and stops there. The
shell root is an application; the sidebar, its row list and the workspace
are named regions; every workspace panel is a pane carrying its label, its
execution target, its waiting state and whether it owns the keyboard; every
live terminal surface is a terminal; the command palette and the add-panel
chooser are dialogs; the footer's usage strip is a status. Seven elements
gained a stable GPUI id along the way, because an element worth naming to a
screen reader is an element worth keeping state on.

The other 137 divs are left alone deliberately. Fixed-width slots, meters,
chips, borders and painted terminal cell runs have no identity to hang a
node on, and publishing 80x24 nodes a frame would be a tree nobody can
navigate and a cost on every repaint.

No new strings were invented for the screen reader where the model already
had one. `describe()` labels each sidebar row -- that is its caller, so its
`#[allow(dead_code)]` is deleted rather than carried, as decision 0023
requires. The three words in a pane header's corner and the three words its
description leads with now come from one function. The two lines a pane
header prints about where it runs and what its process is doing moved out of
`pane_context` into `pane_header_lines`, because the pane's semantic node is
built one level up in `workspace_panel` and recomputing them there would
have meant two expressions that agree today and disagree after the first
edit. The add-panel chooser's heading and its accessible name are one
constant.

Four labels are new strings, because nothing on screen names these areas:
"Lumbridge", "Workspace", "Sidebar" and "Harness usage". Each is the name
the code and the docs already use for the thing it labels.

The scorecard's accessibility row now reads fail rather than a hedge. The
call sites are wired; the semantics are absent, because the adapter no-ops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SPYebLiN2w4TqnHUYGdECq
This commit is contained in:
Metal Agent
2026-09-01 13:14:01 -07:00
co-authored by Claude Opus 5
parent 4b5d92249e
commit b6dc70f9fa
3 changed files with 137 additions and 36 deletions
+104 -22
View File
@@ -1,3 +1,4 @@
mod a11y;
mod attention; mod attention;
mod geometry; mod geometry;
mod input; mod input;
@@ -33,6 +34,7 @@ use lumbridge_ui_fixture::{
SurfaceKind, SurfaceKind,
}; };
use a11y::{A11y as _, PaneSemantics, Role};
use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource}; use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource};
use input::{terminal_key_from_parts, terminal_scroll_from_parts}; use input::{terminal_key_from_parts, terminal_scroll_from_parts};
use surface::{LiveRuntimeStatus, PanelView, SurfaceTab}; 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. /// How close to the seam a press has to land to start a resize.
const SIDEBAR_GRAB_RADIUS: f32 = 4.0; const SIDEBAR_GRAB_RADIUS: f32 = 4.0;
const TERMINAL_ROW_STEP: u16 = 2; const TERMINAL_ROW_STEP: u16 = 2;
@@ -1526,6 +1534,14 @@ impl LumbridgeShell {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
div() 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()
.flex_col() .flex_col()
.size_full() .size_full()
@@ -1564,24 +1580,22 @@ impl LumbridgeShell {
.into_any_element() .into_any_element()
} }
#[allow( /// The two lines a pane header prints about itself: where it runs, and what
clippy::too_many_lines, /// its process is doing.
reason = "a single declarative element tree, not a sequence of steps" ///
)] /// Lifted out of `pane_context` because the accessibility tree needs the
fn pane_context( /// same two strings and is built one level up, in `workspace_panel`.
&self, /// Recomputing them there would have meant two expressions that agree today
pane: &PanelView, /// — a pane's declared target is "local shell" while its attached PTY
selected: bool, /// reports "local · runtime session 7 · pid 4321" — and disagree the first
cx: &mut Context<Self>, /// time either is edited, which is a screen reader announcing a machine the
) -> gpui::AnyElement { /// screen is not showing.
let theme = self.theme.colors; fn pane_header_lines(&self, pane: &PanelView) -> (String, String) {
let pane_id = pane.id;
let can_detach = self.panels.attached_count() > 1;
let external = pane.output_source == OutputSource::External; let external = pane.output_source == OutputSource::External;
// A pane can be marked external and still have no runtime: a failed // A pane can be marked external and still have no runtime: a failed
// spawn, a terminated session, a snapshot restored past its process. // spawn, a terminated session, a snapshot restored past its process.
let live_terminal = external let live_terminal = external
.then(|| self.live_terminals.get(&pane_id)) .then(|| self.live_terminals.get(&pane.id))
.flatten(); .flatten();
let status = live_terminal.map_or(pane.badge.as_str(), |terminal| terminal.status.badge()); let status = live_terminal.map_or(pane.badge.as_str(), |terminal| terminal.status.badge());
let detail = 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<Self>,
) -> 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 native = SurfaceTab::native_for(pane.kind);
let shown = self.surface_for(pane.id, pane.kind); let shown = self.surface_for(pane.id, pane.kind);
let tabs = SurfaceTab::ALL let tabs = SurfaceTab::ALL
@@ -1755,13 +1786,9 @@ impl LumbridgeShell {
.text_xs() .text_xs()
.text_color(theme.muted) .text_color(theme.muted)
.child("TOOLS · CONTEXT · GOAL") .child("TOOLS · CONTEXT · GOAL")
.child(if pane.needs_input() { // The same three words the pane's accessibility description
"NEEDS INPUT" // leads with, from the same function, so they cannot drift.
} else if selected { .child(a11y::pane_standing(pane.needs_input(), selected)),
"KEYBOARD OWNER"
} else {
"RUNNING"
}),
) )
.into_any_element() .into_any_element()
} }
@@ -2080,8 +2107,19 @@ impl LumbridgeShell {
) -> gpui::AnyElement { ) -> gpui::AnyElement {
let theme = self.theme.colors; let theme = self.theme.colors;
let pane_id = pane.id; 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() div()
.id(("workspace-panel", pane_id.get())) .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() .cursor_pointer()
.on_click(cx.listener(move |shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
shell.select_pane(pane_id, window, cx); shell.select_pane(pane_id, window, cx);
@@ -2135,6 +2173,10 @@ impl LumbridgeShell {
}; };
div() div()
.id("command-palette")
.a11y_id("lumbridge.command-palette")
.a11y_role(Role::Dialog)
.a11y_label("Commands")
.absolute() .absolute()
.inset_0() .inset_0()
.flex() .flex()
@@ -2311,6 +2353,12 @@ impl LumbridgeShell {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
div() 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() .absolute()
.inset_0() .inset_0()
.flex() .flex()
@@ -2342,7 +2390,7 @@ impl LumbridgeShell {
div() div()
.text_lg() .text_lg()
.text_color(theme.text) .text_color(theme.text)
.child("Add workspace panel"), .child(ADD_PANEL_TITLE),
) )
.child( .child(
div() div()
@@ -2568,6 +2616,9 @@ impl LumbridgeShell {
} = parts; } = parts;
div() div()
.id("lumbridge-shell") .id("lumbridge-shell")
.a11y_id("lumbridge.application")
.a11y_role(Role::Application)
.a11y_label("Lumbridge")
.relative() .relative()
.track_focus(&self.root_focus) .track_focus(&self.root_focus)
.key_context("LumbridgeShell") .key_context("LumbridgeShell")
@@ -2662,6 +2713,10 @@ impl LumbridgeShell {
.child( .child(
div().flex().flex_1().min_h_0().children(sidebar).child( div().flex().flex_1().min_h_0().children(sidebar).child(
div() div()
.id("workspace")
.a11y_id("lumbridge.workspace")
.a11y_role(Role::Region)
.a11y_label("Workspace")
.flex() .flex()
.flex_col() .flex_col()
.flex_1() .flex_1()
@@ -2836,6 +2891,10 @@ impl LumbridgeShell {
let focused = self.sidebar_has_focus; let focused = self.sidebar_has_focus;
let cursor = self.sidebar.cursor.clone(); let cursor = self.sidebar.cursor.clone();
div() div()
.id("sidebar")
.a11y_id("lumbridge.sidebar")
.a11y_role(Role::Region)
.a11y_label("Sidebar")
.relative() .relative()
.flex() .flex()
.flex_col() .flex_col()
@@ -2846,14 +2905,30 @@ impl LumbridgeShell {
.border_color(theme.border_quiet) .border_color(theme.border_quiet)
.child( .child(
div() 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() .flex_1()
.min_h_0() .min_h_0()
.overflow_hidden() .overflow_hidden()
.children(rows.into_iter().map(|row| { .children(rows.into_iter().map(|row| {
let cursored = cursor.as_ref() == Some(&row.key); 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(); let key = row.key.clone();
div() div()
.id(sidebar::view::element_id(&key)) .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() .cursor_pointer()
.hover(|view| view.bg(theme.surface_raised)) .hover(|view| view.bg(theme.surface_raised))
.on_click(cx.listener(move |shell, _, window, cx| { .on_click(cx.listener(move |shell, _, window, cx| {
@@ -2979,6 +3054,13 @@ impl LumbridgeShell {
let segments = self.usage.strip(); let segments = self.usage.strip();
let orphan = active.is_none(); let orphan = active.is_none();
div() 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() .flex()
.items_center() .items_center()
.gap_4() .gap_4()
+10 -13
View File
@@ -474,20 +474,17 @@ pub(crate) fn move_cursor(
/// The screen-reader sentence for a row. /// The screen-reader sentence for a row.
/// ///
/// Not attached to anything yet: published `gpui 0.2.2` has no AccessKit, so /// Written here rather than in the view so it is testable without a window, and
/// there is no accessibility node to hang it on. Decision 0017 stages that /// so the words a sighted user reads and the words an assistive technology
/// behind an adapter, and this is the text the adapter will announce. It is /// announces come from one place and cannot drift apart.
/// 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, and so the words a /// Decision 0017 wrote this and then called it from nowhere, behind an
/// sighted user reads and the words an assistive technology announces come from /// `#[allow(dead_code)]` promising an accessibility adapter that was never
/// one place and cannot drift apart. /// 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 { pub(crate) fn describe(row: &SidebarRow) -> String {
let mut text = String::new(); let mut text = String::new();
match &row.body { match &row.body {
+23 -1
View File
@@ -10,7 +10,7 @@ from macOS and Linux and the hard gates pass.
| Builds on Ubuntu | yes | conditional | pass | | Builds on Ubuntu | yes | conditional | pass |
| Builds on Omarchy/Arch | yes | pending | pending | | Builds on Omarchy/Arch | yes | pending | pending |
| Dependency/license closure permits Apache-2.0 distribution | 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 | | 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 | | Isolated system browser child | yes | pending | pending |
| Cold startup, p50/p95 | record | 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 tests pass. AT-SPI/OS IME on Linux and VoiceOver/IME on macOS remain end-to-end
gates. 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 The pinned Floem revision has keyboard focus and editor IME plumbing but no
AccessKit dependency or semantic tree. That fails Lumbridge's accessibility AccessKit dependency or semantic tree. That fails Lumbridge's accessibility
gate without a maintained framework fork or adapter. The provisional direction gate without a maintained framework fork or adapter. The provisional direction