@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,339 @@
1
+ import { For } from "solid-js";
2
+ import {
3
+ DEFAULT_SETTINGS,
4
+ resetSettings,
5
+ setSetting,
6
+ settings,
7
+ type TreelocatorSettings,
8
+ } from "../hooks/useSettings";
9
+
10
+ type SettingsPanelProps = {
11
+ onDismiss: () => void;
12
+ };
13
+
14
+ type ToggleKey =
15
+ | "anomalyTracking"
16
+ | "visualDiff"
17
+ | "computedStyles"
18
+ | "computedStylesIncludeDefaults";
19
+ type NumberKey = "sampleRate" | "maxDurationMs" | "jumpMinAbsolute" | "lagMinDelay";
20
+
21
+ const TOGGLES: { key: ToggleKey; label: string; hint: string }[] = [
22
+ {
23
+ key: "anomalyTracking",
24
+ label: "Anomaly tracking",
25
+ hint: "Detect jitter, jumps, stutter, stuck frames",
26
+ },
27
+ {
28
+ key: "visualDiff",
29
+ label: "Visual diff",
30
+ hint: "Snapshot before/after element tree",
31
+ },
32
+ {
33
+ key: "computedStyles",
34
+ label: "Computed styles",
35
+ hint: "Include styles in alt+click clipboard output",
36
+ },
37
+ {
38
+ key: "computedStylesIncludeDefaults",
39
+ label: "Include default styles",
40
+ hint: "Show browser-default values like display:block and font-weight:bold",
41
+ },
42
+ ];
43
+
44
+ const NUMBERS: {
45
+ key: NumberKey;
46
+ label: string;
47
+ hint: string;
48
+ unit: string;
49
+ min: number;
50
+ max: number;
51
+ step: number;
52
+ }[] = [
53
+ {
54
+ key: "sampleRate",
55
+ label: "Sample rate",
56
+ hint: "Max frames per second to capture",
57
+ unit: "Hz",
58
+ min: 1,
59
+ max: 60,
60
+ step: 1,
61
+ },
62
+ {
63
+ key: "maxDurationMs",
64
+ label: "Max duration",
65
+ hint: "Auto-stop recording after this long",
66
+ unit: "ms",
67
+ min: 1000,
68
+ max: 120000,
69
+ step: 1000,
70
+ },
71
+ {
72
+ key: "jumpMinAbsolute",
73
+ label: "Jump threshold",
74
+ hint: "Minimum px delta to flag a jump",
75
+ unit: "px",
76
+ min: 1,
77
+ max: 1000,
78
+ step: 1,
79
+ },
80
+ {
81
+ key: "lagMinDelay",
82
+ label: "Lag threshold",
83
+ hint: "Minimum rAF delay to flag lag",
84
+ unit: "ms",
85
+ min: 1,
86
+ max: 1000,
87
+ step: 1,
88
+ },
89
+ ];
90
+
91
+ const rowStyle = {
92
+ display: "flex",
93
+ "align-items": "center",
94
+ "justify-content": "space-between",
95
+ gap: "12px",
96
+ padding: "8px 0",
97
+ "border-bottom": "1px solid rgba(255, 255, 255, 0.05)",
98
+ };
99
+
100
+ const labelStyle = { "min-width": "0", flex: "1 1 auto" };
101
+ const labelTitle = { "font-weight": "600", color: "#fff" };
102
+ const labelHint = {
103
+ color: "#9ca3af",
104
+ "font-size": "11px",
105
+ "margin-top": "2px",
106
+ "line-height": "1.3",
107
+ };
108
+
109
+ const sectionTitleStyle = {
110
+ "margin-bottom": "4px",
111
+ color: "#9ca3af",
112
+ "font-size": "11px",
113
+ "text-transform": "uppercase",
114
+ "letter-spacing": "0.5px",
115
+ };
116
+
117
+ function Toggle(props: {
118
+ checked: boolean;
119
+ onChange: (v: boolean) => void;
120
+ }) {
121
+ return (
122
+ <div
123
+ role="switch"
124
+ aria-checked={props.checked}
125
+ onClick={() => props.onChange(!props.checked)}
126
+ style={{
127
+ cursor: "pointer",
128
+ width: "32px",
129
+ height: "18px",
130
+ "border-radius": "9px",
131
+ background: props.checked
132
+ ? "rgba(59, 130, 246, 0.6)"
133
+ : "rgba(255, 255, 255, 0.12)",
134
+ position: "relative",
135
+ transition: "background 0.15s",
136
+ "flex-shrink": "0",
137
+ }}
138
+ >
139
+ <div
140
+ style={{
141
+ position: "absolute",
142
+ top: "2px",
143
+ left: props.checked ? "16px" : "2px",
144
+ width: "14px",
145
+ height: "14px",
146
+ "border-radius": "50%",
147
+ background: "#fff",
148
+ transition: "left 0.15s",
149
+ }}
150
+ />
151
+ </div>
152
+ );
153
+ }
154
+
155
+ function NumberInput(props: {
156
+ value: number;
157
+ min: number;
158
+ max: number;
159
+ step: number;
160
+ unit: string;
161
+ onChange: (v: number) => void;
162
+ }) {
163
+ return (
164
+ <div
165
+ style={{
166
+ display: "flex",
167
+ "align-items": "center",
168
+ gap: "4px",
169
+ "flex-shrink": "0",
170
+ }}
171
+ >
172
+ <input
173
+ type="number"
174
+ value={props.value}
175
+ min={props.min}
176
+ max={props.max}
177
+ step={props.step}
178
+ onInput={(e) => {
179
+ const n = Number(e.currentTarget.value);
180
+ if (!Number.isFinite(n)) return;
181
+ const clamped = Math.min(props.max, Math.max(props.min, n));
182
+ props.onChange(clamped);
183
+ }}
184
+ style={{
185
+ width: "72px",
186
+ padding: "4px 6px",
187
+ "border-radius": "4px",
188
+ background: "rgba(255, 255, 255, 0.08)",
189
+ border: "1px solid rgba(255, 255, 255, 0.1)",
190
+ color: "#fff",
191
+ "font-family": "inherit",
192
+ "font-size": "11px",
193
+ "text-align": "right",
194
+ outline: "none",
195
+ }}
196
+ />
197
+ <span style={{ color: "#9ca3af", "font-size": "11px", "min-width": "18px" }}>
198
+ {props.unit}
199
+ </span>
200
+ </div>
201
+ );
202
+ }
203
+
204
+ export function SettingsPanel(props: SettingsPanelProps) {
205
+ const current = () => settings();
206
+
207
+ return (
208
+ <div
209
+ data-treelocator-settings-panel
210
+ style={{
211
+ position: "fixed",
212
+ bottom: "180px",
213
+ right: "20px",
214
+ "z-index": "2147483646",
215
+ width: "340px",
216
+ "max-height": "400px",
217
+ "overflow-y": "auto",
218
+ background: "rgba(15, 15, 15, 0.92)",
219
+ "backdrop-filter": "blur(12px)",
220
+ "border-radius": "12px",
221
+ border: "1px solid rgba(255, 255, 255, 0.1)",
222
+ "box-shadow": "0 8px 32px rgba(0, 0, 0, 0.4)",
223
+ color: "#e5e5e5",
224
+ "font-family": "system-ui, -apple-system, sans-serif",
225
+ "font-size": "12px",
226
+ "pointer-events": "auto",
227
+ }}
228
+ >
229
+ {/* Header */}
230
+ <div
231
+ style={{
232
+ display: "flex",
233
+ "align-items": "center",
234
+ "justify-content": "space-between",
235
+ padding: "12px 14px 8px",
236
+ "border-bottom": "1px solid rgba(255, 255, 255, 0.08)",
237
+ }}
238
+ >
239
+ <div>
240
+ <div style={{ "font-weight": "600", "font-size": "13px", color: "#fff" }}>
241
+ Settings
242
+ </div>
243
+ <div style={{ color: "#9ca3af", "margin-top": "2px" }}>
244
+ Persisted to localStorage
245
+ </div>
246
+ </div>
247
+ <div style={{ display: "flex", "align-items": "center", gap: "4px" }}>
248
+ <div
249
+ style={{
250
+ cursor: "pointer",
251
+ padding: "4px 10px",
252
+ "border-radius": "4px",
253
+ background: "rgba(255, 255, 255, 0.08)",
254
+ color: "#9ca3af",
255
+ "font-size": "11px",
256
+ "font-weight": "600",
257
+ }}
258
+ onClick={resetSettings}
259
+ >
260
+ Reset
261
+ </div>
262
+ <div
263
+ style={{
264
+ cursor: "pointer",
265
+ padding: "4px 8px",
266
+ "border-radius": "4px",
267
+ color: "#9ca3af",
268
+ "font-size": "16px",
269
+ "line-height": "1",
270
+ }}
271
+ onClick={props.onDismiss}
272
+ >
273
+ &times;
274
+ </div>
275
+ </div>
276
+ </div>
277
+
278
+ {/* Feature toggles */}
279
+ <div style={{ padding: "8px 14px" }}>
280
+ <div style={sectionTitleStyle}>Features</div>
281
+ <For each={TOGGLES}>
282
+ {(item) => (
283
+ <div style={rowStyle}>
284
+ <div style={labelStyle}>
285
+ <div style={labelTitle}>{item.label}</div>
286
+ <div style={labelHint}>{item.hint}</div>
287
+ </div>
288
+ <Toggle
289
+ checked={current()[item.key]}
290
+ onChange={(v) => setSetting(item.key, v)}
291
+ />
292
+ </div>
293
+ )}
294
+ </For>
295
+ </div>
296
+
297
+ {/* Thresholds */}
298
+ <div
299
+ style={{
300
+ padding: "8px 14px 12px",
301
+ "border-top": "1px solid rgba(255, 255, 255, 0.08)",
302
+ }}
303
+ >
304
+ <div style={sectionTitleStyle}>Thresholds</div>
305
+ <For each={NUMBERS}>
306
+ {(item) => (
307
+ <div style={rowStyle}>
308
+ <div style={labelStyle}>
309
+ <div style={labelTitle}>{item.label}</div>
310
+ <div style={labelHint}>{item.hint}</div>
311
+ </div>
312
+ <NumberInput
313
+ value={current()[item.key]}
314
+ min={item.min}
315
+ max={item.max}
316
+ step={item.step}
317
+ unit={item.unit}
318
+ onChange={(v) =>
319
+ setSetting(item.key as keyof TreelocatorSettings, v as never)
320
+ }
321
+ />
322
+ </div>
323
+ )}
324
+ </For>
325
+ <div
326
+ style={{
327
+ color: "#6b7280",
328
+ "font-size": "11px",
329
+ "margin-top": "8px",
330
+ }}
331
+ >
332
+ Defaults: sample {DEFAULT_SETTINGS.sampleRate}Hz, max{" "}
333
+ {DEFAULT_SETTINGS.maxDurationMs}ms, jump {DEFAULT_SETTINGS.jumpMinAbsolute}px,
334
+ lag {DEFAULT_SETTINGS.lagMinDelay}ms
335
+ </div>
336
+ </div>
337
+ </div>
338
+ );
339
+ }
@@ -0,0 +1,113 @@
1
+ export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
2
+
3
+ export interface ConsoleEntry {
4
+ level: ConsoleLevel;
5
+ timestamp: number;
6
+ message: string;
7
+ }
8
+
9
+ const MAX_ENTRIES = 500;
10
+ const LEVELS: ConsoleLevel[] = ["log", "info", "warn", "error", "debug"];
11
+
12
+ const CAPTURE_FLAG = "__treelocator_console_captured__";
13
+ const entries: ConsoleEntry[] = [];
14
+
15
+ function formatArg(value: unknown, seen: WeakSet<object>): string {
16
+ if (value === null) return "null";
17
+ if (value === undefined) return "undefined";
18
+ const t = typeof value;
19
+ if (t === "string") return value as string;
20
+ if (t === "number" || t === "boolean" || t === "bigint") return String(value);
21
+ if (t === "function") {
22
+ const name = (value as { name?: string }).name || "anonymous";
23
+ return `[Function: ${name}]`;
24
+ }
25
+ if (value instanceof Error) {
26
+ return `${value.name}: ${value.message}`;
27
+ }
28
+ if (typeof Element !== "undefined" && value instanceof Element) {
29
+ const el = value as Element;
30
+ const id = el.id ? `#${el.id}` : "";
31
+ const cls = el.className && typeof el.className === "string"
32
+ ? `.${el.className.trim().split(/\s+/).join(".")}`
33
+ : "";
34
+ return `<${el.tagName.toLowerCase()}${id}${cls}>`;
35
+ }
36
+ if (t === "object") {
37
+ if (seen.has(value as object)) return "[Circular]";
38
+ seen.add(value as object);
39
+ try {
40
+ const localSeen = new WeakSet<object>();
41
+ return JSON.stringify(value, (_key, v) => {
42
+ if (v === null || v === undefined) return v;
43
+ if (typeof v === "bigint") return v.toString();
44
+ if (typeof v === "function") return `[Function: ${v.name || "anonymous"}]`;
45
+ if (typeof v === "object") {
46
+ if (localSeen.has(v)) return "[Circular]";
47
+ localSeen.add(v);
48
+ }
49
+ return v;
50
+ });
51
+ } catch {
52
+ try {
53
+ return String(value);
54
+ } catch {
55
+ return "[Unserializable]";
56
+ }
57
+ }
58
+ }
59
+ return String(value);
60
+ }
61
+
62
+ function formatArgs(args: unknown[]): string {
63
+ const seen = new WeakSet<object>();
64
+ return args.map((arg) => formatArg(arg, seen)).join(" ");
65
+ }
66
+
67
+ function pushEntry(level: ConsoleLevel, args: unknown[]): void {
68
+ entries.push({
69
+ level,
70
+ timestamp: Date.now(),
71
+ message: formatArgs(args),
72
+ });
73
+ if (entries.length > MAX_ENTRIES) {
74
+ entries.splice(0, entries.length - MAX_ENTRIES);
75
+ }
76
+ }
77
+
78
+ export function installConsoleCapture(): void {
79
+ if (typeof console === "undefined") return;
80
+ const target = console as unknown as Record<string, unknown>;
81
+ if (target[CAPTURE_FLAG]) return;
82
+ target[CAPTURE_FLAG] = true;
83
+
84
+ for (const level of LEVELS) {
85
+ const original = target[level];
86
+ if (typeof original !== "function") continue;
87
+ const wrapped = function (this: unknown, ...args: unknown[]) {
88
+ try {
89
+ pushEntry(level, args);
90
+ } catch {
91
+ // Swallow — capture must never break logging.
92
+ }
93
+ return (original as (...a: unknown[]) => unknown).apply(this, args);
94
+ };
95
+ try {
96
+ target[level] = wrapped;
97
+ } catch {
98
+ // Some environments freeze console — ignore.
99
+ }
100
+ }
101
+ }
102
+
103
+ export function getConsoleEntries(last?: number): ConsoleEntry[] {
104
+ if (typeof last !== "number" || !Number.isFinite(last) || last <= 0) {
105
+ return entries.slice();
106
+ }
107
+ const n = Math.min(Math.floor(last), entries.length);
108
+ return entries.slice(entries.length - n);
109
+ }
110
+
111
+ export function clearConsoleEntries(): void {
112
+ entries.length = 0;
113
+ }
@@ -20,11 +20,12 @@ export interface DejitterConfig {
20
20
  stutter: { velocityRatio: number; maxFrames: number; minVelocity: number };
21
21
  stuck: { minStillFrames: number; maxDelta: number; minSurroundingVelocity: number; highDuration: number; medDuration: number };
22
22
  outlier: { ratioThreshold: number };
23
+ lag: { minDelay: number; highDelay: number; medDelay: number };
23
24
  };
