Accessibility: an AT-SPI2 bridge with no mirror, and where it lives
Design record for the accessibility layer that shipped in core — the why behind accessibility.md, which is the reference for what the API is. Written as built, not as proposed: the alternatives below were live options at the time and the reasons they lost are the useful part.
Checked at react-x11 a937436 (master, 2026-08-07), ntk 7.2.0,
dbus-native 0.15.1, against at-spi2-core 2.60.4 (the installed
introspection data) and at-spi2-core's xml/ interface definitions (the
wire spec). Verified live against this desktop's registry with libatspi
(python3 gi.repository.Atspi) and with Orca's own speech log. File:line
references drift with the code.
0. TL;DR
- Linux accessibility is D-Bus, not X. An app exposes a tree of objects
implementing
org.a11y.atspi.*on a dedicated accessibility bus and registers with the AT-SPI registry daemon; Orca walks that. Being a pure-JS X11 client blocks nothing. - Two modules.
src/a11y.jsis the model: pure functions from the retained node tree to roles/names/states, plus null hook slots the renderer polls (the trace-registry pattern — one property read per event when off).src/atspi.jsis the bridge: D-Bus plumbing, dynamically imported once per process when a root exists. The model is unit-testable with no bus anywhere; the bridge is testable against an in-process bus. - No mirror tree. Every AT call is answered from the live node tree at the moment it arrives. The only per-node bridge state is the object path, the exported interface list, and a snapshot of what the bus was last told — kept solely to turn "something changed" into the precise AT-SPI event diff.
- The vocabulary is the web's (
role,aria-*), not React Native'saccessibilityRolefamily that NEXT_STEPS §11.3 originally recommended. RN itself adopted the ARIA names in 0.71, and the widgets here had been carrying web role strings for months. Role→AT-SPI mappings follow what Chromium and Firefox send on Linux, so Orca sees from a react-x11 app what it sees from a browser. - In core, not
@react-x11/a11y. The dependency argument for a sibling package dissolved when D-Bus landed in core as anoptionalDependency; what remained was a peer-version matrix and an opt-in accessibility layer, which is an accessibility failure. - Off is free and silent. A four-rung ladder (transport → session bus →
GetAddress→ connect+Embed), every rung failing into "off" with no log and no mode; the bridge's socket isunref()d so it never holds the process open. Discovery dials a private, short-lived connection — deliberately not the sharedsessionBus()— see §7.
1. The constraints that shaped it
Three personas had veto power:
- The machine with no accessibility stack — ssh -X, CI, containers,
macOS/XQuartz, bare
startx. react-x11's flagship configurations. The layer must cost them nothing: no import weight, no connection attempts that log, no event-loop hold, no new mode. This is the same rule dbus.md established: "no bus" is a first-class configuration. - The screen-reader user on a desktop. For them the tree has to be correct, not merely present: states that match the pixels, focus events at the moment focus moves, text deltas as they type, and controls that can actually be driven. Half an implementation — a tree with stale states — is worse than none, because the user cannot tell which half to trust.
- The component-library author targeting react-x11. They must not need a react-x11-specific integration. Standard props on host elements had to be the entire seam.
And one engineering constraint: the renderer's hot paths (focus moves,
every applyProps, every text repaint) get touched. The cost of
accessibility being off had to be one nullable property read per site,
which is the same bar trace-registry.js set for the protocol tracer.
2. Where accessibility actually happens
The at-spi2-core xml/ directory is the wire spec, and the shape is:
- A separate bus, not the session bus.
org.a11y.Bus.GetAddresson the session bus answers its address (activatingat-spi-bus-launcheron demand);AT_SPI_BUS_ADDRESSoverrides. - The app exports objects at
/org/a11y/atspi/accessible/<id>implementingAccessible(tree + role + states) and per-capability interfaces:Component(geometry, hit test, focus),Action,Value,Text/EditableText. The app root at…/accessible/rootaddsApplicationand callsorg.a11y.atspi.Socket.Embedon the registry, which replies with the desktop ref the root reports as itsParent. - ATs bulk-load through
org.a11y.atspi.Cache.GetItems(one struct per node) and then track signals:org.a11y.atspi.Event.Object.*(StateChanged,ChildrenChanged,TextChanged,TextCaretMoved,PropertyChange,Announcement, …) andEvent.Window.*(Activate/Deactivate/Create/Destroy), all with the samesiiva{sv}body. Orca's model of the app is built almost entirely from these; getting their details right (state nicks like"focused", insert/delete offsets in code points) is most of the work. - Toolkit-less apps are expected to drive this themselves rather than
inherit it from GTK. That is what
src/atspi.jsis.
Enum values (roles, states, relations) were generated from the installed
gi.repository.Atspi — the very tables Orca reads — and are append-only
upstream, so they are pasted into a11y.js as frozen data with the
regeneration one-liner in a comment, not fetched or computed.
3. The two-module split, and the hook-slot seam
nodes.js / events.js / Reconciler.js (hot paths)
│ a11yHooks.focus?.(…) — null when off
▼
src/a11y.js the model: role/name/states/tree-projection as pure
functions; the hook slots; startA11y() gate
│ dynamic import, once, when a root exists and the gate passes
▼
src/atspi.js the bridge: bus dial, Embed, per-node export, the
event queue/flush, the interface implementations
The precedent is trace-registry.js: an always-imported module whose whole
idle cost is null slots, with the heavy half behind a dynamic import. Two
consequences fall out of the split being exactly at the D-Bus line:
- Everything semantically interesting — what role a node has, what its name
resolves to, which states are set, what the accessible tree looks like
after pruning — is computable and testable with no bus in the
process (
test/a11y.test.js). The bridge test then only has to prove plumbing, not semantics. - The bridge can be rewritten (Wayland's a11y future, a different transport) without touching the model or the renderer wiring.
The renderer chokepoints wired to slots: insertBefore/removeChild
(tree), applyProps (props), TextChunkNode.setText (label content),
TextInputNode._repaint (value/caret/selection — every edit path funnels
through it), EventManager.focus (the one place focus moves),
_onWindowFocus (WM focus), appendChildToContainer/
removeChildFromContainer (toplevels), resetAfterCommit (flush). Focus
and focusability deserve a note: the focusable rule used to exist twice
(EventManager._isFocusable and Node._focusableForRing); it now lives
once in a11y.js and both call it, so the keyboard, the focus ring and the
FOCUSABLE state cannot disagree by construction.
4. No mirror
The obvious design — GTK's, historically — is a shadow "accessible object" per widget, kept in sync. The whole class of bugs that produces (stale name, stale index, remove-before-add races) comes from the mirror being a second copy of the truth.
Here there is no copy. GetChildren walks the live node.children through
the projection (§5) at call time; GetState recomputes from live props and
live focus; GetExtents reads node.abs and the window's live
_screenOrigin. An answer cannot be stale because nothing is cached to go
stale.
What is kept per exported node — { id, ifaces, snapshot } — exists for
one reason: events are diffs, and a diff needs a "before". The snapshot
holds the last name/description/states/value/text the bus was told.
syncNode() recomputes, XORs the two u32 state words, and emits one
StateChanged per flipped bit with its enum nick; _diffText runs a
common-prefix/suffix diff over the code-point arrays and emits the
delete+insert pair with exact offsets. This is also what makes the
wiring correct without per-prop plumbing: any prop change queues the
node, and whatever actually changed is what gets emitted.
Object export is lazy: a node is exported the first time a ref to it is
handed out (refFor), which transitively means "the first time an AT could
possibly call back on it". Per (node, interface) the impl object is
{ __proto__: sharedProto, b: bridge, n: node } — the prototypes hold all
behaviour, dbus-native reads properties through the chain, and the per-node
cost is a couple of two-field objects. Signals are emitted through raw
bus.sendSignal rather than dbus-native's EventEmitter hook-up, so impls
need no emitter machinery. On detach, the subtree is walked, each exported
node unexported with a Cache.RemoveAccessible, and the parent gets one
ChildrenChanged remove — but only if the AT had ever seen the child;
what the bus was never told about, it does not need to hear removed.
5. The tree projection
The accessible tree is the node tree through three rules:
- Pruned subtrees:
aria-hidden, the internal content kinds (textchunk,svgchild),<text>spans (the outer<text>speaks for the run), and everything under<glarea>(scene nodes have no rectangles). Gone entirely. - Erased nodes:
role="none"/"presentation"— the node vanishes and its children take its place, ARIA's semantics. This is what keeps a styling wrapper from adding a filler level around every widget. - Defaults:
<box>is FILLER — the role GTK gives its own layout containers and screen readers step over silently — so an unlabelled tree is boring rather than noisy. Everything with real semantics defaults to them (window→FRAME, text→LABEL, textinput→ENTRY, image→IMAGE, …).
…plus a fourth source of children that is not in the node tree at all: what a registered element says it drew (§8b), appended after whatever it holds retained.
Two structural notes. <popup>s stay nested: a popup's parent is the
JSX node it was written under, so a menu appears inside the widget that
opened it — GTK4 does the same with popovers, and it preserves context that
promoting popups to application children would lose. Screen coordinates
still resolve correctly because extents go through the owning window's
_screenOrigin, and the popup is its own window. And name-from-contents
(button/menuitem/tab/… with no aria-label) concatenates the subtree's
chunk text — which is why MenuRow sets aria-label explicitly: without
it the shortcut column and the submenu arrow would read into the name
("New Ctrl+N ▸").
6. Events: queue, collapse, flush
Hook calls do not emit; they queue. The flush runs at resetAfterCommit
(so a commit's worth of mutations is one batch) and, for changes that
happen outside commits — pointer-driven focus, typing into an uncontrolled
input — on a queueMicrotask fallback. Collapse rules:
- Removals first, so a node that left and rejoined in one batch reads as its add, never as a stale remove.
- Attaches collapse to the topmost node per subtree: the AT gets one
ChildrenChanged add+Cache.AddAccessibleand descends on its own. The initial bottom-up construction of a tree costs nothing — a detached subtree is not "live" (§3's_livewalk) until its top joins a mounted toplevel, so React building a window queues zero events until the container mount, which then announces exactly one child. - Focus emits
StateChanged focusedon both ends plus the legacyEvent.Focus.Focuson the gaining node, because that is what the GTK bridge emits and older ATs still listen for the pair.
7. The ladder, and why discovery dials its own socket
startA11y() (called from createRoot, never awaited): gate →
import('./atspi.js') → transport → session-bus address → GetAddress →
connect → export root + Cache → Embed → install hooks → walk
already-mounted toplevels (via trace-registry.onApp). Every failure is a
silent off; REACT_X11_A11Y=1 makes each rung print why it stopped, which
is the entire debugging story for "Orca doesn't see my app".
The one non-obvious decision: the GetAddress probe uses a private
connection, opened and closed inside the call, not the shared
sessionBus(). The shared machinery is process-global state, and the
climb is an unowned background task — the combination raced this repo's own
test harness, which swaps DBUS_SESSION_BUS_ADDRESS to a per-test broker
and hard-resets the shared state between cases; an in-flight climb from an
earlier test corrupted a later test's portal reads. The fix is not "be
careful", it is no shared state: a probe that owns its socket cannot
interfere with anyone, and as a bonus the shared session connection is no
longer dialled (and kept warm) as the side effect of a probe on machines
where nothing else wants D-Bus.
Related hygiene, borrowed from the ecosystem: NO_AT_BRIDGE is honoured —
it is what at-spi2-atk checks and what GTK's own test suite sets — and
react-x11/test sets it, so a suite running on a developer's desktop does
not parade hundreds of phantom applications through a live screen reader.
Priority order: REACT_X11_A11Y (explicit, either way) →
AT_SPI_BUS_ADDRESS (an address is intent — it is also the hermetic test
seam) → NO_AT_BRIDGE → on.
The bridge holds no event-loop reference (stream.unref()): it is a
passenger, not cargo, and a process with no other work exits — the
registry sees the name drop, which is AT-SPI's own liveness model. A dead
accessibility bus is not redialled, the same no-resurrection contract as
bus.js; the registry restarting is survivable though — the bridge
watches NameOwnerChanged on org.a11y.atspi.Registry and re-embeds,
exactly as the GTK bridge does.
8. AT-driven input goes through the front door
- Activation (
Action.DoAction("activate")) dispatches a synthetic full press gesture — MouseDown, MouseUp, Click — throughEventManager.dispatchat discrete priority with the frame flush, so an AT press is indistinguishable from a finger: capture/bubble order,:activestyling, the commit, the paint. The full gesture rather than a bare Click was a live-testing find:SelectandMenuBaropen their menus on the press, as real menus do, and a Click-only activation silently did nothing on exactly those controls. Coordinate-driven default actions (caret placement, drag arming) are deliberately not invoked — a synthesized centre point is not a place the user chose. - Value writes (
Value.CurrentValueset) route to the node'sonAccessibilityAction({ action: 'setValue', value }), wrapped incallHandlerso a throwing handler cannot unwind into dbus-native's message dispatch.Sliderwires it into the same clamp/quantize/emit as the pointer and the keyboard — one state path, three input routes. - Edits (
EditableText) go throughTextInputNode._commit, the same single mutation point typing uses, soonChangefires and undo history records — an AT edit is a user edit. Offsets need no conversion anywhere: AT-SPI counts code points, and_chars()/_caretalready do. Every write is expressed as one replacement —[start, end) := text— because that is the primitive both implementations can offer: the built-ins splice and_commit, and a registered element implementsa11yReplaceText, which its own editing needs anyway. Set-the-lot, insert and delete are that call with one end moved.
8a. Text from elements core did not write (#257)
A registered element draws text core cannot see: <textinput>'s state is
read off _chars()/_caret/_selection, which a <codeeditor> in
another package does not have. The seam is a pull — a11yTextState()
answers { value, caret, selectionStart, selectionEnd, editable, multiline, preedit } — plus a push that says it is worth pulling,
Node.notifyA11yTextChanged(), which is the same hooks.textState slot
_repaint already fed. Three consequences make it the cheap shape:
- One normalizer, no second model.
customTextState()turns the element's answer into the{ chars, caret, selection, preedit }the bridge and the spy already speak, clamping the offsets on the way — so granularity, attribute runs, the diff,:systemcomposition marking and the spy's transcript are the same code paths<textinput>uses. A third-party editor cannot be read differently from core's, only less. - Two tiers, because viewers are not degenerate editors.
editablesplits them: without it an element isTextand adocumentrole, which is exactly the markdown/code-block/terminal case (selection, Ctrl+C, no caret semantics); with it, EDITABLE, anentryrole, and — only ifa11yReplaceTextexists — theEditableTextinterface. Exporting an interface whose every method answered false would be a lie an AT has no way to test. - Declared, not inferred.
a11yRoleis the element's own default role (below aroleprop, above the scroller rule, so an editor that scrolls is still an entry), andmultilineis a claim rather than a guess off the value — inferring it from a\nwould report the element's shape changing the first time somebody pressed Enter, the same objection that keeps the scroll-pane role offisScroller()flicker.
Deliberately not in the seam: per-character extents (they need a layout only the element has; the fallback is the element's own rect, which is honest and keeps a magnifier tracking something real) and AT-driven copy/paste, which stay the built-ins' clipboard round trip.
8b. Structure from elements core did not write (#304)
The same gap one level up. An element that draws N interactive things is
one node and therefore one accessible — a graph pane reads as "group" with
nothing inside — and unlike the text case there is no prop that could have
said otherwise, because the objects an AT needs do not exist anywhere.
a11yScene() is the pull that produces them: [{ id, rect, role, name, states, props, children }], with Node.notifyA11ySceneChanged() as the
push, routed through the existing hooks.propsChanged slot because
"re-read this node's accessible facts and announce the difference" is
exactly what that slot already means.
Four decisions, in the order they bite:
- Objects, not descriptors. Everything else here is answered from the
live tree (§4), and a scene has no live tree to answer from — the
element rebuilds its description every frame. So a11y.js reconciles the
frame's descriptions against the last frame's by
idand returns the same objects where the id survived. That is what an export can be keyed on: paths, snapshots and every ref an AT is holding stay valid across a scene rebuilt sixty times a second, and an id that disappears is marked defunct rather than forgotten, so the bridge can still walk what it is losing. Identity is the one thing core cannot derive, which is whyidis the only required field besides the rect. - A scene item is read by the model, not beside it. It carries
propsin the samerole/aria-*vocabulary and anabsrect in the same window coordinates, soatspiRoleOf,a11yName,a11yStates,a11yAttributes, the extents, the cache items, the snapshot diff and the test spy are all the code that already existed — the same "no second model" rule §8a's normalizer follows. The friendlystates: { selected, checked, expanded, disabled, busy }is a table of five entries into those props;focusedis the exception, because element-internal focus never reaches a window's focus manager and there is no ARIA spelling for it. - Actions fall back rather than fail.
a11ySceneAction(id, action)claims withtrue; anything else takes core's answer — foractivate, a synthetic click through the ordinary dispatch at the item's own rect. An element hit-tests its scene already, so that is not an approximation of the user's click, it is it. A single multiplexed hook (rather than one method per action) is what makes claimingfocusand leavingactivateto core the default reading of the code rather than a trap. - An action is offered where one lands. The role table decides as it
does everywhere else, plus: an element that implements the seam makes
every item it drew activatable, because implementing it is the same
promise a handler is — and the roles a scene reaches for (
listitemfor a graph node) are mostly ones ARIA promises nothing about.
Deferred with it: hit testing descends into items (GetAccessibleAtPoint
answers with the item under the point, which is what a magnifier following
the pointer needs), but the testing queries still walk retained nodes
only, so getByRole cannot find a drawn item — the same shape as #260,
and it wants that issue's answer rather than a second one here.
9. Testing
Four layers, each catching what the one below cannot:
- Model (
test/a11y.test.js) — no bus: roles, names, states, projection, widget wiring, over the mock app. - The spy (
src/testing/a11y.js,test/a11y-spy.test.js, and the layer applications are meant to use viarenderX11({ a11y: true })) — the hook slots are the seam the bridge itself consumes, so a spy filling the same slots observes the same contract with no transport: synchronous, bus-free, Node-20-and-macOS-safe. It reuses the bridge's own snapshot-and-diff idea in miniature, and it doubles as a renderer regression net — a chokepoint that stops calling its hook fails these tests exactly as it would silence Orca. The utterance strings it (andscripts/a11y-probe.mjs) produce come from one sharedutteranceOf, deliberately documented as react-x11's model rather than an imitation of Orca's wording, which is presentation policy that shifts between releases. - Hermetic bridge (
test/atspi.test.js) — dbus-native'screateBroker()is a real in-process message bus (routing, names, match rules); a stub client ownsorg.a11y.atspi.Registryand answersEmbed;AT_SPI_BUS_ADDRESSpoints the bridge at it, so everything from the address onward is the production path. A second client plays the screen reader with rawinvokecalls and a signal tap, asserting wire-level facts: struct shapes, state bits, text-diff offsets, the press gesture, cache items. Skips itself on Node 20 where the transport legitimately is not installed. - Live — not automated, but scripted and repeatable:
scripts/a11y-probe.mjsis the AT side of the wire in dbus-native (tree dumps, an event tail,--speakthrough speech-dispatcher), and python3gi.repository.Atspiis libatspi, Orca's own client stack —get_desktop(0)→ find the app → walk/do_action/set_current_valueexercises the real registry, withorca --debug-file=…grepped forSPEECH OUTPUTas ground truth. Those runs confirmed the whole chain ("Press me — button", "check box checked", "horizontal slider — 80 percent", menus opening from AT activation) — and found a real bug on first contact:GetRoleNameanswered the ARIA vocabulary whileGetRoleanswered the AT-SPI number. Invisible to libatspi, which derives role names locally from the number and never asks — exactly the class of defect only a second, independent client exposes, and the argument for the probe existing at all.
10. Alternatives rejected, for the record
- A sibling
@react-x11/a11ypackage (the original §11.3 sketch). Made sense when D-Bus was going to be an external dependency; oncedbus-nativebecame core's own optionalDependency the split bought a version matrix and an opt-in accessibility story, and "install another package to be accessible" is the wrong default in a way no engineering argument offsets. - The RN prop vocabulary (
accessibilityRole,accessibilityState). Overtaken by RN itself (0.71 added the ARIA names); and the widgets' existingrolestrings — written before anything read them — were already the web's. - A mirror/shadow accessible tree. §4; the sync-bug class it invites is the thing GTK4's rewrite spent years digging out of.
- Gating on
org.a11y.Status.IsEnabled(Qt's behaviour) rather than connecting whenever the bus exists (GTK's). One more mode, one more property watch, to save a tree that is only maintained when something queries it anyway. GTK's answer is smaller. - Per-node dbus-native interface exports with EventEmitter signal
hook-up. dbus-native wraps
emitper exported object; with thousands of nodes that is machinery nobody asked for. RawsendSignalplus prototype-shared impls keeps per-node cost to two small objects. aria-liveregion tracking. The DOM needs it because content changes are the only signal; hereannounce()is explicit, cannot fire on renders you did not mean, and maps onto the modernEvent.Object.AnnouncementOrca ≥ 45 speaks.
11. Deferred, and what each needs
| gap | what it waits on |
|---|---|
relations (aria-labelledby, label-for) | an element-id registry; aria-label + name-from-contents cover the widgets |
Selection interface on containers | per-item SELECTED states + actions already carry selection; this is polish |
Table interface (row/col navigation calls) | honest table/row/cell roles + posinset shipped; the interface matters most with virtualization, where only rendered rows exist at all |
key-event forwarding (DeviceEventController.NotifyListenersSync) | per-keystroke echo of keys themselves; typed characters already arrive as text-changed events |
soft-wrap line granularity in <textarea> | ntk layout lines are available (caretPosition(...).line); the work is boundary bookkeeping in the Text impl |
GetApplicationBusAddress peer-to-peer mode | niche; empty string is a valid "no" |
12. Sources
- at-spi2-core
xml/— the D-Bus interface definitions (fetched from the GNOME repo at implementation time;Accessible.xml,Cache.xml,Socket.xml,Event.xml,Text.xml, …). gi.repository.Atspi2.60.4 on this machine — enum values, and the client stack used for live verification.- The GTK (at-spi2-atk / GTK4) and Qt (
atspiadaptor) bridges, and Chromium'sax_platform_node_auralinux— prior art for role mappings, event pairs,NO_AT_BRIDGE, and registry-restart handling. - Orca — both as the consumer whose event diet defines "enough", and directly: its debug speech log is the acceptance test.