@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limrun/ui",
3
- "version": "0.9.0-rc.4",
3
+ "version": "0.9.0-rc.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -30,7 +30,9 @@
30
30
  "build": "tsc && vite build",
31
31
  "prepublishOnly": "npm run build",
32
32
  "lint": "eslint .",
33
- "preview": "vite preview"
33
+ "preview": "vite preview",
34
+ "test": "vitest run",
35
+ "test:watch": "vitest"
34
36
  },
35
37
  "repository": {
36
38
  "type": "git",
@@ -43,12 +45,14 @@
43
45
  "@types/react": "^19.1.12",
44
46
  "@types/react-dom": "^19.1.9",
45
47
  "@vitejs/plugin-react-swc": "^4.0.1",
48
+ "jsdom": "^26.0.0",
46
49
  "path": "^0.12.7",
47
50
  "react": "^19.1.1",
48
51
  "react-dom": "^19.1.1",
49
52
  "vite": "^7.1.4",
50
53
  "vite-plugin-dts": "^4.5.4",
51
- "vite-plugin-lib-inject-css": "^2.2.2"
54
+ "vite-plugin-lib-inject-css": "^2.2.2",
55
+ "vitest": "^2.1.9"
52
56
  },
53
57
  "dependencies": {
54
58
  "@foxt/js-srp": "^0.0.3-patch2",
@@ -0,0 +1,223 @@
1
+ .rc-inspect-overlay {
2
+ position: absolute;
3
+ z-index: 25;
4
+ pointer-events: none;
5
+ overflow: hidden;
6
+ }
7
+
8
+ .rc-inspect-overlay-select {
9
+ /* When click-to-select is enabled, the container also captures clicks
10
+ that fall outside any box so we can clear selection. */
11
+ pointer-events: auto;
12
+ }
13
+
14
+ .rc-inspect-box {
15
+ position: absolute;
16
+ box-sizing: border-box;
17
+ border: 1px solid rgba(64, 169, 255, 0.55);
18
+ background: rgba(64, 169, 255, 0.06);
19
+ border-radius: 2px;
20
+ cursor: pointer;
21
+ pointer-events: none;
22
+ transition:
23
+ border-color 80ms ease,
24
+ border-width 80ms ease,
25
+ background-color 80ms ease;
26
+ padding: 0;
27
+ margin: 0;
28
+ font: inherit;
29
+ color: inherit;
30
+ outline: none;
31
+ }
32
+
33
+ .rc-inspect-overlay-select .rc-inspect-box {
34
+ pointer-events: auto;
35
+ }
36
+
37
+ .rc-inspect-box-disabled {
38
+ opacity: 0.55;
39
+ }
40
+
41
+ .rc-inspect-box[data-highlighted='true'] {
42
+ border-color: rgba(255, 197, 28, 0.95);
43
+ border-width: 1.5px;
44
+ background: rgba(255, 197, 28, 0.16);
45
+ z-index: 1;
46
+ }
47
+
48
+ .rc-inspect-box[data-selected='true'] {
49
+ border-color: rgba(0, 122, 255, 1);
50
+ border-width: 2px;
51
+ background: rgba(0, 122, 255, 0.22);
52
+ z-index: 2;
53
+ }
54
+
55
+ .rc-inspect-card {
56
+ /* Card is rendered via React portal to document.body so it can escape
57
+ any ancestor overflow:hidden (e.g. customer-provided .device-wrapper
58
+ in our demo). Coordinates passed in via inline style are
59
+ viewport-absolute. */
60
+ position: fixed;
61
+ z-index: 2147483600;
62
+ pointer-events: auto;
63
+ background: rgba(22, 24, 30, 0.97);
64
+ color: #f5f5f7;
65
+ border: 1px solid rgba(255, 255, 255, 0.1);
66
+ border-radius: 10px;
67
+ padding: 9px 11px 11px;
68
+ min-width: 200px;
69
+ max-width: 260px;
70
+ /* Cap card height. Anything longer scrolls inside the card instead of
71
+ pushing the viewport. */
72
+ max-height: 220px;
73
+ overflow-y: auto;
74
+ box-shadow:
75
+ 0 12px 36px rgba(0, 0, 0, 0.4),
76
+ 0 1px 0 rgba(255, 255, 255, 0.04) inset;
77
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
78
+ font-size: 12px;
79
+ line-height: 1.4;
80
+ backdrop-filter: blur(8px);
81
+ -webkit-backdrop-filter: blur(8px);
82
+ scrollbar-width: thin;
83
+ }
84
+
85
+ .rc-inspect-card-header {
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 5px;
89
+ flex-wrap: wrap;
90
+ margin-bottom: 4px;
91
+ }
92
+
93
+ .rc-inspect-card-role {
94
+ font-size: 10px;
95
+ font-weight: 600;
96
+ text-transform: uppercase;
97
+ letter-spacing: 0.06em;
98
+ color: rgba(245, 245, 247, 0.55);
99
+ display: inline-block;
100
+ white-space: nowrap;
101
+ }
102
+
103
+ .rc-inspect-card-tag {
104
+ font-size: 9px;
105
+ font-weight: 600;
106
+ text-transform: uppercase;
107
+ letter-spacing: 0.05em;
108
+ padding: 1px 5px;
109
+ border-radius: 4px;
110
+ background: rgba(255, 255, 255, 0.08);
111
+ color: rgba(245, 245, 247, 0.7);
112
+ border: 1px solid rgba(255, 255, 255, 0.06);
113
+ }
114
+
115
+ .rc-inspect-card-tag-blue {
116
+ background: rgba(10, 132, 255, 0.18);
117
+ border-color: rgba(10, 132, 255, 0.45);
118
+ color: rgb(140, 195, 255);
119
+ }
120
+
121
+ .rc-inspect-card-title {
122
+ font-size: 12.5px;
123
+ font-weight: 600;
124
+ color: #fff;
125
+ margin: 0 0 7px;
126
+ word-break: break-word;
127
+ /* Clamp to 2 lines so a paragraph-long AXLabel doesn't take over. The
128
+ full text is available on hover via the title attribute and via the
129
+ value/id rows below. */
130
+ display: -webkit-box;
131
+ -webkit-line-clamp: 2;
132
+ line-clamp: 2;
133
+ -webkit-box-orient: vertical;
134
+ overflow: hidden;
135
+ }
136
+
137
+ .rc-inspect-card-row {
138
+ display: flex;
139
+ gap: 6px;
140
+ align-items: baseline;
141
+ font-size: 10.5px;
142
+ color: rgba(245, 245, 247, 0.72);
143
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
144
+ monospace;
145
+ margin: 1px 0;
146
+ min-width: 0;
147
+ }
148
+
149
+ .rc-inspect-card-row-label {
150
+ color: rgba(245, 245, 247, 0.42);
151
+ font-weight: 500;
152
+ flex-shrink: 0;
153
+ }
154
+
155
+ .rc-inspect-card-row-value {
156
+ flex: 1 1 auto;
157
+ min-width: 0;
158
+ word-break: break-word;
159
+ /* Clamp long values (selectors, IDs) to two lines, full value visible
160
+ via the row's title attr or via "Copy" action. */
161
+ display: -webkit-box;
162
+ -webkit-line-clamp: 2;
163
+ line-clamp: 2;
164
+ -webkit-box-orient: vertical;
165
+ overflow: hidden;
166
+ }
167
+
168
+ .rc-inspect-card-actions {
169
+ display: flex;
170
+ flex-wrap: wrap;
171
+ gap: 6px;
172
+ margin-top: 10px;
173
+ padding-top: 9px;
174
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
175
+ }
176
+
177
+ .rc-inspect-card-btn {
178
+ appearance: none;
179
+ border: 1px solid rgba(255, 255, 255, 0.12);
180
+ background: rgba(255, 255, 255, 0.06);
181
+ color: #f5f5f7;
182
+ font: inherit;
183
+ font-size: 11px;
184
+ font-weight: 500;
185
+ padding: 4px 9px;
186
+ border-radius: 6px;
187
+ cursor: pointer;
188
+ transition:
189
+ background-color 100ms ease,
190
+ border-color 100ms ease,
191
+ color 100ms ease;
192
+ }
193
+
194
+ .rc-inspect-card-btn:hover:not(:disabled) {
195
+ background: rgba(255, 255, 255, 0.12);
196
+ border-color: rgba(255, 255, 255, 0.2);
197
+ }
198
+
199
+ .rc-inspect-card-btn:active:not(:disabled) {
200
+ background: rgba(255, 255, 255, 0.16);
201
+ }
202
+
203
+ .rc-inspect-card-btn:disabled {
204
+ opacity: 0.4;
205
+ cursor: not-allowed;
206
+ }
207
+
208
+ .rc-inspect-card-btn-primary {
209
+ background: rgba(0, 122, 255, 0.85);
210
+ border-color: rgba(0, 122, 255, 0.9);
211
+ color: #fff;
212
+ }
213
+
214
+ .rc-inspect-card-btn-primary:hover:not(:disabled) {
215
+ background: rgba(10, 132, 255, 1);
216
+ border-color: rgba(10, 132, 255, 1);
217
+ }
218
+
219
+ .rc-inspect-card-btn-copied {
220
+ background: rgba(48, 209, 88, 0.22);
221
+ border-color: rgba(48, 209, 88, 0.55);
222
+ color: rgb(159, 240, 178);
223
+ }
@@ -0,0 +1,437 @@
1
+ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { clsx } from 'clsx';
4
+ import './inspect-overlay.css';
5
+
6
+ import {
7
+ AxElement,
8
+ AxPlatform,
9
+ AxSnapshot,
10
+ axElementRoleLabel,
11
+ axElementSelectorExpression,
12
+ axElementSummary,
13
+ axElementsEqual,
14
+ clampAxFrameForScreen,
15
+ } from '../core/ax-tree';
16
+
17
+ // Geometry of the rendered video content inside the RemoteControl container,
18
+ // after object-fit:contain letterboxing. All values are in container-local
19
+ // pixels (relative to .rc-container's content box) so the overlay boxes line
20
+ // up with the video.
21
+ //
22
+ // The InfoCard uses pointer-event coordinates (clientX/clientY) directly for
23
+ // its viewport-fixed placement and does NOT need any geometry — the boxes
24
+ // are the only thing geometry-locked.
25
+ export interface InspectOverlayGeometry {
26
+ left: number;
27
+ top: number;
28
+ width: number;
29
+ height: number;
30
+ }
31
+
32
+ export type InspectMode = 'select' | 'hover-only';
33
+
34
+ export interface InspectOverlayProps {
35
+ snapshot: AxSnapshot | null;
36
+ geometry: InspectOverlayGeometry | null;
37
+ highlightedId: string | null;
38
+ selectedId: string | null;
39
+ mode: InspectMode;
40
+ // Current pointer position in viewport coordinates (clientX/Y). Drives the
41
+ // cursor-anchored preview card while hovering. null when the pointer is
42
+ // outside the device.
43
+ cursorPosition: { x: number; y: number } | null;
44
+ // Position where the selection was made (viewport coords). The card stays
45
+ // anchored here once an element is selected so the user can interact with
46
+ // its action buttons.
47
+ frozenCursorPosition: { x: number; y: number } | null;
48
+ // Selection callback. `clickPosition` is the viewport-space pointer
49
+ // position at the moment of the click; pass null to clear selection.
50
+ onSelectChange: (element: AxElement | null, clickPosition?: { x: number; y: number } | null) => void;
51
+ // Called when the user presses "Tap" in the info card. `tapAt` is the
52
+ // viewport-space pointer position the user originally aimed at (i.e. the
53
+ // frozen click position) so we can tap that exact spot instead of an
54
+ // averaged center.
55
+ onTapElement: (element: AxElement, tapAt: { x: number; y: number }) => void;
56
+ }
57
+
58
+ const copyToClipboard = async (text: string): Promise<boolean> => {
59
+ if (!text) return false;
60
+ try {
61
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
62
+ await navigator.clipboard.writeText(text);
63
+ return true;
64
+ }
65
+ } catch {
66
+ // Fall through to the textarea fallback.
67
+ }
68
+ try {
69
+ const ta = document.createElement('textarea');
70
+ ta.value = text;
71
+ ta.style.position = 'fixed';
72
+ ta.style.opacity = '0';
73
+ document.body.appendChild(ta);
74
+ ta.select();
75
+ const ok = document.execCommand('copy');
76
+ document.body.removeChild(ta);
77
+ return ok;
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+
83
+ // ────────────────────────────────────────────────────────────────────────────
84
+ // Single element box
85
+ // ────────────────────────────────────────────────────────────────────────────
86
+
87
+ interface InspectBoxProps {
88
+ element: AxElement;
89
+ screen: { width: number; height: number };
90
+ highlighted: boolean;
91
+ selected: boolean;
92
+ selectable: boolean;
93
+ onClick: (element: AxElement, clickPosition: { x: number; y: number }) => void;
94
+ }
95
+
96
+ const InspectBox = memo(
97
+ function InspectBox({ element, screen, highlighted, selected, selectable, onClick }: InspectBoxProps) {
98
+ const visible = clampAxFrameForScreen(element.frame, screen);
99
+ if (!visible) return null;
100
+ const summary = axElementSummary(element);
101
+ return (
102
+ <button
103
+ type="button"
104
+ className={clsx('rc-inspect-box', !element.enabled && 'rc-inspect-box-disabled')}
105
+ data-ax-id={element.id}
106
+ data-ax-path={element.path}
107
+ data-ax-label={element.label || undefined}
108
+ data-ax-role={element.role || undefined}
109
+ data-highlighted={highlighted ? 'true' : 'false'}
110
+ data-selected={selected ? 'true' : 'false'}
111
+ aria-label={summary}
112
+ // No onMouseEnter/Leave or `title` attr here — hover state is driven
113
+ // by JS hit-testing in RemoteControl's handleInteraction (single
114
+ // source of truth that handles overlapping boxes deterministically),
115
+ // and the InfoCard already shows all the info the title would.
116
+ onClick={(e) => {
117
+ if (!selectable) return;
118
+ e.preventDefault();
119
+ e.stopPropagation();
120
+ onClick(element, { x: e.clientX, y: e.clientY });
121
+ }}
122
+ style={{
123
+ left: `${(visible.x / screen.width) * 100}%`,
124
+ top: `${(visible.y / screen.height) * 100}%`,
125
+ width: `${(visible.width / screen.width) * 100}%`,
126
+ height: `${(visible.height / screen.height) * 100}%`,
127
+ }}
128
+ />
129
+ );
130
+ },
131
+ (prev, next) =>
132
+ prev.highlighted === next.highlighted &&
133
+ prev.selected === next.selected &&
134
+ prev.selectable === next.selectable &&
135
+ prev.screen.width === next.screen.width &&
136
+ prev.screen.height === next.screen.height &&
137
+ prev.onClick === next.onClick &&
138
+ axElementsEqual(prev.element, next.element),
139
+ );
140
+
141
+ // ────────────────────────────────────────────────────────────────────────────
142
+ // Info card (shown next to the focus element)
143
+ // ────────────────────────────────────────────────────────────────────────────
144
+
145
+ interface InfoCardProps {
146
+ element: AxElement;
147
+ platform: AxPlatform;
148
+ // Viewport-coordinate anchor used to position the card. When cursorAnchored
149
+ // is true the card sits at top-right of `anchor` and follows the cursor.
150
+ // When false the card stays put at `anchor` (frozen on selection).
151
+ anchor: { x: number; y: number };
152
+ cursorAnchored: boolean;
153
+ showActions: boolean;
154
+ // Receives the element AND the viewport-space coordinate to tap at
155
+ // (the frozen click position). Tapping at the click point — rather than
156
+ // the element's frame center — preserves the user's aim when the
157
+ // selected element is a container whose children are not exposed by the
158
+ // accessibility tree (e.g. iOS UITabBar's button children).
159
+ onTap: (element: AxElement, tapAt: { x: number; y: number }) => void;
160
+ }
161
+
162
+ // Upper bounds used only for "does the card fit on this side?" decisions.
163
+ // We do NOT use these to compute the actual top/left coordinates — see the
164
+ // CSS-transform-based placement below. Matches the CSS max-width /
165
+ // max-height so flip decisions stay accurate even when the card is at its
166
+ // largest (selected state with all action buttons visible).
167
+ const INFO_CARD_ESTIMATED_WIDTH = 260;
168
+ const INFO_CARD_ESTIMATED_HEIGHT = 220;
169
+ // Gap between the cursor and the nearest card edge. Tight enough that the
170
+ // card visibly "hangs" off the cursor, but not so tight that the OS pointer
171
+ // arrow visually overlaps the card border.
172
+ const CURSOR_OFFSET = 6;
173
+ // Distance from the window edge we try to maintain so the card never gets
174
+ // flush against the page chrome.
175
+ const VIEWPORT_PADDING = 8;
176
+
177
+ const InfoCard = memo(function InfoCard({
178
+ element,
179
+ platform,
180
+ anchor,
181
+ cursorAnchored,
182
+ showActions,
183
+ onTap,
184
+ }: InfoCardProps) {
185
+ const [copied, setCopied] = useState<string | null>(null);
186
+
187
+ useEffect(() => {
188
+ if (!copied) return;
189
+ const t = window.setTimeout(() => setCopied(null), 1100);
190
+ return () => window.clearTimeout(t);
191
+ }, [copied]);
192
+
193
+ // Anchor the nearest card corner exactly CURSOR_OFFSET px from the cursor
194
+ // using CSS transforms. By offsetting the card's coordinate by ±100% on
195
+ // each axis as needed, we don't need to know the card's actual rendered
196
+ // height/width — the corner that's nearest to the cursor sits at a known
197
+ // distance regardless of card content. This solves the "huge gap" issue
198
+ // that arises when the card content is much smaller than the assumed
199
+ // worst-case height.
200
+ //
201
+ // Default: card's bottom-left corner sits to the top-right of the cursor
202
+ // (so the card "hangs" off the cursor up-and-right, similar to system
203
+ // tooltips). Flips to bottom-right if no room to the right, top-left if
204
+ // no room above, and bottom-right if no room above-or-right.
205
+ const cardStyle = useMemo<React.CSSProperties>(() => {
206
+ const W = INFO_CARD_ESTIMATED_WIDTH;
207
+ const H = INFO_CARD_ESTIMATED_HEIGHT;
208
+ const O = CURSOR_OFFSET;
209
+ const P = VIEWPORT_PADDING;
210
+
211
+ const viewportW = typeof window !== 'undefined' ? window.innerWidth : 1024;
212
+ const viewportH = typeof window !== 'undefined' ? window.innerHeight : 768;
213
+
214
+ // Decide which quadrant of the cursor the card sits in. Use the
215
+ // estimated max dimensions as the worst-case footprint when checking
216
+ // for room.
217
+ const placeRight = anchor.x + O + W <= viewportW - P;
218
+ const placeAbove = anchor.y - O - H >= P;
219
+
220
+ let left = placeRight ? anchor.x + O : anchor.x - O;
221
+ let top = placeAbove ? anchor.y - O : anchor.y + O;
222
+
223
+ // translate(-100% …) shifts the card by its own measured width/height,
224
+ // which is what gives us the "corner anchored to cursor" behaviour
225
+ // without having to know the rendered size in JS.
226
+ const tx = placeRight ? '0' : '-100%';
227
+ const ty = placeAbove ? '-100%' : '0';
228
+ const transform = `translate(${tx}, ${ty})`;
229
+
230
+ // Final clamp so the card never escapes the window even on tiny
231
+ // viewports. Using the estimated dimensions as a safety margin.
232
+ left = Math.max(P + (placeRight ? 0 : W), Math.min(viewportW - P - (placeRight ? W : 0), left));
233
+ top = Math.max(P + (placeAbove ? H : 0), Math.min(viewportH - P - (placeAbove ? 0 : H), top));
234
+
235
+ return { left: `${left}px`, top: `${top}px`, transform };
236
+ }, [anchor.x, anchor.y]);
237
+
238
+ const selectorExpr = useMemo(() => axElementSelectorExpression(element, platform), [element, platform]);
239
+ const primaryIdField = platform === 'ios' ? element.selectors.AXUniqueId : element.selectors.resourceId;
240
+ const primaryIdLabel = platform === 'ios' ? 'AXUniqueId' : 'resourceId';
241
+
242
+ const handleCopy = useCallback(async (text: string | undefined, key: string) => {
243
+ if (!text) return;
244
+ const ok = await copyToClipboard(text);
245
+ if (ok) setCopied(key);
246
+ }, []);
247
+
248
+ const roleLabel = axElementRoleLabel(element);
249
+ // Title shows the element's label (clamped). Falls back to the role when no
250
+ // label exists so the card never looks empty.
251
+ const titleText = element.label || element.value || roleLabel;
252
+ // axElementSummary is used as the full tooltip (truncated to ~80 chars).
253
+ const fullSummary = axElementSummary(element);
254
+
255
+ // We do not guard against SSR here: RemoteControl is fundamentally a
256
+ // browser-only component (it instantiates RTCPeerConnection in its main
257
+ // effect), so the InfoCard never gets rendered in a server environment.
258
+
259
+ return createPortal(
260
+ <div
261
+ className="rc-inspect-card"
262
+ data-cursor-anchored={cursorAnchored ? 'true' : 'false'}
263
+ style={cardStyle}
264
+ // The card sits in document.body but is logically a child of the
265
+ // overlay React subtree, so events bubble back to rc-container's
266
+ // mousemove handler. We stop them so hovering the card doesn't make
267
+ // it chase itself.
268
+ onClick={(e) => e.stopPropagation()}
269
+ onMouseDown={(e) => e.stopPropagation()}
270
+ onMouseMove={(e) => e.stopPropagation()}
271
+ >
272
+ <div className="rc-inspect-card-header">
273
+ <span className="rc-inspect-card-role">{roleLabel}</span>
274
+ {!element.enabled && <span className="rc-inspect-card-tag">disabled</span>}
275
+ {element.focused && <span className="rc-inspect-card-tag rc-inspect-card-tag-blue">focused</span>}
276
+ </div>
277
+ <p className="rc-inspect-card-title" title={fullSummary}>
278
+ {titleText}
279
+ </p>
280
+ {primaryIdField && (
281
+ <div className="rc-inspect-card-row">
282
+ <span className="rc-inspect-card-row-label">{primaryIdLabel}:</span>
283
+ <span className="rc-inspect-card-row-value">{primaryIdField}</span>
284
+ </div>
285
+ )}
286
+ {element.value && element.value !== element.label && (
287
+ <div className="rc-inspect-card-row">
288
+ <span className="rc-inspect-card-row-label">value:</span>
289
+ <span className="rc-inspect-card-row-value">{element.value}</span>
290
+ </div>
291
+ )}
292
+ <div className="rc-inspect-card-row">
293
+ <span className="rc-inspect-card-row-label">frame:</span>
294
+ <span className="rc-inspect-card-row-value">
295
+ {Math.round(element.frame.width)}×{Math.round(element.frame.height)} @ {Math.round(element.frame.x)}
296
+ ,{Math.round(element.frame.y)}
297
+ </span>
298
+ </div>
299
+ {showActions && (
300
+ <div className="rc-inspect-card-actions">
301
+ <button
302
+ type="button"
303
+ className="rc-inspect-card-btn rc-inspect-card-btn-primary"
304
+ onClick={() => onTap(element, anchor)}
305
+ >
306
+ Tap
307
+ </button>
308
+ <button
309
+ type="button"
310
+ className={clsx('rc-inspect-card-btn', copied === 'selector' && 'rc-inspect-card-btn-copied')}
311
+ disabled={!selectorExpr}
312
+ title={selectorExpr ?? 'No usable selector for this element'}
313
+ onClick={() => handleCopy(selectorExpr ?? undefined, 'selector')}
314
+ >
315
+ {copied === 'selector' ? 'Copied!' : 'Copy selector'}
316
+ </button>
317
+ <button
318
+ type="button"
319
+ className={clsx('rc-inspect-card-btn', copied === 'id' && 'rc-inspect-card-btn-copied')}
320
+ disabled={!primaryIdField}
321
+ title={primaryIdField ?? `No ${primaryIdLabel}`}
322
+ onClick={() => handleCopy(primaryIdField, 'id')}
323
+ >
324
+ {copied === 'id' ? 'Copied!' : `Copy ${primaryIdLabel}`}
325
+ </button>
326
+ </div>
327
+ )}
328
+ </div>,
329
+ document.body,
330
+ );
331
+ });
332
+
333
+ // ────────────────────────────────────────────────────────────────────────────
334
+ // Overlay root
335
+ // ────────────────────────────────────────────────────────────────────────────
336
+
337
+ export const InspectOverlay = memo(function InspectOverlay({
338
+ snapshot,
339
+ geometry,
340
+ highlightedId,
341
+ selectedId,
342
+ mode,
343
+ cursorPosition,
344
+ frozenCursorPosition,
345
+ onSelectChange,
346
+ onTapElement,
347
+ }: InspectOverlayProps) {
348
+ const selectable = mode === 'select';
349
+
350
+ // Card behaviour:
351
+ // - In `hover-only` mode there is no selection. The card always follows
352
+ // the cursor and only renders when something is highlighted.
353
+ // - In `select` mode the card follows the cursor whenever the user is
354
+ // hovering an element OTHER than the currently selected one (preview).
355
+ // Hovering the selected element or moving the cursor off any box keeps
356
+ // the card frozen at the click position so the action buttons remain
357
+ // reachable.
358
+ const isPreviewingHover =
359
+ selectable ? highlightedId !== null && highlightedId !== selectedId : highlightedId !== null;
360
+ const focusId = selectable ? highlightedId ?? selectedId : highlightedId;
361
+
362
+ // Build an `id → element` index once per snapshot so focus lookups are O(1)
363
+ // even on max-size trees. Hooks must run unconditionally — gate rendering
364
+ // afterwards via the `renderable` check.
365
+ const elementsById = useMemo(() => {
366
+ const m = new Map<string, AxElement>();
367
+ if (!snapshot) return m;
368
+ for (const el of snapshot.elements) m.set(el.id, el);
369
+ return m;
370
+ }, [snapshot]);
371
+
372
+ const focusElement = useMemo(() => {
373
+ if (!focusId) return null;
374
+ return elementsById.get(focusId) ?? null;
375
+ }, [focusId, elementsById]);
376
+
377
+ // The overlay has nothing to draw until both a snapshot and the device
378
+ // geometry are available, and the snapshot has at least one usable
379
+ // element. Conditional rendering goes here — AFTER all hooks — so the
380
+ // hook-count is stable across the snapshot-arrives-after-mount transition.
381
+ const renderable =
382
+ !!snapshot &&
383
+ !!geometry &&
384
+ snapshot.screen.width > 0 &&
385
+ snapshot.screen.height > 0 &&
386
+ snapshot.elements.length > 0;
387
+ if (!renderable) return null;
388
+
389
+ const anchor = isPreviewingHover ? cursorPosition : frozenCursorPosition ?? cursorPosition;
390
+ const cursorAnchored = isPreviewingHover;
391
+ const showActions = selectable && !isPreviewingHover && selectedId !== null;
392
+
393
+ const handleContainerClick = (e: React.MouseEvent) => {
394
+ if (!selectable) return;
395
+ // Click was on the container itself (not a box) — clear selection.
396
+ if (e.target === e.currentTarget) {
397
+ onSelectChange(null, null);
398
+ }
399
+ };
400
+
401
+ return (
402
+ <>
403
+ <div
404
+ className={clsx('rc-inspect-overlay', selectable && 'rc-inspect-overlay-select')}
405
+ style={{
406
+ left: `${geometry!.left}px`,
407
+ top: `${geometry!.top}px`,
408
+ width: `${geometry!.width}px`,
409
+ height: `${geometry!.height}px`,
410
+ }}
411
+ onClick={handleContainerClick}
412
+ >
413
+ {snapshot!.elements.map((element) => (
414
+ <InspectBox
415
+ key={element.id}
416
+ element={element}
417
+ screen={snapshot!.screen}
418
+ highlighted={element.id === highlightedId}
419
+ selected={element.id === selectedId}
420
+ selectable={selectable}
421
+ onClick={onSelectChange}
422
+ />
423
+ ))}
424
+ </div>
425
+ {focusElement && anchor && (
426
+ <InfoCard
427
+ element={focusElement}
428
+ platform={snapshot!.platform}
429
+ anchor={anchor}
430
+ cursorAnchored={cursorAnchored}
431
+ showActions={showActions}
432
+ onTap={onTapElement}
433
+ />
434
+ )}
435
+ </>
436
+ );
437
+ });