git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitaf44d09a8050ab74bf9d3498530a31e4f911f99f
parent7e0e642f99
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-09 01:37
feat(a11y): every typing field is a text field; apps publish their own

- A field that publishes text is a text input to the platform whatever
  the widget would be otherwise (only a text input has EditableText).
  ColorSelector answers a11y_text with its hex and a11y_set_text with
  what typing a hex does, described as a "colour" (A11yText::kind).
  As a description, not a role description: AccessKit makes a node with
  one AT-SPI's Extended role, and such a node never registered on the
  bus -- the gallery's colour selectors vanished from the reader.
- A field an app draws (a LineEdit) is AppNodes::text_field among the
  app's own nodes, its runs in a range of their own (APP_RUN_BASE). A
  reader's edit or request for the keyboard arrives as
  Application::accessibility_action(n, AppAction); the app's own nodes
  took no action at all until this. LineEdit::a11y_text /
  a11y_set_text (undoable; a masked field keeps no history).
- AccessKit is re-exported as cce_ui::accesskit for apps' own nodes.

Spinbox stays a spin button: a number, set through AT-SPI's Value.
Verified over AT-SPI on private buses: cce-browser's address bar set
from a reader, the gallery's colour selector set to #20c060 (swatch
green) and a non-colour refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                          |   8 +-
 docs/rfc-accessibility-locale.md   |  22 +++++
 locale/en-US/cce-ui.ftl            |   4 +
 src/a11y.rs                        | 185 +++++++++++++++++++++++++++++++------
 src/backend/a11y_unix.rs           |  37 ++++++++
 src/backend/app.rs                 |  12 +++
 src/lib.rs                         |   3 +
 src/widget/input/color_selector.rs |  28 ++++++
 src/widget/input/text_box.rs       |   1 +
 src/widget/line_edit.rs            |  53 +++++++++++
 10 files changed, 325 insertions(+), 28 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ab588a4..87fc1ad 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1897,8 +1897,12 @@ cce-system-interface) to confirm behavior, not just the test suite.
   menu; a widget's parts of its own (a radio group's radio buttons) are `A11yItem`s
   (`Input::a11y_items`), and a text field's text is TEXT RUNS with its caret
   (`Input::a11y_text` → `A11yText`; a password as bullets), which is what lets a reader read
-  it by line and set it (`Input::a11y_set_text`). `backend::a11y_unix` publishes it over
-  AT-SPI (the `a11y` feature; see `docs/rfc-accessibility-locale.md`, phase 2).
+  it by line and set it (`Input::a11y_set_text`; `TextBox`, `ColorSelector`). A field an app
+  draws (a `LineEdit`) is `AppNodes::text_field`, and a reader's edit of it arrives as
+  `Application::accessibility_action` (`AppAction`). Never give a node a ROLE DESCRIPTION:
+  AccessKit makes it AT-SPI's `Extended` role, and the node never registers on the bus.
+  `backend::a11y_unix` publishes it over AT-SPI (the `a11y` feature; see
+  `docs/rfc-accessibility-locale.md`, phase 2).
 - `l10n.rs` — the toolkit's catalogue (`tr`, `tr_args`, `catalog`) over `cce_core::l10n`;
   its English is `locale/en-US/cce-ui.ftl`.
 - `style.rs` — the style snapshot: `Style`, `StyleCell`, `batch`, `style_slots!`.
diff --git a/docs/rfc-accessibility-locale.md b/docs/rfc-accessibility-locale.md
index afcd6e7..69ab7da 100644
--- a/docs/rfc-accessibility-locale.md
+++ b/docs/rfc-accessibility-locale.md
@@ -184,6 +184,28 @@ editor's whole text and caret read back, the search box set to "grid" filtering
 there whether its popover was open or not, is hidden while it is closed: it was a field a
 reader found and a Tab stop nobody could see.
 
