@ccheever/exact-renderer 0.1.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 (80) hide show
  1. package/package.json +118 -0
  2. package/src/__tests__/adapter-window-state.test.tsx +190 -0
  3. package/src/__tests__/attrs.test.ts +157 -0
  4. package/src/__tests__/classname.test.ts +332 -0
  5. package/src/__tests__/color.test.ts +169 -0
  6. package/src/__tests__/dom-mirror.test.ts +682 -0
  7. package/src/__tests__/dom-shim.test.ts +274 -0
  8. package/src/__tests__/fixtures/SvelteCounter.svelte +7 -0
  9. package/src/__tests__/fixtures/SvelteInput.svelte +8 -0
  10. package/src/__tests__/host-config.test.ts +51 -0
  11. package/src/__tests__/host-ops.test.ts +2234 -0
  12. package/src/__tests__/image-source.test.ts +135 -0
  13. package/src/__tests__/liquid-glass.test.ts +72 -0
  14. package/src/__tests__/multi-root.test.ts +118 -0
  15. package/src/__tests__/native-view-events.test.ts +102 -0
  16. package/src/__tests__/nodes.test.ts +399 -0
  17. package/src/__tests__/normalize.test.ts +576 -0
  18. package/src/__tests__/paragraph-lowering.test.tsx +144 -0
  19. package/src/__tests__/props.test.ts +518 -0
  20. package/src/__tests__/protocol-encoder.test.ts +732 -0
  21. package/src/__tests__/protocol-fixture-bytes.test.ts +41 -0
  22. package/src/__tests__/reconciler.test.tsx +241 -0
  23. package/src/__tests__/svelte-adapter.test.ts +166 -0
  24. package/src/__tests__/svg-source.test.ts +71 -0
  25. package/src/__tests__/tags.test.ts +354 -0
  26. package/src/__tests__/toggle.test.ts +441 -0
  27. package/src/__tests__/transitions.test.ts +106 -0
  28. package/src/__tests__/web-primitives.test.tsx +454 -0
  29. package/src/__tests__/window-hooks.test.tsx +447 -0
  30. package/src/adapter-contract.ts +68 -0
  31. package/src/attrs.ts +596 -0
  32. package/src/classname-contract.ts +87 -0
  33. package/src/classname-resolve.ts +553 -0
  34. package/src/classname-runtime.ts +29 -0
  35. package/src/components.ts +214 -0
  36. package/src/css-variable-context.ts +83 -0
  37. package/src/dom-hydration.ts +160 -0
  38. package/src/dom-mirror.ts +1459 -0
  39. package/src/dom-shim.ts +1736 -0
  40. package/src/group-context.ts +69 -0
  41. package/src/host-config.ts +431 -0
  42. package/src/host-ops.ts +3167 -0
  43. package/src/image-source.native.ts +703 -0
  44. package/src/image-source.ts +554 -0
  45. package/src/index.ts +278 -0
  46. package/src/inspector-runtime.ts +244 -0
  47. package/src/inspector.ts +3570 -0
  48. package/src/jsx-augmentations.ts +54 -0
  49. package/src/keyboard-avoidance.ts +217 -0
  50. package/src/native-primitives.ts +43 -0
  51. package/src/native-view-events.ts +322 -0
  52. package/src/native-view.ts +60 -0
  53. package/src/nodes/index.ts +41 -0
  54. package/src/nodes/node.ts +531 -0
  55. package/src/peer-context.ts +100 -0
  56. package/src/primitives.native.ts +8 -0
  57. package/src/primitives.ts +8 -0
  58. package/src/props/index.ts +14 -0
  59. package/src/props/normalize.ts +816 -0
  60. package/src/protocol/encoder.ts +940 -0
  61. package/src/protocol/index.ts +33 -0
  62. package/src/reconciler.ts +581 -0
  63. package/src/runtime.ts +11 -0
  64. package/src/safe-area.ts +543 -0
  65. package/src/solid.ts +490 -0
  66. package/src/style/color.js +1 -0
  67. package/src/style/color.ts +15 -0
  68. package/src/style/index.js +1 -0
  69. package/src/style/index.ts +22 -0
  70. package/src/style/normalize.js +1 -0
  71. package/src/style/normalize.ts +1426 -0
  72. package/src/svelte.ts +349 -0
  73. package/src/svg-source.ts +222 -0
  74. package/src/tags/index.ts +21 -0
  75. package/src/tags/tag-map.ts +289 -0
  76. package/src/text/paragraph-lowering.ts +310 -0
  77. package/src/types.ts +1175 -0
  78. package/src/vue.ts +535 -0
  79. package/src/web-host.ts +19 -0
  80. package/src/web-primitives.ts +1654 -0
