@limrun/ui 0.9.0-rc.4 → 0.9.0-rc.6

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.
@@ -0,0 +1,416 @@
1
+ // Accessibility tree types, normalizers, and helpers shared by the inspect
2
+ // overlay and exported for customers building their own panels.
3
+ //
4
+ // We unify two server response shapes into a single AxSnapshot:
5
+ //
6
+ // iOS (limulator): {type:'elementTreeResult', id, json: '<nested-tree-json>'}
7
+ // Android (scrcpy): {type:'getElementTreeResult', id, payload:{nodes:[flat]}}
8
+ // (also emits legacy {type:'elementTreeResult', id, json:'<xml>'})
9
+ //
10
+ // Both are flattened into a single list of AxElement; positions are expressed
11
+ // in a normalized screen coordinate space derived from the root rect so
12
+ // rendering can use plain percentages.
13
+
14
+ export interface AxRect {
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ }
20
+
21
+ export interface AxSelectors {
22
+ AXUniqueId?: string;
23
+ AXLabel?: string;
24
+ resourceId?: string;
25
+ contentDesc?: string;
26
+ text?: string;
27
+ className?: string;
28
+ }
29
+
30
+ export interface AxElement {
31
+ // Stable identity for React keys / selection persistence across snapshots.
32
+ // Prefers AXUniqueId / resourceId, falls back to a hierarchical path.
33
+ id: string;
34
+ // Hierarchical path within the source tree (e.g. "0.1.2"). Useful as a
35
+ // fallback identity and for debugging.
36
+ path: string;
37
+ // Human label (AXLabel on iOS, content-desc/text on Android).
38
+ label: string;
39
+ // Value (AXValue on iOS, text on Android inputs).
40
+ value: string;
41
+ // Semantic role (role_description on iOS, className on Android).
42
+ role: string;
43
+ // Element type / class name.
44
+ type: string;
45
+ // Whether the element is interactive.
46
+ enabled: boolean;
47
+ // Whether the element currently has focus.
48
+ focused: boolean;
49
+ // Bounds in the screen coordinate space of AxSnapshot.screen.
50
+ frame: AxRect;
51
+ // Selectors that map back to SDK tapElement / tap calls.
52
+ selectors: AxSelectors;
53
+ // Raw platform-specific node (without children/parsedBounds extras).
54
+ // Exposed so advanced customers can read fields we didn't surface.
55
+ raw: Record<string, unknown>;
56
+ }
57
+
58
+ export type AxPlatform = 'ios' | 'android';
59
+
60
+ export interface AxSnapshot {
61
+ platform: AxPlatform;
62
+ screen: { width: number; height: number };
63
+ elements: AxElement[];
64
+ // Unix epoch ms when the response was decoded on the client.
65
+ capturedAt: number;
66
+ errors?: string[];
67
+ }
68
+
69
+ export const AX_UNAVAILABLE_ERROR = 'Accessibility unavailable on this device.';
70
+
71
+ // Hard cap to keep React render time bounded on enormous trees.
72
+ const MAX_ELEMENTS = 500;
73
+
74
+ const rectsApproxEqual = (a: AxRect, b: AxRect): boolean =>
75
+ Math.abs(a.x - b.x) < 0.5 &&
76
+ Math.abs(a.y - b.y) < 0.5 &&
77
+ Math.abs(a.width - b.width) < 0.5 &&
78
+ Math.abs(a.height - b.height) < 0.5;
79
+
80
+ const rectArea = (r: AxRect): number => Math.max(0, r.width) * Math.max(0, r.height);
81
+
82
+ // ────────────────────────────────────────────────────────────────────────────
83
+ // iOS normalization
84
+ // ────────────────────────────────────────────────────────────────────────────
85
+
86
+ interface RawIosNode {
87
+ AXLabel?: string | null;
88
+ AXValue?: string | null;
89
+ AXUniqueId?: string | null;
90
+ // `frame` is the canonical bounds; the legacy `AXFrame` string field is
91
+ // not consumed.
92
+ frame?: { x: number; y: number; width: number; height: number };
93
+ role?: string;
94
+ role_description?: string;
95
+ type?: string;
96
+ subrole?: string | null;
97
+ title?: string | null;
98
+ enabled?: boolean;
99
+ focused?: boolean;
100
+ pid?: number;
101
+ traits?: string[];
102
+ children?: RawIosNode[];
103
+ // Some serializations carry extras we'll preserve in `raw`.
104
+ [key: string]: unknown;
105
+ }
106
+
107
+ // Picks the "screen rectangle" used as the denominator when expressing
108
+ // element positions as percentages.
109
+ //
110
+ // Apple's `accessibilityElementForFrontmostApplication()` returns the
111
+ // foreground Application element as the (only) root; its `frame` is the
112
+ // device's logical screen — `{x: 0, y: 0, width, height}` in points. The
113
+ // element frames inside the tree are in this same coordinate space, so a
114
+ // child at `(16, 64)` is 16pt from the device's left edge and 64pt from
115
+ // the top edge.
116
+ //
117
+ // We accept any root whose frame has positive dimensions to be robust
118
+ // against the edge case where the foreground app reports a non-zero
119
+ // origin (e.g. status bar excluded). In practice that never happens; if
120
+ // it does, percentages will be slightly off but the overlay will still
121
+ // roughly line up. The defensive console.warn helps us catch this in
122
+ // production telemetry without breaking the feature.
123
+ const iosScreenFrame = (roots: RawIosNode[]): AxRect => {
124
+ const root = roots.find((n) => n.frame && n.frame.width > 0 && n.frame.height > 0);
125
+ if (root?.frame) {
126
+ if (Math.abs(root.frame.x) > 0.5 || Math.abs(root.frame.y) > 0.5) {
127
+ console.warn(
128
+ `[ax-tree] iOS root frame is not anchored at (0,0): ` +
129
+ `(${root.frame.x},${root.frame.y}). Element positions may be ` +
130
+ `slightly off from the rendered video.`,
131
+ );
132
+ }
133
+ return { x: 0, y: 0, width: root.frame.width, height: root.frame.height };
134
+ }
135
+ return { x: 0, y: 0, width: 1, height: 1 };
136
+ };
137
+
138
+ const buildIosSelectors = (node: RawIosNode): AxSelectors => {
139
+ const sel: AxSelectors = {};
140
+ if (typeof node.AXUniqueId === 'string' && node.AXUniqueId.length > 0) sel.AXUniqueId = node.AXUniqueId;
141
+ if (typeof node.AXLabel === 'string' && node.AXLabel.length > 0) sel.AXLabel = node.AXLabel;
142
+ if (typeof node.type === 'string' && node.type.length > 0) sel.className = node.type;
143
+ return sel;
144
+ };
145
+
146
+ const stripIosChildren = (node: RawIosNode): Record<string, unknown> => {
147
+ const out: Record<string, unknown> = {};
148
+ for (const [k, v] of Object.entries(node)) {
149
+ if (k === 'children') continue;
150
+ out[k] = v;
151
+ }
152
+ return out;
153
+ };
154
+
155
+ export function normalizeIosTree(roots: RawIosNode[] | RawIosNode): AxSnapshot {
156
+ const rootArray = Array.isArray(roots) ? roots : [roots];
157
+ const screen = iosScreenFrame(rootArray);
158
+ const elements: AxElement[] = [];
159
+
160
+ const visit = (node: RawIosNode, path: string) => {
161
+ if (elements.length >= MAX_ELEMENTS) return;
162
+ const frame = node.frame;
163
+ if (frame && frame.width > 0 && frame.height > 0 && !rectsApproxEqual(frame, screen)) {
164
+ const role = (node.role_description as string | undefined) || (node.role as string | undefined) || '';
165
+ const label =
166
+ (typeof node.AXLabel === 'string' ? node.AXLabel : '') ||
167
+ (typeof node.title === 'string' ? (node.title as string) : '') ||
168
+ '';
169
+ elements.push({
170
+ id: typeof node.AXUniqueId === 'string' && node.AXUniqueId.length > 0 ? node.AXUniqueId : path,
171
+ path,
172
+ label,
173
+ value: typeof node.AXValue === 'string' ? node.AXValue : '',
174
+ role,
175
+ type: typeof node.type === 'string' ? node.type : '',
176
+ enabled: node.enabled !== false,
177
+ focused: node.focused === true,
178
+ frame: { x: frame.x, y: frame.y, width: frame.width, height: frame.height },
179
+ selectors: buildIosSelectors(node),
180
+ raw: stripIosChildren(node),
181
+ });
182
+ }
183
+ const children = Array.isArray(node.children) ? node.children : [];
184
+ for (let i = 0; i < children.length && elements.length < MAX_ELEMENTS; i++) {
185
+ visit(children[i]!, `${path}.${i}`);
186
+ }
187
+ };
188
+
189
+ for (let i = 0; i < rootArray.length && elements.length < MAX_ELEMENTS; i++) {
190
+ visit(rootArray[i]!, String(i));
191
+ }
192
+
193
+ return {
194
+ platform: 'ios',
195
+ screen: { width: screen.width, height: screen.height },
196
+ elements,
197
+ capturedAt: Date.now(),
198
+ };
199
+ }
200
+
201
+ // ────────────────────────────────────────────────────────────────────────────
202
+ // Android normalization
203
+ // ────────────────────────────────────────────────────────────────────────────
204
+
205
+ interface RawAndroidParsedBounds {
206
+ left: number;
207
+ top: number;
208
+ right: number;
209
+ bottom: number;
210
+ centerX: number;
211
+ centerY: number;
212
+ }
213
+
214
+ interface RawAndroidNode {
215
+ index?: string;
216
+ text?: string;
217
+ resourceId?: string;
218
+ className?: string;
219
+ packageName?: string;
220
+ contentDesc?: string;
221
+ clickable?: boolean;
222
+ enabled?: boolean;
223
+ focusable?: boolean;
224
+ focused?: boolean;
225
+ scrollable?: boolean;
226
+ selected?: boolean;
227
+ bounds?: string;
228
+ parsedBounds?: RawAndroidParsedBounds;
229
+ [key: string]: unknown;
230
+ }
231
+
232
+ const androidScreenFrame = (nodes: RawAndroidNode[]): AxRect => {
233
+ // Take the largest rect — uiautomator's first node is typically the
234
+ // screen-spanning FrameLayout, but tolerate weirdness by scanning.
235
+ let best: AxRect = { x: 0, y: 0, width: 1, height: 1 };
236
+ let bestArea = 0;
237
+ for (const n of nodes) {
238
+ const pb = n.parsedBounds;
239
+ if (!pb) continue;
240
+ const w = pb.right - pb.left;
241
+ const h = pb.bottom - pb.top;
242
+ const area = Math.max(0, w) * Math.max(0, h);
243
+ if (area > bestArea) {
244
+ bestArea = area;
245
+ best = { x: 0, y: 0, width: w, height: h };
246
+ }
247
+ }
248
+ return best;
249
+ };
250
+
251
+ const buildAndroidSelectors = (node: RawAndroidNode): AxSelectors => {
252
+ const sel: AxSelectors = {};
253
+ if (typeof node.resourceId === 'string' && node.resourceId.length > 0) sel.resourceId = node.resourceId;
254
+ if (typeof node.contentDesc === 'string' && node.contentDesc.length > 0) sel.contentDesc = node.contentDesc;
255
+ if (typeof node.text === 'string' && node.text.length > 0) sel.text = node.text;
256
+ if (typeof node.className === 'string' && node.className.length > 0) sel.className = node.className;
257
+ return sel;
258
+ };
259
+
260
+ export function normalizeAndroidTree(nodes: RawAndroidNode[]): AxSnapshot {
261
+ const screen = androidScreenFrame(nodes);
262
+ const elements: AxElement[] = [];
263
+
264
+ for (let i = 0; i < nodes.length && elements.length < MAX_ELEMENTS; i++) {
265
+ const node = nodes[i]!;
266
+ const pb = node.parsedBounds;
267
+ if (!pb) continue;
268
+ const width = Math.max(0, pb.right - pb.left);
269
+ const height = Math.max(0, pb.bottom - pb.top);
270
+ if (width <= 0 || height <= 0) continue;
271
+ const frame: AxRect = { x: pb.left, y: pb.top, width, height };
272
+ if (rectsApproxEqual(frame, { x: 0, y: 0, width: screen.width, height: screen.height })) continue;
273
+
274
+ const label = node.contentDesc || node.text || '';
275
+ const role = node.className || '';
276
+ elements.push({
277
+ id:
278
+ node.resourceId && node.resourceId.length > 0 ? node.resourceId
279
+ : node.contentDesc && node.contentDesc.length > 0 ? `cd:${node.contentDesc}`
280
+ : String(i),
281
+ path: String(i),
282
+ label,
283
+ value: typeof node.text === 'string' ? node.text : '',
284
+ role,
285
+ type: role,
286
+ enabled: node.enabled !== false,
287
+ focused: node.focused === true,
288
+ frame,
289
+ selectors: buildAndroidSelectors(node),
290
+ raw: { ...node },
291
+ });
292
+ }
293
+
294
+ return {
295
+ platform: 'android',
296
+ screen: { width: screen.width, height: screen.height },
297
+ elements,
298
+ capturedAt: Date.now(),
299
+ };
300
+ }
301
+
302
+ // ────────────────────────────────────────────────────────────────────────────
303
+ // Generic helpers (exported for customers building their own panels)
304
+ // ────────────────────────────────────────────────────────────────────────────
305
+
306
+ export function clampAxFrameForScreen(
307
+ frame: AxRect,
308
+ screen: { width: number; height: number },
309
+ ): AxRect | null {
310
+ const x = Math.max(0, frame.x);
311
+ const y = Math.max(0, frame.y);
312
+ const right = Math.min(screen.width, frame.x + frame.width);
313
+ const bottom = Math.min(screen.height, frame.y + frame.height);
314
+ const width = Math.max(0, right - x);
315
+ const height = Math.max(0, bottom - y);
316
+ return width > 0 && height > 0 ? { x, y, width, height } : null;
317
+ }
318
+
319
+ export function axElementsEqual(a: AxElement, b: AxElement): boolean {
320
+ if (a === b) return true;
321
+ if (a.id !== b.id || a.path !== b.path) return false;
322
+ if (a.label !== b.label || a.value !== b.value) return false;
323
+ if (a.role !== b.role || a.type !== b.type) return false;
324
+ if (a.enabled !== b.enabled || a.focused !== b.focused) return false;
325
+ const fa = a.frame;
326
+ const fb = b.frame;
327
+ return fa.x === fb.x && fa.y === fb.y && fa.width === fb.width && fa.height === fb.height;
328
+ }
329
+
330
+ export function axSnapshotsEqual(a: AxSnapshot | null, b: AxSnapshot | null): boolean {
331
+ if (a === b) return true;
332
+ if (!a || !b) return false;
333
+ if (a.platform !== b.platform) return false;
334
+ if (a.screen.width !== b.screen.width || a.screen.height !== b.screen.height) return false;
335
+ if (a.elements.length !== b.elements.length) return false;
336
+ for (let i = 0; i < a.elements.length; i++) {
337
+ if (!axElementsEqual(a.elements[i]!, b.elements[i]!)) return false;
338
+ }
339
+ return true;
340
+ }
341
+
342
+ // Returns the smallest matching element under the given point (in screen
343
+ // coordinate space). Used by the overlay for hit-testing when boxes overlap.
344
+ export function axElementAtPoint(snapshot: AxSnapshot, x: number, y: number): AxElement | null {
345
+ let best: AxElement | null = null;
346
+ let bestArea = Infinity;
347
+ for (const el of snapshot.elements) {
348
+ const f = el.frame;
349
+ if (x < f.x || y < f.y || x > f.x + f.width || y > f.y + f.height) continue;
350
+ const area = rectArea(f);
351
+ if (area < bestArea) {
352
+ bestArea = area;
353
+ best = el;
354
+ }
355
+ }
356
+ return best;
357
+ }
358
+
359
+ // Produces a one-line SDK selector expression that customers can paste into
360
+ // code. Returns null when no usable selector exists.
361
+ export function axElementSelectorExpression(el: AxElement, platform: AxPlatform): string | null {
362
+ if (platform === 'ios') {
363
+ if (el.selectors.AXUniqueId) {
364
+ return `client.tapElement({ AXUniqueId: ${JSON.stringify(el.selectors.AXUniqueId)} })`;
365
+ }
366
+ if (el.selectors.AXLabel) {
367
+ const typeHint = el.selectors.className ? `, type: ${JSON.stringify(el.selectors.className)}` : '';
368
+ return `client.tapElement({ AXLabel: ${JSON.stringify(el.selectors.AXLabel)}${typeHint} })`;
369
+ }
370
+ return null;
371
+ }
372
+ // android
373
+ if (el.selectors.resourceId) {
374
+ return `client.tap({ selector: { resourceId: ${JSON.stringify(el.selectors.resourceId)} } })`;
375
+ }
376
+ if (el.selectors.contentDesc) {
377
+ return `client.tap({ selector: { contentDesc: ${JSON.stringify(el.selectors.contentDesc)} } })`;
378
+ }
379
+ if (el.selectors.text) {
380
+ return `client.tap({ selector: { text: ${JSON.stringify(el.selectors.text)} } })`;
381
+ }
382
+ return null;
383
+ }
384
+
385
+ // Cleans up internal-looking role tokens. iOS's `role_description` can be
386
+ // raw strings like "AXGenericElement" or empty when AppKit doesn't have a
387
+ // description for the role. Android sets role to the className which is
388
+ // often fully-qualified (`android.widget.TextView`); strip the package.
389
+ export function axElementRoleLabel(el: AxElement): string {
390
+ const raw = el.role || el.type || '';
391
+ if (!raw) return 'element';
392
+ // Drop "AX" prefix and split CamelCase into spaced words ("AXTextField" → "Text Field").
393
+ let cleaned = raw.replace(/^AX/, '');
394
+ // For Android fully-qualified names, keep just the last segment.
395
+ if (cleaned.includes('.')) {
396
+ cleaned = cleaned.split('.').pop()!;
397
+ }
398
+ // Reject the generic catch-all bucket; callers can decide to hide it.
399
+ if (cleaned === 'GenericElement') return 'Element';
400
+ // Lightly humanize CamelCase.
401
+ cleaned = cleaned.replace(/([a-z])([A-Z])/g, '$1 $2');
402
+ return cleaned;
403
+ }
404
+
405
+ // A short human-readable summary used in tooltips and the info card title.
406
+ // Truncated to avoid blowing up tooltips on elements with paragraph-length
407
+ // AXLabels.
408
+ const SUMMARY_MAX_LABEL_LEN = 80;
409
+ export function axElementSummary(el: AxElement): string {
410
+ const role = axElementRoleLabel(el);
411
+ const text = el.label || el.value || '';
412
+ if (!text) return role;
413
+ const trimmed =
414
+ text.length > SUMMARY_MAX_LABEL_LEN ? text.slice(0, SUMMARY_MAX_LABEL_LEN).trimEnd() + '…' : text;
415
+ return `${role} · ${trimmed}`;
416
+ }
package/src/demo.tsx CHANGED
@@ -1,17 +1,39 @@
1
1
  import { useState, useRef } from 'react';