24
25
  }
25
26
 
26
27
  export interface DejitterFinding {
27
- type: 'jitter' | 'flicker' | 'shiver' | 'jump' | 'stutter' | 'stuck' | 'outlier';
28
+ type: 'jitter' | 'flicker' | 'shiver' | 'jump' | 'stutter' | 'stuck' | 'outlier' | 'lag';
28
29
  severity: 'high' | 'medium' | 'low' | 'info';
29
30
  elem: string;
30
31
  elemLabel: { tag: string; cls: string; text: string };
@@ -50,7 +51,7 @@ export interface DejitterAPI {
50
51
  getData(): any;
51
52
  summary(json?: boolean): string | DejitterSummary;
52
53
  findings(json?: boolean): string | DejitterFinding[];
53
- getRaw(): { rawFrames: any[]; mutations: any[] };
54
+ getRaw(): { rawFrames: any[]; mutations: any[]; interactions: any[] };
54
55
  toJSON(): string;
55
56
  }
56
57
 
@@ -70,6 +71,7 @@ export function createDejitterRecorder(): DejitterAPI {
70
71
  stutter: { velocityRatio: 0.3, maxFrames: 3, minVelocity: 0.5 },
71
72
  stuck: { minStillFrames: 3, maxDelta: 0.5, minSurroundingVelocity: 1, highDuration: 500, medDuration: 200 },
72
73
  outlier: { ratioThreshold: 3 },
74
+ lag: { minDelay: 50, highDelay: 200, medDelay: 100 },
73
75
  },
74
76
  };
