@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.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/analysis/cluster.cjs +114 -0
- package/dist/analysis/cluster.d.cts +68 -0
- package/dist/analysis/cluster.d.ts +68 -0
- package/dist/analysis/cluster.js +109 -0
- package/dist/analysis/distances.cjs +124 -0
- package/dist/analysis/distances.d.cts +58 -0
- package/dist/analysis/distances.d.ts +58 -0
- package/dist/analysis/distances.js +119 -0
- package/dist/analysis/geometry.cjs +60 -0
- package/dist/analysis/geometry.d.cts +34 -0
- package/dist/analysis/geometry.d.ts +34 -0
- package/dist/analysis/geometry.js +53 -0
- package/dist/analysis/layout-tree.cjs +245 -0
- package/dist/analysis/layout-tree.d.cts +45 -0
- package/dist/analysis/layout-tree.d.ts +45 -0
- package/dist/analysis/layout-tree.js +242 -0
- package/dist/analysis/region-query.cjs +71 -0
- package/dist/analysis/region-query.d.cts +25 -0
- package/dist/analysis/region-query.d.ts +25 -0
- package/dist/analysis/region-query.js +67 -0
- package/dist/analysis/types.cjs +7 -0
- package/dist/analysis/types.d.cts +143 -0
- package/dist/analysis/types.d.ts +143 -0
- package/dist/analysis/types.js +6 -0
- package/dist/bundle.cjs +182 -0
- package/dist/bundle.d.cts +91 -0
- package/dist/bundle.d.ts +91 -0
- package/dist/bundle.js +175 -0
- package/dist/cli.cjs +755 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +719 -0
- package/dist/diff/changes.cjs +656 -0
- package/dist/diff/changes.d.cts +2 -0
- package/dist/diff/changes.d.ts +2 -0
- package/dist/diff/changes.js +653 -0
- package/dist/diff/identity.cjs +167 -0
- package/dist/diff/identity.d.cts +62 -0
- package/dist/diff/identity.d.ts +62 -0
- package/dist/diff/identity.js +159 -0
- package/dist/diff/index.cjs +84 -0
- package/dist/diff/index.d.cts +76 -0
- package/dist/diff/index.d.ts +76 -0
- package/dist/diff/index.js +68 -0
- package/dist/diff/match.cjs +156 -0
- package/dist/diff/match.d.cts +17 -0
- package/dist/diff/match.d.ts +17 -0
- package/dist/diff/match.js +152 -0
- package/dist/diff/render.cjs +540 -0
- package/dist/diff/render.d.cts +2 -0
- package/dist/diff/render.d.ts +2 -0
- package/dist/diff/render.js +537 -0
- package/dist/diff/types.cjs +2 -0
- package/dist/diff/types.d.cts +211 -0
- package/dist/diff/types.d.ts +211 -0
- package/dist/diff/types.js +1 -0
- package/dist/digest/index.cjs +8 -0
- package/dist/digest/index.d.cts +4 -0
- package/dist/digest/index.d.ts +4 -0
- package/dist/digest/index.js +2 -0
- package/dist/digest/parse.cjs +163 -0
- package/dist/digest/parse.d.cts +80 -0
- package/dist/digest/parse.d.ts +80 -0
- package/dist/digest/parse.js +160 -0
- package/dist/digest/render.cjs +1575 -0
- package/dist/digest/render.d.cts +41 -0
- package/dist/digest/render.d.ts +41 -0
- package/dist/digest/render.js +1571 -0
- package/dist/digest/types.cjs +10 -0
- package/dist/digest/types.d.cts +86 -0
- package/dist/digest/types.d.ts +86 -0
- package/dist/digest/types.js +9 -0
- package/dist/index.cjs +61 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +19 -0
- package/dist/page.cjs +35 -0
- package/dist/page.d.cts +40 -0
- package/dist/page.d.ts +40 -0
- package/dist/page.js +31 -0
- package/dist/playwright.cjs +138 -0
- package/dist/playwright.d.cts +69 -0
- package/dist/playwright.d.ts +69 -0
- package/dist/playwright.js +131 -0
- package/dist/query.cjs +1152 -0
- package/dist/query.d.cts +47 -0
- package/dist/query.d.ts +47 -0
- package/dist/query.js +1146 -0
- package/dist/snapshot/capture.cjs +80 -0
- package/dist/snapshot/capture.d.cts +34 -0
- package/dist/snapshot/capture.d.ts +34 -0
- package/dist/snapshot/capture.js +77 -0
- package/dist/snapshot/cdp.cjs +450 -0
- package/dist/snapshot/cdp.d.cts +36 -0
- package/dist/snapshot/cdp.d.ts +36 -0
- package/dist/snapshot/cdp.js +447 -0
- package/dist/snapshot/clip.cjs +100 -0
- package/dist/snapshot/clip.d.cts +26 -0
- package/dist/snapshot/clip.d.ts +26 -0
- package/dist/snapshot/clip.js +96 -0
- package/dist/snapshot/collect.cjs +358 -0
- package/dist/snapshot/collect.d.cts +18 -0
- package/dist/snapshot/collect.d.ts +18 -0
- package/dist/snapshot/collect.js +355 -0
- package/dist/snapshot/color.cjs +1039 -0
- package/dist/snapshot/color.d.cts +316 -0
- package/dist/snapshot/color.d.ts +316 -0
- package/dist/snapshot/color.js +1026 -0
- package/dist/snapshot/facts.cjs +167 -0
- package/dist/snapshot/facts.d.cts +48 -0
- package/dist/snapshot/facts.d.ts +48 -0
- package/dist/snapshot/facts.js +161 -0
- package/dist/snapshot/limits.cjs +69 -0
- package/dist/snapshot/limits.d.cts +59 -0
- package/dist/snapshot/limits.d.ts +59 -0
- package/dist/snapshot/limits.js +65 -0
- package/dist/snapshot/not-measured.cjs +44 -0
- package/dist/snapshot/not-measured.d.cts +14 -0
- package/dist/snapshot/not-measured.d.ts +14 -0
- package/dist/snapshot/not-measured.js +41 -0
- package/dist/snapshot/types.cjs +2 -0
- package/dist/snapshot/types.d.cts +238 -0
- package/dist/snapshot/types.d.ts +238 -0
- package/dist/snapshot/types.js +1 -0
- package/dist/spec/index.cjs +9 -0
- package/dist/spec/index.d.cts +15 -0
- package/dist/spec/index.d.ts +15 -0
- package/dist/spec/index.js +6 -0
- package/package.json +141 -0
- 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
|
+
}
|