@michaelmagan/dnumb 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +27 -17
- package/README.md +26 -10
- package/dist/cli.cjs +0 -0
- package/dist/digest/render.cjs +77 -1
- package/dist/digest/render.js +77 -1
- package/docs/digest.md +1600 -0
- package/package.json +20 -20
- package/skills/dnumb/SKILL.md +20 -10
package/docs/digest.md
ADDED
|
@@ -0,0 +1,1600 @@
|
|
|
1
|
+
# The dnumb digest format
|
|
2
|
+
|
|
3
|
+
Spec version 1. Digest grammar version 2. Produced by `renderDigest`, read back by
|
|
4
|
+
`parseDigestLine`. Both are exported from the package root.
|
|
5
|
+
|
|
6
|
+
A digest is a rendered page written as text: one line per element that stands on its own, one fold
|
|
7
|
+
line standing in for a whole run of repeated siblings at once (see [The fold
|
|
8
|
+
line](#the-fold-line)), positions and sizes in CSS pixels, plus the measurements that say whether
|
|
9
|
+
content fits inside its box. It exists so a model can answer *where is this and how big is it*
|
|
10
|
+
without looking at an image.
|
|
11
|
+
|
|
12
|
+
It reports. It does not adjudicate. Every number that could mean two things is printed beside the
|
|
13
|
+
CSS that decides which, and the reader draws the conclusion. See
|
|
14
|
+
[Facts](#facts) and [Reading a fact](#reading-a-fact).
|
|
15
|
+
|
|
16
|
+
## Producing one
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { capture, renderDigest } from '@michaelmagan/dnumb';
|
|
20
|
+
|
|
21
|
+
const snapshot = await capture(page); // page: any object with `evaluate`
|
|
22
|
+
const digest = renderDigest(snapshot, {
|
|
23
|
+
url: page.url(),
|
|
24
|
+
viewport: { width: 1024, height: 768 },
|
|
25
|
+
state: 'menu-open',
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or from a bundle written earlier, on a machine with no browser:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { parseBundle, renderDigest } from '@michaelmagan/dnumb';
|
|
33
|
+
import { readFileSync } from 'node:fs';
|
|
34
|
+
|
|
35
|
+
const bundle = parseBundle(readFileSync('.dnumb/settings-desktop.json', 'utf8'));
|
|
36
|
+
const digest = renderDigest(bundle, {
|
|
37
|
+
url: bundle.url,
|
|
38
|
+
viewport: bundle.viewport,
|
|
39
|
+
state: bundle.name,
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A `Bundle` satisfies the `Snapshot` shape `renderDigest` takes, so no conversion step exists.
|
|
44
|
+
|
|
45
|
+
`renderDigest(snapshot, meta, options?)`:
|
|
46
|
+
|
|
47
|
+
| Argument | Shape |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `snapshot` | `{ elements: ElementRecord[]; truncated: boolean; viewport: { width, height }; notMeasured?: NotMeasured }` |
|
|
50
|
+
| `meta` | `{ url: string; viewport: { name?, width, height }; state?: string; capturedAt?: string; strategy?: 'cdp' \| 'walk'; cdpFellBack?: boolean }` |
|
|
51
|
+
| `options.budgetChars` | Soft character budget for the body — the standing meter, always active. Default `32768`. |
|
|
52
|
+
| `options.budgetLines` | Soft line budget for the body. Optional, no default of its own; when a caller passes it, it is an *additional* ceiling on top of `options.budgetChars`, never a replacement for it. |
|
|
53
|
+
| `options.focus` | Element id or tag name. Scopes output to that subtree. Neither budget applies once focused. |
|
|
54
|
+
| `options.edges` | `true` adds the edge-cluster header. Default `false`. |
|
|
55
|
+
|
|
56
|
+
The return value is one string ending in `\n`.
|
|
57
|
+
|
|
58
|
+
## A complete digest
|
|
59
|
+
|
|
60
|
+
Captured from `test/fixtures/clipped-menu.html` at 1024x768, after the menu was opened:
|
|
61
|
+
|
|
62
|
+
**Live example** (`fixture=test/fixtures/clipped-menu.html url=file://.../clipped-menu.html state=menu-open after=__toB`) — regenerated fresh by `test/browser/doc-examples.test.ts` every run; if the renderer's output ever drifts from this block, that test fails before a reader trusts it.
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
# dnumb digest v2 — file://.../clipped-menu.html — 1024x768 — menu-open — cdp
|
|
66
|
+
# 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
|
|
67
|
+
# page: 1024x213 (10 records → 10 layout nodes, 0 dropped, 0 wrappers folded)
|
|
68
|
+
# facts: 5 elements carry facts
|
|
69
|
+
# colors: 4 fg/bg pairs across 7 text elements; ratio 18.26–18.88; lowest e3 #000000 on #efefef self 13.3px/400; 1 unmeasured (1 under)
|
|
70
|
+
e0 body @0,0 1024x181
|
|
71
|
+
e1 h1.heading "Account settings" @24,24 976x25
|
|
72
|
+
e2 div @24,65 976x44 [row] ↓16 [overflow-y +105px; clip]
|
|
73
|
+
e3 button.button "Actions" @39,77 60x21
|
|
74
|
+
e4 span "3 selected" @111,75 90x24 →12
|
|
75
|
+
e5 nav.menu "Actions" @39,104 182x109 →-110 [clipped by e2 div; overflow:hidden, not scrollable; 103.8px below, 182x5 visible]
|
|
76
|
+
e6 a.menuitem "Rename" @40,105 180x36 [clipped by e2 div; overflow:hidden, not scrollable; 31.6px below, 180x4 visible]
|
|
77
|
+
e7 a.menuitem "Duplicate" @40,141 180x36 ↓0 [clipped by e2 div; overflow:hidden, not scrollable; 67.2px below, nothing visible]
|
|
78
|
+
e8 a.menuitem "Delete" @40,176 180x36 ↓0 [clipped by e2 div; overflow:hidden, not scrollable; 102.8px below, nothing visible]
|
|
79
|
+
e9 p.paragraph "The menu is clipped by the too…" @24,123 976x20 ↓14
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The 44px-tall toolbar `e2` reports 105px of vertical overflow with `clip` beside it, and the menu
|
|
83
|
+
`e5` overlaps its own previous sibling `e4` — negative, per [gap](#gap) below. Neither line says
|
|
84
|
+
"bug". The reader combines them. `e6`, `e7` and `e8` do not fold together despite sharing a tag,
|
|
85
|
+
role and box size: a `clipped` fact is part of the fold signature the same way an `overflow-x` fact
|
|
86
|
+
is, and `e6` is genuinely different from the other two — it still shows a 4px sliver where they
|
|
87
|
+
show nothing.
|
|
88
|
+
|
|
89
|
+
## Header lines
|
|
90
|
+
|
|
91
|
+
Every header line starts with `#`. `parseDigestLine` returns `null` for all of them. They are
|
|
92
|
+
emitted in this order; lines 4-10 are conditional. **One condition is not merely conditional but
|
|
93
|
+
exclusive: under sparse capture (item 4), items 2 and 3 below do not print at all, nothing after
|
|
94
|
+
item 4 prints either, and item 4 itself becomes four different lines replacing the entire body —
|
|
95
|
+
see item 4 for why and exactly what prints instead.** Every "always" below should be read as "always,
|
|
96
|
+
except when item 4's refusal condition has already replaced the body.
|
|
97
|
+
|
|
98
|
+
**1. Identity — always, including under the item 4 refusal.**
|
|
99
|
+
|
|
100
|
+
**Grammar template** — placeholders, not real output; see the complete digest above for what the
|
|
101
|
+
segments look like filled in.
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
# dnumb digest v2 — <url> — <viewport> — <state> — <strategy>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`<url>` is `meta.url` verbatim. `<viewport>` is `meta.viewport.name` when set, otherwise
|
|
108
|
+
`<width>x<height>`. The ` — <state>` segment is omitted entirely when `meta.state` is absent.
|
|
109
|
+
Two captures of one URL are only distinguishable by `state`, so name it.
|
|
110
|
+
|
|
111
|
+
**The literal string `dnumb` in this line is a wire-format identifier, not the npm package name.**
|
|
112
|
+
It names the digest grammar (`dnumb digest v2`), the same way an HTTP response names `HTTP/1.1` —
|
|
113
|
+
a label a parser matches on, not a product name that tracks the registry. The package publishes as
|
|
114
|
+
`@michaelmagan/dnumb`; this header does not follow it, on purpose: `parseDigestLine` and every tool
|
|
115
|
+
that reads a digest by scanning for this literal string would break on a scope-only rename that
|
|
116
|
+
changed nothing about what the format actually is. Do not "fix" this line to read `@michaelmagan/dnumb
|
|
117
|
+
digest v2` if the package is ever renamed or rescoped again — the two names are allowed to diverge,
|
|
118
|
+
and this one is pinned.
|
|
119
|
+
|
|
120
|
+
The ` — <strategy>` segment is omitted when `meta.strategy` is absent, otherwise one of `cdp`,
|
|
121
|
+
`walk`, `walk (cdp fell back)`, `walk (closed shadow roots undetectable)`, or `walk (cdp fell back,
|
|
122
|
+
closed shadow roots undetectable)`. A reader comparing two digests needs to know which capability
|
|
123
|
+
tier produced each one, not just what it found — see [Not
|
|
124
|
+
measured](#7-not-measured--only-when-there-is-something-to-disclose) for what `cdp` and `walk` do
|
|
125
|
+
and do not see on a page with shadow DOM, and why that gap is not as clean as "cdp sees it, walk
|
|
126
|
+
does not." `walk (cdp fell back)` is the case worth noticing on its own: it means the CDP fast path
|
|
127
|
+
was tried on a Chromium page and its own protocol call failed, so this is an unplanned downgrade
|
|
128
|
+
rather than a deliberate portable capture on a browser CDP never covers. The `closed shadow roots
|
|
129
|
+
undetectable` clause is a permanent property of the `walk` path itself, not a measurement of this
|
|
130
|
+
particular page — every `walk` capture carries it, regardless of whether the page has any shadow
|
|
131
|
+
DOM at all, which is why it lives here rather than on the page-specific `# not measured:` line (see
|
|
132
|
+
below).
|
|
133
|
+
|
|
134
|
+
**2. Grammar — always, byte-identical every time, except under the item 4 refusal, where there is no grammar to explain because there is no body.**
|
|
135
|
+
|
|
136
|
+
**Live example** (`synthetic=header-grammar-line`) — the literal string, not a template; checked
|
|
137
|
+
verbatim against `renderDigest`'s own output by `test/unit/doc-examples.test.ts` on every run.
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
# 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
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**3. Page — always, except under the item 4 refusal, which reports the same dropped/kept counts in its own sentence instead.**
|
|
144
|
+
|
|
145
|
+
**Grammar template** — placeholders; the plain form is real and checked in place in the complete
|
|
146
|
+
digest above. The dominance-clause form is a live example right below.
|
|
147
|
+
|
|
148
|
+
```text
|
|
149
|
+
# page: [@<x>,<y> ]<W>x<H> (<N> records → <M> layout nodes, <D> dropped, <K> wrappers folded)
|
|
150
|
+
# page: [@<x>,<y> ]<W>x<H> (<N> records → <M> layout nodes, <D> dropped, <K> wrappers folded; <id> extends the page <width|height> by <Npx> beyond the next element)
|
|
151
|
+
# page: [@<x>,<y> ]<W>x<H> (<N> records → <M> layout nodes)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**Live example** (`synthetic=page-dominance-line`) — `e2`'s own bottom edge is the page bbox's `y`
|
|
155
|
+
bound, and the gap to the runner-up (`e1`) is more than 10x `e1`'s own height; `e0`, the root, is
|
|
156
|
+
excluded from the comparison on principle even though it is taller still.
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
# page: 1024x500 (3 records → 3 layout nodes, 0 dropped, 0 wrappers folded; e2 extends the page height by 460px beyond the next element)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`<W>x<H>` is the bounding box of everything rendered, rounded to integers. `<N>` is the number of
|
|
163
|
+
captured `ElementRecord`s. `<M>` is the number of nodes in the rendered tree. `<D>` counts
|
|
164
|
+
elements dropped for having no visible box plus SVG internals. `<K>` counts single-child wrapper
|
|
165
|
+
elements folded into their parent's line.
|
|
166
|
+
|
|
167
|
+
`M` is smaller than `N` on any page with hidden or decorative markup, and the difference is not
|
|
168
|
+
an error. A dropped element
|
|
169
|
+
never takes its subtree with it: surviving children are spliced up to the nearest surviving
|
|
170
|
+
ancestor.
|
|
171
|
+
|
|
172
|
+
**`@<x>,<y> ` prefixes the size when the bbox's own top-left is not the document origin — omitted
|
|
173
|
+
entirely, and byte-identical to before this existed, when it is `0,0`.** A bare `<W>x<H>` looks
|
|
174
|
+
like a size with no origin, and a reader who has only ever seen ordinary pages reasonably assumes
|
|
175
|
+
it is measured from `y=0` — true on almost every real page, but not on one whose own content starts
|
|
176
|
+
below the top. Measured: a rater reading an honest `# page: 1024x610` against a body that itself
|
|
177
|
+
opens at `@0,40` subtracted a child's absolute bottom edge from `610` as if `610` were also
|
|
178
|
+
measured from `y=0`, and reported a fabricated "content overflows the page by 7px" — the true
|
|
179
|
+
absolute bottom, measured correctly from the bbox's own top at `y=40`, was `650`, comfortably
|
|
180
|
+
inside. The element-line grammar (`@x,y WxH`) never produced this failure in the same testing; only
|
|
181
|
+
this header's size-with-no-origin form did, so the fix reuses that exact, already-trusted token
|
|
182
|
+
order rather than adding a sentence of prose. The [complete digest](#a-complete-digest) and every
|
|
183
|
+
other example on this page happen to open at the document origin, so none of them show the prefix;
|
|
184
|
+
[the sparse-capture example](#header-lines) below does (`@-1,-1`), and a real production page can
|
|
185
|
+
carry a large negative one — `ar.wikipedia.org`'s own pinned capture reports `@0,-99791`, from a
|
|
186
|
+
visually-hidden accessibility label parked far above the visible page.
|
|
187
|
+
|
|
188
|
+
**The third grammar form, with no `<D> dropped, <K> wrappers folded` clause at all, is what
|
|
189
|
+
`--focus` prints.** Both counts are computed over the *whole* capture, unscoped by focus, while `M`
|
|
190
|
+
is scoped to the focused subtree — printing all of them together used to produce numbers that look
|
|
191
|
+
like one equation and are not (`985 records → 797 layout nodes, 127 dropped, 46 wrappers folded`,
|
|
192
|
+
where `797 + 127 + 46 ≠ 985`, because two of the four describe the subtree and two describe the
|
|
193
|
+
whole page). Rather than resolve that by rescoping `D`/`K` to the subtree — which would need its own
|
|
194
|
+
second `N` to make sense of, more numbers, not fewer — a focused header just omits the clause: `M`
|
|
195
|
+
out of the whole capture's `N` records, and nothing about drops or wrapper folds.
|
|
196
|
+
|
|
197
|
+
**`<W>x<H>` is the real, undiscarded bounding box, and it can be huge.** A virtualization
|
|
198
|
+
spacer, a visually-hidden accessibility label parked thousands of pixels off-canvas, or a hidden
|
|
199
|
+
cookie-notice heading positioned far to one side each push it to a size that has nothing to do
|
|
200
|
+
with what a reader would call "the page" — measured on real sites: `4512x3802616`, `1441x106410`,
|
|
201
|
+
`11414x4272`. This package does not clamp or discard that number; it is a real measurement, and
|
|
202
|
+
the element responsible is often folded or dropped out of the body, so the header would otherwise
|
|
203
|
+
be the only trace of it.
|
|
204
|
+
|
|
205
|
+
**The trailing `; <id> extends the page <width|height> by <Npx> beyond the next element` clause
|
|
206
|
+
names the element responsible, when one is identifiable.** It only fires for a candidate whose own
|
|
207
|
+
edge literally *is* one of the page bbox's four bounds (leftmost, rightmost, topmost, or
|
|
208
|
+
bottommost) — being merely the biggest element on the page is not enough, and is not what this
|
|
209
|
+
checks. Given such a candidate, if the gap from it to the runner-up on that same side is more than
|
|
210
|
+
~10x the runner-up's own size on that axis, the clause names it. This is deliberately not "biggest
|
|
211
|
+
element by raw size": a plain header/hero/footer page has one element bigger than the rest by a wide
|
|
212
|
+
margin with nothing anomalous about the page total, and would be wrongly named by a same-size
|
|
213
|
+
comparison; the actual real-world case this clause exists for — an off-canvas accessibility label
|
|
214
|
+
tens of thousands of pixels from everything else — is often a `1x1` box with no size to speak of at
|
|
215
|
+
all, and is only findable by *position* (its edge reaching far past where anything else on the page
|
|
216
|
+
reaches), which is what this checks instead. Root containers are excluded from candidacy — their own
|
|
217
|
+
box reflects their content's extent by construction, so if every non-root candidate falls short of
|
|
218
|
+
the true bound, a root's box is what reaches it, and a container being large is not itself the
|
|
219
|
+
finding.
|
|
220
|
+
|
|
221
|
+
**4. Sparse capture — replaces the entire digest body, not a header line among the others.**
|
|
222
|
+
|
|
223
|
+
**This item is structurally different from every other item on this list, and the exception
|
|
224
|
+
belongs stated up front: under this one condition, items 2 and 3 above (the grammar line and the
|
|
225
|
+
`# page:` line) do not print at all, and neither does anything below — no element lines, no
|
|
226
|
+
`[...]` brackets, no `# facts:`, no `# colors:`.** `look` declines to render a digest body and
|
|
227
|
+
renders a capture-level refusal instead. The identity line (item 1) is the only other line that
|
|
228
|
+
still prints, because capture provenance is a fact about the capture, which is exactly what this
|
|
229
|
+
surface reports.
|
|
230
|
+
|
|
231
|
+
**The general rule — this is worth internalizing on its own, because it now governs three
|
|
232
|
+
different scopes in this format, and it is the sharpest statement of this project's own
|
|
233
|
+
discipline:**
|
|
234
|
+
|
|
235
|
+
> Every output surface carries an implicit claim in its form, and dnumb never emits a form whose
|
|
236
|
+
> implicit claim the capture cannot support.
|
|
237
|
+
|
|
238
|
+
Three rungs of the same rule, smallest scope to largest:
|
|
239
|
+
|
|
240
|
+
- **Token scope.** `ratio unmeasured (gradient)`, `unknown (canvas)`, `walk (closed shadow roots
|
|
241
|
+
undetectable)` — a single value the capture cannot honestly produce prints its reason instead of
|
|
242
|
+
a guessed number.
|
|
243
|
+
- **Line scope.** `# not measured: 2 iframes (1 cross-origin)` — a whole disclosure line reports
|
|
244
|
+
what a capture path walked past rather than leaving its absence to be discovered by silence.
|
|
245
|
+
- **Body scope — this item.** A digest body's own form claims "here is this page's layout." On a
|
|
246
|
+
capture where the overwhelming majority of records never reached a layout node and the entire
|
|
247
|
+
rendered extent is a degenerate sliver, that claim is false even though every individual line in
|
|
248
|
+
such a body would be true. So the body itself does not print; a refusal surface describing the
|
|
249
|
+
*capture* prints instead.
|
|
250
|
+
|
|
251
|
+
**Why a refusal, not one more wording fix — this is measured, not a first attempt.** Four rounds
|
|
252
|
+
of reader testing tried five different placements of a caveat explaining a genuinely clean
|
|
253
|
+
mid-hydration capture, each on real, honest, unaltered output: trailing after the numbers,
|
|
254
|
+
interrupting the `zero-box`/`content` pairing, header-only, parenthetical on the fact, and leading
|
|
255
|
+
the header with its own explanation. None got a blind rater's fabrication rate ("this page is
|
|
256
|
+
broken") below roughly a third, and one made it measurably worse (33%→50% on a larger sample).
|
|
257
|
+
Removing the `zero-box` fact from the element line entirely and moving its content into the header
|
|
258
|
+
sentence — a structural change, not a wording one — roughly halved the rate (down to ~43%, pooled
|
|
259
|
+
across two fixtures) but still did not clear it, because a header restating the same numbers above
|
|
260
|
+
a body whose element lines still wear the form of a page description gave readers exactly as much
|
|
261
|
+
to reason from. The diagnosis that survived four rounds: fabrication tracks the *form* of the
|
|
262
|
+
output, not the prose next to it. This refusal is the completion of that diagnosis, not a new
|
|
263
|
+
idea — it is what removing the fact bracket was already pointing at.
|
|
264
|
+
|
|
265
|
+
**Gated on the same condition that has gated this disclosure throughout its evolution, unchanged:
|
|
266
|
+
`!options.focus && elements.length >= 20 && dropped/total >= 0.9 && page.height <= 2`.** Swept
|
|
267
|
+
directly against every committed real-site bundle in this repo (`ar-wikipedia`, `excalidraw`,
|
|
268
|
+
`grafana-play`, `hackernews`, `mdn-array`, `shoelace-docs`, `shoelace-docs-walk`,
|
|
269
|
+
`tailwind-docs`) — verified here, not merely cited: **0 of 8 fire**, none of them close on either
|
|
270
|
+
leg (the worst real site's dropped-ratio is tens of points under the 0.9 threshold, and every real
|
|
271
|
+
site's page height is hundreds of pixels or more against the ≤2px leg). The gate cannot fire on a
|
|
272
|
+
page where a partial digest would have served, because firing requires that no partial digest
|
|
273
|
+
exists.
|
|
274
|
+
|
|
275
|
+
**Live example** (`fixture=test/fixtures/hydration-race.html url=file://.../hydration-race.html state=race strategy=walk`)
|
|
276
|
+
— `test/fixtures/hydration-race.html` reproduces, with a plain `setTimeout` and no framework, the
|
|
277
|
+
exact shape a production soak found mid-render on a real site: dozens of nodes mounted but not yet
|
|
278
|
+
laid out, and a body collapsed toward zero height. Regenerated fresh by
|
|
279
|
+
`test/browser/doc-examples.test.ts`.
|
|
280
|
+
|
|
281
|
+
```text
|
|
282
|
+
# dnumb digest v2 — file://.../hydration-race.html — 1024x768 — race — walk (closed shadow roots undetectable)
|
|
283
|
+
# no layout to digest: 36 of 38 records never reached a layout node (94.7%), and the 2 that did fit in a 1025x1 box. A capture taken mid-hydration or mid-fetch looks exactly like this — the page had not laid out at the instant this capture ran.
|
|
284
|
+
# text present but not yet laid out: e0 body carries "Loading Row 1 Row 2 Row 3 Row 4 Row 5 Ro".
|
|
285
|
+
# to measure the page, capture later in its life: dnumb capture file://.../hydration-race.html --wait networkidle, or re-run --wait load after a settle delay. If a later capture still looks like this, that repetition is itself a measurement — the page never lays out, and two captures agreeing on that is the fact to report.
|
|
286
|
+
# nothing was discarded: q facts, q describe <id>, q find <text>, and look --focus <id> read this bundle in full; this gate changes only what look prints by default.
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**Three things this surface does that a bare "cannot render" would not:**
|
|
290
|
+
|
|
291
|
+
1. **Nothing was discarded.** `q facts`, `q describe <id>`, `q find <text>`, and `look --focus
|
|
292
|
+
<id>` all bypass this gate and return the full, unrerouted measurement — verified directly:
|
|
293
|
+
`q facts` on this same capture still returns `e0`'s complete `zero-box` fact, brackets and all,
|
|
294
|
+
because `src/snapshot/facts.ts` (what those verbs read from) was deliberately left untouched.
|
|
295
|
+
This changes what `look` prints by default; it does not change what was measured or what
|
|
296
|
+
remains reachable.
|
|
297
|
+
2. **The persistence protocol is the more interesting half.** One capture cannot distinguish "still
|
|
298
|
+
loading" (re-capture later and it will look normal) from "never lays out" (a real, permanently
|
|
299
|
+
broken page) — which is precisely why *one* capture must not render a body that invites a
|
|
300
|
+
reader to guess between them. The refusal instead turns that ambiguity into a two-capture
|
|
301
|
+
protocol with a genuine answer: if a second capture, later, still refuses the same way, that
|
|
302
|
+
repetition is itself a measurement no single capture could make — a page that never lays out,
|
|
303
|
+
not a page caught mid-load. A refusal that names its own escape hatch is a diagnostic step, not
|
|
304
|
+
a dead end.
|
|
305
|
+
3. **The gate is a presentation threshold, not a fact threshold — the plan's "no thresholds beyond
|
|
306
|
+
subpixel noise" still holds.** `sparseCapture` decides which *surface* renders; it does not
|
|
307
|
+
round, suppress, or soften a single number in the bundle. Every value this surface states is
|
|
308
|
+
the same real value the pre-refusal header sentence stated.
|
|
309
|
+
|
|
310
|
+
**Verify this against a live blind read before trusting the specific wording above as settled.**
|
|
311
|
+
As of this writing this exact surface is unproven by the same discipline as every version before
|
|
312
|
+
it: the team that built it does not claim it works, and a pre-registered blind-read run (bar: 0
|
|
313
|
+
fabrications in n≥30; any single fabrication reopens the item, per this project's own kill
|
|
314
|
+
criterion) is open against it now. What is not provisional is the rule three rounds of evidence
|
|
315
|
+
converged on — a capture-state observation must not be rendered in a form whose implicit claim it
|
|
316
|
+
cannot support — and the three-rung ladder above, which holds regardless of how this specific
|
|
317
|
+
surface's wording resolves.
|
|
318
|
+
|
|
319
|
+
**5. Facts — only when at least one element carries one.**
|
|
320
|
+
|
|
321
|
+
**Grammar template** — the two real forms are singular/plural of the same line; the plural form is
|
|
322
|
+
checked live in the complete digest above (`5 elements carry facts`). No shipped fixture in this
|
|
323
|
+
document currently produces the singular count, so the `1 element carries facts` form above is a
|
|
324
|
+
template, not a live-checked instance.
|
|
325
|
+
|
|
326
|
+
```text
|
|
327
|
+
# facts: 1 element carries facts
|
|
328
|
+
# facts: 2 elements carry facts
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**6. Colors — only when at least one element carries a color fact.** Full grammar, worked
|
|
332
|
+
examples, and the adjudication rules that keep it a measurement rather than a verdict are in
|
|
333
|
+
[Color](#color) below — including why the clause is a measurement of *this page* (`# colors:`)
|
|
334
|
+
rather than a permanent property of the capture path (which is why it does not live on the
|
|
335
|
+
identity line the way `# not measured:`'s shadow-DOM caveat does).
|
|
336
|
+
|
|
337
|
+
**7. The collapse cue — only when a stage-3 collapse (see [The line
|
|
338
|
+
budget](#the-line-budget)) has hidden at least half of what this render had to show.**
|
|
339
|
+
|
|
340
|
+
**Grammar template** — placeholders; the live example right below is a real instance.
|
|
341
|
+
|
|
342
|
+
```text
|
|
343
|
+
# most of this page is behind collapse at budget <B> chars|lines: <H> of <M> layout nodes[ and <HF> of <TF> facts] (--focus <id>)
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**Live example** (`synthetic=collapse-cue-header`) — the same ten-child scenario as [The collapsed
|
|
347
|
+
line](#the-collapsed-line) above, rendered with an explicit `budgetLines: 3` and no `budgetChars`
|
|
348
|
+
override, full digest this time so the header itself is visible: all 3 of the page's facts sit
|
|
349
|
+
inside the one collapse, so the hidden fraction reads 100%.
|
|
350
|
+
|
|
351
|
+
```text
|
|
352
|
+
# dnumb digest v2 — https://example.test/page — 1024x768
|
|
353
|
+
# 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
|
|
354
|
+
# page: 1024x800 (12 records → 12 layout nodes, 0 dropped, 0 wrappers folded)
|
|
355
|
+
# facts: 3 elements carry facts
|
|
356
|
+
# most of this page is behind collapse at budget 3 lines: 10 of 12 layout nodes and 3 of 3 facts (--focus e1)
|
|
357
|
+
e0 body @0,0 1024x800
|
|
358
|
+
e1 div @0,0 300x400
|
|
359
|
+
[folded: 10 nodes, 3 facts, bbox @0,0 300x390 (--focus e1)]
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
**`<B>` names whichever meter actually bound this render, with its own unit attached — `chars` or
|
|
363
|
+
`lines` — never a bare number.** This matters because the two meters usually are not both real
|
|
364
|
+
constraints at once: a bare `look` (or `renderDigest` call with no options) is bound only by
|
|
365
|
+
`budgetChars` (the standing default, 32768), so the cue names chars; the example above passed
|
|
366
|
+
`budgetLines: 3` and nothing for `budgetChars`, so the cue names *that* — the one real constraint
|
|
367
|
+
this particular render had. Naming the wrong meter used to be a real bug: a render bound by
|
|
368
|
+
`budgetLines` that got told "the budget is 32768" pointed a reader at a knob (`--budget-chars`)
|
|
369
|
+
that would not change anything, because raising the char budget does nothing when the line budget
|
|
370
|
+
is what actually stopped the render. `<H>` and `<M>` are node counts — how many layout nodes are
|
|
371
|
+
hidden behind every collapse on this render, out of how many this render produced in total; the `
|
|
372
|
+
and <HF> of <TF> facts` clause is present only when the page has at least one fact at all, and
|
|
373
|
+
names the same fraction in terms of measurements instead of nodes, which is usually the number
|
|
374
|
+
that matters more. `<id>` is the first collapsed container's own id — the same one printed on its
|
|
375
|
+
own `[folded: …]` line — so the cue is never a dead end.
|
|
376
|
+
|
|
377
|
+
**The gate is a real fraction, not "any collapse happened."** A collapse hiding one heavy sidebar
|
|
378
|
+
out of an otherwise-unremarkable page is the budget ladder working exactly as intended, and does
|
|
379
|
+
not need a header-level flag; a collapse that ate most of what this render had to show is a
|
|
380
|
+
different situation a reader should notice before trusting the body as representative. The
|
|
381
|
+
threshold is 50%, computed against both the layout-node fraction and, when the page has any facts,
|
|
382
|
+
the fact fraction — the cue fires when either reaches it, whichever is worse. A page can hide most
|
|
383
|
+
of its *structure* while its facts stay mostly visible, or the reverse (measured on a real page:
|
|
384
|
+
`linear.app` hid 69% of its layout nodes behind collapse while only 27.5% of its facts were caught
|
|
385
|
+
up in that same collapse — checking facts alone would have left the cue silent on a page whose
|
|
386
|
+
structure was two-thirds invisible). The fact total is scoped to the current render's roots — the
|
|
387
|
+
focused subtree under `--focus`, the whole page otherwise — not the whole capture, matching every
|
|
388
|
+
other number on this line. [The collapsed line](#the-collapsed-line)'s own per-collapse `(--focus
|
|
389
|
+
<id>)` answers "how do I see inside this one collapse"; this line answers "do I need to."
|
|
390
|
+
|
|
391
|
+
**8. Truncation — only when the capture hit the element cap (5000).**
|
|
392
|
+
|
|
393
|
+
**Grammar template** — `<N>` is `snapshot.elements.length`; this package's test suite does not ship
|
|
394
|
+
a 5000-element fixture, so this line is not exercised as a live example here. It is exercised
|
|
395
|
+
directly in `test/unit/digest.test.ts`.
|
|
396
|
+
|
|
397
|
+
```text
|
|
398
|
+
# TRUNCATED: the capture stopped at the element cap; this page has more than <N> elements
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The page has more elements than the digest describes. Re-capture a narrower region rather than
|
|
402
|
+
reasoning about what is missing.
|
|
403
|
+
|
|
404
|
+
**9. Not measured — only when there is a real, counted, page-specific number to disclose.**
|
|
405
|
+
|
|
406
|
+
**Illustrative** — hand-composed to show the iframe segment, which no shipped fixture in this
|
|
407
|
+
document exercises, alongside the shadow-root segment; a real, checked instance of the shadow-root
|
|
408
|
+
form alone is in the sparse-capture example above.
|
|
409
|
+
|
|
410
|
+
```text
|
|
411
|
+
# not measured: 2 iframes (1 cross-origin), 14 shadow roots
|
|
412
|
+
# not measured: 1 open shadow root
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Neither capture path enters an iframe's content document. `collect.ts`'s portable walk also never
|
|
416
|
+
enters a shadow root — it reads `el.children`, and shadow content is not part of that, open or
|
|
417
|
+
closed.
|
|
418
|
+
|
|
419
|
+
**The CDP fast path can flatten shadow content in instead of missing it, but this is not
|
|
420
|
+
guaranteed, and it fails silently when it does not.** Chromium's `DOMSnapshot.captureSnapshot` is
|
|
421
|
+
capable of flattening an author-created shadow root's content directly under its host, open and
|
|
422
|
+
closed both, with nothing on the record to say it came from behind a shadow boundary — verified
|
|
423
|
+
against `test/fixtures/shadow-dom-test.html`, a small page with one simple open shadow root. But a
|
|
424
|
+
real captured page in this repo's own fixtures falsifies "cdp always does this": `cdp` capturing
|
|
425
|
+
`shoelace.style` (`test/fixtures/real-sites/shoelace-docs.json`) reports zero shadow roots, while
|
|
426
|
+
`walk` capturing the *same page* (`test/fixtures/real-sites/shoelace-docs-walk.json`) reports four
|
|
427
|
+
real open ones. CDP did not flatten those four in — it never saw them, which is a different and
|
|
428
|
+
worse failure than "flattened them in with nothing to disclose," because it also has nothing to
|
|
429
|
+
disclose about the ones it missed. **A `cdp` digest with no shadow-root segment on this line is not
|
|
430
|
+
proof the page has no shadow DOM, or that whatever shadow DOM it has was captured.** It means
|
|
431
|
+
either of those, and this line cannot currently tell you which. Confirm shadow DOM presence
|
|
432
|
+
independently (devtools, or grep the app's source for `attachShadow`) when it matters, rather than
|
|
433
|
+
trusting a `cdp` digest's silence on the subject.
|
|
434
|
+
|
|
435
|
+
The line lists, in order, an iframe segment and a shadow-root segment, each present only when
|
|
436
|
+
there is a nonzero count to report:
|
|
437
|
+
|
|
438
|
+
- **iframes** — `<N> iframe<s>`, where `<N>` is every `<iframe>` element either path found
|
|
439
|
+
(neither path is blind to the element itself, only to what is inside it), with a parenthetical
|
|
440
|
+
naming how many of those were `<K> cross-origin` and/or `<K> origin unknown` when either is
|
|
441
|
+
greater than zero. An iframe with no parenthetical is same-origin.
|
|
442
|
+
- **shadow roots** — `<N> shadow root<s>`, with `(<K> closed)` appended when some of them are
|
|
443
|
+
closed and the path can count that. On `walk`, `<N>` only ever counts *open* roots (see below);
|
|
444
|
+
on `cdp`, per the caveat above, `<N>` counts however many shadow roots CDP actually flattened in,
|
|
445
|
+
which is not a reliable count of every shadow root the page has.
|
|
446
|
+
|
|
447
|
+
**Whether `walk` can even count *closed* shadow roots at all is not this line's business any
|
|
448
|
+
more — it is a permanent property of the `walk` path, disclosed once on the digest's identity
|
|
449
|
+
line** (`— walk (closed shadow roots undetectable)`; see [Identity](#header-lines) above) rather
|
|
450
|
+
than repeated here on every single `walk` capture regardless of what the page contains. The reason
|
|
451
|
+
it is undetectable at all: `Element.shadowRoot` is a live reference to an *open* root, so an open
|
|
452
|
+
one is countable by its mere presence even though the walk never enters it, but a *closed* root
|
|
453
|
+
returns `null` from that same property — indistinguishable from "no shadow root here at all" — and
|
|
454
|
+
there is no other DOM API that tells the two apart once the page's own script has moved on.
|
|
455
|
+
Reporting `0` closed roots would be a specific, false claim this whole package exists to refuse;
|
|
456
|
+
disclosing the gap on the identity line is the honest alternative, once per capture rather than
|
|
457
|
+
buried in a line that is otherwise about counted, page-specific numbers.
|
|
458
|
+
|
|
459
|
+
**10. Edges — only with `{ edges: true }`.**
|
|
460
|
+
|
|
461
|
+
**Illustrative** — plausible numbers on an invented page, not one real capture's clusters.
|
|
462
|
+
|
|
463
|
+
```text
|
|
464
|
+
# left edges: 0×6 · right edges: 300×5, 1024×1 · top edges: 0×2, 40×1, 80×1, 120×1, 160×1
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Edge coordinates of every element the capture marked visible, grouped by adjacent-gap chaining
|
|
468
|
+
with a 2px tolerance. The five largest clusters per axis, ordered by count, written
|
|
469
|
+
`<coordinate>×<count>`, where the coordinate is the cluster's mean rounded to an integer.
|
|
470
|
+
|
|
471
|
+
Nothing in this package adjudicates alignment. This is the raw distribution, offered so a reader
|
|
472
|
+
can notice that four cards share a left edge and a fifth does not.
|
|
473
|
+
|
|
474
|
+
## The element line
|
|
475
|
+
|
|
476
|
+
**Grammar template** — placeholders; every element line in every live example elsewhere in this
|
|
477
|
+
document is a real instance of this template.
|
|
478
|
+
|
|
479
|
+
```text
|
|
480
|
+
<indent><id> <tag><annotation> "<text>" @<x>,<y> <W>x<H> [<flow>] <gap> [<fact>]… [below fold]
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
Every token after `<W>x<H>` is optional. Order is fixed: flow, then gap, then facts, then below
|
|
484
|
+
fold.
|
|
485
|
+
|
|
486
|
+
| Token | Form | Emitted when |
|
|
487
|
+
|---|---|---|
|
|
488
|
+
| indent | two spaces per tree depth | depth > 0 |
|
|
489
|
+
| id | `e13` | always |
|
|
490
|
+
| tag | lowercase tag name, `div`, `h1`, `svg` | always |
|
|
491
|
+
| annotation | `#save-btn` or `.heading` | see below |
|
|
492
|
+
| text | `"Quarterly revenue review for t…"` | the element has an accessible name or own text |
|
|
493
|
+
| position | `@71,164` | always |
|
|
494
|
+
| size | `263x18` | always |
|
|
495
|
+
| flow | `[row]` or `[grid]` | the element's `display` is flex-ish or grid-ish |
|
|
496
|
+
| gap | `↓12` or `→-110` | there is a previous rendered sibling |
|
|
497
|
+
| facts | `[overflow-x +147px; clip, ellipsis]` | the element carries that fact |
|
|
498
|
+
| below fold | `[below fold]` | `y` > viewport height |
|
|
499
|
+
|
|
500
|
+
### id
|
|
501
|
+
|
|
502
|
+
`e` followed by the element's index in capture order: `e0`, `e1`, `e13`. Assigned by the
|
|
503
|
+
collector, document order, starting at the `<body>` element.
|
|
504
|
+
|
|
505
|
+
**These ids are stable within one capture and nowhere else.** They are not DOM ids, not
|
|
506
|
+
selectors, and not hashes. Re-capturing the same unchanged page produces the same ids only by
|
|
507
|
+
coincidence of the DOM being identical; inserting one element earlier in the document shifts
|
|
508
|
+
every id after it. Never carry an `e13` from one capture into a claim about another, and never
|
|
509
|
+
persist one as a test expectation. To refer to an element across two captures, match on the
|
|
510
|
+
element's own identity — its test id, its accessible name, its role — or use a diff, which does
|
|
511
|
+
the matching for you.
|
|
512
|
+
|
|
513
|
+
The authored DOM id, when the element has one, is kept on the record as `domId`, and a test id
|
|
514
|
+
(`data-testid`, `data-test-id`, `data-test`, `data-qa`) as `testId`. Those are the fields that
|
|
515
|
+
travel.
|
|
516
|
+
|
|
517
|
+
### annotation
|
|
518
|
+
|
|
519
|
+
`#<testId>` when the element has a test id. Otherwise `.<role>` when it has a computed ARIA role.
|
|
520
|
+
Otherwise nothing. Test id wins; both are never printed.
|
|
521
|
+
|
|
522
|
+
The role is the *computed* role, not the `role` attribute: a bare `<button>` renders
|
|
523
|
+
`.button`, an `<h1>` renders `.heading`. Elements with no implicit role, `<div>` and `<span>`
|
|
524
|
+
among them, get no annotation.
|
|
525
|
+
|
|
526
|
+
### text
|
|
527
|
+
|
|
528
|
+
The element's accessible name if it has one, otherwise its own direct text content — direct child
|
|
529
|
+
text nodes only, not descendants. Whitespace collapsed and trimmed by the collector, then cut to
|
|
530
|
+
30 characters with a trailing `…` when longer. Quoted with plain `"`. Omitted when both are empty.
|
|
531
|
+
|
|
532
|
+
The record itself keeps more than the line shows: 60 characters of own text, 80 of accessible
|
|
533
|
+
name.
|
|
534
|
+
|
|
535
|
+
The name is preferred over the text, so a `<nav aria-label="Actions">` renders `"Actions"` even
|
|
536
|
+
though the nav's own text is empty.
|
|
537
|
+
|
|
538
|
+
### position and size
|
|
539
|
+
|
|
540
|
+
`@<x>,<y> <W>x<H>`, all four `Math.round`ed to integers, in CSS pixels, in **viewport
|
|
541
|
+
coordinates at capture time**. `x` and `y` are the border-box top-left and may be negative. `W`
|
|
542
|
+
and `H` are border-box dimensions.
|
|
543
|
+
|
|
544
|
+
Both capture paths normalise to viewport coordinates, so a page captured while scrolled reports
|
|
545
|
+
`y` relative to the current scroll position, not to the document.
|
|
546
|
+
|
|
547
|
+
### flow
|
|
548
|
+
|
|
549
|
+
`[row]` when the element's computed `display` contains `flex` and `flex-direction` is not
|
|
550
|
+
`column`/`column-reverse`; `[column]` when `display` contains `flex` and `flex-direction` is
|
|
551
|
+
`column` or `column-reverse`; `[grid]` when `display` contains `grid`. Absent otherwise.
|
|
552
|
+
|
|
553
|
+
**Live example** (`synthetic=flow-column`) — a `flex-direction: column` container: the token prints
|
|
554
|
+
`[column]`, and its children's gap is measured vertically, on the same axis the token names.
|
|
555
|
+
|
|
556
|
+
```text
|
|
557
|
+
e0 body @0,0 1024x500 [column]
|
|
558
|
+
e1 div @0,0 300x40
|
|
559
|
+
e2 div @0,60 300x40 ↓20
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
The token is what decides the *gap arrow* on that element's children (see [gap](#gap) below), and
|
|
563
|
+
both come from the same underlying `flow` value, so they always agree: a `flex-direction: column`
|
|
564
|
+
container's children are measured **vertically** (`↓`), the way you would expect from the name.
|
|
565
|
+
|
|
566
|
+
### gap
|
|
567
|
+
|
|
568
|
+
The signed distance from the previous rendered sibling's trailing edge, along the parent's flow
|
|
569
|
+
axis, rounded to an integer.
|
|
570
|
+
|
|
571
|
+
- `→N` when the parent's flow is `row`. Measured on x.
|
|
572
|
+
- `↓N` when the parent's flow is `column`, `grid`, `none`, or the document root. Measured on y.
|
|
573
|
+
|
|
574
|
+
Negative means the two boxes overlap along that axis: `→-110` in the [complete digest](#a-complete-digest)
|
|
575
|
+
example above is `e5` overlapping its own previous sibling. The value is signed rather than
|
|
576
|
+
clamped because an overlap is a fact worth seeing.
|
|
577
|
+
<!-- checked-claim: gap-sign-overlap -->
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
The first sibling in any group has no gap token.
|
|
581
|
+
|
|
582
|
+
**The gap is direction-agnostic, including the sign and magnitude of a genuine overlap.** It does
|
|
583
|
+
not assume the previous sibling is positioned before the current one in screen space — only that it
|
|
584
|
+
comes first in DOM order. On an RTL flex row the DOM-order previous sibling is drawn to the
|
|
585
|
+
*right*, and a naive "current minus previous" always reports which one is *later in the DOM*, not
|
|
586
|
+
which one is *later on screen*: it cannot tell a real gap from an overlap in that layout (measured
|
|
587
|
+
on `ar.wikipedia.org`: two header regions 16px apart rendered as a fabricated `→-1352`), and even
|
|
588
|
+
inside a genuine overlap it can report the wrong *magnitude* by pairing the wrong pair of edges
|
|
589
|
+
(measured on a synthetic case: two boxes overlapping by exactly 1.0px rendered as `→-474`). The
|
|
590
|
+
renderer checks which box is actually first on the axis before deciding which edges to subtract —
|
|
591
|
+
for a real gap, for a fabricated one, and for a genuine overlap alike — which needs no
|
|
592
|
+
`direction`/`writing-mode` data and gives the identical number to the naive computation in every LTR
|
|
593
|
+
case, where DOM order and screen order already agree. See `test/fixtures/rtl-flex-row-gap.html`
|
|
594
|
+
(checked by `test/browser/rtl-gap.test.ts`) for the non-overlapping case, cross-checked against `q
|
|
595
|
+
distance`.
|
|
596
|
+
|
|
597
|
+
### below fold
|
|
598
|
+
|
|
599
|
+
`[below fold]` when the node's `y` exceeds `meta.viewport.height`. Strictly greater, and based on
|
|
600
|
+
`y` alone — a tall element that starts above the fold and ends below it is not marked.
|
|
601
|
+
|
|
602
|
+
## Facts
|
|
603
|
+
|
|
604
|
+
A fact is a bracketed group after the geometry. Facts are computed from the same records the
|
|
605
|
+
lines are and carried on the element by id, so a fact can never land on the wrong line.
|
|
606
|
+
|
|
607
|
+
Four kinds exist. They are emitted in this order when an element has more than one.
|
|
608
|
+
|
|
609
|
+
### `overflow-x`
|
|
610
|
+
|
|
611
|
+
**Illustrative** — four hand-picked cases side by side to show every token combination. The first
|
|
612
|
+
and last are real (the heading in [A truncated heading](#a-truncated-heading) and the column in
|
|
613
|
+
[A column that collapsed to zero width](#a-column-that-collapsed-to-zero-width), both below); the
|
|
614
|
+
middle two show `scroll` and a plain `clip, wraps`, which neither fixture exercises.
|
|
615
|
+
|
|
616
|
+
```text
|
|
617
|
+
[overflow-x +147px; clip, ellipsis]
|
|
618
|
+
[overflow-x +227px; clip, wraps]
|
|
619
|
+
[overflow-x +90px; scroll, nowrap]
|
|
620
|
+
[overflow-x +60px; visible, wraps]
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
`scrollWidth - clientWidth`, emitted whenever that exceeds 1px. Two consequence tokens follow the
|
|
624
|
+
`;`.
|
|
625
|
+
|
|
626
|
+
The first is exactly one of `clip` / `scroll` / `visible`, a partition of the computed
|
|
627
|
+
`overflow-x` value:
|
|
628
|
+
|
|
629
|
+
| Token | `overflow-x` is |
|
|
630
|
+
|---|---|
|
|
631
|
+
| `clip` | `hidden` or `clip` |
|
|
632
|
+
| `scroll` | `auto`, `scroll` or `overlay` |
|
|
633
|
+
| `visible` | anything else |
|
|
634
|
+
|
|
635
|
+
The second describes what the text does:
|
|
636
|
+
|
|
637
|
+
| Token | Condition |
|
|
638
|
+
|---|---|
|
|
639
|
+
| `ellipsis` | `text-overflow: ellipsis` **and** the box clips or scrolls |
|
|
640
|
+
| `wraps` | not `ellipsis`, and `white-space` is not `nowrap` or `pre` |
|
|
641
|
+
| `nowrap` | not `ellipsis`, and `white-space` is `nowrap` or `pre` |
|
|
642
|
+
|
|
643
|
+
`ellipsis` suppresses `wraps` because `text-overflow` only takes effect once wrapping is already
|
|
644
|
+
off. Printing both would restate one CSS rule twice.
|
|
645
|
+
|
|
646
|
+
### `overflow-y`
|
|
647
|
+
|
|
648
|
+
**Illustrative** — one line pulled out for its own subsection; it is real, and already checked
|
|
649
|
+
in place on `e2` in the complete digest above.
|
|
650
|
+
|
|
651
|
+
```text
|
|
652
|
+
[overflow-y +105px; clip]
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
`scrollHeight - clientHeight`, emitted whenever that exceeds 1px. One consequence token,
|
|
656
|
+
`clip` / `scroll` / `visible`, from `overflow-y`. There is no wrapping token on this axis.
|
|
657
|
+
|
|
658
|
+
### `zero-box`
|
|
659
|
+
|
|
660
|
+
**Illustrative** — the first line is real (checked in place in [A column that collapsed to zero
|
|
661
|
+
width](#a-column-that-collapsed-to-zero-width) below); the second shows the descendant-count
|
|
662
|
+
fallback, which no shipped fixture exercises.
|
|
663
|
+
|
|
664
|
+
```text
|
|
665
|
+
[0x1239 zero-box; content "Filters and saved views"]
|
|
666
|
+
[0x480 zero-box; content 7 elements]
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
Emitted when an element is still in the layout — `display` is not `none` — and its width or its
|
|
670
|
+
height is under 1px, **and** it has something inside it. A `0x0` element with no children is
|
|
671
|
+
ordinary spacing and produces nothing.
|
|
672
|
+
|
|
673
|
+
The content clause is what separates the two cases: a collapsed spacer is uninteresting, and a
|
|
674
|
+
column that computed to `0x1239` around a page of content is the defect. The clause is the
|
|
675
|
+
element's own text when it has any, otherwise text gathered breadth-first from its descendants
|
|
676
|
+
and cut to 40 characters, otherwise a count of the descendant elements found.
|
|
677
|
+
|
|
678
|
+
**On the same rare, whole-capture condition covered in [Header lines, item
|
|
679
|
+
4](#header-lines) above, a `zero-box` fact on the affected element is not emitted as an
|
|
680
|
+
element-line bracket at all — because under that condition there is no element-line body to put it
|
|
681
|
+
on.** `look` declines to render a digest body under sparse capture and renders a capture-level
|
|
682
|
+
refusal instead; the `zero-box` fact's content — the id, the tag, the content clause — is one line
|
|
683
|
+
of that refusal (`# text present but not yet laid out: …`), not a bracket on `<body>`'s own line.
|
|
684
|
+
See item 4 for the exact current form, the full argument, and the general rule this is one instance
|
|
685
|
+
of: **dnumb never emits a form whose implicit claim the capture cannot support**, applied here at
|
|
686
|
+
whole-body scope because four rounds of reader testing established that no wording placed next to
|
|
687
|
+
a `[...]` bracket could out-argue what the bracket's own form claims — "a measured, adjudicable
|
|
688
|
+
finding about this element" — on a capture where the element in question never really got measured
|
|
689
|
+
into a page at all. An ordinary `zero-box` fact — outside this rare condition, a real measurement
|
|
690
|
+
about the page rather than an artifact of when the capture ran — is completely unaffected and still
|
|
691
|
+
prints exactly as described above.
|
|
692
|
+
|
|
693
|
+
### `clipped`
|
|
694
|
+
|
|
695
|
+
**Illustrative** — one line pulled out for its own subsection; it is real, and already checked
|
|
696
|
+
in place on `e5` in the complete digest above.
|
|
697
|
+
|
|
698
|
+
```text
|
|
699
|
+
[clipped by e2 div; overflow:hidden, not scrollable; 103.8px below, 182x5 visible]
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
Emitted when an element extends past an ancestor whose `overflow` is not `visible` — the same
|
|
703
|
+
measurement `ElementRecord.clip` carries (see [What is not in the digest](#what-is-not-in-the-digest)
|
|
704
|
+
for the full-precision record fields), rendered as a fact so it lands on the digest line instead of
|
|
705
|
+
requiring a reader to go back to the raw records for it. `by`/`byTag` name the clipping ancestor;
|
|
706
|
+
`overflow` is its computed `overflow` value, `x/y` when the two axes differ; `scrollable` is
|
|
707
|
+
whether a user could actually scroll that ancestor to reveal the hidden part — not the same
|
|
708
|
+
question as whether it clips, since an `overflow:hidden` box has scroll extent and no
|
|
709
|
+
reachability; the free-text clause names which sides are hidden and by how much, then how much of
|
|
710
|
+
the box survives (`WxH visible`, or `nothing visible`).
|
|
711
|
+
|
|
712
|
+
### Numbers inside facts
|
|
713
|
+
|
|
714
|
+
Integers when exact, otherwise one decimal place: `+147px`, `+103.8px`. Geometry tokens on the
|
|
715
|
+
line are always integers; fact numbers are not.
|
|
716
|
+
|
|
717
|
+
### Reading a fact
|
|
718
|
+
|
|
719
|
+
The same number means different things:
|
|
720
|
+
|
|
721
|
+
**Illustrative** — hand-composed to contrast the three cases, with the plain-English gloss (not
|
|
722
|
+
real output) aligned after each.
|
|
723
|
+
|
|
724
|
+
```text
|
|
725
|
+
[overflow-x +147px; clip, ellipsis] 147px of a heading is unreadable, and it ends in three dots
|
|
726
|
+
[overflow-x +227px; clip, wraps] the box clips, but the text wrapped; this is height, not truncation
|
|
727
|
+
[overflow-x +90px; scroll, wraps] a scroll region working as designed
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
The number is identical in kind across all three. The difference is never in the number. Do not
|
|
731
|
+
treat an `overflow-x` fact as a finding, and do not treat its absence as a clean bill of health —
|
|
732
|
+
report the measurement and the consequence tokens together, and say which one you concluded from.
|
|
733
|
+
|
|
734
|
+
## ⚠ Ellipsis inflates the reported overflow
|
|
735
|
+
|
|
736
|
+
Chromium reserves layout space for the ellipsis glyph on an ellipsized element, and that reserved
|
|
737
|
+
space is included in `scrollWidth`. So an `overflow-x` number on a line that also says `ellipsis`
|
|
738
|
+
**includes the ellipsis**, and is larger than the width of the content that is actually hidden.
|
|
739
|
+
|
|
740
|
+
Measured while building the archaeology fixture: **+10px at font-size 10, +17px at font-size 20**.
|
|
741
|
+
Reproduced at +8px and +15px on a different font stack. The amount is the ellipsis glyph's advance
|
|
742
|
+
width in the element's own font, so it scales with `font-size` and varies with `font-family`.
|
|
743
|
+
Setting `text-overflow: clip` on the same element and re-measuring returns the uninflated number.
|
|
744
|
+
|
|
745
|
+
Three consequences for a reader:
|
|
746
|
+
|
|
747
|
+
1. **Do not subtract a constant.** There is no fixed correction. The inflation is font-dependent.
|
|
748
|
+
2. **Treat the number as an upper bound on hidden content** when `ellipsis` is present, and as
|
|
749
|
+
exact when it is not.
|
|
750
|
+
3. **Do not conclude "nothing is hidden" from a small number.** A `+12px` overflow at font-size 16
|
|
751
|
+
may be almost entirely the glyph.
|
|
752
|
+
|
|
753
|
+
`test/fixtures/flex-min-width-ellipsis.html` pins its content width with a block-level child
|
|
754
|
+
specifically so its numbers do not move with the installed font. A fixture whose expected value
|
|
755
|
+
depends on the CI machine's font is a fixture that goes red for a reason nobody enjoys diagnosing.
|
|
756
|
+
|
|
757
|
+
## Color
|
|
758
|
+
|
|
759
|
+
**Not a digest-body fact.** Everything else in [Facts](#facts) — `overflow-x`, `overflow-y`,
|
|
760
|
+
`zero-box`, `clipped` — prints inline, bracketed, on the element line it measures. Color is a fifth
|
|
761
|
+
kind of fact, computed the same way (a measurement plus the CSS that decides what it means) but
|
|
762
|
+
never printed there: see [Where color lives](#where-color-lives) below for why. It reaches a
|
|
763
|
+
reader through the `# colors:` header line (this section), `q color`, and `q describe`.
|
|
764
|
+
|
|
765
|
+
**The gate: a visible element with its own text.** No text, no ink-on-backdrop question to answer,
|
|
766
|
+
no color fact. This is the same gate `overflow-x`/`overflow-y`/`zero-box` use, applied to a
|
|
767
|
+
different question.
|
|
768
|
+
|
|
769
|
+
### Why the compositing walk exists
|
|
770
|
+
|
|
771
|
+
Reading an element's own `background-color` sounds like it should answer "what is this text on."
|
|
772
|
+
On real pages it almost never does: measured across 10,834 resolved real text elements, only
|
|
773
|
+
**2.4%** have an opaque *own* `background-color`. For the other **97.6%**, `background-color` is
|
|
774
|
+
transparent or translucent — `getComputedStyle` returns exactly `rgba(0, 0, 0, 0)` for most of
|
|
775
|
+
them, which is not a slightly-wrong color, it is no color at all. A tool that reported raw
|
|
776
|
+
`background-color` would be `unmeasured` (or worse, silently wrong) for nearly all real text on the
|
|
777
|
+
web. dnumb instead walks up from the element, compositing every ancestor's `background-color`
|
|
778
|
+
layer by alpha until it reaches an opaque one (the canvas is the final opaque layer), and reports
|
|
779
|
+
that composited result as the *effective backdrop* — the color actually behind the text, not the
|
|
780
|
+
element's own unhelpful claim about itself. The element's own `background-color` is still on the
|
|
781
|
+
record as a counter-fact (it is what invalidates a backdrop when it is itself a gradient or image),
|
|
782
|
+
just never trusted as the answer on its own.
|
|
783
|
+
|
|
784
|
+
### The per-element grammar
|
|
785
|
+
|
|
786
|
+
Seen via `q describe <id>` and `q color --all`, never on a digest body line.
|
|
787
|
+
|
|
788
|
+
**Grammar template** — placeholders; the worked examples throughout this section are real
|
|
789
|
+
instances of it.
|
|
790
|
+
|
|
791
|
+
```text
|
|
792
|
+
color <fg> on <bg>; ratio <r>; <size>px/<weight>[; opacity ×<o>][; shadow <color>][; stroke <w>px <color>]
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
`<fg>` is the *effective* ink — the computed `color`, composited at effective opacity over the
|
|
796
|
+
backdrop when opacity is under 1 (with `opacity ×<o>` printed alongside so the raw value is
|
|
797
|
+
recoverable). Colors print as lowercase hex, or verbatim when the color space could not be
|
|
798
|
+
converted (see `color-space` below). `ratio` is the WCAG 2.x relative-luminance contrast ratio,
|
|
799
|
+
two decimals — the name of a formula, not a threshold; see [Never a
|
|
800
|
+
verdict](#never-a-verdict-just-the-number) below.
|
|
801
|
+
|
|
802
|
+
**Live example** (`fixture=test/fixtures/color-alpha-chain.html url=file://.../color-alpha-chain.html`)
|
|
803
|
+
— regenerated fresh by `test/browser/doc-examples.test.ts` every run.
|
|
804
|
+
|
|
805
|
+
```text
|
|
806
|
+
# dnumb digest v2 — file://.../color-alpha-chain.html — 1024x768 — cdp
|
|
807
|
+
# 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
|
|
808
|
+
# page: 1024x226 (9 records → 7 layout nodes, 0 dropped, 2 wrappers folded)
|
|
809
|
+
# colors: 3 fg/bg pairs across 3 text elements; ratio 3.58–21.00; lowest e7 #000000 on #0d743d via e4 (α×2) 16px/400
|
|
810
|
+
e0 body @0,0 1024x226
|
|
811
|
+
e2 div @20,20 984x64
|
|
812
|
+
e3 span "Alpha chain text" @40,40 160x24
|
|
813
|
+
e4 div @0,104 1024x84 ↓20
|
|
814
|
+
e6 div @20,124 984x44
|
|
815
|
+
e7 span "Nested alpha text" @30,134 170x24
|
|
816
|
+
e8 p.paragraph "Plain text, no background set …" @0,204 1024x22 ↓16
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
`e7`'s own color fact (via `q describe e7` or `q color --all`, not shown on the digest line above)
|
|
820
|
+
is `color #000000 on #0d743d via e4 (α×2); ratio 3.58; 16px/400` — two translucent layers
|
|
821
|
+
composited on the way to `e4`'s opaque background, disclosed as `(α×2)`.
|
|
822
|
+
|
|
823
|
+
### The `<bg>` forms
|
|
824
|
+
|
|
825
|
+
| Form | Meaning |
|
|
826
|
+
|---|---|
|
|
827
|
+
| `<hex> via <id>` | Composited backdrop — `<id>` is the ancestor whose opaque background closed the composite, the counter-fact a reader can go inspect. `(α×N)` appended when `N` translucent layers were composited on the way. |
|
|
828
|
+
| `<hex> via canvas` | Composited backdrop that resolved all the way to the browser's own default canvas fill — see [`via canvas`](#via-canvas) below. |
|
|
829
|
+
| `<hex> self` | The element's own background is opaque; nothing to walk. |
|
|
830
|
+
| `gradient(<hex>→<hex>) <id>` | A gradient background on `<id>` sits behind the text. First and last stops are shown so a reader can judge whether both ends sit the same side of the ink's luminance without a precise ratio. `ratio` becomes `unmeasured`. |
|
|
831
|
+
| `image <id> (<W>x<H> of <W>x<H>)` | A `url()` background on `<id>` sits behind the text. `ratio unmeasured`. See [The image footprint](#the-image-footprint) for what the two sizes mean and why both are there. |
|
|
832
|
+
| `unknown (under <id>)` / `unknown (behind <id>)` | A non-ancestor element paints over the text (`under`) or behind it but above the real backdrop (`behind`) — a positioned overlay, an open modal's scrim, a cookie banner. `ratio unmeasured`. |
|
|
833
|
+
| `unknown (canvas)` | The walk ran off the top of the page with no ancestor background *and* no resolvable canvas fill — see [`via canvas`](#via-canvas) below. `ratio unmeasured (canvas)`. |
|
|
834
|
+
| `unknown (not captured <id>)` | This specific capture never measured `<id>`'s `background-image` at all — see [`unknown (not captured <id>)`](#unknown-not-captured-id) below. `ratio unmeasured (not-captured)`. |
|
|
835
|
+
|
|
836
|
+
Every form but the first two ends in `ratio unmeasured` — see `ratioLabel`'s exceptions,
|
|
837
|
+
`color-space`, `canvas`, and `not-captured`, below.
|
|
838
|
+
|
|
839
|
+
### `via canvas`
|
|
840
|
+
|
|
841
|
+
The compositing walk starts at the text element and climbs its ancestor chain looking for an
|
|
842
|
+
opaque `background-color` — but the capture itself starts at `<body>`, and a page's real
|
|
843
|
+
background can live above that: on `<html>`, or nowhere at all, resolved only by the browser's own
|
|
844
|
+
default canvas fill and the page's `color-scheme`. Neither is an element the ancestor walk can
|
|
845
|
+
name, so before this fix the walk simply ran off the top of `<body>` with nothing left to check and
|
|
846
|
+
had no honest way to describe what came next.
|
|
847
|
+
|
|
848
|
+
**Live example** (line `# colors: 1 fg/bg pair …` in [A column that collapsed to zero
|
|
849
|
+
width](#a-column-that-collapsed-to-zero-width) below) — `e6`'s paragraph sits on no declared
|
|
850
|
+
background anywhere in its ancestor chain, all the way up through `body`; the digest names the
|
|
851
|
+
backdrop `via canvas`, not `via e0` (`body`'s own id) or any other element that never actually
|
|
852
|
+
declared it.
|
|
853
|
+
|
|
854
|
+
Two Chromium-verified facts make this resolvable rather than another `unmeasured`: an explicit
|
|
855
|
+
opaque `<html>` background-color wins outright regardless of `color-scheme`, and `color-scheme:
|
|
856
|
+
dark` with no background set anywhere resolves to Chromium's real default fill, `rgb(18, 18, 18)`
|
|
857
|
+
— not white. Guessing white for every unresolved page used to fabricate a real number: a dark page
|
|
858
|
+
with light text and no declared background anywhere used to resolve `via <body-id>` on an assumed
|
|
859
|
+
white canvas and report a contrast ratio *worse* than the truth, or — on a page whose `color-scheme:
|
|
860
|
+
dark` made the ink itself dark-on-dark by design — a fabricated `ratio 1.00` for text that was
|
|
861
|
+
never unreadable at all, because it was never actually on white. `unknown (canvas)` is the honest
|
|
862
|
+
fallback for the residual case: an unconvertible `<html>` background-color, or `color-scheme:
|
|
863
|
+
light dark`, which genuinely depends on the reader's own OS preference and cannot be resolved from
|
|
864
|
+
the page alone.
|
|
865
|
+
|
|
866
|
+
### `unknown (not captured <id>)`
|
|
867
|
+
|
|
868
|
+
`ElementRecord.backgroundImage` is `null`, not `'none'`, when a particular capture never measured
|
|
869
|
+
that field at all — a bundle written before color capture existed. The compositor refuses to
|
|
870
|
+
continue past an element whose `background-image` it does not know: that field is what tells the
|
|
871
|
+
walk whether a gradient or an image sits behind the text, so an unknown value cannot honestly be
|
|
872
|
+
treated as "none of those." This is the same rule the `# not measured:` header line's closed
|
|
873
|
+
shadow roots already follow — `Element.shadowRoot` is `null` for both "no shadow root" and "closed
|
|
874
|
+
shadow root," and rather than guess `0`, the digest reports the honest gap — applied here to a
|
|
875
|
+
second field that can be silently missing for a different reason (an old bundle) instead of a
|
|
876
|
+
different DOM API limitation. Both are one rule, not two coincidences: **a value this package never
|
|
877
|
+
measured is never reported as a measured zero.**
|
|
878
|
+
|
|
879
|
+
**Live example** (`synthetic=color-not-captured`) — one element with `backgroundImage: null` set
|
|
880
|
+
directly; `el()`'s own default is `'none'`, a real measured value, so this has to be set
|
|
881
|
+
explicitly to reach the case.
|
|
882
|
+
|
|
883
|
+
```text
|
|
884
|
+
e1 color #000000 on unknown (not captured e1); ratio unmeasured (not-captured); 16px/400
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
**Live example** (`fixture=test/fixtures/color-modal-scrim.html url=file://.../color-modal-scrim.html`)
|
|
888
|
+
— regenerated fresh by `test/browser/doc-examples.test.ts` every run. `e2`'s own sibling `e3` covers
|
|
889
|
+
it; `e6` sits behind `e5`, which paints above the ancestor chain's own backdrop.
|
|
890
|
+
|
|
891
|
+
```text
|
|
892
|
+
# dnumb digest v2 — file://.../color-modal-scrim.html — 1024x768 — cdp
|
|
893
|
+
# 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
|
|
894
|
+
# page: 1024x240 (7 records → 7 layout nodes, 0 dropped, 0 wrappers folded)
|
|
895
|
+
# colors: 2 fg/bg pairs across 2 text elements; 2 unmeasured (1 under, 1 behind)
|
|
896
|
+
e0 body @0,0 1024x240
|
|
897
|
+
e1 div @0,0 300x100
|
|
898
|
+
e2 span "Text under a scrim" @0,0 170x28
|
|
899
|
+
e3 div @0,0 300x100 ↓-28
|
|
900
|
+
e4 div @0,140 300x100 ↓40
|
|
901
|
+
e5 div @0,140 300x100
|
|
902
|
+
e6 span "Text over a sibling's backgrou…" @0,140 280x24 ↓-100
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
`e2`'s own color fact is `color #000000 on unknown (under e3); ratio unmeasured; 16px/400` — this
|
|
906
|
+
is the most actionable `unmeasured` reason there is. The `<id>` names the actual covering element,
|
|
907
|
+
annotation and all (`e3` here is a plain `div`; on a real page it reads as something like `under
|
|
908
|
+
iframe#consent-frame` or `under div.modal-scrim`), which tells an agent *what* is sitting over the
|
|
909
|
+
text without a further tool call to find out.
|
|
910
|
+
|
|
911
|
+
### The image footprint
|
|
912
|
+
|
|
913
|
+
`image <id> (<W>x<H> of <W>x<H>)` carries two sizes: the first is the image-bearing element `<id>`'s
|
|
914
|
+
own rendered box; the second is the text element's own box. This exists because the token used to
|
|
915
|
+
be indistinguishable in a genuinely useful way: Wikipedia's sortable-table column headers flag
|
|
916
|
+
`unmeasured (image ...)` because of a 12×12px decorative sort-arrow icon in one corner of the cell,
|
|
917
|
+
and without a size, that token read identically to a full-bleed 1440×600 hero photo sitting behind
|
|
918
|
+
a page heading. An agent reading either line alone cannot tell "irrelevant corner decoration, the
|
|
919
|
+
ratio is almost certainly fine" from "genuinely unmeasurable, do not trust anything here" without
|
|
920
|
+
spending a screenshot round-trip — exactly the cost this whole feature exists to avoid. The two
|
|
921
|
+
sizes are a size *proxy*, not the image's true painted footprint: `background-size` and
|
|
922
|
+
`background-position` can still make a small box crop-and-fill a dominant image, or a large box
|
|
923
|
+
tile a small icon, so `image e_th (12x12 of 180x40)` narrows the question honestly rather than
|
|
924
|
+
closing it.
|
|
925
|
+
|
|
926
|
+
**Live example** (`fixture=test/fixtures/color-gradient-hero.html url=file://.../color-gradient-hero.html`)
|
|
927
|
+
— regenerated fresh by `test/browser/doc-examples.test.ts` every run.
|
|
928
|
+
|
|
929
|
+
```text
|
|
930
|
+
# dnumb digest v2 — file://.../color-gradient-hero.html — 1024x768 — cdp
|
|
931
|
+
# 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
|
|
932
|
+
# page: 1024x160 (5 records → 5 layout nodes, 0 dropped, 0 wrappers folded)
|
|
933
|
+
# colors: 2 fg/bg pairs across 2 text elements; 2 unmeasured (1 gradient, 1 image)
|
|
934
|
+
e0 body @0,0 1024x160
|
|
935
|
+
e1 div @0,0 1024x76
|
|
936
|
+
e2 span "Text over a gradient" @20,20 320x36
|
|
937
|
+
e3 div @0,96 1024x64 ↓20
|
|
938
|
+
e4 span "Text over an image" @20,116 190x24
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
`e2`'s color fact is `color #ffffff on gradient(#4285f4→#0d47a2) e1; ratio unmeasured; 24px/700`;
|
|
942
|
+
`e4`'s is `color #ffffff on image e3 (1024x64 of 190x24); ratio unmeasured; 16px/400` — the
|
|
943
|
+
image-bearing box (`e3`, 1024×64) is far larger than the text's own box (190×24), which is itself
|
|
944
|
+
already a signal about how much of the surrounding context the image plausibly dominates.
|
|
945
|
+
|
|
946
|
+
### Modern color syntax
|
|
947
|
+
|
|
948
|
+
Computed colors are not always `rgb()`. `oklch()`, `oklab()`, `lab()`, `lch()`, `color(srgb …)` and
|
|
949
|
+
`color(display-p3 …)` all survive verbatim to `getComputedStyle` and to CDP's own string table —
|
|
950
|
+
Tailwind v4 ships its default palette in `oklch`, and real component-library stylesheets'
|
|
951
|
+
*computed* serialization skews toward `oklab`/`lab` even more than `oklch`. dnumb converts all of
|
|
952
|
+
these deterministically to sRGB hex (display-p3 values outside the sRGB gamut are clamped, and the
|
|
953
|
+
line still reports the number, not the color); anything it cannot convert prints the color
|
|
954
|
+
verbatim with `ratio unmeasured (color-space)` rather than guessing.
|
|
955
|
+
|
|
956
|
+
**Live example** (`fixture=test/fixtures/color-oklch-tailwind.html url=file://.../color-oklch-tailwind.html`)
|
|
957
|
+
— regenerated fresh by `test/browser/doc-examples.test.ts` every run. Four pairs convert cleanly;
|
|
958
|
+
`e10`'s `color(rec2020 1 0 0)` does not.
|
|
959
|
+
|
|
960
|
+
```text
|
|
961
|
+
# dnumb digest v2 — file://.../color-oklch-tailwind.html — 1024x768 — cdp
|
|
962
|
+
# 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
|
|
963
|
+
# page: 1024x400 (11 records → 11 layout nodes, 0 dropped, 0 wrappers folded)
|
|
964
|
+
# colors: 5 fg/bg pairs across 5 text elements; ratio 2.52–10.06; lowest e4 #f4f9ff on #4ba3f7 via e3 16px/400; 1 unmeasured (1 color-space)
|
|
965
|
+
e0 body @0,0 1024x400
|
|
966
|
+
e1 div @0,0 1024x64
|
|
967
|
+
e2 span "Real Tailwind-v4-shaped comput…" @20,20 400x24
|
|
968
|
+
[fold: ×4 more like e1 — e3 @0,84 ↓20, e5 @0,168 ↓20, e7 @0,252 ↓20, e9 @0,336 ↓20]
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
`e10`'s own color fact is `color color(rec2020 1 0 0) on #202124 via e9; ratio unmeasured
|
|
972
|
+
(color-space); 16px/400` — the ink prints verbatim because it could not be converted; the backdrop,
|
|
973
|
+
which did convert, still prints as hex.
|
|
974
|
+
|
|
975
|
+
### The `# colors:` header line
|
|
976
|
+
|
|
977
|
+
**Grammar template** — placeholders; the live examples above are real instances, and the two below
|
|
978
|
+
show the `ratio`/`lowest` clause and the `unmeasured` breakdown each independently omitted.
|
|
979
|
+
|
|
980
|
+
```text
|
|
981
|
+
# colors: <P> fg/bg pairs across <T> text elements[; ratio <min>–<max>; lowest <id> <fg> on <bg> <size>px/<weight>][; <U> unmeasured (<breakdown>)]
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
Emitted whenever at least one element carries a color fact, omitted otherwise — placed after
|
|
985
|
+
`# facts:` and before `# TRUNCATED:` / `# not measured:`, because it is a measurement *of this
|
|
986
|
+
page*, not a permanent property of the capture path (that distinction is what puts `— cdp`/`—
|
|
987
|
+
walk` on the identity line instead). `<P>` is the count of distinct (ink, backdrop) pairs — the
|
|
988
|
+
same one-row-per-pair discipline `q color` uses, not one row per element — and `<T>` is the count
|
|
989
|
+
of gated text elements.
|
|
990
|
+
|
|
991
|
+
**The `ratio`/`lowest` clause is present only when at least one fact resolved to a real number.**
|
|
992
|
+
A page that is entirely gradients, images and overlays has nothing to report there, and omitting
|
|
993
|
+
the clause is more honest than printing a range over zero measured elements — the gradient-hero
|
|
994
|
+
example above has none. Naming the `lowest` reports the minimum of a measured distribution with
|
|
995
|
+
its own counter-facts inline, following the precedent the page-dominance clause already set (`;
|
|
996
|
+
<id> extends the page height by <N>px beyond the next element`, [Page](#header-lines) above): the
|
|
997
|
+
page's lowest-contrast text may be a disabled placeholder, and the line does not say otherwise.
|
|
998
|
+
|
|
999
|
+
**The `unmeasured` tally is the honesty clause.** It names how much of the page's text the
|
|
1000
|
+
distribution above does not cover, broken down by reason, most-common-first:
|
|
1001
|
+
`<count> gradient`, `<count> image`, `<count> under`, `<count> behind`, `<count> color-space` —
|
|
1002
|
+
whichever of those are nonzero, in that order. A reason with a zero count is omitted, not printed
|
|
1003
|
+
as `0 gradient`.
|
|
1004
|
+
|
|
1005
|
+
### The capture-path asymmetry
|
|
1006
|
+
|
|
1007
|
+
Overlap detection — the `under`/`behind` reasons — is the one place the two capture paths differ
|
|
1008
|
+
mechanically, and it is not a footnote. The portable walk samples one point, the text box's
|
|
1009
|
+
*centre*, via `document.elementsFromPoint`; the CDP path checks the *whole rect* against paint
|
|
1010
|
+
order and intersection, Node-side. Both read the same fields into the same grammar, but they can
|
|
1011
|
+
disagree on partially-overlapped text: a cookie banner or sticky nav covering the top third of a
|
|
1012
|
+
heading but not its centre reads as no overlay on `walk` while `cdp`'s whole-rect check would catch
|
|
1013
|
+
it. Measured on a real soak: a raw overlay rate of 1.9% rose to roughly **8.5%** — about 4× —
|
|
1014
|
+
among candidates that otherwise got a clean, confident verdict, which is the direction and rough
|
|
1015
|
+
scale of undercount a centre-point sample on `walk` should be expected to carry. A reader comparing
|
|
1016
|
+
a `walk` capture against a `cdp` capture of the same page should expect *more* `unmeasured
|
|
1017
|
+
(under/behind ...)` reasons on `cdp`, not fewer, independent of anything about the page changing —
|
|
1018
|
+
the same shape of disagreement `# not measured:`'s `cdp`-vs-`walk` shadow DOM gap already has (see
|
|
1019
|
+
[Not measured](#header-lines) above): two capture paths that can silently disagree about what a
|
|
1020
|
+
page contains, disclosed rather than papered over.
|
|
1021
|
+
|
|
1022
|
+
### Never a verdict, just the number
|
|
1023
|
+
|
|
1024
|
+
Same discipline as every other fact in this format, stated in full because color is the one most
|
|
1025
|
+
likely to be misread as a check:
|
|
1026
|
+
|
|
1027
|
+
1. dnumb prints measurements, and deterministic functions of its own measurements — a contrast
|
|
1028
|
+
ratio is `f(two measured colors)`, the same standing as `scrollWidth − clientWidth`.
|
|
1029
|
+
2. It never compares a measurement to a constant that was not measured from the page. `4.5`,
|
|
1030
|
+
`3:1`, "large text" are not on any line. No token ever says `pass`, `fail`, `AA`, `AAA`, `low`,
|
|
1031
|
+
or `poor`. "WCAG" appears only as the name of the ratio formula.
|
|
1032
|
+
<!-- checked-claim: color-never-verdicts -->
|
|
1033
|
+
3. No emission, fold, or ordering decision depends on such a comparison. `q color`'s rows sort by
|
|
1034
|
+
ratio ascending because that is a useful reading order, not because anything below a line is
|
|
1035
|
+
hidden.
|
|
1036
|
+
4. A number this package cannot establish the truth of is never printed. `unmeasured`, with a
|
|
1037
|
+
named reason, instead.
|
|
1038
|
+
|
|
1039
|
+
The agent reading the ratio adjudicates whether it matters, exactly as with every overflow and
|
|
1040
|
+
clip fact in this package. dnumb's job ends at the number being true — composited, overlay-checked,
|
|
1041
|
+
color-space-converted — with the size, weight, opacity and shadow that decide what it means printed
|
|
1042
|
+
right beside it.
|
|
1043
|
+
|
|
1044
|
+
### Where color lives
|
|
1045
|
+
|
|
1046
|
+
Not on a digest body line, by design, for the same reason a color fact is measurement-heavy:
|
|
1047
|
+
folding is deliberately signature-based, and a color fact in the fold signature would make a
|
|
1048
|
+
zebra-striped table — alternating row backgrounds, the most ordinary pattern on the web — refuse to
|
|
1049
|
+
fold at all. `q describe <id>` prints the [per-element grammar](#the-per-element-grammar) for one
|
|
1050
|
+
element; `q color` prints one row per (ink, backdrop) pair across the whole capture, sorted by
|
|
1051
|
+
ratio ascending with `unmeasured` last; `q color --all` prints one line per gated element under the
|
|
1052
|
+
usual `--limit` convention. `# colors:` (above) is the only place color reaches the digest itself.
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
## The fold line
|
|
1056
|
+
|
|
1057
|
+
**Grammar template** — placeholders; the live example right below is a real instance of it.
|
|
1058
|
+
|
|
1059
|
+
```text
|
|
1060
|
+
[fold: ×<N>[/<period>] more like <id>[ (<variance>)] — <id> @<x>,<y><gap>, <id> @<x>,<y><gap>, …]
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
When a run of siblings repeats with some period from one to six (three consecutive identical
|
|
1064
|
+
siblings is a period-1 run of length three; a `dt`/`dd` list is a period-2 run), the first whole
|
|
1065
|
+
period is printed in full — with each member's own children — and the rest collapse into one fold
|
|
1066
|
+
line at the same indentation, immediately after.
|
|
1067
|
+
|
|
1068
|
+
**Live example** (`synthetic=fold-basic`) — five identical `<div>Row</div>` siblings, period 1.
|
|
1069
|
+
Regenerated and diffed against this block by `test/unit/doc-examples.test.ts` on every run — this
|
|
1070
|
+
is the exact scenario the one hand-pinned "spec drift guard" test used to check on its own; this
|
|
1071
|
+
mechanism now covers it and every other example on this page the same way.
|
|
1072
|
+
|
|
1073
|
+
```text
|
|
1074
|
+
r0 div "Row" @0,0 300x30
|
|
1075
|
+
[fold: ×4 more like r0 — r1 @0,40 ↓10, r2 @0,80 ↓10, r3 @0,120 ↓10, r4 @0,160 ↓10]
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
`×4` is the count of *additional* siblings, not the run length: five identical rows produce `×4`.
|
|
1079
|
+
Every folded sibling keeps its id and its position, so nothing a fold covers becomes
|
|
1080
|
+
unaddressable. Gaps inside the list are measured against the preceding entry in the run.
|
|
1081
|
+
|
|
1082
|
+
**A folded sibling's own descendants do not print anywhere in the fold line — the fold line names
|
|
1083
|
+
siblings, never their children.** For a fold member covered only by `[fold: ...]`'s own entry
|
|
1084
|
+
(id, position, gap), nothing about what it contains reaches the digest text. This is not a gap
|
|
1085
|
+
in disguise: an element with a fact *anywhere in its own descendants* is excluded from folding
|
|
1086
|
+
entirely, not merely grouped by a coarser match (see the signature list below) — it always gets
|
|
1087
|
+
its own full line, with its own children printed beneath it, so a fact never goes missing by way
|
|
1088
|
+
of a sibling's fold swallowing it. What a fold can still hide is a folded sibling's descendant
|
|
1089
|
+
*structure and content* when nothing under it carries a fact — the fold line's `id @x,y<gap>` for
|
|
1090
|
+
that sibling is everything the digest says about it; `--focus <id>` on that specific id renders its
|
|
1091
|
+
subtree in full when you need to see it (see "The `--focus` hint" below).
|
|
1092
|
+
|
|
1093
|
+
The signature that decides sameness is tag, computed role, flow, child count, a coarse
|
|
1094
|
+
order-of-magnitude size bucket for width and height, the element's own fact *signatures* — kind
|
|
1095
|
+
plus consequence tokens, with the numeric magnitude stripped — and whether a fact exists *anywhere
|
|
1096
|
+
in its descendants* at all. All of this is deliberate, and narrower than it looks:
|
|
1097
|
+
|
|
1098
|
+
- **The size bucket is coarse on purpose.** Exact-dimension matching was tried and meant nothing
|
|
1099
|
+
on a real page ever folded (thirty story rows with differently-long titles are thirty different
|
|
1100
|
+
heights); no size check at all was also tried and meant a page's own skeleton — a `header`, `nav`,
|
|
1101
|
+
`main` and `footer` sharing a tag, with wildly different heights — folded into itself, its
|
|
1102
|
+
content vanishing with it. The bucket groups sizes within roughly one order of magnitude, which
|
|
1103
|
+
keeps ordinary content variation folding while still splitting a 40px row from a 3000px one.
|
|
1104
|
+
- **Own facts are part of it by kind and consequence, not by exact text.** An element that overflows
|
|
1105
|
+
is not interchangeable with one that does not, however alike their boxes are — but the difference
|
|
1106
|
+
that matters is `clip` vs `scroll`, `ellipsis` vs `wraps`, never the pixel count beside it. Two
|
|
1107
|
+
rows overflowing by 12px and 14px, both `clip, ellipsis`, are the same kind of thing and fold; a
|
|
1108
|
+
row overflowing by 900px and `clip, ellipsis` does not fold with one overflowing by 2px and
|
|
1109
|
+
`scroll, wraps`, however close their boxes are otherwise. This grouping is safe precisely because
|
|
1110
|
+
the representative's own line, printed right above the fold, already shows a fact of the same
|
|
1111
|
+
kind — nothing is lost by treating "12px" and "14px" as interchangeable there.
|
|
1112
|
+
- **A fact anywhere in an element's descendants is not grouped at all — it is excluded from folding,
|
|
1113
|
+
full stop.** No representative stands in for a grandchild's measurement the way it stands in for
|
|
1114
|
+
an own fact, because the fold line names siblings, never grandchildren. So instead of a coarser
|
|
1115
|
+
match, any node with a fact below it gets a signature unique to itself: it can never match another
|
|
1116
|
+
position, and must always print its own line — with its own children, so the descendant's fact
|
|
1117
|
+
prints too — or be swept into an explicitly-disclosed `[folded: N nodes, bbox ...]` collapse under
|
|
1118
|
+
real budget pressure (see [The line budget](#the-line-budget)), never silently absorbed as an
|
|
1119
|
+
unlabelled fold member. Measured on real captures before this was fixed: `shoelace-docs` had 115
|
|
1120
|
+
of 301 fact-bearing elements, and `grafana-play` 97 of 166, missing from the rendered text
|
|
1121
|
+
entirely, with no `[folded:]` line to mark the gap — not folded-and-disclosed, just gone. On the
|
|
1122
|
+
same eight real fixtures now: zero.
|
|
1123
|
+
<!-- checked-claim: fold-never-drops-a-fact -->
|
|
1124
|
+
|
|
1125
|
+
**Live example** (`synthetic=fold-outlier-fact`) — four otherwise-identical rows; the fourth carries
|
|
1126
|
+
an `overflow-x` fact *of its own* the other three do not, so it prints its own line instead of
|
|
1127
|
+
joining their fold, gap token and all.
|
|
1128
|
+
|
|
1129
|
+
```text
|
|
1130
|
+
r0 div "Row" @0,0 300x30
|
|
1131
|
+
[fold: ×2 more like r0 — r1 @0,40 ↓10, r2 @0,80 ↓10]
|
|
1132
|
+
r3 div "Row" @0,120 300x30 ↓10 [overflow-x +900px; clip, ellipsis]
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
**Live example** (`synthetic=fold-descendant-fact`) — four otherwise-identical rows again, but this
|
|
1136
|
+
time the fourth carries no fact of its own; its *child* does. `r3` still breaks out of the fold, and
|
|
1137
|
+
its child `c3` — with the fact — prints beneath it, exactly as it would if `r3` had never been a
|
|
1138
|
+
fold candidate at all.
|
|
1139
|
+
|
|
1140
|
+
```text
|
|
1141
|
+
r0 div "Row" @0,0 300x30
|
|
1142
|
+
c0 span "Label" @4,4 100x20
|
|
1143
|
+
[fold: ×2 more like r0 — r1 @0,40 ↓10, r2 @0,80 ↓10]
|
|
1144
|
+
r3 div "Row" @0,120 300x30 ↓10
|
|
1145
|
+
c3 span "Label" @4,124 100x20 [overflow-x +400px; clip, ellipsis]
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
### Period
|
|
1149
|
+
|
|
1150
|
+
A run does not have to be *consecutively* identical to fold. Real lists are periodic, not
|
|
1151
|
+
uniform — Hacker News alternates story row, subtext row, spacer row; a definition list alternates
|
|
1152
|
+
`dt`, `dd`. The renderer searches periods from one up to six and folds whichever period covers the
|
|
1153
|
+
most siblings; when the period is greater than one, the count is followed by `/<period>`:
|
|
1154
|
+
|
|
1155
|
+
**Live example** (`synthetic=fold-period-dtdd`) — a five-entry `dt`/`dd` definition list, period 2.
|
|
1156
|
+
|
|
1157
|
+
```text
|
|
1158
|
+
x0 dt "Name" @0,0 300x20
|
|
1159
|
+
x1 dd "Ada Lovelace" @0,20 300x20 ↓0
|
|
1160
|
+
[fold: ×8/2 more like x0 — x2 @0,40 ↓0, x3 @0,60 ↓0, x4 @0,80 ↓0, x5 @0,100 ↓0, x6 @0,120 ↓0, x7 @0,140 ↓0, x8 @0,160 ↓0, x9 @0,180 ↓0]
|
|
1161
|
+
```
|
|
1162
|
+
|
|
1163
|
+
The first whole period (one `dt` and one `dd` here) prints in full; the fold covers everything
|
|
1164
|
+
after it. `period` is omitted from the line (and defaults to `1` when parsed) for a plain
|
|
1165
|
+
consecutive run.
|
|
1166
|
+
|
|
1167
|
+
### Variance
|
|
1168
|
+
|
|
1169
|
+
Dimensions are not part of the signature beyond the coarse bucket above, so members of a fold can
|
|
1170
|
+
still differ in size — a row three times taller than its neighbours would otherwise hide silently
|
|
1171
|
+
inside a fold, which is the exact failure this format exists to prevent. When the folded members
|
|
1172
|
+
are not all the same size, a `(...)` token right after the representative's id reports the range —
|
|
1173
|
+
`(w-range <min>-<max> across <count>)`, `(h-range <min>-<max> across <count>)`, or both,
|
|
1174
|
+
comma-joined, when both axes vary. `<count>` is the number of siblings the range was pooled from,
|
|
1175
|
+
including the fully-printed representative.
|
|
1176
|
+
|
|
1177
|
+
**The `-range`/`across <count>` wording is deliberate, not decoration.** A blind read on a *clean*
|
|
1178
|
+
page found a rater treat the older, terser `(h 32-48)` form as if it described one specific
|
|
1179
|
+
element, back-computed an overflow from it, and opened with "Likely issue" — a fabricated
|
|
1180
|
+
`overflow-x` fact the renderer never emitted. The current wording makes that misreading require
|
|
1181
|
+
overriding two explicit words (that this is a *range*, pooled *across* several siblings) rather
|
|
1182
|
+
than simply failing to notice an absent one.
|
|
1183
|
+
|
|
1184
|
+
**Live example** (`synthetic=fold-variance`) — eight rows, one three times taller than the other
|
|
1185
|
+
seven.
|
|
1186
|
+
|
|
1187
|
+
```text
|
|
1188
|
+
r0 div "Row" @0,0 300x30
|
|
1189
|
+
[fold: ×7 more like r0 (h-range 30-90 across 8) — r1 @0,40 ↓10, r2 @0,80 ↓10, r3 @0,120 ↓10, r4 @0,160 ↓-50, r5 @0,200 ↓10, r6 @0,240 ↓10, r7 @0,280 ↓10]
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
Sub-pixel differences (a 0.2px rounding wobble reported as `100-101`) are not a real spread and are
|
|
1193
|
+
suppressed — the comparison uses the same 1px epsilon `facts.ts` uses for overflow, and rounds only
|
|
1194
|
+
for display.
|
|
1195
|
+
|
|
1196
|
+
For a periodic fold, the spread is computed *per phase* rather than pooled across all of them: a
|
|
1197
|
+
story/subtext/spacer list's own inherent height variation is not a defect, and pooling it would
|
|
1198
|
+
make a real outlier in one phase indistinguishable from that healthy variation. Phases with no
|
|
1199
|
+
real spread say nothing; a phase that has one gets its own `phase<k>` token in front of its own
|
|
1200
|
+
`-range`/`across` clause:
|
|
1201
|
+
|
|
1202
|
+
**Live example** (`synthetic=fold-phase`) — a five-period story/subtext/spacer list (heights 30/12/2,
|
|
1203
|
+
its own healthy per-phase variation) with one real outlier: the very first `row` is 90px instead of
|
|
1204
|
+
30px. The pooled reading (spanning all three phases' own normal spread) does not appear; only the
|
|
1205
|
+
phase that actually has an outlier gets a token.
|
|
1206
|
+
|
|
1207
|
+
```text
|
|
1208
|
+
n0 div.row @0,0 1024x90
|
|
1209
|
+
n1 div.subtext @0,90 1024x12 ↓0
|
|
1210
|
+
n2 div.spacer @0,102 1024x2 ↓0
|
|
1211
|
+
[fold: ×12/3 more like n0 (phase0 h-range 30-90 across 5) — n3 @0,104 ↓0, n4 @0,134 ↓0, n5 @0,146 ↓0, n6 @0,148 ↓0, n7 @0,178 ↓0, n8 @0,190 ↓0, n9 @0,192 ↓0, n10 @0,222 ↓0, n11 @0,234 ↓0, n12 @0,236 ↓0, n13 @0,266 ↓0, n14 @0,278 ↓0]
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1214
|
+
At most 40 siblings are named. Past that the line says what it dropped:
|
|
1215
|
+
|
|
1216
|
+
**Live example** (`synthetic=fold-cap-40 elide=true`) — 200 identical rows, no line-budget pressure
|
|
1217
|
+
at all; the 40-name cap on one fold line is independent of `options.budgetLines`. `⋯` below is this
|
|
1218
|
+
document's own elision mark for a block too long to paste in full — deliberately not `…`, which the
|
|
1219
|
+
renderer itself prints (see the real `…+159 not listed` at the end of this same line) — and the
|
|
1220
|
+
check this caption asks for only verifies the real text before and after it, not what `⋯` stands
|
|
1221
|
+
for.
|
|
1222
|
+
|
|
1223
|
+
```text
|
|
1224
|
+
r0 div "Row" @0,0 300x30
|
|
1225
|
+
[fold: ×199 more like r0 — r1 @0,40 ↓10, r2 @0,80 ↓10, r3 @0,120 ↓10, r4 @0,160 ↓10, r5 @0,200 ↓10, r6 @0,240 ↓10, r7 @0,280 ↓10, r8 @0,320 ↓10, r9 @0,360 ↓10, r10 @0,400 ↓10, ⋯, r40 @0,1600 ↓10, …+159 not listed (--focus e0)]
|
|
1226
|
+
```
|
|
1227
|
+
|
|
1228
|
+
### The `--focus` hint
|
|
1229
|
+
|
|
1230
|
+
`--focus <id>` (`renderDigest(snapshot, meta, { focus: 'r0' })` in the library, `--focus <id>` on
|
|
1231
|
+
the CLI) accepts *any* element id or tag name present in the capture and renders that element's own
|
|
1232
|
+
subtree in full. It works identically whether `<id>` is the fold's representative, one of the
|
|
1233
|
+
up-to-40 entries named on the fold line, or an id past the 40-name cap that you already know from
|
|
1234
|
+
elsewhere (`q find`, devtools, an earlier capture of the same element).
|
|
1235
|
+
|
|
1236
|
+
**The `…+N not listed` hint names the fold's *parent*, not the representative — `--focus e0` above,
|
|
1237
|
+
not `--focus r0`.** The representative (`r0`) is already fully expanded on the line right above the
|
|
1238
|
+
fold; focusing it would just re-print what is already on screen. The `…+159 not listed` entries are
|
|
1239
|
+
`r0`'s *siblings*, and the only id whose own subtree is guaranteed to contain every one of them is
|
|
1240
|
+
the container they all share — here, `e0`, the `body`. Focusing a specific sibling directly (`--focus
|
|
1241
|
+
r41`, once you have its id from another source) also works and is more direct when you already know
|
|
1242
|
+
which one you want; the parent hint is what to reach for when you don't.
|
|
1243
|
+
|
|
1244
|
+
**Folding, aggressive shape-widening, and subtree collapse are all disabled entirely once
|
|
1245
|
+
`--focus` has scoped the output — a focused render always converges.** Re-rendering a fold's parent
|
|
1246
|
+
under `--focus` would otherwise just reapply the same period detection and the same 40-name cap to
|
|
1247
|
+
its children, reproducing an identical `…+N not listed` line one level in and making the hint's
|
|
1248
|
+
promise false again, one hop later; collapse used to have the identical problem one stage later —
|
|
1249
|
+
it picked its own candidates *inside* the focused subtree and hid them behind a fresh `[folded:
|
|
1250
|
+
…]` line, so the collapsed line's own `(--focus <id>)` escape hatch led nowhere. Measured on a real
|
|
1251
|
+
capture before this was fixed: focusing `ar.wikipedia.org`'s own `e143`→`e445`→`e1691`→`e1702`
|
|
1252
|
+
chain peeled exactly one structural wrapper per hop while the collapsed fraction stayed
|
|
1253
|
+
97–99.9% the whole way — every hop's ids and arithmetic were correct, but stage 3 was reapplying
|
|
1254
|
+
the same budget to a subtree that was, on that page, itself bigger than the budget, so it just
|
|
1255
|
+
moved the frontier one level down and hid the rest again. Now, `--focus e143` on the same fixture
|
|
1256
|
+
returns the real article prose in full, zero `[folded: …]` lines anywhere in it.
|
|
1257
|
+
|
|
1258
|
+
`--focus` exists so a reader can ask for full detail on one thing; folding and collapsing are both
|
|
1259
|
+
compression devices for output the reader is not zoomed into, so once they are, neither has
|
|
1260
|
+
anything left to justify. **A focused render is deliberately unbounded by either budget option —
|
|
1261
|
+
`options.budgetChars` and `options.budgetLines` alike** — the caller asked to see everything under
|
|
1262
|
+
that id, and re-hiding parts of it would defeat the request rather than serve it. This can make a
|
|
1263
|
+
focused render large when the focused id is itself most of the page, but that is the honest cost of
|
|
1264
|
+
"show me everything under here," not a new failure mode: it is exactly what focusing the document
|
|
1265
|
+
root would already mean with no collapse at all, and since nothing under a focused render can be
|
|
1266
|
+
further collapsed, one `--focus <id>` call now always reaches every fact in that subtree with no
|
|
1267
|
+
further hint to chase. This is also the shape a reader should expect from *manually* naming a
|
|
1268
|
+
coarse id rather than following the cue's own hint: `--focus e143` on `ar.wikipedia.org` — a whole
|
|
1269
|
+
article's own root — returns 192,868 characters fully expanded, not a mistake or a regression, but
|
|
1270
|
+
the literal cost of "show me everything under here" applied to something that is itself most of the
|
|
1271
|
+
page. Following the cue's own hint id, by contrast, converges cheaply because the cue always names
|
|
1272
|
+
the collapse actually blocking the render, not an arbitrary ancestor — see [the two-call
|
|
1273
|
+
idiom](#the-line-budget) below.
|
|
1274
|
+
|
|
1275
|
+
`--focus` also changes the `# page:` line: see [item 3, "Page"](#header-lines) under [Header
|
|
1276
|
+
lines](#header-lines) above.
|
|
1277
|
+
|
|
1278
|
+
## The collapsed line
|
|
1279
|
+
|
|
1280
|
+
**Grammar template** — placeholders; the live examples below are real instances.
|
|
1281
|
+
|
|
1282
|
+
```text
|
|
1283
|
+
[folded: <N> nodes, bbox @<x>,<y> <W>x<H> (--focus <id>)]
|
|
1284
|
+
[folded: <N> nodes, <K> facts, bbox @<x>,<y> <W>x<H> (--focus <id>)]
|
|
1285
|
+
```
|
|
1286
|
+
|
|
1287
|
+
Emitted in place of a container's entire child list when the digest is over budget and folding was
|
|
1288
|
+
not enough. `<N>` counts every descendant node, not just direct children. The bbox is the union of
|
|
1289
|
+
their boxes.
|
|
1290
|
+
|
|
1291
|
+
**Live example** (`synthetic=collapsed-line`) — ten children sharing no signature at any setting
|
|
1292
|
+
(ten different tags: `p`, `span`, `a`, `h2`, `ul`, `button`, `label`, `em`, `strong`, `code`), so
|
|
1293
|
+
folding cannot help under a tight budget and `e1`'s child list collapses instead.
|
|
1294
|
+
|
|
1295
|
+
```text
|
|
1296
|
+
e0 body @0,0 1024x800
|
|
1297
|
+
e1 div @0,0 300x400
|
|
1298
|
+
[folded: 10 nodes, bbox @0,0 300x390 (--focus e1)]
|
|
1299
|
+
```
|
|
1300
|
+
|
|
1301
|
+
The container's own line (`e1`) is still printed. The subtree is summarised, never silently
|
|
1302
|
+
dropped — a cap the reader cannot see is indistinguishable from complete output.
|
|
1303
|
+
|
|
1304
|
+
**`(--focus <id>)` names the collapsed container itself — the same id printed one line above, on
|
|
1305
|
+
the container's own line — so a reader always has a printed route from "this is summarised" to
|
|
1306
|
+
"here is how to see inside it," not just an id they could in principle have copied down themselves.
|
|
1307
|
+
This is the render's heaviest disclosure: a whole subtree, facts among them, reduced to one line,
|
|
1308
|
+
and a reader told how many facts are in there with no printed way to reach them is being taunted,
|
|
1309
|
+
not informed. `--focus e1` on the example above re-renders `e1`'s subtree in full.
|
|
1310
|
+
|
|
1311
|
+
**`, <K> facts` names how many of the collapsed subtree's elements carry a fact — omitted when
|
|
1312
|
+
zero, present whenever at least one of them does.** A collapsed line used to disclose a node count
|
|
1313
|
+
and a bounding box but nothing about what was inside. `<K>` is counted from the same per-element
|
|
1314
|
+
fact map the digest body reads facts from, so it can never disagree with what an uncollapsed render
|
|
1315
|
+
of the same subtree would show. It counts the same digest-body facts `# facts: N` counts
|
|
1316
|
+
(`overflow-x`/`overflow-y`/`zero-box`/`clipped`) — color facts are never in this count, because they
|
|
1317
|
+
are never digest-body facts at all (see [Color](#color) below).
|
|
1318
|
+
|
|
1319
|
+
**Live example** (`synthetic=collapsed-line-with-facts`) — the same ten children, but three of them
|
|
1320
|
+
now carry an `overflow-x` fact each.
|
|
1321
|
+
|
|
1322
|
+
```text
|
|
1323
|
+
e0 body @0,0 1024x800
|
|
1324
|
+
e1 div @0,0 300x400
|
|
1325
|
+
[folded: 10 nodes, 3 facts, bbox @0,0 300x390 (--focus e1)]
|
|
1326
|
+
```
|
|
1327
|
+
|
|
1328
|
+
`<K>` says only *how many* facts are hidden, not what they are — a reader who needs the actual
|
|
1329
|
+
measurements follows `(--focus <id>)`, captures narrower, or loosens `--budget`, rather than
|
|
1330
|
+
reasoning about what a collapsed line does not show.
|
|
1331
|
+
|
|
1332
|
+
**When a collapse eats most of the page, the header says so too — see [the collapse
|
|
1333
|
+
cue](#header-lines) below.** The collapsed line's own `(--focus <id>)` answers "how do I see
|
|
1334
|
+
inside this one collapse"; the header cue answers "do I need to," which matters more on a page
|
|
1335
|
+
where several collapses together hide most of what this render had to show.
|
|
1336
|
+
|
|
1337
|
+
## The line budget
|
|
1338
|
+
|
|
1339
|
+
**The meter is characters, not lines.** `options.budgetChars` (default `32768`) is the standing
|
|
1340
|
+
budget — always active, whether a caller sets it or not — and `options.budgetLines` is an
|
|
1341
|
+
*optional additional* ceiling with no default of its own: when a caller passes it, the render
|
|
1342
|
+
stays under whichever of the two limits is stricter, and passing one never turns the other off.
|
|
1343
|
+
Characters replaced lines as the primary meter because lines were a poor proxy for what the budget
|
|
1344
|
+
actually protects — a reader's token cost. A digest line runs anywhere from about 40 to over 900
|
|
1345
|
+
characters (a full 40-entry fold line can exceed 1000), so a line-count budget could not predict
|
|
1346
|
+
token cost within better than roughly a factor of 15; characters predict it within about ±20%
|
|
1347
|
+
(measured chars-per-token across a real soak: 2.51–3.50, median 2.84) with no dependency on which
|
|
1348
|
+
tokenizer the *consuming* model happens to use. `32768` chars is about 11.5K tokens at that
|
|
1349
|
+
median — the measured "knee" of the trade: half it and a real page's skeleton starts losing
|
|
1350
|
+
section-level structure; double it and an ordinary production page's default `look` costs 20K+
|
|
1351
|
+
tokens again, the exact cost this design exists to bound.
|
|
1352
|
+
|
|
1353
|
+
Over budget, the renderer degrades in three stages, in this order:
|
|
1354
|
+
|
|
1355
|
+
1. **Normal.** Fold runs that match on tag, role, flow, child count, a coarse size bucket, own
|
|
1356
|
+
facts, and whether a fact exists anywhere in their descendants.
|
|
1357
|
+
2. **Aggressive.** Widen only the *shape* half of that match — drop the size bucket, so a page's
|
|
1358
|
+
own wildly different container heights fold more readily. Never widens the fact half: no
|
|
1359
|
+
degradation stage can make two elements fold together if they differ in what they measure, own
|
|
1360
|
+
or descendant. This is a fix, not a restatement of old behaviour — the previous aggressive stage
|
|
1361
|
+
dropped facts from the signature entirely, so a row that overflowed and a row that did not could
|
|
1362
|
+
fold together under budget pressure, with only the representative's fact text left to (silently,
|
|
1363
|
+
incorrectly) stand in for both. See [The fold line](#the-fold-line) for what "a fact anywhere in
|
|
1364
|
+
their descendants" means for folding.
|
|
1365
|
+
3. **Collapse.** Replace a container's children with a `[folded: …]` line, one container at a
|
|
1366
|
+
time, until the budget is met. Candidates are ranked **shallowest first** — by subtree height
|
|
1367
|
+
ascending (a container of only leaves collapses before its parent, which collapses before *its*
|
|
1368
|
+
parent, and so on upward: "peel from the leaves"), ties broken by descendant count ascending —
|
|
1369
|
+
with every non-root candidate tried before any root: collapsing a root can leave nothing else
|
|
1370
|
+
behind to read, so it is the true last resort rather than one more option competing on size. A
|
|
1371
|
+
candidate already inside a just-collapsed container is skipped rather than collapsed again,
|
|
1372
|
+
since it would change nothing, and a candidate whose own collapse would save fewer than two
|
|
1373
|
+
lines (a single-child wrapper, mostly) is skipped too — trading one line for one `[folded: …]`
|
|
1374
|
+
line buys nothing.
|
|
1375
|
+
|
|
1376
|
+
A page with no non-root container at all — a `body` of only leaf children, for instance — has no
|
|
1377
|
+
candidate until stage 3 reaches its root rung. Stage 3 can therefore still collapse the whole page
|
|
1378
|
+
to one `[folded: …]` line under a tight enough budget; that is the intended last resort, not a bug.
|
|
1379
|
+
|
|
1380
|
+
**Why the collapse order changed, and why raising the old line default alone never could have
|
|
1381
|
+
fixed this.** Ranking candidates *biggest first* — the previous order — meant the first collapse
|
|
1382
|
+
tried on a real production page was almost always a whole-page wrapper, so output was a step
|
|
1383
|
+
function with no middle: measured on real captures, `stripe.com`, `bbc.com`, and `tailwindcss.com`
|
|
1384
|
+
each rendered 12 lines at every budget up to some threshold and 1,050–1,363 lines immediately past
|
|
1385
|
+
it, with nothing achievable in between at *any* budget value. No default and no sweep of that one
|
|
1386
|
+
number could fix a step function with no middle rung to land on — the ordering was the defect, not
|
|
1387
|
+
the constant. Ranking shallowest-first instead means a page degrades through its own actual depth
|
|
1388
|
+
structure: measured after the reorder, sweeping the same three sites' budgets from 1K to 200K
|
|
1389
|
+
characters produces smooth, monotonic growth with no cliff anywhere in the range. `stripe.com`
|
|
1390
|
+
itself goes from a 12-line dead end to a 318-line skeleton at the default budget, with 97 disclosed
|
|
1391
|
+
collapses standing in for what a reader isn't looking at — every page in the investigating soak
|
|
1392
|
+
landed between roughly 1.5K and 11.6K tokens, none reduced to near-nothing and none an unbounded
|
|
1393
|
+
dump. Full numbers, method, and the sites involved are in `docs/notes/06-budget.md`; I have not
|
|
1394
|
+
independently reproduced the multi-site sweep here (it depends on soak bundles this environment
|
|
1395
|
+
does not have), so treat the specific figures as reported rather than re-verified by this pass —
|
|
1396
|
+
what I have verified directly is the shallowest-first ordering and the `budgetChars`/`budgetLines`
|
|
1397
|
+
relationship, against this repo's own synthetic fixtures and the shipped `render.ts`.
|
|
1398
|
+
|
|
1399
|
+
It never truncates mid-tree. Folding harder is always preferred to hiding a subtree, and hiding a
|
|
1400
|
+
subtree is always preferred to stopping in the middle.
|
|
1401
|
+
|
|
1402
|
+
### The two-call idiom
|
|
1403
|
+
|
|
1404
|
+
Treat `look` and a follow-up `--focus` as the intended shape of reading an unfamiliar page, not as
|
|
1405
|
+
a fallback for when the default output is too big. A bare `look` is bounded on every page — at
|
|
1406
|
+
most about 11.5K tokens, by design — and answers "where is everything, and where do the
|
|
1407
|
+
measurements live"; the collapse cue and every `[folded: …]` line's own `(--focus <id>)` then name
|
|
1408
|
+
exactly where to point a second, equally bounded call. Reported results from the budget
|
|
1409
|
+
investigation (`docs/notes/06-budget.md` §6; not independently reproduced here — real external
|
|
1410
|
+
sites, not fixtures this repo ships):
|
|
1411
|
+
|
|
1412
|
+
| site | `look` (default budget) | `--focus <cue id>` |
|
|
1413
|
+
|---|---|---|
|
|
1414
|
+
| stripe.com | 32,626 chars / ~11.5K tok / 318 lines | 1,529 chars / 18 lines, 0 folds |
|
|
1415
|
+
| bbc.com | 33,345 chars / ~11.7K tok / 382 lines | 701 chars / 12 lines, 0 folds |
|
|
1416
|
+
| tailwindcss.com | 20,808 chars / ~7.3K tok / 226 lines | 1,149 chars / 20 lines, 0 folds |
|
|
1417
|
+
| ar.wikipedia.org | 32,871 chars / ~11.6K tok / 402 lines | 1,329 chars / 18 lines, 0 folds |
|
|
1418
|
+
|
|
1419
|
+
Two bounded calls following the cue cost roughly what one of the *old* mid-size dumps cost, and the
|
|
1420
|
+
old failure mode — a 300-token render hiding 99%+ of the page with no printed route anywhere in
|
|
1421
|
+
it — no longer exists to fall into. **This is a different question from manually naming a coarse
|
|
1422
|
+
id.** Following the cue's own hint converges cheaply because the cue always names the collapse
|
|
1423
|
+
actually blocking the render; asking for a large, arbitrary subtree by hand is unbounded by design
|
|
1424
|
+
and pays the honest cost of what you asked for — see "The `--focus` hint" above for the
|
|
1425
|
+
`ar.wikipedia.org` example where that cost is 192,868 characters.
|
|
1426
|
+
|
|
1427
|
+
## What is not in the digest
|
|
1428
|
+
|
|
1429
|
+
**Full clip precision.** `capture` attaches a `ClipFact` to every element that extends past a
|
|
1430
|
+
clipping ancestor — which ancestor, its `overflow` value, how far past on each side, how much
|
|
1431
|
+
survives, and whether a user could scroll to reveal the rest. The summary of that renders inline as
|
|
1432
|
+
the [`clipped`](#clipped) fact, but `ElementRecord.clip` itself carries more precision than the
|
|
1433
|
+
line does: in the menu example above, `e5`'s record has `hidden.bottom: 103.78` where the digest
|
|
1434
|
+
line rounds to `103.8px`. Read the record rather than the line when a fractional pixel matters.
|
|
1435
|
+
|
|
1436
|
+
**Verdicts, severities, confidences and scores.** None exist anywhere in this package.
|
|
1437
|
+
|
|
1438
|
+
**Contrast, alignment, spacing and density.** Not measured. The predecessor's `spacing` and
|
|
1439
|
+
`alignment` checks produced zero confirmed true positives across their whole life and were deleted
|
|
1440
|
+
rather than ported.
|
|
1441
|
+
|
|
1442
|
+
**Elements folded away as wrappers.** A single-child wrapper whose child exactly fills its content
|
|
1443
|
+
box is folded into one line, and the line carries the *inner* element's id and tag. Element ids in
|
|
1444
|
+
a digest are therefore not contiguous.
|
|
1445
|
+
|
|
1446
|
+
**Anything after the element cap.** 5000 elements, both capture paths, flagged in the header.
|
|
1447
|
+
|
|
1448
|
+
## Parsing
|
|
1449
|
+
|
|
1450
|
+
```ts
|
|
1451
|
+
import { parseDigestLine } from '@michaelmagan/dnumb';
|
|
1452
|
+
|
|
1453
|
+
for (const line of digest.split('\n')) {
|
|
1454
|
+
const parsed = parseDigestLine(line);
|
|
1455
|
+
if (parsed?.kind === 'element' && parsed.facts.length > 0) {
|
|
1456
|
+
console.log(parsed.id, parsed.rect, parsed.facts);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
`parseDigestLine(line)` returns one of four things.
|
|
1462
|
+
|
|
1463
|
+
`null` — comment lines, blank lines, and anything it does not recognise.
|
|
1464
|
+
|
|
1465
|
+
`{ kind: 'element' }`:
|
|
1466
|
+
|
|
1467
|
+
```ts
|
|
1468
|
+
{
|
|
1469
|
+
kind: 'element';
|
|
1470
|
+
id: string; // 'e13'
|
|
1471
|
+
tag: string; // 'span'
|
|
1472
|
+
annotation: string | null; // 'heading' — bare, the `.` or `#` sigil stripped
|
|
1473
|
+
text: string | null; // as rendered, including the trailing '…' if truncated
|
|
1474
|
+
rect: { x: number; y: number; width: number; height: number };
|
|
1475
|
+
flow: 'row' | 'column' | 'grid' | 'none';
|
|
1476
|
+
gap: { direction: 'down' | 'right'; value: number; overlap: boolean } | null;
|
|
1477
|
+
facts: string[]; // fact bodies, brackets stripped, in rendered order
|
|
1478
|
+
belowFold: boolean;
|
|
1479
|
+
depth: number; // from the indentation, two spaces per level
|
|
1480
|
+
}
|
|
1481
|
+
```
|
|
1482
|
+
|
|
1483
|
+
`{ kind: 'fold' }`:
|
|
1484
|
+
|
|
1485
|
+
```ts
|
|
1486
|
+
{
|
|
1487
|
+
kind: 'fold';
|
|
1488
|
+
count: number;
|
|
1489
|
+
likeId: string;
|
|
1490
|
+
period: number; // 1 for a plain run; >1 for a periodic fold
|
|
1491
|
+
variance: string | null; // the `(...)` spread token, verbatim, or null
|
|
1492
|
+
entries: Array<{ id, x, y, gap }>;
|
|
1493
|
+
}
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
`{ kind: 'collapsed' }`:
|
|
1497
|
+
|
|
1498
|
+
```ts
|
|
1499
|
+
{ kind: 'collapsed'; nodeCount: number; bbox: { x, y, width, height } }
|
|
1500
|
+
```
|
|
1501
|
+
|
|
1502
|
+
Four things about the parser are worth knowing before relying on it.
|
|
1503
|
+
|
|
1504
|
+
**`annotation` does not say which sigil it came from.** `#save-btn` and `.button` both parse to a
|
|
1505
|
+
bare string. If the distinction matters, read the record rather than the line.
|
|
1506
|
+
|
|
1507
|
+
**`facts` is open-ended by design.** The parser keeps every bracketed group that is not a known
|
|
1508
|
+
structural token, so the renderer can add a fact kind without the parser learning its name first.
|
|
1509
|
+
The tokens it excludes are `row`, `column`, `grid`, `below fold`, and `wraps <n>`.
|
|
1510
|
+
|
|
1511
|
+
**It accepts tokens the renderer does not emit.** A gap may carry an ` OVERLAP` suffix and `flow`
|
|
1512
|
+
may be `column`; neither is produced by the current renderer. Do not write a producer that emits
|
|
1513
|
+
them and expect other readers to cope.
|
|
1514
|
+
|
|
1515
|
+
**Text containing `@x,y WxH` breaks the split.** The parser locates the geometry by regex, and an
|
|
1516
|
+
element whose rendered text looks like a coordinate pair will be mis-parsed. Text is capped at 30
|
|
1517
|
+
characters, so this is rare and not defended against.
|
|
1518
|
+
|
|
1519
|
+
Round-tripping is tested: `test/unit/digest.test.ts` parses element lines and fold lines back out
|
|
1520
|
+
of real renderer output and asserts on the numbers.
|
|
1521
|
+
|
|
1522
|
+
## Two worked examples
|
|
1523
|
+
|
|
1524
|
+
### A truncated heading
|
|
1525
|
+
|
|
1526
|
+
`test/fixtures/flex-min-width-ellipsis.html`, the reconstruction of a real merged fix. A flex
|
|
1527
|
+
heading that shrank to 668px around 815px of content.
|
|
1528
|
+
|
|
1529
|
+
**Live example** (`fixture=test/fixtures/flex-min-width-ellipsis.html url=file://.../flex-min-width-ellipsis.html`)
|
|
1530
|
+
— regenerated fresh by `test/browser/doc-examples.test.ts` every run.
|
|
1531
|
+
|
|
1532
|
+
```text
|
|
1533
|
+
# dnumb digest v2 — file://.../flex-min-width-ellipsis.html — 1024x768 — cdp
|
|
1534
|
+
# 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
|
|
1535
|
+
# page: 1024x28 (6 records → 6 layout nodes, 0 dropped, 0 wrappers folded)
|
|
1536
|
+
# facts: 2 elements carry facts
|
|
1537
|
+
# colors: 2 fg/bg pairs across 2 text elements; ratio 18.26–21.00; lowest e5 #000000 on #efefef self 13.3px/400
|
|
1538
|
+
e0 body @0,0 1024x28
|
|
1539
|
+
e1 div @0,0 723x28 [row]
|
|
1540
|
+
e2 h1.heading "Quarterly revenue review for t…" @0,0 668x28 [overflow-x +147px; clip, ellipsis]
|
|
1541
|
+
e3 span "Quarterly revenue review for t…" @0,0 815x28 [clipped by e2 h1; overflow:hidden, not scrollable; 147px right, 668x28 visible]
|
|
1542
|
+
e4 div @684,3 39x23 [row] →16
|
|
1543
|
+
e5 button.button "Edit" @684,4 39x21
|
|
1544
|
+
```
|
|
1545
|
+
|
|
1546
|
+
The predecessor to this tool measured exactly this pair — `scrollWidth: 815, clientWidth: 668` —
|
|
1547
|
+
on the real page this fixture reconstructs, and then discarded the finding, because the rule that
|
|
1548
|
+
owned the measurement read `text-overflow: ellipsis` as absolution. Both facts are now on one line and neither is a verdict. 147px of a heading is
|
|
1549
|
+
unreadable whether or not it ends in three dots; whether that matters depends on whether the full
|
|
1550
|
+
title matters, which is not something a threshold can know.
|
|
1551
|
+
|
|
1552
|
+
`e3`'s box is 815px wide inside a 668px parent. That is the same fact from the other side, rendered
|
|
1553
|
+
as its own `clipped` fact naming `e2` as the clipper.
|
|
1554
|
+
|
|
1555
|
+
### A column that collapsed to zero width
|
|
1556
|
+
|
|
1557
|
+
`test/fixtures/zero-width-column.html`. A flex column with `flex: 0 1 0` and nothing to grow into,
|
|
1558
|
+
whose own `.sidebar` also carries `overflow: hidden`.
|
|
1559
|
+
|
|
1560
|
+
**Live example** (`fixture=test/fixtures/zero-width-column.html url=file://.../zero-width-column.html`)
|
|
1561
|
+
— regenerated fresh by `test/browser/doc-examples.test.ts` every run.
|
|
1562
|
+
|
|
1563
|
+
```text
|
|
1564
|
+
# dnumb digest v2 — file://.../zero-width-column.html — 1024x768 — cdp
|
|
1565
|
+
# 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
|
|
1566
|
+
# page: 1024x1239 (7 records → 5 layout nodes, 1 dropped, 1 wrappers folded)
|
|
1567
|
+
# facts: 2 elements carry facts
|
|
1568
|
+
# colors: 1 fg/bg pair across 1 text element; ratio 21.00–21.00; lowest e6 #000000 on #ffffff via canvas 16px/400
|
|
1569
|
+
e1 div @0,0 1024x1239 [row]
|
|
1570
|
+
e2 aside.complementary @0,0 0x1239 [overflow-x +60px; clip, wraps] [0x1239 zero-box; content "Filters and saved views"]
|
|
1571
|
+
e3 div "Filters and saved views" @0,0 0x1239 [overflow-x +60px; visible, wraps] [0x1239 zero-box; content "Filters and saved views"]
|
|
1572
|
+
e5 main.main @0,0 1024x54 →0
|
|
1573
|
+
e6 p.paragraph "Report body" @0,16 1024x22
|
|
1574
|
+
```
|
|
1575
|
+
|
|
1576
|
+
Three things to notice. `e0` has no line — it is the one single-child wrapper here whose child
|
|
1577
|
+
exactly fills its content box, so it folds, which is why the header says `1 wrappers folded` and
|
|
1578
|
+
the first line is `e1`. `e2` (the `.sidebar` element itself) *does* get its own line, and this is
|
|
1579
|
+
the more interesting half: a wrapper is only folded away when it has nothing of its own to say, and
|
|
1580
|
+
`e2` carries a fact its child does not share — its own `overflow: hidden` versus `e3`'s `visible` —
|
|
1581
|
+
so folding it would have discarded a genuinely different measurement, the exact failure a wrapper
|
|
1582
|
+
fold exists to avoid. `e2` and `e3` are the facts-and-counter-facts pairing this whole format is
|
|
1583
|
+
built around, on two lines instead of the one this fixture used to show: the parent clips
|
|
1584
|
+
(`overflow-x +60px; clip, wraps`) while its own child, inside it, computes as unclipped
|
|
1585
|
+
(`overflow-x +60px; visible, wraps`) — same number, opposite consequence, because the box that
|
|
1586
|
+
overflows and the box that hides the overflow are not the same element.
|
|
1587
|
+
|
|
1588
|
+
Neither `e2` nor `e3` has a visible box at all, so the layout filter would normally drop both.
|
|
1589
|
+
Elements carrying facts are exempt from that filter, because the collapsed column is the entire
|
|
1590
|
+
point.
|
|
1591
|
+
|
|
1592
|
+
Notice, too, what is *not* in the `# page:` line any more: no `; <id> extends the page … beyond the
|
|
1593
|
+
next element` clause. This fixture used to have a single 1239px-tall element standing out from a
|
|
1594
|
+
54px `main`, and the clause named it. Now `e2` and `e3` are both 1239px tall — the wrapper fold that
|
|
1595
|
+
used to hide `e2` behind `e3` is gone — so the candidate that reaches the page bbox's bottom edge has
|
|
1596
|
+
a *tied* runner-up on that same edge, the gap between them is ~0, and `pageOutlier` correctly
|
|
1597
|
+
declines to name either one "the" outlier rather than guessing. That is the dominance heuristic
|
|
1598
|
+
behaving properly on a page that no longer has a single dominant element, not a regression in the
|
|
1599
|
+
clause itself — see item 3 ("Page") under [Header lines](#header-lines) above for a live example of
|
|
1600
|
+
a page where the clause still fires.
|