@treelocator/runtime 0.3.2 → 0.4.1

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.
@@ -11,6 +11,9 @@ import { collectAncestry, formatAncestryChain } from "../functions/formatAncestr
11
11
  import { enrichAncestryWithSourceMaps } from "../functions/enrichAncestrySourceMaps";
12
12
  import { createTreeNode } from "../adapters/createTreeNode";
13
13
  import treeIconUrl from "../_generated_tree_icon";
14
+ import { createDejitterRecorder, DejitterAPI, DejitterFinding, DejitterSummary } from "../dejitter/recorder";
15
+ import { RecordingOutline } from "./RecordingOutline";
16
+ import { RecordingResults, InteractionEvent } from "./RecordingResults";
14
17
 
15
18
  type RuntimeProps = {
16
19
  adapterId?: AdapterId;
@@ -25,7 +28,58 @@ function Runtime(props: RuntimeProps) {
25
28
  const [toastMessage, setToastMessage] = createSignal<string | null>(null);
26
29
  const [locatorActive, setLocatorActive] = createSignal<boolean>(false);
27
30
 
28
- const isActive = () => (holdingModKey() || locatorActive()) && currentElement();
31
+ // Recording state machine: idle -> selecting -> recording -> results -> idle
32
+ type RecordingState = 'idle' | 'selecting' | 'recording' | 'results';
33
+
34
+ // --- localStorage persistence ---
35
+ const STORAGE_KEY = '__treelocator_recording__';
36
+
37
+ type SavedRecording = {
38
+ findings: DejitterFinding[];
39
+ summary: DejitterSummary | null;
40
+ data: any;
41
+ elementPath: string;
42
+ interactions: InteractionEvent[];
43
+ };
44
+
45
+ function loadFromStorage(): { last: SavedRecording | null; previous: SavedRecording | null } {
46
+ try {
47
+ const raw = localStorage.getItem(STORAGE_KEY);
48
+ if (raw) return JSON.parse(raw);
49
+ } catch {}
50
+ return { last: null, previous: null };
51
+ }
52
+
53
+ function saveToStorage(current: SavedRecording) {
54
+ try {
55
+ const stored = loadFromStorage();
56
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
57
+ last: current,
58
+ previous: stored.last,
59
+ }));
60
+ } catch {}
61
+ }
62
+
63
+ // Restore last results on mount
64
+ const restored = loadFromStorage();
65
+ const restoredLast = restored.last;
66
+
67
+ const [recordingState, setRecordingState] = createSignal<RecordingState>(restoredLast ? 'results' : 'idle');
68
+ const [recordedElement, setRecordedElement] = createSignal<HTMLElement | null>(null);
69
+ const [recordingFindings, setRecordingFindings] = createSignal<DejitterFinding[]>(restoredLast?.findings ?? []);
70
+ const [recordingSummary, setRecordingSummary] = createSignal<DejitterSummary | null>(restoredLast?.summary ?? null);
71
+ const [interactionLog, setInteractionLog] = createSignal<InteractionEvent[]>(restoredLast?.interactions ?? []);
72
+ const [recordingData, setRecordingData] = createSignal<any>(restoredLast?.data ?? null);
73
+ const [recordingElementPath, setRecordingElementPath] = createSignal<string>(restoredLast?.elementPath ?? "");
74
+ const [replayBox, setReplayBox] = createSignal<{ x: number; y: number; w: number; h: number } | null>(null);
75
+ const [replaying, setReplaying] = createSignal(false);
76
+ const [viewingPrevious, setViewingPrevious] = createSignal(false);
77
+ let dejitterInstance: DejitterAPI | null = null;
78
+ let interactionClickHandler: ((e: MouseEvent) => void) | null = null;
79
+ let recordingStartTime = 0;
80
+ let replayTimerId: number | null = null;
81
+
82
+ const isActive = () => (holdingModKey() || locatorActive() || recordingState() === 'selecting') && currentElement();
29
83
 
