@michaelmagan/dnumb 0.1.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 (133) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +297 -0
  4. package/dist/analysis/cluster.cjs +114 -0
  5. package/dist/analysis/cluster.d.cts +68 -0
  6. package/dist/analysis/cluster.d.ts +68 -0
  7. package/dist/analysis/cluster.js +109 -0
  8. package/dist/analysis/distances.cjs +124 -0
  9. package/dist/analysis/distances.d.cts +58 -0
  10. package/dist/analysis/distances.d.ts +58 -0
  11. package/dist/analysis/distances.js +119 -0
  12. package/dist/analysis/geometry.cjs +60 -0
  13. package/dist/analysis/geometry.d.cts +34 -0
  14. package/dist/analysis/geometry.d.ts +34 -0
  15. package/dist/analysis/geometry.js +53 -0
  16. package/dist/analysis/layout-tree.cjs +245 -0
  17. package/dist/analysis/layout-tree.d.cts +45 -0
  18. package/dist/analysis/layout-tree.d.ts +45 -0
  19. package/dist/analysis/layout-tree.js +242 -0
  20. package/dist/analysis/region-query.cjs +71 -0
  21. package/dist/analysis/region-query.d.cts +25 -0
  22. package/dist/analysis/region-query.d.ts +25 -0
  23. package/dist/analysis/region-query.js +67 -0
  24. package/dist/analysis/types.cjs +7 -0
  25. package/dist/analysis/types.d.cts +143 -0
  26. package/dist/analysis/types.d.ts +143 -0
  27. package/dist/analysis/types.js +6 -0
  28. package/dist/bundle.cjs +182 -0
  29. package/dist/bundle.d.cts +91 -0
  30. package/dist/bundle.d.ts +91 -0
  31. package/dist/bundle.js +175 -0
  32. package/dist/cli.cjs +755 -0
  33. package/dist/cli.d.cts +2 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +719 -0
  36. package/dist/diff/changes.cjs +656 -0
  37. package/dist/diff/changes.d.cts +2 -0
  38. package/dist/diff/changes.d.ts +2 -0
  39. package/dist/diff/changes.js +653 -0
  40. package/dist/diff/identity.cjs +167 -0
  41. package/dist/diff/identity.d.cts +62 -0
  42. package/dist/diff/identity.d.ts +62 -0
  43. package/dist/diff/identity.js +159 -0
  44. package/dist/diff/index.cjs +84 -0
  45. package/dist/diff/index.d.cts +76 -0
  46. package/dist/diff/index.d.ts +76 -0
  47. package/dist/diff/index.js +68 -0
  48. package/dist/diff/match.cjs +156 -0
  49. package/dist/diff/match.d.cts +17 -0
  50. package/dist/diff/match.d.ts +17 -0
  51. package/dist/diff/match.js +152 -0
  52. package/dist/diff/render.cjs +540 -0
  53. package/dist/diff/render.d.cts +2 -0
  54. package/dist/diff/render.d.ts +2 -0
  55. package/dist/diff/render.js +537 -0
  56. package/dist/diff/types.cjs +2 -0
  57. package/dist/diff/types.d.cts +211 -0
  58. package/dist/diff/types.d.ts +211 -0
  59. package/dist/diff/types.js +1 -0
  60. package/dist/digest/index.cjs +8 -0
  61. package/dist/digest/index.d.cts +4 -0
  62. package/dist/digest/index.d.ts +4 -0
  63. package/dist/digest/index.js +2 -0
  64. package/dist/digest/parse.cjs +163 -0
  65. package/dist/digest/parse.d.cts +80 -0
  66. package/dist/digest/parse.d.ts +80 -0
  67. package/dist/digest/parse.js +160 -0
  68. package/dist/digest/render.cjs +1575 -0
  69. package/dist/digest/render.d.cts +41 -0
  70. package/dist/digest/render.d.ts +41 -0
  71. package/dist/digest/render.js +1571 -0
  72. package/dist/digest/types.cjs +10 -0
  73. package/dist/digest/types.d.cts +86 -0
  74. package/dist/digest/types.d.ts +86 -0
  75. package/dist/digest/types.js +9 -0
  76. package/dist/index.cjs +61 -0
  77. package/dist/index.d.cts +27 -0
  78. package/dist/index.d.ts +27 -0
  79. package/dist/index.js +19 -0
  80. package/dist/page.cjs +35 -0
  81. package/dist/page.d.cts +40 -0
  82. package/dist/page.d.ts +40 -0
  83. package/dist/page.js +31 -0
  84. package/dist/playwright.cjs +138 -0
  85. package/dist/playwright.d.cts +69 -0
  86. package/dist/playwright.d.ts +69 -0
  87. package/dist/playwright.js +131 -0
  88. package/dist/query.cjs +1152 -0
  89. package/dist/query.d.cts +47 -0
  90. package/dist/query.d.ts +47 -0
  91. package/dist/query.js +1146 -0
  92. package/dist/snapshot/capture.cjs +80 -0
  93. package/dist/snapshot/capture.d.cts +34 -0
  94. package/dist/snapshot/capture.d.ts +34 -0
  95. package/dist/snapshot/capture.js +77 -0
  96. package/dist/snapshot/cdp.cjs +450 -0
  97. package/dist/snapshot/cdp.d.cts +36 -0
  98. package/dist/snapshot/cdp.d.ts +36 -0
  99. package/dist/snapshot/cdp.js +447 -0
  100. package/dist/snapshot/clip.cjs +100 -0
  101. package/dist/snapshot/clip.d.cts +26 -0
  102. package/dist/snapshot/clip.d.ts +26 -0
  103. package/dist/snapshot/clip.js +96 -0
  104. package/dist/snapshot/collect.cjs +358 -0
  105. package/dist/snapshot/collect.d.cts +18 -0
  106. package/dist/snapshot/collect.d.ts +18 -0
  107. package/dist/snapshot/collect.js +355 -0
  108. package/dist/snapshot/color.cjs +1039 -0
  109. package/dist/snapshot/color.d.cts +316 -0
  110. package/dist/snapshot/color.d.ts +316 -0
  111. package/dist/snapshot/color.js +1026 -0
  112. package/dist/snapshot/facts.cjs +167 -0
  113. package/dist/snapshot/facts.d.cts +48 -0
  114. package/dist/snapshot/facts.d.ts +48 -0
  115. package/dist/snapshot/facts.js +161 -0
  116. package/dist/snapshot/limits.cjs +69 -0
  117. package/dist/snapshot/limits.d.cts +59 -0
  118. package/dist/snapshot/limits.d.ts +59 -0
  119. package/dist/snapshot/limits.js +65 -0
  120. package/dist/snapshot/not-measured.cjs +44 -0
  121. package/dist/snapshot/not-measured.d.cts +14 -0
  122. package/dist/snapshot/not-measured.d.ts +14 -0
  123. package/dist/snapshot/not-measured.js +41 -0
  124. package/dist/snapshot/types.cjs +2 -0
  125. package/dist/snapshot/types.d.cts +238 -0
  126. package/dist/snapshot/types.d.ts +238 -0
  127. package/dist/snapshot/types.js +1 -0
  128. package/dist/spec/index.cjs +9 -0
  129. package/dist/spec/index.d.cts +15 -0
  130. package/dist/spec/index.d.ts +15 -0
  131. package/dist/spec/index.js +6 -0
  132. package/package.json +141 -0
  133. package/skills/dnumb/SKILL.md +136 -0
