react-x11 2.52.0 → 2.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +10 -10
  2. package/docs/README.md +160 -314
  3. package/docs/accessibility.md +176 -229
  4. package/docs/appearance.md +77 -176
  5. package/docs/click-to-component.md +18 -51
  6. package/docs/clipboard.md +40 -59
  7. package/docs/components.md +518 -1036
  8. package/docs/dbus.md +61 -133
  9. package/docs/debugging.md +152 -245
  10. package/docs/desktop-calendar.md +51 -88
  11. package/docs/desktop.md +222 -347
  12. package/docs/devtools.md +50 -167
  13. package/docs/drag-and-drop.md +126 -204
  14. package/docs/ecosystem/animation.md +91 -125
  15. package/docs/ecosystem/data-fetching.md +38 -53
  16. package/docs/ecosystem/dev-tooling.md +159 -202
  17. package/docs/ecosystem/forms.md +61 -97
  18. package/docs/ecosystem/headless.md +64 -89
  19. package/docs/ecosystem/i18n.md +18 -21
  20. package/docs/ecosystem/icons.md +93 -126
  21. package/docs/ecosystem/layout.md +42 -52
  22. package/docs/ecosystem/routing.md +18 -29
  23. package/docs/ecosystem/state.md +71 -88
  24. package/docs/ecosystem/testing.md +131 -157
  25. package/docs/ecosystem/theming.md +119 -165
  26. package/docs/ecosystem.md +145 -179
  27. package/docs/elements.md +1091 -1774
  28. package/docs/embedding.md +83 -132
  29. package/docs/events.md +412 -695
  30. package/docs/extending.md +784 -1326
  31. package/docs/eyedropper.md +63 -122
  32. package/docs/filedialog.md +71 -107
  33. package/docs/frame.md +195 -339
  34. package/docs/gl.md +65 -62
  35. package/docs/globalmenu.md +63 -146
  36. package/docs/macos.md +531 -3234
  37. package/docs/notifications.md +33 -47
  38. package/docs/packaging.md +166 -309
  39. package/docs/permissions.md +9 -14
  40. package/docs/react-features.md +81 -106
  41. package/docs/remote.md +32 -76
  42. package/docs/scale.md +72 -186
  43. package/docs/styling.md +329 -547
  44. package/docs/system.md +200 -280
  45. package/docs/testing.md +63 -83
  46. package/docs/typescript.md +6 -48
  47. package/docs/uri-schemes.md +67 -107
  48. package/docs/wayland-backend.md +200 -459
  49. package/docs/windows-integrations.md +75 -131
  50. package/docs/windows.md +343 -1367
  51. package/package.json +3 -2
  52. package/src/Reconciler.js +2 -2
  53. package/src/a11y.js +1 -1
  54. package/src/backend/context2d.js +3 -3
  55. package/src/bootstrap.js +1 -1
  56. package/src/cocoa/app.js +6 -6
  57. package/src/cocoa/bezels.js +1 -1
  58. package/src/cocoa/fonts.js +1 -1
  59. package/src/cocoa/globalmenu.js +1 -1
  60. package/src/cocoa/keymap.js +1 -1
  61. package/src/cocoa/panewindow.js +1 -1
  62. package/src/cocoa/player.js +1 -1
  63. package/src/cocoa/presenter.js +2 -2
  64. package/src/cocoa/promotion.js +1 -1
  65. package/src/cocoa/relaunch.js +1 -1
  66. package/src/cocoa/threaded.js +1 -1
  67. package/src/cocoa/video.js +1 -1
  68. package/src/cocoa/window.js +4 -4
  69. package/src/components/Menu.js +7 -2
  70. package/src/components/Switch.js +1 -1
  71. package/src/components/native.js +1 -1
  72. package/src/dnd.js +2 -2
  73. package/src/glnodes.js +1 -1
  74. package/src/grid.js +1 -1
  75. package/src/layouts.js +1 -1
  76. package/src/nodes/animation.js +1 -1
  77. package/src/nodes/invalidate.js +1 -1
  78. package/src/nodes/layouthost.js +1 -1
  79. package/src/nodes/video.js +19 -8
  80. package/src/nodes/window/droptarget.js +1 -1
  81. package/src/pacing.js +1 -1
  82. package/src/refresh/index.js +2 -1
  83. package/src/types/video.d.ts +5 -2
  84. package/src/videobackend.js +28 -0
  85. package/src/videoframes.js +29 -16
  86. package/src/wayland/app.js +27 -0
  87. package/src/wayland/video.js +376 -0
  88. package/src/win32/a11y.js +1 -1
  89. package/src/win32/app.js +2 -2
  90. package/src/win32/fonts.js +1 -1
  91. package/src/win32/glarea.js +4 -4
  92. package/src/win32/overlay.js +1 -1
  93. package/src/win32/panewindow.js +3 -3
  94. package/src/win32/shell.js +1 -1
  95. package/src/win32/window.js +2 -2
  96. package/src/windowid.js +1 -1
  97. package/src/xvideo.js +189 -0
  98. package/docs/architecture/accessibility.md +0 -470
  99. package/docs/architecture/animation.md +0 -707
  100. package/docs/architecture/container-queries.md +0 -525
  101. package/docs/architecture/custom-layout.md +0 -334
  102. package/docs/architecture/drag-and-drop.md +0 -941
  103. package/docs/architecture/element-layer-contents.md +0 -622
  104. package/docs/architecture/frame-pacing.md +0 -255
  105. package/docs/architecture/grid-layout.md +0 -358
  106. package/docs/architecture/protocol-efficiency.md +0 -357
  107. package/docs/architecture/video.md +0 -1149
  108. package/docs/architecture/windowed-regions.md +0 -854
  109. package/docs/glx.md +0 -137
  110. package/docs/wayland.md +0 -545
  111. package/docs/windows-embedding.md +0 -271
  112. package/docs/windows-gl.md +0 -221
