@michaelmagan/dnumb 0.1.0

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