@@ -0,0 +1,653 @@
1
+ const GEOMETRIC_STYLES = [
2
+ 'display',
3
+ 'position',
4
+ 'fontSize',
5
+ 'fontWeight',
6
+ 'flexDirection',
7
+ 'gridTemplateColumns',
8
+ 'textAlign',
9
+ ];
10
+ const BOX_SIDE_STYLES = ['padding', 'margin', 'border'];
11
+ const PAINT_STYLES = ['color', 'backgroundColor'];
12
+ const INHERITED_PAINT = new Set(['color']);
13
+ const isInheritedPaint = (prop) => prop !== undefined && INHERITED_PAINT.has(prop);
14
+ /** Layout arithmetic produces sub-pixel differences constantly. */
15
+ const BOX_TOLERANCE = 0.5;
16
+ /**
17
+ * browser-use's `DYNAMIC_CLASS_PATTERNS`, put to the job step 1 assigned them.
18
+ * They are useless for matching — dnumb drops `class` from identity entirely —
19
+ * but a class list that only gained or lost these tokens is reporting the state
20
+ * the element is in, not a change to the page, and printing it beside a
21
+ * structural change costs one of fifteen lines to say `.is-hover` appeared.
22
+ */
23
+ const DYNAMIC_CLASS_TOKENS = [
24
+ 'focus',
25
+ 'hover',
26
+ 'active',
27
+ 'selected',
28
+ 'disabled',
29
+ 'animation',
30
+ 'transition',
31
+ 'loading',
32
+ 'open',
33
+ 'closed',
34
+ 'expanded',
35
+ 'collapsed',
36
+ 'visible',
37
+ 'hidden',
38
+ 'pressed',
39
+ 'checked',
40
+ 'highlighted',
41
+ 'current',
42
+ 'entering',
43
+ 'leaving',
44
+ ];
45
+ const sides = (s) => `${s.top},${s.right},${s.bottom},${s.left}`;
46
+ const clipKey = (c) => (c ? `${c.by}|${sides(c.hidden)}` : '');
47
+ const boxDelta = (b, a) => ({
48
+ dx: a.rect.x - b.rect.x,
49
+ dy: a.rect.y - b.rect.y,
50
+ dw: a.rect.width - b.rect.width,
51
+ dh: a.rect.height - b.rect.height,
52
+ });
53
+ const moved = (d) => Math.abs(d.dx) > BOX_TOLERANCE || Math.abs(d.dy) > BOX_TOLERANCE;
54
+ const resized = (d) => Math.abs(d.dw) > BOX_TOLERANCE || Math.abs(d.dh) > BOX_TOLERANCE;
55
+ const near = (x, y) => Math.abs(x - y) <= BOX_TOLERANCE;
56
+ /** The class tokens present on one side and not the other, in both directions. */
57
+ function classDelta(beforeClass, afterClass) {
58
+ const b = new Set(beforeClass.split(/\s+/).filter((t) => t !== ''));
59
+ const a = new Set(afterClass.split(/\s+/).filter((t) => t !== ''));
60
+ const out = [];
61
+ for (const token of b)
62
+ if (!a.has(token))
63
+ out.push(token);
64
+ for (const token of a)
65
+ if (!b.has(token))
66
+ out.push(token);
67
+ return out;
68
+ }
69
+ const isDynamicClass = (token) => {
70
+ const lower = token.toLowerCase();
71
+ return DYNAMIC_CLASS_TOKENS.some((pattern) => lower.includes(pattern));
72
+ };
73
+ export function computeChanges(before, after, matched) {
74
+ const changes = [];
75
+ const byAfterId = new Map();
76
+ const record = (c) => {
77
+ changes.push(c);
78
+ if (c.afterId) {
79
+ const list = byAfterId.get(c.afterId);
80
+ if (list)
81
+ list.push(c);
82
+ else
83
+ byAfterId.set(c.afterId, [c]);
84
+ }
85
+ return c;
86
+ };
87
+ for (const el of before.elements) {
88
+ if (!matched.pairs.has(el.id))
89
+ record({ kind: 'removed', beforeId: el.id, before: el });
90
+ }
91
+ for (const el of after.elements) {
92
+ if (!matched.reverse.has(el.id))
93
+ record({ kind: 'added', afterId: el.id, after: el });
94
+ }
95
+ for (const [bid, aid] of matched.pairs) {
96
+ const b = before.byId.get(bid);
97
+ const a = after.byId.get(aid);
98
+ if (!b || !a)
99
+ continue;
100
+ const pair = { afterId: aid, beforeId: bid, before: b, after: a };
101
+ if (b.visible !== a.visible) {
102
+ record({
103
+ kind: a.visible ? 'appeared' : 'disappeared',
104
+ ...pair,
105
+ reason: (a.visible ? b : a).display === 'none' ? 'display:none' : 'hidden',
106
+ });
107
+ }
108
+ if (b.text !== a.text)
109
+ record({ kind: 'text', ...pair });
110
+ for (const prop of GEOMETRIC_STYLES) {
111
+ if (b[prop] !== a[prop])
112
+ record({ kind: 'style', geometric: true, prop, ...pair });
113
+ }
114
+ for (const prop of PAINT_STYLES) {
115
+ if (b[prop] !== a[prop])
116
+ record({ kind: 'style', geometric: false, prop, ...pair });
117
+ }
118
+ for (const prop of BOX_SIDE_STYLES) {
119
+ if (sides(b[prop]) !== sides(a[prop])) {
120
+ record({ kind: 'style', geometric: true, prop, ...pair });
121
+ }
122
+ }
123
+ if (b.gap.row !== a.gap.row || b.gap.column !== a.gap.column) {
124
+ record({ kind: 'style', geometric: true, prop: 'gap', ...pair });
125
+ }
126
+ if (b.className !== a.className) {
127
+ const toggled = classDelta(b.className, a.className);
128
+ // A reordered or re-spaced class list is the same class list, and a style
129
+ // line for it would describe nothing the page can show.
130
+ if (toggled.length > 0) {
131
+ // Geometric in both cases, because a class swap can restyle anything.
132
+ // Calling a state toggle non-geometric would send rule 4 hunting for an
133
+ // ancestor to blame for a box this element moved by itself, which
134
+ // invents a causal claim the measurements do not support.
135
+ const c = record({ kind: 'style', geometric: true, prop: 'className', ...pair });
136
+ // Demoted, not attributed: there is no other measurement to point at,
137
+ // so this carries no `via` — the element is its own cause.
138
+ if (toggled.every(isDynamicClass))
139
+ c.cause = 'derived';
140
+ }
141
+ }
142
+ if (b.ariaExpanded !== a.ariaExpanded) {
143
+ record({ kind: 'attr', prop: 'aria-expanded', ...pair });
144
+ }
145
+ const bOver = b.scrollWidth - b.clientWidth > 1;
146
+ const aOver = a.scrollWidth - a.clientWidth > 1;
147
+ if (bOver !== aOver)
148
+ record({ kind: 'overflow', ...pair, now: aOver });
149
+ if (clipKey(b.clip) !== clipKey(a.clip))
150
+ record({ kind: 'clip', ...pair });
151
+ const d = boxDelta(b, a);
152
+ if (moved(d) || resized(d))
153
+ record({ kind: 'box', ...pair, delta: d });
154
+ }
155
+ // Newly added or newly visible elements have no before-clip to compare to, so
156
+ // their clip is reported on its own. Such a change carries no `before`, which
157
+ // is how a renderer tells it from a clip that changed.
158
+ for (const c of [...changes]) {
159
+ if (c.kind !== 'added' && c.kind !== 'appeared')
160
+ continue;
161
+ if (!c.afterId || !c.after?.clip)
162
+ continue;
163
+ // An element that was matched *and* became visible already produced a clip
164
+ // change above, because its before-clip differed. Don't say it twice.
165
+ if ((byAfterId.get(c.afterId) ?? []).some((o) => o.kind === 'clip'))
166
+ continue;
167
+ record({ kind: 'clip', afterId: c.afterId, after: c.after });
168
+ }
169
+ markBoundaryAmbiguous(before, after, matched, changes);
170
+ subsume(before, after, matched, changes, byAfterId);
171
+ return changes;
172
+ }
173
+ /**
174
+ * Tell a genuine removal/addition apart from an artefact of the element cap.
175
+ *
176
+ * The precondition is per-kind, not "both captures truncated" — that was the
177
+ * bug (F1 in the fabrication review). A `removed` change is a before-element
178
+ * with no after-counterpart; the only way that counterpart could exist but be
179
+ * unreachable is if the *after* capture is a window, because a complete after
180
+ * capture would have found it wherever it moved. Whether the *before* capture
181
+ * is also a window is irrelevant to that question — it already produced the
182
+ * element in hand. Requiring both sides truncated silently dropped the
183
+ * ambiguity flag (and printed a bare fabricated `-`) whenever the complete
184
+ * side happened to be the before side: a complete 4,900-element before and a
185
+ * truncated 5,000-element after, with 200 elements inserted at the top,
186
+ * pushes the last 100 before-elements out of the after window while nothing
187
+ * was actually removed — and the old "both truncated" gate never even looked,
188
+ * because `before.truncated` was `false`. The `added` case mirrors this in
189
+ * the other direction and needs `before.truncated`, not `after.truncated`.
190
+ *
191
+ * Within that regime, "near the boundary" is not a fixed margin. It is
192
+ * measured: for an unmatched before-element, the matcher already recorded how
193
+ * far document order shifted for every pair it *did* match. The nearest
194
+ * matched neighbour's shift is the best available estimate of how far this
195
+ * element's live counterpart also moved — add that shift to its own
196
+ * before-index, and if the result falls at or past where the after capture's
197
+ * window ends, the two explanations ("removed" and "pushed past the cap")
198
+ * are indistinguishable from the evidence in hand. A genuine removal in the
199
+ * middle of an otherwise-stable page has a local shift of zero and is never
200
+ * flagged; only a removal whose neighbourhood demonstrably moved enough to
201
+ * explain it away is. The mirror case (`added`) runs the same arithmetic in
202
+ * the other direction: a "new" element near the after-capture's tail may have
203
+ * existed all along, just past where the before capture's window ended.
204
+ *
205
+ * The shift itself has to have a sign consistent with the story being told
206
+ * (F3 in the review): "pushed past the cap by insertions" requires a
207
+ * *positive* local shift — elements measurably moving later — and "pulled
208
+ * into the window by removals" requires a *negative* one. A shift of exactly
209
+ * zero is evidence that nothing moved nearby at all, so it cannot be blamed
210
+ * for either; before this guard, two captures whose element counts merely
211
+ * differed (for a reason having nothing to do with a nearby insertion) could
212
+ * satisfy `bi + 0 >= after.elements.length` and fabricate an "insertions
213
+ * earlier in the document" explanation with zero supporting evidence.
214
+ */
215
+ function markBoundaryAmbiguous(before, after, matched, changes) {
216
+ // Neither branch below can ever fire unless at least one side is a window,
217
+ // so this is a fast path, not a correctness gate — each branch re-checks
218
+ // the specific side it actually depends on.
219
+ if (!before.truncated && !after.truncated)
220
+ return;
221
+ // One shift sample per matched pair, indexed by each side's own document
222
+ // order so the nearest-neighbour lookup works in either direction.
223
+ const byBeforeIndex = [];
224
+ const byAfterIndex = [];
225
+ for (const [bid, aid] of matched.pairs) {
226
+ const bi = before.docIndex.get(bid);
227
+ const ai = after.docIndex.get(aid);
228
+ if (bi === undefined || ai === undefined)
229
+ continue;
230
+ const shift = ai - bi;
231
+ byBeforeIndex.push({ index: bi, shift });
232
+ byAfterIndex.push({ index: ai, shift });
233
+ }
234
+ byBeforeIndex.sort((x, y) => x.index - y.index);
235
+ byAfterIndex.sort((x, y) => x.index - y.index);
236
+ /** The shift of the nearest sample strictly before `index`, via binary
237
+ * search over the sorted array; `null` when nothing matched before it. */
238
+ const shiftBefore = (samples, index) => {
239
+ let lo = 0;
240
+ let hi = samples.length - 1;
241
+ let found = null;
242
+ while (lo <= hi) {
243
+ const mid = (lo + hi) >> 1;
244
+ const sample = samples[mid];
245
+ if (sample && sample.index < index) {
246
+ found = sample.shift;
247
+ lo = mid + 1;
248
+ }
249
+ else {
250
+ hi = mid - 1;
251
+ }
252
+ }
253
+ return found;
254
+ };
255
+ for (const c of changes) {
256
+ if (c.kind === 'removed' && c.beforeId) {
257
+ // Only the after capture's window can explain a removed element being
258
+ // unreachable — see the doc comment above.
259
+ if (!after.truncated)
260
+ continue;
261
+ const bi = before.docIndex.get(c.beforeId);
262
+ if (bi === undefined)
263
+ continue;
264
+ const shift = shiftBefore(byBeforeIndex, bi);
265
+ // `shift > 0`: only a measured positive shift nearby is evidence of the
266
+ // "pushed past the cap by insertions" story this line tells (F3).
267
+ if (shift !== null && shift > 0 && bi + shift >= after.elements.length) {
268
+ c.boundaryAmbiguous = true;
269
+ c.capSize = after.elements.length;
270
+ }
271
+ }
272
+ else if (c.kind === 'added' && c.afterId) {
273
+ // Only the before capture's window can explain an added element having
274
+ // been there all along — see the doc comment above.
275
+ if (!before.truncated)
276
+ continue;
277
+ const ai = after.docIndex.get(c.afterId);
278
+ if (ai === undefined)
279
+ continue;
280
+ const shift = shiftBefore(byAfterIndex, ai);
281
+ // `shift < 0`: only a measured negative shift nearby is evidence of the
282
+ // "pulled into the window by removals" story this line tells (F3).
283
+ if (shift !== null && shift < 0 && ai - shift >= before.elements.length) {
284
+ c.boundaryAmbiguous = true;
285
+ c.capSize = before.elements.length;
286
+ }
287
+ }
288
+ }
289
+ }
290
+ /**
291
+ * Mark every change `primary`, `derived` (printed, folded, cause named) or
292
+ * `subsumed` (not printed — fully restated by another line).
293
+ */
294
+ function subsume(before, after, matched, changes, byAfterId) {
295
+ const of = (id) => (id ? byAfterId.get(id) : undefined) ?? [];
296
+ // Excludes a boundary-ambiguous match: "a sibling was inserted" is not
297
+ // evidence this box was pushed when the insertion itself is only maybe.
298
+ const kindOn = (id, kind) => of(id).some((c) => c.kind === kind && !c.boundaryAmbiguous);
299
+ // Memoised because rule 4 asks for the same chains once per candidate pair,
300
+ // which is quadratic in the number of moved boxes on a reflow.
301
+ const ancestorCache = new Map();
302
+ const ancestors = (id) => {
303
+ if (!id)
304
+ return [];
305
+ const hit = ancestorCache.get(id);
306
+ if (hit)
307
+ return hit;
308
+ const out = [];
309
+ const seen = new Set([id]);
310
+ let cursor = after.byId.get(id)?.parentId ?? null;
311
+ while (cursor && !seen.has(cursor)) {
312
+ seen.add(cursor);
313
+ out.push(cursor);
314
+ cursor = after.byId.get(cursor)?.parentId ?? null;
315
+ }
316
+ ancestorCache.set(id, out);
317
+ return out;
318
+ };
319
+ // --- 1. Visibility restates its own box and its own styles ----------------
320
+ // A display:none element has a 0x0 box at the origin. "Moved from @0,0" is an
321
+ // artefact of that, not an observation, and `display: none → block` is the
322
+ // same sentence as "appeared" in CSS.
323
+ //
324
+ // Every *other* style goes with them, and that is not an aesthetic call. An
325
+ // element outside the layout has no resolved style to compare against: the
326
+ // CDP path returns no layout row for it at all, so `font-size`, `color`,
327
+ // `border` and the rest read as `'' → 14px`. Those are first observations of
328
+ // a thing that was never rendered, not changes to it, and printing them
329
+ // states as fact that a colour changed when nothing of the sort happened.
330
+ //
331
+ // Left in, a menu opening produced twelve lines where the cold-read version
332
+ // produced three, and eleven of the twelve were this.
333
+ for (const c of changes) {
334
+ if (c.kind !== 'appeared' && c.kind !== 'disappeared')
335
+ continue;
336
+ for (const other of of(c.afterId)) {
337
+ if (other === c)
338
+ continue;
339
+ if (other.kind === 'box' || other.kind === 'style')
340
+ other.subsumed = 'visibility';
341
+ }
342
+ }
343
+ // --- 2. A whole subtree arriving or leaving is one event ------------------
344
+ // A boundary-ambiguous arrival is never confirmed enough to be the *top* of
345
+ // an entering subtree — folding a real, matched descendant under a maybe
346
+ // would hide it behind uncertainty it does not share. It is equally never
347
+ // confirmed enough to be silently folded *as* someone else's descendant
348
+ // (B3): a boundary-ambiguous element's own presence is only maybe, and its
349
+ // `?` line (`render.ts`'s `boundaryLine`) is the one place that says so.
350
+ // Subsuming it — `c.subsumed = 'subtree'` drops it from `live` in
351
+ // `render.ts` before that line is ever built — erases the ambiguity from
352
+ // the text output entirely (it survives only in `--json`) while the header
353
+ // still tallies it as a confirmed arrival or departure. Both fold loops
354
+ // below skip a boundary-ambiguous `c` for this reason, not just a
355
+ // boundary-ambiguous *top*.
356
+ const entering = new Set();
357
+ for (const c of changes) {
358
+ if ((c.kind === 'added' || c.kind === 'appeared') && c.afterId && !c.boundaryAmbiguous) {
359
+ entering.add(c.afterId);
360
+ }
361
+ }
362
+ for (const c of changes) {
363
+ if (c.kind !== 'added' && c.kind !== 'appeared')
364
+ continue;
365
+ if (c.boundaryAmbiguous)
366
+ continue;
367
+ const top = ancestors(c.afterId).find((a) => entering.has(a));
368
+ if (!top)
369
+ continue;
370
+ c.subsumed = 'subtree';
371
+ c.subsumedBy = top;
372
+ for (const other of of(c.afterId))
373
+ other.subsumed ??= 'subtree';
374
+ }
375
+ // `disappeared` belongs beside `removed` here (B11), not just `appeared`
376
+ // beside `added` above. A subtree that leaves via `display:none` never
377
+ // produces `removed` changes for its descendants — every element under it
378
+ // is still in the DOM, still matched, and each one's own `visible` flips
379
+ // to `false` right along with its ancestor's, so each gets its own
380
+ // `disappeared` change. Omitting that kind here meant a departing
381
+ // display:none subtree folded its top element but never its children:
382
+ // one dropdown toggle produced an asymmetric "1 added / 10 folded" opening
383
+ // (fine — `appeared` was already included) against "5 removed / 6 folded"
384
+ // closing, for what is the same act run in reverse, and a large dropdown
385
+ // closing could consume the entire line budget with descendant lines the
386
+ // opening never had to pay for.
387
+ const departing = new Set();
388
+ for (const c of changes) {
389
+ if ((c.kind === 'removed' || c.kind === 'disappeared') && c.beforeId && !c.boundaryAmbiguous) {
390
+ departing.add(c.beforeId);
391
+ }
392
+ }
393
+ for (const c of changes) {
394
+ if (c.kind !== 'removed' && c.kind !== 'disappeared')
395
+ continue;
396
+ if (c.boundaryAmbiguous)
397
+ continue;
398
+ let cursor = c.before?.parentId ?? null;
399
+ while (cursor) {
400
+ if (departing.has(cursor)) {
401
+ c.subsumed = 'subtree';
402
+ c.subsumedBy = cursor;
403
+ break;
404
+ }
405
+ cursor = before.byId.get(cursor)?.parentId ?? null;
406
+ }
407
+ }
408
+ // Two maps, not one. Element ids are unique within a capture and meaningless
409
+ // across captures, so counting arrivals and departures together lets `e5` in
410
+ // the before-tree hand its tally to an unrelated `e5` in the after-tree — the
411
+ // spike printed "+3 inside" on the wrong element for exactly that reason.
412
+ // `disappeared` folds under a before-tree id exactly like `removed` does
413
+ // (both loops above walk `c.before?.parentId` and key `departing` by
414
+ // `beforeId`), so it routes into the same `foldedByBeforeId` map — routing
415
+ // it into `foldedByAfterId` instead would key the count by a before-id
416
+ // under a map every lookup below indexes by after-id, silently losing it.
417
+ const foldedByAfterId = new Map();
418
+ const foldedByBeforeId = new Map();
419
+ for (const c of changes) {
420
+ if (c.subsumed !== 'subtree' || !c.subsumedBy)
421
+ continue;
422
+ const into = c.kind === 'removed' || c.kind === 'disappeared' ? foldedByBeforeId : foldedByAfterId;
423
+ into.set(c.subsumedBy, (into.get(c.subsumedBy) ?? 0) + 1);
424
+ }
425
+ for (const c of changes) {
426
+ if (c.subsumed)
427
+ continue;
428
+ const n = (c.afterId ? foldedByAfterId.get(c.afterId) : undefined) ??
429
+ (c.beforeId ? foldedByBeforeId.get(c.beforeId) : undefined);
430
+ if (n)
431
+ c.descendants = n;
432
+ }
433
+ // --- 3. Inherited paint --------------------------------------------------
434
+ // `color` cascades. One theme swap restates itself on every descendant that
435
+ // did not author its own value. Same from *and* same to, or it stays primary:
436
+ // an independently-authored colour that happens to have moved is its own fact.
437
+ for (const c of changes) {
438
+ const prop = c.prop;
439
+ if (c.kind !== 'style' || c.geometric || c.subsumed)
440
+ continue;
441
+ if (!isInheritedPaint(prop) || !c.before || !c.after)
442
+ continue;
443
+ const from = c.before[prop];
444
+ const to = c.after[prop];
445
+ const source = ancestors(c.afterId).find((a) => of(a).some((o) => o.kind === 'style' &&
446
+ o.prop === prop &&
447
+ !o.subsumed &&
448
+ o.before?.[prop] === from &&
449
+ o.after?.[prop] === to));
450
+ if (source) {
451
+ c.cause = 'derived';
452
+ c.via = source;
453
+ c.how = 'inherited';
454
+ }
455
+ }
456
+ // --- 4. Geometry causality ----------------------------------------------
457
+ const boxChanges = changes.filter((c) => c.kind === 'box' && !c.subsumed && c.afterId);
458
+ const boxByAfterId = new Map();
459
+ for (const c of boxChanges)
460
+ if (c.afterId)
461
+ boxByAfterId.set(c.afterId, c);
462
+ // A parent whose children changed structurally has a reason of its own.
463
+ const structural = new Set();
464
+ for (const c of changes) {
465
+ if (c.subsumed)
466
+ continue;
467
+ // A boundary-ambiguous element cannot license "this box's parent has a
468
+ // structural reason of its own" — that reason is itself only maybe.
469
+ if (c.boundaryAmbiguous)
470
+ continue;
471
+ if (c.kind === 'added' || c.kind === 'appeared' || c.kind === 'disappeared') {
472
+ const parentId = c.afterId ? after.byId.get(c.afterId)?.parentId : null;
473
+ if (parentId)
474
+ structural.add(parentId);
475
+ }
476
+ if (c.kind === 'removed') {
477
+ const beforeParent = c.before?.parentId;
478
+ const afterParent = beforeParent ? matched.pairs.get(beforeParent) : undefined;
479
+ if (afterParent)
480
+ structural.add(afterParent);
481
+ }
482
+ }
483
+ const hasSelfCause = (id) => structural.has(id) ||
484
+ of(id).some((c) => !c.subsumed && (c.kind === 'text' || (c.kind === 'style' && c.geometric === true)));
485
+ for (const c of boxChanges) {
486
+ const id = c.afterId;
487
+ const delta = c.delta;
488
+ if (!id || !delta)
489
+ continue;
490
+ if (hasSelfCause(id)) {
491
+ c.cause = 'primary';
492
+ continue;
493
+ }
494
+ // Carried: an ancestor moved by the same vector and this one only followed.
495
+ if (!resized(delta)) {
496
+ const carrier = ancestors(id).find((a) => {
497
+ const up = boxByAfterId.get(a)?.delta;
498
+ return up !== undefined && near(up.dx, delta.dx) && near(up.dy, delta.dy);
499
+ });
500
+ if (carrier) {
501
+ c.cause = 'derived';
502
+ c.via = carrier;
503
+ c.how = 'carried';
504
+ continue;
505
+ }
506
+ }
507
+ // Reflowed: an ancestor moved *and* resized, and this box did exactly the
508
+ // same thing. A viewport change makes almost every box move and shrink; the
509
+ // ones that did precisely what their container did are restating it. All
510
+ // four components must match — a box whose height changed differently from
511
+ // its parent's did something of its own and keeps its line.
512
+ const reflowSource = ancestors(id).find((a) => {
513
+ const up = boxByAfterId.get(a)?.delta;
514
+ return (up !== undefined &&
515
+ near(up.dx, delta.dx) &&
516
+ near(up.dy, delta.dy) &&
517
+ near(up.dw, delta.dw) &&
518
+ near(up.dh, delta.dh));
519
+ });
520
+ if (reflowSource) {
521
+ c.cause = 'derived';
522
+ c.via = reflowSource;
523
+ c.how = 'reflowed';
524
+ continue;
525
+ }
526
+ // Absorbed: this box only grew because something inside it grew.
527
+ if (resized(delta) && !moved(delta)) {
528
+ const inner = boxChanges.find((o) => {
529
+ const innerDelta = o.delta;
530
+ return (o !== c &&
531
+ o.afterId !== undefined &&
532
+ innerDelta !== undefined &&
533
+ ancestors(o.afterId).includes(id) &&
534
+ near(innerDelta.dh, delta.dh) &&
535
+ near(innerDelta.dw, delta.dw) &&
536
+ hasSelfCause(o.afterId));
537
+ });
538
+ if (inner?.afterId) {
539
+ c.cause = 'derived';
540
+ c.via = inner.afterId;
541
+ c.how = 'absorbed';
542
+ continue;
543
+ }
544
+ }
545
+ // Pushed: an earlier sibling grew or was inserted, by exactly this
546
+ // distance. "By exactly this distance" is load-bearing, not decorative
547
+ // (B2): the first preceding sibling that changed shape at all used to be
548
+ // named the cause regardless of whether its growth could account for
549
+ // this box's own delta, so a sibling that gained 3px of *width* could be
550
+ // blamed for a 40px vertical push while the sibling that actually grew
551
+ // 40px taller sat one slot further back, never even checked. Every
552
+ // candidate's growth on the relevant axis (height, since this branch
553
+ // only fires when the box itself did not resize) must match `delta.dy`
554
+ // within tolerance before it is accepted; a candidate that merely
555
+ // resized on some other axis, or by some other amount, is skipped in
556
+ // favour of the next one further back, not accepted as a fallback.
557
+ const el = after.byId.get(id);
558
+ const parent = el?.parentId ? after.byId.get(el.parentId) : undefined;
559
+ if (parent && !resized(delta) && Math.abs(delta.dx) <= BOX_TOLERANCE) {
560
+ const index = parent.childIds.indexOf(id);
561
+ for (let i = index - 1; i >= 0; i--) {
562
+ const sibling = parent.childIds[i];
563
+ if (!sibling)
564
+ continue;
565
+ const grew = boxByAfterId.get(sibling)?.delta;
566
+ // A resized sibling explains this push only if its own height
567
+ // growth matches the distance this box moved by.
568
+ const growExplains = grew !== undefined && resized(grew) && near(grew.dh, delta.dy);
569
+ // An inserted sibling has no `delta` (there is no before-state to
570
+ // diff against) — the space it claims is its own rendered height.
571
+ const insertedHeight = kindOn(sibling, 'added') || kindOn(sibling, 'appeared')
572
+ ? after.byId.get(sibling)?.rect.height
573
+ : undefined;
574
+ const insertExplains = insertedHeight !== undefined && near(insertedHeight, delta.dy);
575
+ if (growExplains || insertExplains) {
576
+ c.cause = 'derived';
577
+ c.via = sibling;
578
+ c.how = 'pushed';
579
+ break;
580
+ }
581
+ }
582
+ if (c.cause)
583
+ continue;
584
+ }
585
+ // No candidate's growth explained the distance moved: leaving this
586
+ // primary is the honest outcome. An unattributed move is a fact; naming
587
+ // the nearest sibling that merely happened to resize would be a guess
588
+ // wearing the same "by e3" sentence as a confirmed one.
589
+ c.cause = 'primary';
590
+ }
591
+ // --- 5. Roll derived-of-derived up to the act that started it ------------
592
+ // Without this, a pushed row and the four words inside it become five groups
593
+ // that all mean "because e6 was inserted".
594
+ //
595
+ // Two maps, not one (B1). An element can carry *both* a geometry-derived
596
+ // `box` change (via a sibling or ancestor that moved it) and a
597
+ // paint-derived `style` change (via an ancestor's colour cascade) at once —
598
+ // the same element is simultaneously a link in a "carried" chain and a
599
+ // link in an "inherited" chain, for entirely unrelated reasons. A single
600
+ // `Map<afterId, …>` has room for only one entry per id, so whichever of the
601
+ // two changes was recorded later in `changes` silently overwrote the
602
+ // other's roll-up target: a descendant's colour, correctly traced back to
603
+ // an ancestor's real colour change, could have its `rootVia` hijacked
604
+ // mid-walk by that same ancestor's *unrelated* box-carry entry — producing
605
+ // `~ e4 color inherited from e2` when `e2` never had a colour change at
606
+ // all, with the underlying `rootHow` left as `carried` beneath a line that
607
+ // reads "inherited" (`foldDerived` in `render.ts` decides the wording from
608
+ // `kind`, not from `rootHow`, so the mismatch is invisible until the JSON
609
+ // is inspected). Keeping geometry and paint in separate maps means a walk
610
+ // started by a `box` change can only ever continue through other `box`
611
+ // changes, and a walk started by a `style` change only through other
612
+ // `style` changes — the two causal chains can share an element without
613
+ // being able to overwrite each other's link at it.
614
+ const geometryViaOf = new Map();
615
+ const paintViaOf = new Map();
616
+ for (const c of changes) {
617
+ if (c.cause !== 'derived' || !c.via || !c.afterId)
618
+ continue;
619
+ if (c.kind === 'box')
620
+ geometryViaOf.set(c.afterId, { via: c.via, how: c.how });
621
+ else if (c.kind === 'style')
622
+ paintViaOf.set(c.afterId, { via: c.via, how: c.how });
623
+ }
624
+ for (const c of changes) {
625
+ if (c.cause !== 'derived' || !c.via)
626
+ continue;
627
+ const viaOf = c.kind === 'box' ? geometryViaOf : c.kind === 'style' ? paintViaOf : undefined;
628
+ if (!viaOf) {
629
+ c.rootVia = c.via;
630
+ if (c.how)
631
+ c.rootHow = c.how;
632
+ continue;
633
+ }
634
+ const seen = new Set();
635
+ if (c.afterId)
636
+ seen.add(c.afterId);
637
+ let cursor = c.via;
638
+ let how = c.how;
639
+ while (!seen.has(cursor)) {
640
+ const up = viaOf.get(cursor);
641
+ if (!up)
642
+ break;
643
+ seen.add(cursor);
644
+ // A paint change does not move anything, so passing through one must not
645
+ // rewrite a geometric consequence as `inherited`.
646
+ how = up.how === 'inherited' ? how : up.how;
647
+ cursor = up.via;
648
+ }
649
+ c.rootVia = cursor;
650
+ if (how)
651
+ c.rootHow = how;
652
+ }
653
+ }