| 1 | --- |
| 2 | name: accessibility |
| 3 | description: 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 | |
| 8 | The spine is how Snowbound, Clover's OneNote 2010 remake, became readable by |
| 9 | screen readers. Snowbound draws its whole interface with its own Rust UI kit |
| 10 | on the GPU, which is the hard case: nothing is accessible until you publish it. |
| 11 | It now reads on macOS, Windows, Linux, the web and iOS, and no agent ever |
| 12 | listened 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 | |
| 19 | The programmatic angle is this: a screen reader reads only the tree the app |
| 20 | publishes, and acts only through that tree's actions. So the tree is the |
| 21 | product, 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 | ``` |
| 39 | 1 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). |
| 42 | 2 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). |
| 44 | 3 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. |
| 46 | 4 Clover's rule went into memory: assert on the tree, never listen. |
| 47 | 5 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. |
| 50 | 6 Windows: accesskit_windows, its UIA tree read in a VM, with OneNote 2010's |
| 51 | own UIA tree as the reference. |
| 52 | 7 Web: AccessKit has no web adapter, so the app mirrors the tree as hidden |
| 53 | ARIA elements. |
| 54 | 8 iOS: UIKit owns the chrome, so it gets labels, traits and custom actions. |
| 55 | The page's bridge was deferred. |
| 56 | ``` |
| 57 | |
| 58 | The 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). |
| 61 | The code is |
| 62 | [`crates/ui/src/access.rs`](https://shale.paperclover.net/snowbound/tree/-/crates/ui/src/access.rs) |
| 63 | and |
| 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 | ``` |
| 69 | UI built from tree comes from you owe |
| 70 | native widgets (AppKit, UIKit, the toolkit labels, traits, custom actions, order |
| 71 | WinUI, GTK) |
| 72 | HTML the DOM, plus ARIA semantics, focus moves, live regions |
| 73 | a custom-drawn kit or canvas you, through AccessKit every node, state, action and update |
| 74 | a canvas inside native chrome both native outside, a bridge inside |
| 75 | ``` |
| 76 | |
| 77 | Every 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 |
| 79 | AccessKit trees. On the desktop, AccessKit adapts one tree to NSAccessibility, |
| 80 | UI Automation and AT-SPI; it supports Android too. The web build mirrors the |
| 81 | tree into the DOM (section 7). On iOS, UIKit draws everything around the page, |
| 82 | so 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 | ``` |
| 161 | Tab / Shift-Tab next control. A toolbar, tab list, tree or radio group is one |
| 162 | stop, entered at its selected control |
| 163 | arrows, Home/End move within that group, wrapping |
| 164 | F6 cycle between groups and the page, as Windows and GTK cycle |
| 165 | panes; on macOS, Control-F5 jumps to the toolbar |
| 166 | Space / Enter press |
| 167 | Escape close the popup, or return focus to where the keyboard took it from |
| 168 | focus 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 | |
| 238 | 1. **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 | ``` |
| 248 | 2. **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. |
| 251 | 3. **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. |
| 260 | 4. **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. |
| 265 | 5. **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`. |
| 271 | 6. **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. |
| 274 | 7. **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 | |
| 279 | Run 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 | ``` |