@@ -0,0 +1,3570 @@
1
+ import type {
2
+ AccessibilityNode,
3
+ AgentCapability,
4
+ AgentEvent,
5
+ ElementRef,
6
+ ElementRefInfo,
7
+ FindQuery,
8
+ FindResultData,
9
+ Frame,
10
+ HitTestBlocker,
11
+ LayoutDiagnostic,
12
+ Point,
13
+ ResolveTargetData,
14
+ ResolveTargetOcclusion,
15
+ ResolveTargetScrollAncestor,
16
+ ResolvedTarget,
17
+ RootInfo,
18
+ SafeAreaNodeSnapshotData,
19
+ TextPattern,
20
+ VisibilityState,
21
+ ViewNode,
22
+ } from '@exact/core/agent/types';
23
+ import {
24
+ resetAgentStateTracking,
25
+ subscribeAgentStateEvents,
26
+ } from '@exact/core/agent/state';
27
+ import {
28
+ recordCommitTimestamp,
29
+ } from '@exact/core/agent/interaction-state';
30
+ import {
31
+ getFacetComponents,
32
+ findFloatingPositionByViewId,
33
+ getThemeSnapshot,
34
+ } from '@exact/core/agent/experience-registry';
35
+ import {
36
+ EventType,
37
+ } from '@exact/core/protocol/opcodes';
38
+ import {
39
+ _resetAgentLogState,
40
+ recordAgentLog,
41
+ } from '@exact/core/runtime/diagnostics/logs';
42
+ import {
43
+ getRootWindowMetrics,
44
+ } from '@exact/core/window-state';
45
+ import type { CanonicalStyle } from './types.js';
46
+ import type { ElementNode, HostNode, RootNode, TextNode } from './nodes/node.js';
47
+ import { NodeKind } from './nodes/node.js';
48
+ import { getScreenDimensions } from './protocol/index.js';
49
+ import {
50
+ registerInspectorCommitAnalyzer,
51
+ registerInspectorResetHook,
52
+ } from './inspector-runtime.js';
53
+
54
+ type InspectorListener = (event: AgentEvent) => void;
55
+
56
+ interface SnapshotRecord {
57
+ snapshotId: string;
58
+ rootId: number;
59
+ stateVersion: number;
60
+ timestamp: number;
61
+ frameCount: number;
62
+ refs: Record<ElementRef, ElementRefInfo>;
63
+ tree?: ViewNode;
64
+ }
65
+
66
+ interface SerializedSnapshot {
67
+ root: ViewNode;
68
+ refs: Record<ElementRef, ElementRefInfo>;
69
+ frames: Map<number, Frame>;
70
+ }
71
+
72
+ interface AccessibilitySnapshot {
73
+ nodes: AccessibilityNode[];
74
+ refs: Record<ElementRef, ElementRefInfo>;
75
+ }
76
+
77
+ interface ResolvedElement {
78
+ root: RootNode;
79
+ element: ElementNode;
80
+ frame: Frame;
81
+ }
82
+
83
+ interface SharedInspectorState {
84
+ roots: Map<number, RootNode>;
85
+ listeners: Set<InspectorListener>;
86
+ snapshots: Map<string, SnapshotRecord>;
87
+ snapshotOrder: string[];
88
+ textOverrides: Map<number, string>;
89
+ toggleOverrides: Map<number, boolean>;
90
+ valueOverrides: Map<number, unknown>;
91
+ scrollOffsets: Map<number, Point>;
92
+ snapshotCounter: number;
93
+ stateVersion: number;
94
+ frameCount: number;
95
+ lastCommitAt: number;
96
+ }
97
+
98
+ declare global {
99
+ var __exactRendererInspectorState: SharedInspectorState | undefined;
100
+ var __exactSetInspectorScrollOffset:
101
+ | ((viewId: number, offset: Point, rootId?: number) => Point | null)
102
+ | undefined;
103
+ }
104
+
105
+ function getSharedInspectorState(): SharedInspectorState {
106
+ if (globalThis.__exactRendererInspectorState) {
107
+ return globalThis.__exactRendererInspectorState;
108
+ }
109
+
110
+ const state: SharedInspectorState = {
111
+ roots: new Map<number, RootNode>(),
112
+ listeners: new Set<InspectorListener>(),
113
+ snapshots: new Map<string, SnapshotRecord>(),
114
+ snapshotOrder: [],
115
+ textOverrides: new Map<number, string>(),
116
+ toggleOverrides: new Map<number, boolean>(),
117
+ valueOverrides: new Map<number, unknown>(),
118
+ scrollOffsets: new Map<number, Point>(),
119
+ snapshotCounter: 0,
120
+ stateVersion: 0,
121
+ frameCount: 0,
122
+ lastCommitAt: 0,
123
+ };
124
+ globalThis.__exactRendererInspectorState = state;
125
+ return state;
126
+ }
127
+
128
+ const sharedState = getSharedInspectorState();
129
+ const roots = sharedState.roots;
130
+ const listeners = sharedState.listeners;
131
+ const snapshots = sharedState.snapshots;
132
+ const snapshotOrder = sharedState.snapshotOrder;
133
+ const textOverrides = sharedState.textOverrides;
134
+ const toggleOverrides = sharedState.toggleOverrides;
135
+ const valueOverrides = sharedState.valueOverrides;
136
+ const scrollOffsets = sharedState.scrollOffsets;
137
+
138
+ const MAX_SNAPSHOTS = 64;
139
+ const AGENT_CAPABILITIES: AgentCapability[] = [
140
+ 'capabilities',
141
+ 'access',
142
+ 'screenshot',
143
+ 'tree',
144
+ 'layout',
145
+ 'diagnostics',
146
+ 'accessibility',
147
+ 'accessibility-audit',
148
+ 'preferences',
149
+ 'announce',
150
+ 'roots',
151
+ 'snapshot',
152
+ 'find',
153
+ 'resolve-target',
154
+ 'logs',
155
+ 'network',
156
+ 'focus',
157
+ 'action',
158
+ 'navigation',
159
+ 'navigation-graph',
160
+ 'perf',
161
+ 'environment',
162
+ 'state',
163
+ 'tap',
164
+ 'type',
165
+ 'set-value',
166
+ 'scroll',
167
+ 'host-scroll',
168
+ 'event-trace',
169
+ 'native-inspect',
170
+ 'native-hit-test',
171
+ 'native-perform',
172
+ 'native-trace',
173
+ 'native-capture',
174
+ 'native-probe',
175
+ 'protocol-tap',
176
+ 'contract-dataflow',
177
+ 'gesture',
178
+ 'run-js',
179
+ 'wait',
180
+ 'stream',
181
+ 'hit-test',
182
+ 'act',
183
+ 'extract',
184
+ 'observe',
185
+ 'assert-visual',
186
+ 'app-content',
187
+ 'app-affordances',
188
+ 'app-intents',
189
+ 'app-go-back',
190
+ 'floating-positions',
191
+ 'theme',
192
+ 'facet-components',
193
+ // LLP 0140: additive interface-level capability tokens.
194
+ 'mutation.observe-after',
195
+ 'carrier-discovery',
196
+ ];
197
+
198
+ function emit(event: AgentEvent): void {
199
+ for (const listener of listeners) {
200
+ try {
201
+ listener(event);
202
+ } catch (error) {
203
+ console.error('[RendererInspector] listener failed:', error);
204
+ }
205
+ }
206
+ }
207
+
208
+ subscribeAgentStateEvents((event) => {
209
+ emit(event);
210
+ });
211
+
212
+ function clearInteractionOverrides(): void {
213
+ textOverrides.clear();
214
+ toggleOverrides.clear();
215
+ valueOverrides.clear();
216
+ scrollOffsets.clear();
217
+ }
218
+
219
+ function currentTimestamp(): number {
220
+ return Date.now();
221
+ }
222
+
223
+ function findLatestSnapshotRecord(rootId?: number): SnapshotRecord | null {
224
+ for (let index = snapshotOrder.length - 1; index >= 0; index -= 1) {
225
+ const snapshotId = snapshotOrder[index]!;
226
+ const record = snapshots.get(snapshotId);
227
+ if (!record) {
228
+ continue;
229
+ }
230
+ if (rootId == null || record.rootId === rootId) {
231
+ return record;
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+
237
+ function nextSnapshotId(): string {
238
+ sharedState.snapshotCounter += 1;
239
+ return `s_${currentTimestamp()}_${sharedState.snapshotCounter}`;
240
+ }
241
+
242
+ function makeRootInfo(root: RootNode): RootInfo {
243
+ const { width, height } = getScreenDimensions(root.rootId);
244
+ return {
245
+ rootId: root.rootId,
246
+ label: root.rootId === 0 ? 'Main' : `Root ${root.rootId}`,
247
+ size: {
248
+ width,
249
+ height,
250
+ },
251
+ };
252
+ }
253
+
254
+ function getDefaultDimension(base: number, fallback: number): number {
255
+ return Number.isFinite(base) && base > 0 ? base : fallback;
256
+ }
257
+
258
+ function resolveDimension(
259
+ value: unknown,
260
+ base: number,
261
+ fallback: number,
262
+ ): number {
263
+ if (typeof value === 'number' && Number.isFinite(value)) {
264
+ return value;
265
+ }
266
+
267
+ if (
268
+ typeof value === 'object' &&
269
+ value !== null &&
270
+ 'type' in value &&
271
+ 'value' in value &&
272
+ typeof (value as { value?: unknown }).value === 'number'
273
+ ) {
274
+ const dimension = value as { type?: unknown; value: number };
275
+ if (dimension.type === 'points') {
276
+ return dimension.value;
277
+ }
278
+ if (dimension.type === 'percent') {
279
+ return base * (dimension.value / 100);
280
+ }
281
+ }
282
+
283
+ return fallback;
284
+ }
285
+
286
+ function getStyleDimension(
287
+ style: CanonicalStyle,
288
+ key: keyof CanonicalStyle,
289
+ base: number,
290
+ fallback: number,
291
+ ): number {
292
+ return resolveDimension(style[key], base, fallback);
293
+ }
294
+
295
+ function getNodeText(node: ElementNode): string {
296
+ if (node.props.textContent) {
297
+ return node.props.textContent;
298
+ }
299
+
300
+ let text = '';
301
+ for (const child of node.children) {
302
+ if (child.kind === NodeKind.Text) {
303
+ text += child.text;
304
+ continue;
305
+ }
306
+ text += getNodeText(child);
307
+ }
308
+ return text.trim();
309
+ }
310
+
311
+ function getTestId(node: ElementNode): string | undefined {
312
+ const candidate =
313
+ node.originalProps.testId ??
314
+ node.originalProps.testID ??
315
+ node.originalProps['data-testid'] ??
316
+ node.originalProps['data-test-id'] ??
317
+ node.originalProps.id;
318
+ return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined;
319
+ }
320
+
321
+ function getEffectiveTextValue(node: ElementNode): string | undefined {
322
+ if (textOverrides.has(node.id)) {
323
+ return textOverrides.get(node.id);
324
+ }
325
+ return node.props.value;
326
+ }
327
+
328
+ function getEffectiveToggleValue(node: ElementNode): boolean | undefined {
329
+ if (toggleOverrides.has(node.id)) {
330
+ return toggleOverrides.get(node.id);
331
+ }
332
+ return node.props.toggleValue;
333
+ }
334
+
335
+ function getElementLabel(
336
+ node: ElementNode,
337
+ labelledByIndex?: Map<string, string>,
338
+ ): string | undefined {
339
+ if (node.props.accessibilityLabel) {
340
+ return node.props.accessibilityLabel;
341
+ }
342
+
343
+ if (
344
+ typeof node.props.accessibilityLabelledBy === 'string' &&
345
+ labelledByIndex?.has(node.props.accessibilityLabelledBy)
346
+ ) {
347
+ return labelledByIndex.get(node.props.accessibilityLabelledBy);
348
+ }
349
+
350
+ if (node.tagType === 'input') {
351
+ return getEffectiveTextValue(node) ?? node.props.placeholder;
352
+ }
353
+
354
+ if (node.tagType === 'toggle') {
355
+ return node.props.textContent ?? getNodeText(node);
356
+ }
357
+
358
+ const text = getNodeText(node);
359
+ return text || undefined;
360
+ }
361
+
362
+ function getImplicitRole(node: ElementNode): string {
363
+ switch (node.tagType) {
364
+ case 'pressable':
365
+ return 'button';
366
+ case 'input':
367
+ return 'textbox';
368
+ case 'toggle':
369
+ return 'switch';
370
+ case 'scroll':
371
+ return 'scrollview';
372
+ case 'image':
373
+ return 'image';
374
+ case 'text':
375
+ return 'text';
376
+ default:
377
+ return 'none';
378
+ }
379
+ }
380
+
381
+ function getRole(node: ElementNode): string {
382
+ if (typeof node.props.accessibilityRole === 'string' && node.props.accessibilityRole.length > 0) {
383
+ return node.props.accessibilityRole;
384
+ }
385
+
386
+ return getImplicitRole(node);
387
+ }
388
+
389
+ function getAccessibilityText(node: ElementNode): string | undefined {
390
+ const text = getNodeText(node);
391
+ return text.length > 0 ? text : undefined;
392
+ }
393
+
394
+ function getAccessibilityValue(node: ElementNode): string | undefined {
395
+ if (typeof node.props.accessibilityValueText === 'string' && node.props.accessibilityValueText.length > 0) {
396
+ return node.props.accessibilityValueText;
397
+ }
398
+ if (typeof node.props.accessibilityValueNow === 'number' && Number.isFinite(node.props.accessibilityValueNow)) {
399
+ return String(node.props.accessibilityValueNow);
400
+ }
401
+ if (node.tagType === 'toggle') {
402
+ const toggleValue = getEffectiveToggleValue(node);
403
+ if (typeof toggleValue === 'boolean') {
404
+ return toggleValue ? 'true' : 'false';
405
+ }
406
+ }
407
+ return getEffectiveTextValue(node);
408
+ }
409
+
410
+ function getAccessibilityCheckedState(
411
+ node: ElementNode,
412
+ ): AccessibilityNode['checked'] | undefined {
413
+ const checked = node.props.accessibilityChecked;
414
+ if (checked === 'mixed') {
415
+ return 'mixed';
416
+ }
417
+ if (typeof checked === 'boolean') {
418
+ return checked;
419
+ }
420
+ const toggleValue = getEffectiveToggleValue(node);
421
+ return typeof toggleValue === 'boolean' ? toggleValue : undefined;
422
+ }
423
+
424
+ function getAccessibilityLiveMode(node: ElementNode): AccessibilityNode['live'] | undefined {
425
+ const live = node.props.accessibilityLive;
426
+ if (live === 'polite' || live === 'assertive') {
427
+ return live;
428
+ }
429
+ return 'off';
430
+ }
431
+
432
+ function getAccessibilityActions(node: ElementNode): AccessibilityNode['actions'] {
433
+ const rawActions = node.props.accessibilityActions ?? node.originalProps.accessibilityActions;
434
+ const parsed =
435
+ typeof rawActions === 'string'
436
+ ? (() => {
437
+ try {
438
+ return JSON.parse(rawActions) as unknown;
439
+ } catch {
440
+ return [];
441
+ }
442
+ })()
443
+ : rawActions;
444
+
445
+ if (!Array.isArray(parsed)) {
446
+ return [];
447
+ }
448
+
449
+ return parsed.flatMap((action): AccessibilityNode['actions'] => {
450
+ if (!action || typeof action !== 'object') {
451
+ return [];
452
+ }
453
+ const candidate = action as { name?: unknown; label?: unknown };
454
+ if (typeof candidate.name !== 'string' || candidate.name.trim().length === 0) {
455
+ return [];
456
+ }
457
+ const name = candidate.name.trim();
458
+ const label =
459
+ typeof candidate.label === 'string' && candidate.label.trim().length > 0
460
+ ? candidate.label.trim()
461
+ : name;
462
+ return [{ name, label }];
463
+ });
464
+ }
465
+
466
+ function shouldHideAccessibilityElement(node: ElementNode): boolean {
467
+ return node.props.inert === true || node.props.accessibilityElementsHidden === true;
468
+ }
469
+
470
+ function shouldFlattenAccessibilityNode(
471
+ node: ElementNode,
472
+ role: string,
473
+ label: string | undefined,
474
+ hint: string | undefined,
475
+ actions: AccessibilityNode['actions'],
476
+ focusable: boolean,
477
+ childIds: string[],
478
+ ): boolean {
479
+ return (
480
+ (role === 'generic' || role === 'none') &&
481
+ !label &&
482
+ !hint &&
483
+ (actions?.length ?? 0) === 0 &&
484
+ !focusable &&
485
+ node.props.accessibilityBusy !== true &&
486
+ node.props.accessibilityModal !== true &&
487
+ node.props.accessibilityExpanded === undefined &&
488
+ node.props.accessibilitySelected === undefined &&
489
+ getAccessibilityCheckedState(node) === undefined &&
490
+ childIds.length > 0 &&
491
+ node.tagType !== 'text'
492
+ );
493
+ }
494
+
495
+ function parseVariantProps(raw: unknown): Record<string, unknown> | undefined {
496
+ if (typeof raw !== 'string' || raw.length === 0) {
497
+ return undefined;
498
+ }
499
+
500
+ try {
501
+ const parsed = JSON.parse(raw);
502
+ return typeof parsed === 'object' && parsed !== null
503
+ ? parsed as Record<string, unknown>
504
+ : undefined;
505
+ } catch {
506
+ return undefined;
507
+ }
508
+ }
509
+
510
+ function parseNativeViewProps(raw: unknown): Record<string, unknown> | undefined {
511
+ if (typeof raw !== 'string' || raw.length === 0) {
512
+ return undefined;
513
+ }
514
+
515
+ try {
516
+ const parsed = JSON.parse(raw);
517
+ return typeof parsed === 'object' && parsed !== null
518
+ ? parsed as Record<string, unknown>
519
+ : undefined;
520
+ } catch {
521
+ return undefined;
522
+ }
523
+ }
524
+
525
+ // A single inspector snapshot needs to answer two global interaction questions:
526
+ // which modal, if any, currently owns input focus; and whether a given element
527
+ // is therefore effectively inert even if its own props look enabled. We cache
528
+ // that root-level answer once per tree walk so tree serialization, diagnostics,
529
+ // hit testing, and resolve-target all use the same modal view of the world.
530
+ interface InteractionContext {
531
+ activeModal: ElementNode | null;
532
+ }
533
+
534
+ function focusScopeModeFromValue(value: unknown): string | undefined {
535
+ if (typeof value === 'string' && value.length > 0) {
536
+ return value;
537
+ }
538
+ if (value === true) {
539
+ return 'contained';
540
+ }
541
+ return undefined;
542
+ }
543
+
544
+ function isActiveModalNode(node: ElementNode): boolean {
545
+ return (
546
+ node.props.accessibilityModal === true &&
547
+ node.props.__exactPresencePhase !== 'exiting'
548
+ );
549
+ }
550
+
551
+ function buildInteractionContext(root: RootNode): InteractionContext {
552
+ let activeModal: ElementNode | null = null;
553
+
554
+ const walk = (node: HostNode): void => {
555
+ if (node.kind === NodeKind.Element && isActiveModalNode(node)) {
556
+ // Later visible modals replace earlier ones so the foreground overlay is
557
+ // the one that blocks background interactions in the derived snapshot.
558
+ activeModal = node;
559
+ }
560
+
561
+ if (node.kind === NodeKind.Element || node.kind === NodeKind.Root) {
562
+ for (const child of node.children) {
563
+ walk(child);
564
+ }
565
+ }
566
+ };
567
+
568
+ walk(root);
569
+ return {
570
+ activeModal,
571
+ };
572
+ }
573
+
574
+ function isDescendantOfElement(
575
+ node: HostNode | null,
576
+ ancestor: ElementNode,
577
+ ): boolean {
578
+ let current = node;
579
+ while (current) {
580
+ if (current.kind === NodeKind.Element && current.id === ancestor.id) {
581
+ return true;
582
+ }
583
+ current = current.parent as HostNode | null;
584
+ }
585
+ return false;
586
+ }
587
+
588
+ function blockingModalForElement(
589
+ node: ElementNode,
590
+ interactionContext: InteractionContext,
591
+ ): ElementNode | null {
592
+ const activeModal = interactionContext.activeModal;
593
+ if (!activeModal) {
594
+ return null;
595
+ }
596
+
597
+ return isDescendantOfElement(node, activeModal) ? null : activeModal;
598
+ }
599
+
600
+ function recordBehaviorWarning(
601
+ message: string,
602
+ rootId: number,
603
+ context: Record<string, unknown>,
604
+ ): void {
605
+ recordAgentLog({
606
+ source: 'runtime',
607
+ level: 'warn',
608
+ message,
609
+ rootId,
610
+ context: {
611
+ category: 'behavior',
612
+ ...context,
613
+ },
614
+ });
615
+ }
616
+
617
+ function recordComponentWarning(
618
+ message: string,
619
+ rootId: number,
620
+ context: Record<string, unknown>,
621
+ ): void {
622
+ recordAgentLog({
623
+ source: 'runtime',
624
+ level: 'warn',
625
+ message,
626
+ rootId,
627
+ context: {
628
+ category: 'component',
629
+ ...context,
630
+ },
631
+ });
632
+ }
633
+
634
+ function buildNativeIdIndex(elements: ElementNode[]): Map<string, ElementNode> {
635
+ const nativeIds = new Map<string, ElementNode>();
636
+
637
+ for (const element of elements) {
638
+ if (typeof element.props.nativeID === 'string' && element.props.nativeID.length > 0) {
639
+ nativeIds.set(element.props.nativeID, element);
640
+ }
641
+ const testId = getTestId(element);
642
+ if (testId) {
643
+ nativeIds.set(testId, element);
644
+ }
645
+ }
646
+
647
+ return nativeIds;
648
+ }
649
+
650
+ function buildLabelledByIndex(elements: ElementNode[]): Map<string, string> {
651
+ const labels = new Map<string, string>();
652
+
653
+ for (const element of elements) {
654
+ const text = getNodeText(element);
655
+ const label =
656
+ typeof element.props.accessibilityLabel === 'string' && element.props.accessibilityLabel.length > 0
657
+ ? element.props.accessibilityLabel
658
+ : text.length > 0
659
+ ? text
660
+ : undefined;
661
+ if (!label) {
662
+ continue;
663
+ }
664
+ if (typeof element.props.nativeID === 'string' && element.props.nativeID.length > 0) {
665
+ labels.set(element.props.nativeID, label);
666
+ }
667
+ const testId = getTestId(element);
668
+ if (testId) {
669
+ labels.set(testId, label);
670
+ }
671
+ }
672
+
673
+ return labels;
674
+ }
675
+
676
+ function hasFocusableDescendants(node: ElementNode): boolean {
677
+ for (const child of node.children) {
678
+ if (child.kind !== NodeKind.Element) {
679
+ continue;
680
+ }
681
+
682
+ if (isElementInteractable(child)) {
683
+ return true;
684
+ }
685
+
686
+ if (hasFocusableDescendants(child)) {
687
+ return true;
688
+ }
689
+ }
690
+
691
+ return false;
692
+ }
693
+
694
+ function analyzeCommitWarnings(root: RootNode): void {
695
+ // Retained runtime warnings are emitted at commit time so `/agent/logs`
696
+ // reflects what the app actually rendered, not just what component authors
697
+ // intended to wire. That makes the warnings actionable for both humans and
698
+ // automated doctor-style audits.
699
+ const elements: ElementNode[] = [];
700
+ flattenElements(root, elements);
701
+ const nativeIds = buildNativeIdIndex(elements);
702
+
703
+ for (const element of elements) {
704
+ if (
705
+ typeof element.props.accessibilityLabelledBy === 'string' &&
706
+ !nativeIds.has(element.props.accessibilityLabelledBy)
707
+ ) {
708
+ recordBehaviorWarning('accessibilityLabelledBy target was not found.', root.rootId, {
709
+ subcategory: 'labelledBy',
710
+ viewId: element.id,
711
+ targetId: element.props.accessibilityLabelledBy,
712
+ });
713
+ }
714
+
715
+ if (
716
+ typeof element.props.accessibilityDescribedBy === 'string' &&
717
+ !nativeIds.has(element.props.accessibilityDescribedBy)
718
+ ) {
719
+ recordBehaviorWarning('accessibilityDescribedBy target was not found.', root.rootId, {
720
+ subcategory: 'describedBy',
721
+ viewId: element.id,
722
+ targetId: element.props.accessibilityDescribedBy,
723
+ });
724
+ }
725
+
726
+ const focusScopeMode = focusScopeModeFromValue(element.props.focusScope);
727
+ if (focusScopeMode && !hasFocusableDescendants(element)) {
728
+ recordBehaviorWarning('Focus scope has no focusable descendants.', root.rootId, {
729
+ subcategory: 'focusScope',
730
+ viewId: element.id,
731
+ mode: focusScopeMode,
732
+ });
733
+ }
734
+
735
+ const componentName = typeof element.props.__exactComponentName === 'string'
736
+ ? element.props.__exactComponentName.split('.')[0] ?? element.props.__exactComponentName
737
+ : undefined;
738
+ const slotName =
739
+ typeof element.props.__exactComponentSlot === 'string'
740
+ ? element.props.__exactComponentSlot
741
+ : undefined;
742
+
743
+ if (
744
+ (componentName === 'Dialog' || componentName === 'AlertDialog' || componentName === 'Sheet') &&
745
+ slotName === 'content' &&
746
+ typeof element.props.accessibilityLabelledBy !== 'string'
747
+ ) {
748
+ recordComponentWarning('Facet dialog content is missing accessibilityLabelledBy wiring.', root.rootId, {
749
+ subcategory: 'missing-label',
750
+ componentName,
751
+ viewId: element.id,
752
+ });
753
+ }
754
+
755
+ if (
756
+ componentName === 'Toast' &&
757
+ slotName === 'root' &&
758
+ typeof element.props.accessibilityLive !== 'string'
759
+ ) {
760
+ recordComponentWarning('Facet toast root is missing accessibilityLive semantics.', root.rootId, {
761
+ subcategory: 'missing-live-region',
762
+ componentName,
763
+ viewId: element.id,
764
+ });
765
+ }
766
+ }
767
+ }
768
+
769
+ registerInspectorCommitAnalyzer(analyzeCommitWarnings);
770
+
771
+ function resetFullInspectorAgentState(): void {
772
+ _resetAgentLogState();
773
+ resetAgentStateTracking();
774
+ }
775
+
776
+ registerInspectorResetHook(resetFullInspectorAgentState);
777
+
778
+ function buildBehaviorMetadata(node: ElementNode): ViewNode['behaviors'] {
779
+ const behaviors: NonNullable<ViewNode['behaviors']> = {};
780
+ const semanticState: NonNullable<NonNullable<ViewNode['behaviors']>['semanticState']> = {};
781
+
782
+ if (node.props.focusScope !== undefined) {
783
+ behaviors.focusScope = node.props.focusScope;
784
+ }
785
+ if (node.props.__exactFocusRestore === true) {
786
+ behaviors.focusRestore = true;
787
+ }
788
+ if (node.props.inert === true) {
789
+ behaviors.inert = true;
790
+ }
791
+ if (node.props.scrollLocked === true) {
792
+ behaviors.scrollLocked = true;
793
+ }
794
+ if (node.props.__exactDismissableLayer === true) {
795
+ behaviors.dismissableLayer = true;
796
+ }
797
+ if (typeof node.props.__exactDismissAction === 'string') {
798
+ behaviors.dismissAction = node.props.__exactDismissAction;
799
+ }
800
+ if (typeof node.props.accessibilityRole === 'string') {
801
+ behaviors.accessibilityRole = node.props.accessibilityRole;
802
+ }
803
+ if (typeof node.props.accessibilityModal === 'boolean') {
804
+ behaviors.accessibilityModal = node.props.accessibilityModal;
805
+ }
806
+ if (typeof node.props.accessibilityExpanded === 'boolean') {
807
+ semanticState.expanded = node.props.accessibilityExpanded;
808
+ }
809
+ if (typeof node.props.accessibilitySelected === 'boolean') {
810
+ semanticState.selected = node.props.accessibilitySelected;
811
+ }
812
+ if (
813
+ typeof node.props.accessibilityChecked === 'boolean' ||
814
+ node.props.accessibilityChecked === 'mixed'
815
+ ) {
816
+ semanticState.checked = node.props.accessibilityChecked;
817
+ }
818
+ if (typeof node.props.disabled === 'boolean') {
819
+ semanticState.disabled = node.props.disabled;
820
+ } else if (typeof node.props.accessibilityDisabled === 'boolean') {
821
+ semanticState.disabled = node.props.accessibilityDisabled;
822
+ }
823
+ if (typeof node.props.accessibilityValueText === 'string') {
824
+ semanticState.valueText = node.props.accessibilityValueText;
825
+ }
826
+ if (Object.keys(semanticState).length > 0) {
827
+ behaviors.semanticState = semanticState;
828
+ }
829
+
830
+ return Object.keys(behaviors).length > 0 ? behaviors : undefined;
831
+ }
832
+
833
+ function buildAnchorMetadata(node: ElementNode): ViewNode['anchor'] {
834
+ if (typeof node.props.__exactAnchorTarget !== 'string') {
835
+ return undefined;
836
+ }
837
+
838
+ return {
839
+ target: node.props.__exactAnchorTarget,
840
+ placement:
841
+ typeof node.props.__exactAnchorPlacement === 'string'
842
+ ? node.props.__exactAnchorPlacement
843
+ : undefined,
844
+ strategy:
845
+ typeof node.props.__exactAnchorStrategy === 'string'
846
+ ? node.props.__exactAnchorStrategy
847
+ : undefined,
848
+ offset:
849
+ typeof node.props.__exactAnchorOffset === 'number'
850
+ ? node.props.__exactAnchorOffset
851
+ : undefined,
852
+ };
853
+ }
854
+
855
+ function buildPortalMetadata(node: ElementNode): ViewNode['portal'] {
856
+ if (
857
+ node.props.portalTarget === undefined &&
858
+ node.props.__exactPortalLevel === undefined &&
859
+ node.props.__exactPortalPresentation === undefined
860
+ ) {
861
+ return undefined;
862
+ }
863
+
864
+ return {
865
+ target: typeof node.props.portalTarget === 'string' ? node.props.portalTarget : undefined,
866
+ level:
867
+ typeof node.props.__exactPortalLevel === 'string'
868
+ ? node.props.__exactPortalLevel
869
+ : undefined,
870
+ presentation:
871
+ typeof node.props.__exactPortalPresentation === 'string'
872
+ ? node.props.__exactPortalPresentation
873
+ : undefined,
874
+ };
875
+ }
876
+
877
+ function buildDevProvenance(node: ElementNode): ViewNode['devProvenance'] {
878
+ if (
879
+ node.props.__exactComponentName === undefined &&
880
+ node.props.__exactSourceFilePath === undefined
881
+ ) {
882
+ return undefined;
883
+ }
884
+
885
+ return {
886
+ componentName:
887
+ typeof node.props.__exactComponentName === 'string'
888
+ ? node.props.__exactComponentName
889
+ : undefined,
890
+ slotName:
891
+ typeof node.props.__exactComponentSlot === 'string'
892
+ ? node.props.__exactComponentSlot
893
+ : undefined,
894
+ sourceFilePath:
895
+ typeof node.props.__exactSourceFilePath === 'string'
896
+ ? node.props.__exactSourceFilePath
897
+ : undefined,
898
+ variantProps: parseVariantProps(node.props.__exactVariantProps),
899
+ };
900
+ }
901
+
902
+ function buildSafeAreaSnapshot(
903
+ node: ElementNode,
904
+ ): SafeAreaNodeSnapshotData | undefined {
905
+ const safeArea = node.safeAreaState;
906
+ if (!safeArea) {
907
+ return undefined;
908
+ }
909
+
910
+ return {
911
+ strategy: safeArea.strategy,
912
+ regions: [...safeArea.config.regions],
913
+ edges: { ...safeArea.config.edges },
914
+ sourceRegions: {
915
+ container: { ...safeArea.sourceRegions.container },
916
+ displayCutout: { ...safeArea.sourceRegions.displayCutout },
917
+ gestures: { ...safeArea.sourceRegions.gestures },
918
+ },
919
+ siblingExpansion: { ...safeArea.siblingExpansion },
920
+ availableInsets: { ...safeArea.availableInsets },
921
+ keyboardOverlap: { ...safeArea.keyboardOverlap },
922
+ appliedInsets: { ...safeArea.appliedInsets },
923
+ remainingRegions: {
924
+ container: { ...safeArea.remainingRegions.container },
925
+ displayCutout: { ...safeArea.remainingRegions.displayCutout },
926
+ gestures: { ...safeArea.remainingRegions.gestures },
927
+ },
928
+ keyboardState: {
929
+ ...safeArea.keyboardState,
930
+ occlusion: { ...safeArea.keyboardState.occlusion },
931
+ },
932
+ };
933
+ }
934
+
935
+ function findFacetComponentInfo(node: ElementNode): ViewNode['component'] {
936
+ const componentName = node.props.__exactComponentName;
937
+ if (typeof componentName !== 'string' || componentName.length === 0) {
938
+ return undefined;
939
+ }
940
+
941
+ const baseName = componentName.split('.')[0] ?? componentName;
942
+ return getFacetComponents().find((entry) => entry.name === baseName);
943
+ }
944
+
945
+ function computeWhyNotInteractable(
946
+ node: ElementNode,
947
+ floating: ViewNode['floating'],
948
+ presencePhase: ViewNode['presencePhase'],
949
+ interactionContext: InteractionContext,
950
+ ): string | undefined {
951
+ if (node.props.disabled === true || node.props.accessibilityDisabled === true) {
952
+ return 'disabled';
953
+ }
954
+ if (node.props.inert === true) {
955
+ return 'inert-explicit';
956
+ }
957
+ if (isElementInteractable(node) && blockingModalForElement(node, interactionContext)) {
958
+ return 'inert-modal';
959
+ }
960
+ if (presencePhase === 'exiting') {
961
+ return 'presence-exiting';
962
+ }
963
+ if (floating?.scrolling === true) {
964
+ return 'floating-scrolling';
965
+ }
966
+ if (floating?.state === 'premeasure') {
967
+ return 'floating-premeasure';
968
+ }
969
+ if (
970
+ floating?.state === 'hidden-anchor-escaped' ||
971
+ floating?.state === 'hidden-reference-hidden' ||
972
+ floating?.state === 'hidden-anchor-removed'
973
+ ) {
974
+ return 'floating-hidden';
975
+ }
976
+ return undefined;
977
+ }
978
+
979
+ function isElementInteractable(node: ElementNode): boolean {
980
+ if (node.props.inert || node.props.disabled || node.props.accessibilityDisabled) {
981
+ return false;
982
+ }
983
+
984
+ return (
985
+ node.events.has(EventType.Press) ||
986
+ node.events.has(EventType.PressIn) ||
987
+ node.events.has(EventType.PressOut) ||
988
+ node.events.has(EventType.LongPress) ||
989
+ node.events.has(EventType.Change) ||
990
+ node.events.has(EventType.Focus) ||
991
+ node.events.has(EventType.KeyDown) ||
992
+ node.props.focusable === true ||
993
+ node.tagType === 'pressable' ||
994
+ node.tagType === 'input' ||
995
+ node.tagType === 'toggle' ||
996
+ node.tagType === 'scroll'
997
+ );
998
+ }
999
+
1000
+ function getResolvedTarget(node: ElementNode, ref?: ElementRef): ResolvedTarget {
1001
+ return {
1002
+ ref,
1003
+ viewId: node.id,
1004
+ type: node.originalTag,
1005
+ label: getElementLabel(node),
1006
+ testId: getTestId(node),
1007
+ };
1008
+ }
1009
+
1010
+ function approximateTextFrame(text: string, parentFrame: Frame): Frame {
1011
+ return {
1012
+ x: parentFrame.x,
1013
+ y: parentFrame.y,
1014
+ width: Math.max(0, Math.min(parentFrame.width, text.length * 7)),
1015
+ height: 18,
1016
+ };
1017
+ }
1018
+
1019
+ function getDefaultNodeSize(
1020
+ node: ElementNode,
1021
+ availableWidth: number,
1022
+ availableHeight: number,
1023
+ ): { width: number; height: number } {
1024
+ switch (node.tagType) {
1025
+ case 'text':
1026
+ return {
1027
+ width: Math.min(availableWidth, Math.max(32, getNodeText(node).length * 7)),
1028
+ height: 20,
1029
+ };
1030
+ case 'input':
1031
+ return { width: Math.min(availableWidth, 240), height: 44 };
1032
+ case 'toggle':
1033
+ return { width: Math.min(availableWidth, 120), height: 32 };
1034
+ case 'image':
1035
+ return { width: Math.min(availableWidth, 120), height: 120 };
1036
+ case 'pressable':
1037
+ return { width: Math.min(availableWidth, 160), height: 44 };
1038
+ default:
1039
+ return {
1040
+ width: getDefaultDimension(availableWidth, 120),
1041
+ height: Math.min(getDefaultDimension(availableHeight, 120), 120),
1042
+ };
1043
+ }
1044
+ }
1045
+
1046
+ function resolveOptionalDimension(value: unknown, base: number): number | undefined {
1047
+ const resolved = resolveDimension(value, base, Number.NaN);
1048
+ return Number.isFinite(resolved) ? Math.max(0, resolved) : undefined;
1049
+ }
1050
+
1051
+ function getStyleNumber(style: CanonicalStyle, key: keyof CanonicalStyle): number | undefined {
1052
+ const value = style[key];
1053
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
1054
+ }
1055
+
1056
+ function constrainSize(
1057
+ style: CanonicalStyle,
1058
+ width: number,
1059
+ height: number,
1060
+ containingWidth: number,
1061
+ containingHeight: number,
1062
+ ): { width: number; height: number } {
1063
+ const minWidth = resolveOptionalDimension(style.minWidth, containingWidth);
1064
+ const minHeight = resolveOptionalDimension(style.minHeight, containingHeight);
1065
+ const maxWidth = resolveOptionalDimension(style.maxWidth, containingWidth);
1066
+ const maxHeight = resolveOptionalDimension(style.maxHeight, containingHeight);
1067
+
1068
+ let constrainedWidth = width;
1069
+ let constrainedHeight = height;
1070
+
1071
+ if (minWidth !== undefined) constrainedWidth = Math.max(constrainedWidth, minWidth);
1072
+ if (minHeight !== undefined) constrainedHeight = Math.max(constrainedHeight, minHeight);
1073
+ if (maxWidth !== undefined) constrainedWidth = Math.min(constrainedWidth, maxWidth);
1074
+ if (maxHeight !== undefined) constrainedHeight = Math.min(constrainedHeight, maxHeight);
1075
+
1076
+ return {
1077
+ width: Math.max(0, constrainedWidth),
1078
+ height: Math.max(0, constrainedHeight),
1079
+ };
1080
+ }
1081
+
1082
+ function getMainAxisGap(style: CanonicalStyle, direction: CanonicalStyle['flexDirection']): number {
1083
+ const rawGap = direction === 'row'
1084
+ ? getStyleNumber(style, 'columnGap')
1085
+ : getStyleNumber(style, 'rowGap');
1086
+ return rawGap !== undefined ? Math.max(0, rawGap) : 0;
1087
+ }
1088
+
1089
+ function getCrossAxisGap(style: CanonicalStyle, direction: CanonicalStyle['flexDirection']): number {
1090
+ const rawGap = direction === 'row'
1091
+ ? getStyleNumber(style, 'rowGap')
1092
+ : getStyleNumber(style, 'columnGap');
1093
+ return rawGap !== undefined ? Math.max(0, rawGap) : 0;
1094
+ }
1095
+
1096
+ function hasFlexWrap(style: CanonicalStyle): boolean {
1097
+ return style.flexWrap === 'wrap' || style.flexWrap === 'wrap-reverse';
1098
+ }
1099
+
1100
+ function computeIntrinsicElementSize(
1101
+ node: ElementNode,
1102
+ availableWidth: number,
1103
+ availableHeight: number,
1104
+ depth = 0,
1105
+ ): { width: number; height: number } {
1106
+ const defaultSize = getDefaultNodeSize(node, availableWidth, availableHeight);
1107
+ const explicitWidth = resolveOptionalDimension(node.style.width, availableWidth);
1108
+ const explicitHeight = resolveOptionalDimension(node.style.height, availableHeight);
1109
+
1110
+ const canUseChildIntrinsicSize =
1111
+ node.tagType === 'view' ||
1112
+ node.tagType === 'scroll' ||
1113
+ (node.tagType === 'pressable' && node.children.length > 0);
1114
+
1115
+ if (depth > 16 || !canUseChildIntrinsicSize) {
1116
+ return constrainSize(
1117
+ node.style,
1118
+ explicitWidth ?? defaultSize.width,
1119
+ explicitHeight ?? defaultSize.height,
1120
+ availableWidth,
1121
+ availableHeight,
1122
+ );
1123
+ }
1124
+
1125
+ const paddingTop = getStyleDimension(node.style, 'paddingTop', availableHeight, 0);
1126
+ const paddingRight = getStyleDimension(node.style, 'paddingRight', availableWidth, 0);
1127
+ const paddingBottom = getStyleDimension(node.style, 'paddingBottom', availableHeight, 0);
1128
+ const paddingLeft = getStyleDimension(node.style, 'paddingLeft', availableWidth, 0);
1129
+ const contentWidth = Math.max(0, availableWidth - paddingLeft - paddingRight);
1130
+ const contentHeight = Math.max(0, availableHeight - paddingTop - paddingBottom);
1131
+ const direction = node.style.flexDirection ?? 'column';
1132
+ const gap = getMainAxisGap(node.style, direction);
1133
+ const crossGap = getCrossAxisGap(node.style, direction);
1134
+ const shouldWrap = direction === 'row' && hasFlexWrap(node.style);
1135
+
1136
+ let mainSize = 0;
1137
+ let crossSize = 0;
1138
+ let normalChildCount = 0;
1139
+ let lineMainSize = 0;
1140
+ let lineCrossSize = 0;
1141
+ let lineCount = 0;
1142
+ let lineChildCount = 0;
1143
+
1144
+ const commitLine = (): void => {
1145
+ if (lineChildCount === 0) {
1146
+ return;
1147
+ }
1148
+ mainSize = Math.max(mainSize, lineMainSize);
1149
+ crossSize += lineCrossSize;
1150
+ lineCount += 1;
1151
+ lineMainSize = 0;
1152
+ lineCrossSize = 0;
1153
+ lineChildCount = 0;
1154
+ };
1155
+
1156
+ for (const child of node.children) {
1157
+ const childSize = child.kind === NodeKind.Text
1158
+ ? approximateTextFrame(child.text, {
1159
+ x: 0,
1160
+ y: 0,
1161
+ width: contentWidth,
1162
+ height: contentHeight,
1163
+ })
1164
+ : computeIntrinsicElementSize(child, contentWidth, contentHeight, depth + 1);
1165
+
1166
+ const style = child.kind === NodeKind.Element ? child.style : ({} as CanonicalStyle);
1167
+ const marginTop = getStyleDimension(style, 'marginTop', contentHeight, 0);
1168
+ const marginRight = getStyleDimension(style, 'marginRight', contentWidth, 0);
1169
+ const marginBottom = getStyleDimension(style, 'marginBottom', contentHeight, 0);
1170
+ const marginLeft = getStyleDimension(style, 'marginLeft', contentWidth, 0);
1171
+
1172
+ if (direction === 'row') {
1173
+ const childMain = childSize.width + marginLeft + marginRight;
1174
+ const childCross = childSize.height + marginTop + marginBottom;
1175
+ if (shouldWrap) {
1176
+ const nextLineMain = lineChildCount > 0
1177
+ ? lineMainSize + gap + childMain
1178
+ : childMain;
1179
+ if (lineChildCount > 0 && nextLineMain > contentWidth + 0.5) {
1180
+ commitLine();
1181
+ }
1182
+ lineMainSize = lineChildCount > 0
1183
+ ? lineMainSize + gap + childMain
1184
+ : childMain;
1185
+ lineCrossSize = Math.max(lineCrossSize, childCross);
1186
+ lineChildCount += 1;
1187
+ } else {
1188
+ mainSize += childMain;
1189
+ crossSize = Math.max(crossSize, childCross);
1190
+ }
1191
+ } else {
1192
+ mainSize += childSize.height + marginTop + marginBottom;
1193
+ crossSize = Math.max(crossSize, childSize.width + marginLeft + marginRight);
1194
+ }
1195
+
1196
+ normalChildCount += 1;
1197
+ }
1198
+
1199
+ if (shouldWrap) {
1200
+ commitLine();
1201
+ if (lineCount > 1) {
1202
+ crossSize += crossGap * (lineCount - 1);
1203
+ }
1204
+ } else if (normalChildCount > 1) {
1205
+ mainSize += gap * (normalChildCount - 1);
1206
+ }
1207
+
1208
+ const intrinsicWidth = direction === 'row'
1209
+ ? mainSize + paddingLeft + paddingRight
1210
+ : crossSize + paddingLeft + paddingRight;
1211
+ const intrinsicHeight = direction === 'row'
1212
+ ? crossSize + paddingTop + paddingBottom
1213
+ : mainSize + paddingTop + paddingBottom;
1214
+
1215
+ return constrainSize(
1216
+ node.style,
1217
+ explicitWidth ?? (intrinsicWidth > 0 ? intrinsicWidth : defaultSize.width),
1218
+ explicitHeight ?? (intrinsicHeight > 0 ? intrinsicHeight : defaultSize.height),
1219
+ availableWidth,
1220
+ availableHeight,
1221
+ );
1222
+ }
1223
+
1224
+ function getBridge():
1225
+ | {
1226
+ getLayout?: (viewId: number) => Frame | null;
1227
+ getAbsoluteLayout?: (viewId: number) => Frame | null;
1228
+ getLayoutGeneration?: () => number;
1229
+ hitTest?: (x: number, y: number) => number | null;
1230
+ }
1231
+ | null {
1232
+ const bridge = (globalThis as { exact?: unknown }).exact;
1233
+ return typeof bridge === 'object' && bridge !== null
1234
+ ? (bridge as {
1235
+ getLayout?: (viewId: number) => Frame | null;
1236
+ getAbsoluteLayout?: (viewId: number) => Frame | null;
1237
+ getLayoutGeneration?: () => number;
1238
+ hitTest?: (x: number, y: number) => number | null;
1239
+ })
1240
+ : null;
1241
+ }
1242
+
1243
+ function computeApproximateFrames(root: RootNode): Map<number, Frame> {
1244
+ const frames = new Map<number, Frame>();
1245
+ const viewport = getScreenDimensions(root.rootId);
1246
+ const rootFrame: Frame = {
1247
+ x: 0,
1248
+ y: 0,
1249
+ width: viewport.width,
1250
+ height: viewport.height,
1251
+ };
1252
+
1253
+ frames.set(root.id, rootFrame);
1254
+
1255
+ const layoutChildren = (
1256
+ parent: RootNode | ElementNode,
1257
+ parentFrame: Frame,
1258
+ ): void => {
1259
+ const children = parent.kind === NodeKind.Root ? parent.children : parent.children;
1260
+ const style = parent.kind === NodeKind.Root ? ({} as CanonicalStyle) : parent.style;
1261
+ const paddingTop = parent.kind === NodeKind.Root ? 0 : getStyleDimension(style, 'paddingTop', parentFrame.height, 0);
1262
+ const paddingRight = parent.kind === NodeKind.Root ? 0 : getStyleDimension(style, 'paddingRight', parentFrame.width, 0);
1263
+ const paddingBottom = parent.kind === NodeKind.Root ? 0 : getStyleDimension(style, 'paddingBottom', parentFrame.height, 0);
1264
+ const paddingLeft = parent.kind === NodeKind.Root ? 0 : getStyleDimension(style, 'paddingLeft', parentFrame.width, 0);
1265
+ const contentX = parentFrame.x + paddingLeft;
1266
+ const contentY = parentFrame.y + paddingTop;
1267
+ const contentWidth = Math.max(0, parentFrame.width - paddingLeft - paddingRight);
1268
+ const contentHeight = Math.max(0, parentFrame.height - paddingTop - paddingBottom);
1269
+ const direction = parent.kind === NodeKind.Root ? 'column' : (parent.style.flexDirection ?? 'column');
1270
+ const alignItems = parent.kind === NodeKind.Root ? 'stretch' : (style.alignItems ?? 'stretch');
1271
+ const scrollOffset =
1272
+ parent.kind === NodeKind.Root ||
1273
+ (parent.kind === NodeKind.Element && parent.tagType === 'scroll')
1274
+ ? scrollOffsets.get(parent.id) ?? { x: 0, y: 0 }
1275
+ : { x: 0, y: 0 };
1276
+ const gap = getMainAxisGap(style, direction);
1277
+ const contentMainSize = direction === 'row' ? contentWidth : contentHeight;
1278
+
1279
+ type LayoutEntry = {
1280
+ child: ElementNode | TextNode;
1281
+ width: number;
1282
+ height: number;
1283
+ marginTop: number;
1284
+ marginRight: number;
1285
+ marginBottom: number;
1286
+ marginLeft: number;
1287
+ isAbsolute: boolean;
1288
+ absoluteLeft: number;
1289
+ absoluteTop: number;
1290
+ flexGrow: number;
1291
+ };
1292
+
1293
+ const entries: LayoutEntry[] = [];
1294
+ let normalEntryCount = 0;
1295
+ let occupiedMainSize = 0;
1296
+ let totalFlexGrow = 0;
1297
+
1298
+ for (const child of children) {
1299
+ if (child.kind === NodeKind.Text) {
1300
+ const textFrame = approximateTextFrame(
1301
+ child.text,
1302
+ {
1303
+ x: 0,
1304
+ y: 0,
1305
+ width: contentWidth,
1306
+ height: contentHeight,
1307
+ },
1308
+ );
1309
+ entries.push({
1310
+ child,
1311
+ width: textFrame.width,
1312
+ height: textFrame.height,
1313
+ marginTop: 0,
1314
+ marginRight: 0,
1315
+ marginBottom: 0,
1316
+ marginLeft: 0,
1317
+ isAbsolute: false,
1318
+ absoluteLeft: Number.NaN,
1319
+ absoluteTop: Number.NaN,
1320
+ flexGrow: 0,
1321
+ });
1322
+ normalEntryCount += 1;
1323
+ occupiedMainSize += direction === 'row' ? textFrame.width : textFrame.height;
1324
+ continue;
1325
+ }
1326
+
1327
+ const marginTop = getStyleDimension(child.style, 'marginTop', contentHeight, 0);
1328
+ const marginRight = getStyleDimension(child.style, 'marginRight', contentWidth, 0);
1329
+ const marginBottom = getStyleDimension(child.style, 'marginBottom', contentHeight, 0);
1330
+ const marginLeft = getStyleDimension(child.style, 'marginLeft', contentWidth, 0);
1331
+ const intrinsicSize = computeIntrinsicElementSize(child, contentWidth, contentHeight);
1332
+ const explicitWidth = resolveOptionalDimension(child.style.width, contentWidth);
1333
+ const explicitHeight = resolveOptionalDimension(child.style.height, contentHeight);
1334
+ const flexGrow = Math.max(0, getStyleNumber(child.style, 'flexGrow') ?? 0);
1335
+ const flexBasis = resolveOptionalDimension(child.style.flexBasis, contentMainSize);
1336
+ const isAbsolute = child.style.positionType === 'absolute';
1337
+ const alignSelf = child.style.alignSelf ?? 'auto';
1338
+ const crossAxisAlign = alignSelf === 'auto' ? alignItems : alignSelf;
1339
+
1340
+ let width = explicitWidth ?? (direction === 'row'
1341
+ ? intrinsicSize.width
1342
+ : (!isAbsolute && crossAxisAlign === 'stretch' ? contentWidth : intrinsicSize.width));
1343
+ let height = explicitHeight ?? (direction === 'row' && !isAbsolute && crossAxisAlign === 'stretch' && !hasFlexWrap(style)
1344
+ ? contentHeight
1345
+ : intrinsicSize.height);
1346
+
1347
+ if (direction === 'row' && flexGrow > 0 && explicitWidth === undefined) {
1348
+ width = flexBasis ?? 0;
1349
+ } else if (direction !== 'row' && flexGrow > 0 && explicitHeight === undefined) {
1350
+ height = flexBasis ?? 0;
1351
+ }
1352
+
1353
+ ({ width, height } = constrainSize(
1354
+ child.style,
1355
+ width,
1356
+ height,
1357
+ contentWidth,
1358
+ contentHeight,
1359
+ ));
1360
+
1361
+ const absoluteLeft = resolveDimension(child.style.left, contentWidth, Number.NaN);
1362
+ const absoluteTop = resolveDimension(child.style.top, contentHeight, Number.NaN);
1363
+
1364
+ entries.push({
1365
+ child,
1366
+ width,
1367
+ height,
1368
+ marginTop,
1369
+ marginRight,
1370
+ marginBottom,
1371
+ marginLeft,
1372
+ isAbsolute,
1373
+ absoluteLeft,
1374
+ absoluteTop,
1375
+ flexGrow,
1376
+ });
1377
+
1378
+ if (!isAbsolute) {
1379
+ normalEntryCount += 1;
1380
+ totalFlexGrow += flexGrow;
1381
+ occupiedMainSize += direction === 'row'
1382
+ ? width + marginLeft + marginRight
1383
+ : height + marginTop + marginBottom;
1384
+ }
1385
+ }
1386
+
1387
+ if (normalEntryCount > 1) {
1388
+ occupiedMainSize += gap * (normalEntryCount - 1);
1389
+ }
1390
+
1391
+ const remainingMainSize = Math.max(0, contentMainSize - occupiedMainSize);
1392
+ if (totalFlexGrow > 0 && remainingMainSize > 0) {
1393
+ for (const entry of entries) {
1394
+ if (entry.isAbsolute || entry.flexGrow <= 0) {
1395
+ continue;
1396
+ }
1397
+
1398
+ const delta = remainingMainSize * (entry.flexGrow / totalFlexGrow);
1399
+ if (direction === 'row') {
1400
+ entry.width += delta;
1401
+ } else {
1402
+ entry.height += delta;
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ let cursorX = contentX - scrollOffset.x;
1408
+ let cursorY = contentY - scrollOffset.y;
1409
+
1410
+ if (direction === 'row' && hasFlexWrap(style)) {
1411
+ type FlexLine = {
1412
+ entries: LayoutEntry[];
1413
+ mainSize: number;
1414
+ crossSize: number;
1415
+ };
1416
+ const lines: FlexLine[] = [];
1417
+ let currentLine: FlexLine = {
1418
+ entries: [],
1419
+ mainSize: 0,
1420
+ crossSize: 0,
1421
+ };
1422
+ const commitLine = (): void => {
1423
+ if (currentLine.entries.length === 0) {
1424
+ return;
1425
+ }
1426
+ lines.push(currentLine);
1427
+ currentLine = {
1428
+ entries: [],
1429
+ mainSize: 0,
1430
+ crossSize: 0,
1431
+ };
1432
+ };
1433
+
1434
+ for (const entry of entries) {
1435
+ if (entry.isAbsolute) {
1436
+ continue;
1437
+ }
1438
+
1439
+ const entryMain = entry.width + entry.marginLeft + entry.marginRight;
1440
+ const entryCross = entry.height + entry.marginTop + entry.marginBottom;
1441
+ const nextMain = currentLine.entries.length > 0
1442
+ ? currentLine.mainSize + gap + entryMain
1443
+ : entryMain;
1444
+ if (currentLine.entries.length > 0 && nextMain > contentWidth + 0.5) {
1445
+ commitLine();
1446
+ }
1447
+ currentLine.mainSize = currentLine.entries.length > 0
1448
+ ? currentLine.mainSize + gap + entryMain
1449
+ : entryMain;
1450
+ currentLine.crossSize = Math.max(currentLine.crossSize, entryCross);
1451
+ currentLine.entries.push(entry);
1452
+ }
1453
+ commitLine();
1454
+
1455
+ const crossGap = getCrossAxisGap(style, direction);
1456
+ const orderedLines = style.flexWrap === 'wrap-reverse'
1457
+ ? [...lines].reverse()
1458
+ : lines;
1459
+ let lineY = contentY - scrollOffset.y;
1460
+
1461
+ for (let lineIndex = 0; lineIndex < orderedLines.length; lineIndex += 1) {
1462
+ const line = orderedLines[lineIndex]!;
1463
+ let lineX = contentX - scrollOffset.x;
1464
+ for (let entryIndex = 0; entryIndex < line.entries.length; entryIndex += 1) {
1465
+ const entry = line.entries[entryIndex]!;
1466
+ const { child } = entry;
1467
+ const frame: Frame = {
1468
+ x: lineX + entry.marginLeft,
1469
+ y: lineY + entry.marginTop,
1470
+ width: entry.width,
1471
+ height: entry.height,
1472
+ };
1473
+ frames.set(child.id, frame);
1474
+ if (child.kind === NodeKind.Element) {
1475
+ layoutChildren(child, frame);
1476
+ }
1477
+ lineX += entry.width + entry.marginLeft + entry.marginRight +
1478
+ (entryIndex < line.entries.length - 1 ? gap : 0);
1479
+ }
1480
+ lineY += line.crossSize + (lineIndex < orderedLines.length - 1 ? crossGap : 0);
1481
+ }
1482
+
1483
+ for (const entry of entries) {
1484
+ if (!entry.isAbsolute) {
1485
+ continue;
1486
+ }
1487
+ const { child } = entry;
1488
+ const frame: Frame = {
1489
+ x: Number.isFinite(entry.absoluteLeft)
1490
+ ? contentX + entry.absoluteLeft + entry.marginLeft
1491
+ : contentX + entry.marginLeft,
1492
+ y: Number.isFinite(entry.absoluteTop)
1493
+ ? contentY + entry.absoluteTop + entry.marginTop
1494
+ : contentY + entry.marginTop,
1495
+ width: entry.width,
1496
+ height: entry.height,
1497
+ };
1498
+ frames.set(child.id, frame);
1499
+ if (child.kind === NodeKind.Element) {
1500
+ layoutChildren(child, frame);
1501
+ }
1502
+ }
1503
+ return;
1504
+ }
1505
+
1506
+ for (let index = 0; index < entries.length; index += 1) {
1507
+ const entry = entries[index];
1508
+ const { child } = entry;
1509
+ const x = entry.isAbsolute && Number.isFinite(entry.absoluteLeft)
1510
+ ? contentX + entry.absoluteLeft + entry.marginLeft
1511
+ : cursorX + entry.marginLeft;
1512
+ const y = entry.isAbsolute && Number.isFinite(entry.absoluteTop)
1513
+ ? contentY + entry.absoluteTop + entry.marginTop
1514
+ : cursorY + entry.marginTop;
1515
+
1516
+ const frame: Frame = { x, y, width: entry.width, height: entry.height };
1517
+ frames.set(child.id, frame);
1518
+ if (child.kind === NodeKind.Element) {
1519
+ layoutChildren(child, frame);
1520
+ }
1521
+
1522
+ if (entry.isAbsolute) {
1523
+ continue;
1524
+ }
1525
+
1526
+ if (direction === 'row') {
1527
+ cursorX += entry.width + entry.marginLeft + entry.marginRight + (index < entries.length - 1 ? gap : 0);
1528
+ } else {
1529
+ cursorY += entry.height + entry.marginTop + entry.marginBottom + (index < entries.length - 1 ? gap : 0);
1530
+ }
1531
+ }
1532
+ };
1533
+
1534
+ layoutChildren(root, rootFrame);
1535
+ return frames;
1536
+ }
1537
+
1538
+ function buildFrameIndex(root: RootNode): Map<number, Frame> {
1539
+ const bridge = getBridge();
1540
+ const approximate = computeApproximateFrames(root);
1541
+ if (!bridge?.getLayout) {
1542
+ applyFloatingPositionFrames(root, approximate);
1543
+ return approximate;
1544
+ }
1545
+
1546
+ const applyKernelFrames = (node: HostNode): void => {
1547
+ const kernelFrame = bridge.getAbsoluteLayout?.(node.id) ?? bridge.getLayout?.(node.id);
1548
+ if (kernelFrame) {
1549
+ approximate.set(node.id, kernelFrame);
1550
+ }
1551
+
1552
+ if (node.kind === NodeKind.Element || node.kind === NodeKind.Root) {
1553
+ for (const child of node.children) {
1554
+ applyKernelFrames(child);
1555
+ }
1556
+ }
1557
+ };
1558
+
1559
+ applyKernelFrames(root);
1560
+ applyFloatingPositionFrames(root, approximate);
1561
+ return approximate;
1562
+ }
1563
+
1564
+ function isFiniteFrame(frame: Frame): boolean {
1565
+ return (
1566
+ Number.isFinite(frame.x) &&
1567
+ Number.isFinite(frame.y) &&
1568
+ Number.isFinite(frame.width) &&
1569
+ Number.isFinite(frame.height)
1570
+ );
1571
+ }
1572
+
1573
+ function translateSubtreeFrames(
1574
+ node: HostNode,
1575
+ frames: Map<number, Frame>,
1576
+ dx: number,
1577
+ dy: number,
1578
+ ): void {
1579
+ if (dx === 0 && dy === 0) {
1580
+ return;
1581
+ }
1582
+
1583
+ const frame = frames.get(node.id);
1584
+ if (frame) {
1585
+ frames.set(node.id, {
1586
+ ...frame,
1587
+ x: frame.x + dx,
1588
+ y: frame.y + dy,
1589
+ });
1590
+ }
1591
+
1592
+ if (node.kind === NodeKind.Element || node.kind === NodeKind.Root) {
1593
+ for (const child of node.children) {
1594
+ translateSubtreeFrames(child, frames, dx, dy);
1595
+ }
1596
+ }
1597
+ }
1598
+
1599
+ function applyFloatingPositionFrames(
1600
+ root: RootNode,
1601
+ frames: Map<number, Frame>,
1602
+ ): void {
1603
+ const visit = (node: HostNode): void => {
1604
+ if (node.kind === NodeKind.Element) {
1605
+ const floating = findFloatingPositionByViewId(node.id);
1606
+ if (floating?.positioned === true) {
1607
+ const current = frames.get(node.id);
1608
+ const next: Frame = {
1609
+ x: floating.x,
1610
+ y: floating.y,
1611
+ width: floating.width,
1612
+ height: floating.height,
1613
+ };
1614
+
1615
+ if (current && isFiniteFrame(next)) {
1616
+ translateSubtreeFrames(node, frames, next.x - current.x, next.y - current.y);
1617
+ frames.set(node.id, next);
1618
+ }
1619
+ }
1620
+ }
1621
+
1622
+ if (node.kind === NodeKind.Element || node.kind === NodeKind.Root) {
1623
+ for (const child of node.children) {
1624
+ visit(child);
1625
+ }
1626
+ }
1627
+ };
1628
+
1629
+ visit(root);
1630
+ }
1631
+
1632
+ function resolveRootAndNodeByViewId(viewId: number): {
1633
+ root: RootNode;
1634
+ node: ElementNode;
1635
+ frames: Map<number, Frame>;
1636
+ } | null {
1637
+ for (const root of roots.values()) {
1638
+ const node = findNodeByViewId(root, viewId);
1639
+ if (node?.kind === NodeKind.Element) {
1640
+ return {
1641
+ root,
1642
+ node,
1643
+ frames: buildFrameIndex(root),
1644
+ };
1645
+ }
1646
+ }
1647
+
1648
+ return null;
1649
+ }
1650
+
1651
+ export function queryClippingAncestorsForViewId(viewId: number): ClipChainEntry[] {
1652
+ const resolved = resolveRootAndNodeByViewId(viewId);
1653
+ if (!resolved) {
1654
+ return [];
1655
+ }
1656
+
1657
+ const entries: ClipChainEntry[] = [];
1658
+ let ancestor: HostNode | null = resolved.node.parent as HostNode | null;
1659
+
1660
+ while (ancestor) {
1661
+ const ancestorFrame = resolved.frames.get(ancestor.id);
1662
+ const clipsDescendants =
1663
+ ancestor.kind === NodeKind.Root ||
1664
+ (ancestor.kind === NodeKind.Element &&
1665
+ (ancestor.tagType === 'scroll' ||
1666
+ ancestor.style.overflow === 'hidden' ||
1667
+ ancestor.style.overflow === 'scroll'));
1668
+
1669
+ if (clipsDescendants && ancestorFrame) {
1670
+ const type =
1671
+ ancestor.kind === NodeKind.Root
1672
+ ? 'Root'
1673
+ : ancestor.kind === NodeKind.Element
1674
+ ? ancestor.originalTag
1675
+ : 'Unknown';
1676
+ entries.push({
1677
+ viewId: ancestor.id,
1678
+ type,
1679
+ frame: ancestorFrame,
1680
+ overflow: ancestor.kind === NodeKind.Element ? ancestor.style.overflow : undefined,
1681
+ });
1682
+ }
1683
+
1684
+ ancestor = ancestor.parent as HostNode | null;
1685
+ }
1686
+
1687
+ return entries;
1688
+ }
1689
+
1690
+ export function queryClippingBoundaryForViewId(viewId: number): Frame | null {
1691
+ const resolved = resolveRootAndNodeByViewId(viewId);
1692
+ if (!resolved) {
1693
+ return null;
1694
+ }
1695
+
1696
+ const viewport = resolved.frames.get(resolved.root.id) ?? {
1697
+ x: 0,
1698
+ y: 0,
1699
+ width: getScreenDimensions(resolved.root.rootId).width,
1700
+ height: getScreenDimensions(resolved.root.rootId).height,
1701
+ };
1702
+ const ancestors = queryClippingAncestorsForViewId(viewId);
1703
+
1704
+ return ancestors.reduce<Frame>((boundary, entry) => {
1705
+ const nextX = Math.max(boundary.x, entry.frame.x);
1706
+ const nextY = Math.max(boundary.y, entry.frame.y);
1707
+ const nextRight = Math.min(boundary.x + boundary.width, entry.frame.x + entry.frame.width);
1708
+ const nextBottom = Math.min(boundary.y + boundary.height, entry.frame.y + entry.frame.height);
1709
+
1710
+ return {
1711
+ x: nextX,
1712
+ y: nextY,
1713
+ width: Math.max(0, nextRight - nextX),
1714
+ height: Math.max(0, nextBottom - nextY),
1715
+ };
1716
+ }, viewport);
1717
+ }
1718
+
1719
+ function makeViewNode(
1720
+ node: HostNode,
1721
+ frames: Map<number, Frame>,
1722
+ refs: Record<ElementRef, ElementRefInfo>,
1723
+ depth: number | undefined,
1724
+ refCounter: { value: number },
1725
+ interactionContext: InteractionContext,
1726
+ ): ViewNode {
1727
+ const frame = frames.get(node.id) ?? { x: 0, y: 0, width: 0, height: 0 };
1728
+ const shouldDescend = depth === undefined || depth > 0;
1729
+ const nextDepth = depth === undefined ? undefined : Math.max(0, depth - 1);
1730
+
1731
+ if (node.kind === NodeKind.Root) {
1732
+ const theme = getThemeSnapshot() ?? undefined;
1733
+ return {
1734
+ viewId: node.id,
1735
+ type: 'View',
1736
+ frame,
1737
+ props: {
1738
+ rootId: node.rootId,
1739
+ layoutGeneration: getBridge()?.getLayoutGeneration?.() ?? 0,
1740
+ },
1741
+ style: {},
1742
+ theme,
1743
+ children: shouldDescend
1744
+ ? node.children.map((child) =>
1745
+ makeViewNode(child, frames, refs, nextDepth, refCounter, interactionContext),
1746
+ )
1747
+ : [],
1748
+ };
1749
+ }
1750
+
1751
+ if (node.kind === NodeKind.Text) {
1752
+ return {
1753
+ viewId: node.id,
1754
+ type: 'Text',
1755
+ frame,
1756
+ text: node.text,
1757
+ props: {},
1758
+ style: {},
1759
+ children: [],
1760
+ };
1761
+ }
1762
+
1763
+ const label = getElementLabel(node);
1764
+ const testId = getTestId(node);
1765
+ const shouldAssignRef = isElementInteractable(node) || typeof testId === 'string';
1766
+ const ref = shouldAssignRef ? (`@e${refCounter.value++}` as ElementRef) : undefined;
1767
+
1768
+ if (ref) {
1769
+ refs[ref] = {
1770
+ ref,
1771
+ viewId: node.id,
1772
+ type: node.originalTag,
1773
+ label,
1774
+ testId,
1775
+ frame,
1776
+ };
1777
+ }
1778
+
1779
+ const props: Record<string, unknown> = {
1780
+ ...node.props,
1781
+ testId,
1782
+ };
1783
+
1784
+ const floating = findFloatingPositionByViewId(node.id) ?? undefined;
1785
+ const behaviors = buildBehaviorMetadata(node);
1786
+ const portal = buildPortalMetadata(node);
1787
+ const anchor = buildAnchorMetadata(node);
1788
+ const presencePhase =
1789
+ typeof node.props.__exactPresencePhase === 'string'
1790
+ ? node.props.__exactPresencePhase
1791
+ : undefined;
1792
+ const component = findFacetComponentInfo(node);
1793
+ const devProvenance = buildDevProvenance(node);
1794
+
1795
+ if (textOverrides.has(node.id)) {
1796
+ props.value = textOverrides.get(node.id);
1797
+ }
1798
+ if (toggleOverrides.has(node.id)) {
1799
+ props.toggleValue = toggleOverrides.get(node.id);
1800
+ }
1801
+ if (valueOverrides.has(node.id)) {
1802
+ const overriddenValue = valueOverrides.get(node.id);
1803
+ props.value = overriddenValue;
1804
+ if (typeof overriddenValue === 'number') {
1805
+ props.accessibilityValueNow = overriddenValue;
1806
+ }
1807
+ }
1808
+ if (scrollOffsets.has(node.id)) {
1809
+ props.contentOffset = scrollOffsets.get(node.id);
1810
+ }
1811
+ if (typeof node.props.__exactRenderMode === 'string') {
1812
+ // `__exactRenderMode` is an implementation detail on the host node, but
1813
+ // agents need a stable public field that answers the RFC 0073 question
1814
+ // directly: is this control taking the native or composed path?
1815
+ props.renderMode = node.props.__exactRenderMode;
1816
+ delete props.__exactRenderMode;
1817
+ }
1818
+ if (node.tagType === 'nativeView') {
1819
+ if (typeof node.props.nativeViewModuleName === 'string') {
1820
+ props.moduleName = node.props.nativeViewModuleName;
1821
+ }
1822
+
1823
+ const moduleProps = parseNativeViewProps(node.props.nativeViewProps);
1824
+ if (moduleProps) {
1825
+ if (valueOverrides.has(node.id)) {
1826
+ moduleProps.value = valueOverrides.get(node.id);
1827
+ }
1828
+ props.moduleProps = moduleProps;
1829
+ }
1830
+
1831
+ // The raw transport fields stay useful internally, but they are noisy in
1832
+ // the public tree. Expose the friendlier `moduleName` / `moduleProps`
1833
+ // projection instead so agents do not need to decode renderer internals.
1834
+ delete props.nativeViewModuleName;
1835
+ delete props.nativeViewProps;
1836
+ }
1837
+ const safeArea = buildSafeAreaSnapshot(node);
1838
+ if (safeArea) {
1839
+ props.safeArea = safeArea;
1840
+ }
1841
+
1842
+ return {
1843
+ viewId: node.id,
1844
+ type: node.originalTag,
1845
+ frame,
1846
+ ref,
1847
+ text: getNodeText(node) || undefined,
1848
+ props,
1849
+ style: { ...node.style },
1850
+ behaviors,
1851
+ portal,
1852
+ anchor,
1853
+ presencePhase,
1854
+ whyNotInteractable: computeWhyNotInteractable(
1855
+ node,
1856
+ floating,
1857
+ presencePhase,
1858
+ interactionContext,
1859
+ ),
1860
+ devProvenance,
1861
+ floating,
1862
+ component,
1863
+ children: shouldDescend
1864
+ ? node.children.map((child) =>
1865
+ makeViewNode(child, frames, refs, nextDepth, refCounter, interactionContext),
1866
+ )
1867
+ : [],
1868
+ };
1869
+ }
1870
+
1871
+ function flattenElements(node: HostNode, elements: ElementNode[]): void {
1872
+ if (node.kind === NodeKind.Element) {
1873
+ elements.push(node);
1874
+ }
1875
+
1876
+ if (node.kind === NodeKind.Element || node.kind === NodeKind.Root) {
1877
+ for (const child of node.children) {
1878
+ flattenElements(child, elements);
1879
+ }
1880
+ }
1881
+ }
1882
+
1883
+ function collectSerializedViewIds(node: ViewNode, ids: Set<number>): void {
1884
+ ids.add(node.viewId);
1885
+ for (const child of node.children) {
1886
+ collectSerializedViewIds(child, ids);
1887
+ }
1888
+ }
1889
+
1890
+ function findNodeByViewId(node: HostNode, viewId: number): HostNode | null {
1891
+ if (node.id === viewId) {
1892
+ return node;
1893
+ }
1894
+
1895
+ if (node.kind === NodeKind.Element || node.kind === NodeKind.Root) {
1896
+ for (const child of node.children) {
1897
+ const match = findNodeByViewId(child, viewId);
1898
+ if (match) {
1899
+ return match;
1900
+ }
1901
+ }
1902
+ }
1903
+
1904
+ return null;
1905
+ }
1906
+
1907
+ function normalizeLookup(value: string): string {
1908
+ return value.trim().toLowerCase();
1909
+ }
1910
+
1911
+ function pointInFrame(point: Point, frame: Frame): boolean {
1912
+ return (
1913
+ point.x >= frame.x &&
1914
+ point.y >= frame.y &&
1915
+ point.x <= frame.x + frame.width &&
1916
+ point.y <= frame.y + frame.height
1917
+ );
1918
+ }
1919
+
1920
+ function framesIntersect(left: Frame, right: Frame): boolean {
1921
+ return !(
1922
+ left.x + left.width <= right.x ||
1923
+ right.x + right.width <= left.x ||
1924
+ left.y + left.height <= right.y ||
1925
+ right.y + right.height <= left.y
1926
+ );
1927
+ }
1928
+
1929
+ function findElementByPoint(
1930
+ root: RootNode,
1931
+ frames: Map<number, Frame>,
1932
+ point: Point,
1933
+ ): ElementNode | null {
1934
+ const visit = (node: HostNode): ElementNode | null => {
1935
+ const frame = frames.get(node.id);
1936
+ if (!frame || !pointInFrame(point, frame)) {
1937
+ return null;
1938
+ }
1939
+
1940
+ if (node.kind === NodeKind.Element || node.kind === NodeKind.Root) {
1941
+ for (let index = node.children.length - 1; index >= 0; index -= 1) {
1942
+ const child = node.children[index];
1943
+ const match = visit(child);
1944
+ if (match) {
1945
+ return match;
1946
+ }
1947
+ }
1948
+ }
1949
+
1950
+ return node.kind === NodeKind.Element ? node : null;
1951
+ };
1952
+
1953
+ const bridge = getBridge();
1954
+ if (bridge?.hitTest) {
1955
+ const viewId = bridge.hitTest(point.x, point.y);
1956
+ if (typeof viewId === 'number' && viewId >= 0) {
1957
+ const exactMatch = findNodeByViewId(root, viewId);
1958
+ return exactMatch?.kind === NodeKind.Element ? exactMatch : null;
1959
+ }
1960
+ }
1961
+
1962
+ return visit(root);
1963
+ }
1964
+
1965
+ function nearestActionableElement(node: ElementNode | null): ElementNode | null {
1966
+ let current: HostNode | null = node;
1967
+ while (current) {
1968
+ if (current.kind === NodeKind.Element && isElementInteractable(current)) {
1969
+ return current;
1970
+ }
1971
+ current = current.parent as HostNode | null;
1972
+ }
1973
+ return null;
1974
+ }
1975
+
1976
+ function resolveRoot(rootId?: number): RootNode | null {
1977
+ if (typeof rootId === 'number') {
1978
+ return roots.get(rootId) ?? null;
1979
+ }
1980
+ return roots.get(0) ?? roots.values().next().value ?? null;
1981
+ }
1982
+
1983
+ export function registerRoot(root: RootNode): void {
1984
+ roots.set(root.rootId, root);
1985
+ sharedState.stateVersion += 1;
1986
+ emit({
1987
+ type: 'connect',
1988
+ rootId: root.rootId,
1989
+ label: makeRootInfo(root).label,
1990
+ size: makeRootInfo(root).size,
1991
+ });
1992
+ }
1993
+
1994
+ export function unregisterRoot(rootId: number): void {
1995
+ if (!roots.delete(rootId)) {
1996
+ return;
1997
+ }
1998
+ sharedState.stateVersion += 1;
1999
+ emit({
2000
+ type: 'disconnect',
2001
+ reason: `root:${rootId}:unregistered`,
2002
+ exitCode: null,
2003
+ signal: null,
2004
+ });
2005
+ }
2006
+
2007
+ export function notifyCommit(
2008
+ root: RootNode,
2009
+ options: {
2010
+ changedViews?: Iterable<number>;
2011
+ analyzeWarnings?: boolean;
2012
+ } = {},
2013
+ ): void {
2014
+ roots.set(root.rootId, root);
2015
+ sharedState.frameCount += 1;
2016
+ sharedState.stateVersion += 1;
2017
+ sharedState.lastCommitAt = currentTimestamp();
2018
+ recordCommitTimestamp(sharedState.lastCommitAt);
2019
+ if (options.analyzeWarnings !== false) {
2020
+ analyzeCommitWarnings(root);
2021
+ }
2022
+
2023
+ if (listeners.size === 0) {
2024
+ return;
2025
+ }
2026
+
2027
+ const changedViews =
2028
+ options.changedViews !== undefined
2029
+ ? Array.from(options.changedViews)
2030
+ : (() => {
2031
+ const collected: number[] = [];
2032
+ const collect = (node: HostNode): void => {
2033
+ collected.push(node.id);
2034
+ if (node.kind === NodeKind.Element || node.kind === NodeKind.Root) {
2035
+ for (const child of node.children) {
2036
+ collect(child);
2037
+ }
2038
+ }
2039
+ };
2040
+ collect(root);
2041
+ return collected;
2042
+ })();
2043
+
2044
+ emit({
2045
+ type: 'render',
2046
+ frameCount: sharedState.frameCount,
2047
+ changedViews,
2048
+ });
2049
+ }
2050
+
2051
+ export function emitInspectorError(
2052
+ error: unknown,
2053
+ options: {
2054
+ source?: 'runtime' | 'agent';
2055
+ rootId?: number;
2056
+ componentStack?: string;
2057
+ context?: Record<string, unknown>;
2058
+ } = {},
2059
+ ): void {
2060
+ const message =
2061
+ error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error';
2062
+ const latestSnapshot = findLatestSnapshotRecord(options.rootId);
2063
+ recordAgentLog({
2064
+ source: options.source ?? 'runtime',
2065
+ level: 'error',
2066
+ message,
2067
+ stack: error instanceof Error ? error.stack : undefined,
2068
+ componentStack: options.componentStack,
2069
+ context: options.context,
2070
+ rootId: latestSnapshot?.rootId,
2071
+ snapshotId: latestSnapshot?.snapshotId,
2072
+ });
2073
+ emit({
2074
+ type: 'error',
2075
+ message,
2076
+ stack: error instanceof Error ? error.stack : undefined,
2077
+ });
2078
+ }
2079
+
2080
+ export function emitInspectorEvent(event: AgentEvent): void {
2081
+ emit(event);
2082
+ }
2083
+
2084
+ export function subscribeAgentEvents(listener: InspectorListener): () => void {
2085
+ listeners.add(listener);
2086
+ return () => {
2087
+ listeners.delete(listener);
2088
+ };
2089
+ }
2090
+
2091
+ export function getFrameCount(): number {
2092
+ return sharedState.frameCount;
2093
+ }
2094
+
2095
+ export function getAgentCapabilities(): AgentCapability[] {
2096
+ return AGENT_CAPABILITIES.slice();
2097
+ }
2098
+
2099
+ export function getStateVersion(): number {
2100
+ return sharedState.stateVersion;
2101
+ }
2102
+
2103
+ export function getLastCommitAt(): number {
2104
+ return sharedState.lastCommitAt;
2105
+ }
2106
+
2107
+ export function getRootsSnapshot(): RootInfo[] {
2108
+ return Array.from(roots.values()).map(makeRootInfo);
2109
+ }
2110
+
2111
+ export function getRootNode(rootId?: number): RootNode | null {
2112
+ return resolveRoot(rootId);
2113
+ }
2114
+
2115
+ export function serializeTree(
2116
+ rootId?: number,
2117
+ depth?: number,
2118
+ viewId?: number,
2119
+ ): SerializedSnapshot {
2120
+ const root = resolveRoot(rootId);
2121
+ if (!root) {
2122
+ throw new Error('No active Exact roots');
2123
+ }
2124
+
2125
+ const frames = buildFrameIndex(root);
2126
+ const refs: Record<ElementRef, ElementRefInfo> = {};
2127
+ const refCounter = { value: 1 };
2128
+ const source = typeof viewId === 'number' ? findNodeByViewId(root, viewId) ?? root : root;
2129
+ const interactionContext = buildInteractionContext(root);
2130
+
2131
+ return {
2132
+ root: makeViewNode(source, frames, refs, depth, refCounter, interactionContext),
2133
+ refs,
2134
+ frames,
2135
+ };
2136
+ }
2137
+
2138
+ export function serializeLayout(
2139
+ rootId?: number,
2140
+ viewId?: number,
2141
+ ): { frames: Array<Frame & { viewId: number }>; refs: Record<ElementRef, ElementRefInfo> } {
2142
+ const snapshot = serializeTree(rootId, undefined, viewId);
2143
+ const resolvedRoot = resolveRoot(rootId);
2144
+ const includedViewIds = new Set<number>();
2145
+ collectSerializedViewIds(snapshot.root, includedViewIds);
2146
+ const frames: Array<Frame & { viewId: number }> = [];
2147
+ for (const [id, frame] of snapshot.frames) {
2148
+ if (!includedViewIds.has(id)) {
2149
+ continue;
2150
+ }
2151
+ const node = resolvedRoot ? findNodeByViewId(resolvedRoot, id) : null;
2152
+ const safeArea =
2153
+ node && node.kind === NodeKind.Element
2154
+ ? buildSafeAreaSnapshot(node)
2155
+ : undefined;
2156
+ frames.push({
2157
+ viewId: id,
2158
+ ...frame,
2159
+ ...(safeArea ? { safeArea } : {}),
2160
+ });
2161
+ }
2162
+ frames.sort((left, right) => left.viewId - right.viewId);
2163
+ return {
2164
+ frames,
2165
+ refs: snapshot.refs,
2166
+ };
2167
+ }
2168
+
2169
+ export function serializeAccessibility(rootId?: number): AccessibilitySnapshot {
2170
+ const root = resolveRoot(rootId);
2171
+ if (!root) {
2172
+ throw new Error('No active Exact roots');
2173
+ }
2174
+
2175
+ const frames = buildFrameIndex(root);
2176
+ const refs: Record<ElementRef, ElementRefInfo> = {};
2177
+ const nodes: AccessibilityNode[] = [];
2178
+ const refCounter = { value: 1 };
2179
+ const elements: ElementNode[] = [];
2180
+ flattenElements(root, elements);
2181
+ const labelledByIndex = buildLabelledByIndex(elements);
2182
+ const visitElement = (element: ElementNode): string[] => {
2183
+ if (shouldHideAccessibilityElement(element)) {
2184
+ return [];
2185
+ }
2186
+
2187
+ const childIds: string[] = [];
2188
+ for (const child of element.children) {
2189
+ if (child.kind === NodeKind.Element) {
2190
+ childIds.push(...visitElement(child));
2191
+ }
2192
+ }
2193
+
2194
+ const role = getRole(element);
2195
+ const implicitRole = getImplicitRole(element);
2196
+ const label = getElementLabel(element, labelledByIndex);
2197
+ const hint =
2198
+ typeof element.props.accessibilityHint === 'string' &&
2199
+ element.props.accessibilityHint.length > 0
2200
+ ? element.props.accessibilityHint
2201
+ : undefined;
2202
+ const actions = getAccessibilityActions(element);
2203
+ const focusable =
2204
+ element.props.focusable === true ||
2205
+ (typeof element.props.tabIndex === 'number' && element.props.tabIndex >= 0) ||
2206
+ isElementInteractable(element);
2207
+
2208
+ if (role === 'presentation') {
2209
+ return [];
2210
+ }
2211
+
2212
+ if (shouldFlattenAccessibilityNode(element, role, label, hint, actions, focusable, childIds)) {
2213
+ return childIds;
2214
+ }
2215
+
2216
+ const frame = frames.get(element.id) ?? { x: 0, y: 0, width: 0, height: 0 };
2217
+ const testId = getTestId(element);
2218
+ const ref = `@e${refCounter.value++}` as ElementRef;
2219
+ const id = `v${element.id}`;
2220
+ refs[ref] = {
2221
+ ref,
2222
+ viewId: element.id,
2223
+ type: element.originalTag,
2224
+ label,
2225
+ testId,
2226
+ frame,
2227
+ };
2228
+
2229
+ nodes.push({
2230
+ id,
2231
+ viewId: element.id,
2232
+ ref,
2233
+ role,
2234
+ implicitRole,
2235
+ explicitRole:
2236
+ typeof element.props.accessibilityRole === 'string' &&
2237
+ element.props.accessibilityRole.length > 0
2238
+ ? element.props.accessibilityRole
2239
+ : undefined,
2240
+ label,
2241
+ name: label ?? element.originalTag,
2242
+ hint,
2243
+ text: getAccessibilityText(element),
2244
+ value: getAccessibilityValue(element),
2245
+ valueNow:
2246
+ typeof element.props.accessibilityValueNow === 'number' &&
2247
+ Number.isFinite(element.props.accessibilityValueNow)
2248
+ ? element.props.accessibilityValueNow
2249
+ : undefined,
2250
+ valueMin:
2251
+ typeof element.props.accessibilityValueMin === 'number' &&
2252
+ Number.isFinite(element.props.accessibilityValueMin)
2253
+ ? element.props.accessibilityValueMin
2254
+ : undefined,
2255
+ valueMax:
2256
+ typeof element.props.accessibilityValueMax === 'number' &&
2257
+ Number.isFinite(element.props.accessibilityValueMax)
2258
+ ? element.props.accessibilityValueMax
2259
+ : undefined,
2260
+ expanded:
2261
+ typeof element.props.accessibilityExpanded === 'boolean'
2262
+ ? element.props.accessibilityExpanded
2263
+ : undefined,
2264
+ selected:
2265
+ typeof element.props.accessibilitySelected === 'boolean'
2266
+ ? element.props.accessibilitySelected
2267
+ : undefined,
2268
+ checked: getAccessibilityCheckedState(element),
2269
+ disabled:
2270
+ element.props.disabled === true || element.props.accessibilityDisabled === true,
2271
+ busy: element.props.accessibilityBusy === true,
2272
+ modal: element.props.accessibilityModal === true,
2273
+ focusable,
2274
+ live: getAccessibilityLiveMode(element),
2275
+ labelledBy:
2276
+ typeof element.props.accessibilityLabelledBy === 'string' &&
2277
+ element.props.accessibilityLabelledBy.length > 0
2278
+ ? element.props.accessibilityLabelledBy
2279
+ : undefined,
2280
+ describedBy:
2281
+ typeof element.props.accessibilityDescribedBy === 'string' &&
2282
+ element.props.accessibilityDescribedBy.length > 0
2283
+ ? element.props.accessibilityDescribedBy
2284
+ : undefined,
2285
+ headingLevel:
2286
+ typeof element.props.accessibilityHeadingLevel === 'number'
2287
+ ? element.props.accessibilityHeadingLevel
2288
+ : undefined,
2289
+ nativeID:
2290
+ typeof element.props.nativeID === 'string' && element.props.nativeID.length > 0
2291
+ ? element.props.nativeID
2292
+ : undefined,
2293
+ testId,
2294
+ tabIndex:
2295
+ typeof element.props.tabIndex === 'number' && Number.isFinite(element.props.tabIndex)
2296
+ ? element.props.tabIndex
2297
+ : undefined,
2298
+ synthetic: element.props.accessibilitySynthetic === true,
2299
+ actions,
2300
+ children: childIds,
2301
+ });
2302
+
2303
+ return [id];
2304
+ };
2305
+
2306
+ for (const child of root.children) {
2307
+ if (child.kind === NodeKind.Element) {
2308
+ visitElement(child);
2309
+ }
2310
+ }
2311
+
2312
+ return { nodes, refs };
2313
+ }
2314
+
2315
+ export function rememberSnapshot(
2316
+ rootId: number,
2317
+ refs: Record<ElementRef, ElementRefInfo>,
2318
+ tree?: ViewNode,
2319
+ ): SnapshotRecord {
2320
+ const record: SnapshotRecord = {
2321
+ snapshotId: nextSnapshotId(),
2322
+ rootId,
2323
+ stateVersion: sharedState.stateVersion,
2324
+ timestamp: currentTimestamp(),
2325
+ frameCount: sharedState.frameCount,
2326
+ refs,
2327
+ tree,
2328
+ };
2329
+ snapshots.set(record.snapshotId, record);
2330
+ snapshotOrder.push(record.snapshotId);
2331
+
2332
+ while (snapshotOrder.length > MAX_SNAPSHOTS) {
2333
+ const oldest = snapshotOrder.shift();
2334
+ if (oldest) {
2335
+ snapshots.delete(oldest);
2336
+ }
2337
+ }
2338
+
2339
+ return record;
2340
+ }
2341
+
2342
+ export function getSnapshot(snapshotId: string): SnapshotRecord | null {
2343
+ return snapshots.get(snapshotId) ?? null;
2344
+ }
2345
+
2346
+ export function snapshotIsCurrent(snapshotId: string): boolean {
2347
+ const snapshot = getSnapshot(snapshotId);
2348
+ return snapshot !== null && snapshot.stateVersion === sharedState.stateVersion;
2349
+ }
2350
+
2351
+ export function getLatestSnapshotId(): string | null {
2352
+ return snapshotOrder.length > 0
2353
+ ? snapshotOrder[snapshotOrder.length - 1]!
2354
+ : null;
2355
+ }
2356
+
2357
+ export function setTextValue(viewId: number, value: string): void {
2358
+ textOverrides.set(viewId, value);
2359
+ }
2360
+
2361
+ export function setToggleValue(viewId: number, value: boolean): void {
2362
+ toggleOverrides.set(viewId, value);
2363
+ }
2364
+
2365
+ export function setControlValue(viewId: number, value: unknown): void {
2366
+ valueOverrides.set(viewId, value);
2367
+ }
2368
+
2369
+ export function applyScrollDelta(viewId: number, dx: number, dy: number): Point {
2370
+ const current = scrollOffsets.get(viewId) ?? { x: 0, y: 0 };
2371
+ const next = {
2372
+ x: current.x + dx,
2373
+ y: current.y + dy,
2374
+ };
2375
+ scrollOffsets.set(viewId, next);
2376
+ return next;
2377
+ }
2378
+
2379
+ export function setScrollOffset(viewId: number, offset: Point): Point {
2380
+ const next = {
2381
+ x: offset.x,
2382
+ y: offset.y,
2383
+ };
2384
+ scrollOffsets.set(viewId, next);
2385
+ return next;
2386
+ }
2387
+
2388
+ function normalizeScrollCoordinate(value: unknown): number {
2389
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
2390
+ }
2391
+
2392
+ function syncInspectorScrollOffset(
2393
+ viewId: number,
2394
+ offset: Point,
2395
+ rootId?: number,
2396
+ ): Point | null {
2397
+ const root = resolveRoot(rootId);
2398
+ if (!root || !findNodeByViewId(root, viewId)) {
2399
+ return null;
2400
+ }
2401
+
2402
+ const next = setScrollOffset(viewId, {
2403
+ x: normalizeScrollCoordinate(offset?.x),
2404
+ y: normalizeScrollCoordinate(offset?.y),
2405
+ });
2406
+ notifyCommit(root);
2407
+ return next;
2408
+ }
2409
+
2410
+ globalThis.__exactSetInspectorScrollOffset = syncInspectorScrollOffset;
2411
+
2412
+ export function getScrollMetrics(
2413
+ viewId: number,
2414
+ rootId?: number,
2415
+ ): ResolveTargetScrollAncestor | null {
2416
+ const root = resolveRoot(rootId);
2417
+ if (!root) {
2418
+ return null;
2419
+ }
2420
+
2421
+ const node = findNodeByViewId(root, viewId);
2422
+ if (
2423
+ !node ||
2424
+ !(
2425
+ node.kind === NodeKind.Root ||
2426
+ (node.kind === NodeKind.Element && node.tagType === 'scroll')
2427
+ )
2428
+ ) {
2429
+ return null;
2430
+ }
2431
+
2432
+ const snapshot = serializeTree(root.rootId);
2433
+ const refIndex = new Map<number, ElementRef>();
2434
+ for (const [ref, info] of Object.entries(snapshot.refs)) {
2435
+ refIndex.set(info.viewId, ref as ElementRef);
2436
+ }
2437
+
2438
+ return scrollAncestorSnapshot(node, snapshot.frames, refIndex);
2439
+ }
2440
+
2441
+ export function getTextForView(viewId: number, rootId?: number): string | undefined {
2442
+ const root = resolveRoot(rootId);
2443
+ if (!root) {
2444
+ return undefined;
2445
+ }
2446
+ const node = findNodeByViewId(root, viewId);
2447
+ if (!node || node.kind !== NodeKind.Element) {
2448
+ return undefined;
2449
+ }
2450
+ return (getEffectiveTextValue(node) ?? getNodeText(node)) || undefined;
2451
+ }
2452
+
2453
+ export function resolveElement(
2454
+ selector: {
2455
+ ref?: ElementRef;
2456
+ snapshotId?: string;
2457
+ testId?: string;
2458
+ nativeID?: string;
2459
+ label?: string;
2460
+ viewId?: number;
2461
+ x?: number;
2462
+ y?: number;
2463
+ rootId?: number;
2464
+ },
2465
+ ): ResolvedElement | null {
2466
+ if (selector.ref) {
2467
+ if (!selector.snapshotId) {
2468
+ throw new Error('snapshotId is required when using refs');
2469
+ }
2470
+ const snapshot = getSnapshot(selector.snapshotId);
2471
+ if (!snapshot) {
2472
+ throw new Error(`Unknown snapshot ${selector.snapshotId}`);
2473
+ }
2474
+ const root = resolveRoot(snapshot.rootId);
2475
+ if (!root) {
2476
+ return null;
2477
+ }
2478
+ const ref = snapshot.refs[selector.ref];
2479
+ if (!ref) {
2480
+ return null;
2481
+ }
2482
+ const node = findNodeByViewId(root, ref.viewId);
2483
+ if (!node || node.kind !== NodeKind.Element) {
2484
+ return null;
2485
+ }
2486
+ const frames = buildFrameIndex(root);
2487
+ return {
2488
+ root,
2489
+ element: node,
2490
+ frame: frames.get(node.id) ?? ref.frame,
2491
+ };
2492
+ }
2493
+
2494
+ const root = resolveRoot(selector.rootId);
2495
+ if (!root) {
2496
+ return null;
2497
+ }
2498
+
2499
+ const frames = buildFrameIndex(root);
2500
+
2501
+ if (typeof selector.viewId === 'number') {
2502
+ const node = findNodeByViewId(root, selector.viewId);
2503
+ if (node?.kind === NodeKind.Element) {
2504
+ return {
2505
+ root,
2506
+ element: node,
2507
+ frame: frames.get(node.id) ?? { x: 0, y: 0, width: 0, height: 0 },
2508
+ };
2509
+ }
2510
+ return null;
2511
+ }
2512
+
2513
+ if (typeof selector.x === 'number' && typeof selector.y === 'number') {
2514
+ const node = findElementByPoint(root, frames, { x: selector.x, y: selector.y });
2515
+ if (!node) {
2516
+ return null;
2517
+ }
2518
+ return {
2519
+ root,
2520
+ element: node,
2521
+ frame: frames.get(node.id) ?? { x: selector.x, y: selector.y, width: 0, height: 0 },
2522
+ };
2523
+ }
2524
+
2525
+ const elements: ElementNode[] = [];
2526
+ flattenElements(root, elements);
2527
+
2528
+ if (selector.testId) {
2529
+ const matches = elements.filter((element) => getTestId(element) === selector.testId);
2530
+ if (matches.length === 0) {
2531
+ return null;
2532
+ }
2533
+ if (matches.length > 1) {
2534
+ throw new Error(`Ambiguous testId ${selector.testId}`);
2535
+ }
2536
+ return {
2537
+ root,
2538
+ element: matches[0],
2539
+ frame: frames.get(matches[0].id) ?? { x: 0, y: 0, width: 0, height: 0 },
2540
+ };
2541
+ }
2542
+
2543
+ if (selector.nativeID) {
2544
+ const nativeIds = buildNativeIdIndex(elements);
2545
+ const match = nativeIds.get(selector.nativeID);
2546
+ if (!match) {
2547
+ return null;
2548
+ }
2549
+ return {
2550
+ root,
2551
+ element: match,
2552
+ frame: frames.get(match.id) ?? { x: 0, y: 0, width: 0, height: 0 },
2553
+ };
2554
+ }
2555
+
2556
+ if (selector.label) {
2557
+ const normalized = normalizeLookup(selector.label);
2558
+ const matches = elements.filter((element) => normalizeLookup(getElementLabel(element) ?? '') === normalized);
2559
+ if (matches.length === 0) {
2560
+ return null;
2561
+ }
2562
+ if (matches.length > 1) {
2563
+ throw new Error(`Ambiguous label ${selector.label}`);
2564
+ }
2565
+ return {
2566
+ root,
2567
+ element: matches[0],
2568
+ frame: frames.get(matches[0].id) ?? { x: 0, y: 0, width: 0, height: 0 },
2569
+ };
2570
+ }
2571
+
2572
+ return null;
2573
+ }
2574
+
2575
+ export function resolveActionableElement(
2576
+ selector: Parameters<typeof resolveElement>[0],
2577
+ ): ResolvedElement | null {
2578
+ const resolved = resolveElement(selector);
2579
+ if (!resolved) {
2580
+ return null;
2581
+ }
2582
+ const actionable = nearestActionableElement(resolved.element);
2583
+ if (!actionable) {
2584
+ return resolved;
2585
+ }
2586
+ const frames = buildFrameIndex(resolved.root);
2587
+ return {
2588
+ root: resolved.root,
2589
+ element: actionable,
2590
+ frame: frames.get(actionable.id) ?? resolved.frame,
2591
+ };
2592
+ }
2593
+
2594
+ function nearestChangeElement(node: ElementNode | null): ElementNode | null {
2595
+ let current: HostNode | null = node;
2596
+ while (current) {
2597
+ if (current.kind === NodeKind.Element && current.events.has(EventType.Change)) {
2598
+ return current;
2599
+ }
2600
+ current = current.parent as HostNode | null;
2601
+ }
2602
+ return null;
2603
+ }
2604
+
2605
+ function findDescendantChangeElement(node: ElementNode): ElementNode | null {
2606
+ if (node.events.has(EventType.Change)) {
2607
+ return node;
2608
+ }
2609
+
2610
+ for (const child of node.children) {
2611
+ if (child.kind !== NodeKind.Element) {
2612
+ continue;
2613
+ }
2614
+
2615
+ const match = findDescendantChangeElement(child);
2616
+ if (match) {
2617
+ return match;
2618
+ }
2619
+ }
2620
+
2621
+ return null;
2622
+ }
2623
+
2624
+ export function resolveChangeElement(
2625
+ selector: Parameters<typeof resolveElement>[0],
2626
+ ): ResolvedElement | null {
2627
+ const resolved = resolveElement(selector);
2628
+ if (!resolved) {
2629
+ return null;
2630
+ }
2631
+
2632
+ // `exact_set_value` often targets a Facet wrapper by testId while the actual
2633
+ // Change handler lives on an inner native-view leaf. Search upward first so a
2634
+ // hit inside an input/slider resolves to the bound control, then search
2635
+ // downward so wrapper nodes still find their change-capable descendant.
2636
+ const target =
2637
+ nearestChangeElement(resolved.element) ??
2638
+ findDescendantChangeElement(resolved.element);
2639
+ if (!target) {
2640
+ return resolved;
2641
+ }
2642
+
2643
+ const frames = buildFrameIndex(resolved.root);
2644
+ return {
2645
+ root: resolved.root,
2646
+ element: target,
2647
+ frame: frames.get(target.id) ?? resolved.frame,
2648
+ };
2649
+ }
2650
+
2651
+ export function buildHitTestBlockers(
2652
+ element: ElementNode | null,
2653
+ frame: Frame | null,
2654
+ options: {
2655
+ root?: RootNode | null;
2656
+ point?: Point;
2657
+ interactionContext?: InteractionContext;
2658
+ } = {},
2659
+ ): HitTestBlocker[] {
2660
+ if (!element || !frame) {
2661
+ return [{ reason: 'offScreen' }];
2662
+ }
2663
+
2664
+ const point = options.point ?? getElementCenter(frame);
2665
+ const blockers: HitTestBlocker[] = [];
2666
+
2667
+ if (frame.width <= 0 || frame.height <= 0) {
2668
+ blockers.push({ reason: 'zeroSize' });
2669
+ }
2670
+
2671
+ if (element.props.disabled) {
2672
+ blockers.push({
2673
+ reason: 'disabled',
2674
+ by: {
2675
+ viewId: element.id,
2676
+ type: element.originalTag,
2677
+ label: getElementLabel(element),
2678
+ frame,
2679
+ },
2680
+ });
2681
+ }
2682
+
2683
+ const root = options.root;
2684
+ if (!root) {
2685
+ return blockers;
2686
+ }
2687
+
2688
+ const frames = buildFrameIndex(root);
2689
+ const interactionContext = options.interactionContext ?? buildInteractionContext(root);
2690
+ const blockingModal = blockingModalForElement(element, interactionContext);
2691
+ if (blockingModal) {
2692
+ blockers.push({
2693
+ reason: 'inert',
2694
+ by: {
2695
+ viewId: blockingModal.id,
2696
+ type: blockingModal.originalTag,
2697
+ label: getElementLabel(blockingModal),
2698
+ frame: frames.get(blockingModal.id) ?? undefined,
2699
+ },
2700
+ });
2701
+ }
2702
+ const floating = findFloatingPositionByViewId(element.id);
2703
+ if (floating?.scrolling === true) {
2704
+ blockers.push({
2705
+ reason: 'scrolling',
2706
+ by: {
2707
+ viewId: element.id,
2708
+ type: element.originalTag,
2709
+ label: getElementLabel(element),
2710
+ frame,
2711
+ },
2712
+ });
2713
+ }
2714
+ const rootFrame = frames.get(root.id);
2715
+ if (rootFrame && !pointInFrame(point, rootFrame)) {
2716
+ blockers.push({ reason: 'offScreen' });
2717
+ }
2718
+
2719
+ let ancestor: HostNode | null = element.parent as HostNode | null;
2720
+ while (ancestor) {
2721
+ if (ancestor.kind === NodeKind.Element) {
2722
+ const ancestorFrame = frames.get(ancestor.id);
2723
+ const clipsDescendants =
2724
+ ancestor.tagType === 'scroll' ||
2725
+ ancestor.style.overflow === 'hidden' ||
2726
+ ancestor.style.overflow === 'scroll';
2727
+ if (
2728
+ clipsDescendants &&
2729
+ ancestorFrame &&
2730
+ (!framesIntersect(frame, ancestorFrame) || !pointInFrame(point, ancestorFrame))
2731
+ ) {
2732
+ blockers.push({
2733
+ reason: 'clipped',
2734
+ by: {
2735
+ viewId: ancestor.id,
2736
+ type: ancestor.originalTag,
2737
+ label: getElementLabel(ancestor),
2738
+ frame: ancestorFrame,
2739
+ },
2740
+ });
2741
+ break;
2742
+ }
2743
+ }
2744
+ ancestor = ancestor.parent as HostNode | null;
2745
+ }
2746
+
2747
+ const target = nearestActionableElement(element) ?? element;
2748
+ const topmost = nearestActionableElement(findElementByPoint(root, frames, point));
2749
+ if (topmost && topmost.id !== target.id) {
2750
+ const topmostFrame = frames.get(topmost.id) ?? null;
2751
+ blockers.push({
2752
+ reason: 'overlapped',
2753
+ by: {
2754
+ viewId: topmost.id,
2755
+ type: topmost.originalTag,
2756
+ label: getElementLabel(topmost),
2757
+ frame: topmostFrame ?? undefined,
2758
+ },
2759
+ });
2760
+ }
2761
+
2762
+ return blockers;
2763
+ }
2764
+
2765
+ function buildViewIdToRefIndex(
2766
+ refs: Record<ElementRef, ElementRefInfo>,
2767
+ ): Map<number, ElementRef> {
2768
+ const index = new Map<number, ElementRef>();
2769
+ for (const [ref, info] of Object.entries(refs) as Array<[ElementRef, ElementRefInfo]>) {
2770
+ index.set(info.viewId, ref);
2771
+ }
2772
+ return index;
2773
+ }
2774
+
2775
+ function intersectFrames(left: Frame, right: Frame): Frame | null {
2776
+ const x = Math.max(left.x, right.x);
2777
+ const y = Math.max(left.y, right.y);
2778
+ const maxX = Math.min(left.x + left.width, right.x + right.width);
2779
+ const maxY = Math.min(left.y + left.height, right.y + right.height);
2780
+ const width = maxX - x;
2781
+ const height = maxY - y;
2782
+ if (width <= 0 || height <= 0) {
2783
+ return null;
2784
+ }
2785
+ return { x, y, width, height };
2786
+ }
2787
+
2788
+ function frameEquals(left: Frame, right: Frame): boolean {
2789
+ return (
2790
+ left.x === right.x &&
2791
+ left.y === right.y &&
2792
+ left.width === right.width &&
2793
+ left.height === right.height
2794
+ );
2795
+ }
2796
+
2797
+ function unionFrames(left: Frame | null, right: Frame | null): Frame | null {
2798
+ if (!left) {
2799
+ return right ? { ...right } : null;
2800
+ }
2801
+ if (!right) {
2802
+ return { ...left };
2803
+ }
2804
+ const x = Math.min(left.x, right.x);
2805
+ const y = Math.min(left.y, right.y);
2806
+ const maxX = Math.max(left.x + left.width, right.x + right.width);
2807
+ const maxY = Math.max(left.y + left.height, right.y + right.height);
2808
+ return {
2809
+ x,
2810
+ y,
2811
+ width: maxX - x,
2812
+ height: maxY - y,
2813
+ };
2814
+ }
2815
+
2816
+ function patternMatches(
2817
+ value: string | undefined,
2818
+ pattern: string | TextPattern | undefined,
2819
+ ): boolean {
2820
+ if (pattern === undefined) {
2821
+ return true;
2822
+ }
2823
+ if (typeof value !== 'string' || value.length === 0) {
2824
+ return false;
2825
+ }
2826
+ if (typeof pattern === 'string') {
2827
+ return normalizeLookup(value).includes(normalizeLookup(pattern));
2828
+ }
2829
+ try {
2830
+ return new RegExp(pattern.regex, pattern.flags).test(value);
2831
+ } catch {
2832
+ return false;
2833
+ }
2834
+ }
2835
+
2836
+ function computeDescendantBounds(
2837
+ node: ElementNode | RootNode,
2838
+ frames: Map<number, Frame>,
2839
+ ): Frame | null {
2840
+ let bounds: Frame | null = frames.get(node.id) ?? null;
2841
+ for (const child of node.children) {
2842
+ if (child.kind === NodeKind.Element) {
2843
+ bounds = unionFrames(bounds, computeDescendantBounds(child, frames));
2844
+ continue;
2845
+ }
2846
+ bounds = unionFrames(bounds, frames.get(child.id) ?? null);
2847
+ }
2848
+ return bounds;
2849
+ }
2850
+
2851
+ function scrollAncestorSnapshot(
2852
+ ancestor: ElementNode | RootNode,
2853
+ frames: Map<number, Frame>,
2854
+ refIndex: Map<number, ElementRef>,
2855
+ ): ResolveTargetScrollAncestor {
2856
+ const frame = frames.get(ancestor.id) ?? { x: 0, y: 0, width: 0, height: 0 };
2857
+ const bounds = computeDescendantBounds(ancestor, frames);
2858
+ const offset = scrollOffsets.get(ancestor.id) ?? { x: 0, y: 0 };
2859
+ const contentWidth = bounds
2860
+ ? Math.max(frame.width, bounds.x + bounds.width - frame.x + offset.x)
2861
+ : frame.width;
2862
+ const contentHeight = bounds
2863
+ ? Math.max(frame.height, bounds.y + bounds.height - frame.y + offset.y)
2864
+ : frame.height;
2865
+
2866
+ return {
2867
+ viewId: ancestor.id,
2868
+ ref: ancestor.kind === NodeKind.Element ? refIndex.get(ancestor.id) : undefined,
2869
+ type: ancestor.kind === NodeKind.Element ? ancestor.originalTag : 'Root',
2870
+ currentOffset: { ...offset },
2871
+ contentSize: {
2872
+ width: contentWidth,
2873
+ height: contentHeight,
2874
+ },
2875
+ viewportSize: {
2876
+ width: frame.width,
2877
+ height: frame.height,
2878
+ },
2879
+ };
2880
+ }
2881
+
2882
+ function rootScrollAncestorSnapshot(
2883
+ ancestor: RootNode,
2884
+ frames: Map<number, Frame>,
2885
+ refIndex: Map<number, ElementRef>,
2886
+ ): ResolveTargetScrollAncestor | null {
2887
+ const snapshot = scrollAncestorSnapshot(ancestor, frames, refIndex);
2888
+ const hasOffset =
2889
+ snapshot.currentOffset.x !== 0 || snapshot.currentOffset.y !== 0;
2890
+ const hasScrollableContent =
2891
+ snapshot.contentSize.width > snapshot.viewportSize.width ||
2892
+ snapshot.contentSize.height > snapshot.viewportSize.height;
2893
+ return hasOffset || hasScrollableContent ? snapshot : null;
2894
+ }
2895
+
2896
+ function occlusionFromBy(
2897
+ by:
2898
+ | {
2899
+ viewId: number;
2900
+ ref?: ElementRef;
2901
+ type: string;
2902
+ label?: string;
2903
+ }
2904
+ | undefined,
2905
+ ): ResolveTargetOcclusion | undefined {
2906
+ if (!by) {
2907
+ return undefined;
2908
+ }
2909
+ return {
2910
+ viewId: by.viewId,
2911
+ ref: by.ref,
2912
+ type: by.type,
2913
+ label: by.label,
2914
+ };
2915
+ }
2916
+
2917
+ function computeScrollDeltaForViewport(frame: Frame, viewport: Frame): Point {
2918
+ let x = 0;
2919
+ let y = 0;
2920
+
2921
+ if (frame.x < viewport.x) {
2922
+ x = frame.x - viewport.x;
2923
+ } else if (frame.x + frame.width > viewport.x + viewport.width) {
2924
+ x = frame.x + frame.width - (viewport.x + viewport.width);
2925
+ }
2926
+
2927
+ if (frame.y < viewport.y) {
2928
+ y = frame.y - viewport.y;
2929
+ } else if (frame.y + frame.height > viewport.y + viewport.height) {
2930
+ y = frame.y + frame.height - (viewport.y + viewport.height);
2931
+ }
2932
+
2933
+ return { x, y };
2934
+ }
2935
+
2936
+ function visibilityStateForRects(frame: Frame, visibleRect: Frame | null): VisibilityState {
2937
+ if (!visibleRect || visibleRect.width <= 0 || visibleRect.height <= 0) {
2938
+ return 'none';
2939
+ }
2940
+ return frameEquals(frame, visibleRect) ? 'full' : 'partial';
2941
+ }
2942
+
2943
+ function buildResolveTargetDataForElement(
2944
+ root: RootNode,
2945
+ element: ElementNode,
2946
+ frames: Map<number, Frame>,
2947
+ refIndex: Map<number, ElementRef>,
2948
+ explicitRef?: ElementRef,
2949
+ interactionContext: InteractionContext = buildInteractionContext(root),
2950
+ ): ResolveTargetData {
2951
+ const frame = frames.get(element.id) ?? { x: 0, y: 0, width: 0, height: 0 };
2952
+ let visibleRect: Frame | null = frame.width > 0 && frame.height > 0 ? { ...frame } : null;
2953
+ let clipAncestor: ResolveTargetOcclusion | undefined;
2954
+ let recommendedScrollDelta: ResolveTargetData['recommendedScrollDelta'];
2955
+ const scrollChain: ResolveTargetScrollAncestor[] = [];
2956
+
2957
+ let ancestor: HostNode | null = element.parent as HostNode | null;
2958
+ while (ancestor) {
2959
+ if (ancestor.kind === NodeKind.Element || ancestor.kind === NodeKind.Root) {
2960
+ const ancestorFrame = frames.get(ancestor.id);
2961
+ const scrollAncestor =
2962
+ ancestor.kind === NodeKind.Element && ancestor.tagType === 'scroll'
2963
+ ? scrollAncestorSnapshot(ancestor, frames, refIndex)
2964
+ : ancestor.kind === NodeKind.Root
2965
+ ? rootScrollAncestorSnapshot(ancestor, frames, refIndex)
2966
+ : null;
2967
+ if (scrollAncestor) {
2968
+ scrollChain.push(scrollAncestor);
2969
+ }
2970
+
2971
+ const clipsDescendants =
2972
+ ancestor.kind === NodeKind.Root ||
2973
+ (ancestor.kind === NodeKind.Element &&
2974
+ (ancestor.tagType === 'scroll' ||
2975
+ ancestor.style.overflow === 'hidden' ||
2976
+ ancestor.style.overflow === 'scroll'));
2977
+
2978
+ if (clipsDescendants && ancestorFrame && visibleRect) {
2979
+ const nextVisibleRect = intersectFrames(visibleRect, ancestorFrame);
2980
+ const wasClipped =
2981
+ !nextVisibleRect || !frameEquals(visibleRect, nextVisibleRect);
2982
+
2983
+ if (wasClipped && !clipAncestor) {
2984
+ clipAncestor = {
2985
+ viewId: ancestor.id,
2986
+ ref: ancestor.kind === NodeKind.Element ? refIndex.get(ancestor.id) : undefined,
2987
+ type: ancestor.kind === NodeKind.Element ? ancestor.originalTag : 'Root',
2988
+ label: ancestor.kind === NodeKind.Element ? getElementLabel(ancestor) : undefined,
2989
+ };
2990
+ }
2991
+
2992
+ if (
2993
+ wasClipped &&
2994
+ !recommendedScrollDelta &&
2995
+ scrollAncestor
2996
+ ) {
2997
+ const delta = computeScrollDeltaForViewport(frame, ancestorFrame);
2998
+ if (delta.x !== 0 || delta.y !== 0) {
2999
+ recommendedScrollDelta = {
3000
+ ancestor: {
3001
+ viewId: scrollAncestor.viewId,
3002
+ ref: scrollAncestor.ref,
3003
+ type: scrollAncestor.type,
3004
+ },
3005
+ delta,
3006
+ };
3007
+ }
3008
+ }
3009
+
3010
+ visibleRect = nextVisibleRect;
3011
+ }
3012
+ }
3013
+ ancestor = ancestor.parent as HostNode | null;
3014
+ }
3015
+
3016
+ const visibility = visibilityStateForRects(frame, visibleRect);
3017
+ const tapPoint = visibleRect ? getElementCenter(visibleRect) : null;
3018
+ const blockers = buildHitTestBlockers(element, frame, {
3019
+ root,
3020
+ point: tapPoint ?? getElementCenter(frame),
3021
+ interactionContext,
3022
+ });
3023
+ const occludedBy = blockers
3024
+ .filter((blocker) => blocker.reason === 'overlapped')
3025
+ .map((blocker) => occlusionFromBy(blocker.by))
3026
+ .filter((value): value is ResolveTargetOcclusion => value !== undefined);
3027
+ const interactable =
3028
+ visibility !== 'none' &&
3029
+ elementIsInteractable(element) &&
3030
+ blockers.length === 0;
3031
+
3032
+ return {
3033
+ target: getResolvedTarget(element, explicitRef ?? refIndex.get(element.id)),
3034
+ frame,
3035
+ visibleRect,
3036
+ visibility,
3037
+ occludedBy: occludedBy.length > 0 ? occludedBy : undefined,
3038
+ clipAncestor,
3039
+ scrollChain,
3040
+ interactable,
3041
+ recommendedTapPoint: interactable ? tapPoint : null,
3042
+ recommendedScrollDelta,
3043
+ };
3044
+ }
3045
+
3046
+ export function resolveTargetDetails(
3047
+ selector: Parameters<typeof resolveElement>[0],
3048
+ ): ResolveTargetData | null {
3049
+ const resolved =
3050
+ typeof selector.x === 'number' && typeof selector.y === 'number'
3051
+ ? resolveElement(selector)
3052
+ : resolveElement(selector);
3053
+ if (!resolved) {
3054
+ return null;
3055
+ }
3056
+
3057
+ const frames = buildFrameIndex(resolved.root);
3058
+ const snapshot = serializeTree(resolved.root.rootId);
3059
+ const refIndex = buildViewIdToRefIndex(snapshot.refs);
3060
+ const explicitRef =
3061
+ typeof selector.ref === 'string' ? (selector.ref as ElementRef) : undefined;
3062
+ const interactionContext = buildInteractionContext(resolved.root);
3063
+
3064
+ return buildResolveTargetDataForElement(
3065
+ resolved.root,
3066
+ resolved.element,
3067
+ frames,
3068
+ refIndex,
3069
+ explicitRef,
3070
+ interactionContext,
3071
+ );
3072
+ }
3073
+
3074
+ export function findMatchingElements(query: FindQuery): FindResultData {
3075
+ const root = resolveRoot(query.rootId);
3076
+ if (!root) {
3077
+ return {
3078
+ matches: [],
3079
+ total: 0,
3080
+ };
3081
+ }
3082
+
3083
+ const scopedNode =
3084
+ typeof query.viewId === 'number'
3085
+ ? findNodeByViewId(root, query.viewId) ?? root
3086
+ : root;
3087
+ if (scopedNode.kind !== NodeKind.Element && scopedNode.kind !== NodeKind.Root) {
3088
+ return {
3089
+ matches: [],
3090
+ total: 0,
3091
+ };
3092
+ }
3093
+
3094
+ const frames = buildFrameIndex(root);
3095
+ const snapshot = serializeTree(root.rootId);
3096
+ const refIndex = buildViewIdToRefIndex(snapshot.refs);
3097
+ const interactionContext = buildInteractionContext(root);
3098
+ const elements: ElementNode[] = [];
3099
+ flattenElements(scopedNode, elements);
3100
+
3101
+ const matches = elements
3102
+ .map((element) => {
3103
+ const frame = frames.get(element.id) ?? { x: 0, y: 0, width: 0, height: 0 };
3104
+ const label = getElementLabel(element);
3105
+ const text = (getEffectiveTextValue(element) ?? getNodeText(element)) || undefined;
3106
+ const testId = getTestId(element);
3107
+ const role = getRole(element);
3108
+ const resolved = buildResolveTargetDataForElement(
3109
+ root,
3110
+ element,
3111
+ frames,
3112
+ refIndex,
3113
+ undefined,
3114
+ interactionContext,
3115
+ );
3116
+
3117
+ return {
3118
+ ref: refIndex.get(element.id),
3119
+ viewId: element.id,
3120
+ type: element.originalTag,
3121
+ label,
3122
+ text,
3123
+ testId,
3124
+ role,
3125
+ frame,
3126
+ visible: resolved.visibility !== 'none',
3127
+ interactable: resolved.interactable,
3128
+ };
3129
+ })
3130
+ .filter((match) => {
3131
+ if (query.role && normalizeLookup(match.role ?? '') !== normalizeLookup(query.role)) {
3132
+ return false;
3133
+ }
3134
+ if (query.type && normalizeLookup(match.type) !== normalizeLookup(query.type)) {
3135
+ return false;
3136
+ }
3137
+ if (query.testId && match.testId !== query.testId) {
3138
+ return false;
3139
+ }
3140
+ if (!patternMatches(match.label, query.label)) {
3141
+ return false;
3142
+ }
3143
+ if (!patternMatches(match.text, query.text)) {
3144
+ return false;
3145
+ }
3146
+ if (typeof query.visible === 'boolean' && match.visible !== query.visible) {
3147
+ return false;
3148
+ }
3149
+ if (typeof query.interactable === 'boolean' && match.interactable !== query.interactable) {
3150
+ return false;
3151
+ }
3152
+ return true;
3153
+ })
3154
+ .sort((left, right) => left.viewId - right.viewId);
3155
+
3156
+ const limit = Math.max(0, query.limit ?? 20);
3157
+ return {
3158
+ matches: matches.slice(0, limit),
3159
+ total: matches.length,
3160
+ };
3161
+ }
3162
+
3163
+ export function describeResolvedElement(
3164
+ resolved: ResolvedElement | null,
3165
+ ref?: ElementRef,
3166
+ ): ResolvedTarget | undefined {
3167
+ if (!resolved) {
3168
+ return undefined;
3169
+ }
3170
+ return getResolvedTarget(resolved.element, ref);
3171
+ }
3172
+
3173
+ export function elementIsInteractable(element: ElementNode): boolean {
3174
+ return isElementInteractable(element);
3175
+ }
3176
+
3177
+ export function dispatchElementEvent(
3178
+ element: ElementNode,
3179
+ eventType: EventType,
3180
+ payload: unknown,
3181
+ ): boolean {
3182
+ const binding = element.events.get(eventType);
3183
+ if (!binding) {
3184
+ return false;
3185
+ }
3186
+
3187
+ const dispatch = (
3188
+ globalThis as {
3189
+ __exactDispatchEvent?: (handlerId: number, payload: unknown) => void;
3190
+ }
3191
+ ).__exactDispatchEvent;
3192
+
3193
+ if (typeof dispatch === 'function') {
3194
+ dispatch(binding.handlerId, payload);
3195
+ } else {
3196
+ binding.handler(payload);
3197
+ }
3198
+ return true;
3199
+ }
3200
+
3201
+ export function getElementCenter(frame: Frame): Point {
3202
+ return {
3203
+ x: frame.x + frame.width / 2,
3204
+ y: frame.y + frame.height / 2,
3205
+ };
3206
+ }
3207
+
3208
+ export function getVisibleInteractiveElements(rootId?: number): Array<{
3209
+ ref: ElementRef;
3210
+ description: string;
3211
+ }> {
3212
+ const snapshot = serializeTree(rootId);
3213
+ const entries: Array<{ ref: ElementRef; description: string }> = [];
3214
+
3215
+ for (const ref of Object.keys(snapshot.refs) as ElementRef[]) {
3216
+ const info = snapshot.refs[ref];
3217
+ const description = info.label
3218
+ ? `${info.type} "${info.label}"`
3219
+ : info.testId
3220
+ ? `${info.type} (${info.testId})`
3221
+ : info.type;
3222
+ entries.push({ ref, description });
3223
+ }
3224
+
3225
+ return entries;
3226
+ }
3227
+
3228
+ export interface ClipChainEntry {
3229
+ viewId: number;
3230
+ type: string;
3231
+ frame: Frame;
3232
+ overflow: string | undefined;
3233
+ }
3234
+
3235
+ function hasScrollableAncestor(node: ElementNode): boolean {
3236
+ let current: HostNode | null = node.parent as HostNode | null;
3237
+ while (current) {
3238
+ if (current.kind === NodeKind.Element && current.tagType === 'scroll') {
3239
+ return true;
3240
+ }
3241
+ current = current.parent as HostNode | null;
3242
+ }
3243
+ return false;
3244
+ }
3245
+
3246
+ function hasKeyboardAvoidanceAncestor(node: ElementNode): boolean {
3247
+ let current: HostNode | null = node;
3248
+ while (current) {
3249
+ if (
3250
+ current.kind === NodeKind.Element &&
3251
+ current.safeAreaState?.config.regions.includes('keyboard')
3252
+ ) {
3253
+ return true;
3254
+ }
3255
+ current = current.parent as HostNode | null;
3256
+ }
3257
+ return false;
3258
+ }
3259
+
3260
+ function nearlyMatches(value: number, expected: number, tolerance = 2): boolean {
3261
+ return Math.abs(value - expected) <= tolerance;
3262
+ }
3263
+
3264
+ export function computeDiagnostics(rootId?: number, viewId?: number): LayoutDiagnostic[] {
3265
+ const root = resolveRoot(rootId);
3266
+ if (!root) {
3267
+ return [];
3268
+ }
3269
+
3270
+ const frames = buildFrameIndex(root);
3271
+ const interactionContext = buildInteractionContext(root);
3272
+ const windowMetrics = getRootWindowMetrics(root.rootId);
3273
+ const diagnostics: LayoutDiagnostic[] = [];
3274
+ const source =
3275
+ typeof viewId === 'number'
3276
+ ? findNodeByViewId(root, viewId) ?? root
3277
+ : root;
3278
+
3279
+ const walk = (node: HostNode): void => {
3280
+ if (node.kind !== NodeKind.Element && node.kind !== NodeKind.Root) {
3281
+ return;
3282
+ }
3283
+
3284
+ if (node.kind === NodeKind.Element) {
3285
+ const frame = frames.get(node.id);
3286
+ if (frame) {
3287
+ const floating = findFloatingPositionByViewId(node.id);
3288
+ const invisiblePresentReason =
3289
+ node.props.inert === true
3290
+ ? 'inert-explicit'
3291
+ : isElementInteractable(node) && blockingModalForElement(node, interactionContext)
3292
+ ? 'inert-modal'
3293
+ : node.props.__exactPresencePhase === 'exiting'
3294
+ ? 'presence-exiting'
3295
+ : floating?.scrolling === true
3296
+ ? 'floating-scrolling'
3297
+ : floating?.state === 'premeasure'
3298
+ ? 'floating-premeasure'
3299
+ : floating?.state === 'hidden-anchor-escaped' ||
3300
+ floating?.state === 'hidden-reference-hidden' ||
3301
+ floating?.state === 'hidden-anchor-removed'
3302
+ ? 'floating-hidden'
3303
+ : null;
3304
+
3305
+ if (invisiblePresentReason) {
3306
+ diagnostics.push({
3307
+ severity: 'warning',
3308
+ kind: 'invisible-present',
3309
+ message: `Element is present in the tree but not interactable because ${invisiblePresentReason}`,
3310
+ viewId: node.id,
3311
+ type: node.originalTag,
3312
+ label: getElementLabel(node),
3313
+ frame,
3314
+ details: {
3315
+ reason: invisiblePresentReason,
3316
+ interactable: false,
3317
+ floatingState: floating?.state,
3318
+ },
3319
+ });
3320
+ }
3321
+
3322
+ // Walk the ancestor clip chain to detect clipping
3323
+ let ancestor: HostNode | null = node.parent as HostNode | null;
3324
+ while (ancestor) {
3325
+ if (ancestor.kind === NodeKind.Element || ancestor.kind === NodeKind.Root) {
3326
+ const ancestorFrame = frames.get(ancestor.id);
3327
+ const clipsDescendants = ancestor.kind === NodeKind.Root ||
3328
+ (ancestor.kind === NodeKind.Element && (
3329
+ ancestor.tagType === 'scroll' ||
3330
+ ancestor.style.overflow === 'hidden' ||
3331
+ ancestor.style.overflow === 'scroll'
3332
+ ));
3333
+
3334
+ if (clipsDescendants && ancestorFrame) {
3335
+ // Check if this element extends beyond its clipping ancestor
3336
+ const overflowRight = (frame.x + frame.width) - (ancestorFrame.x + ancestorFrame.width);
3337
+ const overflowBottom = (frame.y + frame.height) - (ancestorFrame.y + ancestorFrame.height);
3338
+ const overflowLeft = ancestorFrame.x - frame.x;
3339
+ const overflowTop = ancestorFrame.y - frame.y;
3340
+
3341
+ if (overflowRight > 1 || overflowBottom > 1 || overflowLeft > 1 || overflowTop > 1) {
3342
+ // Compute the visible rect after clipping
3343
+ const visibleX = Math.max(frame.x, ancestorFrame.x);
3344
+ const visibleY = Math.max(frame.y, ancestorFrame.y);
3345
+ const visibleRight = Math.min(frame.x + frame.width, ancestorFrame.x + ancestorFrame.width);
3346
+ const visibleBottom = Math.min(frame.y + frame.height, ancestorFrame.y + ancestorFrame.height);
3347
+ const visibleWidth = Math.max(0, visibleRight - visibleX);
3348
+ const visibleHeight = Math.max(0, visibleBottom - visibleY);
3349
+ const totalArea = frame.width * frame.height;
3350
+ const visibleArea = visibleWidth * visibleHeight;
3351
+ const clippedPercent = totalArea > 0
3352
+ ? Math.round((1 - visibleArea / totalArea) * 100)
3353
+ : 0;
3354
+
3355
+ if (clippedPercent > 5) {
3356
+ const scrollAncestor =
3357
+ ancestor.kind === NodeKind.Element && ancestor.tagType === 'scroll'
3358
+ ? ancestor
3359
+ : null;
3360
+ if (scrollAncestor) {
3361
+ if (isElementInteractable(node)) {
3362
+ diagnostics.push({
3363
+ severity: 'info',
3364
+ kind: 'scrollContentClipped',
3365
+ message: `${clippedPercent}% of this element is outside the ScrollView viewport; scroll to reveal it`,
3366
+ viewId: node.id,
3367
+ type: node.originalTag,
3368
+ label: getElementLabel(node),
3369
+ frame,
3370
+ details: {
3371
+ clipAncestor: {
3372
+ viewId: scrollAncestor.id,
3373
+ type: scrollAncestor.originalTag,
3374
+ frame: ancestorFrame,
3375
+ overflow: scrollAncestor.style.overflow,
3376
+ },
3377
+ visibleRect: { x: visibleX, y: visibleY, width: visibleWidth, height: visibleHeight },
3378
+ clippedPercent,
3379
+ },
3380
+ });
3381
+ }
3382
+ break;
3383
+ }
3384
+
3385
+ diagnostics.push({
3386
+ severity: 'warning',
3387
+ kind: 'clipped',
3388
+ message: `${clippedPercent}% of this element is clipped by ancestor ${ancestor.kind === NodeKind.Root ? 'root' : (ancestor as ElementNode).originalTag} (viewId ${ancestor.id})`,
3389
+ viewId: node.id,
3390
+ type: node.originalTag,
3391
+ label: getElementLabel(node),
3392
+ frame,
3393
+ details: {
3394
+ clipAncestor: {
3395
+ viewId: ancestor.id,
3396
+ type: ancestor.kind === NodeKind.Root ? 'Root' : (ancestor as ElementNode).originalTag,
3397
+ frame: ancestorFrame,
3398
+ overflow: ancestor.kind === NodeKind.Element
3399
+ ? (ancestor as ElementNode).style.overflow
3400
+ : undefined,
3401
+ },
3402
+ visibleRect: { x: visibleX, y: visibleY, width: visibleWidth, height: visibleHeight },
3403
+ clippedPercent,
3404
+ },
3405
+ });
3406
+ }
3407
+ }
3408
+ break; // Only check the nearest clipping ancestor
3409
+ }
3410
+ }
3411
+ ancestor = ancestor.parent as HostNode | null;
3412
+ }
3413
+
3414
+ // Detect zero-size elements that have children
3415
+ if ((frame.width <= 0 || frame.height <= 0) && node.children.length > 0) {
3416
+ diagnostics.push({
3417
+ severity: 'warning',
3418
+ kind: 'zeroSize',
3419
+ message: `Element has zero ${frame.width <= 0 ? 'width' : 'height'} but contains ${node.children.length} children`,
3420
+ viewId: node.id,
3421
+ type: node.originalTag,
3422
+ label: getElementLabel(node),
3423
+ frame,
3424
+ });
3425
+ }
3426
+
3427
+ const containerInsets = windowMetrics.regions.container;
3428
+ const rootFrame = frames.get(root.id);
3429
+ if (rootFrame && isElementInteractable(node)) {
3430
+ const violatedEdges: string[] = [];
3431
+ if (containerInsets.top > 0 && frame.y < rootFrame.y + containerInsets.top - 1) {
3432
+ violatedEdges.push('top');
3433
+ }
3434
+ if (
3435
+ containerInsets.right > 0 &&
3436
+ frame.x + frame.width > rootFrame.x + rootFrame.width - containerInsets.right + 1
3437
+ ) {
3438
+ violatedEdges.push('right');
3439
+ }
3440
+ if (
3441
+ containerInsets.bottom > 0 &&
3442
+ frame.y + frame.height > rootFrame.y + rootFrame.height - containerInsets.bottom + 1
3443
+ ) {
3444
+ violatedEdges.push('bottom');
3445
+ }
3446
+ if (containerInsets.left > 0 && frame.x < rootFrame.x + containerInsets.left - 1) {
3447
+ violatedEdges.push('left');
3448
+ }
3449
+
3450
+ if (violatedEdges.length > 0) {
3451
+ diagnostics.push({
3452
+ severity: 'warning',
3453
+ kind: 'safeAreaOcclusion',
3454
+ message: `Interactive content extends into the ${violatedEdges.join(', ')} safe area.`,
3455
+ viewId: node.id,
3456
+ type: node.originalTag,
3457
+ label: getElementLabel(node),
3458
+ frame,
3459
+ details: {
3460
+ edges: violatedEdges,
3461
+ containerInsets,
3462
+ },
3463
+ });
3464
+ }
3465
+ }
3466
+
3467
+ const keyboard = windowMetrics.keyboard;
3468
+ if (
3469
+ keyboard.visible &&
3470
+ keyboard.occlusion.width > 0 &&
3471
+ keyboard.occlusion.height > 0 &&
3472
+ framesIntersect(frame, keyboard.occlusion) &&
3473
+ !hasKeyboardAvoidanceAncestor(node) &&
3474
+ (node.tagType === 'input' || isElementInteractable(node))
3475
+ ) {
3476
+ diagnostics.push({
3477
+ severity: 'warning',
3478
+ kind: 'keyboardOcclusion',
3479
+ message: 'Content is intersecting the keyboard without keyboard avoidance.',
3480
+ viewId: node.id,
3481
+ type: node.originalTag,
3482
+ label: getElementLabel(node),
3483
+ frame,
3484
+ details: {
3485
+ keyboard: {
3486
+ ...keyboard,
3487
+ occlusion: { ...keyboard.occlusion },
3488
+ },
3489
+ },
3490
+ });
3491
+ }
3492
+
3493
+ if (
3494
+ node.tagType === 'input' &&
3495
+ !hasScrollableAncestor(node) &&
3496
+ !hasKeyboardAvoidanceAncestor(node)
3497
+ ) {
3498
+ diagnostics.push({
3499
+ severity: 'warning',
3500
+ kind: 'missingKeyboardAvoidance',
3501
+ message: 'Text input lives in a non-scrolling container with no keyboard avoidance.',
3502
+ viewId: node.id,
3503
+ type: node.originalTag,
3504
+ label: getElementLabel(node),
3505
+ frame,
3506
+ });
3507
+ }
3508
+
3509
+ if (!node.safeAreaState) {
3510
+ const paddingTop = getStyleDimension(node.style, 'paddingTop', frame.height, 0);
3511
+ const paddingBottom = getStyleDimension(node.style, 'paddingBottom', frame.height, 0);
3512
+ if (
3513
+ (containerInsets.top > 0 && nearlyMatches(paddingTop, containerInsets.top)) ||
3514
+ (containerInsets.bottom > 0 && nearlyMatches(paddingBottom, containerInsets.bottom))
3515
+ ) {
3516
+ diagnostics.push({
3517
+ severity: 'info',
3518
+ kind: 'duplicateSafeAreaPadding',
3519
+ message: 'Hardcoded padding closely matches the current container safe area.',
3520
+ viewId: node.id,
3521
+ type: node.originalTag,
3522
+ label: getElementLabel(node),
3523
+ frame,
3524
+ details: {
3525
+ paddingTop,
3526
+ paddingBottom,
3527
+ containerInsets,
3528
+ },
3529
+ });
3530
+ }
3531
+ }
3532
+ }
3533
+ }
3534
+
3535
+ // Walk children
3536
+ if (node.kind === NodeKind.Element || node.kind === NodeKind.Root) {
3537
+ for (const child of node.children) {
3538
+ walk(child);
3539
+ }
3540
+ }
3541
+ };
3542
+
3543
+ walk(source);
3544
+ return diagnostics;
3545
+ }
3546
+
3547
+ export function _resetInspectorState(): void {
3548
+ roots.clear();
3549
+ listeners.clear();
3550
+ snapshots.clear();
3551
+ snapshotOrder.length = 0;
3552
+ _resetAgentLogState();
3553
+ resetAgentStateTracking();
3554
+ clearInteractionOverrides();
3555
+ sharedState.snapshotCounter = 0;
3556
+ sharedState.stateVersion = 0;
3557
+ sharedState.frameCount = 0;
3558
+ sharedState.lastCommitAt = 0;
3559
+ }
3560
+
3561
+ (globalThis as typeof globalThis & {
3562
+ __exactRendererInspectorTargeting?: Pick<
3563
+ typeof import('./inspector.js'),
3564
+ 'resolveElement' | 'resolveTargetDetails' | 'setScrollOffset'
3565
+ >;
3566
+ }).__exactRendererInspectorTargeting = {
3567
+ resolveElement,
3568
+ resolveTargetDetails,
3569
+ setScrollOffset,
3570
+ };