@michaelmagan/dnumb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +180 -0
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/analysis/cluster.cjs +114 -0
- package/dist/analysis/cluster.d.cts +68 -0
- package/dist/analysis/cluster.d.ts +68 -0
- package/dist/analysis/cluster.js +109 -0
- package/dist/analysis/distances.cjs +124 -0
- package/dist/analysis/distances.d.cts +58 -0
- package/dist/analysis/distances.d.ts +58 -0
- package/dist/analysis/distances.js +119 -0
- package/dist/analysis/geometry.cjs +60 -0
- package/dist/analysis/geometry.d.cts +34 -0
- package/dist/analysis/geometry.d.ts +34 -0
- package/dist/analysis/geometry.js +53 -0
- package/dist/analysis/layout-tree.cjs +245 -0
- package/dist/analysis/layout-tree.d.cts +45 -0
- package/dist/analysis/layout-tree.d.ts +45 -0
- package/dist/analysis/layout-tree.js +242 -0
- package/dist/analysis/region-query.cjs +71 -0
- package/dist/analysis/region-query.d.cts +25 -0
- package/dist/analysis/region-query.d.ts +25 -0
- package/dist/analysis/region-query.js +67 -0
- package/dist/analysis/types.cjs +7 -0
- package/dist/analysis/types.d.cts +143 -0
- package/dist/analysis/types.d.ts +143 -0
- package/dist/analysis/types.js +6 -0
- package/dist/bundle.cjs +182 -0
- package/dist/bundle.d.cts +91 -0
- package/dist/bundle.d.ts +91 -0
- package/dist/bundle.js +175 -0
- package/dist/cli.cjs +755 -0
- package/dist/cli.d.cts +2 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +719 -0
- package/dist/diff/changes.cjs +656 -0
- package/dist/diff/changes.d.cts +2 -0
- package/dist/diff/changes.d.ts +2 -0
- package/dist/diff/changes.js +653 -0
- package/dist/diff/identity.cjs +167 -0
- package/dist/diff/identity.d.cts +62 -0
- package/dist/diff/identity.d.ts +62 -0
- package/dist/diff/identity.js +159 -0
- package/dist/diff/index.cjs +84 -0
- package/dist/diff/index.d.cts +76 -0
- package/dist/diff/index.d.ts +76 -0
- package/dist/diff/index.js +68 -0
- package/dist/diff/match.cjs +156 -0
- package/dist/diff/match.d.cts +17 -0
- package/dist/diff/match.d.ts +17 -0
- package/dist/diff/match.js +152 -0
- package/dist/diff/render.cjs +540 -0
- package/dist/diff/render.d.cts +2 -0
- package/dist/diff/render.d.ts +2 -0
- package/dist/diff/render.js +537 -0
- package/dist/diff/types.cjs +2 -0
- package/dist/diff/types.d.cts +211 -0
- package/dist/diff/types.d.ts +211 -0
- package/dist/diff/types.js +1 -0
- package/dist/digest/index.cjs +8 -0
- package/dist/digest/index.d.cts +4 -0
- package/dist/digest/index.d.ts +4 -0
- package/dist/digest/index.js +2 -0
- package/dist/digest/parse.cjs +163 -0
- package/dist/digest/parse.d.cts +80 -0
- package/dist/digest/parse.d.ts +80 -0
- package/dist/digest/parse.js +160 -0
- package/dist/digest/render.cjs +1575 -0
- package/dist/digest/render.d.cts +41 -0
- package/dist/digest/render.d.ts +41 -0
- package/dist/digest/render.js +1571 -0
- package/dist/digest/types.cjs +10 -0
- package/dist/digest/types.d.cts +86 -0
- package/dist/digest/types.d.ts +86 -0
- package/dist/digest/types.js +9 -0
- package/dist/index.cjs +61 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +19 -0
- package/dist/page.cjs +35 -0
- package/dist/page.d.cts +40 -0
- package/dist/page.d.ts +40 -0
- package/dist/page.js +31 -0
- package/dist/playwright.cjs +138 -0
- package/dist/playwright.d.cts +69 -0
- package/dist/playwright.d.ts +69 -0
- package/dist/playwright.js +131 -0
- package/dist/query.cjs +1152 -0
- package/dist/query.d.cts +47 -0
- package/dist/query.d.ts +47 -0
- package/dist/query.js +1146 -0
- package/dist/snapshot/capture.cjs +80 -0
- package/dist/snapshot/capture.d.cts +34 -0
- package/dist/snapshot/capture.d.ts +34 -0
- package/dist/snapshot/capture.js +77 -0
- package/dist/snapshot/cdp.cjs +450 -0
- package/dist/snapshot/cdp.d.cts +36 -0
- package/dist/snapshot/cdp.d.ts +36 -0
- package/dist/snapshot/cdp.js +447 -0
- package/dist/snapshot/clip.cjs +100 -0
- package/dist/snapshot/clip.d.cts +26 -0
- package/dist/snapshot/clip.d.ts +26 -0
- package/dist/snapshot/clip.js +96 -0
- package/dist/snapshot/collect.cjs +358 -0
- package/dist/snapshot/collect.d.cts +18 -0
- package/dist/snapshot/collect.d.ts +18 -0
- package/dist/snapshot/collect.js +355 -0
- package/dist/snapshot/color.cjs +1039 -0
- package/dist/snapshot/color.d.cts +316 -0
- package/dist/snapshot/color.d.ts +316 -0
- package/dist/snapshot/color.js +1026 -0
- package/dist/snapshot/facts.cjs +167 -0
- package/dist/snapshot/facts.d.cts +48 -0
- package/dist/snapshot/facts.d.ts +48 -0
- package/dist/snapshot/facts.js +161 -0
- package/dist/snapshot/limits.cjs +69 -0
- package/dist/snapshot/limits.d.cts +59 -0
- package/dist/snapshot/limits.d.ts +59 -0
- package/dist/snapshot/limits.js +65 -0
- package/dist/snapshot/not-measured.cjs +44 -0
- package/dist/snapshot/not-measured.d.cts +14 -0
- package/dist/snapshot/not-measured.d.ts +14 -0
- package/dist/snapshot/not-measured.js +41 -0
- package/dist/snapshot/types.cjs +2 -0
- package/dist/snapshot/types.d.cts +238 -0
- package/dist/snapshot/types.d.ts +238 -0
- package/dist/snapshot/types.js +1 -0
- package/dist/spec/index.cjs +9 -0
- package/dist/spec/index.d.cts +15 -0
- package/dist/spec/index.d.ts +15 -0
- package/dist/spec/index.js +6 -0
- package/package.json +141 -0
- package/skills/dnumb/SKILL.md +136 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# dnumb — for agents
|
|
2
|
+
|
|
3
|
+
You are the intended reader. This package has no HTML report, no dashboard, and nothing to open
|
|
4
|
+
in a browser. Every output is text a model reads.
|
|
5
|
+
|
|
6
|
+
## What it is
|
|
7
|
+
|
|
8
|
+
`dnumb` turns a rendered page into measurements: element geometry, overflow, clipping, and the
|
|
9
|
+
CSS that says what those measurements mean. It renders them as a linear text digest — one line per
|
|
10
|
+
element that stands on its own, and one fold line standing in for a whole run of repeated siblings
|
|
11
|
+
at once, each still addressable by id (see [`docs/digest.md`](https://github.com/michaelmagan/dnumb/blob/main/docs/digest.md#the-fold-line)) — and
|
|
12
|
+
writes them to a self-contained JSON bundle you can query later on a machine with no app and no
|
|
13
|
+
browser.
|
|
14
|
+
|
|
15
|
+
**Live example** (`fixture=test/fixtures/flex-min-width-ellipsis.html line=e2`) — one real line out
|
|
16
|
+
of a real capture, checked by `test/browser/doc-examples.test.ts`; the full capture is in
|
|
17
|
+
[`docs/digest.md`](https://github.com/michaelmagan/dnumb/blob/main/docs/digest.md#a-truncated-heading).
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
e2 h1.heading "Quarterly revenue review for t…" @0,0 668x28 [overflow-x +147px; clip, ellipsis]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That line says a heading is 668px wide around 815px of content, that the box clips, and that the
|
|
24
|
+
clipped edge ends in an ellipsis. It does not say whether that is a bug.
|
|
25
|
+
|
|
26
|
+
## When to reach for this instead of a screenshot
|
|
27
|
+
|
|
28
|
+
Reach for it when the question is *where things are*, not *what they look like*:
|
|
29
|
+
|
|
30
|
+
- Is this text truncated, and by how much?
|
|
31
|
+
- Does this container overflow, and can a user scroll to what is hidden?
|
|
32
|
+
- Did this element move, and by how much, between two states?
|
|
33
|
+
- Is this menu clipped by an ancestor's `overflow: hidden`?
|
|
34
|
+
- What is at this coordinate in the screenshot I am looking at?
|
|
35
|
+
- How far apart are these two boxes, exactly?
|
|
36
|
+
- Is this text's contrast against what it is actually drawn on a real number, and what is that
|
|
37
|
+
number — not "does it look readable," a composited ratio with the size and weight beside it.
|
|
38
|
+
|
|
39
|
+
Stay with the screenshot for *paint* — imagery, typography, and overall visual impression, and
|
|
40
|
+
anything painted rather than styled (a photo, a canvas, video, a gradient's true value at the
|
|
41
|
+
glyphs). Color is the one item that moved off this list: dnumb now measures the ink and the
|
|
42
|
+
composited backdrop text is actually drawn on, and reports a WCAG contrast ratio, never a
|
|
43
|
+
pass/fail — see [`docs/digest.md`](https://github.com/michaelmagan/dnumb/blob/main/docs/digest.md#color).
|
|
44
|
+
The two tools still compose well: use the image to form a suspicion and the numbers to check it.
|
|
45
|
+
|
|
46
|
+
Honest cost: on the predecessor to this tool, tool-grounded review cost **25–30% more tokens**
|
|
47
|
+
than screenshot-only, driven by round-trip count rather than payload size. In the same
|
|
48
|
+
experiment, vision alone reported 4 fabricated findings on a provably clean page, and 10 on
|
|
49
|
+
replication; both tool-grounded arms reported zero, twice. Spend round-trips when a visual
|
|
50
|
+
impression needs checking. Do not sweep.
|
|
51
|
+
|
|
52
|
+
## Facts, with counter-facts
|
|
53
|
+
|
|
54
|
+
Nothing in this package decides. A fact is emitted whenever the measurement exists — no
|
|
55
|
+
thresholds beyond subpixel noise, no severity, no confidence, no suppression — and it carries the
|
|
56
|
+
adjacent CSS that determines what the measurement means:
|
|
57
|
+
|
|
58
|
+
**Illustrative** — hand-composed to contrast three cases; the first is real (see above), the gloss
|
|
59
|
+
after each is plain English, not renderer output.
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
[overflow-x +147px; clip, ellipsis] 147px of text is cut off, and it ends in three dots
|
|
63
|
+
[overflow-x +227px; clip, wraps] same clip, but the text wrapped; this is height, not truncation
|
|
64
|
+
[overflow-x +90px; scroll, wraps] a scroll region, working as intended
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Same number, three situations, and the difference is never in the number.
|
|
68
|
+
|
|
69
|
+
Five kinds of fact exist, and this is what each one *is* — none of them says whether it is a
|
|
70
|
+
problem:
|
|
71
|
+
|
|
72
|
+
- **`overflow-x` / `overflow-y`** — `scrollWidth`/`scrollHeight` exceeds `clientWidth`/
|
|
73
|
+
`clientHeight` by the stated number of px. Carries what happens to the extra content: clipped,
|
|
74
|
+
scrolled, or visible, and (on the x axis) whether the text wraps, doesn't wrap, or ends in an
|
|
75
|
+
ellipsis.
|
|
76
|
+
- **`zero-box`** — the element is still in the layout but has collapsed to under 1px on one axis,
|
|
77
|
+
and there is content inside it. Carries that content, so a collapsed spacer (nothing inside) and
|
|
78
|
+
a collapsed column (a page of content inside) don't look the same.
|
|
79
|
+
- **`clipped`** — an ancestor's `overflow` hides part of the box. Carries which ancestor, how much
|
|
80
|
+
is hidden on each side, how much survives, and whether it is `scrollable` — i.e. whether a user
|
|
81
|
+
could ever reach the hidden part.
|
|
82
|
+
- **`color`** — the different one: not printed inline on an element line at all. Every visible
|
|
83
|
+
element with its own text gets an *effective backdrop* (the real, composited color behind it,
|
|
84
|
+
not its own usually-transparent `background-color`) and, where both ink and backdrop resolve, a
|
|
85
|
+
WCAG contrast ratio — never a pass/fail. Reachable via the digest's `# colors:` header line, `q
|
|
86
|
+
color`, and `q describe`, per [`docs/digest.md`](https://github.com/michaelmagan/dnumb/blob/main/docs/digest.md#color).
|
|
87
|
+
|
|
88
|
+
Full grammar, every token, in [`docs/digest.md`](https://github.com/michaelmagan/dnumb/blob/main/docs/digest.md).
|
|
89
|
+
|
|
90
|
+
**Do not treat a fact as a verdict.** Do not report `[overflow-x +147px]` as "an overflow bug"
|
|
91
|
+
without the consequence tokens beside it, and do not treat the absence of facts as a clean bill of
|
|
92
|
+
health — this package does not measure typography or alignment at all, and even where it does
|
|
93
|
+
measure color and contrast, it never says whether a ratio is good enough; that threshold lives in
|
|
94
|
+
the reader, not the tool. When you draw a conclusion, name the fact you drew it from, so a reader
|
|
95
|
+
can check the inference separately from the measurement.
|
|
96
|
+
|
|
97
|
+
The predecessor to this tool died of exactly this. It measured `scrollWidth: 815, clientWidth:
|
|
98
|
+
668` on a real truncated heading, then discarded the finding because the rule that owned the
|
|
99
|
+
measurement read `text-overflow: ellipsis` as absolution. The full account is in
|
|
100
|
+
[`docs/01-POSTMORTEM.md`](https://github.com/michaelmagan/dnumb/blob/main/docs/01-POSTMORTEM.md).
|
|
101
|
+
|
|
102
|
+
## Two things that will trip you up
|
|
103
|
+
|
|
104
|
+
**Element ids are stable within one capture only.** `e13` is the element's index in capture order.
|
|
105
|
+
It is not a DOM id and not a selector. Inserting one element earlier in the document shifts every
|
|
106
|
+
id after it. Never carry an id from one capture into a claim about another, and never write one
|
|
107
|
+
into a test expectation. Match on `testId`, `domId`, accessible name, or role instead.
|
|
108
|
+
|
|
109
|
+
**An overflow number on an ellipsized element includes the ellipsis.** Chromium reserves layout
|
|
110
|
+
space for the ellipsis glyph and counts it in `scrollWidth`. Measured at +10px at font-size 10 and
|
|
111
|
+
+17px at font-size 20; the amount is that glyph's advance width, so it tracks font-size and varies
|
|
112
|
+
with font-family. When a line says `ellipsis`, treat the number as an upper bound on the hidden
|
|
113
|
+
content, not an exact width, and do not subtract a constant. Full detail in
|
|
114
|
+
[`docs/digest.md`](https://github.com/michaelmagan/dnumb/blob/main/docs/digest.md).
|
|
115
|
+
|
|
116
|
+
## The library surface that exists today
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import {
|
|
120
|
+
capture, // capture(page, options?) -> CaptureResult. `page` is anything with `evaluate`.
|
|
121
|
+
renderDigest, // renderDigest(snapshot, meta, options?) -> string
|
|
122
|
+
parseDigestLine, // parseDigestLine(line) -> element | fold | collapsed | null
|
|
123
|
+
diffCaptures, // diffCaptures(before, after, options?) -> { text, changes, truncated }
|
|
124
|
+
factsFor, // factsFor(el, byId) -> Fact[]
|
|
125
|
+
factsForAll, // factsForAll(elements) -> Array<{ element, facts }>
|
|
126
|
+
renderFacts, // renderFacts(facts) -> "[fact] [fact]"
|
|
127
|
+
clipFactFor, // clipFactFor(el, byId) -> ClipFact | undefined
|
|
128
|
+
attachClipFacts, // attachClipFacts(elements) -> elements, `clip` attached in place
|
|
129
|
+
formatNotMeasured, // formatNotMeasured(notMeasured) -> "not measured: …" | null
|
|
130
|
+
buildBundle, // buildBundle(captureResult, { name, url, capturedAt, ... }) -> Bundle
|
|
131
|
+
parseBundle, // parseBundle(text, source?) -> Bundle, throws BundleFormatError
|
|
132
|
+
serializeBundle, // serializeBundle(bundle) -> string
|
|
133
|
+
SPEC_VERSION, // 1
|
|
134
|
+
} from '@michaelmagan/dnumb';
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`diffCaptures` is the other core verb — what changed between two captures, and why, at the same
|
|
138
|
+
"report, do not adjudicate" register as everything else in this package. See the CLI's `diff` verb
|
|
139
|
+
and [Reading a diff](https://github.com/michaelmagan/dnumb/blob/main/README.md#reading-a-diff) in
|
|
140
|
+
the README for the grammar and a worked example.
|
|
141
|
+
|
|
142
|
+
Plus the geometry primitives — `intersect`, `contains`, `gapAlong`, `area`, `center` — all pure
|
|
143
|
+
functions over plain `{ x, y, width, height }` rects.
|
|
144
|
+
|
|
145
|
+
From a Playwright suite:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { capture } from '@michaelmagan/dnumb/playwright';
|
|
149
|
+
|
|
150
|
+
test('settings page', async ({ page }, testInfo) => {
|
|
151
|
+
await page.goto('/settings');
|
|
152
|
+
await capture(page, 'settings-desktop', { testInfo });
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
That writes `.dnumb/settings-desktop.json` and attaches it to the test report. The two `capture`
|
|
157
|
+
functions are not the same: the root export returns records in memory, `@michaelmagan/dnumb/playwright`'s takes
|
|
158
|
+
a name and writes a bundle to disk.
|
|
159
|
+
|
|
160
|
+
Types are also exported standalone from `@michaelmagan/dnumb/spec` — `ElementRecord`, `Snapshot`, `ClipFact`,
|
|
161
|
+
`Rect`, `BoxSides`, `SPEC_VERSION` — with zero runtime code and zero dependencies.
|
|
162
|
+
|
|
163
|
+
`playwright-core` is an optional peer. Capture needs a driver; everything downstream of a bundle
|
|
164
|
+
does not.
|
|
165
|
+
|
|
166
|
+
## Status
|
|
167
|
+
|
|
168
|
+
**Pre-release, not yet on npm.** The library surface above is built and tested. The CLI has a
|
|
169
|
+
stable verb set — `capture`, `look`, `diff`, and `q describe|find|at|within|distance|nearest|facts|color`
|
|
170
|
+
— documented with worked examples in
|
|
171
|
+
[README § From the CLI](https://github.com/michaelmagan/dnumb/blob/main/README.md#from-the-cli) and
|
|
172
|
+
[§ Reading a diff](https://github.com/michaelmagan/dnumb/blob/main/README.md#reading-a-diff). This
|
|
173
|
+
file still does not restate flags, on purpose: run `dnumb --help` for the exhaustive, versioned
|
|
174
|
+
list of what your installed copy actually supports, and treat it as authoritative over any prose
|
|
175
|
+
here or there.
|
|
176
|
+
|
|
177
|
+
The digest format is specified in
|
|
178
|
+
[`docs/digest.md`](https://github.com/michaelmagan/dnumb/blob/main/docs/digest.md). Build progress,
|
|
179
|
+
including what was tried and what was wrong, is in
|
|
180
|
+
[`LOG.md`](https://github.com/michaelmagan/dnumb/blob/main/LOG.md).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Magan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# dnumb
|
|
2
|
+
|
|
3
|
+
**Design, in numbers.** Turns a rendered page into measurements an agent can read — element
|
|
4
|
+
geometry, overflow, clipping, and the CSS that says what each measurement means.
|
|
5
|
+
|
|
6
|
+
The consumer is always an agent. There is no HTML report, no dashboard, and nothing to open in a
|
|
7
|
+
browser.
|
|
8
|
+
|
|
9
|
+
**Live example** (`fixture=test/fixtures/clipped-menu.html url=file://.../clipped-menu.html state=menu-open after=__toB`)
|
|
10
|
+
— regenerated fresh by `test/browser/doc-examples.test.ts` every run. The identical capture, spelled
|
|
11
|
+
out line by line, is in [`docs/digest.md`](docs/digest.md#a-complete-digest).
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
# dnumb digest v2 — file://.../clipped-menu.html — 1024x768 — menu-open — cdp
|
|
15
|
+
# 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
|
|
16
|
+
# page: 1024x213 (10 records → 10 layout nodes, 0 dropped, 0 wrappers folded)
|
|
17
|
+
# facts: 5 elements carry facts
|
|
18
|
+
# 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)
|
|
19
|
+
e0 body @0,0 1024x181
|
|
20
|
+
e1 h1.heading "Account settings" @24,24 976x25
|
|
21
|
+
e2 div @24,65 976x44 [row] ↓16 [overflow-y +105px; clip]
|
|
22
|
+
e3 button.button "Actions" @39,77 60x21
|
|
23
|
+
e4 span "3 selected" @111,77 68x20 →12
|
|
24
|
+
e5 nav.menu "Actions" @39,104 182x109 →-110 [clipped by e2 div; overflow:hidden, not scrollable; 103.8px below, 182x5 visible]
|
|
25
|
+
e6 a.menuitem "Rename" @40,105 180x36 [clipped by e2 div; overflow:hidden, not scrollable; 31.6px below, 180x4 visible]
|
|
26
|
+
e7 a.menuitem "Duplicate" @40,141 180x36 ↓0 [clipped by e2 div; overflow:hidden, not scrollable; 67.2px below, nothing visible]
|
|
27
|
+
e8 a.menuitem "Delete" @40,176 180x36 ↓0 [clipped by e2 div; overflow:hidden, not scrollable; 102.8px below, nothing visible]
|
|
28
|
+
e9 p.paragraph "The menu is clipped by the too…" @24,123 976x20 ↓14
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
That is real output, rendered from `test/fixtures/clipped-menu.html`, captured via the CDP path
|
|
32
|
+
(the trailing `— cdp` on the identity line; see [`docs/digest.md`](docs/digest.md#header-lines)).
|
|
33
|
+
A 44px toolbar reporting 105px of vertical overflow with `clip` beside it, a menu overlapping its
|
|
34
|
+
own previous sibling (negative gap, `→-110`), and a menu plus its first item that survive as a
|
|
35
|
+
4-5px sliver while the last two items are entirely hidden — `e6`, `e7` and `e8` share a tag, role
|
|
36
|
+
and box size but do not fold together, because that sliver is a real difference the fold signature
|
|
37
|
+
is built to keep visible. Neither line says "bug". The reader combines them.
|
|
38
|
+
|
|
39
|
+
The `# colors:` line is a fifth kind of fact — never printed inline on an element line the way the
|
|
40
|
+
other four are, only in this one header line and the `q color` verb — naming every ink-on-backdrop
|
|
41
|
+
pair on the page with a WCAG contrast ratio beside it, and never a pass/fail. See
|
|
42
|
+
[`docs/digest.md`](docs/digest.md#color) for the full grammar.
|
|
43
|
+
|
|
44
|
+
> **Status: pre-release.** Not published to npm. The library — capture, digest, facts, clip
|
|
45
|
+
> analysis, bundles, the Playwright integration — is built and tested. The CLI (`capture`, `look`,
|
|
46
|
+
> `diff`, `q`) has a stable verb set, worked below in [From the CLI](#from-the-cli) and
|
|
47
|
+
> [Reading a diff](#reading-a-diff); `dnumb --help` stays the exhaustive, versioned option list.
|
|
48
|
+
> Build journal in [`LOG.md`](LOG.md); the plan is
|
|
49
|
+
> [`docs/00-PLAN-OF-RECORD.md`](docs/00-PLAN-OF-RECORD.md).
|
|
50
|
+
|
|
51
|
+
## The idea
|
|
52
|
+
|
|
53
|
+
A screenshot tells a model what a page looks like. It does not tell it that a heading is truncated
|
|
54
|
+
by 147 pixels, or that a menu opened 104 pixels below the bottom of an `overflow: hidden` toolbar
|
|
55
|
+
and is therefore unreachable. Those are numbers the DOM already has.
|
|
56
|
+
|
|
57
|
+
`dnumb` collects them, renders them as text, and then refuses to draw the conclusion. Every fact
|
|
58
|
+
comes with the adjacent fact that disambiguates it:
|
|
59
|
+
|
|
60
|
+
**Illustrative** — hand-composed to contrast three cases; the first is real (see the digest above),
|
|
61
|
+
the gloss after each is plain English, not renderer output.
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
[overflow-x +147px; clip, ellipsis] truncated
|
|
65
|
+
[overflow-x +227px; clip, wraps] fine — the text wrapped
|
|
66
|
+
[overflow-x +90px; scroll, wraps] fine — a scroll region
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Same measurement, three different situations, and the difference is never in the number. A rule
|
|
70
|
+
engine standing between the measurement and the model is what the predecessor to this tool got
|
|
71
|
+
wrong, at length, in [`docs/01-POSTMORTEM.md`](docs/01-POSTMORTEM.md).
|
|
72
|
+
|
|
73
|
+
Five kinds of fact exist in total — `overflow-x`, `overflow-y`, `zero-box`, `clipped`, and
|
|
74
|
+
`color` (the one that never prints inline, only in the `# colors:` header line and `q color`; see
|
|
75
|
+
[`docs/digest.md`](docs/digest.md#color)) — one line each on what the measurement *is* in
|
|
76
|
+
[AGENTS.md](AGENTS.md#facts-with-counter-facts).
|
|
77
|
+
|
|
78
|
+
## In a Playwright suite
|
|
79
|
+
|
|
80
|
+
The whole adoption thesis is one import and one call in a test somebody already wrote:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { capture } from '@michaelmagan/dnumb/playwright';
|
|
84
|
+
|
|
85
|
+
test('settings page', async ({ page }, testInfo) => {
|
|
86
|
+
await page.goto('/settings');
|
|
87
|
+
await page.getByRole('heading', { name: 'Settings' }).waitFor(); // wait for real content, not just `load`
|
|
88
|
+
await capture(page, 'settings-desktop', { testInfo });
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Capture takes no wait of its own — it measures whatever is on screen the instant it runs.**
|
|
93
|
+
`goto()`'s default `load` event fires on a client-rendered page before hydration has painted
|
|
94
|
+
anything, so capturing right after it can write a bundle that is almost entirely empty: hundreds
|
|
95
|
+
of real DOM nodes present but zero-size and invisible, a body box collapsed to `0` height, while
|
|
96
|
+
the actual page renders a heartbeat later. This is not theoretical — it reproduced on roughly half
|
|
97
|
+
of repeated captures of a live production SPA in soak testing, following this exact
|
|
98
|
+
`goto()`-then-`capture()` pattern. Wait for something you know should be there — a locator, as
|
|
99
|
+
above, is the most reliable signal; `page.waitForLoadState('networkidle')` is a weaker fallback
|
|
100
|
+
that can hang on pages that poll or hold a websocket open. On a capture this sparse, `look` does
|
|
101
|
+
not print a half-empty digest and let you notice — it declines to render a body at all and prints
|
|
102
|
+
a `# no layout to digest: …` refusal instead, naming the same shape (records that never reached a
|
|
103
|
+
layout node, a near-zero page bbox) and how to re-capture; see
|
|
104
|
+
[`docs/digest.md`](docs/digest.md#header-lines) item 4. Treat that as "re-run this capture," not as
|
|
105
|
+
a description of the real page — and if a second capture, later, still refuses the same way, that
|
|
106
|
+
repetition is itself a finding: the page never lays out, not that it was merely caught mid-load.
|
|
107
|
+
`q facts`, `q describe`, `q find`, and `look --focus <id>` all still read the underlying bundle in
|
|
108
|
+
full regardless. The CLI's `capture` verb has the same hazard and the same knob, `--wait`; see
|
|
109
|
+
[From the CLI](#from-the-cli) below.
|
|
110
|
+
|
|
111
|
+
**A capture can also take seconds instead of milliseconds, for a reason that has nothing to do with
|
|
112
|
+
dnumb.** Measured on a real page: 927 elements took 5–10.7s, where a comparable page captured 2,918
|
|
113
|
+
elements in 124ms. Root cause, isolated down to a raw no-op CDP call (500ms+ on the slow page,
|
|
114
|
+
under 2ms on the control) and confirmed idle on Blink's own render thread the whole time: a
|
|
115
|
+
full-viewport `requestAnimationFrame` animation (a WebGL hero, a large animated canvas) running
|
|
116
|
+
under **headless Chromium with no GPU access** falls back to a CPU software rasterizer, and that
|
|
117
|
+
pegs the browser's GPU process at 600%+ CPU — every CDP round trip, dnumb's included, queues behind
|
|
118
|
+
it. This is not a dnumb defect: both capture paths are affected equally and identically, dnumb's
|
|
119
|
+
own work on the page is not what is slow, and the cost is **unbounded** — it scales with the page's
|
|
120
|
+
own pixel count × shader cost × frame rate, which dnumb has no visibility into. It hits precisely
|
|
121
|
+
where dnumb is meant to run: headless, no GPU passthrough, is the default for most CI and most
|
|
122
|
+
agent sandboxes. If a capture is inexplicably slow, suspect a full-bleed animated canvas or WebGL
|
|
123
|
+
element on the page before suspecting dnumb.
|
|
124
|
+
|
|
125
|
+
**Giving the browser real GPU access is the only known remedy — measured, not theorised — because
|
|
126
|
+
the two mechanisms that looked like they should stop the animation both fail to.** Pausing Web
|
|
127
|
+
Animations (`document.getAnimations()`) does not help: you cannot `cancelAnimationFrame` a loop the
|
|
128
|
+
page's own script owns, not yours. Freezing the page around the capture window
|
|
129
|
+
(`Page.setWebLifecycleState({ state: 'frozen' })`) looked like it should target the actual
|
|
130
|
+
mechanism, but tested against a real stalling page, it froze nothing — the `requestAnimationFrame`
|
|
131
|
+
callbacks and timers kept running at full rate straight through it. A third candidate, overriding
|
|
132
|
+
`requestAnimationFrame` in a page-init script, only takes effect across a navigation dnumb itself
|
|
133
|
+
performs, so it can never apply to the library's usual mode, where the caller hands in a page
|
|
134
|
+
that has already navigated. With no in-page mechanism that survives, GPU passthrough is what is
|
|
135
|
+
left.
|
|
136
|
+
|
|
137
|
+
**This is a performance hazard, not a geometry-correctness one, and the two do not imply each
|
|
138
|
+
other.** A page under heavy GPU load simply takes longer to capture; separately, some pages are
|
|
139
|
+
genuinely in motion at the instant they are captured (the settledness hazard above). The two can
|
|
140
|
+
co-occur, but neither one means the other is happening: the real page measured for this section —
|
|
141
|
+
the one that stalls every CDP round trip for half a second — produced **zero** moving elements
|
|
142
|
+
between two samples taken 100ms apart. Its canvas repaints continuously; nothing in its layout
|
|
143
|
+
moves. A slow capture is a reason to expect a wait, not a reason to distrust a measurement that
|
|
144
|
+
came back stable.
|
|
145
|
+
|
|
146
|
+
That writes `.dnumb/settings-desktop.json` and attaches it to the test report. The bundle is one
|
|
147
|
+
self-contained file: it can be committed, emailed, or attached to CI, and queried later on a
|
|
148
|
+
machine with neither the app nor a browser.
|
|
149
|
+
|
|
150
|
+
Render a digest from it any time after:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { parseBundle, renderDigest } from '@michaelmagan/dnumb';
|
|
154
|
+
import { readFileSync } from 'node:fs';
|
|
155
|
+
|
|
156
|
+
const bundle = parseBundle(readFileSync('.dnumb/settings-desktop.json', 'utf8'));
|
|
157
|
+
console.log(renderDigest(bundle, { url: bundle.url, viewport: bundle.viewport, state: bundle.name }));
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`playwright-core` is an optional peer, `>=1.17.0`. Capture needs a driver; everything downstream
|
|
161
|
+
of a bundle does not — the offline `--from <bundle>` path has no peer at all, and nothing in this
|
|
162
|
+
package ever imports `playwright-core` except the CLI's `capture` verb, which loads it lazily and
|
|
163
|
+
tells you how to install it if it is absent.
|
|
164
|
+
|
|
165
|
+
The floor is the version, and only the version, that the code can justify: `page.goto`'s
|
|
166
|
+
`waitUntil: 'commit'` — which `dnumb capture --wait commit` exposes — landed in 1.17.0. Verified in
|
|
167
|
+
both directions: 1.16.0 answers `waitUntil: expected one of (load|domcontentloaded|networkidle)`,
|
|
168
|
+
and 1.17.0 completes a full CDP-fast-path capture. The library entrypoints (`dnumb`,
|
|
169
|
+
`dnumb/playwright`) need less than that: any object with `evaluate`.
|
|
170
|
+
|
|
171
|
+
## From the CLI
|
|
172
|
+
|
|
173
|
+
No test suite yet, or just want a bundle right now? The shortest path from a URL to a digest is
|
|
174
|
+
two commands:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
dnumb capture https://example.com/settings --name settings-desktop
|
|
178
|
+
dnumb --from .dnumb/settings-desktop.json look
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`capture` launches a browser, navigates, captures, and writes `.dnumb/settings-desktop.json` —
|
|
182
|
+
printing the path it wrote. `look` reads that bundle back and renders the digest. Every other verb
|
|
183
|
+
— `q describe`, `q find`, `diff` — reads the same bundle the same way; none of them need a browser.
|
|
184
|
+
|
|
185
|
+
`capture` takes `--name`, `--dir` (default `.dnumb`), `--viewport WIDTHxHEIGHT`, `--wait
|
|
186
|
+
<load|domcontentloaded|networkidle|commit>`, and `--timeout`. It needs `playwright-core` — no
|
|
187
|
+
other verb does — and says so plainly if it is missing.
|
|
188
|
+
|
|
189
|
+
**Capture measures one instant.** A page still hydrating, still animating, or still fetching is
|
|
190
|
+
reported exactly as it looked at that instant — skeleton screen included — with no way for dnumb to
|
|
191
|
+
tell settled from loading. Pick `--wait` deliberately and re-run if a digest looks wrong. On a
|
|
192
|
+
capture sparse enough that there is no real page to describe, `look` refuses to render a body at
|
|
193
|
+
all rather than print one — see [`docs/digest.md`](docs/digest.md#header-lines) item 4;
|
|
194
|
+
`npx dnumb --from ... look --json` adds a `"sparseCapture": true|false` field so a script can
|
|
195
|
+
branch on this without parsing the digest text. If the capture itself takes seconds rather than
|
|
196
|
+
milliseconds, see [the animated-canvas hazard](#in-a-playwright-suite) above before suspecting
|
|
197
|
+
dnumb — headless Chromium without GPU access can turn a full-viewport WebGL or canvas animation
|
|
198
|
+
into a CPU cost dnumb only queues behind.
|
|
199
|
+
|
|
200
|
+
`dnumb --help` is the authoritative option list. `dnumb --version` prints two numbers, `dnumb
|
|
201
|
+
<package version> (spec <N>)` — the installed package version (`package.json`'s own, not restated
|
|
202
|
+
here since it moves independently of this file) and the bundle format's own `SPEC_VERSION`. The two
|
|
203
|
+
move independently on purpose: the package can ship a new CLI verb or renderer improvement without
|
|
204
|
+
the bundle shape changing at all.
|
|
205
|
+
|
|
206
|
+
`q find` matches a needle against tag, role, test id, DOM id, text and accessible name — and, since
|
|
207
|
+
a digest line's own annotation (`li.listitem`, `button#save-btn`) is something you will often copy
|
|
208
|
+
straight off one, `q find` also accepts that exact compound shape as a conjunctive tag+role or
|
|
209
|
+
tag+test-id search, and strips a trailing `…` a needle picks up from a truncated digest line before
|
|
210
|
+
matching. Pasting an `e13`-shaped id in gets a pointer to `q describe` instead of a silent zero
|
|
211
|
+
matches — ids are not text, and `find` does not search them.
|
|
212
|
+
|
|
213
|
+
**`look`'s own fact count can be bigger than the facts `look` actually shows you.** `# facts: N
|
|
214
|
+
elements carry facts` in a digest header counts every element with a fact, but folding and the line
|
|
215
|
+
budget can still hide most of those facts from the body: a run of siblings can fold together with
|
|
216
|
+
only their shared representative keeping its `[...]` bracket, or an entire subtree can collapse into
|
|
217
|
+
one `[folded: …]` line, taking every fact inside it out of view. `q facts` is the verb that does not
|
|
218
|
+
do this — it lists every fact-carrying element, one per line, independent of the digest's fold and
|
|
219
|
+
collapse rules, capped by `--limit` (default 50) like every other list verb. `q facts --limit 0`
|
|
220
|
+
prints all of them; reach for it whenever you need to know you have seen every fact, not just the
|
|
221
|
+
ones the digest chose to keep on-screen.
|
|
222
|
+
<!-- checked-claim: facts-limit-0-is-complete -->
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
## Reading a diff
|
|
226
|
+
|
|
227
|
+
`dnumb --from before.json diff after.json` is the other core verb: not one moment, but what
|
|
228
|
+
changed between two. Six independent readers, given only this format and nothing else, scored 6/6
|
|
229
|
+
correct accounts with zero fabrications in the format's original validation — including a control
|
|
230
|
+
capture where nothing changed at all, which is why the fabrication guard below exists.
|
|
231
|
+
|
|
232
|
+
**Live example** (`fixture=test/fixtures/state-01-menu-open.html source=diff transition=__toB before-name=menu-closed after-name=menu-open`)
|
|
233
|
+
— a real transition on a real fixture, regenerated fresh by `test/browser/doc-examples.test.ts`
|
|
234
|
+
every run.
|
|
235
|
+
|
|
236
|
+
```text
|
|
237
|
+
menu-closed → menu-open 1024x768 → 1024x768 cdp → cdp
|
|
238
|
+
1 added 0 removed 3 changed 0 derived 43 folded
|
|
239
|
+
|
|
240
|
+
+ e5 nav.menu "Actions" @39,104.2 182x108.8 (was display:none) +3 inside
|
|
241
|
+
~ e3 button.button "Actions" aria-expanded false → true
|
|
242
|
+
! e5 nav.menu "Actions" clipped by e2 div [overflow:hidden, not scrollable] — 103.8px below it, 182x5 visible
|
|
243
|
+
~ e2 div class-name bar → bar open
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The header names the two captures and their viewports, then — when both sides know it — the two
|
|
247
|
+
captures' own strategy (`cdp → cdp` above; see [`docs/digest.md`](docs/digest.md#header-lines) for
|
|
248
|
+
what `cdp` and `walk` do and do not see), then how many primary changes each kind
|
|
249
|
+
covers; `43 folded` means 43 more changes (mostly other elements pushed down by the menu opening)
|
|
250
|
+
were subsumed into the four lines shown rather than printed on their own — nothing is dropped
|
|
251
|
+
silently, `derived`/`folded` count what got rolled up. The body is one line per change, ranked
|
|
252
|
+
structure-then-visibility-then-content-then-facts-then-geometry-then-paint so an agent reading
|
|
253
|
+
top-down meets the act before its consequences:
|
|
254
|
+
|
|
255
|
+
| Glyph | Means |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `+` / `-` | added / removed |
|
|
258
|
+
| `~` | changed (text, an attribute, a box, a style) |
|
|
259
|
+
| `!` | now clipped by an ancestor — the same measurement the digest's `clipped` fact carries |
|
|
260
|
+
| `✓` | a clip or an overflow that resolved — the fact is gone, stated rather than left silent |
|
|
261
|
+
| `?` | boundary-ambiguous: both captures hit the 5000-element cap, and this element's presence or absence could be a real change or just the document-order shift the cap induces. Never printed as a plain `+`/`-`, because either would assert an answer this diff does not have. |
|
|
262
|
+
|
|
263
|
+
`TRUNCATED: before capped at <N> elements, after capped at <N> elements — this diff compares a
|
|
264
|
+
window of the page, not the whole thing` appears as a caveat line under the header whenever either
|
|
265
|
+
capture hit that same 5000-element cap — the diff's own counterpart to the digest's `# TRUNCATED:`
|
|
266
|
+
header line.
|
|
267
|
+
|
|
268
|
+
A diff is capped at 15 body lines by default (`--limit` on the CLI); past that, the line says what
|
|
269
|
+
it dropped rather than stopping silently, the same rule every cap in this package follows. A page
|
|
270
|
+
where nothing observable changed prints `(no observable change)` rather than nothing at all — an
|
|
271
|
+
empty body and a page that did not change would otherwise look identical.
|
|
272
|
+
|
|
273
|
+
## Documentation
|
|
274
|
+
|
|
275
|
+
| Doc | What it is |
|
|
276
|
+
|---|---|
|
|
277
|
+
| [docs/digest.md](docs/digest.md) | **The digest format.** Every token, every fact form, the fold and collapse syntax, the parser contract |
|
|
278
|
+
| [AGENTS.md](AGENTS.md) | For the model that will use it. Ships in the tarball |
|
|
279
|
+
| [skills/dnumb/SKILL.md](skills/dnumb/SKILL.md) | The agent skill. Ships in the tarball, discoverable via `@tanstack/intent` |
|
|
280
|
+
| [LOG.md](LOG.md) | Build journal, one entry per step |
|
|
281
|
+
| [docs/00-PLAN-OF-RECORD](docs/00-PLAN-OF-RECORD.md) | **Authoritative.** Decisions, build order, kill criteria |
|
|
282
|
+
| [docs/01-POSTMORTEM](docs/01-POSTMORTEM.md) | Why the `layout-audit` prototype failed |
|
|
283
|
+
| [docs/02-PLAN-fable](docs/02-PLAN-fable.md) | Product design: verbs, digest v2, diff grammar, validation |
|
|
284
|
+
| [docs/03-SCAFFOLD](docs/03-SCAFFOLD.md) | Repo/build/CI/publish spec |
|
|
285
|
+
| [docs/04-CRITIQUE](docs/04-CRITIQUE.md) | Build-vs-buy evidence, live-verified CDP tests |
|
|
286
|
+
| [docs/notes/](docs/notes/) | Per-step working notes and validation results |
|
|
287
|
+
|
|
288
|
+
The prototype this succeeds is a worktree of another repo, not part of this one:
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
git -C ~/workspace/github/tambo-ai/charming worktree add ~/Projects/dnumb-prototype \
|
|
292
|
+
origin/claude/agent-design-tool-checks-pw2heg
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT © 2026 Michael Magan
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 1-D numeric clustering and spacing-grid detection — pure arithmetic over
|
|
4
|
+
* plain `number[]`, no `LayoutNode`/`ElementRecord` knowledge at all (the
|
|
5
|
+
* caller decides whether the numbers are x-coordinates, gaps, or anything
|
|
6
|
+
* else). Same boundary rules as the rest of `src/analysis/`: no imports
|
|
7
|
+
* beyond `../snapshot/types.js` and sibling `analysis/` modules — this file
|
|
8
|
+
* needs neither, so it has no imports.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.clusterEdges = clusterEdges;
|
|
12
|
+
exports.detectBaseUnit = detectBaseUnit;
|
|
13
|
+
exports.spacingStats = spacingStats;
|
|
14
|
+
/**
|
|
15
|
+
* Groups `values` into 1-D clusters using adjacent-gap chaining: sort
|
|
16
|
+
* ascending, then start a new cluster whenever the gap between two
|
|
17
|
+
* consecutive sorted values exceeds `tolerance`. This is deliberately
|
|
18
|
+
* "adjacent" rather than "each within `tolerance` of the cluster's running
|
|
19
|
+
* mean" — a run of values 2px apart (0, 2, 4, 6, 8) chains into one cluster
|
|
20
|
+
* under either rule, but the two rules diverge once a cluster's span grows
|
|
21
|
+
* past `tolerance`; adjacent-gap chaining is the simpler rule to reason
|
|
22
|
+
* about and to test, and matches "group any within tolerance px of each
|
|
23
|
+
* other" read as a chain of pairwise closeness rather than a fixed-radius
|
|
24
|
+
* ball around one center.
|
|
25
|
+
*/
|
|
26
|
+
function clusterEdges(values, tolerance = 2) {
|
|
27
|
+
if (values.length === 0)
|
|
28
|
+
return [];
|
|
29
|
+
const sorted = values.map((value, index) => ({ value, index })).sort((a, b) => a.value - b.value);
|
|
30
|
+
const clusters = [];
|
|
31
|
+
let current = [];
|
|
32
|
+
function flush() {
|
|
33
|
+
if (current.length === 0)
|
|
34
|
+
return;
|
|
35
|
+
const sum = current.reduce((total, entry) => total + entry.value, 0);
|
|
36
|
+
clusters.push({
|
|
37
|
+
value: sum / current.length,
|
|
38
|
+
count: current.length,
|
|
39
|
+
members: current.map((entry) => entry.index),
|
|
40
|
+
});
|
|
41
|
+
current = [];
|
|
42
|
+
}
|
|
43
|
+
for (const entry of sorted) {
|
|
44
|
+
const prev = current.at(-1);
|
|
45
|
+
if (prev !== undefined && entry.value - prev.value > tolerance)
|
|
46
|
+
flush();
|
|
47
|
+
current.push(entry);
|
|
48
|
+
}
|
|
49
|
+
flush();
|
|
50
|
+
return clusters;
|
|
51
|
+
}
|
|
52
|
+
const MIN_GAP_SAMPLES = 5;
|
|
53
|
+
const MIN_ON_GRID_SHARE = 0.5;
|
|
54
|
+
const GRID_TOLERANCE = 1;
|
|
55
|
+
/**
|
|
56
|
+
* Picks the candidate spacing unit (default `[4, 8]`) that best explains
|
|
57
|
+
* `gaps`: for each candidate, the fraction of non-zero gaps within 1px of a
|
|
58
|
+
* multiple of that unit. Zero-valued gaps are excluded from both the sample
|
|
59
|
+
* count and the coverage fraction — a touching pair (gap 0) is trivially "on
|
|
60
|
+
* every grid" and would inflate the score without saying anything about the
|
|
61
|
+
* page's actual spacing rhythm.
|
|
62
|
+
*
|
|
63
|
+
* Requires at least `MIN_GAP_SAMPLES` non-zero gaps and at least
|
|
64
|
+
* `MIN_ON_GRID_SHARE` coverage from the best candidate; otherwise returns
|
|
65
|
+
* `{unit: null, onGridShare: 0, offGrid: gaps}` (the *original*, unfiltered
|
|
66
|
+
* `gaps` — a caller with too little data to trust gets everything back as
|
|
67
|
+
* "off-grid" rather than a partially-filtered list) instead of a
|
|
68
|
+
* low-confidence guess. Ties are broken toward the smaller candidate — the
|
|
69
|
+
* candidates are tried in the order given and a later candidate only wins by
|
|
70
|
+
* strictly exceeding the current best share.
|
|
71
|
+
*/
|
|
72
|
+
function detectBaseUnit(gaps, candidates = [4, 8]) {
|
|
73
|
+
const nonZero = gaps.filter((gap) => gap !== 0);
|
|
74
|
+
if (nonZero.length < MIN_GAP_SAMPLES) {
|
|
75
|
+
return { unit: null, onGridShare: 0, offGrid: gaps };
|
|
76
|
+
}
|
|
77
|
+
let bestUnit = null;
|
|
78
|
+
let bestShare = 0;
|
|
79
|
+
let bestOffGrid = gaps;
|
|
80
|
+
for (const unit of candidates) {
|
|
81
|
+
if (unit <= 0)
|
|
82
|
+
continue;
|
|
83
|
+
const offGrid = [];
|
|
84
|
+
let onGrid = 0;
|
|
85
|
+
for (const gap of nonZero) {
|
|
86
|
+
const nearestMultiple = Math.round(gap / unit) * unit;
|
|
87
|
+
if (Math.abs(gap - nearestMultiple) <= GRID_TOLERANCE)
|
|
88
|
+
onGrid++;
|
|
89
|
+
else
|
|
90
|
+
offGrid.push(gap);
|
|
91
|
+
}
|
|
92
|
+
const share = onGrid / nonZero.length;
|
|
93
|
+
if (share > bestShare) {
|
|
94
|
+
bestShare = share;
|
|
95
|
+
bestUnit = unit;
|
|
96
|
+
bestOffGrid = offGrid;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (bestUnit === null || bestShare < MIN_ON_GRID_SHARE) {
|
|
100
|
+
return { unit: null, onGridShare: 0, offGrid: gaps };
|
|
101
|
+
}
|
|
102
|
+
return { unit: bestUnit, onGridShare: bestShare, offGrid: bestOffGrid };
|
|
103
|
+
}
|
|
104
|
+
/** Histogram of `gaps`, each value rounded to the nearest px before being
|
|
105
|
+
* used as the key — the digest and the (future) spacing check use this to
|
|
106
|
+
* summarize "column gaps: 24×4" rather than listing every raw float. */
|
|
107
|
+
function spacingStats(gaps) {
|
|
108
|
+
const distinct = {};
|
|
109
|
+
for (const gap of gaps) {
|
|
110
|
+
const key = String(Math.round(gap));
|
|
111
|
+
distinct[key] = (distinct[key] ?? 0) + 1;
|
|
112
|
+
}
|
|
113
|
+
return { distinct };
|
|
114
|
+
}
|