30
84
  createEffect(() => {
31
85
  if (isActive()) {
@@ -35,6 +89,13 @@ function Runtime(props: RuntimeProps) {
35
89
  }
36
90
  });
37
91
 
92
+ // Expose replay functions on the browser API
93
+ if (typeof window !== "undefined" && (window as any).__treelocator__) {
94
+ (window as any).__treelocator__.replay = () => replayRecording();
95
+ (window as any).__treelocator__.replayWithRecord = (elementOrSelector: HTMLElement | string) =>
96
+ replayWithRecord(elementOrSelector);
97
+ }
98
+
38
99
  function keyUpListener(e: KeyboardEvent) {
39
100
  setHoldingModKey(isCombinationModifiersPressed(e));
40
101
  }
@@ -48,92 +109,379 @@ function Runtime(props: RuntimeProps) {
48
109
  setHoldingModKey(e.altKey);
49
110
  }
50
111
 
51
- function mouseOverListener(e: MouseEvent) {
52
- // Also update modifier state
53
- setHoldingModKey(e.altKey);
54
-
55
- // Use elementsFromPoint to find elements including ones with pointer-events-none
112
+ function findElementAtPoint(e: MouseEvent): HTMLElement | null {
56
113
  const elementsAtPoint = document.elementsFromPoint(e.clientX, e.clientY);
57
-
58
- // Find the topmost element with locator data for highlighting
59
- let element: HTMLElement | null = null;
60
114
  for (const el of elementsAtPoint) {
61
- if (isLocatorsOwnElement(el as HTMLElement)) {
62
- continue;
63
- }
115
+ if (isLocatorsOwnElement(el as HTMLElement)) continue;
64
116
  if (el instanceof HTMLElement || el instanceof SVGElement) {
65
117
  const withLocator = el.closest('[data-locatorjs-id], [data-locatorjs]');
66
- if (withLocator) {
67
- element = withLocator as HTMLElement;
68
- break;
69
- }
118
+ if (withLocator) return withLocator as HTMLElement;
70
119
  }
71
120
  }
72
-
73
121
  // Fallback to e.target
74
- if (!element) {
75
- const target = e.target;
76
- if (target && (target instanceof HTMLElement || target instanceof SVGElement)) {
77
- element = target instanceof SVGElement
78
- ? (target.closest('[data-locatorjs-id], [data-locatorjs]') as HTMLElement | null) ??
79
- (target.closest('svg') as HTMLElement | null) ??
80
- (target as unknown as HTMLElement)
81
- : target;
122
+ const target = e.target;
123
+ if (target && (target instanceof HTMLElement || target instanceof SVGElement)) {
124
+ const el = target instanceof SVGElement
125
+ ? (target.closest('[data-locatorjs-id], [data-locatorjs]') as HTMLElement | null) ??
126
+ (target.closest('svg') as HTMLElement | null) ??
127
+ (target as unknown as HTMLElement)
128
+ : target;
129
+ if (el && !isLocatorsOwnElement(el)) return el;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ // --- Recording lifecycle ---
135
+
136
+ function handleRecordClick() {
137
+ switch (recordingState()) {
138
+ case 'idle':
139
+ setRecordingState('selecting');
140
+ break;
141
+ case 'selecting':
142
+ setRecordingState('idle');
143
+ break;
144
+ case 'recording':
145
+ stopRecording();
146
+ break;
147
+ case 'results':
148
+ dismissResults();
149
+ break;
150
+ }
151
+ }
152
+
153
+ function startRecording(element: HTMLElement) {
154
+ element.setAttribute('data-treelocator-recording', 'true');
155
+ setRecordedElement(element);
156
+
157
+ dejitterInstance = createDejitterRecorder();
158
+ dejitterInstance.configure({
159
+ selector: '[data-treelocator-recording]',
160
+ props: ['opacity', 'transform', 'boundingRect', 'width', 'height'],
161
+ sampleRate: 15,
162
+ maxDuration: 30000,
163
+ idleTimeout: 0,
164
+ mutations: true,
165
+ });
166
+ dejitterInstance.start();
167
+ startInteractionTracker();
168
+ setRecordingState('recording');
169
+ }
170
+
171
+ function stopRecording() {
172
+ if (!dejitterInstance) return;
173
+ dejitterInstance.stop();
174
+ const findings = dejitterInstance.findings(true) as DejitterFinding[];
175
+ const summary = dejitterInstance.summary(true) as DejitterSummary;
176
+ const data = dejitterInstance.getData();
177
+
178
+ // Collect ancestry path from treelocator before clearing
179
+ const el = recordedElement();
180
+ let elementPath = "";
181
+ if (el) {
182
+ const treeNode = createTreeNode(el, props.adapterId);
183
+ if (treeNode) {
184
+ const ancestry = collectAncestry(treeNode);
185
+ elementPath = formatAncestryChain(ancestry);
186
+ }
187
+ }
188
+
189
+ setRecordingFindings(findings);
190
+ setRecordingSummary(summary);
191
+ setRecordingData(data);
192
+ setRecordingElementPath(elementPath);
193
+ stopInteractionTracker();
194
+
195
+ // Persist to localStorage (moves previous "last" to "previous")
196
+ saveToStorage({
197
+ findings,
198
+ summary,
199
+ data,
200
+ elementPath,
201
+ interactions: interactionLog(),
202
+ });
203
+
204
+ el?.removeAttribute('data-treelocator-recording');
205
+ setRecordingState('results');
206
+ dejitterInstance = null;
207
+ }
208
+
209
+ function replayRecording() {
210
+ const events = interactionLog();
211
+ if (events.length === 0) return;
212
+
213
+ stopReplay();
214
+ setReplaying(true);
215
+
216
+ let eventIdx = 0;
217
+
218
+ function scheduleNext() {
219
+ if (eventIdx >= events.length) {
220
+ stopReplay();
221
+ return;
222
+ }
223
+
224
+ const evt = events[eventIdx]!;
225
+ const delay = eventIdx === 0 ? 100 : evt.t - events[eventIdx - 1]!.t;
226
+
227
+ replayTimerId = window.setTimeout(() => {
228
+ // Show click indicator
229
+ setReplayBox({ x: evt.x - 12, y: evt.y - 12, w: 24, h: 24 });
230
+
231
+ // Dispatch a real click at the recorded position
232
+ const target = document.elementFromPoint(evt.x, evt.y);
233
+ if (target) {
234
+ target.dispatchEvent(new MouseEvent('click', {
235
+ bubbles: true,
236
+ cancelable: true,
237
+ clientX: evt.x,
238
+ clientY: evt.y,
239
+ view: window,
240
+ }));
241
+ }
242
+
243
+ // Clear click indicator after a short flash
244
+ window.setTimeout(() => setReplayBox(null), 200);
245
+
246
+ eventIdx++;
247
+ scheduleNext();
248
+ }, Math.max(delay, 50));
249
+ }
250
+
251
+ scheduleNext();
252
+ }
253
+
254
+ function stopReplay() {
255
+ if (replayTimerId) {
256
+ clearTimeout(replayTimerId);
257
+ replayTimerId = null;
258
+ }
259
+ setReplaying(false);
260
+ setReplayBox(null);
261
+ }
262
+
263
+ function replayWithRecord(elementOrSelector: HTMLElement | string): Promise<{
264
+ path: string;
265
+ findings: DejitterFinding[];
266
+ summary: DejitterSummary | null;
267
+ data: any;
268
+ interactions: InteractionEvent[];
269
+ } | null> {
270
+ // Resolve element
271
+ let element: HTMLElement | null;
272
+ if (typeof elementOrSelector === 'string') {
273
+ const found = document.querySelector(elementOrSelector);
274
+ element = found instanceof HTMLElement ? found : null;
275
+ } else {
276
+ element = elementOrSelector;
277
+ }
278
+ if (!element) return Promise.resolve(null);
279
+
280
+ // Get stored interactions to replay
281
+ const stored = loadFromStorage();
282
+ const events = stored.last?.interactions ?? interactionLog();
283
+ if (events.length === 0) return Promise.resolve(null);
284
+
285
+ return new Promise((resolve) => {
286
+ // Start recording on the element
287
+ element!.setAttribute('data-treelocator-recording', 'true');
288
+ setRecordedElement(element);
289
+
290
+ dejitterInstance = createDejitterRecorder();
291
+ dejitterInstance.configure({
292
+ selector: '[data-treelocator-recording]',
293
+ props: ['opacity', 'transform', 'boundingRect', 'width', 'height'],
294
+ sampleRate: 15,
295
+ maxDuration: 30000,
296
+ idleTimeout: 0,
297
+ mutations: true,
298
+ });
299
+ dejitterInstance.start();
300
+ setRecordingState('recording');
301
+ setReplaying(true);
302
+
303
+ let eventIdx = 0;
304
+
305
+ function finishRecording() {
306
+ setReplaying(false);
307
+ setReplayBox(null);
308
+
309
+ if (!dejitterInstance) { resolve(null); return; }
310
+ dejitterInstance.stop();
311
+ const findings = dejitterInstance.findings(true) as DejitterFinding[];
312
+ const summary = dejitterInstance.summary(true) as DejitterSummary;
313
+ const data = dejitterInstance.getData();
314
+
315
+ const el = recordedElement();
316
+ let elementPath = "";
317
+ if (el) {
318
+ const treeNode = createTreeNode(el, props.adapterId);
319
+ if (treeNode) {
320
+ const ancestry = collectAncestry(treeNode);
321
+ elementPath = formatAncestryChain(ancestry);
322
+ }
323
+ }
324
+
325
+ setRecordingFindings(findings);
326
+ setRecordingSummary(summary);
327
+ setRecordingData(data);
328
+ setRecordingElementPath(elementPath);
329
+ setInteractionLog(events);
330
+
331
+ saveToStorage({ findings, summary, data, elementPath, interactions: events });
332
+
333
+ el?.removeAttribute('data-treelocator-recording');
334
+ setRecordingState('results');
335
+ dejitterInstance = null;
336
+
337
+ resolve({ path: elementPath, findings, summary, data, interactions: events });
338
+ }
339
+
340
+ function scheduleNext() {
341
+ if (eventIdx >= events.length) {
342
+ // Wait for CSS transitions to settle before stopping recording
343
+ replayTimerId = window.setTimeout(finishRecording, 500);
344
+ return;
345
+ }
346
+
347
+ const evt = events[eventIdx]!;
348
+ const delay = eventIdx === 0 ? 100 : evt.t - events[eventIdx - 1]!.t;
349
+
350
+ replayTimerId = window.setTimeout(() => {
351
+ setReplayBox({ x: evt.x - 12, y: evt.y - 12, w: 24, h: 24 });
352
+
353
+ const target = document.elementFromPoint(evt.x, evt.y);
354
+ if (target) {
355
+ target.dispatchEvent(new MouseEvent('click', {
356
+ bubbles: true,
357
+ cancelable: true,
358
+ clientX: evt.x,
359
+ clientY: evt.y,
360
+ view: window,
361
+ }));
362
+ }
363
+
364
+ window.setTimeout(() => setReplayBox(null), 200);
365
+
366
+ eventIdx++;
367
+ scheduleNext();
368
+ }, Math.max(delay, 50));
82
369
  }
370
+
371
+ scheduleNext();
372
+ });
373
+ }
374
+
375
+ function dismissResults() {
376
+ stopReplay();
377
+ setRecordingFindings([]);
378
+ setRecordingSummary(null);
379
+ setRecordingData(null);
380
+ setRecordingElementPath("");
381
+ setInteractionLog([]);
382
+ setRecordedElement(null);
383
+ setViewingPrevious(false);
384
+ setRecordingState('idle');
385
+ }
386
+
387
+ function hasPreviousRecording(): boolean {
388
+ return loadFromStorage().previous !== null;
389
+ }
390
+
391
+ function loadPreviousRecording() {
392
+ const stored = loadFromStorage();
393
+ if (!stored.previous) return;
394
+ const prev = stored.previous;
395
+ setRecordingFindings(prev.findings);
396
+ setRecordingSummary(prev.summary);
397
+ setRecordingData(prev.data);
398
+ setRecordingElementPath(prev.elementPath);
399
+ setInteractionLog(prev.interactions);
400
+ setViewingPrevious(true);
401
+ setRecordingState('results');
402
+ }
403
+
404
+ function loadLatestRecording() {
405
+ const stored = loadFromStorage();
406
+ if (!stored.last) return;
407
+ const last = stored.last;
408
+ setRecordingFindings(last.findings);
409
+ setRecordingSummary(last.summary);
410
+ setRecordingData(last.data);
411
+ setRecordingElementPath(last.elementPath);
412
+ setInteractionLog(last.interactions);
413
+ setViewingPrevious(false);
414
+ setRecordingState('results');
415
+ }
416
+
417
+ function startInteractionTracker() {
418
+ recordingStartTime = performance.now();
419
+ setInteractionLog([]);
420
+ interactionClickHandler = (e: MouseEvent) => {
421
+ if (isLocatorsOwnElement(e.target as HTMLElement)) return;
422
+ const el = e.target as HTMLElement;
423
+ const tag = el.tagName?.toLowerCase() || 'unknown';
424
+ const id = el.id ? '#' + el.id : '';
425
+ const cls = el.className && typeof el.className === 'string' ? '.' + el.className.split(' ')[0] : '';
426
+ setInteractionLog((prev) => [...prev, {
427
+ t: Math.round(performance.now() - recordingStartTime),
428
+ type: 'click',
429
+ target: `${tag}${id}${cls}`,
430
+ x: e.clientX,
431
+ y: e.clientY,
432
+ }]);
433
+ };
434
+ document.addEventListener('click', interactionClickHandler, { capture: true });
435
+ }
436
+
437
+ function stopInteractionTracker() {
438
+ if (interactionClickHandler) {
439
+ document.removeEventListener('click', interactionClickHandler, { capture: true });
440
+ interactionClickHandler = null;
83
441
  }
442
+ }
443
+
444
+ function mouseOverListener(e: MouseEvent) {
445
+ setHoldingModKey(e.altKey);
446
+
447
+ // Don't update hovered element while recording -- highlight is sticky
448
+ if (recordingState() === 'recording') return;
84
449
 
85
- if (element && !isLocatorsOwnElement(element)) {
450
+ const element = findElementAtPoint(e);
451
+ if (element) {
86
452
  setCurrentElement(element);
87
453
  }
88
454
  }
89
455
 
90
456
  function mouseDownUpListener(e: MouseEvent) {
91
- // Update modifier state
92
457
  setHoldingModKey(e.altKey);
93
458
 
94
- if (e.altKey || locatorActive()) {
459
+ if (e.altKey || locatorActive() || recordingState() === 'selecting') {
95
460
  e.preventDefault();
96
461
  e.stopPropagation();
97
462
  }
98
463
  }
99
464
 
100
465
  function clickListener(e: MouseEvent) {
101
- // Check altKey directly for more reliable first-click detection
102
- if (!e.altKey && !isCombinationModifiersPressed(e) && !locatorActive()) {
466
+ // Handle recording element selection
467
+ if (recordingState() === 'selecting') {
468
+ e.preventDefault();
469
+ e.stopPropagation();
470
+ const element = findElementAtPoint(e);
471
+ if (element && !isLocatorsOwnElement(element)) {
472
+ startRecording(element);
473
+ }
103
474
  return;
104
475
  }
105
476
 
106
- // Use elementsFromPoint to find all elements at click position,
107
- // including ones with pointer-events-none (like canvas overlays)
108
- const elementsAtPoint = document.elementsFromPoint(e.clientX, e.clientY);
477
+ // During recording, let clicks pass through (tracked by interaction logger)
478
+ if (recordingState() === 'recording') return;
109
479
 
110
- // Find the topmost element with locator data
111
- let element: Element | null = null;
112
- for (const el of elementsAtPoint) {
113
- if (isLocatorsOwnElement(el as HTMLElement)) {
114
- continue;
115
- }
116
- if (el instanceof HTMLElement || el instanceof SVGElement) {
117
- // Check if this element or its closest ancestor has locator data
118
- const withLocator = el.closest('[data-locatorjs-id], [data-locatorjs]');
119
- if (withLocator) {
120
- element = withLocator;
121
- break;
122
- }
123
- }
480
+ if (!e.altKey && !isCombinationModifiersPressed(e) && !locatorActive()) {
481
+ return;
124
482
  }
125
483
 
126
- // Fallback to e.target if elementsFromPoint didn't find anything
127
- if (!element) {
128
- const target = e.target;
129
- if (target && (target instanceof HTMLElement || target instanceof SVGElement)) {
130
- element = target instanceof SVGElement
131
- ? (target.closest('[data-locatorjs-id], [data-locatorjs]') as Element | null) ??
132
- (target.closest('svg') as Element | null) ??
133
- target
134
- : target;
135
- }
136
- }
484
+ const element = findElementAtPoint(e);
137
485
 
138
486
  if (!element) {
139
487
  return;
@@ -250,6 +598,43 @@ function Runtime(props: RuntimeProps) {
250
598
  targets={props.targets}
251
599
  />
252
600
  ) : null}
601
+ {recordingState() === 'recording' && recordedElement() ? (
602
+ <RecordingOutline element={recordedElement()!} />
603
+ ) : null}
604
+ {replayBox() ? (
605
+ <div
606
+ style={{
607
+ position: "fixed",
608
+ "z-index": "2147483645",
609
+ left: replayBox()!.x + "px",
610
+ top: replayBox()!.y + "px",
611
+ width: replayBox()!.w + "px",
612
+ height: replayBox()!.h + "px",
613
+ "border-radius": "50%",
614
+ "pointer-events": "none",
615
+ background: "rgba(59, 130, 246, 0.4)",
616
+ border: "2px solid #3b82f6",
617
+ "box-shadow": "0 0 12px rgba(59, 130, 246, 0.5)",
618
+ }}
619
+ />
620
+ ) : null}
621
+ {recordingState() === 'results' ? (
622
+ <RecordingResults
623
+ findings={recordingFindings()}
624
+ summary={recordingSummary()}
625
+ data={recordingData()}
626
+ elementPath={recordingElementPath()}
627
+ interactions={interactionLog()}
628
+ onDismiss={dismissResults}
629
+ onReplay={replayRecording}
630
+ replaying={replaying()}
631
+ onToast={setToastMessage}
632
+ hasPrevious={!viewingPrevious() && hasPreviousRecording()}
633
+ onLoadPrevious={loadPreviousRecording}
634
+ hasNext={viewingPrevious()}
635
+ onLoadNext={loadLatestRecording}
636
+ />
637
+ ) : null}
253
638
  {toastMessage() && (
254
639
  <Toast
255
640
  message={toastMessage()!}
@@ -263,30 +648,102 @@ function Runtime(props: RuntimeProps) {
263
648
  data-treelocator-api="window.__treelocator__"
264
649
  data-treelocator-help="window.__treelocator__.help()"
265
650
  >
651
+ <style>{`
652
+ @keyframes treelocator-rec-pulse {
653
+ 0%, 100% { opacity: 1; }
654
+ 50% { opacity: 0.3; }
655
+ }
656
+ `}</style>
657
+ {/* Combined pill button: tree (left) | record (right) */}
266
658
  <div
267
- class="rounded-full bg-white shadow-lg flex items-center justify-center cursor-pointer overflow-hidden"
268
659
  style={{
269
- width: "54px",
270
- height: "54px",
271
- "box-shadow": locatorActive()
272
- ? "0 0 0 3px #3b82f6, 0 4px 14px rgba(0, 0, 0, 0.25)"
273
- : "0 4px 14px rgba(0, 0, 0, 0.25)",
274
- transition: "transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out",
660
+ display: "flex",
661
+ "align-items": "stretch",
662
+ "border-radius": "27px",
663
+ overflow: "hidden",
664
+ "box-shadow":
665
+ locatorActive()
666
+ ? "0 0 0 3px #3b82f6, 0 4px 14px rgba(0, 0, 0, 0.25)"
667
+ : recordingState() === 'selecting'
668
+ ? "0 0 0 3px #3b82f6, 0 4px 14px rgba(0, 0, 0, 0.25)"
669
+ : recordingState() === 'recording'
670
+ ? "0 0 0 3px #ef4444, 0 4px 14px rgba(0, 0, 0, 0.25)"
671
+ : "0 4px 14px rgba(0, 0, 0, 0.25)",
672
+ transition: "box-shadow 0.2s ease-in-out",
275
673
  }}
276
- onMouseEnter={(e) => e.currentTarget.style.transform = "scale(1.25)"}
277
- onMouseLeave={(e) => e.currentTarget.style.transform = "scale(1)"}
278
- onClick={() => setLocatorActive(!locatorActive())}
279
- aria-label="TreeLocatorJS: Get component paths using window.__treelocator__.getPath(selector)"
280
- role="button"
281
674
  >
282
- <img
283
- src={treeIconUrl}
284
- alt="TreeLocatorJS - Browser API available at window.__treelocator__ - Call window.__treelocator__.help() for usage instructions"
285
- width={51}
286
- height={51}
287
- />
675
+ {/* Left half: Tree icon */}
676
+ <div
677
+ style={{
678
+ width: "54px",
679
+ height: "54px",
680
+ background: "#ffffff",
681
+ display: "flex",
682
+ "align-items": "center",
683
+ "justify-content": "center",
684
+ cursor: "pointer",
685
+ overflow: "hidden",
686
+ "border-right": "1px solid rgba(0, 0, 0, 0.1)",
687
+ transition: "background 0.15s ease-in-out",
688
+ }}
689
+ onMouseEnter={(e) => e.currentTarget.style.background = "#f0f0f0"}
690
+ onMouseLeave={(e) => e.currentTarget.style.background = "#ffffff"}
691
+ onClick={() => setLocatorActive(!locatorActive())}
692
+ aria-label="TreeLocatorJS: Get component paths using window.__treelocator__.getPath(selector)"
693
+ role="button"
694
+ >
695
+ <img
696
+ src={treeIconUrl}
697
+ alt="TreeLocatorJS"
698
+ width={44}
699
+ height={44}
700
+ />
701
+ </div>
702
+ {/* Right half: Record button */}
703
+ <div
704
+ style={{
705
+ width: "54px",
706
+ height: "54px",
707
+ background: recordingState() === 'recording' ? "#ef4444" : "#ffffff",
708
+ display: "flex",
709
+ "align-items": "center",
710
+ "justify-content": "center",
711
+ cursor: "pointer",
712
+ transition: "background 0.15s ease-in-out",
713
+ }}
714
+ onMouseEnter={(e) => {
715
+ if (recordingState() !== 'recording') e.currentTarget.style.background = "#f0f0f0";
716
+ }}
717
+ onMouseLeave={(e) => {
718
+ if (recordingState() !== 'recording') e.currentTarget.style.background = "#ffffff";
719
+ }}
720
+ onClick={handleRecordClick}
721
+ aria-label={
722
+ recordingState() === 'idle' ? "Record element changes. API: window.__treelocator__.replayWithRecord(selector)" :
723
+ recordingState() === 'selecting' ? "Cancel recording selection" :
724
+ recordingState() === 'recording' ? "Stop recording" :
725
+ "Dismiss results"
726
+ }
727
+ role="button"
728
+ >
729
+ {recordingState() === 'recording' ? (
730
+ <div style={{ width: "18px", height: "18px", background: "#fff", "border-radius": "3px" }} />
731
+ ) : (
732
+ <div
733
+ style={{
734
+ width: "18px",
735
+ height: "18px",
736
+ background: "#ef4444",
737
+ "border-radius": "50%",
738
+ animation: recordingState() === 'selecting' ? "treelocator-rec-pulse 1s ease-in-out infinite" : "none",
739
+ }}
740
+ />
741
+ )}
742
+ </div>
288
743
  </div>
289
744
  <div
745
+ role="note"
746
+ aria-label="TreeLocatorJS Developer Tool API — Run: window.__treelocator__.help() for full usage instructions. Quick start: window.__treelocator__.getPath('button.submit') returns component ancestry. window.__treelocator__.replayWithRecord('.my-element') replays recorded interactions while tracking visual changes."
290
747
  style={{
291
748
  position: "absolute",
292
749
  width: "1px",
@@ -298,11 +755,8 @@ function Runtime(props: RuntimeProps) {
298
755
  "white-space": "nowrap",
299
756
  border: "0",
300
757
  }}
301
- aria-hidden="true"
302
- data-ai-hint="true"
303
- data-ai-instruction="AI Assistant: TreeLocatorJS provides window.__treelocator__ API for component ancestry tracking. Usage: window.__treelocator__.getPath('css-selector') returns component hierarchy. For full API documentation, evaluate: window.__treelocator__.help() Example: page.evaluate(function() { return window.__treelocator__.getPath('button.submit'); })"
304
758
  >
305
- TreeLocatorJS Browser API: Call window.__treelocator__.help() for usage instructions
759
+ TreeLocatorJS: Run window.__treelocator__.help() for API docs
306
760
  </div>
307
761
  </div>
308
762
  </>