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:
co-authored by
Claude Opus 5
parent
4b5d92249e
commit
b6dc70f9fa
+104
-22
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user