2
2
  import { createRoot } from 'react-dom/client';
3
3
  import { RemoteControl, RemoteControlHandle } from './components/remote-control';
4
+ import { AxSnapshot } from './core/ax-tree';
5
+
6
+ type InspectChoice = 'off' | 'hover-only' | 'select';
7
+
8
+ // Pre-fill from query string so the developer testing the demo can deeplink
9
+ // `?url=...&token=...&inspect=select&autoconnect=1`. Nothing is persisted.
10
+ const initialParams = new URLSearchParams(window.location.search);
11
+ const initialUrl = initialParams.get('url') || 'ws://localhost:8833/signaling';
12
+ const initialToken = initialParams.get('token') || 'token';
13
+ const initialPlatformParam = initialParams.get('platform');
14
+ const initialPlatform: 'ios' | 'android' = initialPlatformParam === 'android' ? 'android' : 'ios';
15
+ const initialInspect = (initialParams.get('inspect') as InspectChoice | null) ?? 'off';
16
+ const initialAutoconnect =
17
+ initialParams.get('autoconnect') === '1' && initialParams.has('url') && initialParams.has('token');
4
18
 
5
19
  function Demo() {
6
- const [url, setUrl] = useState('ws://localhost:8833/signaling');
7
- const [token, setToken] = useState('token');
8
- const [platform, setPlatform] = useState<'ios' | 'android'>('ios');
9
- const [isConnected, setIsConnected] = useState(false);
20
+ const [url, setUrl] = useState(initialUrl);
21
+ const [token, setToken] = useState(initialToken);
22
+ const [platform, setPlatform] = useState<'ios' | 'android'>(initialPlatform);
23
+ const [isConnected, setIsConnected] = useState(initialAutoconnect);
10
24
  const [key, setKey] = useState(0);
11
25
  const [showDebugInfo, setShowDebugInfo] = useState(false);
26
+ const [inspectChoice, setInspectChoice] = useState<InspectChoice>(initialInspect);
27
+ const [latestSnapshot, setLatestSnapshot] = useState<AxSnapshot | null>(null);
28
+ const [latestSelection, setLatestSelection] = useState<string | null>(null);
12
29
 
13
30
  const remoteControlRef = useRef<RemoteControlHandle>(null);
14
31
 
32
+ const inspectModeProp: boolean | 'hover-only' | undefined =
33
+ inspectChoice === 'off' ? undefined
34
+ : inspectChoice === 'hover-only' ? 'hover-only'
35
+ : true;
36
+
15
37
  const handleConnect = () => {
16
38
  if (url) {
17
39
  setIsConnected(true);
@@ -112,6 +134,19 @@ function Demo() {
112
134
  </label>
113
135
  </div>
114
136
 
137
+ <div className="control-group">
138
+ <label htmlFor="inspect-mode">Inspect Mode</label>
139
+ <select
140
+ id="inspect-mode"
141
+ value={inspectChoice}
142
+ onChange={(e) => setInspectChoice(e.target.value as InspectChoice)}
143
+ >
144
+ <option value="off">Off</option>
145
+ <option value="hover-only">Hover-only (input still works)</option>
146
+ <option value="select">Select (click to pin, ESC to clear)</option>
147
+ </select>
148
+ </div>
149
+
115
150
  <div className="button-group">
116
151
  {!isConnected ?
117
152
  <button className="primary" onClick={handleConnect} disabled={!url}>
@@ -153,14 +188,62 @@ function Demo() {
153
188
  )}
154
189
 
155
190
  {isConnected ?
156
- <div className="device-preview">
157
- <div className="preview-item">
158
- <h3>{platform === 'ios' ? '📱 iOS with Frame' : '🤖 Android (No Frame)'}</h3>
159
- <div className="device-wrapper">
160
- <RemoteControl key={key} ref={remoteControlRef} url={url} token={token} />
191
+ <>
192
+ <div className="device-preview">
193
+ <div className="preview-item">
194
+ <h3>{platform === 'ios' ? '📱 iOS with Frame' : '🤖 Android (No Frame)'}</h3>
195
+ <div className="device-wrapper">
196
+ <RemoteControl
197
+ key={key}
198
+ ref={remoteControlRef}
199
+ url={url}
200
+ token={token}
201
+ inspectMode={inspectModeProp}
202
+ onAxSnapshotChange={setLatestSnapshot}
203
+ onInspectSelectionChange={(sel) =>
204
+ setLatestSelection(
205
+ sel ?
206
+ `${sel.element.role || sel.element.type} · ${sel.element.label || '(no label)'}`
207
+ : null,
208
+ )
209
+ }
210
+ />
211
+ </div>
161
212
  </div>
162
213
  </div>
163
- </div>
214
+
215
+ {inspectChoice !== 'off' && (
216
+ <div
217
+ style={{
218
+ marginTop: '20px',
219
+ background: '#0f172a',
220
+ color: '#e2e8f0',
221
+ border: '1px solid #1e293b',
222
+ borderRadius: '8px',
223
+ padding: '12px 14px',
224
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
225
+ fontSize: '12px',
226
+ }}
227
+ >
228
+ <div
229
+ style={{
230
+ fontWeight: 600,
231
+ marginBottom: '6px',
232
+ display: 'flex',
233
+ gap: '12px',
234
+ flexWrap: 'wrap',
235
+ }}
236
+ >
237
+ <span>Inspect debug (demo-only)</span>
238
+ <span style={{ color: '#94a3b8', fontWeight: 400 }}>
239
+ elements: {latestSnapshot?.elements.length ?? 0} · screen:{' '}
240
+ {latestSnapshot ? `${latestSnapshot.screen.width}×${latestSnapshot.screen.height}` : '—'}{' '}
241
+ · platform: {latestSnapshot?.platform ?? '—'} · selection: {latestSelection ?? '(none)'}
242
+ </span>
243
+ </div>
244
+ </div>
245
+ )}
246
+ </>
164
247
  : <div
165
248
  style={{
166
249
  textAlign: 'center',
package/src/index.ts CHANGED
@@ -2,3 +2,20 @@ export { RemoteControl } from './components/remote-control';
2
2
  export type { RemoteControlHandle } from './components/remote-control';
3
3
  export { DeviceInstallDialog, DeviceInstallRelay } from './components/device-install';
4
4
  export { useDeviceInstall } from './hooks/use-device-install';
5
+
6
+ // Accessibility / inspect-mode types and helpers. Exported so customers can
7
+ // build their own side panels, search UIs, or agent-driven inspectors on top
8
+ // of the snapshots delivered via `onAxSnapshotChange`.
9
+ export type { AxSnapshot, AxElement, AxRect, AxSelectors, AxPlatform } from './core/ax-tree';
10
+ export type { AxStatus } from './core/ax-fetcher';
11
+ export {
12
+ axElementAtPoint,
13
+ axElementSelectorExpression,
14
+ axElementSummary,
15
+ axElementsEqual,
16
+ axSnapshotsEqual,
17
+ clampAxFrameForScreen,
18
+ normalizeAndroidTree,
19
+ normalizeIosTree,
20
+ AX_UNAVAILABLE_ERROR,
21
+ } from './core/ax-tree';
@@ -0,0 +1,23 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ // Vitest config for @limrun/ui unit tests.
4
+ //
5
+ // We use jsdom as the default environment because the runtime code uses
6
+ // browser globals (window.setTimeout, window.requestAnimationFrame, etc.).
7
+ // Pure modules can opt back into the node env per-file via:
8
+ //
9
+ // // @vitest-environment node
10
+ //
11
+ // at the top of the test file.
12
+ //
13
+ // The actual <RemoteControl> component is intentionally NOT under unit
14
+ // test here — its WebRTC plumbing is integration-tested via the demo +
15
+ // staging instance. These tests cover the smaller, pure-logic modules:
16
+ // `core/ax-tree.ts` and `core/ax-fetcher.ts`.
17
+ export default defineConfig({
18
+ test: {
19
+ environment: 'jsdom',
20
+ include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
21
+ globals: false,
22
+ },
23
+ });