1---
2name: accessibility
3description: Make an app work by keyboard and assistive technology, and prove it without ever turning a screen reader on. Covers accessibility trees (AccessKit, macOS AX, Windows UI Automation, AT-SPI, the browser's), roles, names, states and actions, focus and keyboard paths, custom-drawn UI kits and canvas text, web semantics and ARIA, reduced motion, contrast, zoom and text scaling, and headless tests that assert on and drive the tree. Use this whenever you build or change a UI control, custom widget, popup, dialog, menu, or any focus or keyboard behaviour; when you draw UI yourself on a canvas or GPU; when testing, auditing or reviewing accessibility; and whenever someone mentions screen readers, VoiceOver, NVDA, Narrator, Orca, TalkBack, a11y, ARIA or WCAG.
4---
5
6# Accessibility
7
8The spine is how Snowbound, Clover's OneNote 2010 remake, became readable by
9screen readers. Snowbound draws its whole interface with its own Rust UI kit
10on the GPU, which is the hard case: nothing is accessible until you publish it.
11It now reads on macOS, Windows, Linux, the web and iOS, and no agent ever
12listened to a screen reader. The quotes are Clover's.
13
14> "when codex was doing the initial UI, it was trying to test it by turning
15> voiceover on my entire machine, but then couldnt even hear anything and was
16> asking me if i had heard any of its things. lol. there's gotta be a
17> programatic angle for this." — Clover, Snowbound
18
19The programmatic angle is this: a screen reader reads only the tree the app
20publishes, and acts only through that tree's actions. So the tree is the
21product, and asserting on it is the test.
22
23## 1. The tree is the test
24
25- **Never turn on VoiceOver, Narrator, NVDA, Orca or TalkBack on the owner's
26 machine**, and never ask the owner what they heard. A screen reader is
27 system-wide, takes over their keyboard and audio, and you can't hear it.
28- **Test what the screen reader would read and do.** That means roles, names,
29 values, states, focus, actions and bounds. Snapshot the tree in unit tests,
30 dump it from the running app, and drive the UI through its actions
31 (section 8).
32- **Build new accessibility work test-first against the tree.**
33- **A listening pass is a person's job, at the owner's say-so.** It adds to
34 the tree tests. It never gates other work.
35
36## 2. How Snowbound got there
37
38```
391 The page already published an AccessKit tree. An agent building the first UI
40 tested its accessibility with VoiceOver on across Clover's machine (the
41 quote above).
422 Rebuilding the page's tree cost about 12 ms per keystroke or scroll step on
43 large pages. Incremental updates cut a keystroke to 0.25 ms (section 5).
443 A wrapped right-to-left word broke the tree's caret mapping. That update ran
45 before saving in the same frame, so the edit wasn't saved.
464 Clover's rule went into memory: assert on the tree, never listen.
475 Issue #20: the kit's own tree (toolbar, menus, dialogs, palette, tabs), with
48 the page grafted in. Then keyboard paths, the `accessibility PATH` replay
49 dump and snapshot tests, followed by three rounds of fixes.
506 Windows: accesskit_windows, its UIA tree read in a VM, with OneNote 2010's
51 own UIA tree as the reference.
527 Web: AccessKit has no web adapter, so the app mirrors the tree as hidden
53 ARIA elements.
548 iOS: UIKit owns the chrome, so it gets labels, traits and custom actions.
55 The page's bridge was deferred.
56```
57
58The design is in
59[arc/ui.md](https://shale.paperclover.net/snowbound/tree/-/arc/ui.md) and
60[arc/canvas.md](https://shale.paperclover.net/snowbound/tree/-/arc/canvas.md).
61The code is
62[`crates/ui/src/access.rs`](https://shale.paperclover.net/snowbound/tree/-/crates/ui/src/access.rs)
63and
64[`crates/canvas/src/interaction/accessibility.rs`](https://shale.paperclover.net/snowbound/tree/-/crates/canvas/src/interaction/accessibility.rs).
65
66## 3. Pick who owns the tree
67
68```
69UI built from tree comes from you owe
70native widgets (AppKit, UIKit, the toolkit labels, traits, custom actions, order
71 WinUI, GTK)
72HTML the DOM, plus ARIA semantics, focus moves, live regions
73a custom-drawn kit or canvas you, through AccessKit every node, state, action and update
74a canvas inside native chrome both native outside, a bridge inside
75```
76
77Every platform control you replace with a drawn one becomes a node you now owe
78(`ui-craft` §2, native first). Snowbound's desktop and web builds both build
79AccessKit trees. On the desktop, AccessKit adapts one tree to NSAccessibility,
80UI Automation and AT-SPI; it supports Android too. The web build mirrors the
81tree into the DOM (section 7). On iOS, UIKit draws everything around the page,
82so most of the app is accessible natively.
83
84## 4. A tree for a custom-drawn UI
85
86- **Build the tree from the boxes the frame is built from.** A box with a role
87 is a node. A box without one passes its children through, and its text reads
88 as a label. With one source, the tree can't drift from the pixels.
89- **Names come from what a sighted user reads:** the control's text, its
90 children's text, or its tooltip. The tooltip's title becomes the name, its
91 chord the keyboard shortcut, and its description the description. An icon
92 button takes its command's title. For the words, see `ui-copy`'s
93 accessibility floor.
94- **Give every name a single owner within its group.** "Font Color" appeared
95 twice, once on the button and once on its arrow. Snowbound now calls the
96 arrow "Font Color Options". OneNote 2010's own UIA tree instead nests a
97 `Button` and a `MenuItem`, both named "Font Color", inside a `SplitButton`.
98 AccessKit has no split-button role. Which convention to keep is the owner's
99 call. A test checks every toolbar control for a non-empty, unique name at
100 every width the toolbar folds to.
101- **Expose every state the kind of control has.** Toggles were marked toggled
102 only while lit, so an unpressed Bold read as a plain button and changed role
103 when pressed. A toggle now always says on or off. Anything that opens a popup
104 says expanded or collapsed. A disabled control keeps its name and value but
105 loses its click and focus actions.
106- **Describe things as what they are, not how they're drawn.** A "›" on a menu
107 row was announced as a keyboard shortcut; it's now a has-popup state. Colour
108 swatches were read out as hex; they now carry Office's colour names.
109- **Popups are menus and dialogs.** Opening one moves the focus into it, and
110 closing it gives the focus back. A menu's highlighted row is the focus, so
111 arrowing reads each row. The command palette is a dialog: its field keeps
112 the focus while the list's selection moves. Command menus open at the top
113 with nothing highlighted. Value pickers (fonts, sizes) open on the current
114 value.
115- **Actions are input.** A Click, Focus or SetValue from assistive technology
116 arrives as an event and is answered on the next frame, exactly as a click
117 would be. Pointer, keyboard and screen reader all take one code path.
118- **Read in visual order.** Snowbound paints the open tab over the others, so
119 paint order isn't reading order. It sorts a group's children by position.
120- **Keep node ids stable, and give each node one parent.** A split button's
121 popup reused its arrow's id, which gave one node two parents. Skip a box
122 built twice in one frame.
123- **Send only what changed, and only while something listens**
124 (`update_if_active`). Send the whole tree on `InitialTreeRequested`, forget
125 it on `AccessibilityDeactivated`, and send nothing for a caret blink.
126- **Answer a tree request that arrives before the first frame** with the bare
127 window. The web build panicked here once screen-reader support was saved as
128 on.
129- **Graft subtrees carefully.** The page is its own tree, held at the page's
130 box by a tree id:
131 - send the holding tree first;
132 - focus the graft only once its subtree exists, or AccessKit crashes;
133 - build that box's id only as the graft. A "No sections" notice once reused
134 it and produced a graft with no tree.
135- **A failing tree update must never block saving.** Save first, then update
136 the input method and accessibility. If the update fails, send a minimal tree
137 and deactivate.
138
139## 5. Text drawn on a canvas
140
141- **Publish each editable region as a multiline text input** whose runs carry
142 the canvas's real line boxes and character positions. Then the screen
143 reader's caret and selection land where the eye does, including affinity at
144 wraps and bidirectional text.
145- **Route the screen reader's edits through the editor.** Selection,
146 replacement and set-value all go into the editor's history, so undo works on
147 them.
148- **Make updates cost what changed.** The page node holds the viewport
149 transform, so a scroll or zoom resends two nodes. Each paragraph sits in a
150 translated container, so reflow above it moves one node, and a keystroke
151 sends only its paragraph. On 5,000 paragraphs a keystroke went from 7.4 ms
152 to 0.25 ms. A test replays 160 steps and checks that the incremental tree
153 equals a fresh build.
154- **Sweep the position mapping over a real corpus.** Select-all over every
155 outline in the corpus found 125 failures. All of them were one shape: a
156 wrapped right-to-left word whose caret landed on the line above.
157
158## 6. Keyboard and focus
159
160```
161Tab / Shift-Tab next control. A toolbar, tab list, tree or radio group is one
162 stop, entered at its selected control
163arrows, Home/End move within that group, wrapping
164F6 cycle between groups and the page, as Windows and GTK cycle
165 panes; on macOS, Control-F5 jumps to the toolbar
166Space / Enter press
167Escape close the popup, or return focus to where the keyboard took it from
168focus ring drawn after keyboard focus until the pointer is next used
169```
170
171- **These are the WAI-ARIA APG patterns** for
172 [toolbar](https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/),
173 [menu](https://www.w3.org/WAI/ARIA/apg/patterns/menubar/) and
174 [modal dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/).
175 Follow them on every platform.
176- **An editor that keeps Tab for itself (to indent) needs a documented way
177 out.** Snowbound's is F6. Without one you have a keyboard trap
178 ([WCAG 2.1.2](https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap)).
179- **Diff the tree around every key.** The first diffs caught a real bug:
180 focusing a toolbar button disabled Paste and Undo, because the commands
181 treated any focus as a text field.
182
183## 7. Web and general rules
184
185- **Use semantic HTML first:** `button`, `a href`, `label for`, `fieldset`,
186 `dialog`, headings, landmarks, lists and tables. Add ARIA only where no
187 element fits ([first rule of ARIA
188 use](https://www.w3.org/TR/using-aria/#rule1)). The APG warns that "No ARIA
189 is better than bad ARIA".
190- **Manage focus.** Open modals with `showModal()` or make the background
191 `inert`. Return focus to whatever opened the modal. After a route change,
192 move focus to the new heading. Never move focus or change context merely on
193 focus ([3.2.1](https://www.w3.org/WAI/WCAG22/Understanding/on-focus)).
194- **Show focus** ([2.4.7](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible)),
195 and keep it out from under sticky headers
196 ([2.4.11](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum)).
197- **Announce status messages through a live region**
198 ([4.1.3](https://www.w3.org/WAI/WCAG22/Understanding/status-messages)). On
199 iOS, post an announcement, as Snowbound's sync toast does.
200- **Reduce motion when asked.** Swap movement for an instant change or a fade
201 under `prefers-reduced-motion`, or under the platform setting:
202 `NSWorkspace.accessibilityDisplayShouldReduceMotion`,
203 `UIAccessibility.isReduceMotionEnabled` or Windows' "Show animations"
204 ([2.3.3](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions)).
205- **Meet contrast:** 4.5:1 for text and 3:1 for large text
206 ([1.4.3](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum)), and
207 3:1 for control edges and focus rings
208 ([1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast)).
209 Check each theme separately, and check `forced-colors`.
210- **Let text scale and reflow.** Text must work at 200%
211 ([1.4.4](https://www.w3.org/WAI/WCAG22/Understanding/resize-text)) and
212 reflow at 320 CSS px
213 ([1.4.10](https://www.w3.org/WAI/WCAG22/Understanding/reflow)). On iOS use
214 Dynamic Type: `preferredFont` with `adjustsFontForContentSizeCategory`.
215- **Make targets at least 24×24 CSS px**
216 ([2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum)).
217- **Give every drag a non-drag alternative**
218 ([2.5.7](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements)).
219 Snowbound's iOS reordering offers Move Up, Move Down, Make Subpage, Promote
220 Subpage and Move to Section as VoiceOver custom actions.
221- **A canvas app in the browser mirrors its tree into the DOM.** Snowbound
222 does it this way
223 ([`glue.js`](https://shale.paperclover.net/snowbound/tree/-/crates/snowbound/web/glue.js)):
224 - each AccessKit node becomes a visually hidden element with an ARIA role;
225 - focus is `aria-activedescendant` on the input textarea;
226 - the mirror starts when a visually hidden "Turn on screen reader support"
227 button is pressed, and stays on for later visits.
228
229 Three bugs crossed the bridge, each fixed:
230 - 64-bit node ids lost precision as JavaScript numbers, so ids now cross as
231 decimal strings;
232 - `aria-disabled` now always says `true` or `false`;
233 - a mirrored click bubbled to the ancestor nodes and dismissed the menu
234 before its command ran, so the handler now stops propagation.
235
236## 8. Testing without a screen reader
237
2381. **Unit-test snapshots.** Build frames headlessly, then feed the update to
239 `accesskit_consumer`, which shows the tree as the platform sees it once
240 generic containers are filtered. Print a line per node and assert the exact
241 text
242 ([tests](https://shale.paperclover.net/snowbound/tree/-/crates/ui/src/access/tests.rs)):
243 ```
244 Toolbar
245 Button "Bold" [toggled] <⌘B> -- Makes the selected text bold. {click focus}
246 ComboBox "Font" = "Calibri" [collapsed] {click focus}
247 ```
2482. **Drive the UI through the tree.** Send the `ActionRequest`s a screen reader
249 would (Click, Focus, SetValue) and assert the outcome. That's how the
250 ancestor-click bug showed up on the web.
2513. **Dump the real app.** Run it in a hidden window from a replay script. An
252 `accessibility PATH` step writes the window's whole tree, page included,
253 once the app settles. A cargo test
254 ([`replay.rs`](https://shale.paperclover.net/snowbound/tree/-/crates/snowbound/tests/replay.rs))
255 runs the real binary on copies of corpus notebooks with a scratch `HOME`,
256 then asserts on the text. Diff the dumps between steps. The dumps also make
257 a refactor oracle: a simplification pass proved "no behaviour change"
258 because the trees were identical, and the renderer switch test checks that
259 the tree survives ten live switches.
2604. **Settle, don't sleep.** A settle step waits until nothing is on its way
261 (loads, spawned work, search jobs). It then sends a marker through the
262 event loop, and it answers only when the app is still quiet once that
263 marker arrives. Fixed waits flaked under load. A window-system resize is
264 the one place that still needs a wait.
2655. **Read the platform's tree in a VM.** A hidden window's macOS AX hierarchy
266 shows only the menu bar, so the platform tree needs a visible window. Open
267 that window in a disposable VM, never on the owner's screen. Read the
268 reference app's tree there too: OneNote 2010's UIA tree settled the
269 split-button naming question. The recipes are in
270 `references/tree-readers.md`.
2716. **Time the tree.** Measure update cost on the largest realistic document
272 with a client attached. A tree that's slow only when someone listens is
273 still a dropped frame for them.
2747. **Run rule checkers too.** On the web, axe-core catches missing names and
275 low contrast. It can't prove keyboard paths, focus moves or reading order.
276
277## 9. Checklist
278
279Run this for every control you add or change:
280
281```
282[ ] role fits what it does; container roles only hold controls
283[ ] name is what a sighted user reads, unique among its siblings; icon-only controls are named (ui-copy)
284[ ] value and every state its kind has: toggled off, collapsed, selected, disabled
285[ ] pointer, keyboard and assistive-technology actions run one code path
286[ ] reachable by Tab, or by arrows within its group; focus ring shows; Escape backs out
287[ ] a popup takes the focus and gives it back; no keyboard trap
288[ ] every drag has an alternative; targets are at least 24 px
289[ ] status changes are announced, not only drawn
290[ ] contrast passes in each theme; reduced motion is honoured; text scales to 200%
291[ ] the tree snapshot test is updated, and the real app's dump is diffed around the change
292[ ] tree update cost measured on the biggest document, sent only while a client listens
293```