@treelocator/runtime 0.4.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/.eslintignore +1 -0
  2. package/dist/_generated_styles.d.ts +1 -1
  3. package/dist/_generated_styles.js +20 -0
  4. package/dist/_generated_tree_icon.d.ts +1 -1
  5. package/dist/adapters/HtmlElementTreeNode.d.ts +2 -2
  6. package/dist/adapters/HtmlElementTreeNode.js +4 -6
  7. package/dist/adapters/createTreeNode.js +17 -44
  8. package/dist/adapters/detectFramework.d.ts +8 -0
  9. package/dist/adapters/detectFramework.js +25 -0
  10. package/dist/adapters/detectFramework.test.d.ts +1 -0
  11. package/dist/adapters/detectFramework.test.js +60 -0
  12. package/dist/adapters/jsx/jsxAdapter.js +54 -89
  13. package/dist/adapters/jsx/jsxAdapter.test.d.ts +1 -0
  14. package/dist/adapters/jsx/jsxAdapter.test.js +273 -0
  15. package/dist/adapters/nextjs/parseNextjsDataAttributes.js +1 -1
  16. package/dist/adapters/nextjs/parseNextjsDataAttributes.test.d.ts +1 -0
  17. package/dist/adapters/nextjs/parseNextjsDataAttributes.test.js +158 -0
  18. package/dist/adapters/react/findFiberByHtmlElement.d.ts +1 -1
  19. package/dist/adapters/react/findFiberByHtmlElement.js +1 -1
  20. package/dist/adapters/react/getAllParentsElementsAndRootComponent.js +4 -0
  21. package/dist/adapters/resolveAdapter.d.ts +1 -1
  22. package/dist/adapters/resolveAdapter.js +4 -8
  23. package/dist/adapters/svelte/svelteAdapter.test.d.ts +1 -0
  24. package/dist/adapters/svelte/svelteAdapter.test.js +280 -0
  25. package/dist/adapters/vue/vueAdapter.test.d.ts +1 -0
  26. package/dist/adapters/vue/vueAdapter.test.js +222 -0
  27. package/dist/browserApi.d.ts +148 -0
  28. package/dist/browserApi.js +146 -5
  29. package/dist/browserApi.test.d.ts +1 -0
  30. package/dist/browserApi.test.js +287 -0
  31. package/dist/components/RecordingPillButton.d.ts +11 -0
  32. package/dist/components/RecordingPillButton.js +202 -0
  33. package/dist/components/RecordingResults.d.ts +2 -0
  34. package/dist/components/RecordingResults.js +213 -78
  35. package/dist/components/Runtime.js +161 -554
  36. package/dist/components/SettingsPanel.d.ts +5 -0
  37. package/dist/components/SettingsPanel.js +312 -0
  38. package/dist/consoleCapture.d.ts +9 -0
  39. package/dist/consoleCapture.js +95 -0
  40. package/dist/dejitter/recorder.d.ts +7 -1
  41. package/dist/dejitter/recorder.js +64 -1
  42. package/dist/functions/cssRuleInspector.d.ts +83 -0
  43. package/dist/functions/cssRuleInspector.js +608 -0
  44. package/dist/functions/cssRuleInspector.test.d.ts +1 -0
  45. package/dist/functions/cssRuleInspector.test.js +439 -0
  46. package/dist/functions/deduplicateLabels.test.d.ts +1 -0
  47. package/dist/functions/deduplicateLabels.test.js +178 -0
  48. package/dist/functions/enrichAncestrySourceMaps.js +0 -1
  49. package/dist/functions/extractComputedStyles.d.ts +51 -0
  50. package/dist/functions/extractComputedStyles.js +447 -0
  51. package/dist/functions/extractComputedStyles.test.d.ts +1 -0
  52. package/dist/functions/extractComputedStyles.test.js +549 -0
  53. package/dist/functions/formatAncestryChain.d.ts +8 -0
  54. package/dist/functions/formatAncestryChain.js +21 -1
  55. package/dist/functions/formatAncestryChain.test.js +18 -0
  56. package/dist/functions/getUsableName.test.d.ts +1 -0
  57. package/dist/functions/getUsableName.test.js +219 -0
  58. package/dist/functions/isCombinationModifiersPressed.test.d.ts +1 -0
  59. package/dist/functions/isCombinationModifiersPressed.test.js +192 -0
  60. package/dist/functions/mergeRects.test.js +210 -1
  61. package/dist/functions/namedSnapshots.d.ts +52 -0
  62. package/dist/functions/namedSnapshots.js +161 -0
  63. package/dist/functions/namedSnapshots.test.d.ts +1 -0
  64. package/dist/functions/namedSnapshots.test.js +85 -0
  65. package/dist/functions/normalizeFilePath.test.d.ts +1 -0
  66. package/dist/functions/normalizeFilePath.test.js +66 -0
  67. package/dist/functions/parseDataId.test.d.ts +1 -0
  68. package/dist/functions/parseDataId.test.js +101 -0
  69. package/dist/hooks/getStorage.d.ts +3 -0
  70. package/dist/hooks/getStorage.js +17 -0
  71. package/dist/hooks/useEventListeners.d.ts +15 -0
  72. package/dist/hooks/useEventListeners.js +56 -0
  73. package/dist/hooks/useLocatorStorage.d.ts +18 -0
  74. package/dist/hooks/useLocatorStorage.js +41 -0
  75. package/dist/hooks/useLocatorStorage.test.d.ts +1 -0
  76. package/dist/hooks/useLocatorStorage.test.js +124 -0
  77. package/dist/hooks/useRecordingState.d.ts +43 -0
  78. package/dist/hooks/useRecordingState.js +387 -0
  79. package/dist/hooks/useSettings.d.ts +13 -0
  80. package/dist/hooks/useSettings.js +66 -0
  81. package/dist/index.d.ts +5 -2
  82. package/dist/index.js +4 -2
  83. package/dist/initRuntime.d.ts +3 -1
  84. package/dist/initRuntime.js +4 -1
  85. package/dist/mcpBridge.d.ts +61 -0
  86. package/dist/mcpBridge.js +534 -0
  87. package/dist/mcpBridge.test.d.ts +1 -0
  88. package/dist/mcpBridge.test.js +248 -0
  89. package/dist/output.css +20 -0
  90. package/dist/visualDiff/diff.d.ts +9 -0
  91. package/dist/visualDiff/diff.js +209 -0
  92. package/dist/visualDiff/diff.test.d.ts +1 -0
  93. package/dist/visualDiff/diff.test.js +253 -0
  94. package/dist/visualDiff/settle.d.ts +3 -0
  95. package/dist/visualDiff/settle.js +50 -0
  96. package/dist/visualDiff/settle.test.d.ts +1 -0
  97. package/dist/visualDiff/settle.test.js +65 -0
  98. package/dist/visualDiff/snapshot.d.ts +4 -0
  99. package/dist/visualDiff/snapshot.js +84 -0
  100. package/dist/visualDiff/snapshot.test.d.ts +1 -0
  101. package/dist/visualDiff/snapshot.test.js +245 -0
  102. package/dist/visualDiff/types.d.ts +37 -0
  103. package/dist/visualDiff/types.js +1 -0
  104. package/package.json +2 -2
  105. package/scripts/wrapCSS.js +1 -1
  106. package/scripts/wrapImage.js +1 -1
  107. package/src/_generated_styles.ts +21 -1
  108. package/src/_generated_tree_icon.ts +1 -1
  109. package/src/adapters/HtmlElementTreeNode.ts +10 -7
  110. package/src/adapters/createTreeNode.ts +12 -51
  111. package/src/adapters/detectFramework.test.ts +73 -0
  112. package/src/adapters/detectFramework.ts +28 -0
  113. package/src/adapters/jsx/jsxAdapter.test.ts +240 -0
  114. package/src/adapters/jsx/jsxAdapter.ts +53 -106
  115. package/src/adapters/nextjs/parseNextjsDataAttributes.test.ts +212 -0
  116. package/src/adapters/nextjs/parseNextjsDataAttributes.ts +1 -1
  117. package/src/adapters/react/findDebugSource.ts +5 -6
  118. package/src/adapters/react/findFiberByHtmlElement.ts +3 -3
  119. package/src/adapters/react/getAllParentsElementsAndRootComponent.ts +3 -0
  120. package/src/adapters/react/reactAdapter.ts +1 -2
  121. package/src/adapters/resolveAdapter.ts +4 -14
  122. package/src/adapters/svelte/svelteAdapter.test.ts +334 -0
  123. package/src/adapters/vue/vueAdapter.test.ts +259 -0
  124. package/src/browserApi.test.ts +329 -0
  125. package/src/browserApi.ts +351 -4
  126. package/src/components/RecordingPillButton.tsx +301 -0
  127. package/src/components/RecordingResults.tsx +114 -13
  128. package/src/components/Runtime.tsx +176 -621
  129. package/src/components/SettingsPanel.tsx +339 -0
  130. package/src/consoleCapture.ts +113 -0
  131. package/src/dejitter/recorder.ts +67 -3
  132. package/src/functions/cssRuleInspector.test.ts +517 -0
  133. package/src/functions/cssRuleInspector.ts +708 -0
  134. package/src/functions/deduplicateLabels.test.ts +115 -0
  135. package/src/functions/enrichAncestrySourceMaps.ts +6 -3
  136. package/src/functions/extractComputedStyles.test.ts +681 -0
  137. package/src/functions/extractComputedStyles.ts +768 -0
  138. package/src/functions/formatAncestryChain.test.ts +23 -1
  139. package/src/functions/formatAncestryChain.ts +22 -1
  140. package/src/functions/getUsableName.test.ts +242 -0
  141. package/src/functions/isCombinationModifiersPressed.test.ts +156 -0
  142. package/src/functions/mergeRects.test.ts +111 -1
  143. package/src/functions/namedSnapshots.test.ts +106 -0
  144. package/src/functions/namedSnapshots.ts +232 -0
  145. package/src/functions/normalizeFilePath.test.ts +80 -0
  146. package/src/functions/parseDataId.test.ts +125 -0
  147. package/src/hooks/getStorage.ts +26 -0
  148. package/src/hooks/useEventListeners.ts +97 -0
  149. package/src/hooks/useLocatorStorage.test.ts +127 -0
  150. package/src/hooks/useLocatorStorage.ts +60 -0
  151. package/src/hooks/useRecordingState.ts +516 -0
  152. package/src/hooks/useSettings.ts +83 -0
  153. package/src/index.ts +10 -5
  154. package/src/initRuntime.ts +5 -0
  155. package/src/mcpBridge.test.ts +260 -0
  156. package/src/mcpBridge.ts +677 -0
  157. package/src/visualDiff/diff.test.ts +167 -0
  158. package/src/visualDiff/diff.ts +242 -0
  159. package/src/visualDiff/settle.test.ts +77 -0
  160. package/src/visualDiff/settle.ts +62 -0
  161. package/src/visualDiff/snapshot.test.ts +200 -0
  162. package/src/visualDiff/snapshot.ts +119 -0
  163. package/src/visualDiff/types.ts +40 -0
  164. package/tsconfig.json +3 -1
  165. package/vitest.config.ts +18 -0
  166. package/jest.config.ts +0 -195
