@treelocator/runtime 0.3.2 → 0.4.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.
@@ -3,14 +3,21 @@ import { normalizeFilePath } from "./normalizeFilePath";
3
3
 
4
4
  /**
5
5
  * Check if any DOM element has React 19 fibers (with _debugStack instead of _debugSource).
6
+ * Must walk the _debugOwner chain because DOM element fibers (HostComponent) never have
7
+ * _debugStack — only function component fibers do.
6
8
  */
7
9
  function isReact19Environment() {
8
10
  const el = document.querySelector("[class]") || document.body;
9
11
  if (!el) return false;
10
12
  const fiberKey = Object.keys(el).find(k => k.startsWith("__reactFiber$"));
11
13
  if (!fiberKey) return false;
12
- const fiber = el[fiberKey];
13
- return !fiber?._debugSource && !!fiber?._debugStack;
14
+ let fiber = el[fiberKey];
15
+ while (fiber) {
16
+ if (fiber._debugSource) return false; // React 18
17
+ if (fiber._debugStack) return true; // React 19
18
+ fiber = fiber._debugOwner || null;
19
+ }
20
+ return false;
14
21
  }
15
22
 
16
23
  /**
package/dist/output.css CHANGED
@@ -827,6 +827,10 @@ input:where([type='file']):focus {
827
827
  position: relative;
828
828
  }
829
829
 
830
+ .sticky {
831
+ position: sticky;
832
+ }
833
+
830
834
  .-bottom-7 {
831
835
  bottom: -1.75rem;
832
836
  }
@@ -1758,10 +1762,19 @@ input:where([type='file']):focus {
1758
1762
  --tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity, 1));
1759
1763
  }
1760
1764
 
1765
+ .blur {
1766
+ --tw-blur: blur(8px);
1767
+ filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
1768
+ }
1769
+
1761
1770
  .filter {
1762
1771
  filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
1763
1772
  }
1764
1773
 
1774
+ .backdrop-filter {
1775
+ backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
1776
+ }
1777
+
1765
1778
  .transition {
1766
1779
  transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
1767
1780
  transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treelocator/runtime",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "TreeLocatorJS runtime for component ancestry tracking. Alt+click any element to copy its component tree to clipboard. Exposes window.__treelocator__ API for browser automation (Playwright, Puppeteer, Selenium, Cypress).",
5
5
  "keywords": [
6
6
  "locator",
@@ -73,5 +73,5 @@
73
73
  "directory": "packages/runtime"
74
74
  },
75
75
  "license": "MIT",
76
- "gitHead": "511af51703d71e9a5c4d4990c66140b283f05afe"
76
+ "gitHead": "4f74117d9076e063c072c6b172f785e5572b3be9"
77
77
  }
package/src/browserApi.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  AncestryItem,
7
7
  } from "./functions/formatAncestryChain";
8
8
  import { enrichAncestryWithSourceMaps } from "./functions/enrichAncestrySourceMaps";
9
+ import type { DejitterFinding, DejitterSummary } from "./dejitter/recorder";
10
+ import type { InteractionEvent } from "./components/RecordingResults";
9
11
 
10
12
  export interface LocatorJSAPI {
11
13
  /**
@@ -107,6 +109,53 @@ export interface LocatorJSAPI {
107
109
  * console.log(help);
108
110
  */
109
111
  help(): string;