package/src/xvideo.js ADDED
@@ -0,0 +1,189 @@
1
+ // `<video frames>` on X11 with the server doing the conversion: an XVideo
2
+ // port where the display has an image adaptor (ntk/xv;
3
+ // design/architecture/video.md §2.6).
4
+ //
5
+ // The drawn path converts each YCbCr frame in a JavaScript loop over every
6
+ // pixel and puts four bytes a pixel on the wire. Through Xv the frame
7
+ // crosses as the decoder made it — 1.5 bytes a pixel for NV12 and I420,
8
+ // through shared memory on a local display — and the matrix is the server's
9
+ // GPU's: glamor's textured adaptor, which Xorg's modesetting driver and
10
+ // Xwayland both ship.
11
+ //
12
+ // Where the frame goes is the record's open question, answered by
13
+ // measurement: an adaptor takes no pixmap (`BadWindow` into one on
14
+ // Xwayland), so the frame goes into a `VideoTarget` — a manually redirected
15
+ // child window outside the window it belongs to, shown nowhere — and from
16
+ // there, in one RENDER copy, into the `<video>`'s own surface. Everything
17
+ // after that is the drawn path unchanged: the surface is composited in paint
18
+ // order, under the window's popups and inside its scroll panes, and painted
19
+ // again from without a frame.
20
+ //
21
+ // Declined, and the frame goes the JavaScript way, where the server has no
22
+ // image adaptor (Xvfb, XQuartz, most remote displays), where ntk predates
23
+ // ntk/xv, for a full-range frame or a colour space the port cannot be told
24
+ // (every adaptor reads video range; one without XV_COLORSPACE reads BT.601
25
+ // alone), while the window is not on screen, and for the first frame of a
26
+ // size, while the port says how it wants it laid out.
27
+ //
28
+ // Nothing is asked of the server until a sink or a `<video>` asks: an app
29
+ // that shows no video grabs no port and pays no round trip.
30
+
31
+ const writers = new WeakMap();
32
+
33
+ /** The writer for `app`'s connection, made on first ask. */
34
+ export function xvVideoFor(app) {
35
+ let writer = writers.get(app);
36
+ if (!writer) writers.set(app, (writer = new XvVideo(app)));
37
+ return writer;
38
+ }
39
+
40
+ const same = (planes) => planes;
41
+ const swapChroma = (planes) => planes && [planes[0], planes[2], planes[1]];
42
+
43
+ /** A sink's format, as the formats a port may take it in — the planes put
44
+ * in the port's order — cheapest first. */
45
+ const PORT_FORMATS = {
46
+ NV12: [['NV12', same]],
47
+ I420: [
48
+ ['I420', same],
49
+ ['YV12', swapChroma],
50
+ ],
51
+ BGRA: [['AR24', same]],
52
+ };
53
+
54
+ class XvVideo {
55
+ constructor(app) {
56
+ this.app = app;
57
+ /** `undefined` while it is being asked for, then a VideoPort or null */
58
+ this.port = undefined;
59
+ this._opening = null;
60
+ /** the sizes asked about before the port answered, to lay out once it does */
61
+ this._waiting = [];
62
+ /** surface -> { target, window }: where each `<video>`'s frames go */
63
+ this._targets = new Map();
64
+ }
65
+
66
+ /**
67
+ * Start asking for a port, and — for a sink — for the layout of its
68
+ * frames, so its first frame is the only one drawn the slow way. Cheap
69
+ * and idempotent: a sink calls it when it is made.
70
+ */
71
+ prepare(sink) {
72
+ if (this.port === undefined) {
73
+ if (sink) this._waiting.push(sink);
74
+ if (!this._opening) this._open();
75
+ return;
76
+ }
77
+ if (this.port && sink) this._layout(sink);
78
+ }
79
+
80
+ _open() {
81
+ this._opening = import('ntk/xv')
82
+ .then(({ openVideoPort }) => openVideoPort(this.app))
83
+ .catch(() => null)
84
+ .then((port) => {
85
+ // a port with nowhere to put a frame — no Composite, so no target —
86
+ // is no port: given back, and the formats are JavaScript's
87
+ if (port && !port.targets) {
88
+ port.close();
89
+ port = null;
90
+ }
91
+ this.port = port;
92
+ const waiting = this._waiting;
93
+ this._waiting = [];
94
+ if (port) for (const sink of waiting) this._layout(sink);
95
+ });
96
+ }
97
+
98
+ /** Ask the port how it lays out `sink`'s frames, if it takes them at all. */
99
+ _layout(sink) {
100
+ const choice = this._formatFor(sink);
101
+ if (choice) this.port.layout(choice[0], sink.width, sink.height);
102
+ }
103
+
104
+ /**
105
+ * `preferredFormats` on X11 (src/videoframes.js): `null` while the port is
106
+ * being asked for — an answer not to keep — then the YCbCr formats the
107
+ * port takes ahead of BGRA, or BGRA alone where there is no port and every
108
+ * frame is drawn in JavaScript.
109
+ */
110
+ videoFormats() {
111
+ if (this.port === undefined) {
112
+ this.prepare();
113
+ return null;
114
+ }
115
+ if (this.port === null) return ['BGRA'];
116
+ const takes = (name) =>
117
+ PORT_FORMATS[name].some(([format]) => this.port.formats.includes(format));
118
+ return [...['NV12', 'I420'].filter(takes), 'BGRA'];
119
+ }
120
+
121
+ /** The port's format for `sink` and how its planes go, or null where the
122
+ * port cannot show it as the sink means it. */
123
+ _formatFor(sink) {
124
+ const port = this.port;
125
+ if (sink.format !== 'BGRA') {
126
+ if (sink.range !== 'video') return null;
127
+ if (!port.colorSpaces.includes(sink.colorSpace)) return null;
128
+ }
129
+ return (
130
+ PORT_FORMATS[sink.format]?.find(([format]) =>
131
+ port.formats.includes(format),
132
+ ) ?? null
133
+ );
134
+ }
135
+
136
+ /**
137
+ * A `<video>`'s frame into `surface`, its drawn presentation: through the
138
+ * port into the node's target, then copied into the surface. False — and
139
+ * the node converts the frame itself — wherever this cannot (see the top).
140
+ */
141
+ writeVideoFrame(surface, sink, frame, node) {
142
+ const port = this.port;
143
+ if (!port) {
144
+ if (port === undefined) this.prepare(sink);
145
+ return false;
146
+ }
147
+ if (port.closed) return false;
148
+ const choice = this._formatFor(sink);
149
+ const window = node?.root?.window;
150
+ if (!choice || !window || typeof window.id !== 'number') return false;
151
+ const [format, order] = choice;
152
+ const { width, height } = sink;
153
+ let kept = this._targets.get(surface);
154
+ if (kept && (kept.window !== window || kept.target.destroyed)) {
155
+ // the node is in another window now, or its window took the target
156
+ // with it
157
+ kept.target.destroy();
158
+ this._targets.delete(surface);
159
+ kept = undefined;
160
+ }
161
+ if (!kept) {
162
+ // the first frame of a size: asked, and drawn the other way meanwhile
163
+ if (!port.layout(format, width, height)) return false;
164
+ const target = port.createTarget(window, { width, height });
165
+ if (!target) return false;
166
+ kept = { target, window };
167
+ this._targets.set(surface, kept);
168
+ }
169
+ const sent = kept.target.put({
170
+ format,
171
+ width,
172
+ height,
173
+ planes: order(frame.planes),
174
+ strides: order(frame.strides),
175
+ colorSpace: sink.colorSpace,
176
+ });
177
+ if (!sent) return false;
178
+ kept.target.copyTo(surface);
179
+ return true;
180
+ }
181
+
182
+ /** The node let `surface` go: so does its target. */
183
+ releaseVideoFrame(surface) {
184
+ const kept = this._targets.get(surface);
185
+ if (!kept) return;
186
+ this._targets.delete(surface);
187
+ kept.target.destroy();
188
+ }
189
+ }
@@ -1,470 +0,0 @@
1
- # Accessibility: an AT-SPI2 bridge with no mirror, and where it lives
2
-
3
- Design record for the accessibility layer that shipped in core — the _why_
4
- behind [accessibility.md](../accessibility.md), which is the reference for
5
- what the API is. Written as built, not as proposed: the alternatives below
6
- were live options at the time and the reasons they lost are the useful part.
7
-
8
- _Checked at react-x11 `a937436` (master, 2026-08-07), **ntk 7.2.0**,
9
- `dbus-native` 0.15.1, against at-spi2-core 2.60.4 (the installed
10
- introspection data) and at-spi2-core's `xml/` interface definitions (the
11
- wire spec). Verified live against this desktop's registry with libatspi
12
- (python3 `gi.repository.Atspi`) and with Orca's own speech log. File:line
13
- references drift with the code._
14
-
15
- ---
16
-
17
- ## 0. TL;DR
18
-
19
- - **Linux accessibility is D-Bus, not X.** An app exposes a tree of objects
20
- implementing `org.a11y.atspi.*` on a dedicated _accessibility bus_ and
21
- registers with the AT-SPI registry daemon; Orca walks that. Being a
22
- pure-JS X11 client blocks nothing.
23
- - **Two modules.** `src/a11y.js` is the model: pure functions from the
24
- retained node tree to roles/names/states, plus null hook slots the
25
- renderer polls (the trace-registry pattern — one property read per event
26
- when off). `src/atspi.js` is the bridge: D-Bus plumbing, dynamically
27
- imported once per process when a root exists. The model is unit-testable
28
- with no bus anywhere; the bridge is testable against an in-process bus.
29
- - **No mirror tree.** Every AT call is answered from the live node tree at
30
- the moment it arrives. The only per-node bridge state is the object path,
31
- the exported interface list, and a snapshot of what the bus was last
32
- _told_ — kept solely to turn "something changed" into the precise AT-SPI
33
- event diff.
34
- - **The vocabulary is the web's** (`role`, `aria-*`), not React Native's
35
- `accessibilityRole` family that NEXT_STEPS §11.3 originally recommended.
36
- RN itself adopted the ARIA names in 0.71, and the widgets here had been
37
- carrying web role strings for months. Role→AT-SPI mappings follow what
38
- Chromium and Firefox send on Linux, so Orca sees from a react-x11 app
39
- what it sees from a browser.
40
- - **In core, not `@react-x11/a11y`.** The dependency argument for a sibling
41
- package dissolved when D-Bus landed in core as an `optionalDependency`;
42
- what remained was a peer-version matrix and an opt-in accessibility
43
- layer, which is an accessibility failure.
44
- - **Off is free and silent.** A four-rung ladder (transport → session bus →
45
- `GetAddress` → connect+`Embed`), every rung failing into "off" with no
46
- log and no mode; the bridge's socket is `unref()`d so it never holds the
47
- process open. Discovery dials a **private, short-lived connection** —
48
- deliberately not the shared `sessionBus()` — see §7.
49
-
50
- ## 1. The constraints that shaped it
51
-
52
- Three personas had veto power:
53
-
54
- 1. **The machine with no accessibility stack** — ssh -X, CI, containers,
55
- macOS/XQuartz, bare `startx`. react-x11's flagship configurations. The
56
- layer must cost them nothing: no import weight, no connection attempts
57
- that log, no event-loop hold, no new mode. This is the same rule
58
- [dbus.md](../dbus.md) established: "no bus" is a first-class
59
- configuration.
60
- 2. **The screen-reader user on a desktop.** For them the tree has to be
61
- _correct_, not merely present: states that match the pixels, focus
62
- events at the moment focus moves, text deltas as they type, and controls
63
- that can actually be driven. Half an implementation — a tree with stale
64
- states — is worse than none, because the user cannot tell which half to
65
- trust.
66
- 3. **The component-library author targeting react-x11.** They must not need
67
- a react-x11-specific integration. Standard props on host elements had to
68
- be the entire seam.
69
-
70
- And one engineering constraint: the renderer's hot paths (focus moves,
71
- every `applyProps`, every text repaint) get touched. The cost of
72
- accessibility being _off_ had to be one nullable property read per site,
73
- which is the same bar `trace-registry.js` set for the protocol tracer.
74
-
75
- ## 2. Where accessibility actually happens
76
-
77
- The at-spi2-core `xml/` directory is the wire spec, and the shape is:
78
-
79
- - A **separate bus**, not the session bus. `org.a11y.Bus.GetAddress` on the
80
- session bus answers its address (activating `at-spi-bus-launcher` on
81
- demand); `AT_SPI_BUS_ADDRESS` overrides.
82
- - The app exports objects at `/org/a11y/atspi/accessible/<id>` implementing
83
- `Accessible` (tree + role + states) and per-capability interfaces:
84
- `Component` (geometry, hit test, focus), `Action`, `Value`,
85
- `Text`/`EditableText`. The app root at `…/accessible/root` adds
86
- `Application` and calls `org.a11y.atspi.Socket.Embed` on the registry,
87
- which replies with the desktop ref the root reports as its `Parent`.
88
- - ATs bulk-load through `org.a11y.atspi.Cache.GetItems` (one struct per
89
- node) and then track **signals**: `org.a11y.atspi.Event.Object.*`
90
- (`StateChanged`, `ChildrenChanged`, `TextChanged`, `TextCaretMoved`,
91
- `PropertyChange`, `Announcement`, …) and `Event.Window.*`
92
- (`Activate`/`Deactivate`/`Create`/`Destroy`), all with the same
93
- `siiva{sv}` body. Orca's model of the app is built almost entirely from
94
- these; getting their _details_ right (state nicks like `"focused"`,
95
- insert/delete offsets in code points) is most of the work.
96
- - Toolkit-less apps are expected to drive this themselves rather than
97
- inherit it from GTK. That is what `src/atspi.js` is.
98
-
99
- Enum values (roles, states, relations) were generated from the installed
100
- `gi.repository.Atspi` — the very tables Orca reads — and are append-only
101
- upstream, so they are pasted into `a11y.js` as frozen data with the
102
- regeneration one-liner in a comment, not fetched or computed.
103
-
104
- ## 3. The two-module split, and the hook-slot seam
105
-
106
- ```
107
- src/nodes/, events.js, Reconciler.js (hot paths)
108
- │ a11yHooks.focus?.(…) — null when off
109
- ▼
110
- src/a11y.js the model: role/name/states/tree-projection as pure
111
- functions; the hook slots; startA11y() gate
112
- │ dynamic import, once, when a root exists and the gate passes
113
- ▼
114
- src/atspi.js the bridge: bus dial, Embed, per-node export, the
115
- event queue/flush, the interface implementations
116
- ```
117
-
118
- The precedent is `trace-registry.js`: an always-imported module whose whole
119
- idle cost is null slots, with the heavy half behind a dynamic import. Two
120
- consequences fall out of the split being _exactly at the D-Bus line_:
121
-
122
- - Everything semantically interesting — what role a node has, what its name
123
- resolves to, which states are set, what the accessible tree looks like
124
- after pruning — is computable and testable with **no bus in the
125
- process** (`test/a11y.test.js`). The bridge test then only has to prove
126
- plumbing, not semantics.
127
- - The bridge can be rewritten (Wayland's a11y future, a different
128
- transport) without touching the model or the renderer wiring.
129
-
130
- The renderer chokepoints wired to slots: `insertBefore`/`removeChild`
131
- (tree), `applyProps` (props), `TextChunkNode.setText` (label content),
132
- `TextInputNode._repaint` (value/caret/selection — every edit path funnels
133
- through it), `EventManager.focus` (the one place focus moves),
134
- `_onWindowFocus` (WM focus), `appendChildToContainer`/
135
- `removeChildFromContainer` (toplevels), `resetAfterCommit` (flush). Focus
136
- and focusability deserve a note: the focusable rule used to exist twice
137
- (`EventManager._isFocusable` and `Node._focusableForRing`); it now lives
138
- once in `a11y.js` and both call it, so the keyboard, the focus ring and the
139
- FOCUSABLE state cannot disagree by construction.
140
-
141
- ## 4. No mirror
142
-
143
- The obvious design — GTK's, historically — is a shadow "accessible object"
144
- per widget, kept in sync. The whole class of bugs that produces (stale
145
- name, stale index, remove-before-add races) comes from the mirror being a
146
- second copy of the truth.
147
-
148
- Here there is no copy. `GetChildren` walks the live `node.children` through
149
- the projection (§5) at call time; `GetState` recomputes from live props and
150
- live focus; `GetExtents` reads `node.abs` and the window's live
151
- `_screenOrigin`. An answer cannot be stale because nothing is cached to go
152
- stale.
153
-
154
- What _is_ kept per exported node — `{ id, ifaces, snapshot }` — exists for
155
- one reason: **events are diffs, and a diff needs a "before".** The snapshot
156
- holds the last name/description/states/value/text the bus was told.
157
- `syncNode()` recomputes, XORs the two u32 state words, and emits one
158
- `StateChanged` per flipped bit with its enum nick; `_diffText` runs a
159
- common-prefix/suffix diff over the code-point arrays and emits the
160
- `delete`+`insert` pair with exact offsets. This is also what makes the
161
- wiring correct without per-prop plumbing: _any_ prop change queues the
162
- node, and whatever actually changed is what gets emitted.
163
-
164
- Object **export is lazy**: a node is exported the first time a ref to it is
165
- handed out (`refFor`), which transitively means "the first time an AT could
166
- possibly call back on it". Per (node, interface) the impl object is
167
- `{ __proto__: sharedProto, b: bridge, n: node }` — the prototypes hold all
168
- behaviour, dbus-native reads properties through the chain, and the per-node
169
- cost is a couple of two-field objects. Signals are emitted through raw
170
- `bus.sendSignal` rather than dbus-native's EventEmitter hook-up, so impls
171
- need no emitter machinery. On detach, the subtree is walked, each exported
172
- node unexported with a `Cache.RemoveAccessible`, and the parent gets one
173
- `ChildrenChanged remove` — but only if the AT had ever seen the child;
174
- what the bus was never told about, it does not need to hear removed.
175
-
176
- ## 5. The tree projection
177
-
178
- The accessible tree is the node tree through three rules:
179
-
180
- - **Pruned subtrees**: `aria-hidden`, the internal content kinds
181
- (`textchunk`, `svgchild`), `<text>` spans (the outer `<text>` speaks for
182
- the run), and everything under `<glarea>` (scene nodes have no
183
- rectangles). Gone entirely.
184
- - **Erased nodes**: `role="none"`/`"presentation"` — the node vanishes and
185
- its children take its place, ARIA's semantics. This is what keeps a
186
- styling wrapper from adding a filler level around every widget.
187
- - **Defaults**: `<box>` is FILLER — the role GTK gives its own layout
188
- containers and screen readers step over silently — so an unlabelled tree
189
- is _boring_ rather than noisy. Everything with real semantics defaults to
190
- them (window→FRAME, text→LABEL, textinput→ENTRY, image→IMAGE, …).
191
-
192
- …plus a fourth source of children that is not in the node tree at all: what
193
- a registered element says it drew (§8b), appended after whatever it holds
194
- retained.
195
-
196
- Two structural notes. `<popup>`s stay **nested**: a popup's `parent` is the
197
- JSX node it was written under, so a menu appears inside the widget that
198
- opened it — GTK4 does the same with popovers, and it preserves context that
199
- promoting popups to application children would lose. Screen coordinates
200
- still resolve correctly because extents go through the _owning window's_
201
- `_screenOrigin`, and the popup is its own window. And name-from-contents
202
- (button/menuitem/tab/… with no `aria-label`) concatenates the subtree's
203
- chunk text — which is why `MenuRow` sets `aria-label` explicitly: without
204
- it the shortcut column and the submenu arrow would read into the name
205
- ("New Ctrl+N ▸").
206
-
207
- ## 6. Events: queue, collapse, flush
208
-
209
- Hook calls do not emit; they queue. The flush runs at `resetAfterCommit`
210
- (so a commit's worth of mutations is one batch) and, for changes that
211
- happen outside commits — pointer-driven focus, typing into an uncontrolled
212
- input — on a `queueMicrotask` fallback. Collapse rules:
213
-
214
- - **Removals first**, so a node that left and rejoined in one batch reads
215
- as its add, never as a stale remove.
216
- - **Attaches collapse to the topmost node** per subtree: the AT gets one
217
- `ChildrenChanged add` + `Cache.AddAccessible` and descends on its own.
218
- The initial bottom-up construction of a tree costs _nothing_ — a detached
219
- subtree is not "live" (§3's `_live` walk) until its top joins a mounted
220
- toplevel, so React building a window queues zero events until the
221
- container mount, which then announces exactly one child.
222
- - Focus emits `StateChanged focused` on both ends **plus** the legacy
223
- `Event.Focus.Focus` on the gaining node, because that is what the GTK
224
- bridge emits and older ATs still listen for the pair.
225
-
226
- ## 7. The ladder, and why discovery dials its own socket
227
-
228
- `startA11y()` (called from `createRoot`, never awaited): gate →
229
- `import('./atspi.js')` → transport → session-bus address → `GetAddress` →
230
- connect → export root + `Cache` → `Embed` → install hooks → walk
231
- already-mounted toplevels (via `trace-registry.onApp`). Every failure is a
232
- silent off; `REACT_X11_A11Y=1` makes each rung print why it stopped, which
233
- is the entire debugging story for "Orca doesn't see my app".
234
-
235
- The one non-obvious decision: **the `GetAddress` probe uses a private
236
- connection, opened and closed inside the call, not the shared
237
- `sessionBus()`**. The shared machinery is process-global state, and the
238
- climb is an unowned background task — the combination raced this repo's own
239
- test harness, which swaps `DBUS_SESSION_BUS_ADDRESS` to a per-test broker
240
- and hard-resets the shared state between cases; an in-flight climb from an
241
- earlier test corrupted a later test's portal reads. The fix is not "be
242
- careful", it is _no shared state_: a probe that owns its socket cannot
243
- interfere with anyone, and as a bonus the shared session connection is no
244
- longer dialled (and kept warm) as the side effect of a probe on machines
245
- where nothing else wants D-Bus.
246
-
247
- Related hygiene, borrowed from the ecosystem: `NO_AT_BRIDGE` is honoured —
248
- it is what at-spi2-atk checks and what GTK's own test suite sets — and
249
- `react-x11/test` sets it, so a suite running on a developer's desktop does
250
- not parade hundreds of phantom applications through a live screen reader.
251
- Priority order: `REACT_X11_A11Y` (explicit, either way) →
252
- `AT_SPI_BUS_ADDRESS` (an address is intent — it is also the hermetic test
253
- seam) → `NO_AT_BRIDGE` → on.
254
-
255
- The bridge holds no event-loop reference (`stream.unref()`): it is a
256
- passenger, not cargo, and a process with no other work exits — the
257
- registry sees the name drop, which is AT-SPI's own liveness model. A dead
258
- accessibility bus is not redialled, the same no-resurrection contract as
259
- `bus.js`; the registry _restarting_ is survivable though — the bridge
260
- watches `NameOwnerChanged` on `org.a11y.atspi.Registry` and re-embeds,
261
- exactly as the GTK bridge does.
262
-
263
- ## 8. AT-driven input goes through the front door
264
-
265
- - **Activation** (`Action.DoAction("activate")`) dispatches a synthetic
266
- **full press gesture** — MouseDown, MouseUp, Click — through
267
- `EventManager.dispatch` at discrete priority with the frame flush, so an
268
- AT press is indistinguishable from a finger: capture/bubble order,
269
- `:active` styling, the commit, the paint. The full gesture rather than a
270
- bare Click was a live-testing find: `Select` and `MenuBar` open their
271
- menus **on the press**, as real menus do, and a Click-only activation
272
- silently did nothing on exactly those controls. Coordinate-driven
273
- _default_ actions (caret placement, drag arming) are deliberately not
274
- invoked — a synthesized centre point is not a place the user chose.
275
- - **Value writes** (`Value.CurrentValue` set) route to the node's
276
- `onAccessibilityAction({ action: 'setValue', value })`, wrapped in
277
- `callHandler` so a throwing handler cannot unwind into dbus-native's
278
- message dispatch. `Slider` wires it into the same clamp/quantize/emit as
279
- the pointer and the keyboard — one state path, three input routes.
280
- - **Edits** (`EditableText`) go through `TextInputNode._commit`, the same
281
- single mutation point typing uses, so `onChange` fires and undo history
282
- records — an AT edit _is_ a user edit. Offsets need no conversion
283
- anywhere: AT-SPI counts code points, and `_chars()`/`_caret` already do.
284
- Every write is expressed as **one replacement** — `[start, end) := text`
285
- — because that is the primitive both implementations can offer: the
286
- built-ins splice and `_commit`, and a registered element implements
287
- `a11yReplaceText`, which its own editing needs anyway. Set-the-lot,
288
- insert and delete are that call with one end moved.
289
-
290
- ## 8a. Text from elements core did not write (#257)
291
-
292
- A registered element draws text core cannot see: `<textinput>`'s state is
293
- read off `_chars()`/`_caret`/`_selection`, which a `<codeeditor>` in
294
- another package does not have. The seam is a **pull** — `a11yTextState()`
295
- answers `{ value, caret, selectionStart, selectionEnd, editable,
296
- multiline, preedit }` — plus a **push** that says it is worth pulling,
297
- `Node.notifyA11yTextChanged()`, which is the same `hooks.textState` slot
298
- `_repaint` already fed. Three consequences make it the cheap shape:
299
-
300
- - **One normalizer, no second model.** `customTextState()` turns the
301
- element's answer into the `{ chars, caret, selection, preedit }` the
302
- bridge and the spy already speak, clamping the offsets on the way — so
303
- granularity, attribute runs, the diff, `:system` composition marking and
304
- the spy's transcript are the same code paths `<textinput>` uses. A
305
- third-party editor cannot be read _differently_ from core's, only less.
306
- - **Two tiers, because viewers are not degenerate editors.** `editable`
307
- splits them: without it an element is `Text` and a `document` role,
308
- which is exactly the markdown/code-block/terminal case (selection,
309
- Ctrl+C, no caret semantics); with it, EDITABLE, an `entry` role, and —
310
- only if `a11yReplaceText` exists — the `EditableText` interface.
311
- Exporting an interface whose every method answered false would be a lie
312
- an AT has no way to test.
313
- - **Declared, not inferred.** `a11yRole` is the element's own default
314
- role (below a `role` prop, above the scroller rule, so an editor that
315
- scrolls is still an entry), and `multiline` is a claim rather than a
316
- guess off the value — inferring it from a `\n` would report the
317
- element's _shape_ changing the first time somebody pressed Enter, the
318
- same objection that keeps the scroll-pane role off `isScroller()`
319
- flicker.
320
-
321
- Deliberately not in the seam: per-character extents (they need a layout
322
- only the element has; the fallback is the element's own rect, which is
323
- honest and keeps a magnifier tracking something real) and AT-driven
324
- copy/paste, which stay the built-ins' clipboard round trip.
325
-
326
- ## 8b. Structure from elements core did not write (#304)
327
-
328
- The same gap one level up. An element that _draws_ N interactive things is
329
- one node and therefore one accessible — a graph pane reads as "group" with
330
- nothing inside — and unlike the text case there is no prop that could have
331
- said otherwise, because the objects an AT needs do not exist anywhere.
332
- `a11yScene()` is the pull that produces them: `[{ id, rect, role, name,
333
- states, props, children }]`, with `Node.notifyA11ySceneChanged()` as the
334
- push, routed through the existing `hooks.propsChanged` slot because
335
- "re-read this node's accessible facts and announce the difference" is
336
- exactly what that slot already means.
337
-
338
- Four decisions, in the order they bite:
339
-
340
- - **Objects, not descriptors.** Everything else here is answered from the
341
- live tree (§4), and a scene has no live tree to answer from — the
342
- element rebuilds its description every frame. So a11y.js reconciles the
343
- frame's descriptions against the last frame's by `id` and returns the
344
- _same_ objects where the id survived. That is what an export can be keyed
345
- on: paths, snapshots and every ref an AT is holding stay valid across a
346
- scene rebuilt sixty times a second, and an id that disappears is marked
347
- defunct rather than forgotten, so the bridge can still walk what it is
348
- losing. Identity is the one thing core cannot derive, which is why `id`
349
- is the only required field besides the rect.
350
- - **A scene item is read by the model, not beside it.** It carries `props`
351
- in the same `role`/`aria-*` vocabulary and an `abs` rect in the same
352
- window coordinates, so `atspiRoleOf`, `a11yName`, `a11yStates`,
353
- `a11yAttributes`, the extents, the cache items, the snapshot diff and the
354
- test spy are all the code that already existed — the same "no second
355
- model" rule §8a's normalizer follows. The friendly `states: { selected,
356
- checked, expanded, disabled, busy }` is a table of five entries into
357
- those props; `focused` is the exception, because element-internal focus
358
- never reaches a window's focus manager and there is no ARIA spelling for
359
- it.
360
- - **Actions fall back rather than fail.** `a11ySceneAction(id, action)`
361
- claims with `true`; anything else takes core's answer — for `activate`, a
362
- synthetic click through the ordinary dispatch at the item's own rect. An
363
- element hit-tests its scene already, so that is not an approximation of
364
- the user's click, it _is_ it. A single multiplexed hook (rather than one
365
- method per action) is what makes claiming `focus` and leaving `activate`
366
- to core the default reading of the code rather than a trap.
367
- - **An action is offered where one lands.** The role table decides as it
368
- does everywhere else, plus: an element that implements the seam makes
369
- every item it drew activatable, because implementing it is the same
370
- promise a handler is — and the roles a scene reaches for (`listitem` for
371
- a graph node) are mostly ones ARIA promises nothing about.
372
-
373
- Deferred with it: hit testing descends into items (`GetAccessibleAtPoint`
374
- answers with the item under the point, which is what a magnifier following
375
- the pointer needs), but the **testing queries** still walk retained nodes
376
- only, so `getByRole` cannot find a drawn item — the same shape as #260,
377
- and it wants that issue's answer rather than a second one here.
378
-
379
- ## 9. Testing
380
-
381
- Four layers, each catching what the one below cannot:
382
-
383
- 1. **Model** (`test/a11y.test.js`) — no bus: roles, names, states,
384
- projection, widget wiring, over the mock app.
385
- 2. **The spy** (`src/testing/a11y.js`, `test/a11y-spy.test.js`, and the
386
- layer applications are meant to use via `renderX11({ a11y: true })`) —
387
- the hook slots are the seam the bridge itself consumes, so a spy
388
- filling the same slots observes the same contract with no transport:
389
- synchronous, bus-free, Node-20-and-macOS-safe. It reuses the bridge's
390
- own snapshot-and-diff idea in miniature, and it doubles as a renderer
391
- regression net — a chokepoint that stops calling its hook fails these
392
- tests exactly as it would silence Orca. The utterance strings it (and
393
- `scripts/a11y-probe.mjs`) produce come from one shared `utteranceOf`,
394
- deliberately documented as react-x11's model rather than an imitation
395
- of Orca's wording, which is presentation policy that shifts between
396
- releases.
397
- 3. **Hermetic bridge** (`test/atspi.test.js`) — dbus-native's
398
- `createBroker()` is a real in-process message bus (routing, names, match
399
- rules); a stub client owns `org.a11y.atspi.Registry` and answers
400
- `Embed`; `AT_SPI_BUS_ADDRESS` points the bridge at it, so everything
401
- from the address onward is the production path. A second client plays
402
- the screen reader with raw `invoke` calls and a signal tap, asserting
403
- wire-level facts: struct shapes, state bits, text-diff offsets, the
404
- press gesture, cache items. Skips itself on Node 20 where the transport
405
- legitimately is not installed.
406
- 4. **Live** — not automated, but scripted and repeatable:
407
- `scripts/a11y-probe.mjs` is the AT side of the wire in dbus-native
408
- (tree dumps, an event tail, `--speak` through speech-dispatcher), and
409
- python3 `gi.repository.Atspi` is libatspi, Orca's own client stack —
410
- `get_desktop(0)` → find the app → walk/`do_action`/`set_current_value`
411
- exercises the real registry, with `orca --debug-file=…` grepped for
412
- `SPEECH OUTPUT` as ground truth. Those runs confirmed the whole chain
413
- ("Press me — button", "check box checked", "horizontal slider — 80
414
- percent", menus opening from AT activation) — and found a real bug on
415
- first contact: `GetRoleName` answered the ARIA vocabulary while
416
- `GetRole` answered the AT-SPI number. Invisible to libatspi, which
417
- derives role names locally from the number and never asks — exactly
418
- the class of defect only a second, independent client exposes, and the
419
- argument for the probe existing at all.
420
-
421
- ## 10. Alternatives rejected, for the record
422
-
423
- - **A sibling `@react-x11/a11y` package** (the original §11.3 sketch).
424
- Made sense when D-Bus was going to be an external dependency; once
425
- `dbus-native` became core's own optionalDependency the split bought a
426
- version matrix and an opt-in accessibility story, and "install another
427
- package to be accessible" is the wrong default in a way no engineering
428
- argument offsets.
429
- - **The RN prop vocabulary** (`accessibilityRole`, `accessibilityState`).
430
- Overtaken by RN itself (0.71 added the ARIA names); and the widgets'
431
- existing `role` strings — written before anything read them — were
432
- already the web's.
433
- - **A mirror/shadow accessible tree.** §4; the sync-bug class it invites
434
- is the thing GTK4's rewrite spent years digging out of.
435
- - **Gating on `org.a11y.Status.IsEnabled`** (Qt's behaviour) rather than
436
- connecting whenever the bus exists (GTK's). One more mode, one more
437
- property watch, to save a tree that is only maintained when something
438
- queries it anyway. GTK's answer is smaller.
439
- - **Per-node dbus-native interface exports with EventEmitter signal
440
- hook-up.** dbus-native wraps `emit` per exported object; with thousands
441
- of nodes that is machinery nobody asked for. Raw `sendSignal` plus
442
- prototype-shared impls keeps per-node cost to two small objects.
443
- - **`aria-live` region tracking.** The DOM needs it because content
444
- changes are the only signal; here `announce()` is explicit, cannot fire
445
- on renders you did not mean, and maps onto the modern
446
- `Event.Object.Announcement` Orca ≥ 45 speaks.
447
-
448
- ## 11. Deferred, and what each needs
449
-
450
- | gap | what it waits on |
451
- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
452
- | relations (`aria-labelledby`, label-for) | an element-id registry; `aria-label` + name-from-contents cover the widgets |
453
- | `Selection` interface on containers | per-item SELECTED states + actions already carry selection; this is polish |
454
- | `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 |
455
- | key-event forwarding (`DeviceEventController.NotifyListenersSync`) | per-keystroke echo of keys themselves; typed characters already arrive as text-changed events |
456
- | soft-wrap line granularity in `<textarea>` | ntk layout lines are available (`caretPosition(...).line`); the work is boundary bookkeeping in the Text impl |
457
- | `GetApplicationBusAddress` peer-to-peer mode | niche; empty string is a valid "no" |
458
-
459
- ## 12. Sources
460
-
461
- - at-spi2-core `xml/` — the D-Bus interface definitions (fetched from the
462
- GNOME repo at implementation time; `Accessible.xml`, `Cache.xml`,
463
- `Socket.xml`, `Event.xml`, `Text.xml`, …).
464
- - `gi.repository.Atspi` 2.60.4 on this machine — enum values, and the
465
- client stack used for live verification.
466
- - The GTK (at-spi2-atk / GTK4) and Qt (`atspiadaptor`) bridges, and
467
- Chromium's `ax_platform_node_auralinux` — prior art for role mappings,
468
- event pairs, `NO_AT_BRIDGE`, and registry-restart handling.
469
- - Orca — both as the consumer whose event diet defines "enough", and
470
- directly: its debug speech log is the acceptance test.