@@ -0,0 +1,301 @@
1
+ import { createSignal, For } from "solid-js";
2
+ import type { RecordingState } from "../hooks/useRecordingState";
3
+ import treeIconUrl from "../_generated_tree_icon";
4
+
5
+ type RecordingPillButtonProps = {
6
+ locatorActive: boolean;
7
+ recordingState: RecordingState;
8
+ settingsOpen: boolean;
9
+ onLocatorToggle: () => void;
10
+ onRecordClick: () => void;
11
+ onSettingsClick: () => void;
12
+ };
13
+
14
+ const CIRCLE_SIZE = 42;
15
+ const WRAPPER_W = 154;
16
+ const WRAPPER_H = 138;
17
+
18
+ type CircleKind = "record" | "stub1" | "settings";
19
+
20
+ type CircleDef = {
21
+ left: number;
22
+ top: number;
23
+ openDelay: number;
24
+ closeDelay: number;
25
+ kind: CircleKind;
26
+ };
27
+
28
+ // Positions are relative to the 154x138 wrapper anchored at right:23 bottom:23.
29
+ // Button center is at (77, 87); circles sit on an arc of radius ~65 around that
30
+ // point at angles 225° / 270° / 315°.
31
+ const CIRCLES: CircleDef[] = [
32
+ { left: 10, top: 34, openDelay: 0, closeDelay: 200, kind: "record" },
33
+ { left: 56, top: 10, openDelay: 100, closeDelay: 100, kind: "stub1" },
34
+ { left: 102, top: 34, openDelay: 200, closeDelay: 0, kind: "settings" },
35
+ ];
36
+
37
+ export function RecordingPillButton(props: RecordingPillButtonProps) {
38
+ const [hovered, setHovered] = createSignal(false);
39
+
40
+ const open = () =>
41
+ hovered() ||
42
+ props.settingsOpen ||
43
+ props.recordingState === "selecting" ||
44
+ props.recordingState === "recording";
45
+
46
+ return (
47
+ <div
48
+ class="fixed"
49
+ style={{
50
+ "z-index": "2147483646",
51
+ bottom: "23px",
52
+ right: "23px",
53
+ width: WRAPPER_W + "px",
54
+ height: WRAPPER_H + "px",
55
+ "pointer-events": "auto",
56
+ }}
57
+
58
+ data-treelocator-api="window.__treelocator__"
59
+ data-treelocator-help="window.__treelocator__.help()"
60
+ onMouseEnter={() => setHovered(true)}
61
+ onMouseLeave={() => setHovered(false)}
62
+ >
63
+ <style>{`
64
+ @keyframes treelocator-rec-pulse {
65
+ 0%, 100% { opacity: 1; }
66
+ 50% { opacity: 0.3; }
67
+ }
68
+
69
+ @keyframes treelocator-rec-puddle {
70
+ 0% {
71
+ transform: translate(-50%, -50%) scale(0.9);
72
+ opacity: 0;
73
+ }
74
+ 20% {
75
+ opacity: 0.38;
76
+ }
77
+ 100% {
78
+ transform: translate(-50%, -50%) scale(2);
79
+ opacity: 0;
80
+ }
81
+ }
82
+ `}</style>
83
+
84
+ {/* Faint arc connecting the circles */}
85
+ <svg
86
+ width={WRAPPER_W}
87
+ height={WRAPPER_H}
88
+ style={{
89
+ position: "absolute",
90
+ left: "0",
91
+ top: "0",
92
+ "pointer-events": "none",
93
+ opacity: open() ? "0.4" : "0",
94
+ transition: "opacity 0.3s ease-out",
95
+ "transition-delay": open() ? "50ms" : "0ms",
96
+ }}
97
+ >
98
+ <path
99
+ d="M 31 55 A 65 65 0 0 1 123 55"
100
+ fill="none"
101
+ stroke="rgba(255, 255, 255, 0.6)"
102
+ stroke-width="1"
103
+ />
104
+ </svg>
105
+
106
+ {/* Circles */}
107
+ <For each={CIRCLES}>
108
+ {(circle) => (
109
+ <div
110
+ data-treelocator-settings-toggle={
111
+ circle.kind === "settings" ? "" : undefined
112
+ }
113
+ style={{
114
+ position: "absolute",
115
+ left: circle.left + "px",
116
+ top: circle.top + "px",
117
+ width: CIRCLE_SIZE + "px",
118
+ height: CIRCLE_SIZE + "px",
119
+ "border-radius": "50%",
120
+ background: "#ffffff",
121
+ display: "flex",
122
+ "align-items": "center",
123
+ "justify-content": "center",
124
+ cursor:
125
+ circle.kind === "record" || circle.kind === "settings"
126
+ ? "pointer"
127
+ : "default",
128
+ "box-shadow": "0 4px 14px rgba(0, 0, 0, 0.25)",
129
+ transform: open() ? "scale(1)" : "scale(0)",
130
+ opacity: open() ? "1" : "0",
131
+ transition:
132
+ "transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease-out",
133
+ "transition-delay": open()
134
+ ? circle.openDelay + "ms"
135
+ : circle.closeDelay + "ms",
136
+ "transform-origin": "center center",
137
+ overflow: "visible",
138
+ }}
139
+ onClick={(e) => {
140
+ if (circle.kind === "record") {
141
+ e.stopPropagation();
142
+ props.onRecordClick();
143
+ return;
144
+ }
145
+ if (circle.kind === "settings") {
146
+ e.stopPropagation();
147
+ props.onSettingsClick();
148
+ return;
149
+ }
150
+ }}
151
+ aria-label={
152
+ circle.kind === "record"
153
+ ? props.recordingState === "idle"
154
+ ? "Record element changes"
155
+ : props.recordingState === "selecting"
156
+ ? "Cancel recording selection"
157
+ : props.recordingState === "recording"
158
+ ? "Stop recording"
159
+ : "Dismiss results"
160
+ : circle.kind === "settings"
161
+ ? props.settingsOpen
162
+ ? "Close settings"
163
+ : "Open settings"
164
+ : "Coming soon"
165
+ }
166
+ role="button"
167
+ >
168
+ {circle.kind === "record" ? (
169
+ props.recordingState === "recording" ? (
170
+ <>
171
+ <div
172
+ aria-hidden="true"
173
+ style={{
174
+ position: "absolute",
175
+ left: "50%",
176
+ top: "50%",
177
+ width: "42px",
178
+ height: "42px",
179
+ border: "2px solid rgba(239, 68, 68, 0.48)",
180
+ "border-radius": "50%",
181
+ transform: "translate(-50%, -50%) scale(0.9)",
182
+ opacity: "0",
183
+ filter: "blur(1px)",
184
+ animation:
185
+ "treelocator-rec-puddle 1.8s ease-out 0s infinite both",
186
+ "pointer-events": "none",
187
+ "z-index": "0",
188
+ }}
189
+ />
190
+ <div
191
+ aria-hidden="true"
192
+ style={{
193
+ position: "absolute",
194
+ left: "50%",
195
+ top: "50%",
196
+ width: "42px",
197
+ height: "42px",
198
+ border: "2px solid rgba(239, 68, 68, 0.4)",
199
+ "border-radius": "50%",
200
+ transform: "translate(-50%, -50%) scale(0.9)",
201
+ opacity: "0",
202
+ filter: "blur(1px)",
203
+ animation:
204
+ "treelocator-rec-puddle 1.8s ease-out 0.9s infinite both",
205
+ "pointer-events": "none",
206
+ "z-index": "0",
207
+ }}
208
+ />
209
+ <div
210
+ style={{
211
+ width: "14px",
212
+ height: "14px",
213
+ background: "#ef4444",
214
+ "border-radius": "2px",
215
+ "z-index": "1",
216
+ }}
217
+ />
218
+ </>
219
+ ) : (
220
+ <div
221
+ style={{
222
+ width: "14px",
223
+ height: "14px",
224
+ background: "#ef4444",
225
+ "border-radius": "50%",
226
+ animation:
227
+ props.recordingState === "selecting"
228
+ ? "treelocator-rec-pulse 1s ease-in-out infinite"
229
+ : "none",
230
+ }}
231
+ />
232
+ )
233
+ ) : circle.kind === "settings" ? (
234
+ <svg
235
+ width="18"
236
+ height="18"
237
+ viewBox="0 0 24 24"
238
+ fill="none"
239
+ stroke={props.settingsOpen ? "#3b82f6" : "#1f2937"}
240
+ stroke-width="2"
241
+ stroke-linecap="round"
242
+ stroke-linejoin="round"
243
+ >
244
+ <circle cx="12" cy="12" r="3" />
245
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
246
+ </svg>
247
+ ) : null}
248
+ </div>
249
+ )}
250
+ </For>
251
+
252
+ {/* Main tree button */}
253
+ <div
254
+ style={{
255
+ position: "absolute",
256
+ right: "50px",
257
+ bottom: "10px",
258
+ width: "54px",
259
+ height: "54px",
260
+ "border-radius": "50%",
261
+ background: "#ffffff",
262
+ display: "flex",
263
+ "align-items": "center",
264
+ "justify-content": "center",
265
+ cursor: "pointer",
266
+ overflow: "hidden",
267
+ "box-shadow": props.locatorActive
268
+ ? "0 0 0 3px #3b82f6, 0 4px 14px rgba(0, 0, 0, 0.25)"
269
+ : "0 4px 14px rgba(0, 0, 0, 0.25)",
270
+ transition: "box-shadow 0.2s ease-in-out",
271
+ }}
272
+ onClick={(e) => {
273
+ e.stopPropagation();
274
+ props.onLocatorToggle();
275
+ }}
276
+ aria-label="TreeLocatorJS: Get component paths using window.__treelocator__.getPath(selector)"
277
+ role="button"
278
+ >
279
+ <img src={treeIconUrl} alt="TreeLocatorJS" width={44} height={44} />
280
+ </div>
281
+
282
+ <div
283
+ role="note"
284
+ 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."
285
+ style={{
286
+ position: "absolute",
287
+ width: "1px",
288
+ height: "1px",
289
+ padding: "0",
290
+ margin: "-1px",
291
+ overflow: "hidden",
292
+ clip: "rect(0,0,0,0)",
293
+ "white-space": "nowrap",
294
+ border: "0",
295
+ }}
296
+ >
297
+ TreeLocatorJS: Run window.__treelocator__.help() for API docs
298
+ </div>
299
+ </div>
300
+ );
301
+ }
@@ -1,5 +1,6 @@
1
1
  import { For, Show } from "solid-js";