112
+
113
+ /**
114
+ * Replay the last recorded interaction sequence.
115
+ * Dispatches the recorded clicks at the original positions and timing.
116
+ * Must have a completed recording with interactions to replay.
117
+ *
118
+ * @example
119
+ * // In browser console
120
+ * window.__treelocator__.replay();
121
+ *
122
+ * @example
123
+ * // In Playwright
124
+ * await page.evaluate(() => window.__treelocator__.replay());
125
+ */
126
+ replay(): void;
127
+
128
+ /**
129
+ * Replay the last recorded interaction sequence while recording an element's property changes.
130
+ * Combines replay and dejitter recording: plays back stored clicks at original timing while
131
+ * tracking visual changes (opacity, transform, position, size) on the target element.
132
+ * Returns the dejitter analysis results when replay completes.
133
+ *
134
+ * @param elementOrSelector - HTMLElement or CSS selector for the element to record during replay
135
+ * @returns Promise resolving to recording results with findings, summary, and interaction log
136
+ *
137
+ * @example
138
+ * // Record the sliding panel while replaying user clicks
139
+ * const results = await window.__treelocator__.replayWithRecord('[data-locatorjs-id="SlidingPanel"]');
140
+ * console.log(results.findings); // anomaly analysis
141
+ * console.log(results.path); // component ancestry
142
+ *
143
+ * @example
144
+ * // In Playwright - automated regression test
145
+ * const results = await page.evaluate(async () => {
146
+ * return await window.__treelocator__.replayWithRecord('.my-panel');
147
+ * });
148
+ * expect(results.findings.filter(f => f.severity === 'high')).toHaveLength(0);
149
+ */
150
+ replayWithRecord(
151
+ elementOrSelector: HTMLElement | string
152
+ ): Promise<{
153
+ path: string;
154
+ findings: DejitterFinding[];
155
+ summary: DejitterSummary | null;
156
+ data: any;
157
+ interactions: InteractionEvent[];
158
+ } | null>;
110
159
  }
111
160
 
112
161
  let adapterId: AdapterId | undefined;
@@ -179,7 +228,22 @@ METHODS:
179
228
  console.log(data.path) // formatted string
180
229
  console.log(data.ancestry) // structured array
181
230
 
182
- 4. help()
231
+ 4. replay()
232
+ Replays the last recorded interaction sequence as a macro.
233
+
234
+ Usage:
235
+ window.__treelocator__.replay()
236
+
237
+ 5. replayWithRecord(elementOrSelector)
238
+ Replays stored interactions while recording element changes.
239
+ Returns dejitter analysis when replay completes.
240
+
241
+ Usage:
242
+ const results = await window.__treelocator__.replayWithRecord('[data-locatorjs-id="SlidingPanel"]')
243
+ console.log(results.findings) // anomaly analysis
244
+ console.log(results.path) // component ancestry
245
+
246
+ 6. help()
183
247
  Displays this help message.
184
248
 
185
249
  PLAYWRIGHT EXAMPLES:
@@ -280,6 +344,15 @@ export function createBrowserAPI(
280
344
  help(): string {
281
345
  return HELP_TEXT;
282
346
  },
347
+
348
+ replay() {
349
+ // Replaced by Runtime component once mounted
350
+ },
351
+
352
+ replayWithRecord() {
353
+ // Replaced by Runtime component once mounted
354
+ return Promise.resolve(null);
355
+ },
283
356
  };
284
357
  }
285
358
 
