@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,167 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { computeDiff, formatReport } from "./diff";
3
+ import type { ElementSnapshot } from "./types";
4
+
5
+ function snap(overrides: Partial<ElementSnapshot> = {}): ElementSnapshot {
6
+ return {
7
+ key: "1",
8
+ tagName: "div",
9
+ classes: [],
10
+ x: 0,
11
+ y: 0,
12
+ width: 100,
13
+ height: 100,
14
+ visible: true,
15
+ opacity: 1,
16
+ inViewport: true,
17
+ pointerEvents: "auto",
18
+ disabled: false,
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("computeDiff", () => {
24
+ test("empty snapshots produce empty report", () => {
25
+ const r = computeDiff([], []);
26
+ expect(r.entries).toHaveLength(0);
27
+ expect(r.counts).toEqual({ added: 0, removed: 0, changed: 0, moved: 0 });
28
+ });
29
+
30
+ test("identical snapshots produce no entries", () => {
31
+ const a = [snap({ key: "1" }), snap({ key: "2" })];
32
+ const b = [snap({ key: "1" }), snap({ key: "2" })];
33
+ const r = computeDiff(a, b);
34
+ expect(r.entries).toHaveLength(0);
35
+ });
36
+
37
+ test("+ for keys only in after (when visible)", () => {
38
+ const a = [snap({ key: "1" })];
39
+ const b = [snap({ key: "1" }), snap({ key: "2", id: "new" })];
40
+ const r = computeDiff(a, b);
41
+ expect(r.counts.added).toBe(1);
42
+ expect(r.entries[0]!.type).toBe("+");
43
+ expect(r.entries[0]!.key).toBe("2");
44
+ });
45
+
46
+ test("+ is skipped when newly-added element is invisible", () => {
47
+ const a: ElementSnapshot[] = [];
48
+ const b = [snap({ key: "2", visible: false })];
49
+ const r = computeDiff(a, b);
50
+ expect(r.counts.added).toBe(0);
51
+ });
52
+
53
+ test("- for keys only in before (when visible)", () => {
54
+ const a = [snap({ key: "1" }), snap({ key: "2" })];
55
+ const b = [snap({ key: "1" })];
56
+ const r = computeDiff(a, b);
57
+ expect(r.counts.removed).toBe(1);
58
+ expect(r.entries[0]!.type).toBe("-");
59
+ });
60
+
61
+ test("- when element becomes invisible in after", () => {
62
+ const a = [snap({ key: "1", visible: true })];
63
+ const b = [snap({ key: "1", visible: false })];
64
+ const r = computeDiff(a, b);
65
+ expect(r.counts.removed).toBe(1);
66
+ expect(r.entries[0]!.type).toBe("-");
67
+ });
68
+
69
+ test("~ when opacity changes by >= 0.05", () => {
70
+ const a = [snap({ key: "1", opacity: 1 })];
71
+ const b = [snap({ key: "1", opacity: 0.95 })];
72
+ const r = computeDiff(a, b);
73
+ expect(r.counts.changed).toBe(1);
74
+ expect(r.entries[0]!.type).toBe("~");
75
+ expect(r.entries[0]!.changedFields).toContain("opacity");
76
+ });
77
+
78
+ test("no entry when opacity changes by < 0.05", () => {
79
+ const a = [snap({ key: "1", opacity: 1 })];
80
+ const b = [snap({ key: "1", opacity: 0.97 })];
81
+ const r = computeDiff(a, b);
82
+ expect(r.entries).toHaveLength(0);
83
+ });
84
+
85
+ test("→ when only x/y change by >= 4px", () => {
86
+ const a = [snap({ key: "1", x: 0, y: 0 })];
87
+ const b = [snap({ key: "1", x: 10, y: 20 })];
88
+ const r = computeDiff(a, b);
89
+ expect(r.counts.moved).toBe(1);
90
+ expect(r.entries[0]!.type).toBe("→");
91
+ });
92
+
93
+ test("no entry when position changes by 3px (under threshold)", () => {
94
+ const a = [snap({ key: "1", x: 0, y: 0 })];
95
+ const b = [snap({ key: "1", x: 3, y: 3 })];
96
+ const r = computeDiff(a, b);
97
+ expect(r.entries).toHaveLength(0);
98
+ });
99
+
100
+ test("w/h: 2px delta triggers ~, 1px does not", () => {
101
+ const r1 = computeDiff(
102
+ [snap({ key: "1", width: 100 })],
103
+ [snap({ key: "1", width: 102 })]
104
+ );
105
+ expect(r1.counts.changed).toBe(1);
106
+ const r2 = computeDiff(
107
+ [snap({ key: "1", width: 100 })],
108
+ [snap({ key: "1", width: 101 })]
109
+ );
110
+ expect(r2.entries).toHaveLength(0);
111
+ });
112
+
113
+ test("mixed position + opacity change produces ~, not →", () => {
114
+ const a = [snap({ key: "1", x: 0, opacity: 1 })];
115
+ const b = [snap({ key: "1", x: 20, opacity: 0.5 })];
116
+ const r = computeDiff(a, b);
117
+ expect(r.counts.changed).toBe(1);
118
+ expect(r.counts.moved).toBe(0);
119
+ expect(r.entries[0]!.type).toBe("~");
120
+ expect(r.entries[0]!.changedFields).toContain("x");
121
+ expect(r.entries[0]!.changedFields).toContain("opacity");
122
+ });
123
+
124
+ test("text change is reported", () => {
125
+ const a = [snap({ key: "1", text: "Submit" })];
126
+ const b = [snap({ key: "1", text: "Submitting..." })];
127
+ const r = computeDiff(a, b);
128
+ expect(r.counts.changed).toBe(1);
129
+ expect(r.entries[0]!.changedFields).toContain("text");
130
+ });
131
+
132
+ test("class addition is reported", () => {
133
+ const a = [snap({ key: "1", classes: ["foo"] })];
134
+ const b = [snap({ key: "1", classes: ["foo", "active"] })];
135
+ const r = computeDiff(a, b);
136
+ expect(r.counts.changed).toBe(1);
137
+ expect(r.entries[0]!.changedFields).toContain("classes");
138
+ });
139
+
140
+ test("pointer-events change is reported", () => {
141
+ const a = [snap({ key: "1", pointerEvents: "auto" })];
142
+ const b = [snap({ key: "1", pointerEvents: "none" })];
143
+ const r = computeDiff(a, b);
144
+ expect(r.counts.changed).toBe(1);
145
+ expect(r.entries[0]!.changedFields).toContain("pointerEvents");
146
+ });
147
+ });
148
+
149
+ describe("formatReport", () => {
150
+ test("no-changes report contains header and divider", () => {
151
+ const out = formatReport([], { elapsedMs: 100, settle: "clean" });
152
+ expect(out).toContain("Visual diff");
153
+ expect(out).toContain("(no changes)");
154
+ });
155
+
156
+ test("summary line counts match entry types", () => {
157
+ const a = [snap({ key: "1" }), snap({ key: "2" })];
158
+ const b = [
159
+ snap({ key: "1", x: 20 }),
160
+ snap({ key: "3", id: "added" }),
161
+ ];
162
+ const r = computeDiff(a, b);
163
+ expect(r.text).toContain("1 added");
164
+ expect(r.text).toContain("1 removed");
165
+ expect(r.text).toContain("1 moved");
166
+ });
167
+ });
@@ -0,0 +1,242 @@
1
+ import type { DeltaEntry, DeltaReport, ElementSnapshot } from "./types";
2
+
3
+ export const POS_THRESHOLD = 4;
4
+ export const SIZE_THRESHOLD = 2;
5
+ export const OPACITY_THRESHOLD = 0.05;
6
+
7
+ function arrayEq(a: string[], b: string[]): boolean {
8
+ if (a.length !== b.length) return false;
9
+ const set = new Set(b);
10
+ for (const v of a) if (!set.has(v)) return false;
11
+ return true;
12
+ }
13
+
14
+ function labelFor(snap: ElementSnapshot): string {
15
+ let label = snap.tagName;
16
+ if (snap.id) label += "#" + snap.id;
17
+ if (snap.classes.length > 0) label += "." + snap.classes[0];
18
+ if (snap.text) {
19
+ const t = snap.text.length > 30 ? snap.text.slice(0, 30) + "…" : snap.text;
20
+ label += ` "${t}"`;
21
+ }
22
+ return label;
23
+ }
24
+
25
+ function comparePair(
26
+ before: ElementSnapshot,
27
+ after: ElementSnapshot
28
+ ): { changed: string[] } {
29
+ const changed: string[] = [];
30
+
31
+ if (Math.abs(after.x - before.x) >= POS_THRESHOLD) changed.push("x");
32
+ if (Math.abs(after.y - before.y) >= POS_THRESHOLD) changed.push("y");
33
+ if (Math.abs(after.width - before.width) >= SIZE_THRESHOLD) {
34
+ changed.push("width");
35
+ }
36
+ if (Math.abs(after.height - before.height) >= SIZE_THRESHOLD) {
37
+ changed.push("height");
38
+ }
39
+ if (Math.abs(after.opacity - before.opacity) >= OPACITY_THRESHOLD) {
40
+ changed.push("opacity");
41
+ }
42
+ if (after.text !== before.text) changed.push("text");
43
+ if (after.disabled !== before.disabled) changed.push("disabled");
44
+ if (after.pointerEvents !== before.pointerEvents) {
45
+ changed.push("pointerEvents");
46
+ }
47
+ if (!arrayEq(after.classes, before.classes)) changed.push("classes");
48
+ if (after.inViewport !== before.inViewport) changed.push("inViewport");
49
+
50
+ return { changed };
51
+ }
52
+
53
+ export function computeDiff(
54
+ before: ElementSnapshot[],
55
+ after: ElementSnapshot[]
56
+ ): DeltaReport {
57
+ const beforeMap = new Map<string, ElementSnapshot>();
58
+ const afterMap = new Map<string, ElementSnapshot>();
59
+ for (const s of before) beforeMap.set(s.key, s);
60
+ for (const s of after) afterMap.set(s.key, s);
61
+
62
+ const entries: DeltaEntry[] = [];
63
+ let added = 0;
64
+ let removed = 0;
65
+ let changed = 0;
66
+ let moved = 0;
67
+
68
+ for (const [key, afterSnap] of afterMap) {
69
+ const beforeSnap = beforeMap.get(key);
70
+ if (!beforeSnap) {
71
+ if (afterSnap.visible) {
72
+ entries.push({
73
+ type: "+",
74
+ key,
75
+ label: labelFor(afterSnap),
76
+ after: afterSnap,
77
+ });
78
+ added++;
79
+ }
80
+ continue;
81
+ }
82
+
83
+ if (beforeSnap.visible && !afterSnap.visible) {
84
+ entries.push({
85
+ type: "-",
86
+ key,
87
+ label: labelFor(beforeSnap),
88
+ before: beforeSnap,
89
+ });
90
+ removed++;
91
+ continue;
92
+ }
93
+
94
+ const { changed: changedFields } = comparePair(beforeSnap, afterSnap);
95
+ if (changedFields.length === 0) continue;
96
+
97
+ const onlyPosition =
98
+ changedFields.length > 0 &&
99
+ changedFields.every((f) => f === "x" || f === "y");
100
+
101
+ if (onlyPosition) {
102
+ entries.push({
103
+ type: "→",
104
+ key,
105
+ label: labelFor(afterSnap),
106
+ before: beforeSnap,
107
+ after: afterSnap,
108
+ changedFields,
109
+ });
110
+ moved++;
111
+ } else {
112
+ entries.push({
113
+ type: "~",
114
+ key,
115
+ label: labelFor(afterSnap),
116
+ before: beforeSnap,
117
+ after: afterSnap,
118
+ changedFields,
119
+ });
120
+ changed++;
121
+ }
122
+ }
123
+
124
+ for (const [key, beforeSnap] of beforeMap) {
125
+ if (afterMap.has(key)) continue;
126
+ if (!beforeSnap.visible) continue;
127
+ entries.push({
128
+ type: "-",
129
+ key,
130
+ label: labelFor(beforeSnap),
131
+ before: beforeSnap,
132
+ });
133
+ removed++;
134
+ }
135
+
136
+ const meta = { elapsedMs: 0, settle: "clean" as const };
137
+ const text = formatReport(entries, meta);
138
+
139
+ return {
140
+ elapsedMs: 0,
141
+ settle: "clean",
142
+ counts: { added, removed, changed, moved },
143
+ entries,
144
+ text,
145
+ };
146
+ }
147
+
148
+ function fmtNum(n: number): string {
149
+ return Math.round(n).toString();
150
+ }
151
+
152
+ function fieldDelta(
153
+ field: string,
154
+ before: ElementSnapshot,
155
+ after: ElementSnapshot
156
+ ): string {
157
+ switch (field) {
158
+ case "x":
159
+ return `x ${fmtNum(before.x)}→${fmtNum(after.x)}`;
160
+ case "y":
161
+ return `y ${fmtNum(before.y)}→${fmtNum(after.y)}`;
162
+ case "width":
163
+ return `w ${fmtNum(before.width)}→${fmtNum(after.width)}`;
164
+ case "height":
165
+ return `h ${fmtNum(before.height)}→${fmtNum(after.height)}`;
166
+ case "opacity":
167
+ return `opacity ${before.opacity.toFixed(2)}→${after.opacity.toFixed(2)}`;
168
+ case "text":
169
+ return `text "${before.text ?? ""}"→"${after.text ?? ""}"`;
170
+ case "disabled":
171
+ return `disabled ${before.disabled}→${after.disabled}`;
172
+ case "pointerEvents":
173
+ return `pointer-events ${before.pointerEvents}→${after.pointerEvents}`;
174
+ case "classes":
175
+ return `classes [${before.classes.join(" ")}]→[${after.classes.join(" ")}]`;
176
+ case "inViewport":
177
+ return `inViewport ${before.inViewport}→${after.inViewport}`;
178
+ default:
179
+ return field;
180
+ }
181
+ }
182
+
183
+ export function formatReport(
184
+ entries: DeltaEntry[],
185
+ meta: { elapsedMs: number; settle: "clean" | "timeout" }
186
+ ): string {
187
+ const header = `Visual diff (${Math.round(meta.elapsedMs)}ms, settle: ${meta.settle})`;
188
+ const divider = "────────────────────────────────";
189
+
190
+ if (entries.length === 0) {
191
+ return `${header}\n${divider}\n(no changes)\n${divider}`;
192
+ }
193
+
194
+ const lines: string[] = [header, divider];
195
+
196
+ let added = 0;
197
+ let removed = 0;
198
+ let changed = 0;
199
+ let moved = 0;
200
+
201
+ for (const entry of entries) {
202
+ switch (entry.type) {
203
+ case "+": {
204
+ added++;
205
+ const s = entry.after!;
206
+ lines.push(
207
+ `+ ${entry.label} (${fmtNum(s.x)},${fmtNum(s.y)}) ${fmtNum(s.width)}×${fmtNum(s.height)}`
208
+ );
209
+ break;
210
+ }
211
+ case "-": {
212
+ removed++;
213
+ lines.push(`- ${entry.label}`);
214
+ break;
215
+ }
216
+ case "~": {
217
+ changed++;
218
+ const deltas = (entry.changedFields ?? [])
219
+ .map((f) => fieldDelta(f, entry.before!, entry.after!))
220
+ .join(", ");
221
+ lines.push(`~ ${entry.label}: ${deltas}`);
222
+ break;
223
+ }
224
+ case "→": {
225
+ moved++;
226
+ const b = entry.before!;
227
+ const a = entry.after!;
228
+ lines.push(
229
+ `→ ${entry.label}: (${fmtNum(b.x)},${fmtNum(b.y)})→(${fmtNum(a.x)},${fmtNum(a.y)})`
230
+ );
231
+ break;
232
+ }
233
+ }
234
+ }
235
+
236
+ lines.push(divider);
237
+ lines.push(
238
+ `${entries.length} changes: ${added} added, ${removed} removed, ${changed} changed, ${moved} moved`
239
+ );
240
+
241
+ return lines.join("\n");
242
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test, beforeEach, afterEach, vi } from "vitest";
2
+ import { waitForSettle } from "./settle";
3
+
4
+ describe("waitForSettle", () => {
5
+ beforeEach(() => {
6
+ document.body.innerHTML = "";
7
+ if (typeof (document as any).getAnimations !== "function") {
8
+ (document as any).getAnimations = () => [];
9
+ }
10
+ });
11
+
12
+ afterEach(() => {
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ test("returns 'clean' when no mutations and no animations", async () => {
17
+ const result = await waitForSettle(500);
18
+ expect(result).toBe("clean");
19
+ });
20
+
21
+ test("returns 'timeout' when mutations keep firing past the deadline", async () => {
22
+ const interval = setInterval(() => {
23
+ const d = document.createElement("div");
24
+ document.body.appendChild(d);
25
+ setTimeout(() => d.remove(), 0);
26
+ }, 20);
27
+
28
+ const result = await waitForSettle(400);
29
+ clearInterval(interval);
30
+ expect(result).toBe("timeout");
31
+ });
32
+
33
+ test("settles after mutations stop", async () => {
34
+ const start = performance.now();
35
+ let mutationCount = 0;
36
+ const interval = setInterval(() => {
37
+ if (mutationCount >= 3) {
38
+ clearInterval(interval);
39
+ return;
40
+ }
41
+ const d = document.createElement("div");
42
+ document.body.appendChild(d);
43
+ mutationCount++;
44
+ }, 30);
45
+
46
+ const result = await waitForSettle(2000);
47
+ const elapsed = performance.now() - start;
48
+ expect(result).toBe("clean");
49
+ expect(elapsed).toBeLessThan(2000);
50
+ });
51
+
52
+ test("ignores mutations outside the provided root", async () => {
53
+ const root = document.createElement("div");
54
+ document.body.appendChild(root);
55
+
56
+ const interval = setInterval(() => {
57
+ const d = document.createElement("div");
58
+ document.body.appendChild(d);
59
+ setTimeout(() => d.remove(), 0);
60
+ }, 20);
61
+
62
+ const result = await waitForSettle(500, root);
63
+ clearInterval(interval);
64
+ expect(result).toBe("clean");
65
+ });
66
+
67
+ test("treats animations as non-idle", async () => {
68
+ const fakeAnim = { playState: "running" } as unknown as Animation;
69
+ const spy = vi
70
+ .spyOn(document, "getAnimations")
71
+ .mockReturnValue([fakeAnim]);
72
+
73
+ const result = await waitForSettle(300);
74
+ expect(result).toBe("timeout");
75
+ spy.mockRestore();
76
+ });
77
+ });
@@ -0,0 +1,62 @@
1
+ export const DEFAULT_SETTLE_TIMEOUT_MS = 3000;
2
+ export const MUTATION_SILENCE_MS = 150;
3
+
4
+ function nextFrame(): Promise<void> {
5
+ return new Promise((resolve) => {
6
+ if (typeof requestAnimationFrame === "function") {
7
+ requestAnimationFrame(() => resolve());
8
+ } else {
9
+ setTimeout(() => resolve(), 16);
10
+ }
11
+ });
12
+ }
13
+
14
+ function animationsRunning(): boolean {
15
+ if (
16
+ typeof document === "undefined" ||
17
+ typeof (document as Document).getAnimations !== "function"
18
+ ) {
19
+ return false;
20
+ }
21
+ const anims = (document as Document).getAnimations();
22
+ for (const a of anims) {
23
+ if (a.playState === "running") return true;
24
+ }
25
+ return false;
26
+ }
27
+
28
+ export async function waitForSettle(
29
+ timeoutMs: number = DEFAULT_SETTLE_TIMEOUT_MS,
30
+ root?: Node
31
+ ): Promise<"clean" | "timeout"> {
32
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") {
33
+ return "clean";
34
+ }
35
+
36
+ const observeTarget = root ?? document.documentElement;
37
+ const deadline = performance.now() + timeoutMs;
38
+ let lastMutation = performance.now();
39
+
40
+ const mo = new MutationObserver(() => {
41
+ lastMutation = performance.now();
42
+ });
43
+ mo.observe(observeTarget, {
44
+ childList: true,
45
+ subtree: true,
46
+ attributes: true,
47
+ characterData: true,
48
+ });
49
+
50
+ try {
51
+ while (performance.now() < deadline) {
52
+ const sinceMutation = performance.now() - lastMutation;
53
+ if (!animationsRunning() && sinceMutation >= MUTATION_SILENCE_MS) {
54
+ return "clean";
55
+ }
56
+ await nextFrame();
57
+ }
58
+ return "timeout";
59
+ } finally {
60
+ mo.disconnect();
61
+ }
62
+ }