+**And so is every field where a person types (2026-10-09).** A field is published as a
+text input whatever the widget would be otherwise, since only a text input has
+EditableText: the `ColorSelector` answers `a11y_text` with its hex and `a11y_set_text` with
+what typing a hex does (a colour it parses, or nothing), described as a "colour"
+(`A11yText::kind`). As a DESCRIPTION, not a role description: AccessKit gives a node with a
+role description AT-SPI's `Extended` role, and such a node never registered on the bus —
+the gallery's two colour selectors were simply missing from the reader until it moved. A
+`Spinbox` stays a spin button: it is a number, which AT-SPI's Value interface sets
+(`SetCurrentValue`), as it already did. A field an APP draws — a `LineEdit` — is
+`AppNodes::text_field(n, label, &edit.a11y_text(has_keyboard), bounds)` among the app's own
+nodes (`Application::accessibility`; AccessKit is re-exported as `cce_ui::accesskit` for
+them), its runs in a range of their own (`APP_RUN_BASE`); a reader's edit, or its request
+for the keyboard, arrives as `Application::accessibility_action(n, AppAction::SetText(..) /
+Focus)` — the app's own nodes took no action at all until this — and `LineEdit::a11y_set_text`
+replaces the text undoably (a masked field keeps no history). cce-browser publishes its
+address bar, the bookmarks search, the vi command line and a dialog (modal, titled, its
+message as description) with its fields, and builds cce-ui's `a11y` feature, publishing
+with `CCE_A11Y=1` (not by default yet). Verified over AT-SPI on private buses: the
+browser's address bar set from the reader — the bar unfolds, takes the keyboard and shows
+it, the caret at its end — and the gallery's colour selector set to `#20c060`, its swatch
+green, "not a colour" refused.
+
 Next: the macOS adapter onto the AppKit view, and listening with Orca.
 
 - **Wayland / Linux:** AT-SPI over D-Bus. AccessKit's Unix adapter is the likely carrier
diff --git a/locale/en-US/cce-ui.ftl b/locale/en-US/cce-ui.ftl
index 4b02094..921fc88 100644
--- a/locale/en-US/cce-ui.ftl
+++ b/locale/en-US/cce-ui.ftl
@@ -41,3 +41,7 @@ keybind-none = None
 ## The document editor's Properties table
 doc-properties = Properties
 doc-empty = Empty
+
+## Accessibility
+# What a colour selector's hex field is, said by a screen reader after its name.
+a11y-colour = colour
diff --git a/src/a11y.rs b/src/a11y.rs
index 63aa6a9..4710ee8 100644
--- a/src/a11y.rs
+++ b/src/a11y.rs
@@ -81,6 +81,11 @@ pub struct A11yText {
     /// What it shows while empty ("Search..."): a hint, and the field's only words when it
     /// has no label.
     pub placeholder: Option<String>,
+    /// What kind of field it is, in words, when "text field" says too little: a colour
+    /// selector's hex field is a "colour". Published as the node's description, not a role
+    /// description: AccessKit makes a node with one AT-SPI's `Extended` role, and such a
+    /// node never registered on the bus (2026-10-09) — the field was gone from the reader.
+    pub kind: Option<String>,
 }
 
 /// A text field's text runs take the item indices from here up, one per line, so they never
@@ -108,21 +113,21 @@ fn text_lines(text: &str) -> Vec<&str> {
     lines
 }
 