@@ -0,0 +1,66 @@
1
+ import { createSignal, onCleanup, onMount } from "solid-js";
2
+
3
+ type RecordingOutlineProps = {
4
+ element: HTMLElement;
5
+ };
6
+
7
+ export function RecordingOutline(props: RecordingOutlineProps) {
8
+ const [box, setBox] = createSignal(props.element.getBoundingClientRect());
9
+
10
+ let rafId: number;
11
+ const updateBox = () => {
12
+ setBox(props.element.getBoundingClientRect());
13
+ rafId = requestAnimationFrame(updateBox);
14
+ };
15
+ onMount(() => {
16
+ rafId = requestAnimationFrame(updateBox);
17
+ });
18
+ onCleanup(() => cancelAnimationFrame(rafId));
19
+
20
+ return (
21
+ <div
22
+ style={{
23
+ position: "fixed",
24
+ "z-index": "2",
25
+ left: box().x + "px",
26
+ top: box().y + "px",
27
+ width: box().width + "px",
28
+ height: box().height + "px",
29
+ border: "2px dashed #ef4444",
30
+ "border-radius": "2px",
31
+ "pointer-events": "none",
32
+ }}
33
+ >
34
+ <div
35
+ style={{
36
+ position: "absolute",
37
+ top: "-22px",
38
+ left: "4px",
39
+ display: "flex",
40
+ "align-items": "center",
41
+ gap: "4px",
42
+ padding: "2px 8px",
43
+ background: "rgba(239, 68, 68, 0.9)",
44
+ "border-radius": "4px",
45
+ color: "#fff",
46
+ "font-size": "10px",
47
+ "font-family": "system-ui, sans-serif",
48
+ "font-weight": "600",
49
+ "letter-spacing": "0.5px",
50
+ "white-space": "nowrap",
51
+ }}
52
+ >
53
+ <div
54
+ style={{
55
+ width: "6px",
56
+ height: "6px",
57
+ "border-radius": "50%",
58
+ background: "#fff",
59
+ animation: "treelocator-rec-pulse 1s ease-in-out infinite",
60
+ }}
61
+ />
62
+ REC
63
+ </div>
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,287 @@
1
+ import { For, Show } from "solid-js";
2
+ import type { DejitterFinding, DejitterSummary } from "../dejitter/recorder";
3
+
4
+ export type InteractionEvent = {
5
+ t: number;
6
+ type: string;
7
+ target: string;
8
+ x: number;
9
+ y: number;
10
+ };
11
+
12
+ type RecordingResultsProps = {
13
+ findings: DejitterFinding[];
14
+ summary: DejitterSummary | null;
15
+ data: any;
16
+ elementPath: string;
17
+ interactions: InteractionEvent[];
18
+ onDismiss: () => void;
19
+ onReplay?: () => void;
20
+ replaying?: boolean;
21
+ onToast?: (msg: string) => void;
22
+ hasPrevious?: boolean;
23
+ onLoadPrevious?: () => void;
24
+ hasNext?: boolean;
25
+ onLoadNext?: () => void;
26
+ };
27
+
28
+ const SEVERITY_COLORS: Record<string, string> = {
29
+ high: "#ef4444",
30
+ medium: "#f59e0b",
31
+ low: "#eab308",
32
+ info: "#9ca3af",
33
+ };
34
+
35
+ function formatDataForClipboard(
36
+ data: any,
37
+ elementPath: string,
38
+ summary: DejitterSummary | null,
39
+ findings: DejitterFinding[],
40
+ interactions: InteractionEvent[]
41
+ ): string {
42
+ const lines: string[] = [];
43
+
44
+ // Element ancestry path from treelocator
45
+ if (elementPath) {
46
+ lines.push(`Element: ${elementPath}`);
47
+ }
48
+
49
+ // Header
50
+ lines.push(`Recording: ${Math.round(summary?.duration ?? 0)}ms, ${summary?.rawFrameCount ?? 0} frames`);
51
+ lines.push('');
52
+
53
+ // Property changes
54
+ if (data?.propStats?.props && data.propStats.props.length > 0) {
55
+ lines.push('Changed properties:');
56
+ for (const p of data.propStats.props) {
57
+ if (p.raw === 0) continue;
58
+ lines.push(` ${p.prop}: ${p.raw} changes (${p.mode})`);
59
+ }
60
+ lines.push('');
61
+ }
62
+
63
+ // Samples with actual values — replace dejitter element IDs with the ancestry path
64
+ if (data?.samples && data.samples.length > 0) {
65
+ lines.push('Timeline:');
66
+ for (const frame of data.samples) {
67
+ for (const change of frame.changes) {
68
+ const { id, ...props } = change;
69
+ const propEntries = Object.entries(props);
70
+ if (propEntries.length > 0) {
71
+ const vals = propEntries.map(([k, v]) => `${k}=${v}`).join(', ');
72
+ lines.push(` ${frame.t}ms: ${vals}`);
73
+ }
74
+ }
75
+ }
76
+ lines.push('');
77
+ }
78
+
79
+ // Findings — replace dejitter element IDs in descriptions
80
+ if (findings.length > 0) {
81
+ lines.push('Anomalies:');
82
+ for (const f of findings) {
83
+ lines.push(` [${f.severity}] ${f.type}: ${f.description}`);
84
+ }
85
+ lines.push('');
86
+ }
87
+
88
+ // Interactions
89
+ if (interactions.length > 0) {
90
+ lines.push('User interactions:');
91
+ for (const evt of interactions) {
92
+ lines.push(` ${evt.t}ms ${evt.type} ${evt.target} (${evt.x},${evt.y})`);
93
+ }
94
+ }
95
+
96
+ return lines.join('\n');
97
+ }
98
+
99
+ const buttonStyle = (active?: boolean) => ({
100
+ cursor: "pointer",
101
+ padding: "4px 10px",
102
+ "border-radius": "4px",
103
+ background: active ? "rgba(59, 130, 246, 0.2)" : "rgba(255, 255, 255, 0.08)",
104
+ color: active ? "#60a5fa" : "#9ca3af",
105
+ "font-size": "11px",
106
+ "font-weight": "600",
107
+ "line-height": "1.4",
108
+ transition: "background 0.15s, color 0.15s",
109
+ });
110
+
111
+ export function RecordingResults(props: RecordingResultsProps) {
112
+ const duration = () => props.summary?.duration ?? 0;
113
+ const frameCount = () => props.summary?.rawFrameCount ?? 0;
114
+
115
+ function handleCopy() {
116
+ const text = formatDataForClipboard(props.data, props.elementPath, props.summary, props.findings, props.interactions);
117
+ navigator.clipboard.writeText(text).then(() => {
118
+ props.onToast?.("Copied to clipboard");
119
+ });
120
+ }
121
+
122
+ return (
123
+ <div
124
+ style={{
125
+ position: "fixed",
126
+ bottom: "84px",
127
+ right: "20px",
128
+ "z-index": "2147483646",
129
+ width: "340px",
130
+ "max-height": "400px",
131
+ "overflow-y": "auto",
132
+ background: "rgba(15, 15, 15, 0.92)",
133
+ "backdrop-filter": "blur(12px)",
134
+ "border-radius": "12px",
135
+ border: "1px solid rgba(255, 255, 255, 0.1)",
136
+ "box-shadow": "0 8px 32px rgba(0, 0, 0, 0.4)",
137
+ color: "#e5e5e5",
138
+ "font-family": "system-ui, -apple-system, sans-serif",
139
+ "font-size": "12px",
140
+ "pointer-events": "auto",
141
+ }}
142
+ >
143
+ {/* Header */}
144
+ <div
145
+ style={{
146
+ display: "flex",
147
+ "align-items": "center",
148
+ "justify-content": "space-between",
149
+ padding: "12px 14px 8px",
150
+ "border-bottom": "1px solid rgba(255, 255, 255, 0.08)",
151
+ }}
152
+ >
153
+ <div>
154
+ <div style={{ "font-weight": "600", "font-size": "13px", color: "#fff" }}>
155
+ Recording Results
156
+ </div>
157
+ <div style={{ color: "#9ca3af", "margin-top": "2px" }}>
158
+ {Math.round(duration())}ms &middot; {frameCount()} frames
159
+ </div>
160
+ </div>
161
+ <div style={{ display: "flex", "align-items": "center", gap: "4px" }}>
162
+ <div style={buttonStyle()} onClick={handleCopy}>
163
+ Copy
164
+ </div>
165
+ {props.onReplay && (
166
+ <div style={buttonStyle(props.replaying)} onClick={props.onReplay}>
167
+ {props.replaying ? "Replaying..." : "Replay"}
168
+ </div>
169
+ )}
170
+ {props.hasPrevious && props.onLoadPrevious && (
171
+ <div style={buttonStyle()} onClick={props.onLoadPrevious}>
172
+ Prev
173
+ </div>
174
+ )}
175
+ {props.hasNext && props.onLoadNext && (
176
+ <div style={buttonStyle()} onClick={props.onLoadNext}>
177
+ Next
178
+ </div>
179
+ )}
180
+ <div
181
+ style={{
182
+ cursor: "pointer",
183
+ padding: "4px 8px",
184
+ "border-radius": "4px",
185
+ color: "#9ca3af",
186
+ "font-size": "16px",
187
+ "line-height": "1",
188
+ }}
189
+ onClick={props.onDismiss}
190
+ >
191
+ &times;
192
+ </div>
193
+ </div>
194
+ </div>
195
+
196
+ {/* Findings */}
197
+ <div style={{ padding: "8px 14px" }}>
198
+ <Show
199
+ when={props.findings.length > 0}
200
+ fallback={
201
+ <div
202
+ style={{
203
+ display: "flex",
204
+ "align-items": "center",
205
+ gap: "6px",
206
+ padding: "8px 0",
207
+ color: "#4ade80",
208
+ }}
209
+ >
210
+ <span style={{ "font-size": "14px" }}>&#10003;</span>
211
+ No anomalies detected
212
+ </div>
213
+ }
214
+ >
215
+ <div style={{ "margin-bottom": "4px", color: "#9ca3af", "font-size": "11px", "text-transform": "uppercase", "letter-spacing": "0.5px" }}>
216
+ Findings ({props.findings.length})
217
+ </div>
218
+ <For each={props.findings}>
219
+ {(finding) => (
220
+ <div
221
+ style={{
222
+ display: "flex",
223
+ "align-items": "flex-start",
224
+ gap: "8px",
225
+ padding: "6px 0",
226
+ "border-bottom": "1px solid rgba(255, 255, 255, 0.05)",
227
+ }}
228
+ >
229
+ <div
230
+ style={{
231
+ width: "8px",
232
+ height: "8px",
233
+ "border-radius": "50%",
234
+ background: SEVERITY_COLORS[finding.severity] || "#9ca3af",
235
+ "flex-shrink": "0",
236
+ "margin-top": "3px",
237
+ }}
238
+ />
239
+ <div style={{ "min-width": "0" }}>
240
+ <div style={{ display: "flex", gap: "6px", "align-items": "center" }}>
241
+ <span style={{ "font-weight": "600", color: "#fff" }}>{finding.type}</span>
242
+ <span style={{ color: SEVERITY_COLORS[finding.severity], "font-size": "11px" }}>
243
+ {finding.severity}
244
+ </span>
245
+ </div>
246
+ <div
247
+ style={{
248
+ color: "#a1a1aa",
249
+ "margin-top": "2px",
250
+ "line-height": "1.4",
251
+ "word-break": "break-word",
252
+ }}
253
+ >
254
+ {finding.description}
255
+ </div>
256
+ </div>
257
+ </div>
258
+ )}
259
+ </For>
260
+ </Show>
261
+ </div>
262
+
263
+ {/* Interactions */}
264
+ <Show when={props.interactions.length > 0}>
265
+ <div
266
+ style={{
267
+ padding: "8px 14px 12px",
268
+ "border-top": "1px solid rgba(255, 255, 255, 0.08)",
269
+ }}
270
+ >
271
+ <div style={{ "margin-bottom": "4px", color: "#9ca3af", "font-size": "11px", "text-transform": "uppercase", "letter-spacing": "0.5px" }}>
272
+ Interactions ({props.interactions.length})
273
+ </div>
274
+ <For each={props.interactions}>
275
+ {(evt) => (
276
+ <div style={{ padding: "3px 0", color: "#a1a1aa", "font-family": "monospace", "font-size": "11px" }}>
277
+ <span style={{ color: "#9ca3af" }}>{evt.t}ms</span>{" "}
278
+ <span style={{ color: "#60a5fa" }}>{evt.type}</span>{" "}
279
+ <span>{evt.target}</span>
280
+ </div>
281
+ )}
282
+ </For>
283
+ </div>
284
+ </Show>
285
+ </div>
286
+ );
287
+ }