@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,1575 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Renders a capture as the text an agent reads instead of raw JSON.
|
|
4
|
+
*
|
|
5
|
+
* The line grammar is carried over from the prototype unchanged. It survived
|
|
6
|
+
* two cold reads there and a third here, and it is the one part of this project
|
|
7
|
+
* that five registry searches found no substitute for, so it is deliberately
|
|
8
|
+
* not being redesigned:
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* # dnumb digest v2 — <url> — <viewport> — <state>
|
|
12
|
+
* # grammar: id tag[.role|#testid] ["text"] @x,y WxH [flow] [↓gap|→gap to prev sibling] [facts]; px, integers; y>viewport.height is below the fold
|
|
13
|
+
* e13 span "Task Board 1" @71,164 263x18 →10
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Two things are new in v2.
|
|
17
|
+
*
|
|
18
|
+
* **Facts are joined by element id, not by selector.** The prototype matched
|
|
19
|
+
* check findings to digest lines through a CSS selector string, which is how it
|
|
20
|
+
* shipped the Step 16 misattribution bug: two elements resolving to the same
|
|
21
|
+
* selector meant a fact landing on the wrong line. Facts here are computed from
|
|
22
|
+
* the same records the lines are, and carried on the record.
|
|
23
|
+
*
|
|
24
|
+
* **The header names the state.** Without it two captures of one URL are
|
|
25
|
+
* indistinguishable in their own headers, and the whole point of the format is
|
|
26
|
+
* to describe more than one moment of a page.
|
|
27
|
+
*/
|
|
28
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
+
exports.SPARSE_CAPTURE_MARKER = void 0;
|
|
30
|
+
exports.renderDigest = renderDigest;
|
|
31
|
+
const cluster_js_1 = require("../analysis/cluster.cjs");
|
|
32
|
+
const layout_tree_js_1 = require("../analysis/layout-tree.cjs");
|
|
33
|
+
const color_js_1 = require("../snapshot/color.cjs");
|
|
34
|
+
const facts_js_1 = require("../snapshot/facts.cjs");
|
|
35
|
+
const not_measured_js_1 = require("../snapshot/not-measured.cjs");
|
|
36
|
+
/**
|
|
37
|
+
* P1-1 (superseded -- see `docs/notes/06-budget.md`, the design of record for
|
|
38
|
+
* everything in this comment and the two below it). Raising `DEFAULT_BUDGET`
|
|
39
|
+
* from 300 to 1000 lines was tuned against eight committed fixtures and at
|
|
40
|
+
* most four live-site stand-ins, and a later 28-page live soak falsified the
|
|
41
|
+
* frame it was tuned in, not just the number. Two things were wrong at once,
|
|
42
|
+
* and only one of them was the constant:
|
|
43
|
+
*
|
|
44
|
+
* 1. **Lines are a poor proxy for the thing the budget protects.** A digest
|
|
45
|
+
* line runs 40-900+ characters (a 40-entry fold line alone can hit 1088);
|
|
46
|
+
* the real constraint is the reader's context window, denominated in
|
|
47
|
+
* tokens, and characters predict tokens to within ±20% (2.51-3.50
|
|
48
|
+
* chars/token, median 2.84, measured across the soak's real cl100k
|
|
49
|
+
* counts) with no tokenizer dependency. Lines predict tokens far worse,
|
|
50
|
+
* and not at all once folds appear.
|
|
51
|
+
* 2. **The scalar was never the real defect -- the collapse ladder's
|
|
52
|
+
* ordering was.** Stage 3 (`collapseCandidates`, below) used to rank
|
|
53
|
+
* candidates biggest-first, so the first collapse tried on a real page
|
|
54
|
+
* was a wrapper containing nearly the whole document: output was a step
|
|
55
|
+
* function, 12 lines or the whole page, nothing in between at any
|
|
56
|
+
* budget. No default line count could fix that -- `stripe.com`,
|
|
57
|
+
* `bbc.com`, and `tailwindcss.com` all had *no* budget between 1200 and
|
|
58
|
+
* 1300 that produced anything but those two extremes (measured against
|
|
59
|
+
* this repo's own soak bundles). See `collapseCandidates`'s doc comment
|
|
60
|
+
* for the leaf-up reordering that replaced it.
|
|
61
|
+
*
|
|
62
|
+
* The budget is now `budgetChars` (`DEFAULT_BUDGET_CHARS`, below), not
|
|
63
|
+
* lines. `budgetLines` still works when a caller passes it explicitly --
|
|
64
|
+
* this package has two other call sites (`src/cli.ts`, `src/query.ts`) that
|
|
65
|
+
* type and pass it through, both owned by crews mid-flight the night this
|
|
66
|
+
* landed, so an outright removal would have broken code this change could
|
|
67
|
+
* not also fix -- but it carries no default of its own any more and is not
|
|
68
|
+
* how a bare `renderDigest(snapshot, meta)` call is bounded.
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* Default `budgetChars` -- the measured knee, not a round number.
|
|
72
|
+
* `docs/notes/06-budget.md` §5: the soak's median natural page (3.6K tokens)
|
|
73
|
+
* fits with 3× headroom, 11 of 28 pages render in full untouched, and every
|
|
74
|
+
* page that degrades still yields a 200-450-line skeleton once stage 3 peels
|
|
75
|
+
* leaf-up (`collapseCandidates`) instead of biggest-first. Half it and
|
|
76
|
+
* `stripe.com`'s skeleton loses its section internals; double it and the
|
|
77
|
+
* default `look` is back to 20K+ tokens on a third of the web -- the exact
|
|
78
|
+
* cost the soak flagged. 32768 chars is ~11.5K tokens at the corpus's
|
|
79
|
+
* measured 2.84 chars/token (§2), comfortably inside the range the old
|
|
80
|
+
* line-based sweep itself called "the honest price of a real page's
|
|
81
|
+
* content" (9-22K tokens), at the bottom of it because the soak showed
|
|
82
|
+
* expensive pages are the common case, not the sweep's assumed exception.
|
|
83
|
+
*/
|
|
84
|
+
const DEFAULT_BUDGET_CHARS = 32768;
|
|
85
|
+
const TEXT_TRUNCATE = 30;
|
|
86
|
+
/** A run shorter than this is cheaper to print in full than to describe. */
|
|
87
|
+
const MIN_FOLD_RUN = 3;
|
|
88
|
+
const round = (n) => Math.round(n);
|
|
89
|
+
function label(node, ctx) {
|
|
90
|
+
const el = ctx.byId.get(node.elementId);
|
|
91
|
+
let head = `${node.elementId} ${node.tag}`;
|
|
92
|
+
if (el?.testId)
|
|
93
|
+
head += `#${el.testId}`;
|
|
94
|
+
else if (el?.role)
|
|
95
|
+
head += `.${el.role}`;
|
|
96
|
+
const text = el?.name || el?.text;
|
|
97
|
+
if (text) {
|
|
98
|
+
head += ` "${text.length > TEXT_TRUNCATE ? `${text.slice(0, TEXT_TRUNCATE)}…` : text}"`;
|
|
99
|
+
}
|
|
100
|
+
return head;
|
|
101
|
+
}
|
|
102
|
+
function geometry(node) {
|
|
103
|
+
return `@${round(node.rect.x)},${round(node.rect.y)} ${round(node.rect.width)}x${round(node.rect.height)}`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Signed edge-to-edge distance between two boxes along one axis, in DOM
|
|
107
|
+
* order (`previous` before `node`). Positive is a real gap; negative is a
|
|
108
|
+
* genuine overlap, measured from the earlier box's trailing edge to the
|
|
109
|
+
* later box's leading edge -- the same "positioned back over its sibling"
|
|
110
|
+
* measurement the docs' menu example describes.
|
|
111
|
+
*
|
|
112
|
+
* All three branches are direction-agnostic: each checks which box is
|
|
113
|
+
* actually first on the axis before deciding which pair of edges to
|
|
114
|
+
* subtract, rather than assuming `previous` is always the one with the
|
|
115
|
+
* smaller coordinate. The naive DOM-order subtraction -- `node.start -
|
|
116
|
+
* previous.end`, unconditionally -- is an LTR-only assumption. On an RTL
|
|
117
|
+
* flex row the DOM-order previous sibling is drawn to the *right*, so that
|
|
118
|
+
* subtraction pairs the wrong edges and reports a number with no
|
|
119
|
+
* relationship to the real geometry: a huge fabricated overlap for two boxes
|
|
120
|
+
* that do not touch at all (measured on `ar.wikipedia.org`: two header
|
|
121
|
+
* regions 16px apart rendered as `→-1352`), and, in the third branch, a
|
|
122
|
+
* wrong magnitude for boxes that genuinely do overlap (two rows overlapping
|
|
123
|
+
* by exactly 1.0px rendered as `→-474` -- the pre-fix third branch was still
|
|
124
|
+
* `node.start - previous.end` unconditionally, the identical LTR-only bug
|
|
125
|
+
* one branch later). `analysis/distances.ts#distance` (the `q distance`
|
|
126
|
+
* verb) never had this bug because `gapAlong` already checks direction this
|
|
127
|
+
* way; the first two branches mirror that logic exactly (same result in
|
|
128
|
+
* every LTR case, since `previous` really is first on the axis there). The
|
|
129
|
+
* third branch cannot just mirror `gapAlong`'s overlap arm, though --
|
|
130
|
+
* `gapAlong` reports the *intersection length*, a different and smaller
|
|
131
|
+
* number than "how far back" a box was positioned, which is what this token
|
|
132
|
+
* has always reported and what `docs/digest.md`'s worked example pins -- so
|
|
133
|
+
* it picks the correct pair of edges (the later-on-screen box's leading edge
|
|
134
|
+
* against the earlier-on-screen box's trailing edge) itself, the same
|
|
135
|
+
* direction check as the other two branches, applied to this branch's own
|
|
136
|
+
* arithmetic instead of borrowing `gapAlong`'s.
|
|
137
|
+
*/
|
|
138
|
+
function edgeDistance(nodeStart, nodeSize, prevStart, prevSize) {
|
|
139
|
+
const nodeEnd = nodeStart + nodeSize;
|
|
140
|
+
const prevEnd = prevStart + prevSize;
|
|
141
|
+
if (nodeStart >= prevEnd)
|
|
142
|
+
return nodeStart - prevEnd;
|
|
143
|
+
if (prevStart >= nodeEnd)
|
|
144
|
+
return prevStart - nodeEnd;
|
|
145
|
+
// Overlap. `previous` is only the box drawn first on screen when
|
|
146
|
+
// `prevStart <= nodeStart` (every LTR case). When it is not -- an RTL
|
|
147
|
+
// sibling pair, `previous` drawn to the right -- the box actually first on
|
|
148
|
+
// screen is `node`, so the pair to subtract is `node`'s trailing edge
|
|
149
|
+
// against `previous`'s leading edge instead.
|
|
150
|
+
if (prevStart > nodeStart)
|
|
151
|
+
return -(nodeEnd - prevStart);
|
|
152
|
+
return nodeStart - prevEnd;
|
|
153
|
+
}
|
|
154
|
+
function gapToken(node, previous, flow) {
|
|
155
|
+
if (!previous)
|
|
156
|
+
return '';
|
|
157
|
+
const vertical = flow !== 'row';
|
|
158
|
+
const value = vertical
|
|
159
|
+
? edgeDistance(node.rect.y, node.rect.height, previous.rect.y, previous.rect.height)
|
|
160
|
+
: edgeDistance(node.rect.x, node.rect.width, previous.rect.x, previous.rect.width);
|
|
161
|
+
if (!Number.isFinite(value))
|
|
162
|
+
return '';
|
|
163
|
+
return ` ${vertical ? '↓' : '→'}${round(value)}`;
|
|
164
|
+
}
|
|
165
|
+
function lineFor(node, previous, parentFlow, depth, ctx) {
|
|
166
|
+
const parts = [`${' '.repeat(depth)}${label(node, ctx)} ${geometry(node)}`];
|
|
167
|
+
if (node.flow !== 'none')
|
|
168
|
+
parts.push(`[${node.flow}]`);
|
|
169
|
+
const gap = gapToken(node, previous, parentFlow);
|
|
170
|
+
if (gap)
|
|
171
|
+
parts.push(gap.trim());
|
|
172
|
+
for (const fact of ctx.factsById.get(node.elementId) ?? [])
|
|
173
|
+
parts.push(`[${fact}]`);
|
|
174
|
+
if (node.rect.y > ctx.viewportHeight)
|
|
175
|
+
parts.push('[below fold]');
|
|
176
|
+
return parts.join(' ');
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Coarse order-of-magnitude size class for one dimension.
|
|
180
|
+
*
|
|
181
|
+
* The signature needs *some* notion of size -- without one, a page skeleton
|
|
182
|
+
* (four sibling containers 60/40/3000/400px tall) is one signature, and the
|
|
183
|
+
* 3000px one folds into the others along with everything inside it. But
|
|
184
|
+
* requiring exact dimensions was tried before this and meant nothing on a real
|
|
185
|
+
* page ever folded (thirty story rows whose titles are different lengths are
|
|
186
|
+
* thirty different heights). A bucket a few pixels wide is as bad as no
|
|
187
|
+
* bucket; one that spans three orders of magnitude is as bad as exact
|
|
188
|
+
* matching. This buckets by ~8x (three bits of `log2`), which keeps rows that
|
|
189
|
+
* merely differ in wrapped line count together while still splitting a 40px
|
|
190
|
+
* row from a 3000px one.
|
|
191
|
+
*/
|
|
192
|
+
const SIZE_BUCKET_BITS = 3;
|
|
193
|
+
function sizeBucket(n) {
|
|
194
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
195
|
+
return 0;
|
|
196
|
+
return Math.round(Math.log2(n) / SIZE_BUCKET_BITS);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* A fact's meaning for folding purposes: kind plus consequence tokens, with
|
|
200
|
+
* the numeric magnitude stripped. `[overflow-x +900px; clip, ellipsis]` and
|
|
201
|
+
* `[overflow-x +2px; scroll, wraps]` are not interchangeable even though both
|
|
202
|
+
* are `overflow-x` -- the consequence tokens are where the difference that
|
|
203
|
+
* matters actually lives, per `docs/digest.md`'s facts section. Two rows that
|
|
204
|
+
* only differ in *how much* they overflow (`+12px` vs `+14px`, same
|
|
205
|
+
* consequence tokens) are the same kind of thing and still fold.
|
|
206
|
+
*/
|
|
207
|
+
function factSignature(fact) {
|
|
208
|
+
return `${fact.kind}|${fact.text.replace(/[+-]?\d+(\.\d+)?px/g, '')}`;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Every fact signature found anywhere in a subtree -- the node's own plus
|
|
212
|
+
* every descendant's, deduped and sorted for a stable comparison key.
|
|
213
|
+
*
|
|
214
|
+
* Computed bottom-up, once per render call, over the whole tree, and cached
|
|
215
|
+
* by element id rather than recomputed per comparison: without memoizing,
|
|
216
|
+
* this is a walk of the node's whole subtree, and `signature()` is called
|
|
217
|
+
* once per sibling at every level, so recomputing it inline would make an
|
|
218
|
+
* n-node tree's total work O(n * average subtree size) instead of O(n).
|
|
219
|
+
*
|
|
220
|
+
* This is the F1 fix: folding a subtree containing a fact into one that
|
|
221
|
+
* lacks it was invisible to the old `signature()`, which was built from each
|
|
222
|
+
* node's own box and its own facts only -- never what the box contains. A
|
|
223
|
+
* `li` that folds identically to seven others at the top level can still
|
|
224
|
+
* hide the one `span` inside it that overflows; nothing about the `li`'s own
|
|
225
|
+
* tag/role/size/fact-list changes when its child does, and the fold line
|
|
226
|
+
* only ever named the folded `li`'s id and position, never its child's.
|
|
227
|
+
* Measured on real captures before this fix: `shoelace-docs` had 115 of 301
|
|
228
|
+
* fact-bearing ids, and `grafana-play` 97 of 166, absent from the rendered
|
|
229
|
+
* text entirely -- not folded-and-disclosed, just gone, budget-independent
|
|
230
|
+
* (folding runs at every budget, not only over it).
|
|
231
|
+
*
|
|
232
|
+
* Returns only the boolean "has a fact somewhere below" (`hasDescendantFact`
|
|
233
|
+
* on `Ctx`), not the descendant signatures themselves: unlike an element's
|
|
234
|
+
* *own* fact, which the representative line still stands in for when a fold
|
|
235
|
+
* groups matching own-fact signatures (see `signature()`), a descendant's
|
|
236
|
+
* fact has no representative once its parent has folded away -- the fold
|
|
237
|
+
* line names siblings, never grandchildren (`real-sites-facts-reach-output.
|
|
238
|
+
* test.ts`'s "fold-named" route checks exactly this and does not extend to
|
|
239
|
+
* descendants). So a coarser *match* on descendant fact kind buys nothing;
|
|
240
|
+
* what matters is presence at all, which is what forces the node out of
|
|
241
|
+
* folding entirely in `signature()`.
|
|
242
|
+
*/
|
|
243
|
+
function computeDeepFactIds(roots, factSignaturesById) {
|
|
244
|
+
const hasDescendantFact = new Set();
|
|
245
|
+
function subtreeHasFact(node) {
|
|
246
|
+
let childHasFact = false;
|
|
247
|
+
for (const child of node.children) {
|
|
248
|
+
if (subtreeHasFact(child))
|
|
249
|
+
childHasFact = true;
|
|
250
|
+
}
|
|
251
|
+
if (childHasFact)
|
|
252
|
+
hasDescendantFact.add(node.elementId);
|
|
253
|
+
return childHasFact || (factSignaturesById.get(node.elementId)?.length ?? 0) > 0;
|
|
254
|
+
}
|
|
255
|
+
for (const root of roots)
|
|
256
|
+
subtreeHasFact(root);
|
|
257
|
+
return hasDescendantFact;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* What makes two siblings "the same" for folding.
|
|
261
|
+
*
|
|
262
|
+
* Own facts (`factSignaturesById`) are part of it by matching kind and
|
|
263
|
+
* consequence, not exact text, same as before -- this is what keeps `docs/
|
|
264
|
+
* digest.md`'s own worked example true ("two rows overflowing by 12px and
|
|
265
|
+
* 14px, both clip, ellipsis, are the same kind of thing and fold"), because
|
|
266
|
+
* the folded-away member's own fact is still "the same kind" as the
|
|
267
|
+
* representative's, printed on the line right above it.
|
|
268
|
+
*
|
|
269
|
+
* A fact anywhere in a node's *descendants* (`ctx.hasDescendantFact`) is
|
|
270
|
+
* different: there is no representative standing in for it (see
|
|
271
|
+
* `computeDeepFactIds`'s doc comment), so instead of grouping by a coarser
|
|
272
|
+
* match, such a node is excluded from folding altogether -- its signature is
|
|
273
|
+
* made unique to it (the element id itself, which nothing else shares) so it
|
|
274
|
+
* can never match any other position, fact-bearing or not, and must always
|
|
275
|
+
* either print its own line (with its own children, so the descendant's
|
|
276
|
+
* fact prints too) or be swept into an explicitly-disclosed `[folded: N
|
|
277
|
+
* nodes, bbox ...]` collapse under real budget pressure -- never silently
|
|
278
|
+
* absorbed as an unlabelled fold member (F1 in the audit).
|
|
279
|
+
*
|
|
280
|
+
* Aggressive mode (`ctx.aggressive`) still widens *something* under budget
|
|
281
|
+
* pressure -- dropping the size bucket from the shape half, so a page's own
|
|
282
|
+
* wildly different container heights fold more readily. That trade is fine
|
|
283
|
+
* for shape; it was never fine for facts, own or descendant (F2 in the
|
|
284
|
+
* audit -- stage 2 used to strip facts from the signature entirely, so two
|
|
285
|
+
* rows differing only in *what they measure* folded together and the
|
|
286
|
+
* representative's fact text silently stood in for both). Neither the own-
|
|
287
|
+
* fact signature nor the descendant-fact marker below is ever dropped here.
|
|
288
|
+
*/
|
|
289
|
+
function signature(node, ctx) {
|
|
290
|
+
const el = ctx.byId.get(node.elementId);
|
|
291
|
+
const shape = ctx.aggressive
|
|
292
|
+
? `${node.tag}|${el?.role ?? ''}|${node.flow}|${node.children.length}`
|
|
293
|
+
: `${node.tag}|${el?.role ?? ''}|${node.flow}|${node.children.length}|${sizeBucket(node.rect.width)}x${sizeBucket(node.rect.height)}`;
|
|
294
|
+
const ownFacts = (ctx.factSignaturesById.get(node.elementId) ?? []).join(';');
|
|
295
|
+
const deepMarker = ctx.hasDescendantFact.has(node.elementId) ? `#${node.elementId}` : '';
|
|
296
|
+
return `${shape}|${ownFacts}${deepMarker}`;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* How many folded siblings get named individually.
|
|
300
|
+
*
|
|
301
|
+
* The fold line keeps each id and position so that nothing a fold covers
|
|
302
|
+
* becomes unaddressable -- but a 200-row table produces one *line* of 2,400
|
|
303
|
+
* characters, and a budget measured in lines does not bound tokens at all. Past
|
|
304
|
+
* this many the line says how many it dropped and how to get at them, because a
|
|
305
|
+
* cap the reader cannot see is indistinguishable from complete output.
|
|
306
|
+
*/
|
|
307
|
+
const MAX_FOLD_ENTRIES = 40;
|
|
308
|
+
/**
|
|
309
|
+
* `[fold: ×N[/period] more like <id>[ (variance)] — entries]`. The count and
|
|
310
|
+
* the optional period sit before `more like` and the optional variance sits
|
|
311
|
+
* in parens right after the id -- both stay clear of the ` — ` separator on
|
|
312
|
+
* purpose. `parse.ts`'s fold regex used to require `(\S+)` immediately before
|
|
313
|
+
* ` — `, so a variance token with a space in it (`r0 (h-range 30-90 across
|
|
314
|
+
* 8)`) made the whole line unparseable; the parser now has an explicit
|
|
315
|
+
* optional group for exactly this shape, but a future token added here still
|
|
316
|
+
* has to stay inside one of the two designated slots or it will break parsing
|
|
317
|
+
* again.
|
|
318
|
+
*/
|
|
319
|
+
function foldLine(run, parentFlow, depth, parentId, period = 1) {
|
|
320
|
+
const first = run[0];
|
|
321
|
+
if (!first)
|
|
322
|
+
return '';
|
|
323
|
+
const rest = run.slice(period);
|
|
324
|
+
const named = rest.slice(0, MAX_FOLD_ENTRIES);
|
|
325
|
+
const entries = named.map((node, i) => {
|
|
326
|
+
const previous = i === 0 ? run[period - 1] : rest[i - 1];
|
|
327
|
+
return `${node.elementId} @${round(node.rect.x)},${round(node.rect.y)}${gapToken(node, previous, parentFlow)}`;
|
|
328
|
+
});
|
|
329
|
+
const hidden = rest.length - named.length;
|
|
330
|
+
// F3 in the audit: `--focus` has to name an id whose subtree actually
|
|
331
|
+
// contains every entry this line is about to drop. `first` is one of the
|
|
332
|
+
// SIBLINGS the fold covers -- `--focus <first>` re-shows only that one
|
|
333
|
+
// sibling's own subtree, not the ones `…+N not listed` refers to, and a
|
|
334
|
+
// real case (`--focus e47` on a tailwind capture) returned exactly one
|
|
335
|
+
// line for it. The parent -- the container all these siblings share -- is
|
|
336
|
+
// the only id whose subtree covers all of them. `first`'s own id remains
|
|
337
|
+
// the fallback for the one case with no addressable container: a fold
|
|
338
|
+
// among multiple top-level document roots, which have no shared parent id
|
|
339
|
+
// to point at.
|
|
340
|
+
const hint = parentId ?? first.elementId;
|
|
341
|
+
const tail = hidden > 0 ? `, …+${hidden} not listed (--focus ${hint})` : '';
|
|
342
|
+
const periodToken = period > 1 ? `/${period}` : '';
|
|
343
|
+
return `${' '.repeat(depth)}[fold: ×${rest.length}${periodToken} more like ${first.elementId}${variance(run, period)} — ${entries.join(', ')}${tail}]`;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The min/max of one dimension across a set of values, or `null` when they
|
|
347
|
+
* are the same box within rounding noise.
|
|
348
|
+
*
|
|
349
|
+
* Compares the *raw* values against `OVERFLOW_EPSILON` -- the same 1px
|
|
350
|
+
* threshold `facts.ts` uses for overflow -- and rounds only for display.
|
|
351
|
+
* Rounding first and comparing rounded integers (what shipped) turns
|
|
352
|
+
* `100.4, 100.6, 100.6, 100.6` into a reported `100-101`: a manufactured 1px
|
|
353
|
+
* spread out of what is really 0.2px of subpixel layout. Non-finite values
|
|
354
|
+
* (a hand-edited bundle, a NaN rect) are dropped rather than propagated --
|
|
355
|
+
* `round(NaN)` is `NaN`, and `NaN !== NaN` would otherwise print `w NaN-NaN`.
|
|
356
|
+
*/
|
|
357
|
+
function spread(rawValues) {
|
|
358
|
+
const values = rawValues.filter((v) => Number.isFinite(v));
|
|
359
|
+
if (values.length === 0)
|
|
360
|
+
return null;
|
|
361
|
+
const min = Math.min(...values);
|
|
362
|
+
const max = Math.max(...values);
|
|
363
|
+
if (max - min <= facts_js_1.OVERFLOW_EPSILON)
|
|
364
|
+
return null;
|
|
365
|
+
return [round(min), round(max)];
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* The size range across a fold, when the members are not all the same size.
|
|
369
|
+
*
|
|
370
|
+
* Dimensions are deliberately not part of the fold signature (beyond the
|
|
371
|
+
* coarse bucket in `signature()`) -- requiring an exact match meant nothing on
|
|
372
|
+
* a real page ever folded. But that trade hides exactly the thing this tool
|
|
373
|
+
* exists to surface: fold eighty-eight rows and one of them is three times
|
|
374
|
+
* taller than the rest, and the digest says nothing. Facts already break a
|
|
375
|
+
* fold, so an overflowing row stays visible; a merely misshapen one would not.
|
|
376
|
+
*
|
|
377
|
+
* For a periodic fold (`period > 1`) the spread is computed *per phase* --
|
|
378
|
+
* story row, subtext row, spacer row are each a different shape on purpose,
|
|
379
|
+
* and pooling their heights together reports the list's own inherent
|
|
380
|
+
* variation as if it were an outlier. A phase with no real spread says
|
|
381
|
+
* nothing; a phase with one gets its own `phaseN` token.
|
|
382
|
+
*
|
|
383
|
+
* **`w-range`/`h-range`, not bare `w`/`h`, and `across <count>` on every
|
|
384
|
+
* token.** `docs/notes/05-v3-control.md` SECOND RUN §3/§5/§7 measured what
|
|
385
|
+
* the bare form (`(w 32-48)`) does to a careful reader on a real, clean
|
|
386
|
+
* control page: 1 of 5 blind raters read it as data precise enough to belong
|
|
387
|
+
* to one specific folded sibling, picked the range's upper bound as that
|
|
388
|
+
* sibling's own width, and derived a right edge from it -- a specific,
|
|
389
|
+
* invented `overflow-x` the renderer never emitted, on a page carrying zero
|
|
390
|
+
* facts. The entries printed right after this token give x-position and gap
|
|
391
|
+
* for each folded id, in the same px units, which is exactly what makes the
|
|
392
|
+
* reconstruction *look* well-founded; nothing in the old token's own text
|
|
393
|
+
* said it was an aggregate rather than a per-entry lookup. `w-range`/`h-range`
|
|
394
|
+
* names what kind of number this is (a spread, not a measurement of one box),
|
|
395
|
+
* and `across <count>` states how many boxes it was pooled from -- the same
|
|
396
|
+
* count as the fold's own representative-plus-entries -- so a reader has to
|
|
397
|
+
* override two explicit words to make the mistake the old token invited
|
|
398
|
+
* silently.
|
|
399
|
+
*/
|
|
400
|
+
function variance(run, period) {
|
|
401
|
+
const parts = [];
|
|
402
|
+
for (let phase = 0; phase < period; phase++) {
|
|
403
|
+
const members = run.filter((_, i) => i % period === phase);
|
|
404
|
+
const w = spread(members.map((node) => node.rect.width));
|
|
405
|
+
const h = spread(members.map((node) => node.rect.height));
|
|
406
|
+
const phaseParts = [];
|
|
407
|
+
if (w)
|
|
408
|
+
phaseParts.push(`w-range ${w[0]}-${w[1]} across ${members.length}`);
|
|
409
|
+
if (h)
|
|
410
|
+
phaseParts.push(`h-range ${h[0]}-${h[1]} across ${members.length}`);
|
|
411
|
+
if (phaseParts.length === 0)
|
|
412
|
+
continue;
|
|
413
|
+
const label = period > 1 ? `phase${phase} ` : '';
|
|
414
|
+
parts.push(`${label}${phaseParts.join(', ')}`);
|
|
415
|
+
}
|
|
416
|
+
return parts.length > 0 ? ` (${parts.join('; ')})` : '';
|
|
417
|
+
}
|
|
418
|
+
function countNodes(node) {
|
|
419
|
+
let total = 1;
|
|
420
|
+
for (const child of node.children)
|
|
421
|
+
total += countNodes(child);
|
|
422
|
+
return total;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* How many of a subtree's elements carry a fact -- what a `[folded: N nodes,
|
|
426
|
+
* bbox ...]` collapse takes with it, unstated until now. The line already
|
|
427
|
+
* disclosed the node count and the box; a reader had no way to tell "796
|
|
428
|
+
* ordinary nodes" from "796 nodes, 448 of which were measurements" without
|
|
429
|
+
* re-running an uncollapsed capture. Counted from the same `factsById` map
|
|
430
|
+
* `lineFor` reads facts from, so this can never disagree with what the
|
|
431
|
+
* uncollapsed render would have shown.
|
|
432
|
+
*/
|
|
433
|
+
function countFacts(node, factsById) {
|
|
434
|
+
let total = factsById.has(node.elementId) ? 1 : 0;
|
|
435
|
+
for (const child of node.children)
|
|
436
|
+
total += countFacts(child, factsById);
|
|
437
|
+
return total;
|
|
438
|
+
}
|
|
439
|
+
function bbox(nodes) {
|
|
440
|
+
let x0 = Number.POSITIVE_INFINITY;
|
|
441
|
+
let y0 = Number.POSITIVE_INFINITY;
|
|
442
|
+
let x1 = Number.NEGATIVE_INFINITY;
|
|
443
|
+
let y1 = Number.NEGATIVE_INFINITY;
|
|
444
|
+
const visit = (n) => {
|
|
445
|
+
x0 = Math.min(x0, n.rect.x);
|
|
446
|
+
y0 = Math.min(y0, n.rect.y);
|
|
447
|
+
x1 = Math.max(x1, n.rect.x + n.rect.width);
|
|
448
|
+
y1 = Math.max(y1, n.rect.y + n.rect.height);
|
|
449
|
+
for (const child of n.children)
|
|
450
|
+
visit(child);
|
|
451
|
+
};
|
|
452
|
+
for (const n of nodes)
|
|
453
|
+
visit(n);
|
|
454
|
+
if (!Number.isFinite(x0))
|
|
455
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
456
|
+
return { x: round(x0), y: round(y0), width: round(x1 - x0), height: round(y1 - y0) };
|
|
457
|
+
}
|
|
458
|
+
function flatten(nodes) {
|
|
459
|
+
const out = [];
|
|
460
|
+
const visit = (n) => {
|
|
461
|
+
out.push(n);
|
|
462
|
+
for (const child of n.children)
|
|
463
|
+
visit(child);
|
|
464
|
+
};
|
|
465
|
+
for (const n of nodes)
|
|
466
|
+
visit(n);
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
/** How many times the runner-up's own size the gap between the bbox-defining
|
|
470
|
+
* node and its runner-up must be, before that node is called out by name. */
|
|
471
|
+
const PAGE_OUTLIER_RATIO = 10;
|
|
472
|
+
/** Rounding tolerance for "is this candidate's edge the bbox bound". */
|
|
473
|
+
const BOUND_EPSILON = 0.5;
|
|
474
|
+
/**
|
|
475
|
+
* Every element id's set of ancestor ids (not including itself), computed
|
|
476
|
+
* once per `pageOutlier` call. Exists for exactly one purpose: telling a
|
|
477
|
+
* *structural* tie at the bbox extreme apart from a genuine second
|
|
478
|
+
* occurrence of it -- see the tie-handling loop below.
|
|
479
|
+
*/
|
|
480
|
+
function buildAncestry(roots) {
|
|
481
|
+
const ancestry = new Map();
|
|
482
|
+
const visit = (node, ancestors) => {
|
|
483
|
+
ancestry.set(node.elementId, ancestors);
|
|
484
|
+
const withSelf = new Set(ancestors);
|
|
485
|
+
withSelf.add(node.elementId);
|
|
486
|
+
for (const child of node.children)
|
|
487
|
+
visit(child, withSelf);
|
|
488
|
+
};
|
|
489
|
+
for (const root of roots)
|
|
490
|
+
visit(root, new Set());
|
|
491
|
+
return ancestry;
|
|
492
|
+
}
|
|
493
|
+
function isLineage(a, b, ancestry) {
|
|
494
|
+
return ((ancestry.get(a.elementId)?.has(b.elementId) ?? false) ||
|
|
495
|
+
(ancestry.get(b.elementId)?.has(a.elementId) ?? false));
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The single element responsible when the page's bounding box is dominated
|
|
499
|
+
* by one wildly out-of-scale node, and which axis it dominates on.
|
|
500
|
+
*
|
|
501
|
+
* `# page: WxH` is the first line of every digest, so it is the number a
|
|
502
|
+
* reader is most likely to trust without checking. On real sites it has
|
|
503
|
+
* measured `4512x3802616` -- a grid's virtualization spacer, real
|
|
504
|
+
* `scrollHeight` -- `1441x106410` from a visually-hidden a11y label parked at
|
|
505
|
+
* `y: -99836`, and `11414x4272` from a hidden cookie-notice heading at
|
|
506
|
+
* `x: -9974`. Three unrelated causes, one symptom: a single node pushes the
|
|
507
|
+
* honest bbox to a size nothing else on the page approaches, and that node is
|
|
508
|
+
* often folded or dropped out of the body, so the header is the only trace of
|
|
509
|
+
* it.
|
|
510
|
+
*
|
|
511
|
+
* This does not clamp or discard the measurement -- it is real, and nothing
|
|
512
|
+
* in this package adjudicates what a reader should see. It identifies which
|
|
513
|
+
* one node is responsible so a reader can get from the header number to the
|
|
514
|
+
* element without leaving the digest.
|
|
515
|
+
*
|
|
516
|
+
* F5 in the audit: the previous version compared each candidate's own SIZE
|
|
517
|
+
* (`rect.width`/`rect.height`) against the next-largest candidate's size,
|
|
518
|
+
* which is the wrong quantity twice over. First, it fabricates a causal
|
|
519
|
+
* claim: a plain header/hero/footer page has one non-root element (the hero)
|
|
520
|
+
* bigger than the rest by a wide margin, and that comparison alone flagged
|
|
521
|
+
* it as "accounting for" the page height even though every element there is
|
|
522
|
+
* ordinary in-flow content, nothing anomalous about the total. A 300px modal
|
|
523
|
+
* in a 900px page reproduces the same false attribution the moment some
|
|
524
|
+
* other, smaller element happens to be the next-largest by chance -- the
|
|
525
|
+
* 900px total has nothing to do with the modal at all. Second, and this is
|
|
526
|
+
* why it stayed silent on all eight real captures including the ar-wikipedia
|
|
527
|
+
* case its own doc comment cites as motivation: the actual off-canvas
|
|
528
|
+
* a11y-label case is a 1x1 box. It has no size to speak of; what makes it
|
|
529
|
+
* responsible is its *position* -- its own top edge is the page bbox's `y0`,
|
|
530
|
+
* tens of thousands of px beyond anything else on the page -- and a
|
|
531
|
+
* same-size comparison can never see that.
|
|
532
|
+
*
|
|
533
|
+
* This version asks the question the header line's own arithmetic asks:
|
|
534
|
+
* which node's `x`/`x+w` or `y`/`y+h` literally IS one of the bbox's four
|
|
535
|
+
* bounds (`BOUND_EPSILON` of rounding slack), and how far beyond the
|
|
536
|
+
* *runner-up* on that same side that bound reaches, relative to the
|
|
537
|
+
* runner-up's own size on that axis -- the same "is this gap big compared to
|
|
538
|
+
* the scale of what's next to it" question `PAGE_OUTLIER_RATIO` always
|
|
539
|
+
* asked, just computed from the right pair of numbers. Requiring the exact
|
|
540
|
+
* bound match is what keeps this from ever naming a node that is not
|
|
541
|
+
* actually why the number in `<W>x<H>` is what it is: if every non-root
|
|
542
|
+
* candidate falls short of the true extreme, the *root's own* box is what
|
|
543
|
+
* reaches it (a container's box reflects its content's extent by
|
|
544
|
+
* construction), and roots are deliberately never named here -- a container
|
|
545
|
+
* being large is not itself the finding, and this is also why the modal case
|
|
546
|
+
* is silent even when a modal happens to be the largest non-root candidate
|
|
547
|
+
* by raw size: if its own edges do not reach the page's actual bbox bounds,
|
|
548
|
+
* it is not a candidate for any of the four probes below at all.
|
|
549
|
+
*/
|
|
550
|
+
function pageOutlier(flat, roots) {
|
|
551
|
+
const rootIds = new Set(roots.map((n) => n.elementId));
|
|
552
|
+
const candidates = flat.filter((n) => !rootIds.has(n.elementId));
|
|
553
|
+
if (candidates.length < 2)
|
|
554
|
+
return null;
|
|
555
|
+
// The bbox's own four bounds, from every node including roots -- the same
|
|
556
|
+
// extremes `<W>x<H>` in the header is built from.
|
|
557
|
+
const trueBound = {
|
|
558
|
+
xMin: Math.min(...flat.map((n) => n.rect.x)),
|
|
559
|
+
xMax: Math.max(...flat.map((n) => n.rect.x + n.rect.width)),
|
|
560
|
+
yMin: Math.min(...flat.map((n) => n.rect.y)),
|
|
561
|
+
yMax: Math.max(...flat.map((n) => n.rect.y + n.rect.height)),
|
|
562
|
+
};
|
|
563
|
+
const probes = [
|
|
564
|
+
{ axis: 'width', target: trueBound.xMin, dir: 1, value: (n) => n.rect.x },
|
|
565
|
+
{ axis: 'width', target: trueBound.xMax, dir: -1, value: (n) => n.rect.x + n.rect.width },
|
|
566
|
+
{ axis: 'height', target: trueBound.yMin, dir: 1, value: (n) => n.rect.y },
|
|
567
|
+
{ axis: 'height', target: trueBound.yMax, dir: -1, value: (n) => n.rect.y + n.rect.height },
|
|
568
|
+
];
|
|
569
|
+
const ancestry = buildAncestry(roots);
|
|
570
|
+
let winner = null;
|
|
571
|
+
for (const probe of probes) {
|
|
572
|
+
const sorted = [...candidates].sort((a, b) => probe.dir * (probe.value(a) - probe.value(b)));
|
|
573
|
+
const first = sorted[0];
|
|
574
|
+
// Only a candidate whose own edge actually IS the page's bbox bound on
|
|
575
|
+
// this side is eligible -- see the doc comment above for why (the root
|
|
576
|
+
// owns this bound instead when no non-root candidate reaches it).
|
|
577
|
+
if (!first || Math.abs(probe.value(first) - probe.target) > BOUND_EPSILON)
|
|
578
|
+
continue;
|
|
579
|
+
// The runner-up. Two ways a candidate can tie with `first` at the
|
|
580
|
+
// extreme, and they mean opposite things:
|
|
581
|
+
// - a STRUCTURAL tie -- `first`'s own parent or child -- is the SAME
|
|
582
|
+
// measurement seen at two nesting levels, not independent evidence
|
|
583
|
+
// (`docs/digest.md`'s own worked example: a zero-width column and its
|
|
584
|
+
// single child both compute to the page's full 1239px height, and the
|
|
585
|
+
// doc explicitly wants this to stay silent -- "no unique second-
|
|
586
|
+
// largest box... to compare the largest one against"). Such a tie
|
|
587
|
+
// IS the runner-up: it belongs in the comparison, and its ~0px gap
|
|
588
|
+
// correctly suppresses the probe.
|
|
589
|
+
// - an INDEPENDENT tie -- an unrelated element elsewhere in the tree
|
|
590
|
+
// that happens to reach the same extreme -- is a second, separate
|
|
591
|
+
// occurrence of the same anomaly, not a competing measurement (five
|
|
592
|
+
// unrelated 1x1 off-canvas labels on a real `ar-wikipedia` capture,
|
|
593
|
+
// all parked at the identical `y:-99791`). Comparing `first` against
|
|
594
|
+
// one of ITS OWN kind would report a 0px gap and wrongly suppress the
|
|
595
|
+
// exact case this rewrite exists to surface, so these are skipped in
|
|
596
|
+
// favor of the next candidate whose value is either genuinely
|
|
597
|
+
// different or a structural tie.
|
|
598
|
+
let second;
|
|
599
|
+
for (const candidate of sorted.slice(1)) {
|
|
600
|
+
const tied = Math.abs(probe.value(candidate) - probe.value(first)) <= facts_js_1.OVERFLOW_EPSILON;
|
|
601
|
+
if (tied && !isLineage(first, candidate, ancestry))
|
|
602
|
+
continue;
|
|
603
|
+
second = candidate;
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
if (!second)
|
|
607
|
+
continue;
|
|
608
|
+
const gap = Math.abs(probe.value(second) - probe.value(first));
|
|
609
|
+
const secondSize = probe.axis === 'width' ? second.rect.width : second.rect.height;
|
|
610
|
+
const ratio = gap / Math.max(secondSize, 1);
|
|
611
|
+
if (ratio <= PAGE_OUTLIER_RATIO)
|
|
612
|
+
continue;
|
|
613
|
+
if (!winner || ratio > winner.ratio) {
|
|
614
|
+
winner = { id: first.elementId, axis: probe.axis, extent: round(gap), ratio };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return winner ? { id: winner.id, axis: winner.axis, extent: winner.extent } : null;
|
|
618
|
+
}
|
|
619
|
+
const MAX_FOLD_PERIOD = 6;
|
|
620
|
+
/**
|
|
621
|
+
* The longest run of siblings starting at `from` that repeats with some period.
|
|
622
|
+
*
|
|
623
|
+
* Real lists are periodic, not uniform. Hacker News alternates story row,
|
|
624
|
+
* subtext row, spacer row; a definition list alternates `dt`, `dd`; a table
|
|
625
|
+
* body alternates header and data rows. Looking only for *consecutive
|
|
626
|
+
* identical* signatures -- which is what this did -- finds runs of length one
|
|
627
|
+
* or two in all of those, never reaches the fold threshold, and leaves the
|
|
628
|
+
* budget ladder no option but to collapse the page's actual content into a
|
|
629
|
+
* single `[folded: 626 nodes]` line. That is what shipped, and it made the
|
|
630
|
+
* digest useless on every list-heavy page, which is most of them.
|
|
631
|
+
*
|
|
632
|
+
* Periods beyond a handful stop being a pattern a reader would recognise, so
|
|
633
|
+
* the search is bounded; the period covering the most siblings wins.
|
|
634
|
+
*
|
|
635
|
+
* `signatures` is the *whole* sibling level's signatures, computed once by
|
|
636
|
+
* `renderNodes` -- this used to take the raw node list and rebuild the
|
|
637
|
+
* signature array itself on every call, which made an n-sibling level's
|
|
638
|
+
* `renderNodes` loop O(n^2): each of the n positions re-signed every
|
|
639
|
+
* remaining sibling. Taking the precomputed array plus an offset turns that
|
|
640
|
+
* into O(n) total.
|
|
641
|
+
*/
|
|
642
|
+
function periodicRun(signatures, from) {
|
|
643
|
+
const remaining = signatures.length - from;
|
|
644
|
+
let best = { length: 1, period: 1 };
|
|
645
|
+
for (let period = 1; period <= Math.min(MAX_FOLD_PERIOD, remaining); period++) {
|
|
646
|
+
let repeats = 1;
|
|
647
|
+
while ((repeats + 1) * period <= remaining) {
|
|
648
|
+
let matches = true;
|
|
649
|
+
for (let k = 0; k < period; k++) {
|
|
650
|
+
// No array allocation per attempt -- this loop can run many times per
|
|
651
|
+
// sibling position, and `Array.from(...).every(...)` (what shipped)
|
|
652
|
+
// allocated a fresh array on every single one of them.
|
|
653
|
+
if (signatures[from + repeats * period + k] !== signatures[from + k]) {
|
|
654
|
+
matches = false;
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (!matches)
|
|
659
|
+
break;
|
|
660
|
+
repeats++;
|
|
661
|
+
}
|
|
662
|
+
// Two repeats of a three-row pattern is six lines described in seven; the
|
|
663
|
+
// fold has to cover more than it costs.
|
|
664
|
+
if (repeats >= MIN_FOLD_RUN && repeats * period > best.length) {
|
|
665
|
+
best = { length: repeats * period, period };
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return best;
|
|
669
|
+
}
|
|
670
|
+
function renderNodes(nodes, parentFlow, depth, ctx, out,
|
|
671
|
+
/** The container id all of `nodes` share, or `null` for the top-level
|
|
672
|
+
* roots (which have no shared parent id) -- threaded through to
|
|
673
|
+
* `foldLine`'s `--focus` hint. See F3 in the audit. */
|
|
674
|
+
parentId) {
|
|
675
|
+
// Computed once for the whole sibling level, not once per position -- see
|
|
676
|
+
// `periodicRun`'s doc comment for why the previous version, which rebuilt
|
|
677
|
+
// this from `i` at every index, made an n-sibling level O(n^2). Skipped
|
|
678
|
+
// entirely under `--focus` (see `Ctx.focused`'s doc comment) -- there is
|
|
679
|
+
// nothing to fold *into*, since the whole render already stopped there.
|
|
680
|
+
const signatures = ctx.focused ? [] : nodes.map((node) => signature(node, ctx));
|
|
681
|
+
let i = 0;
|
|
682
|
+
while (i < nodes.length) {
|
|
683
|
+
const node = nodes[i];
|
|
684
|
+
if (!node)
|
|
685
|
+
break;
|
|
686
|
+
const previous = nodes[i - 1];
|
|
687
|
+
const { length, period } = ctx.focused ? { length: 1, period: 1 } : periodicRun(signatures, i);
|
|
688
|
+
if (length >= MIN_FOLD_RUN) {
|
|
689
|
+
// The first whole period is printed in full and stands for the rest --
|
|
690
|
+
// for an alternating story/subtext/spacer table that means one of each,
|
|
691
|
+
// not one row of three. The fold line keeps every remaining id and
|
|
692
|
+
// position, so nothing it covers becomes unaddressable.
|
|
693
|
+
for (let k = 0; k < period; k++) {
|
|
694
|
+
const member = nodes[i + k];
|
|
695
|
+
if (!member)
|
|
696
|
+
break;
|
|
697
|
+
out.push(lineFor(member, nodes[i + k - 1], parentFlow, depth, ctx));
|
|
698
|
+
renderChildren(member, depth + 1, ctx, out);
|
|
699
|
+
}
|
|
700
|
+
out.push(foldLine(nodes.slice(i, i + length), parentFlow, depth, parentId, period));
|
|
701
|
+
i += length;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
out.push(lineFor(node, previous, parentFlow, depth, ctx));
|
|
705
|
+
renderChildren(node, depth + 1, ctx, out);
|
|
706
|
+
i++;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* The only place that knows about collapsing.
|
|
711
|
+
*
|
|
712
|
+
* It used to be guarded here *and* at both call sites, so a collapsed container
|
|
713
|
+
* emitted nothing at all rather than its summary line -- the subtree vanished
|
|
714
|
+
* from the digest with no trace that it had been dropped. A cap the reader
|
|
715
|
+
* cannot see is worse than no cap.
|
|
716
|
+
*
|
|
717
|
+
* `(--focus <id>)` at the end is the same disclosure the fold line already
|
|
718
|
+
* makes for its own `…+N not listed` tail (see `foldLine` above) -- the id is
|
|
719
|
+
* `node.elementId`, printed on the container's own line directly above this
|
|
720
|
+
* one, so a reader could always work it out by hand. Making it explicit here
|
|
721
|
+
* matters more than it does on the fold line, not less: this is the render's
|
|
722
|
+
* *heaviest* disclosure -- a whole subtree, `K facts` among them, reduced to
|
|
723
|
+
* one line -- and a reader told "827 facts are in here" with no printed route
|
|
724
|
+
* to them is being taunted, not informed.
|
|
725
|
+
*/
|
|
726
|
+
function renderChildren(node, depth, ctx, out) {
|
|
727
|
+
if (node.children.length === 0)
|
|
728
|
+
return;
|
|
729
|
+
if (ctx.collapsed.has(node.elementId)) {
|
|
730
|
+
const box = bbox(node.children);
|
|
731
|
+
const count = node.children.reduce((sum, c) => sum + countNodes(c), 0);
|
|
732
|
+
const facts = node.children.reduce((sum, c) => sum + countFacts(c, ctx.factsById), 0);
|
|
733
|
+
// Only when nonzero -- omitted entirely rather than printing `0 facts` on
|
|
734
|
+
// the common case where a collapse hides nothing measured, so the token
|
|
735
|
+
// stays reserved for the case a reader actually needs to notice.
|
|
736
|
+
const factsClause = facts > 0 ? `, ${facts} facts` : '';
|
|
737
|
+
out.push(`${' '.repeat(depth)}[folded: ${count} nodes${factsClause}, bbox @${box.x},${box.y} ${box.width}x${box.height} (--focus ${node.elementId})]`);
|
|
738
|
+
// This is the single source of truth for "which collapses actually
|
|
739
|
+
// reached the reader" -- see `Ctx.printedCollapsed`'s doc comment for
|
|
740
|
+
// why summing/naming from `ctx.collapsed` directly can diverge from
|
|
741
|
+
// this.
|
|
742
|
+
ctx.printedCollapsed.push(node.elementId);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
renderNodes(node.children, node.flow, depth, ctx, out, node.elementId);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Containers worth collapsing, shallowest first -- "peel from the leaves"
|
|
749
|
+
* (`docs/notes/06-budget.md` §3-4, a 28-site soak's finding, verified
|
|
750
|
+
* independently against this repo's own built renderer before this change
|
|
751
|
+
* landed, not taken on the note's word: patching only this comparator,
|
|
752
|
+
* nothing else, already turns `stripe.com` from a 12-line-or-1290-line step
|
|
753
|
+
* function into a smooth curve at every budget from 300 lines up -- 304
|
|
754
|
+
* lines at budget 300, 1006 at 1000, no cliff anywhere in between).
|
|
755
|
+
*
|
|
756
|
+
* The previous key was raw size, descending -- collapse the biggest
|
|
757
|
+
* container first. On a real production page the biggest container is
|
|
758
|
+
* always a whole-page wrapper, so the very first collapse ate essentially
|
|
759
|
+
* the entire document and every budget from "small" to "the page's own
|
|
760
|
+
* size" produced the identical single collapse: output was a step function,
|
|
761
|
+
* nothing between "12 lines" and "everything," on `stripe.com`, `bbc.com`
|
|
762
|
+
* and `tailwindcss.com` alike (measured across 17 budgets, 300-50,000, on
|
|
763
|
+
* real soak captures). No amount of retuning `DEFAULT_BUDGET` fixes a step
|
|
764
|
+
* function -- the ladder had no middle rungs to land on.
|
|
765
|
+
*
|
|
766
|
+
* Depth-from-root was also tried, as the obvious alternative reading of
|
|
767
|
+
* "start from the bottom," and is the wrong key -- verified directly here,
|
|
768
|
+
* not assumed: patching depth-descending (deepest node first) into the real
|
|
769
|
+
* renderer collapses `linear.app` down to 68 lines / 4.6KB at the same
|
|
770
|
+
* 32KB-char budget height-ascending renders at 209 lines / 16.7KB, because
|
|
771
|
+
* `linear.app`'s deepest nodes sit inside whole 200-500-node page sections
|
|
772
|
+
* that are deep-rooted *and* tall -- "deepest first" swallows one of those
|
|
773
|
+
* whole and overshoots well under budget, the same failure mode as
|
|
774
|
+
* size-descending one level removed. Height measures a subtree's own
|
|
775
|
+
* *tallness*, not its distance from the root, so a shallow-but-bushy
|
|
776
|
+
* wrapper and a deep-but-thin one are told apart correctly: a container of
|
|
777
|
+
* only leaves (height 0) collapses before its parent (height 1), which
|
|
778
|
+
* collapses before *its* parent, and so on upward -- exactly "peel from the
|
|
779
|
+
* leaves." Ties within one height class break by descendant count
|
|
780
|
+
* ascending, same reasoning one level down: several small disclosed
|
|
781
|
+
* collapses at one level tell a reader strictly more than swallowing the
|
|
782
|
+
* biggest one first.
|
|
783
|
+
*
|
|
784
|
+
* "Heaviest × depth" (an earlier scoring attempt, before either of the
|
|
785
|
+
* above) was also wrong, on a plain `descendants × depth` chain: a 30-deep
|
|
786
|
+
* wrapper chain scores higher than a 200-item list next to it (240 vs 201),
|
|
787
|
+
* so the renderer spent its collapse budget on a chain that barely moves the
|
|
788
|
+
* line count instead of the list that actually would.
|
|
789
|
+
*
|
|
790
|
+
* A root is still a candidate here, sorted into its own rung after every
|
|
791
|
+
* non-root container regardless of height or size: collapsing a root can
|
|
792
|
+
* leave nothing else behind to read, so it only happens once nothing
|
|
793
|
+
* shallower was enough -- the true last resort, not competing on score with
|
|
794
|
+
* the rest of the tree. This is also what makes the flat-page case (a `body`
|
|
795
|
+
* of only leaf children) work: `depth > 0` used to exclude the root and
|
|
796
|
+
* `children.length > 0` excludes leaves, so a page with no non-root
|
|
797
|
+
* container at all had *zero* collapse candidates and could never make
|
|
798
|
+
* budget.
|
|
799
|
+
*
|
|
800
|
+
* Height and descendant count are computed once per node, bottom-up, in the
|
|
801
|
+
* same tree walk that collects candidates -- an n-candidate sort calls its
|
|
802
|
+
* comparator O(n log n) times, and recomputing either number from scratch on
|
|
803
|
+
* every comparison (what both the old `countNodes` call and an unmemoized
|
|
804
|
+
* height calculation would do) turns a linear pass into a much more
|
|
805
|
+
* expensive one on a page with hundreds of candidates.
|
|
806
|
+
*/
|
|
807
|
+
function collapseCandidates(tree) {
|
|
808
|
+
const nonRoot = [];
|
|
809
|
+
const roots = [];
|
|
810
|
+
const visit = (node, depth, ancestorIds) => {
|
|
811
|
+
let height = 0;
|
|
812
|
+
let size = 1;
|
|
813
|
+
for (const child of node.children) {
|
|
814
|
+
const c = visit(child, depth + 1, [node.elementId, ...ancestorIds]);
|
|
815
|
+
height = Math.max(height, c.height + 1);
|
|
816
|
+
size += c.size;
|
|
817
|
+
}
|
|
818
|
+
if (node.children.length > 0) {
|
|
819
|
+
const candidate = {
|
|
820
|
+
node,
|
|
821
|
+
ancestorIds,
|
|
822
|
+
height,
|
|
823
|
+
descendants: size - 1,
|
|
824
|
+
};
|
|
825
|
+
(depth > 0 ? nonRoot : roots).push(candidate);
|
|
826
|
+
}
|
|
827
|
+
return { height, size };
|
|
828
|
+
};
|
|
829
|
+
for (const node of tree)
|
|
830
|
+
visit(node, 0, []);
|
|
831
|
+
const bySize = (a, b) => a.height - b.height || a.descendants - b.descendants;
|
|
832
|
+
return [...nonRoot.sort(bySize), ...roots.sort(bySize)];
|
|
833
|
+
}
|
|
834
|
+
function findFocus(nodes, focus) {
|
|
835
|
+
for (const node of nodes) {
|
|
836
|
+
if (node.elementId === focus || node.tag === focus)
|
|
837
|
+
return node;
|
|
838
|
+
const hit = findFocus(node.children, focus);
|
|
839
|
+
if (hit)
|
|
840
|
+
return hit;
|
|
841
|
+
}
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
/** Majority, not "any" -- the cue below exists for the moment a reader is
|
|
845
|
+
* told almost nothing, not every partial collapse. A page that collapses one
|
|
846
|
+
* 20-node sidebar out of 900 nodes is the mechanism working quietly, exactly
|
|
847
|
+
* as designed; this is for the P1-1 case, where the collapse ate the page. */
|
|
848
|
+
const COLLAPSE_CUE_THRESHOLD = 0.5;
|
|
849
|
+
/**
|
|
850
|
+
* How much of the page's own measurements and structure are sitting behind
|
|
851
|
+
* this render's collapse(s), summed across every collapsed container --
|
|
852
|
+
* the same per-container arithmetic `renderChildren` already prints on each
|
|
853
|
+
* `[folded: …]` line, just totalled rather than left for a reader to add up
|
|
854
|
+
* by hand across however many collapses fired.
|
|
855
|
+
*
|
|
856
|
+
* `ids` must be `ctx.printedCollapsed` from the render this is reporting on
|
|
857
|
+
* -- the ids that actually printed a `[folded: …]` line -- not the raw
|
|
858
|
+
* `ctx.collapsed`. Two ways the two sets can diverge, both real, both found
|
|
859
|
+
* on soak captures rather than constructed:
|
|
860
|
+
*
|
|
861
|
+
* - **Nested collapses.** Under the old size-descending stage-3 order the
|
|
862
|
+
* first (usually only) collapse was always the page's one dominant
|
|
863
|
+
* subtree, so `ctx.collapsed` and "what the reader actually sees
|
|
864
|
+
* collapsed" were always the same set. Height-ascending peeling
|
|
865
|
+
* (`collapseCandidates`) can collapse a small child on one iteration and,
|
|
866
|
+
* later in the same render, a bigger ancestor of that same child on a
|
|
867
|
+
* later one -- the ancestor-skip check in `renderDigest`'s stage-3 loop
|
|
868
|
+
* only ever stops a candidate whose *own* ancestor is already collapsed,
|
|
869
|
+
* not the reverse, and a root candidate's `ancestorIds` is always `[]`, so
|
|
870
|
+
* nothing stops a root from collapsing after its own descendants already
|
|
871
|
+
* did. `ctx.collapsed` ends up holding both ids; the render only ever
|
|
872
|
+
* prints one `[folded: …]` line, for the outer one.
|
|
873
|
+
* - **A collapsed candidate that stage 1 also folds away.**
|
|
874
|
+
* `collapseCandidates` picks candidates from the raw `LayoutNode` tree,
|
|
875
|
+
* with no awareness of stage-1 folding. If a node chosen for collapse is
|
|
876
|
+
* *also* a non-representative member of a plain or periodic sibling fold
|
|
877
|
+
* at its own level, `renderNodes` never calls `renderChildren` on it at
|
|
878
|
+
* all -- folded-away members never do -- so its `[folded: …]` line never
|
|
879
|
+
* prints either, even though nothing about it looks nested in
|
|
880
|
+
* `ctx.collapsed`.
|
|
881
|
+
*
|
|
882
|
+
* Summing the raw `ctx.collapsed` set over both cases is what printed an
|
|
883
|
+
* impossible `3506 of 1483 layout nodes` (hidden greater than the page has)
|
|
884
|
+
* on one real render and a merely-wrong `550 of 812` against a body whose
|
|
885
|
+
* own `[folded: …]` lines summed to 530 on another (`tailwind-docs` at the
|
|
886
|
+
* shipped default) -- one bug caught by a synthetic fixture, the other only
|
|
887
|
+
* by a real capture, which is why `ctx.printedCollapsed` records the ground
|
|
888
|
+
* truth as it happens rather than trying to re-derive it after the fact from
|
|
889
|
+
* either the tree or `ctx.collapsed` alone. See that field's own doc comment
|
|
890
|
+
* on `Ctx`.
|
|
891
|
+
*/
|
|
892
|
+
function collapsedTotals(ids, roots, factsById) {
|
|
893
|
+
let nodes = 0;
|
|
894
|
+
let facts = 0;
|
|
895
|
+
for (const id of ids) {
|
|
896
|
+
const node = findFocus(roots, id);
|
|
897
|
+
if (!node)
|
|
898
|
+
continue;
|
|
899
|
+
for (const child of node.children) {
|
|
900
|
+
nodes += countNodes(child);
|
|
901
|
+
facts += countFacts(child, factsById);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return { nodes, facts };
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* The ` — <strategy>` segment of the identity line.
|
|
908
|
+
*
|
|
909
|
+
* `cdp` and `walk` do not see the same page on shadow DOM -- see
|
|
910
|
+
* `DigestMeta.strategy` -- so a reader needs to know which one produced this
|
|
911
|
+
* capture, and whether `walk` here means "no other option" or "the fast path
|
|
912
|
+
* just failed" (`cdpFellBack`).
|
|
913
|
+
*
|
|
914
|
+
* `walk` also carries a second, permanent caveat that belongs here rather
|
|
915
|
+
* than on the `# not measured:` line (see `not-measured.ts#
|
|
916
|
+
* shadowRootSegment`'s doc comment, which points back to this function): a
|
|
917
|
+
* closed shadow root's count is unknowable on this path on *every* page,
|
|
918
|
+
* whether or not the page in front of it has any -- a property of the
|
|
919
|
+
* capture path, not a measurement of this particular page, so it does not
|
|
920
|
+
* belong crowded onto a line that otherwise reports only counted totals
|
|
921
|
+
* (mixing the two taught a reader to skip the whole line, including the runs
|
|
922
|
+
* where it carried a real count). `cdp` gets no matching caveat here because
|
|
923
|
+
* it is not blind to shadow content the way `walk` is -- it flattens shadow
|
|
924
|
+
* roots into the element tree instead of skipping them -- but that is not
|
|
925
|
+
* the same as "sees everything": on the committed `shoelace-docs.json`
|
|
926
|
+
* capture, the `cdp` path flattened zero shadow content on a page where
|
|
927
|
+
* `walk` found 4 open shadow roots. That gap is data-dependent per capture,
|
|
928
|
+
* not a constant property of the `cdp` path, so unlike `walk`'s blindness it
|
|
929
|
+
* has no fixed wording to state unconditionally here.
|
|
930
|
+
*/
|
|
931
|
+
function strategyLabelFor(meta) {
|
|
932
|
+
if (meta.strategy !== 'walk')
|
|
933
|
+
return meta.strategy ?? null;
|
|
934
|
+
const fellBack = meta.cdpFellBack ? 'cdp fell back, ' : '';
|
|
935
|
+
return `walk (${fellBack}closed shadow roots undetectable)`;
|
|
936
|
+
}
|
|
937
|
+
const colorPx = (n) => Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, '');
|
|
938
|
+
/**
|
|
939
|
+
* Priority for the order reasons appear in the `# colors:` unmeasured
|
|
940
|
+
* breakdown -- roughly most to least common on a real page, so the reader
|
|
941
|
+
* sees the dominant cause first rather than an alphabetical shuffle every
|
|
942
|
+
* capture. Priority only, never presence: `colorHeaderLine` below sorts the
|
|
943
|
+
* counts it actually saw by this table, it does not filter by membership in
|
|
944
|
+
* it, so a `RatioReason` with no entry here still prints (last, via the `??`
|
|
945
|
+
* fallback) instead of silently vanishing.
|
|
946
|
+
*
|
|
947
|
+
* That distinction is not decorative. The previous version was a plain
|
|
948
|
+
* five-entry array standing in for both roles at once, and `colorHeaderLine`
|
|
949
|
+
* built the breakdown as `RATIO_REASON_ORDER.filter((r) => counts.has(r))` --
|
|
950
|
+
* using the ordering array as a whitelist. `RatioReason` has seven members;
|
|
951
|
+
* `not-captured` and `canvas` were never in the five, so both were computed
|
|
952
|
+
* correctly on every fact and then discarded before the line printed,
|
|
953
|
+
* reducing every capture that hit either one to a bare count with the cause
|
|
954
|
+
* stripped off (`314 unmeasured`, no breakdown) -- exactly the distinction
|
|
955
|
+
* ("predates colour capture" vs "this page is all gradients") this tally
|
|
956
|
+
* exists to draw, silent because the two whitelists (ordering, filtering)
|
|
957
|
+
* were the same array and nothing forced them to agree. `satisfies
|
|
958
|
+
* Record<RatioReason, number>` is what makes that recur as a type error
|
|
959
|
+
* instead of a silent drop: adding an eighth `RatioReason` member without a
|
|
960
|
+
* priority entry here fails the build, rather than shipping green with the
|
|
961
|
+
* new reason invisible.
|
|
962
|
+
*/
|
|
963
|
+
const RATIO_REASON_PRIORITY = {
|
|
964
|
+
gradient: 0,
|
|
965
|
+
image: 1,
|
|
966
|
+
under: 2,
|
|
967
|
+
behind: 3,
|
|
968
|
+
'color-space': 4,
|
|
969
|
+
'not-captured': 5,
|
|
970
|
+
canvas: 6,
|
|
971
|
+
};
|
|
972
|
+
/**
|
|
973
|
+
* `# colors: <P> fg/bg pairs across <T> text elements; ratio <min>–<max>;
|
|
974
|
+
* lowest <id> <fg> on <bg> <size>px/<weight>; <U> unmeasured (<breakdown>)`
|
|
975
|
+
* -- `docs/notes/03-color.md` §6, following the page-dominance clause's own
|
|
976
|
+
* precedent (`# page:`'s "`<id>` extends the page ... by `<N>`px"): naming
|
|
977
|
+
* the minimum of a measured distribution, with its own counter-facts inline,
|
|
978
|
+
* is a measurement of this page, not a verdict about it. A measurement of
|
|
979
|
+
* *this page* belongs in the conditional block (this line), not the identity
|
|
980
|
+
* line, which is reserved for permanent properties of the capture path.
|
|
981
|
+
*
|
|
982
|
+
* The `ratio`/`lowest` clause is present only when at least one fact
|
|
983
|
+
* resolved to a real number -- a page that is entirely gradients and
|
|
984
|
+
* overlays has nothing to report there, and saying so by omission is more
|
|
985
|
+
* honest than printing a range over zero measured elements.
|
|
986
|
+
*/
|
|
987
|
+
function colorHeaderLine(colorFacts) {
|
|
988
|
+
const pairs = (0, color_js_1.groupColorFacts)(colorFacts);
|
|
989
|
+
let line = `# colors: ${pairs.length} fg/bg ${pairs.length === 1 ? 'pair' : 'pairs'} across ` +
|
|
990
|
+
`${colorFacts.length} text ${colorFacts.length === 1 ? 'element' : 'elements'}`;
|
|
991
|
+
const measured = colorFacts.filter((f) => f.ratio !== null);
|
|
992
|
+
if (measured.length > 0) {
|
|
993
|
+
const ratios = measured.map((f) => f.ratio);
|
|
994
|
+
const min = Math.min(...ratios).toFixed(2);
|
|
995
|
+
const max = Math.max(...ratios).toFixed(2);
|
|
996
|
+
const lowest = measured.reduce((a, b) => (b.ratio < a.ratio ? b : a));
|
|
997
|
+
line +=
|
|
998
|
+
`; ratio ${min}–${max}; lowest ${lowest.elementId} ${lowest.fg} on ${(0, color_js_1.backdropLabel)(lowest.backdrop)} ` +
|
|
999
|
+
`${colorPx(lowest.fontSizePx)}px/${lowest.fontWeight}`;
|
|
1000
|
+
}
|
|
1001
|
+
const unmeasured = colorFacts.length - measured.length;
|
|
1002
|
+
if (unmeasured > 0) {
|
|
1003
|
+
const counts = new Map();
|
|
1004
|
+
for (const f of colorFacts) {
|
|
1005
|
+
if (f.ratio === null && f.ratioReason)
|
|
1006
|
+
counts.set(f.ratioReason, (counts.get(f.ratioReason) ?? 0) + 1);
|
|
1007
|
+
}
|
|
1008
|
+
// Every reason actually seen, sorted by priority -- never filtered by
|
|
1009
|
+
// membership in `RATIO_REASON_PRIORITY`, so a reason that table has no
|
|
1010
|
+
// entry for still prints (see that constant's own doc comment for why
|
|
1011
|
+
// this is not the same code as before).
|
|
1012
|
+
const breakdown = [...counts.entries()]
|
|
1013
|
+
.sort(([a], [b]) => (RATIO_REASON_PRIORITY[a] ?? 99) - (RATIO_REASON_PRIORITY[b] ?? 99))
|
|
1014
|
+
.map(([reason, count]) => `${count} ${reason}`);
|
|
1015
|
+
line += `; ${unmeasured} unmeasured${breakdown.length > 0 ? ` (${breakdown.join(', ')})` : ''}`;
|
|
1016
|
+
}
|
|
1017
|
+
return line;
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Total body size in characters, the way a reader's context actually pays
|
|
1021
|
+
* for it -- `docs/notes/06-budget.md` §2's cost function verified directly
|
|
1022
|
+
* against this file's own ladder: `body.reduce((a, l) => a + l.length + 1,
|
|
1023
|
+
* 0)`, the `+1` for the newline every line but the last still costs once
|
|
1024
|
+
* this array is joined into text. This is the file's *only* char-counting
|
|
1025
|
+
* cost function; every ladder checkpoint below calls through `overBudget`
|
|
1026
|
+
* rather than reimplementing the sum, so the meter cannot drift between
|
|
1027
|
+
* checkpoints the way `budget`/`DEFAULT_BUDGET` never had to worry about
|
|
1028
|
+
* when a length check was just `.length`.
|
|
1029
|
+
*/
|
|
1030
|
+
function charLength(lines) {
|
|
1031
|
+
return lines.reduce((total, line) => total + line.length + 1, 0);
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* The refusal surface's second line always opens with this exact prefix --
|
|
1035
|
+
* the single signal a caller needs to detect `sparseCapture` from rendered
|
|
1036
|
+
* text alone, without re-deriving `renderDigest`'s own gate expression a
|
|
1037
|
+
* second time somewhere else (the CLI's `--json` envelope, in particular --
|
|
1038
|
+
* see `src/cli.ts`). `docs/notes/07-refusal.md` §4: "Keep the single source;
|
|
1039
|
+
* do not re-derive it." A rendered digest is the one place that decision is
|
|
1040
|
+
* already made; this constant is how a caller reads it back off the text
|
|
1041
|
+
* instead of recomputing `elements >= 20 && dropped/total >= 0.9 &&
|
|
1042
|
+
* page.height <= 2` against a bundle it may not even have the same shape of.
|
|
1043
|
+
*/
|
|
1044
|
+
exports.SPARSE_CAPTURE_MARKER = '# no layout to digest:';
|
|
1045
|
+
/**
|
|
1046
|
+
* `docs/notes/07-refusal.md` §2 -- the exact surface, verbatim except for
|
|
1047
|
+
* substituting the real captured URL into the re-capture line (§7 point 1).
|
|
1048
|
+
*
|
|
1049
|
+
* Four runs (`docs/notes/05-v3-control.md`) tried five placements of a
|
|
1050
|
+
* caveat next to a `zero-box` fact on a sparse capture's own `<body>` line --
|
|
1051
|
+
* trailing on the bracket, interrupting it, parenthetical, header-led,
|
|
1052
|
+
* header-only -- and the best of them still fabricated a "broken page"
|
|
1053
|
+
* finding 40-50% of the time, because a `[...]` fact bracket on an element
|
|
1054
|
+
* line *is* the grammar for "a measured, adjudicable finding about this
|
|
1055
|
+
* element," independent of any prose beside it (run 3's diagnosis, run 4's
|
|
1056
|
+
* confirmation). The fix run 4 stopped short of: this function is called
|
|
1057
|
+
* *instead of* the normal render, under the same `sparseCapture` gate
|
|
1058
|
+
* (computed once in `renderDigest`, this function's only caller, and never
|
|
1059
|
+
* re-derived here or anywhere else) -- so no element line, no `[...]`
|
|
1060
|
+
* bracket, no `# facts:` line, and no grammar line (there is no grammar to
|
|
1061
|
+
* explain when there is no body) ever reaches the reader on a capture this
|
|
1062
|
+
* sparse. Nothing is discarded: every number below is real (the same
|
|
1063
|
+
* `droppedFromTree`/`droppedRatio`/`page`/content values the pre-refusal
|
|
1064
|
+
* header sentence used to carry), and `q facts` / `q describe` / `q find` /
|
|
1065
|
+
* `look --focus` all still read the full, unrerouted bundle -- this changes
|
|
1066
|
+
* only what `look` prints by default (`src/snapshot/facts.ts` is untouched).
|
|
1067
|
+
*
|
|
1068
|
+
* This is unproven, not a confirmed fix -- `docs/notes/07-refusal.md` §5
|
|
1069
|
+
* commits in advance that if a pre-registered blind read scores this surface
|
|
1070
|
+
* above 0/30 fabrications, the next move is not a sixth wording, it is
|
|
1071
|
+
* escalating back to the format level.
|
|
1072
|
+
*/
|
|
1073
|
+
function renderSparseRefusal(identity, meta, totalRecords, droppedFromTree, droppedRatio, nodeCount, page, sparseZeroBoxNotes) {
|
|
1074
|
+
// The per-element clause used to carry `<id> <tag> is <w>x<h> and still
|
|
1075
|
+
// carries content <content>` when it lived in the pre-refusal header
|
|
1076
|
+
// sentence -- the size is dropped here because this surface already states
|
|
1077
|
+
// an aggregate size (`the <M> that did fit in a <W>x<H> box`, next line),
|
|
1078
|
+
// and a per-element `<w>x<h>` here would be exactly the fact-shaped
|
|
1079
|
+
// measurement §2 argues this surface must not print. The size is not lost:
|
|
1080
|
+
// `q describe <id>` returns it, and the escape-hatch line below names that
|
|
1081
|
+
// command.
|
|
1082
|
+
const contentClause = sparseZeroBoxNotes.length > 0
|
|
1083
|
+
? `# text present but not yet laid out: ${sparseZeroBoxNotes.join('; ')}.`
|
|
1084
|
+
: '# no surviving record carries text outside a laid-out box.';
|
|
1085
|
+
const lines = [
|
|
1086
|
+
identity,
|
|
1087
|
+
`${exports.SPARSE_CAPTURE_MARKER} ${droppedFromTree} of ${totalRecords} records never reached a ` +
|
|
1088
|
+
`layout node (${(droppedRatio * 100).toFixed(1)}%), and the ${nodeCount} that did fit in a ` +
|
|
1089
|
+
`${page.width}x${page.height} box. A capture taken mid-hydration or mid-fetch looks exactly ` +
|
|
1090
|
+
`like this — the page had not laid out at the instant this capture ran.`,
|
|
1091
|
+
contentClause,
|
|
1092
|
+
`# to measure the page, capture later in its life: dnumb capture ${meta.url} --wait ` +
|
|
1093
|
+
`networkidle, or re-run --wait load after a settle delay. If a later capture still looks ` +
|
|
1094
|
+
`like this, that repetition is itself a measurement — the page never lays out, and two ` +
|
|
1095
|
+
`captures agreeing on that is the fact to report.`,
|
|
1096
|
+
'# nothing was discarded: q facts, q describe <id>, q find <text>, and look --focus <id> ' +
|
|
1097
|
+
'read this bundle in full; this gate changes only what look prints by default.',
|
|
1098
|
+
];
|
|
1099
|
+
return `${lines.join('\n')}\n`;
|
|
1100
|
+
}
|
|
1101
|
+
function renderDigest(snapshot, meta, options = {}) {
|
|
1102
|
+
// `budgetChars` is the standing meter (`DEFAULT_BUDGET_CHARS`'s doc
|
|
1103
|
+
// comment, `docs/notes/06-budget.md` §2/§5) and always applies.
|
|
1104
|
+
// `budgetLines`, when a caller passes it, is an *additional* ceiling on
|
|
1105
|
+
// top of it, never a replacement -- see `RenderDigestOptions.budgetLines`.
|
|
1106
|
+
// `overBudget` is the single predicate every stage-3 checkpoint below
|
|
1107
|
+
// calls, so "what counts as over budget" cannot drift between them.
|
|
1108
|
+
const budgetChars = options.budgetChars ?? DEFAULT_BUDGET_CHARS;
|
|
1109
|
+
const budgetLines = options.budgetLines;
|
|
1110
|
+
const overBudget = (lines) => {
|
|
1111
|
+
if (budgetLines !== undefined && lines.length > budgetLines)
|
|
1112
|
+
return true;
|
|
1113
|
+
return charLength(lines) > budgetChars;
|
|
1114
|
+
};
|
|
1115
|
+
// What the cue below names as "the budget", when it fires. By the time the
|
|
1116
|
+
// stage-3 loop stops, `overBudget` is false either way -- both meters are
|
|
1117
|
+
// satisfied at once -- so the final body's own size cannot say which one
|
|
1118
|
+
// actually did the binding; only what the caller passed can. A caller who
|
|
1119
|
+
// gave only `budgetLines` a real (finite) value gets it named in lines --
|
|
1120
|
+
// that is the one real constraint they gave this render, and naming the
|
|
1121
|
+
// *other* meter (the always-present `budgetChars`, default or not) points
|
|
1122
|
+
// them at a knob that was never why their page collapsed: they would raise
|
|
1123
|
+
// it and nothing would change. `Number.MAX_SAFE_INTEGER` counts as "not a
|
|
1124
|
+
// real value" here, not just "absent" -- callers isolating one meter for a
|
|
1125
|
+
// test pass the other one that sentinel explicitly, to disable it without
|
|
1126
|
+
// leaving it `undefined` (`undefined` would mean "use the default", which
|
|
1127
|
+
// for `budgetChars` is a real 32768, not disabled). Naming `budgetChars`
|
|
1128
|
+
// is otherwise correct -- it is the standing meter, explicit or default --
|
|
1129
|
+
// including when the caller gave both a real value (an intentionally rare,
|
|
1130
|
+
// ambiguous case with no single answer).
|
|
1131
|
+
const isRealBound = (n) => n !== undefined && n < Number.MAX_SAFE_INTEGER;
|
|
1132
|
+
const budgetLabel = isRealBound(budgetLines) && !isRealBound(options.budgetChars)
|
|
1133
|
+
? `${budgetLines} lines`
|
|
1134
|
+
: `${budgetChars} chars`;
|
|
1135
|
+
const byId = new Map(snapshot.elements.map((el) => [el.id, el]));
|
|
1136
|
+
const factsById = new Map();
|
|
1137
|
+
const factSignaturesById = new Map();
|
|
1138
|
+
for (const el of snapshot.elements) {
|
|
1139
|
+
const facts = (0, facts_js_1.factsFor)(el, byId);
|
|
1140
|
+
if (facts.length > 0) {
|
|
1141
|
+
factsById.set(el.id, facts.map((f) => f.text));
|
|
1142
|
+
factSignaturesById.set(el.id, facts.map(factSignature));
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
// Never drop an element the digest has something to say about. Without this
|
|
1146
|
+
// the zero-width column that the whole facts model exists to surface is
|
|
1147
|
+
// filtered out as "not visible" before it reaches a line.
|
|
1148
|
+
const { tree, dropped } = (0, layout_tree_js_1.buildLayoutTree)(snapshot.elements, {
|
|
1149
|
+
keepIds: new Set(factsById.keys()),
|
|
1150
|
+
});
|
|
1151
|
+
const roots = options.focus ? [findFocus(tree, options.focus)].filter((n) => n !== null) : tree;
|
|
1152
|
+
// `page`/`nodeCount` do not depend on rendering, only on `roots` -- lifted
|
|
1153
|
+
// here (rather than computed after `render()`, where the header line used
|
|
1154
|
+
// to compute them) so the sparse-capture condition below can be known
|
|
1155
|
+
// before `ctx.factsById` is handed to the render loop. The header further
|
|
1156
|
+
// down reuses these same three values rather than recomputing them.
|
|
1157
|
+
const nodeCount = roots.reduce((sum, n) => sum + countNodes(n), 0);
|
|
1158
|
+
const page = bbox(roots);
|
|
1159
|
+
const droppedFromTree = snapshot.elements.length - nodeCount;
|
|
1160
|
+
const droppedRatio = snapshot.elements.length > 0 ? droppedFromTree / snapshot.elements.length : 0;
|
|
1161
|
+
const sparseCapture = !options.focus && snapshot.elements.length >= 20 && droppedRatio >= 0.9 && page.height <= 2;
|
|
1162
|
+
// Hoisted here, ahead of the `sparseCapture` early return below, purely so
|
|
1163
|
+
// the identity line is computed exactly once regardless of which path this
|
|
1164
|
+
// call takes -- the refusal surface and the ordinary header both open with
|
|
1165
|
+
// it, verbatim.
|
|
1166
|
+
const viewport = meta.viewport.name ?? `${meta.viewport.width}x${meta.viewport.height}`;
|
|
1167
|
+
const strategyLabel = strategyLabelFor(meta);
|
|
1168
|
+
const identity = `# dnumb digest v2 — ${meta.url} — ${viewport}${meta.state ? ` — ${meta.state}` : ''}${strategyLabel ? ` — ${strategyLabel}` : ''}`;
|
|
1169
|
+
// THIRD RUN (`docs/notes/05-v3-control.md`) A/B/C/D-tested every placement
|
|
1170
|
+
// a caveat can occupy relative to a `zero-box` fact -- trailing on the
|
|
1171
|
+
// bracket (the previous shape of this block), interrupting it, bound to it
|
|
1172
|
+
// as a parenthetical, and removed from the fact entirely in favor of the
|
|
1173
|
+
// header alone. All four still fabricated a "broken page" narrative on
|
|
1174
|
+
// this exact clean fixture between 73% and 93% of the time, statistically
|
|
1175
|
+
// indistinguishable from each other. Run 3 §4's own diagnosis, quoted
|
|
1176
|
+
// here because it is the reason this block no longer emits a `zero-box`
|
|
1177
|
+
// fact at all under this condition: "the reader treats `zero-box` on
|
|
1178
|
+
// `<body>` -- the only fact in the entire digest, sitting on the root
|
|
1179
|
+
// element -- as a measured, load-bearing finding, and reasons forward
|
|
1180
|
+
// ... in one step ... No arrangement of the caveat's position ... changed
|
|
1181
|
+
// this reasoning step for a majority of readers in any arm." A `fact` is
|
|
1182
|
+
// the grammar slot this format reserves for "a real, adjudicable
|
|
1183
|
+
// measurement about this element" (`docs/digest.md`'s Facts section,
|
|
1184
|
+
// `AGENTS.md`'s "Facts, with counter-facts"); a capture caught mid-render
|
|
1185
|
+
// is not a property of `<body>`, it is a property of *this capture*, and
|
|
1186
|
+
// printing it in the fact grammar was asserting "defect found" in the
|
|
1187
|
+
// line's own form, independent of any prose next to it.
|
|
1188
|
+
//
|
|
1189
|
+
// So under `sparseCapture` -- the same rare, whole-capture condition
|
|
1190
|
+
// computed once above and never re-derived -- a `zero-box` fact is pulled
|
|
1191
|
+
// off the element entirely and its `content` clause (the one datum
|
|
1192
|
+
// `renderSparseRefusal`'s "text present but not yet laid out" line needs)
|
|
1193
|
+
// is carried forward into that surface instead. FOURTH RUN folded this
|
|
1194
|
+
// into the header sentence and kept printing the element's own line below
|
|
1195
|
+
// it (`e0 body @0,0 1024x0`); that measured 40-50% fabrication, still not
|
|
1196
|
+
// clean, because a reader reasoning about "the page" had an element line
|
|
1197
|
+
// to reason from at all. `docs/notes/07-refusal.md`'s decision is the next
|
|
1198
|
+
// step run 3 §8 recommendation 2 named as untested: no element line prints
|
|
1199
|
+
// under this condition either, so the geometry `zero-box` used to sit
|
|
1200
|
+
// beside is not discarded (`q describe <id>` / `q facts` still return it
|
|
1201
|
+
// in full) but is no longer printed as part of a would-be page
|
|
1202
|
+
// description. An ordinary zero-height spacer or collapsed sidebar column
|
|
1203
|
+
// outside this rare condition is untouched -- still a normal `zero-box`
|
|
1204
|
+
// fact on a normally-printed element line -- because that case is a real,
|
|
1205
|
+
// settled measurement about the page, not a capture-timing artifact.
|
|
1206
|
+
const sparseZeroBoxNotes = [];
|
|
1207
|
+
if (sparseCapture) {
|
|
1208
|
+
const zeroBoxPattern = /^([\d.]+)x([\d.]+) zero-box; content (.+)$/;
|
|
1209
|
+
for (const [id, facts] of [...factsById]) {
|
|
1210
|
+
const index = facts.findIndex((text) => zeroBoxPattern.test(text));
|
|
1211
|
+
if (index === -1)
|
|
1212
|
+
continue;
|
|
1213
|
+
const match = zeroBoxPattern.exec(facts[index] ?? '');
|
|
1214
|
+
if (!match)
|
|
1215
|
+
continue;
|
|
1216
|
+
const [, , , content] = match;
|
|
1217
|
+
const tag = byId.get(id)?.tag ?? '';
|
|
1218
|
+
// Width and height are deliberately not in this clause -- see
|
|
1219
|
+
// `renderSparseRefusal`'s doc comment on `contentClause` for why: the
|
|
1220
|
+
// refusal surface states an aggregate size one line up, and a
|
|
1221
|
+
// per-element `<w>x<h>` here would be the fact-shaped measurement §2
|
|
1222
|
+
// argues this surface must never print. `q describe <id>` still
|
|
1223
|
+
// returns it undiscarded.
|
|
1224
|
+
sparseZeroBoxNotes.push(`${id} ${tag} carries ${content}`);
|
|
1225
|
+
const remainingFacts = facts.filter((_, i) => i !== index);
|
|
1226
|
+
const remainingSignatures = (factSignaturesById.get(id) ?? []).filter((_, i) => i !== index);
|
|
1227
|
+
if (remainingFacts.length > 0) {
|
|
1228
|
+
factsById.set(id, remainingFacts);
|
|
1229
|
+
factSignaturesById.set(id, remainingSignatures);
|
|
1230
|
+
}
|
|
1231
|
+
else {
|
|
1232
|
+
factsById.delete(id);
|
|
1233
|
+
factSignaturesById.delete(id);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// `docs/notes/07-refusal.md` -- the decision of record. A digest body
|
|
1238
|
+
// claims "here is this page's layout"; on a capture this sparse that claim
|
|
1239
|
+
// is false even though every remaining number is true (§1.2). Returns
|
|
1240
|
+
// before any element line, grammar line, `# facts:` line, or budget/
|
|
1241
|
+
// collapse machinery runs -- none of that work is wasted per se, but none
|
|
1242
|
+
// of it may reach the reader either, so there is no reason to do it.
|
|
1243
|
+
if (sparseCapture) {
|
|
1244
|
+
return renderSparseRefusal(identity, meta, snapshot.elements.length, droppedFromTree, droppedRatio, nodeCount, page, sparseZeroBoxNotes);
|
|
1245
|
+
}
|
|
1246
|
+
const ctx = {
|
|
1247
|
+
byId,
|
|
1248
|
+
factsById,
|
|
1249
|
+
factSignaturesById,
|
|
1250
|
+
// Computed once, over the final `roots` (post-focus), before any
|
|
1251
|
+
// rendering happens -- see F1/F2 in the audit and this set's doc
|
|
1252
|
+
// comment on `Ctx`.
|
|
1253
|
+
hasDescendantFact: computeDeepFactIds(roots, factSignaturesById),
|
|
1254
|
+
viewportHeight: meta.viewport.height,
|
|
1255
|
+
aggressive: false,
|
|
1256
|
+
collapsed: new Set(),
|
|
1257
|
+
printedCollapsed: [],
|
|
1258
|
+
focused: Boolean(options.focus),
|
|
1259
|
+
};
|
|
1260
|
+
// Normal, then aggressive, then collapse candidate subtrees one at a
|
|
1261
|
+
// time. Folding harder is always preferred to hiding a subtree, and hiding a
|
|
1262
|
+
// subtree is always preferred to cutting the output off mid-tree.
|
|
1263
|
+
//
|
|
1264
|
+
// Both later stages are skipped entirely under `--focus` (`ctx.focused`) --
|
|
1265
|
+
// this is P2 in the soak. Stage 1 (fold) is already disabled per-node inside
|
|
1266
|
+
// `renderNodes` when focused (see `Ctx.focused`'s doc comment), but stage 3
|
|
1267
|
+
// (collapse) used to run anyway, picking its own candidates *inside* the
|
|
1268
|
+
// focused subtree and hiding them behind a fresh `[folded: …]` line. On a
|
|
1269
|
+
// real capture that meant the cue's own `(--focus <id>)` escape hatch led
|
|
1270
|
+
// nowhere: following it into `ar.wikipedia.org`'s `e143`→`e445`→`e1691`→
|
|
1271
|
+
// `e1702` chain peeled exactly one structural wrapper per hop while the
|
|
1272
|
+
// collapsed fraction stayed 97-99.9% the whole way (verified against this
|
|
1273
|
+
// repo's own `test/fixtures/real-sites/` stand-in below), and the identical
|
|
1274
|
+
// shape on `tailwindcss.com`'s deeply nested grid: six hops, still ~93-99%
|
|
1275
|
+
// collapsed at every one. Every hop's ids and arithmetic were correct --
|
|
1276
|
+
// stage 3 was reapplying the same budget to a subtree that was, on these
|
|
1277
|
+
// pages, itself bigger than the budget, so it just moved the frontier one
|
|
1278
|
+
// level down and hid the rest again.
|
|
1279
|
+
//
|
|
1280
|
+
// The fix applies the same reasoning `--focus` already applies to folding:
|
|
1281
|
+
// the caller explicitly asked to see *this* subtree, so re-hiding parts of
|
|
1282
|
+
// it defeats the request rather than serving it. Once focused, a collapse
|
|
1283
|
+
// has nothing left to justify either -- it is a compression device for
|
|
1284
|
+
// output the reader is not zoomed into. This can make a focused render
|
|
1285
|
+
// large (unbounded by `budget`) when the caller focuses something that is
|
|
1286
|
+
// itself most of the page, but that is the honest cost of "show me
|
|
1287
|
+
// everything under here," not a new failure mode: it is exactly what
|
|
1288
|
+
// focusing the document root would already mean with no collapse at all.
|
|
1289
|
+
// Guaranteed to converge in the one hop that matters: since nothing under a
|
|
1290
|
+
// focused render can be further collapsed, a single `--focus <id>` call
|
|
1291
|
+
// always reaches every fact in that subtree, with no further hint to chase.
|
|
1292
|
+
let body = [];
|
|
1293
|
+
const render = () => {
|
|
1294
|
+
const out = [];
|
|
1295
|
+
// Reset before every render -- `ctx.collapsed` only grows across the
|
|
1296
|
+
// stage-3 loop's repeated `render()` calls, but which collapses
|
|
1297
|
+
// actually printed a `[folded: …]` line can change on every call (a
|
|
1298
|
+
// newly-collapsed ancestor can swallow a previously-printed one, or a
|
|
1299
|
+
// stage-1 fold grouping can shift), so this render's own truth has to
|
|
1300
|
+
// be rebuilt from scratch each time, not accumulated. See
|
|
1301
|
+
// `Ctx.printedCollapsed`'s doc comment.
|
|
1302
|
+
ctx.printedCollapsed = [];
|
|
1303
|
+
// No shared parent id at the top level -- multiple document roots (rare,
|
|
1304
|
+
// but possible) have nothing in common to point `--focus` at. See
|
|
1305
|
+
// `renderNodes`'s `parentId` param and `foldLine`'s fallback.
|
|
1306
|
+
renderNodes(roots, 'column', 0, ctx, out, null);
|
|
1307
|
+
return out;
|
|
1308
|
+
};
|
|
1309
|
+
body = render();
|
|
1310
|
+
if (!ctx.focused && overBudget(body)) {
|
|
1311
|
+
ctx.aggressive = true;
|
|
1312
|
+
body = render();
|
|
1313
|
+
}
|
|
1314
|
+
if (!ctx.focused && overBudget(body)) {
|
|
1315
|
+
// Candidates are scored and sorted once. A candidate whose ancestor is
|
|
1316
|
+
// already collapsed is skipped without a `render()` call -- collapsing it
|
|
1317
|
+
// would change nothing (its subtree is already hidden inside the
|
|
1318
|
+
// ancestor's `[folded:]` line), but re-rendering the whole tree to
|
|
1319
|
+
// discover that is exactly the wasted work that made this loop O(candidates
|
|
1320
|
+
// * n) for no benefit on real pages with many small candidates (measured:
|
|
1321
|
+
// 2001 nodes / 400 candidates at 1572ms before this fix).
|
|
1322
|
+
for (const { node, ancestorIds, descendants } of collapseCandidates(roots)) {
|
|
1323
|
+
if (!overBudget(body))
|
|
1324
|
+
break;
|
|
1325
|
+
if (ancestorIds.some((id) => ctx.collapsed.has(id)))
|
|
1326
|
+
continue;
|
|
1327
|
+
// Height-ascending order (see `collapseCandidates`) tries small
|
|
1328
|
+
// candidates first now, so a container that wraps at most one other
|
|
1329
|
+
// node -- `descendants < 3` means collapsing it would remove at most
|
|
1330
|
+
// two lines and add one `[folded: …]` line back, net savings under
|
|
1331
|
+
// two -- can reach the front of the queue. Under the old
|
|
1332
|
+
// size-descending order the first candidate tried was always the
|
|
1333
|
+
// page's dominant subtree, so a near-zero-gain collapse never
|
|
1334
|
+
// happened in practice; it does now, and spending a `render()` call
|
|
1335
|
+
// on one buys nothing. `docs/notes/06-budget.md` §4: "a single-child
|
|
1336
|
+
// container's `[folded: 1 nodes…]` line replaces one line with one
|
|
1337
|
+
// line."
|
|
1338
|
+
if (descendants < 3)
|
|
1339
|
+
continue;
|
|
1340
|
+
ctx.collapsed.add(node.elementId);
|
|
1341
|
+
body = render();
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
// `nodeCount`/`page` were computed earlier, alongside `sparseCapture` --
|
|
1345
|
+
// see the comment there. Which single node the page bbox is dominated by,
|
|
1346
|
+
// when it is -- see `pageOutlier`'s doc comment. `page` itself stays the
|
|
1347
|
+
// honest, undiscarded full bbox; this is purely additional attribution for
|
|
1348
|
+
// the header line.
|
|
1349
|
+
const outlier = pageOutlier(flatten(roots), roots);
|
|
1350
|
+
// F3 in the audit: `<D> dropped` and `<K> wrappers folded` are always
|
|
1351
|
+
// computed over the *whole* capture (`buildLayoutTree(snapshot.elements,
|
|
1352
|
+
// ...)` above, unscoped by focus), while `<N>` deliberately stays the
|
|
1353
|
+
// whole-capture record count too (see the sparse-capture comment below) but
|
|
1354
|
+
// `<M>` (`nodeCount`) is scoped to the focused subtree. Printing all four
|
|
1355
|
+
// together under `--focus` is what produced `985 records → 797 layout
|
|
1356
|
+
// nodes, 127 dropped, 46 wrappers folded` on a real capture -- numbers that
|
|
1357
|
+
// look like one equation and are not (797+127+46=970≠985), because two of
|
|
1358
|
+
// them describe the focused subtree and two describe the whole page.
|
|
1359
|
+
// Nothing here can make that equation hold without either quietly
|
|
1360
|
+
// rescoping `<N>` (which the sparse-capture heuristic below depends on
|
|
1361
|
+
// staying whole-capture) or computing a second, subtree-only dropped/folded
|
|
1362
|
+
// count that would need its own `<N>` to sum against -- more numbers, not a
|
|
1363
|
+
// fix. Omitting the clause under `--focus` says only what is true: how many
|
|
1364
|
+
// layout nodes this subtree rendered as, out of how many records the whole
|
|
1365
|
+
// capture had.
|
|
1366
|
+
const droppedClause = options.focus
|
|
1367
|
+
? ''
|
|
1368
|
+
: `, ${dropped.invisible + dropped.svgInternal} dropped, ${dropped.wrappers} wrappers folded`;
|
|
1369
|
+
// `page.width x page.height` is a *size*, and always has been -- but a
|
|
1370
|
+
// reader who has only ever seen it printed alone reasonably assumes the
|
|
1371
|
+
// bbox starts at the document origin, because that is true on almost every
|
|
1372
|
+
// real page. `docs/notes/05-v3-control.md` THIRD RUN §6 measured a rater
|
|
1373
|
+
// reading `form-control.html`'s honest `# page: 1024x610` against a body
|
|
1374
|
+
// that itself opens `@0,40` and computing a false "content overflows the
|
|
1375
|
+
// page by 7px": the rater subtracted a child's absolute bottom edge (617)
|
|
1376
|
+
// from 610 as if 610 were also measured from y=0, when it was really
|
|
1377
|
+
// measured from the bbox's own top at y=40 (true absolute bottom: 650).
|
|
1378
|
+
// The element-line grammar (`@x,y WxH`) never produced this failure in any
|
|
1379
|
+
// of the three runs -- only this header's size-with-no-origin form did --
|
|
1380
|
+
// so the fix reuses that same, already-trusted token order rather than
|
|
1381
|
+
// adding prose: when the bbox's own top-left is not the document origin,
|
|
1382
|
+
// print it, in the identical `@x,y` form every element line already
|
|
1383
|
+
// carries. Omitted when the origin is `0,0` (the overwhelming common case,
|
|
1384
|
+
// and every existing fixture in this repo), so this changes nothing about
|
|
1385
|
+
// the ordinary page.
|
|
1386
|
+
const originClause = page.x !== 0 || page.y !== 0 ? `@${page.x},${page.y} ` : '';
|
|
1387
|
+
const header = [
|
|
1388
|
+
identity,
|
|
1389
|
+
'# grammar: id tag[.role|#testid] ["text"] @x,y WxH [flow] [↓gap|→gap to prev sibling] [facts]; px, integers; y>viewport.height is below the fold',
|
|
1390
|
+
`# page: ${originClause}${page.width}x${page.height} (${snapshot.elements.length} records → ${nodeCount} layout nodes${droppedClause}${outlier ? `; ${outlier.id} extends the page ${outlier.axis === 'width' ? 'width' : 'height'} by ${outlier.extent}px beyond the next element` : ''})`,
|
|
1391
|
+
];
|
|
1392
|
+
// A capture is one instant. Land it mid-hydration, mid-animation, or
|
|
1393
|
+
// mid-fetch and almost every record has no box yet -- the page line above
|
|
1394
|
+
// already has both numbers this needs, they are just not next to each
|
|
1395
|
+
// other: nearly all records missing from the layout tree, and a page bbox
|
|
1396
|
+
// shrunk toward zero. This states that pair, the same way an overflow fact
|
|
1397
|
+
// states a measurement beside the CSS that disambiguates it, and does not
|
|
1398
|
+
// decide that *this* capture is unsettled -- only a reader knows whether a
|
|
1399
|
+
// re-capture is warranted. Gated on the raw element count (skip tiny
|
|
1400
|
+
// fixtures, where one hidden element is 10%+ of the page) and skipped
|
|
1401
|
+
// under `--focus` (nodeCount then counts one subtree while elements.length
|
|
1402
|
+
// still counts the whole capture, so the ratio is not comparable).
|
|
1403
|
+
//
|
|
1404
|
+
// The explanation leads the sentence, the numbers follow it -- not the
|
|
1405
|
+
// reverse. A V3 control-page blind-read arm (`docs/notes/05-v3-control.md`
|
|
1406
|
+
// §5-6) measured what the old, numbers-first order did on a real, honest
|
|
1407
|
+
// mid-hydration capture: 1 of 3 fresh readers of the identical text opened
|
|
1408
|
+
// "Yes -- a few things stand out," called the zero-height body "a
|
|
1409
|
+
// broken/collapsed layout," and concluded "a real user loading this page
|
|
1410
|
+
// would most likely see a blank/stuck screen." The other two, reading the
|
|
1411
|
+
// same text, correctly called it an ordinary loading state. The old line
|
|
1412
|
+
// put `94.7%` and `1025x1` first and the explanation last, as a trailing
|
|
1413
|
+
// clause after an em dash -- a reader forms an impression from the alarming
|
|
1414
|
+
// numbers before reaching the sentence that defuses them, per §6's own
|
|
1415
|
+
// diagnosis: "the explanation is one clause at the end of one line,
|
|
1416
|
+
// competing with the far more visually alarming numbers in front of it."
|
|
1417
|
+
// Every number is still here, unsoftened -- only the order changed, so a
|
|
1418
|
+
// reader reaches "this is what a capture like this looks like" before, not
|
|
1419
|
+
// after, the count and the bbox that support it. Word order alone did not
|
|
1420
|
+
// hold up under measurement (`docs/notes/05-v3-control.md` SECOND RUN §5:
|
|
1421
|
+
// the reword above measurably improved how a fabricating rater engaged
|
|
1422
|
+
// with the explanation, but not how often fabrication happened at all,
|
|
1423
|
+
// 33%→50% on a larger sample) -- kept anyway, because it is still a real,
|
|
1424
|
+
// if insufficient, improvement in mechanism, not reverted on the strength
|
|
1425
|
+
// of a result it was never sufficient to fix alone.
|
|
1426
|
+
//
|
|
1427
|
+
// This paragraph and the two above it describe FOURTH RUN's shape, which
|
|
1428
|
+
// no longer reaches this function at all: `sparseCapture` now returns via
|
|
1429
|
+
// `renderSparseRefusal` before any of this header is built (see the early
|
|
1430
|
+
// return above, and `docs/notes/07-refusal.md`). Left here rather than
|
|
1431
|
+
// deleted because the reasoning -- "no adjacent prose out-argues the
|
|
1432
|
+
// grammatical form a claim arrives in" -- is exactly why the refusal
|
|
1433
|
+
// surface prints no fact bracket and no grammar line either; only the
|
|
1434
|
+
// *placement* of that reasoning moved.
|
|
1435
|
+
if (factsById.size > 0) {
|
|
1436
|
+
header.push(`# facts: ${factsById.size} ${factsById.size === 1 ? 'element carries' : 'elements carry'} facts`);
|
|
1437
|
+
}
|
|
1438
|
+
// P1-1: at the CLI default, whole-page (or near-whole-page) collapse was
|
|
1439
|
+
// the *common* outcome on a real production page -- stage 3 doing exactly
|
|
1440
|
+
// what `docs/digest.md`'s line budget section says it may do ("the
|
|
1441
|
+
// intended last resort, not a bug"), but with nothing telling a reader
|
|
1442
|
+
// that is what just happened. `[folded: …]`'s own `(--focus <id>)` (see
|
|
1443
|
+
// `renderChildren` above) is the fix for "how do I see inside it"; this is
|
|
1444
|
+
// the fix for "notice that you need to" -- a header-level cue that fires
|
|
1445
|
+
// only when a collapse ate *most* of what this render had to show
|
|
1446
|
+
// (`COLLAPSE_CUE_THRESHOLD`), not on the ordinary partial collapse that
|
|
1447
|
+
// hides one heavy sidebar out of an otherwise-fine page. `nodeCount` in
|
|
1448
|
+
// the denominator, not `snapshot.elements.length`, matches what every
|
|
1449
|
+
// other number on this line is scoped to (the rendered tree, post-focus).
|
|
1450
|
+
//
|
|
1451
|
+
// Two bugs proved live on `linear.app` (a real soak capture, not a
|
|
1452
|
+
// constructed one): a single dominant collapse hid 1738 of 2517 layout
|
|
1453
|
+
// nodes (69%) but only 295 of the page's 1072 facts (27.5%) lived inside
|
|
1454
|
+
// it, and the gate used to fall back to the *node* fraction only when the
|
|
1455
|
+
// page had *zero* facts anywhere -- with any facts present at all
|
|
1456
|
+
// (27.5% < 50%), it read only the fact side and stayed silent on a page
|
|
1457
|
+
// whose structure was 69% invisible. `totalFacts` is now the max of the
|
|
1458
|
+
// node fraction and the fact fraction, not a fact-only reading with a
|
|
1459
|
+
// zero-fact escape hatch: a collapse a reader needs to notice can be
|
|
1460
|
+
// dominant in nodes without being dominant in facts (a huge, mostly
|
|
1461
|
+
// plain-structure sidebar) or the reverse (a small, fact-dense widget), and
|
|
1462
|
+
// either shape alone is real budget-pressure-hides-most-of-the-page, not a
|
|
1463
|
+
// false alarm.
|
|
1464
|
+
//
|
|
1465
|
+
// `totalFacts` itself used to be `factsById.size` -- every fact in the
|
|
1466
|
+
// *whole snapshot*, unscoped by `--focus`. Under focus that stayed wrong in
|
|
1467
|
+
// its own way: `--focus e4` on the same `linear.app` capture collapsed
|
|
1468
|
+
// 93.6% of `e4`'s own local nodes (1628/1739) but was still divided against
|
|
1469
|
+
// the page's whole-capture 1072 facts, so it stayed silent too. It is now
|
|
1470
|
+
// counted over `roots` -- the current render's own scope, the focused
|
|
1471
|
+
// subtree under `--focus` and the whole page otherwise -- the same scoping
|
|
1472
|
+
// `nodeCount` and `hiddenNodes` already use, so a focused render is judged
|
|
1473
|
+
// against what it itself contains, not the page it was carved out of.
|
|
1474
|
+
if (ctx.collapsed.size > 0) {
|
|
1475
|
+
// `ctx.printedCollapsed` from the final `render()` call above -- the
|
|
1476
|
+
// ids that actually printed a `[folded: …]` line, in the order they
|
|
1477
|
+
// printed -- not the raw `ctx.collapsed`. See `collapsedTotals`'s doc
|
|
1478
|
+
// comment for the two ways those sets can diverge. `collapsedIds[0]` --
|
|
1479
|
+
// the cue's `--focus` hint -- is therefore also the first collapse a
|
|
1480
|
+
// reader scanning the body top to bottom would actually reach.
|
|
1481
|
+
const collapsedIds = ctx.printedCollapsed;
|
|
1482
|
+
const { nodes: hiddenNodes, facts: hiddenFacts } = collapsedTotals(collapsedIds, roots, factsById);
|
|
1483
|
+
const totalFacts = roots.reduce((sum, n) => sum + countFacts(n, factsById), 0);
|
|
1484
|
+
const nodeFraction = nodeCount > 0 ? hiddenNodes / nodeCount : 0;
|
|
1485
|
+
const factFraction = totalFacts > 0 ? hiddenFacts / totalFacts : 0;
|
|
1486
|
+
const hiddenFraction = Math.max(nodeFraction, factFraction);
|
|
1487
|
+
if (hiddenFraction >= COLLAPSE_CUE_THRESHOLD) {
|
|
1488
|
+
const factsClause = totalFacts > 0 ? ` and ${hiddenFacts} of ${totalFacts} facts` : '';
|
|
1489
|
+
// `budgetLabel` names whichever meter the caller actually bounded this
|
|
1490
|
+
// render with -- see its own doc comment above for why the final
|
|
1491
|
+
// body's size cannot answer that question by itself.
|
|
1492
|
+
header.push(`# most of this page is behind collapse at budget ${budgetLabel}: ${hiddenNodes} of ${nodeCount} ` +
|
|
1493
|
+
`layout nodes${factsClause} (--focus ${collapsedIds[0]})`);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
// A measurement of this page, not a permanent property of the capture path
|
|
1497
|
+
// (that distinction is the identity line's `— walk`/`— cdp` segment) --
|
|
1498
|
+
// gated on having at least one color fact to report, same as `# facts:`
|
|
1499
|
+
// just above, and skipped entirely under `--focus`, unlike `# facts:`.
|
|
1500
|
+
//
|
|
1501
|
+
// `snapshot.documentBackground` is `<html>`'s own background-color plus
|
|
1502
|
+
// color-scheme, the terminal layer `resolveBackdrop` falls through to when
|
|
1503
|
+
// no element in the ancestor chain up to `<body>` paints one -- both
|
|
1504
|
+
// capture paths start walking backgrounds at `<body>`, so without this a
|
|
1505
|
+
// page whose real background lives on `<html>` (or the browser's own UA
|
|
1506
|
+
// canvas default) had no terminal layer to resolve at all. Before this was
|
|
1507
|
+
// wired through, that case did not error or go silent -- `colorFactFor`
|
|
1508
|
+
// still returned a fact, just one that had walked off the end of the
|
|
1509
|
+
// chain with `bgRgb: null` and no distinguishing `ratioReason`, which
|
|
1510
|
+
// upstream code was fabricating white from (`ratio 1.00`, `on #ffffff via
|
|
1511
|
+
// e0` for an element with no background at all -- white text on a
|
|
1512
|
+
// dark-mode app measuring as if printed on itself). Passing this through
|
|
1513
|
+
// is what turns that into the honest `unmeasured (canvas)` at minimum, and
|
|
1514
|
+
// a real resolved ratio (`on #121212 via canvas; ratio 18.73`) whenever a
|
|
1515
|
+
// capture actually recorded the document background.
|
|
1516
|
+
//
|
|
1517
|
+
// `!options.focus` was added, not present before: `colorFactsForAll` walks
|
|
1518
|
+
// and composites *every* element's backdrop chain, unscoped by `roots`,
|
|
1519
|
+
// the same whole-capture cost regardless of what the render is actually
|
|
1520
|
+
// showing. Measured on `linear-app` (4,242 elements): ~235ms of a ~236ms
|
|
1521
|
+
// focused render -- 200x the cost of everything else `renderDigest` does
|
|
1522
|
+
// for that call (`buildLayoutTree`, `factsForAll` are ~1ms each on the
|
|
1523
|
+
// same bundle) -- to compute a header line describing colors across a
|
|
1524
|
+
// page the reader explicitly narrowed away from. `# facts:` stays
|
|
1525
|
+
// unconditional because it is cheap (`factsById` is already built, for
|
|
1526
|
+
// reasons unrelated to this header, before focus is even applied) and
|
|
1527
|
+
// genuinely whole-capture by design -- the same "describes the whole
|
|
1528
|
+
// capture, not a permanent property, not a comparable partial count" this
|
|
1529
|
+
// comment opened with. `# colors:` fails both: expensive to recompute for
|
|
1530
|
+
// a response that was never going to use most of it, and, per the F3
|
|
1531
|
+
// reasoning that already gates the dropped/wrappers-folded clause above,
|
|
1532
|
+
// arguably the wrong number to hand back at all once a caller has said
|
|
1533
|
+
// "I only asked about this subtree." Scoping the pass to `roots` instead
|
|
1534
|
+
// of skipping it was considered and rejected: it would still cost
|
|
1535
|
+
// O(subtree) work for a number whose whole point (per this function's own
|
|
1536
|
+
// doc comment) is to describe the *page's* color story, not one
|
|
1537
|
+
// container's -- a subtree-scoped `# colors:` reads as page-scoped and
|
|
1538
|
+
// would not be. Caching was also considered and rejected: `renderDigest`
|
|
1539
|
+
// computes this once per call already, and the real-world cost above was
|
|
1540
|
+
// measured on a single `dnumb look --focus <id>` process, which never
|
|
1541
|
+
// calls it twice -- a cache only pays for itself across repeated calls on
|
|
1542
|
+
// the same snapshot, which is not this package's usage pattern.
|
|
1543
|
+
const colorFacts = options.focus
|
|
1544
|
+
? []
|
|
1545
|
+
: (0, color_js_1.colorFactsForAll)(snapshot.elements, snapshot.documentBackground);
|
|
1546
|
+
if (colorFacts.length > 0)
|
|
1547
|
+
header.push(colorHeaderLine(colorFacts));
|
|
1548
|
+
if (snapshot.truncated) {
|
|
1549
|
+
header.push(`# TRUNCATED: the capture stopped at the element cap; this page has more than ${snapshot.elements.length} elements`);
|
|
1550
|
+
}
|
|
1551
|
+
// Shadow DOM and iframe content neither capture path enters. Absent (not
|
|
1552
|
+
// `false`) on a hand-built `Snapshot` with no opinion on it -- see the
|
|
1553
|
+
// `NotMeasured` doc comment -- so this only fires for a real capture, and
|
|
1554
|
+
// only when there is something to disclose.
|
|
1555
|
+
const notMeasured = snapshot.notMeasured ? (0, not_measured_js_1.formatNotMeasured)(snapshot.notMeasured) : null;
|
|
1556
|
+
if (notMeasured)
|
|
1557
|
+
header.push(`# ${notMeasured}`);
|
|
1558
|
+
if (options.edges) {
|
|
1559
|
+
// A page-wide histogram of where boxes start and end. Alignment is not
|
|
1560
|
+
// adjudicated anywhere in this package -- the prototype's `alignment` check
|
|
1561
|
+
// produced zero confirmed true positives across its whole life -- but the
|
|
1562
|
+
// raw distribution is still the thing a reader would want in order to
|
|
1563
|
+
// notice that four of five cards share a left edge and the fifth does not.
|
|
1564
|
+
const visible = snapshot.elements.filter((el) => el.visible);
|
|
1565
|
+
const cluster = (values) => (0, cluster_js_1.clusterEdges)(values, 2)
|
|
1566
|
+
.sort((a, b) => b.count - a.count)
|
|
1567
|
+
.slice(0, 5)
|
|
1568
|
+
.map((c) => `${round(c.value)}×${c.count}`)
|
|
1569
|
+
.join(', ');
|
|
1570
|
+
header.push(`# left edges: ${cluster(visible.map((el) => el.rect.x))}` +
|
|
1571
|
+
` · right edges: ${cluster(visible.map((el) => el.rect.x + el.rect.width))}` +
|
|
1572
|
+
` · top edges: ${cluster(visible.map((el) => el.rect.y))}`);
|
|
1573
|
+
}
|
|
1574
|
+
return `${[...header, ...body].join('\n')}\n`;
|
|
1575
|
+
}
|