-/// Char index `at` of a field's text as a position in its runs.
-fn run_position(id: WidgetId, lines: &[&str], at: usize) -> TextPosition {
+/// Char index `at` of a field's text as a position in its runs, named by `run_id`.
+fn run_position(run_id: &impl Fn(usize) -> NodeId, lines: &[&str], at: usize) -> TextPosition {
     let mut start = 0;
     for (i, line) in lines.iter().enumerate() {
         let len = line.chars().count();
         if at < start + len || i + 1 == lines.len() {
-            return TextPosition { node: text_run_id(id, i), character_index: at.saturating_sub(start).min(len) };
+            return TextPosition { node: run_id(i), character_index: at.saturating_sub(start).min(len) };
         }
         start += len;
     }
-    TextPosition { node: text_run_id(id, 0), character_index: 0 }
+    TextPosition { node: run_id(0), character_index: 0 }
 }
 
-/// The text run nodes of widget `id`'s field, in order.
-pub fn text_run_nodes(id: WidgetId, text: &A11yText) -> Vec<(NodeId, Node)> {
+/// A field's text runs, in order, named by `run_id` (the line's number).
+fn run_nodes(text: &A11yText, run_id: impl Fn(usize) -> NodeId) -> Vec<(NodeId, Node)> {
     text_lines(&text.text)
         .into_iter()
         .enumerate()
@@ -130,11 +135,48 @@ pub fn text_run_nodes(id: WidgetId, text: &A11yText) -> Vec<(NodeId, Node)> {
             let mut run = Node::new(Role::TextRun);
             run.set_value(line);
             run.set_character_lengths(line.chars().map(|c| c.len_utf8() as u8).collect::<Vec<u8>>());
-            (text_run_id(id, i), run)
+            (run_id(i), run)
         })
         .collect()
 }
 
+/// The text run nodes of widget `id`'s field, in order.
+pub fn text_run_nodes(id: WidgetId, text: &A11yText) -> Vec<(NodeId, Node)> {
+    run_nodes(text, |line| text_run_id(id, line))
+}
+
+/// The role a field that publishes `text` has: a text input of its kind, whatever the
+/// widget would be otherwise — only a text input has AT-SPI's EditableText.
+fn text_field_role(text: &A11yText) -> Role {
+    if text.password {
+        Role::PasswordInput
+    } else if text.multiline {
+        Role::MultilineTextInput
+    } else {
+        Role::TextInput
+    }
+}
+
+/// What a field's node says of its text, beside the runs that hold it: the selection in
+/// them, its hint and kind, and whether a reader may set it.
+fn describe_text_field(node: &mut Node, text: &A11yText, run_id: impl Fn(usize) -> NodeId) {
+    if let Some((anchor, focus)) = text.selection {
+        let lines = text_lines(&text.text);
+        node.set_text_selection(TextSelection { anchor: run_position(&run_id, &lines, anchor), focus: run_position(&run_id, &lines, focus) });
+    }
+    if let Some(hint) = text.placeholder.as_deref().filter(|h| !h.is_empty()) {
+        node.set_placeholder(hint);
+    }
+    if let Some(kind) = text.kind.as_deref().filter(|k| !k.is_empty()) {
+        node.set_description(kind);
+    }
+    if text.editable {
+        node.add_action(Action::SetValue);
+    } else {
+        node.set_read_only();
+    }
+}
+
 /// The node of item `idx` of widget `id` ([`A11yItem`]).
 pub fn item_id(id: WidgetId, idx: usize) -> NodeId {
     NodeId(ITEM_BASE + ((id.0 as u64) << 16) + (idx as u64 & 0xffff))
@@ -148,7 +190,7 @@ pub fn item_of(node: NodeId) -> Option<(WidgetId, usize)> {
 
 /// The context-menu row a node is, when it is one ([`menu_row_id`]'s inverse).
 pub fn menu_row_of(node: NodeId) -> Option<usize> {
-    (node.0 > MENU.0).then(|| (node.0 - MENU.0 - 1) as usize)
+    (node.0 > MENU.0 && node.0 < APP_RUN_BASE).then(|| (node.0 - MENU.0 - 1) as usize)
 }
 
 /// The open context menu's node, and the first of its rows' (`MENU + 1 + row`): far above any
@@ -163,6 +205,36 @@ pub fn menu_row_id(row: usize) -> NodeId {
 /// Where an app's own nodes ([`AppNodes::id`]) begin: above every widget's, below the menu's.
 const APP_BASE: u64 = 1 << 61;
 
+/// Where the text runs of an app's own fields ([`AppNodes::text_field`]) begin: above the
+/// menu's rows. `APP_RUN_BASE + (n << 15) + line` for the app's node `n`.
+const APP_RUN_BASE: u64 = 1 << 63;
+
+/// The node of line `line`'s text run in the app's own field `n`.
+fn app_text_run_id(n: u64, line: usize) -> NodeId {
+    NodeId(APP_RUN_BASE + ((n & ((1 << 48) - 1)) << 15) + line.min(RUN_BASE - 1) as u64)
+}
+
+/// The app's own number for a node it declared ([`AppNodes::id`]'s inverse).
+pub fn app_node_of(node: NodeId) -> Option<u64> {
+    (node.0 >= APP_BASE && node.0 < MENU.0).then(|| node.0 - APP_BASE)
+}
+
+/// What an assistive tool asks of one of the app's own nodes
+/// (`Application::accessibility_action`).
+#[derive(Debug, Clone, PartialEq)]
+pub enum AppAction {
+    /// Put the keyboard on it.
+    Focus,
+    /// Press it.
+    Click,
+    /// Replace a field's text (AT-SPI's `SetTextContents`): as the user replacing it would.
+    SetText(String),
+    /// Set a value (AT-SPI's `SetCurrentValue`).
+    SetNumber(f64),
+    Increment,
+    Decrement,
+}
+
 /// The nodes an app declares itself, for what it draws without a [`UiContext`] — see
 /// [`Application::accessibility`](crate::backend::app::Application::accessibility). Ids come
 /// from [`AppNodes::id`], an app's own numbering; children are set on a node with
@@ -196,6 +268,31 @@ impl AppNodes {
     pub fn set_focus(&mut self, id: NodeId) {
         self.focus = Some(id);
     }
+
+    /// A text field the app draws itself — a `LineEdit` (`LineEdit::a11y_text`) — as node
+    /// `n`: a text input named `label`, at `bounds` (window px), whose text is runs this
+    /// pushes, so a reader reads it by character, follows its caret and sets it. The
+    /// field's node is returned for the app to place (`push_top`, or `push` under one of
+    /// its nodes) at [`AppNodes::id`]`(n)`. A reader's edit arrives as
+    /// `AppAction::SetText` on `n` (`Application::accessibility_action`), and a request
+    /// for the keyboard as `AppAction::Focus`.
+    pub fn text_field(&mut self, n: u64, label: &str, text: &A11yText, bounds: Option<crate::scene::layout::Rect>) -> Node {
+        let mut node = Node::new(text_field_role(text));
+        if !label.is_empty() {
+            node.set_label(label);
+        }
+        if let Some(r) = bounds {
+            node.set_bounds(Rect::new(r.x as f64, r.y as f64, (r.x + r.width) as f64, (r.y + r.height) as f64));
+        }
+        node.add_action(Action::Focus);
+        let runs = run_nodes(text, |line| app_text_run_id(n, line));
+        node.set_children(runs.iter().map(|(id, _)| *id).collect::<Vec<_>>());
+        for (id, run) in runs {
+            self.push(id, run);
+        }
+        describe_text_field(&mut node, text, |line| app_text_run_id(n, line));
+        node
+    }
 }
 
 /// A widget's node: its id, moved up one so no widget can be the window.
@@ -240,10 +337,9 @@ pub fn role_for(type_name: &str, focus: FocusRole, explicit: Option<Role>) -> Ro
 pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
     let focus = w.focus_role();
     let text = w.a11y_text();
-    let role = match (&text, role_for(w.type_name(), focus, w.a11y_role())) {
-        (Some(t), Role::TextInput) if t.password => Role::PasswordInput,
-        (Some(t), Role::TextInput) if t.multiline => Role::MultilineTextInput,
-        (_, role) => role,
+    let role = match &text {
+        Some(t) => text_field_role(t),
+        None => role_for(w.type_name(), focus, w.a11y_role()),
     };
     let mut node = Node::new(role);
     let name = w.base().accessible_name.clone().filter(|n| !n.is_empty());
@@ -251,21 +347,9 @@ pub fn widget_node(w: &dyn WidgetHost, children: Vec<NodeId>) -> Node {
         node.set_label(label);
     }
     if let Some(t) = &text {
-        // The text is the runs (`text_run_nodes`, the node's first children); the selection
-        // is in them.
+        // The text is the runs (`text_run_nodes`, the node's first children).
         let id = w.base().id();
-        if let Some((anchor, focus)) = t.selection {
-            let lines = text_lines(&t.text);
-            node.set_text_selection(TextSelection { anchor: run_position(id, &lines, anchor), focus: run_position(id, &lines, focus) });
-        }
-        if let Some(hint) = t.placeholder.as_deref().filter(|h| !h.is_empty()) {
-            node.set_placeholder(hint);
-        }
-        if t.editable {
-            node.add_action(Action::SetValue);
-        } else {
-            node.set_read_only();
-        }
+        describe_text_field(&mut node, t, |line| text_run_id(id, line));
     } else if let Some(value) = w.a11y_value() {
         match role {
             Role::CheckBox | Role::Switch => {
@@ -702,6 +786,55 @@ mod tests {
         assert_eq!(t.text_selection().unwrap().focus, TextPosition { node: t.children()[1], character_index: 0 });
     }
 
+    /// A colour selector's hex field is a text field too, said to be a colour.
+    #[test]
+    fn a_colour_selectors_hex_is_a_colour_field() {
+        let mut ctx = UiContext::new();
+        let c = ctx.insert(crate::widget::ColorSelector::new([0x40, 0x80, 0xff]).with_label("Accent"));
+        ctx[c].set_rect(10.0, 10.0, 200.0, 24.0);
+        let update = tree_update(&ctx, "", 1.0);
+        let n = node(&update, node_id(c.id()));
+        assert_eq!((n.role(), n.label(), n.description()), (Role::TextInput, Some("Accent"), Some("colour")));
+        assert_eq!(n.role_description(), None, "a role description would make it AT-SPI's Extended role, which never registers");
+        assert!(n.supports_action(Action::SetValue));
+        assert_eq!(node(&update, n.children()[0]).value(), Some("#4080ff"));
+    }
+
+    /// A field an app draws itself (a `LineEdit`) is published as one of its own nodes: a
+    /// text input whose runs and caret are where a widget's would be, its secret bulleted,
+    /// its runs' ids clear of every other kind of node.
+    #[test]
+    fn an_app_drawn_field_is_a_text_field_of_the_apps() {
+        use crate::widget::LineEdit;
+        let mut url = LineEdit::with_text("héllo.org");
+        url.cursor = 3; // after the é, a two-byte char: the caret is char 2
+        let mut pass = LineEdit::masked();
+        pass.a11y_set_text("hunter2");
+        let mut app = AppNodes::default();
+        let mut t = url.a11y_text(true);
+        t.placeholder = Some("Search or enter address".into());
+        let field = app.text_field(7, "Address", &t, Some(crate::scene::layout::Rect { x: 0.0, y: 0.0, width: 300.0, height: 30.0 }));
+        app.push_top(AppNodes::id(7), field);
+        let secret = app.text_field(8, "Password", &pass.a11y_text(false), None);
+        app.push_top(AppNodes::id(8), secret);
+        app.set_focus(AppNodes::id(7));
+        let update = window_tree(None, app, "", 1.0);
+
+        let f = node(&update, AppNodes::id(7));
+        assert_eq!((f.role(), f.label(), f.placeholder()), (Role::TextInput, Some("Address"), Some("Search or enter address")));
+        assert!(f.supports_action(Action::SetValue) && f.supports_action(Action::Focus));
+        let run = f.children()[0];
+        assert_eq!(node(&update, run).value(), Some("héllo.org"));
+        assert_eq!(f.text_selection().unwrap().focus, TextPosition { node: run, character_index: 2 });
+        assert_eq!(app_node_of(AppNodes::id(7)), Some(7));
+        assert_eq!((app_node_of(run), menu_row_of(run), widget_of(run), item_of(run)), (None, None, None, None), "a run is nobody's node");
+
+        let p = node(&update, AppNodes::id(8));
+        assert_eq!(p.role(), Role::PasswordInput);
+        assert_eq!(node(&update, p.children()[0]).value(), Some("\u{2022}".repeat(7).as_str()));
+        assert_eq!(p.text_selection(), None, "without the keyboard, no caret");
+    }
+
     #[test]
     fn hidden_widgets_are_not_in_the_tree_and_focus_falls_back_to_the_window() {
         let mut ctx = UiContext::new();
diff --git a/src/backend/a11y_unix.rs b/src/backend/a11y_unix.rs
index f54393d..105dc8a 100644
--- a/src/backend/a11y_unix.rs
+++ b/src/backend/a11y_unix.rs
@@ -81,6 +81,9 @@ pub enum Acted {
 ///   does (`WidgetHost::a11y_select_item`) and puts the keyboard on its widget.
 /// - **Click** on an open context menu's row presses it where it is drawn, so the menu runs
 ///   the row's action exactly as a pointer would.
+/// - Anything on one of the app's own nodes (`Application::accessibility`) is the app's:
+///   `Application::accessibility_action`, in its own terms (`a11y::AppAction`) — a reader
+///   setting a field the app draws (`AppNodes::text_field`) is `AppAction::SetText`.
 ///
 /// Anything else does nothing, as AccessKit requires of an action the app cannot perform.
 /// A key reaches the widget only through the app's `handle_key_input`, as every key does —
@@ -115,6 +118,9 @@ pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> Acted {
         }
         return Acted::Changed;
     }
+    if let Some(n) = crate::a11y::app_node_of(request.target_node) {
+        return if app_action(request).is_some_and(|action| app.accessibility_action(n, action)) { Acted::Changed } else { Acted::Nothing };
+    }
     let Some(id) = crate::a11y::widget_of(request.target_node) else { return Acted::Nothing };
     let Some(ctx) = app.ui_context_mut() else { return Acted::Nothing };
     if request.action == Action::SetValue {
@@ -135,6 +141,20 @@ pub fn act<A: Application>(app: &mut A, request: &ActionRequest) -> Acted {
     key.map_or(Acted::Changed, Acted::Key)
 }
 
+/// A request on one of the app's own nodes, in the app's terms (`Application::accessibility_action`).
+fn app_action(request: &ActionRequest) -> Option<crate::a11y::AppAction> {
+    use crate::a11y::AppAction;
+    Some(match (request.action, request.data.as_ref()) {
+        (Action::Focus, _) => AppAction::Focus,
+        (Action::Click, _) => AppAction::Click,
+        (Action::SetValue, Some(ActionData::Value(text))) => AppAction::SetText(text.to_string()),
+        (Action::SetValue, Some(ActionData::NumericValue(value))) => AppAction::SetNumber(*value),
+        (Action::Increment, _) => AppAction::Increment,
+        (Action::Decrement, _) => AppAction::Decrement,
+        _ => return None,
+    })
+}
+
 /// A reader's `SetValue` on widget `id`, where it is, focus untouched (a reader adjusting a
 /// value has not moved): a number for a slider or spin button (`WidgetHost::a11y_set_value`),
 /// text for a text field (AT-SPI's `SetTextContents`, `WidgetHost::a11y_set_text`).
@@ -269,8 +289,25 @@ mod tests {
         assert!(ctx[name].context_action(crate::widget::ContextAction::Undo), "undoable");
         assert_eq!(ctx[name].a11y_text().unwrap().text, "Grace");
 
+        // A colour selector takes a colour as text, and nothing that is not one.
+        let accent = ctx.insert(crate::widget::ColorSelector::new([0x40, 0x80, 0xff]));
+        assert_eq!(set_value(&mut ctx, accent.id(), Some(&text("#00ff00"))), Acted::Changed);
+        assert_eq!(ctx[accent].a11y_value().as_deref(), Some("#00ff00"));
+        assert_eq!(set_value(&mut ctx, accent.id(), Some(&text("green-ish"))), Acted::Nothing);
+
         ctx[name].disabled = true;
         assert_eq!(set_value(&mut ctx, name.id(), Some(&text("Hopper"))), Acted::Nothing, "a disabled field");
         assert_eq!(set_value(&mut ctx, crate::widget::WidgetId(usize::MAX), Some(&text("x"))), Acted::Nothing, "nothing there");
     }
+
+    /// What a reader asks of an app's own node reaches the app in its own terms.
+    #[test]
+    fn a_request_on_an_apps_node_is_the_apps_action() {
+        use crate::a11y::AppAction;
+        let req = |action, data| ActionRequest { action, target_tree: accesskit::TreeId::ROOT, target_node: crate::a11y::AppNodes::id(3), data };
+        assert_eq!(app_action(&req(Action::SetValue, Some(ActionData::Value("x.org".into())))), Some(AppAction::SetText("x.org".into())));
+        assert_eq!(app_action(&req(Action::SetValue, Some(ActionData::NumericValue(2.0)))), Some(AppAction::SetNumber(2.0)));
+        assert_eq!(app_action(&req(Action::Focus, None)), Some(AppAction::Focus));
+        assert_eq!(app_action(&req(Action::ScrollIntoView, None)), None);
+    }
 }
diff --git a/src/backend/app.rs b/src/backend/app.rs
index b3aa206..729193f 100644
--- a/src/backend/app.rs
+++ b/src/backend/app.rs
@@ -342,6 +342,18 @@ pub trait Application: Sized + 'static {
         let _ = nodes;
     }
 
+    /// An assistive tool asked something of one of the app's own nodes
+    /// ([`accessibility`](Self::accessibility)), named by the app's own number `n`
+    /// (`AppNodes::id(n)`): a reader's edit of a field the app draws
+    /// (`AppAction::SetText`, for one published with `AppNodes::text_field`), a request
+    /// for the keyboard, a press. Do what the user doing it would, and answer whether
+    /// anything changed (the window is then redrawn and the tree republished). Default:
+    /// nothing.
+    fn accessibility_action(&mut self, n: u64, action: crate::a11y::AppAction) -> bool {
+        let _ = (n, action);
+        false
+    }
+
     fn ui_context(&self) -> Option<&crate::context::UiContext> {
         None
     }
diff --git a/src/lib.rs b/src/lib.rs
index 21ba405..f34abca 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -59,6 +59,9 @@ pub mod colors {
 /// Re-exporting also keeps every client on the one version cce-ui shapes with —
 /// a `FontSystem` handed across the boundary must be the same type.
 pub use cosmic_text;
+/// AccessKit, whose nodes an app pushes for what it draws itself
+/// (`Application::accessibility`, `a11y::AppNodes`), so it needs no dependency of its own.
+pub use accesskit;
 
 
 
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index 7f6ad90..b2c7b97 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -536,6 +536,34 @@ impl Input for ColorSelector {
         Some(self.value_hex())
     }
 
+    fn a11y_text(&self) -> Option<crate::a11y::A11yText> {
+        // The hex field: what it shows, its caret while it is being typed into.
+        let text = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
+        let caret = self.cursor_idx.min(text.chars().count());
+        Some(crate::a11y::A11yText {
+            text,
+            selection: self.editing.then_some((caret, caret)),
+            editable: true,
+            kind: Some(crate::l10n::tr("a11y-colour")),
+            ..Default::default()
+        })
+    }
+
+    fn a11y_set_text(&mut self, text: &str) -> bool {
+        // A colour, as the field takes one when typed: a hex it parses, or nothing.
+        let text = text.trim();
+        if self.set_value_string(text) {
+            return true;
+        }
+        // The colour it already holds, set while a half-typed hex is up: the field shows it.
+        if self.editing && parse_hex(text).is_some() && self.edit_buffer != self.value_hex() {
+            self.edit_buffer = self.value_hex();
+            self.cursor_idx = self.edit_buffer.chars().count();
+            return true;
+        }
+        false
+    }
+
     fn set_value_string(&mut self, val: &str) -> bool {
         if let Some(c) = parse_hex(val) {
             let target_color = [c[0], c[1], c[2]];
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 98cd9f0..6e7842d 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -2159,6 +2159,7 @@ impl Input for TextBox {
             password: self.is_password,
             editable: !self.disabled,
             placeholder: self.placeholder.clone(),
+            kind: None,
         })
     }
 
diff --git a/src/widget/line_edit.rs b/src/widget/line_edit.rs
index a7c859b..4331519 100644
--- a/src/widget/line_edit.rs
+++ b/src/widget/line_edit.rs
@@ -570,6 +570,41 @@ impl LineEdit {
         true
     }
 
+    /// The field for a screen reader (`a11y::AppNodes::text_field`): what it holds — a
+    /// masked field as bullets, never an input method's composition — and, while it has the
+    /// keyboard (`editing`), its selection and caret. The caller adds what the field does
+    /// not know: its placeholder, whether it may be set.
+    pub fn a11y_text(&self, editing: bool) -> crate::a11y::A11yText {
+        let chars = |byte: usize| self.text[..byte.min(self.text.len())].chars().count();
+        let len = self.text.chars().count();
+        let text = if self.masked { "\u{2022}".repeat(len) } else { self.text.clone() };
+        let selection = editing.then(|| match self.selection {
+            // A selection is normalized; the caret is at one of its ends.
+            Some((a, b)) if self.cursor == a => (chars(b), chars(a)),
+            Some((a, b)) => (chars(a), chars(b)),
+            None => (chars(self.cursor), chars(self.cursor)),
+        });
+        crate::a11y::A11yText { text, selection, password: self.masked, editable: true, ..Default::default() }
+    }
+
+    /// Replace the text with what a screen reader set (AT-SPI's `SetTextContents`), as the
+    /// user replacing it would: undoable (a masked field keeps no history), the caret at its
+    /// end, any composition dropped. Whether it changed.
+    pub fn a11y_set_text(&mut self, text: &str) -> bool {
+        self.drop_composition();
+        if self.text == text {
+            return false;
+        }
+        if !self.masked {
+            let before = self.snapshot();
+            self.history.record(before);
+        }
+        self.text = text.to_string();
+        self.cursor = self.text.len();
+        self.selection = None;
+        true
+    }
+
     pub fn can_undo(&self) -> bool {
         self.history.can_undo()
     }
@@ -1326,4 +1361,22 @@ mod tests {
         assert_eq!(e.handle_key(&ctrl("x")), EditOutcome::Ignored);
         assert_eq!(e.text, "hunter2", "cut must not have removed it");
     }
+
+    /// A reader's edit replaces the text as the user's would: the caret at its end, a
+    /// selection dropped, one undo step back — and none for a masked field, which keeps no
+    /// history. The same text again changes nothing.
+    #[test]
+    fn a_readers_edit_replaces_the_text_undoably() {
+        let mut e = LineEdit::with_text("old");
+        e.selection = Some((0, 3));
+        assert!(e.a11y_set_text("new.org"));
+        assert_eq!((e.text.as_str(), e.cursor, e.selection), ("new.org", 7, None));
+        assert!(!e.a11y_set_text("new.org"));
+        assert!(e.undo());
+        assert_eq!(e.text, "old");
+        let mut p = LineEdit::masked();
+        assert!(p.a11y_set_text("hunter2"));
+        assert!(!p.undo(), "no history for a secret");
+        assert_eq!(p.a11y_text(false).text, "\u{2022}".repeat(7));
+    }
 }