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 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::<Vec<_>>();
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<Self>,
) -> 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<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 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::<Vec<_>>();
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()
+10 -13
View File
@@ -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 {