react-x11 1.2.0 → 2.0.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 (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -0,0 +1,484 @@
1
+ // The assistive-technology spy: what a screen reader would be told, as an
2
+ // in-process event log — no D-Bus, no bus, no async gap between the app
3
+ // changing and the assertion seeing it.
4
+ //
5
+ // ## Why this exists next to the real bridge
6
+ //
7
+ // The AT-SPI bridge (src/atspi.js) consumes the renderer through a set of
8
+ // hook slots (src/a11y.js) and a pure semantic model. Everything an AT
9
+ // learns — focus moved, a state flipped, text changed, an announcement —
10
+ // crosses those hooks before it ever becomes D-Bus. So a spy that fills
11
+ // the same slots observes the same contract with none of the transport:
12
+ // it runs on the mock backend, on Node 20 where dbus-native is not
13
+ // installed, on macOS, and it is synchronous where the wire is not. The
14
+ // bridge's own wire behaviour is covered separately (test/atspi.test.js,
15
+ // against an in-process message bus); an application's tests should not
16
+ // pay D-Bus latency to check their own labels.
17
+ //
18
+ // Because the slots are the seam, the spy also keeps the renderer honest:
19
+ // a regression that stops a hook firing fails these tests exactly as it
20
+ // would silence a real screen reader.
21
+ //
22
+ // ## Two layers per entry
23
+ //
24
+ // Every log entry is a precise fact (`{ type: 'state', state: 'checked',
25
+ // on: true, node }`) plus a `summary` string ("state: checked"). Assert on
26
+ // the facts when exactness matters; assert on `transcript()` when the
27
+ // question is "what would a user have been told" — which is also the form
28
+ // that catches the omission tree-shaped assertions structurally miss: a
29
+ // control nobody named renders as "(no accessible name)" instead of
30
+ // passing because no assertion mentioned its name.
31
+ //
32
+ // The utterances follow react-x11's **own documented model** (name, role,
33
+ // the states worth speaking, a value as a percentage) — deliberately not
34
+ // an imitation of Orca, whose wording is presentation policy that shifts
35
+ // between releases and verbosity settings. See docs/accessibility.md.
36
+ //
37
+ // ```js
38
+ // const { at, getByRole } = await renderX11(h(App), { a11y: true });
39
+ // await userEvent.tab();
40
+ // assert.equal(at.focused().utterance, 'Save, button');
41
+ // await userEvent.key(XK_space);
42
+ // assert.ok(at.since().some((e) => e.type === 'state' && e.state === 'checked'));
43
+ // ```
44
+
45
+ import {
46
+ hooks,
47
+ ATSPI_ROLE_NICK,
48
+ ATSPI_STATE,
49
+ ATSPI_STATE_NICK,
50
+ atspiRoleOf,
51
+ a11yName,
52
+ a11yStates,
53
+ a11yValue,
54
+ a11yParent,
55
+ sceneChildrenOf,
56
+ hasTextInterface,
57
+ textStateOf,
58
+ inPreedit,
59
+ diffChars,
60
+ } from '../a11y.js';
61
+
62
+ /** The states a screen reader would speak, in speaking order, with the
63
+ * words this library's model uses for them. Everything else stays quiet —
64
+ * a reader that voiced every flag would be unusable. */
65
+ const SPOKEN_STATES = [
66
+ ['checked', 'checked'],
67
+ ['indeterminate', 'partially checked'],
68
+ ['pressed', 'pressed'],
69
+ ['expanded', 'expanded'],
70
+ ['collapsed', 'collapsed'],
71
+ ['selected', 'selected'],
72
+ ];
73
+
74
+ /**
75
+ * One utterance from its parts — the shared formatter behind the spy, and
76
+ * behind `scripts/a11y-probe.mjs`, which feeds it values read over real
77
+ * D-Bus so the two can never drift apart.
78
+ *
79
+ * @param {object} parts
80
+ * @param {string} parts.name the accessible name ('' when there is none)
81
+ * @param {string} [parts.role] the AT-SPI role name ("check box", "entry")
82
+ * @param {string[]} [parts.states] state nicks ("checked", "sensitive", …)
83
+ * @param {{now: number, min?: number, max?: number} | null} [parts.value]
84
+ * @returns {string} e.g. `"Volume, slider, 30 percent"`, or
85
+ * `"(no accessible name)"` when there is nothing to say — which is the
86
+ * defect this string exists to make loud.
87
+ */
88
+ export function utteranceOf({ name, role, states = [], value = null }) {
89
+ const words = [];
90
+ if (name) words.push(name);
91
+ if (role && role !== 'filler') words.push(role);
92
+ // an empty states array means "not told", not "insensitive"
93
+ if (states.length > 0 && !states.includes('sensitive')) {
94
+ words.push('unavailable');
95
+ }
96
+ for (const [state, said] of SPOKEN_STATES) {
97
+ if (states.includes(state)) words.push(said);
98
+ }
99
+ if (value && typeof value.now === 'number') {
100
+ const span = (value.max ?? 0) - (value.min ?? 0);
101
+ words.push(
102
+ span > 0
103
+ ? `${Math.round(((value.now - (value.min ?? 0)) / span) * 100)} percent`
104
+ : String(value.now),
105
+ );
106
+ }
107
+ return words.length ? words.join(', ') : '(no accessible name)';
108
+ }
109
+
110
+ /** The set state nicks of a node, from the model's two uint32s. */
111
+ function stateNicksOf(node) {
112
+ const [lo, hi] = a11yStates(node);
113
+ const out = [];
114
+ for (const bit of Object.values(ATSPI_STATE)) {
115
+ const on = bit < 32 ? lo & (1 << bit) : hi & (1 << (bit - 32));
116
+ if (on) out.push(ATSPI_STATE_NICK[bit]);
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /** The utterance for a live node, through the same model the bridge
122
+ * serves. */
123
+ export function nodeUtterance(node) {
124
+ return utteranceOf({
125
+ name: a11yName(node),
126
+ role: ATSPI_ROLE_NICK[atspiRoleOf(node)],
127
+ states: stateNicksOf(node),
128
+ value: a11yValue(node),
129
+ });
130
+ }
131
+
132
+ /** Internal kinds that are content, not accessibility objects — the same
133
+ * exclusion the tree projection makes. */
134
+ const CONTENT_KINDS = new Set(['textchunk', 'svgchild']);
135
+
136
+ export class A11ySpy {
137
+ constructor() {
138
+ /** Every entry, oldest first. Each has `type`, `summary`, and usually
139
+ * `node`; see the handlers below for the shapes. */
140
+ this.log = [];
141
+ this._sinceIndex = 0;
142
+ this._snapshots = new WeakMap();
143
+ this._toplevels = [];
144
+ this._installed = null;
145
+ }
146
+
147
+ // ---- lifecycle -------------------------------------------------------
148
+
149
+ install() {
150
+ if (this._installed) return this;
151
+ const occupied = Object.entries(hooks)
152
+ .filter(([, slot]) => slot !== null)
153
+ .map(([name]) => name);
154
+ if (occupied.length > 0) {
155
+ throw new Error(
156
+ 'react-x11/test: the a11y hook slots are already taken ' +
157
+ `(${occupied.join(', ')}) — the AT-SPI bridge is live in this ` +
158
+ 'process. The spy and the bridge observe through the same seam, ' +
159
+ 'so run spy tests without AT_SPI_BUS_ADDRESS/REACT_X11_A11Y=1 ' +
160
+ '(react-x11/test sets NO_AT_BRIDGE for exactly this reason).',
161
+ );
162
+ }
163
+ const spy = this;
164
+ const installed = {
165
+ rootMounted(win) {
166
+ if (!spy._toplevels.includes(win)) spy._toplevels.push(win);
167
+ spy._snapshotTree(win);
168
+ },
169
+ rootUnmounted(win) {
170
+ const at = spy._toplevels.indexOf(win);
171
+ if (at !== -1) spy._toplevels.splice(at, 1);
172
+ },
173
+ attached(parent, child) {
174
+ if (spy._live(parent)) spy._snapshotTree(child);
175
+ },
176
+ detach: null,
177
+ propsChanged(node) {
178
+ spy._diffNode(node);
179
+ },
180
+ textContent(chunk) {
181
+ // the chunk's string is the content of its <text>, whose name — and
182
+ // the name of any contents-named ancestor — may just have changed
183
+ let text = chunk.parent;
184
+ while (text && text.kind === 'text' && text.isSpan) text = text.parent;
185
+ if (!text) return;
186
+ for (let n = text; n; n = a11yParent(n)) {
187
+ spy._diffNode(n);
188
+ if (n.isWindow) break;
189
+ }
190
+ },
191
+ textState(node) {
192
+ spy._diffNode(node);
193
+ },
194
+ focus(previous, next) {
195
+ // re-snapshot both ends so the FOCUSED bit never double-reports
196
+ // through a later props diff
197
+ if (previous && !previous.destroyed) spy._resnapshot(previous);
198
+ if (next && !next.destroyed) {
199
+ spy._resnapshot(next);
200
+ const utterance = nodeUtterance(next);
201
+ spy._push({
202
+ type: 'focus',
203
+ node: next,
204
+ utterance,
205
+ summary: `focus: ${utterance}`,
206
+ });
207
+ } else {
208
+ spy._push({ type: 'blur', node: previous ?? null, summary: 'blur' });
209
+ }
210
+ },
211
+ windowFocus(win, focused) {
212
+ spy._resnapshot(win);
213
+ spy._push({
214
+ type: 'window',
215
+ node: win,
216
+ focused,
217
+ summary: `window: ${focused ? 'active' : 'inactive'}`,
218
+ });
219
+ },
220
+ commit: null,
221
+ announce(text, opts) {
222
+ spy._push({
223
+ type: 'announce',
224
+ text: String(text),
225
+ assertive: Boolean(opts?.assertive),
226
+ summary: `announce: ${text}`,
227
+ });
228
+ // an announcement the test observed *was* delivered
229
+ return true;
230
+ },
231
+ };
232
+ for (const [name, fn] of Object.entries(installed)) hooks[name] = fn;
233
+ this._installed = installed;
234
+ return this;
235
+ }
236
+
237
+ /** Put the slots back. Idempotent, and careful not to evict somebody
238
+ * else's handlers if the test already replaced them. */
239
+ uninstall() {
240
+ if (!this._installed) return;
241
+ for (const [name, fn] of Object.entries(this._installed)) {
242
+ if (hooks[name] === fn) hooks[name] = null;
243
+ }
244
+ this._installed = null;
245
+ }
246
+
247
+ // ---- the log ---------------------------------------------------------
248
+
249
+ /** Everything recorded so far, oldest first. */
250
+ events() {
251
+ return [...this.log];
252
+ }
253
+
254
+ /** The `summary` line of every entry — the "what was the user told"
255
+ * view, made for `assert.deepEqual`. */
256
+ transcript() {
257
+ return this.log.map((entry) => entry.summary);
258
+ }
259
+
260
+ /** Entries recorded since the previous `since()` (or `clear()`), so a
261
+ * test reads one interaction's worth at a time. */
262
+ since() {
263
+ const fresh = this.log.slice(this._sinceIndex);
264
+ this._sinceIndex = this.log.length;
265
+ return fresh;
266
+ }
267
+
268
+ clear() {
269
+ this.log.length = 0;
270
+ this._sinceIndex = 0;
271
+ }
272
+
273
+ // ---- live queries ----------------------------------------------------
274
+
275
+ /** The focused node as an AT would describe it, or null. With several
276
+ * roots, the window that actually holds the X focus wins. */
277
+ focused() {
278
+ const win =
279
+ this._toplevels.find((w) => w.events?.windowFocused) ??
280
+ this._toplevels[0];
281
+ const manager = win?.events?.focusManager ?? win?.events;
282
+ const node = manager?.focused ?? null;
283
+ if (!node || node.destroyed) return null;
284
+ return this._describe(node);
285
+ }
286
+
287
+ /**
288
+ * Every node the keyboard can reach, in Tab order — the same sequential
289
+ * order the event manager cycles, focus scopes included. The one-line
290
+ * audit this powers:
291
+ *
292
+ * ```js
293
+ * for (const stop of at.focusables()) {
294
+ * assert.notEqual(stop.utterance, '(no accessible name)');
295
+ * }
296
+ * ```
297
+ */
298
+ focusables() {
299
+ const out = [];
300
+ for (const win of this._toplevels) {
301
+ const manager = win.events;
302
+ if (!manager?._tabbables) continue;
303
+ for (const node of manager._tabbables()) out.push(this._describe(node));
304
+ }
305
+ return out;
306
+ }
307
+
308
+ _describe(node) {
309
+ return {
310
+ node,
311
+ name: a11yName(node),
312
+ role: ATSPI_ROLE_NICK[atspiRoleOf(node)],
313
+ states: stateNicksOf(node),
314
+ utterance: nodeUtterance(node),
315
+ };
316
+ }
317
+
318
+ // ---- snapshots and diffing ------------------------------------------
319
+
320
+ _push(entry) {
321
+ this.log.push(entry);
322
+ }
323
+
324
+ _live(node) {
325
+ let n = node;
326
+ while (n?.parent) n = n.parent;
327
+ return n ? this._toplevels.includes(n) : false;
328
+ }
329
+
330
+ _snap(node) {
331
+ return {
332
+ states: a11yStates(node),
333
+ name: a11yName(node),
334
+ value: a11yValue(node)?.now ?? null,
335
+ text: hasTextInterface(node) ? textStateOf(node) : null,
336
+ };
337
+ }
338
+
339
+ _resnapshot(node) {
340
+ this._snapshots.set(node, this._snap(node));
341
+ }
342
+
343
+ _snapshotTree(node) {
344
+ if (node.destroyed || CONTENT_KINDS.has(node.kind)) return;
345
+ if (!this._snapshots.has(node)) this._resnapshot(node);
346
+ for (const child of node.children ?? []) this._snapshotTree(child);
347
+ // what an element drew is baselined with everything else, or the first
348
+ // thing that happens to an item — the selection landing on it — reads
349
+ // as a mount and says nothing
350
+ for (const item of sceneChildrenOf(node)) this._snapshotTree(item);
351
+ }
352
+
353
+ /**
354
+ * Recompute what an AT knows about a node and log the exact difference —
355
+ * the same snapshot-and-diff the bridge turns into D-Bus events, so any
356
+ * prop change produces the right entries with no per-prop wiring. A node
357
+ * seen for the first time only takes a baseline: a mount is not a
358
+ * change.
359
+ */
360
+ _diffNode(node) {
361
+ if (node.destroyed || CONTENT_KINDS.has(node.kind)) return;
362
+ const before = this._snapshots.get(node);
363
+ const now = this._snap(node);
364
+ this._snapshots.set(node, now);
365
+ if (!before) return;
366
+
367
+ if (now.name !== before.name) {
368
+ this._push({
369
+ type: 'name',
370
+ node,
371
+ name: now.name,
372
+ summary: `name: ${now.name}`,
373
+ });
374
+ }
375
+ if (now.value !== before.value && now.value !== null) {
376
+ this._push({
377
+ type: 'value',
378
+ node,
379
+ value: now.value,
380
+ summary: `value: ${now.value}`,
381
+ });
382
+ }
383
+ for (const half of [0, 1]) {
384
+ let changed = (before.states[half] ^ now.states[half]) >>> 0;
385
+ while (changed !== 0) {
386
+ const low = changed & -changed;
387
+ const bit = 31 - Math.clz32(low) + half * 32;
388
+ const on = Boolean(now.states[half] & low);
389
+ const nick = ATSPI_STATE_NICK[bit];
390
+ this._push({
391
+ type: 'state',
392
+ node,
393
+ state: nick,
394
+ on,
395
+ summary: `state: ${on ? '' : 'not '}${nick}`,
396
+ });
397
+ changed = (changed ^ low) >>> 0;
398
+ }
399
+ }
400
+ if (before.text && now.text) {
401
+ const diff = diffChars(before.text.chars, now.text.chars);
402
+ // A composition's own churn is not an edit. A preedit appearing,
403
+ // growing or being abandoned is one `preedit` entry rather than the
404
+ // insert and delete it technically is on screen — and what the
405
+ // sequence finally *commits* stays an ordinary insert, because that
406
+ // is the character the user typed. The bridge draws the same line
407
+ // with the `:system` detail suffix; this is the same rule, spoken.
408
+ const wasComposing = before.text.preedit?.text ?? '';
409
+ const isComposing = now.text.preedit?.text ?? '';
410
+ if (
411
+ diff &&
412
+ diff.removed.length > 0 &&
413
+ !inPreedit(before.text, diff.offset, diff.removed.length)
414
+ ) {
415
+ this._push({
416
+ type: 'text-delete',
417
+ node,
418
+ text: diff.removed.join(''),
419
+ offset: diff.offset,
420
+ summary: `delete: ${JSON.stringify(diff.removed.join(''))}`,
421
+ });
422
+ }
423
+ if (isComposing !== wasComposing) {
424
+ this._push({
425
+ type: 'preedit',
426
+ node,
427
+ text: isComposing,
428
+ offset: now.text.preedit?.offset ?? before.text.preedit?.offset ?? 0,
429
+ summary: isComposing
430
+ ? `preedit: ${JSON.stringify(isComposing)}`
431
+ : 'preedit: cleared',
432
+ });
433
+ }
434
+ if (
435
+ diff &&
436
+ diff.inserted.length > 0 &&
437
+ !inPreedit(now.text, diff.offset, diff.inserted.length)
438
+ ) {
439
+ this._push({
440
+ type: 'text-insert',
441
+ node,
442
+ text: diff.inserted.join(''),
443
+ offset: diff.offset,
444
+ summary: `insert: ${JSON.stringify(diff.inserted.join(''))}`,
445
+ });
446
+ }
447
+ if (!diff && now.text.caret !== before.text.caret) {
448
+ // a caret move without an edit — arrows, Home/End, a click
449
+ this._push({
450
+ type: 'caret',
451
+ node,
452
+ offset: now.text.caret,
453
+ summary: `caret: ${now.text.caret}`,
454
+ });
455
+ }
456
+ const [s0, e0] = before.text.selection;
457
+ const [s1, e1] = now.text.selection;
458
+ if ((s0 !== s1 || e0 !== e1) && s1 !== e1) {
459
+ this._push({
460
+ type: 'selection',
461
+ node,
462
+ start: s1,
463
+ end: e1,
464
+ summary: `selection: ${s1}..${e1}`,
465
+ });
466
+ }
467
+ }
468
+
469
+ // An element that draws its own children reports them through the one
470
+ // notification it makes about itself (#304), so they are diffed here
471
+ // rather than through a feed of their own — a scene item is a node to
472
+ // everything above.
473
+ for (const item of sceneChildrenOf(node)) this._diffNode(item);
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Install a fresh spy on the a11y hook slots and return it. Prefer
479
+ * `renderX11(element, { a11y: true })`, which installs before the mount —
480
+ * so the initial tree is baselined — and uninstalls in `cleanup()`.
481
+ */
482
+ export function installA11ySpy() {
483
+ return new A11ySpy().install();
484
+ }