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

docs/rfc-accessibility-locale.md (26.2K)

  1 # RFC: Accessibility and locale — what the toolkit owes people who are not its author
  2 
  3 **Status:** Draft / roadmap (2026-10-08)
  4 **Scope:** Assistive-technology access (screen readers, switch and voice control),
  5 keyboard-only use, the user's locale, right-to-left and non-monospace text editing, and
  6 translatable UI strings — across `cce-ui` and the apps built on it.
  7 **Appetite:** Phased. Phase 0 is a few lines; each later phase is its own piece of work
  8 and leaves every app building and drawing as before until the step meant to change it.
  9 **Reads before this:** `CLAUDE.md` § "Plates, wells and seams" (focus roles, the walk),
 10 § "Input-method composition is one model for every shell" (the IME model this extends);
 11 `docs/rfc-core-rebuild.md` (the widget tree the accessibility tree would mirror).
 12 
 13 ---
 14 
 15 ## 1. Why now
 16 
 17 The toolkit audit of 2026-10-07 found no accessibility support and no locale handling at
 18 all. Both are cheap to design for and expensive to retrofit: every widget added without a
 19 role, every label used as an identifier, and every editor that assumes left-to-right
 20 monospace text is a later rewrite. The widget set is still growing (the field family, the
 21 spreadsheet's selection and the trackball all landed in the last two weeks), so this is
 22 the cheapest moment there will be.
 23 
 24 ## 2. Where things stand (measured 2026-10-08)
 25 
 26 **Accessibility.**
 27 - Nothing in the tree speaks to assistive technology: no AT-SPI, no AccessKit, no
 28   NSAccessibility, no ARIA. A screen reader sees an untitled surface.
 29 - What exists is a good seed. Every retained widget reports a `FocusRole` (`Plate`, `Well`
 30   or `None`), a `label()`, a value (`Adapted::get_value_string`), its rect, its focus and
 31   its `type_name()`, and `UiContext` holds them all by `WidgetId`. Since phase 2 of the
 32   pointer work (`widget::Owned`) every registered app widget sits at a stable address.
 33 - 11 of the 27 `Application` apps draw immediate-mode (no `UiContext` tree): calendar,
 34   browser, grid, keyboard, screensaver, map, status bar, model, preview, terminal,
 35   notifier. They have nothing for a tree to be built from.
 36 
 37 **Keyboard.**
 38 - Tab / Shift+Tab plate navigation exists (`UiContext::focus_step`, `focus_step_group`)
 39   but is OPT-IN (`Application::plate_navigation`, default false; on by default since phase
 40   3). Four apps turn it on:
 41   cce-data-editor, cce-files, cce-gallery, cce-system-interface (plus the demo).
 42 - There is no tooltip (where an accessible description usually lives), no modal dialog
 43   widget (where focus must be trapped), and no radio group.
 44 
 45 **Locale.**
 46 - The locale is the literal `"en-US"` in all four `FontSystem` constructors
 47   (`lib.rs`, `backend/text.rs` twice, `backend/frame.rs`). cosmic-text uses it to order
 48   its font fallback, so a Japanese user gets the Chinese variants of shared Han glyphs.
 49 - No message catalogue. The toolkit itself ships about 20 English strings (the context
 50   menu's Cut / Copy / Paste / Select All / Undo / Redo, Copy Path / Copy Key / Copy Value,
 51   Expand / Collapse, Properties, Search…, OK / Cancel / Save / Delete).
 52 - **Labels are identifiers.** A context-menu row's text is its identity (CLAUDE.md: "the
 53   text stays the row's identity, so hosts that match their own labels still match"), and
 54   hosts dispatch by comparing label strings. Translating a label today breaks the action
 55   behind it.
 56 
 57 **Text direction and script.**
 58 - cosmic-text shapes bidirectional text and lays out right-to-left runs, so DRAWING Arabic
 59   or Hebrew mostly works.
 60 - EDITING does not. `ShapingMeasure::offsets` sorts glyph positions by byte and assumes x
 61   grows with the text (false inside a right-to-left run); the `DocEditor` and `LineEdit`
 62   carets are built on it. `TextBox` is a column editor: it measures one monospace advance
 63   (`"MMMMMMMM"` / 8) and wraps by character count, which holds for neither proportional
 64   fonts nor CJK, whose characters are two columns wide.
 65 - The IME model (`crate::ime`) is direction-agnostic and needs no change.
 66 
 67 ## 3. The plan
 68 
 69 ### Phase 0 — the locale comes from the user (small; do first) — DONE 2026-10-08
 70 
 71 Done as written below: `cce_core::locale` (re-exported as `cce_ui::locale`), used by all four
 72 constructors, and the browser shell sets it from `navigator.language` before the first font
 73 system. Tests: `locale::tests` in cce-core (the parsing and the environment's order) and
 74 `every_font_system_is_built_with_the_users_locale` in cce-ui.
 75 
 76 One `cce_core::locale()`: `LC_ALL`, else `LC_CTYPE`, else `LANG`, turned from POSIX form
 77 (`ja_JP.UTF-8`) into a BCP 47 tag (`ja-JP`), falling back to `en-US`; in the browser,
 78 `navigator.language`. The four `FontSystem` constructors take it. Nothing changes for an
 79 `en_US` session, which is every session today.
 80 
 81 ### Phase 1 — an accessibility tree the toolkit can produce
 82 
 83 **Progress (2026-10-08):** the tree for retained widgets is `crate::a11y::tree_update`
 84 (AccessKit's `TreeUpdate`, § 5): a window node carrying the HiDPI scale, and a node per
 85 registered visible widget with its role (`WidgetHost::a11y_role`, else `a11y::role_for` from
 86 type and focus role), label, value (`a11y_value`, the widget's `value_string`: toggled for a
 87 check box or switch, numeric for a slider, spin button or progress bar), logical bounds,
 88 focus and click actions, children in reading order, and the context's focus.
 89 
 90 **Phase 1 is done (2026-10-08).** Also landed:
 91 - **An open context menu is in the tree**: a `Menu` under the window, its rows items — `✓` a
 92   checkable item, `●` / `○` a radio item (toggled as marked), a slider row a slider with its
 93   range, a header a label, separators left out — and while it is open the focus is its
 94   highlighted row, else the menu.
 95 - **The hook for apps without widgets**: `Application::accessibility(&mut self, &mut
 96   a11y::AppNodes)`, AccessKit nodes in an id range of the app's own (`AppNodes::id`), with
 97   `push_top` / `push` / `set_focus`; `a11y::app_tree(&mut app, scale)` is an app's whole
 98   tree (its widgets, its own nodes, the menu).
 99 - **Menu rows carry their actions**: `context_menu::set_row_actions` /
100   `UiContext::show_context_menu_rows` give each row a `ContextAction`, and a press runs it;
101   matching the English label (`legacy_action_for_label`) is only a fallback for menus built
102   without. The toolkit's own menus (a widget's standard menu, the tree list's) set theirs, so
103   translating their labels changes nothing they do. Apps that match labels themselves (the
104   designer's menus) move when they are next touched.
105 
106 - A node per registered widget: **role** (from `type_name` and `FocusRole` first, then an
107   explicit `Input::a11y_role()` a widget can override: button, checkbox, toggle-button,
108   slider, spin-button, text-input, combo-box, list, tree, table, menu), **name** (the
109   label), **value** (`get_value_string`), **bounds**, **focus**, and the **actions** it
110   takes (activate, increment / decrement, set value, focus). Parent and child links come
111   from `UiContext`'s tree.
112 - For the immediate-mode apps, an `Application::accessibility(&mut self, out)` hook where an
113   app declares its own nodes, the way it already declares `input_regions`.
114 - Incremental: a node is keyed by `WidgetId`, and only changed nodes are re-sent. The
115   per-frame damage diff (`backend::frame`) is the model for what "changed" means.
116 - **Rule from this phase on:** a new widget declares its role and name when it lands, and a
117   context-menu row or any other actionable label gets an ID separate from its text.
118 
119 ### Phase 2 — speak to the platform
120 
121 **Progress (2026-10-08): Linux works, on one app.** `backend::a11y_unix` runs `accesskit_unix`
122 in the Wayland shell behind the `a11y` feature (23 crates, zbus among them; nothing without
123 it), for an app that returns true from `Application::publishes_accessibility` or any app run
124 with `CCE_A11Y=1`. The adapter's callbacks only post into the runner's calloop loop; the
125 loop publishes the whole tree when a reader connects (an idle window renders nothing, and
126 AccessKit wants it by the next refresh), after every frame (`update_if_active`: nothing is
127 built while no reader is connected), and answers `Focus` on a widget (`UiContext::
128 set_focused_id`) and `Click` on a context-menu row (a press where it is drawn). The window's
129 keyboard enter / leave is AccessKit's window focus, so a node reads FOCUSED only while the
130 window has the keyboard. Widgets parked off-screen or without a size are left out.
131 `CCE_A11Y_DEBUG=1` logs each connection, action, publish (with its build time) and focus change.
132 
133 Proven on cce-data-editor in a shadow session through `pyatspi`'s `Atspi` (no screen reader
134 is installed), with the session's `org.a11y.Status IsEnabled` set for the run: the app is
135 listed with toolkit `cce-ui`; its frame holds buttons, combo boxes, entries, spin buttons
136 with values, a check box, the menu bar and the tree with their roles; `grab_focus` on a
137 button focuses it in the window (its ring drawn) and reads back FOCUSED; Tab steps are
138 republished. A tree of 21 nodes builds in 50–100 µs in a debug build.
139 
140 What it showed is the apps' to fix, not the adapter's: most of the data editor's controls
141 have no label (an unlabelled field is an empty name to a reader), and its inactive editors
142 are registered and visible though not drawn.
143 
144 **Widget actions (2026-10-08).** A reader can now press and set widgets, not only focus
145 them. What AT-SPI reaches through AccessKit is narrower than AccessKit's action list: the
146 Action interface carries `click` alone, and a value is adjusted through the Value
147 interface's `SetCurrentValue` (`Action::SetValue` with a number); Increment / Decrement
148 exist for the macOS and Windows adapters. So:
149 - **Click** focuses the widget and presses Space through the app's own
150   `handle_key_input` (`Driver::press_named_key`), what activates a plate from the keyboard,
151   so the app hears of it as it hears of a key. **Increment / Decrement** are the arrows
152   (Right / Left on a slider or range, Up / Down on a spin button), ready for the other
153   adapters. `a11y::key_for` is the one table: a node offers exactly the actions it answers.
154 - **SetValue** sets the value on the widget (`Input::a11y_set_value`, Slider and Spinbox),
155   clamped and marked changed as a typed value is, without moving focus. Not by keys: a key
156   a widget does not take falls through to the app, where Backspace or Escape may mean
157   something else. An app that drains `take_change` in `tick` sees it this turn; one that
158   drains only in its input handlers, at the next input.
159 - A slider's or spin button's node carries its range and step (`Input::a11y_range`), what a
160   reader reads a percentage and a step from.
161 - A Spinbox now steps on Up / Down, and a focused box takes keys again after Enter (it
162   ignored every key until refocused).
163 
164 Verified on cce-data-editor in a shadow: a check box clicked on and off (CHECKED read
165 back), spin buttons set to 7 and 3 with their ranges read.
166 
167 **A text field is read and set as text (2026-10-09).** A field says what a reader reads and
168 edits (`Input::a11y_text` → `a11y::A11yText`: what it shows, its caret and selection while
169 it is edited, multi-line, password, editable, placeholder), and the tree publishes it as
170 TEXT RUNS — one per line, the line's break at its end, each character's UTF-8 length beside
171 it — with the selection in them, instead of a plain value. Runs are what AccessKit gives
172 AT-SPI's Text and EditableText interfaces for: a reader reads the field by character and
173 line, follows the caret, and sets it with `SetTextContents`, which arrives as `SetValue`
174 with text and is `Input::a11y_set_text` (`backend::a11y_unix::set_value`): the text
175 replaced as the user replacing it would — undoable, the caret at its end — and reported to
176 the app as a typed change. A multi-line box is a `MultilineTextInput`, a password box a
177 `PasswordInput` whose runs are bullets; until this its secret was the node's value, in the
178 clear. A disabled field is read-only. `TextBox` implements both; the placeholder rides as
179 AT-SPI's `placeholder-text`. Verified on cce-data-editor, on a private session bus with its
180 own accessibility bus (a plain `dbus-daemon`, `at-spi2-registryd`, and a stand-in
181 `org.a11y.Bus` reporting it enabled — so the desktop's own AT-SPI stays off): the source
182 editor's whole text and caret read back, the search box set to "grid" filtering the tree to
183 `grid_gap`, the source set to new KDL re-parsed into the tree. The add-key popover's box,
184 there whether its popover was open or not, is hidden while it is closed: it was a field a
185 reader found and a Tab stop nobody could see.
186 
187 **And so is every field where a person types (2026-10-09).** A field is published as a
188 text input whatever the widget would be otherwise, since only a text input has
189 EditableText: the `ColorSelector` answers `a11y_text` with its hex and `a11y_set_text` with
190 what typing a hex does (a colour it parses, or nothing), described as a "colour"
191 (`A11yText::kind`). As a DESCRIPTION, not a role description: AccessKit gives a node with a
192 role description AT-SPI's `Extended` role, and such a node never registered on the bus —
193 the gallery's two colour selectors were simply missing from the reader until it moved. A
194 `Spinbox` stays a spin button: it is a number, which AT-SPI's Value interface sets
195 (`SetCurrentValue`), as it already did. A field an APP draws — a `LineEdit` — is
196 `AppNodes::text_field(n, label, &edit.a11y_text(has_keyboard), bounds)` among the app's own
197 nodes (`Application::accessibility`; AccessKit is re-exported as `cce_ui::accesskit` for
198 them), its runs in a range of their own (`APP_RUN_BASE`); a reader's edit, or its request
199 for the keyboard, arrives as `Application::accessibility_action(n, AppAction::SetText(..) /
200 Focus)` — the app's own nodes took no action at all until this — and `LineEdit::a11y_set_text`
201 replaces the text undoably (a masked field keeps no history). cce-browser publishes its
202 address bar, the bookmarks search, the vi command line and a dialog (modal, titled, its
203 message as description) with its fields, and builds cce-ui's `a11y` feature, publishing
204 with `CCE_A11Y=1` (not by default yet). Verified over AT-SPI on private buses: the
205 browser's address bar set from the reader — the bar unfolds, takes the keyboard and shows
206 it, the caret at its end — and the gallery's colour selector set to `#20c060`, its swatch
207 green, "not a colour" refused.
208 
209 Next: the macOS adapter onto the AppKit view, and listening with Orca.
210 
211 - **Wayland / Linux:** AT-SPI over D-Bus. AccessKit's Unix adapter is the likely carrier
212   (evaluate it first; the alternative is a small AT-SPI server of our own). The compositor
213   needs nothing new: AT-SPI is a session-bus protocol between the app and the reader.
214 - **macOS:** NSAccessibility through the same tree (AccessKit's macOS adapter, or direct).
215 - **Browser:** mirror the tree as hidden ARIA elements over the canvas — the same move the
216   IME already makes with its hidden `<textarea>`.
217 
218 ### Phase 3 — keyboard first
219 
220 **Progress (2026-10-08): the walk is on by default.** `Application::plate_navigation`
221 answers true. It changes nothing for an app without a `UiContext` (the walk finds no stops
222 and Tab reaches `handle_key_input` as before: the terminal, the browser, the calendar and
223 the other immediate-mode apps), so the apps it reached are the seven with a context that had
224 not opted in. Three give Tab a meaning of their own and opt out: the designer (the node
225 palette), the display manager (its username / password order) and cce-notes (accepting a
226 link completion). A focused widget that types Tab keeps it (`Input::keeps_tab`: a
227 multi-line `TextBox` while editing), and Ctrl+Tab still leaves it
228 (`tab_walks_the_stops_but_a_multi_line_box_keeps_it`). Checked in a shadow, two Tabs each:
229 cce-list, cce-weather, the demo and cce-relief ring their second stop; cce-fonts' first
230 two stops were its picker buttons, registered outside picker mode and never drawn, and are
231 now registered only in it; cce-text-editor has no stops and is unchanged.
232 
233 **A modal dialog and a radio group (2026-10-08).** `widget::Dialog` is a raised plate (the
234 menu's material) around a lasso of members the host lays out; opening it makes it modal
235 through the context (`UiContext::open_modal` / `close_modal`): the Tab walk is trapped among
236 its members, every widget outside reads as covered so neither a press nor a hover reaches
237 it, focus moves in and is given back on close, and a reader sees a modal `Dialog` node
238 holding the members. `widget::RadioGroup` is one Tab stop whose arrows move the choice;
239 each option is the check box's well at a full corner, the chosen one holding a lit bead.
240 To a reader it is a radio group of radio buttons, each clickable, which needed a way for a
241 widget to show parts of itself as nodes (`Input::a11y_items`, `a11y::A11yItem`, clicked
242 through `Input::a11y_select_item`). The demo's Options… dialog holds a radio group and
243 OK / Cancel; in a shadow: Tab cycles its three stops only, a click behind it does nothing,
244 Escape cancels, Space on OK keeps the choice; over AT-SPI the dialog holds the group's
245 three radio buttons, and a click on one chooses it. The tooltip that doubles as the
246 accessible description is not done: no tooltips yet, by decision.
247 
248 - `plate_navigation` defaults to true; an app that routes Tab itself (a terminal, a web
249   view) opts OUT.
250 - A modal dialog widget that traps focus, a tooltip that doubles as the accessible
251   description, a radio group.
252 
253 ### Phase 4 — text that is not left-to-right monospace
254 
255 **Progress (2026-10-08).** Carets, clicks and selections follow the text in either
256 direction, and a multiline `TextBox` wraps by shaped width:
257 - **`backend::text::shaped_run`** (`ShapingMeasure::shape`) is a line as an editor needs it:
258   the caret's x at every char boundary — a cluster's LEADING edge, a right-to-left
259   letter's right — the clusters in logical order with their boxes, the width, and the
260   paragraph's base direction, which cosmic-text already takes from the first strong
261   character. `index_at` is a click, `spans` a selection (two where it crosses a change of
262   direction). `ShapingMeasure::offsets` is its stops; `shaped_cluster_offsets`, what
263   hand-drawn fields (`LineEdit` hosts) read, gives leading edges too.
264 - **A fix underneath:** `normalized_glyph_starts` rebuilt glyph starts whenever they fell,
265   to repair cosmic-text 0.12's Basic shaping (span-relative starts) — but they also fall in
266   every right-to-left run, which it scrambled. Basic shaping is ASCII only, so the repair
267   now is too.
268 - **`DocEditor`**: runs carry their cluster boxes and the shaped width; a selection is the
269   boxes it covers (`LineLayout::selection_rects`), so it is drawn where its letters are.
270 - **`TextBox`**: its column offsets are the shaped stops (a right-to-left word is clicked and
271   selected where it is drawn), and a multiline box wraps by the summed shaped advances of
272   its chars against the width (`wrap_text(max_width)`, `wrap_width`), not a char count
273   against one monospace advance, so a proportional face and CJK wrap where they are drawn.
274   The advances are measured with the paint's font system in `prepare_text` and cached;
275   an edit that outruns the frame measures with the shared geometry font system, or a
276   thread's own when that one is held.
277 
278 **And the rest (the same day):**
279 - **A right-to-left paragraph is set against the right.** `backend::text::paragraph_rtl` is
280   a paragraph's base direction (`unicode-bidi`, already in the tree through cosmic-text).
281   In a `TextBox` the shift is folded into the offsets, so caret, click and selection
282   follow, and added to where the text is drawn — a one-line box's text when it fits, each
283   wrapped line of a multiline box by its paragraph's direction. The shaping key now
284   carries the room the text is aligned in, which also re-shapes a box whose width was set
285   after its first shaping: cce-text-editor's highlight had stopped short on a CJK line
286   because its offsets were never re-shaped once the box had its size.
287 - **The `DocEditor` draws a line's styled runs in visual order.** The line's bidi levels
288   (`bidi_levels`, neutrals resolved) split every run where the level changes, so a plain
289   run holding an English word and a Hebrew one becomes two, and each row's runs are placed
290   in the order rule L2 gives (`visual_order`); a plain or heading line of a right-to-left
291   paragraph is set against the right edge (a list, quote or table keeps its markers at the
292   left and only reorders).
293 - **A selection crossing a change of direction is drawn as its pieces** in a `TextBox` too,
294   one-line and multiline, from the cluster boxes of each line's shaped run.
295 
296 Tests: `carets_follow_the_text_in_either_direction`,
297 `right_to_left_words_are_laid_out_and_selected_where_they_are`,
298 `styled_runs_are_placed_in_visual_order`, `runs_are_reordered_as_the_bidi_algorithm_draws_them`,
299 `a_right_to_left_word_is_edited_where_it_is_drawn`,
300 `a_right_to_left_paragraph_is_set_against_the_right`, `a_multiline_box_wraps_where_its_text_is_wide`.
301 Checked in a shadow: cce-text-editor sets a Hebrew paragraph against the right with its
302 English and Arabic in bidi order. Still the shaping's own limit: a run's base direction is
303 cosmic-text's guess from the run's text, which a style split mid-paragraph can get wrong
304 for a run that starts with a neutral; arrow keys move the caret in logical order, as
305 editors on most platforms do.
306 
307 - Carets and selection from shaped clusters in VISUAL order (cosmic-text's layout runs
308   carry direction), not byte-sorted x positions: `ShapingMeasure::offsets` grows a
309   direction-aware form, and `DocEditor` and `LineEdit` use it.
310 - `TextBox` moves from its column model to shaped wrapping (cosmic-text's own wrap), which
311   also makes it correct for proportional fonts and CJK.
312 - Base direction per paragraph from the first strong character, as the Unicode
313   bidirectional algorithm does.
314 
315 ### Phase 5 — translatable strings
316 
317 **Done (2026-10-08).** `cce_core::l10n` (the `l10n` feature, off by default so the compositor
318 does not carry it; fluent-bundle and its seven crates) is a catalogue per domain in Project
319 Fluent's format: the domain's English built in (`Catalog::new(domain, include_str!(…))`),
320 a translation found on first use for the user's locale chain (`ja-JP`, then `ja`) as
321 `<tag>/<domain>.ftl` under `CCE_LOCALE_DIR`, `$XDG_DATA_HOME/cce/locale` and each
322 `$XDG_DATA_DIRS/*/cce/locale`, and a message looked up most specific first, then English,
323 then its own id (a gap shows). Placeables are not wrapped in isolation marks, which the
324 renderer would draw. A page reads no files (`Catalog::add_translation`); a suite reads no
325 directory.
326 
327 cce-ui's words are its `locale/en-US/cce-ui.ftl` (27 messages): the standard context menu
328 (Cut, Copy, Paste, Select All, Clear, Copy Path, the ramp's Collapse controls, the config
329 header's File / Key rows), the tree list's rows, the plate dock's, the search placeholder,
330 the copy and ramp-delete buttons, the keybind recorder's prompt and empty value, and the
331 `DocEditor`'s Properties table — through `crate::l10n::tr` / `tr_args`.
332 `every_message_the_toolkit_names_is_in_its_english` scans the source for every id looked up
333 and holds them to the file both ways. One identity-by-text went with it: the standard menu
334 knew a search box by its placeholder being "Search..."; a search box says so now
335 (`TextBox::with_search`), the English placeholder read only for the apps that set it
336 themselves. Checked in a shadow: the demo under `LANG=de_DE.UTF-8` with a German
337 `cce-ui.ftl` shows its text box's menu as Ausschneiden, Kopieren, Einfügen, Alles auswählen.
338 No translation ships yet; an app's own words are its own domain, adopted at its own pace.
339 
340 - The toolkit's ~20 strings through a small catalogue keyed by message ID, in `cce-core`
341   so services can use it too. Fluent is the candidate format.
342 - Apps adopt it at their own pace. Only phase 1's rule — actions keyed by ID, never by
343   label — has to come first.
344 
345 ## 4. Order and cost
346 
347 Phase 0 is an afternoon. Phase 1 is the real design work, and everything after it stands
348 on it; it is additive, and the tree costs nothing for an app nobody inspects. Phases 3 and
349 4 are independent of 1 and 2 and can go in parallel. Phase 5 waits on phase 1's ID rule.
350 
351 ## 5. Decision: AccessKit (2026-10-08)
352 
353 The tree is AccessKit's, and so are the platform adapters. AccessKit is a schema for an
354 accessibility tree (the `accesskit` crate: pure data, one required dependency, `uuid`, so it
355 builds for every target cce-ui does) plus adapters that publish it, each pushed to by the
356 toolkit. Phase 1 produces its `TreeUpdate` directly; there is no schema of our own to
357 translate.
358 
359 Why, against writing our own AT-SPI server:
360 - **It is the problem it was built for**: "toolkits that render their own user interface
361   elements". egui, Bevy, Slint, GPUI, Masonry/Xilem, Freya, Vizia, KAS and Servo use it.
362 - **Linux alone would be three crates' worth of work** (`accesskit_consumer`,
363   `accesskit_atspi_common`, `accesskit_unix`, ~115 KB compressed): the AT-SPI object
364   interfaces (accessible, component, action, value, text, editable text, selection, table),
365   their events and cache, tree diffing and text navigation.
366 - **macOS comes with it** (`accesskit_macos`, onto the AppKit shell's view). Our own server
367   would cover Linux only.
368 - **No cost without a screen reader**: `Adapter::update_if_active` builds nothing until an
369   assistive tool connects. Its handlers run on another thread, which `AppSender` already
370   serves.
371 - MIT or Apache-2.0, like this crate.
372 
373 What it costs, accepted:
374 - `accesskit_unix` needs zbus 5.19 or later (cce-ui has none today), so it goes in the
375   Wayland shell and behind a feature if its weight shows.
376 - Pre-1.0: its crates release together with breaking minor versions every few months.
377 - Editable text is exposed in its model (text runs with per-character positions), which
378   phase 4's direction-aware carets must feed.
379 
380 Unchanged by the choice: the browser still needs our hidden-ARIA mirror (AccessKit's web
381 adapter is planned, not released), and on Wayland a window's screen position is unknown to
382 any toolkit, so a screen reader's pointer features are approximate there for GTK as much as
383 for us.
384 
385 How it is proven: phase 1's tree, then `accesskit_unix` in the Wayland shell for one app
386 (cce-data-editor: plate navigation on, a tree list, text fields, a menubar), measured in
387 dependencies and frame time and listened to with Orca (`at-spi2-core` is installed; a
388 screen reader is not).
389 
390 ## 6. Open questions
391 - Should the compositor expose its own UI (window titles, overview, the grid) to AT-SPI?
392   It draws natively, so it would need its own tree.
393 - Do immediate-mode apps get the hook or get ported? The status bar and the notifier are
394   the most-used surfaces in the DE, and both are immediate.