75
77
 
@@ -79,6 +81,7 @@ export function createDejitterRecorder(): DejitterAPI {
79
81
  let rafId: number | null = null;
80
82
  let stopTimer: ReturnType<typeof setTimeout> | null = null;
81
83
  let mutationObserver: MutationObserver | null = null;
84
+ let interactionAbort: AbortController | null = null;
82
85
  let onStopCallbacks: Array<() => void> = [];
83
86
 
84
87
  let lastSeen = new Map<string, Record<string, any>>();
@@ -86,6 +89,7 @@ export function createDejitterRecorder(): DejitterAPI {
86
89
  let lastChangeTime = 0;
87
90
  let hasSeenChange = false;
88
91
  let mutations: any[] = [];
92
+ let interactions: Array<{ t: number; type: string }> = [];
89
93
  let nextElemId = 0;
90
94
 
91
95
  function elemId(el: any): string {
@@ -212,6 +216,19 @@ export function createDejitterRecorder(): DejitterAPI {
212
216
  });
213
217
  }
214
218
 
219
+ function startInteractionListeners(): void {
220
+ interactionAbort = new AbortController();
221
+ const opts: AddEventListenerOptions & { signal: AbortSignal } = { capture: true, signal: interactionAbort.signal };
222
+ const handler = (e: Event): void => {
223
+ if (!recording) return;
224
+ const t = Math.round(performance.now() - startTime);
225
+ interactions.push({ t, type: e.type });
226
+ };
227
+ for (const evt of ['click', 'pointerdown', 'keydown'] as const) {
228
+ document.addEventListener(evt, handler, opts);
229
+ }
230
+ }
231
+
215
232
  // --- Downsampling ---
216
233
 
217
234
  function downsample(): any[] {
@@ -778,6 +795,49 @@ export function createDejitterRecorder(): DejitterAPI {
778
795
  return findings;
779
796
  }
780
797
 
798
+ function detectLagFindings(elements: Record<string, any>): DejitterFinding[] {
799
+ const findings: DejitterFinding[] = [];
800
+ if (interactions.length === 0 || rawFrames.length === 0) return findings;
801
+
802
+ const lt = config.thresholds.lag;
803
+
804
+ for (const interaction of interactions) {
805
+ let firstFrame: { t: number; changes: any[] } | null = null;
806
+ for (const frame of rawFrames) {
807
+ if (frame.t > interaction.t) {
808
+ firstFrame = frame;
809
+ break;
810
+ }
811
+ }
812
+
813
+ if (!firstFrame) continue;
814
+
815
+ const delay = firstFrame.t - interaction.t;
816
+ if (delay < lt.minDelay) continue;
817
+
818
+ const severity = delay >= lt.highDelay ? 'high' : delay >= lt.medDelay ? 'medium' : 'low';
819
+
820
+ const firstChange = firstFrame.changes[0];
821
+ const label = (firstChange && elements[firstChange.id]) || { tag: '?', cls: '', text: '' };
822
+
823
+ findings.push(makeFinding(
824
+ 'lag', severity,
825
+ firstChange?.id || '?', label, interaction.type,
826
+ `${delay}ms between ${interaction.type} at t=${interaction.t}ms and first visual change at t=${firstFrame.t}ms`,
827
+ {
828
+ lag: {
829
+ interactionType: interaction.type,
830
+ interactionT: interaction.t,
831
+ firstChangeT: firstFrame.t,
832
+ delay,
833
+ },
834
+ }
835
+ ));
836
+ }
837
+
838
+ return findings;
839
+ }
840
+
781
841
  function deduplicateShivers(findings: DejitterFinding[]): DejitterFinding[] {
782
842
  const shiverFindings = findings.filter((f) => f.type === 'shiver');
783
843
  const otherFindings = findings.filter((f) => f.type !== 'shiver');
@@ -814,6 +874,7 @@ export function createDejitterRecorder(): DejitterAPI {
814
874
  findings = findings.concat(detectJumpFindings(propStats, elements));
815
875
  findings = findings.concat(detectStutterFindings(propStats, elements));
816
876
  findings = findings.concat(detectStuckFindings(propStats, elements));
877
+ findings = findings.concat(detectLagFindings(elements));
817
878
  findings = deduplicateShivers(findings);
818
879
 
819
880
  const sevOrder: Record<string, number> = { high: 0, medium: 1, low: 2, info: 3 };
@@ -842,6 +903,7 @@ export function createDejitterRecorder(): DejitterAPI {
842
903
  start() {
843
904
  rawFrames = [];
844
905
  mutations = [];
906
+ interactions = [];
845
907
  lastSeen = new Map();
846
908
  nextElemId = 0;
847
909
  recording = true;
@@ -851,6 +913,7 @@ export function createDejitterRecorder(): DejitterAPI {
851
913
  onStopCallbacks = [];
852
914
 
853
915
  startMutationObserver();
916
+ startInteractionListeners();
854
917
  rafId = requestAnimationFrame(loop);
855
918
 
856
919
  if (config.maxDuration > 0) {
@@ -865,6 +928,7 @@ export function createDejitterRecorder(): DejitterAPI {
865
928
  if (rafId) cancelAnimationFrame(rafId);
866
929
  if (stopTimer) clearTimeout(stopTimer);
867
930
  mutationObserver?.disconnect();
931
+ interactionAbort?.abort();
868
932
 
869
933
  const msg = `Stopped. ${rawFrames.length} raw frames, ${mutations.length} mutation events.`;
870
934
 
@@ -921,7 +985,7 @@ export function createDejitterRecorder(): DejitterAPI {
921
985
  },
922
986
 
923
987
  getRaw() {
924
- return { rawFrames, mutations };
988
+ return { rawFrames, mutations, interactions };
925
989
  },
926
990
 
927
991
  toJSON() {