@michaelmagan/dnumb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +297 -0
  4. package/dist/analysis/cluster.cjs +114 -0
  5. package/dist/analysis/cluster.d.cts +68 -0
  6. package/dist/analysis/cluster.d.ts +68 -0
  7. package/dist/analysis/cluster.js +109 -0
  8. package/dist/analysis/distances.cjs +124 -0
  9. package/dist/analysis/distances.d.cts +58 -0
  10. package/dist/analysis/distances.d.ts +58 -0
  11. package/dist/analysis/distances.js +119 -0
  12. package/dist/analysis/geometry.cjs +60 -0
  13. package/dist/analysis/geometry.d.cts +34 -0
  14. package/dist/analysis/geometry.d.ts +34 -0
  15. package/dist/analysis/geometry.js +53 -0
  16. package/dist/analysis/layout-tree.cjs +245 -0
  17. package/dist/analysis/layout-tree.d.cts +45 -0
  18. package/dist/analysis/layout-tree.d.ts +45 -0
  19. package/dist/analysis/layout-tree.js +242 -0
  20. package/dist/analysis/region-query.cjs +71 -0
  21. package/dist/analysis/region-query.d.cts +25 -0
  22. package/dist/analysis/region-query.d.ts +25 -0
  23. package/dist/analysis/region-query.js +67 -0
  24. package/dist/analysis/types.cjs +7 -0
  25. package/dist/analysis/types.d.cts +143 -0
  26. package/dist/analysis/types.d.ts +143 -0
  27. package/dist/analysis/types.js +6 -0
  28. package/dist/bundle.cjs +182 -0
  29. package/dist/bundle.d.cts +91 -0
  30. package/dist/bundle.d.ts +91 -0
  31. package/dist/bundle.js +175 -0
  32. package/dist/cli.cjs +755 -0
  33. package/dist/cli.d.cts +2 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +719 -0
  36. package/dist/diff/changes.cjs +656 -0
  37. package/dist/diff/changes.d.cts +2 -0
  38. package/dist/diff/changes.d.ts +2 -0
  39. package/dist/diff/changes.js +653 -0
  40. package/dist/diff/identity.cjs +167 -0
  41. package/dist/diff/identity.d.cts +62 -0
  42. package/dist/diff/identity.d.ts +62 -0
  43. package/dist/diff/identity.js +159 -0
  44. package/dist/diff/index.cjs +84 -0
  45. package/dist/diff/index.d.cts +76 -0
  46. package/dist/diff/index.d.ts +76 -0
  47. package/dist/diff/index.js +68 -0
  48. package/dist/diff/match.cjs +156 -0
  49. package/dist/diff/match.d.cts +17 -0
  50. package/dist/diff/match.d.ts +17 -0
  51. package/dist/diff/match.js +152 -0
  52. package/dist/diff/render.cjs +540 -0
  53. package/dist/diff/render.d.cts +2 -0
  54. package/dist/diff/render.d.ts +2 -0
  55. package/dist/diff/render.js +537 -0
  56. package/dist/diff/types.cjs +2 -0
  57. package/dist/diff/types.d.cts +211 -0
  58. package/dist/diff/types.d.ts +211 -0
  59. package/dist/diff/types.js +1 -0
  60. package/dist/digest/index.cjs +8 -0
  61. package/dist/digest/index.d.cts +4 -0
  62. package/dist/digest/index.d.ts +4 -0
  63. package/dist/digest/index.js +2 -0
  64. package/dist/digest/parse.cjs +163 -0
  65. package/dist/digest/parse.d.cts +80 -0
  66. package/dist/digest/parse.d.ts +80 -0
  67. package/dist/digest/parse.js +160 -0
  68. package/dist/digest/render.cjs +1575 -0
  69. package/dist/digest/render.d.cts +41 -0
  70. package/dist/digest/render.d.ts +41 -0
  71. package/dist/digest/render.js +1571 -0
  72. package/dist/digest/types.cjs +10 -0
  73. package/dist/digest/types.d.cts +86 -0
  74. package/dist/digest/types.d.ts +86 -0
  75. package/dist/digest/types.js +9 -0
  76. package/dist/index.cjs +61 -0
  77. package/dist/index.d.cts +27 -0
  78. package/dist/index.d.ts +27 -0
  79. package/dist/index.js +19 -0
  80. package/dist/page.cjs +35 -0
  81. package/dist/page.d.cts +40 -0
  82. package/dist/page.d.ts +40 -0
  83. package/dist/page.js +31 -0
  84. package/dist/playwright.cjs +138 -0
  85. package/dist/playwright.d.cts +69 -0
  86. package/dist/playwright.d.ts +69 -0
  87. package/dist/playwright.js +131 -0
  88. package/dist/query.cjs +1152 -0
  89. package/dist/query.d.cts +47 -0
  90. package/dist/query.d.ts +47 -0
  91. package/dist/query.js +1146 -0
  92. package/dist/snapshot/capture.cjs +80 -0
  93. package/dist/snapshot/capture.d.cts +34 -0
  94. package/dist/snapshot/capture.d.ts +34 -0
  95. package/dist/snapshot/capture.js +77 -0
  96. package/dist/snapshot/cdp.cjs +450 -0
  97. package/dist/snapshot/cdp.d.cts +36 -0
  98. package/dist/snapshot/cdp.d.ts +36 -0
  99. package/dist/snapshot/cdp.js +447 -0
  100. package/dist/snapshot/clip.cjs +100 -0
  101. package/dist/snapshot/clip.d.cts +26 -0
  102. package/dist/snapshot/clip.d.ts +26 -0
  103. package/dist/snapshot/clip.js +96 -0
  104. package/dist/snapshot/collect.cjs +358 -0
  105. package/dist/snapshot/collect.d.cts +18 -0
  106. package/dist/snapshot/collect.d.ts +18 -0
  107. package/dist/snapshot/collect.js +355 -0
  108. package/dist/snapshot/color.cjs +1039 -0
  109. package/dist/snapshot/color.d.cts +316 -0
  110. package/dist/snapshot/color.d.ts +316 -0
  111. package/dist/snapshot/color.js +1026 -0
  112. package/dist/snapshot/facts.cjs +167 -0
  113. package/dist/snapshot/facts.d.cts +48 -0
  114. package/dist/snapshot/facts.d.ts +48 -0
  115. package/dist/snapshot/facts.js +161 -0
  116. package/dist/snapshot/limits.cjs +69 -0
  117. package/dist/snapshot/limits.d.cts +59 -0
  118. package/dist/snapshot/limits.d.ts +59 -0
  119. package/dist/snapshot/limits.js +65 -0
  120. package/dist/snapshot/not-measured.cjs +44 -0
  121. package/dist/snapshot/not-measured.d.cts +14 -0
  122. package/dist/snapshot/not-measured.d.ts +14 -0
  123. package/dist/snapshot/not-measured.js +41 -0
  124. package/dist/snapshot/types.cjs +2 -0
  125. package/dist/snapshot/types.d.cts +238 -0
  126. package/dist/snapshot/types.d.ts +238 -0
  127. package/dist/snapshot/types.js +1 -0
  128. package/dist/spec/index.cjs +9 -0
  129. package/dist/spec/index.d.cts +15 -0
  130. package/dist/spec/index.d.ts +15 -0
  131. package/dist/spec/index.js +6 -0
  132. package/package.json +141 -0
  133. package/skills/dnumb/SKILL.md +136 -0
@@ -0,0 +1,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
+ }