2
2
  import type { DejitterFinding, DejitterSummary } from "../dejitter/recorder";
3
+ import type { DeltaReport } from "../visualDiff/types";
3
4
 
4
5
  export type InteractionEvent = {
5
6
  t: number;
@@ -15,6 +16,7 @@ type RecordingResultsProps = {
15
16
  data: any;
16
17
  elementPath: string;
17
18
  interactions: InteractionEvent[];
19
+ visualDiff?: DeltaReport | null;
18
20
  onDismiss: () => void;
19
21
  onReplay?: () => void;
20
22
  replaying?: boolean;
@@ -37,7 +39,8 @@ function formatDataForClipboard(
37
39
  elementPath: string,
38
40
  summary: DejitterSummary | null,
39
41
  findings: DejitterFinding[],
40
- interactions: InteractionEvent[]
42
+ interactions: InteractionEvent[],
43
+ visualDiff: DeltaReport | null | undefined
41
44
  ): string {
42
45
  const lines: string[] = [];
43
46
 
@@ -47,21 +50,28 @@ function formatDataForClipboard(
47
50
  }
48
51
 
49
52
  // Header
50
- lines.push(`Recording: ${Math.round(summary?.duration ?? 0)}ms, ${summary?.rawFrameCount ?? 0} frames`);
53
+ const frameCount = summary?.rawFrameCount ?? 0;
54
+ lines.push(`Recording: ${Math.round(summary?.duration ?? 0)}ms, ${frameCount} frames`);
51
55
  lines.push('');
52
56
 
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})`);
57
+ // Property changes — only meaningful with >1 frame; a single sample is
58
+ // the initial reading, not a change, and dejitter labels it "anomaly" which
59
+ // is misleading.
60
+ if (frameCount > 1 && data?.propStats?.props && data.propStats.props.length > 0) {
61
+ const realChanges = data.propStats.props.filter(
62
+ (p: { raw: number }) => p.raw > 1
63
+ );
64
+ if (realChanges.length > 0) {
65
+ lines.push('Changed properties:');
66
+ for (const p of realChanges) {
67
+ lines.push(` ${p.prop}: ${p.raw - 1} changes (${p.mode})`);
68
+ }
69
+ lines.push('');
59
70
  }
60
- lines.push('');
61
71
  }
62
72
 
63
- // Samples with actual values — replace dejitter element IDs with the ancestry path
64
- if (data?.samples && data.samples.length > 0) {
73
+ // Samples with actual values — skip if we only captured the initial frame.
74
+ if (frameCount > 1 && data?.samples && data.samples.length > 0) {
65
75
  lines.push('Timeline:');
66
76
  for (const frame of data.samples) {
67
77
  for (const change of frame.changes) {
@@ -91,6 +101,12 @@ function formatDataForClipboard(
91
101
  for (const evt of interactions) {
92
102
  lines.push(` ${evt.t}ms ${evt.type} ${evt.target} (${evt.x},${evt.y})`);
93
103
  }
104
+ lines.push('');
105
+ }
106
+
107
+ // Visual diff
108
+ if (visualDiff && visualDiff.entries.length > 0) {
109
+ lines.push(visualDiff.text);
94
110
  }
95
111
 
96
112
  return lines.join('\n');
@@ -113,7 +129,14 @@ export function RecordingResults(props: RecordingResultsProps) {
113
129
  const frameCount = () => props.summary?.rawFrameCount ?? 0;
114
130
 
115
131
  function handleCopy() {
116
- const text = formatDataForClipboard(props.data, props.elementPath, props.summary, props.findings, props.interactions);
132
+ const text = formatDataForClipboard(
133
+ props.data,
134
+ props.elementPath,
135
+ props.summary,
136
+ props.findings,
137
+ props.interactions,
138
+ props.visualDiff
139
+ );
117
140
  navigator.clipboard.writeText(text).then(() => {
118
141
  props.onToast?.("Copied to clipboard");
119
142
  });
@@ -123,7 +146,7 @@ export function RecordingResults(props: RecordingResultsProps) {
123
146
  <div
124
147
  style={{
125
148
  position: "fixed",
126
- bottom: "84px",
149
+ bottom: "180px",
127
150
  right: "20px",
128
151
  "z-index": "2147483646",
129
152
  width: "340px",
@@ -282,6 +305,84 @@ export function RecordingResults(props: RecordingResultsProps) {
282
305
  </For>
283
306
  </div>
284
307
  </Show>
308
+
309
+ {/* Visual diff */}
310
+ <Show when={props.visualDiff && props.visualDiff.entries.length > 0}>
311
+ <div
312
+ style={{
313
+ padding: "8px 14px 12px",
314
+ "border-top": "1px solid rgba(255, 255, 255, 0.08)",
315
+ }}
316
+ >
317
+ <div style={{ "margin-bottom": "4px", color: "#9ca3af", "font-size": "11px", "text-transform": "uppercase", "letter-spacing": "0.5px" }}>
318
+ Visual diff ({props.visualDiff!.entries.length})
319
+ </div>
320
+ <div
321
+ style={{
322
+ display: "flex",
323
+ gap: "8px",
324
+ "margin-bottom": "6px",
325
+ color: "#9ca3af",
326
+ "font-size": "11px",
327
+ }}
328
+ >
329
+ <Show when={props.visualDiff!.counts.added > 0}>
330
+ <span style={{ color: "#4ade80" }}>+{props.visualDiff!.counts.added}</span>
331
+ </Show>
332
+ <Show when={props.visualDiff!.counts.removed > 0}>
333
+ <span style={{ color: "#ef4444" }}>-{props.visualDiff!.counts.removed}</span>
334
+ </Show>
335
+ <Show when={props.visualDiff!.counts.changed > 0}>
336
+ <span style={{ color: "#eab308" }}>~{props.visualDiff!.counts.changed}</span>
337
+ </Show>
338
+ <Show when={props.visualDiff!.counts.moved > 0}>
339
+ <span style={{ color: "#60a5fa" }}>→{props.visualDiff!.counts.moved}</span>
340
+ </Show>
341
+ <span style={{ "margin-left": "auto" }}>
342
+ {Math.round(props.visualDiff!.elapsedMs)}ms · settle: {props.visualDiff!.settle}
343
+ </span>
344
+ </div>
345
+ <For each={props.visualDiff!.entries.slice(0, 20)}>
346
+ {(entry) => (
347
+ <div
348
+ style={{
349
+ padding: "2px 0",
350
+ "font-family": "monospace",
351
+ "font-size": "11px",
352
+ color: "#a1a1aa",
353
+ "word-break": "break-all",
354
+ }}
355
+ >
356
+ <span
357
+ style={{
358
+ color:
359
+ entry.type === "+"
360
+ ? "#4ade80"
361
+ : entry.type === "-"
362
+ ? "#ef4444"
363
+ : entry.type === "~"
364
+ ? "#eab308"
365
+ : "#60a5fa",
366
+ "font-weight": "600",
367
+ "margin-right": "6px",
368
+ }}
369
+ >
370
+ {entry.type}
371
+ </span>
372
+ <span>{entry.label}</span>
373
+ <Show when={entry.changedFields && entry.changedFields.length > 0}>
374
+ <span style={{ color: "#6b7280" }}> ({entry.changedFields!.join(", ")})</span>
375
+ </Show>
376
+ </div>
377
+ )}
378
+ </For>
379
+ <Show when={props.visualDiff!.entries.length > 20}>
380
+ <div style={{ color: "#6b7280", "font-size": "11px", "margin-top": "4px" }}>
381
+ … {props.visualDiff!.entries.length - 20} more (copy to see all)
382
+ </div>
383
+ </Show>
384
+ </div>
385
+ </Show>
285
386
  </div>
286
387
  );
287
388
  }