@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 CHANGED
@@ -8,13 +8,13 @@ in a browser. Every output is text a model reads.
8
8
  `dnumb` turns a rendered page into measurements: element geometry, overflow, clipping, and the
9
9
  CSS that says what those measurements mean. It renders them as a linear text digest — one line per
10
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
11
+ at once, each still addressable by id (see [`docs/digest.md`](docs/digest.md#the-fold-line)) — and
12
12
  writes them to a self-contained JSON bundle you can query later on a machine with no app and no
13
13
  browser.
14
14
 
15
15
  **Live example** (`fixture=test/fixtures/flex-min-width-ellipsis.html line=e2`) — one real line out
16
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).
17
+ [`docs/digest.md`](docs/digest.md#a-truncated-heading).
18
18
 
19
19
  ```text
20
20
  e2 h1.heading "Quarterly revenue review for t…" @0,0 668x28 [overflow-x +147px; clip, ellipsis]
@@ -40,7 +40,7 @@ Stay with the screenshot for *paint* — imagery, typography, and overall visual
40
40
  anything painted rather than styled (a photo, a canvas, video, a gradient's true value at the
41
41
  glyphs). Color is the one item that moved off this list: dnumb now measures the ink and the
42
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).
43
+ pass/fail — see [`docs/digest.md`](docs/digest.md#color).
44
44
  The two tools still compose well: use the image to form a suspicion and the numbers to check it.
45
45
 
46
46
  Honest cost: on the predecessor to this tool, tool-grounded review cost **25–30% more tokens**
@@ -83,9 +83,9 @@ problem:
83
83
  element with its own text gets an *effective backdrop* (the real, composited color behind it,
84
84
  not its own usually-transparent `background-color`) and, where both ink and backdrop resolve, a
85
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).
86
+ color`, and `q describe`, per [`docs/digest.md`](docs/digest.md#color).
87
87
 
88
- Full grammar, every token, in [`docs/digest.md`](https://github.com/michaelmagan/dnumb/blob/main/docs/digest.md).
88
+ Full grammar, every token, in [`docs/digest.md`](docs/digest.md).
89
89
 
90
90
  **Do not treat a fact as a verdict.** Do not report `[overflow-x +147px]` as "an overflow bug"
91
91
  without the consequence tokens beside it, and do not treat the absence of facts as a clean bill of
@@ -96,8 +96,7 @@ can check the inference separately from the measurement.
96
96
 
97
97
  The predecessor to this tool died of exactly this. It measured `scrollWidth: 815, clientWidth:
98
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).
99
+ measurement read `text-overflow: ellipsis` as absolution.
101
100
 
102
101
  ## Two things that will trip you up
103
102
 
@@ -111,7 +110,7 @@ space for the ellipsis glyph and counts it in `scrollWidth`. Measured at +10px a
111
110
  +17px at font-size 20; the amount is that glyph's advance width, so it tracks font-size and varies
112
111
  with font-family. When a line says `ellipsis`, treat the number as an upper bound on the hidden
113
112
  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).
113
+ [`docs/digest.md`](docs/digest.md).
115
114
 
116
115
  ## The library surface that exists today
117
116
 
@@ -136,7 +135,7 @@ import {
136
135
 
137
136
  `diffCaptures` is the other core verb — what changed between two captures, and why, at the same
138
137
  "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
138
+ and [Reading a diff](README.md#reading-a-diff) in
140
139
  the README for the grammar and a worked example.
141
140
 
142
141
  Plus the geometry primitives — `intersect`, `contains`, `gapAlong`, `area`, `center` — all pure
@@ -165,16 +164,27 @@ does not.
165
164
 
166
165
  ## Status
167
166
 
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`
167
+ **Published.** `npm install --save-dev @michaelmagan/dnumb` MIT, zero dependencies. The library
168
+ surface above is built and tested. The CLI has a stable verb set — `capture`, `look`, `diff`, and
169
+ `q describe|find|at|within|distance|nearest|facts|color`
170
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
171
+ [README § From the CLI](README.md#from-the-cli) and
172
+ [§ Reading a diff](README.md#reading-a-diff). This
173
173
  file still does not restate flags, on purpose: run `dnumb --help` for the exhaustive, versioned
174
174
  list of what your installed copy actually supports, and treat it as authoritative over any prose
175
175
  here or there.
176
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).
177
+ **Invoking it three forms, not one.** Nothing installed yet, any package manager: `npx
178
+ @michaelmagan/dnumb --help` (or `bunx @michaelmagan/dnumb --help`) the zero-install form, works
179
+ everywhere. Already a dependency installed via `npm install`, `pnpm add`, or `bun add`, all of
180
+ which populate `node_modules/.bin/dnumb` the same way: `npx dnumb --help` or `bunx dnumb --help`,
181
+ either resolves the local bin regardless of which tool installed it (verified: `npx` correctly
182
+ resolves a bun-installed bin, including under bun's isolated linker). **If a bare `npx dnumb ...` or
183
+ `bunx dnumb ...` 404s, that means nothing is installed locally, not that the package doesn't
184
+ exist** — both tools then resolve `dnumb` as a *package* name against the registry, and there is no
185
+ unscoped `dnumb` package on npm (permanently blocked by npm's similarity filter); retry with the
186
+ scoped form above, or install first: `npm install --save-dev @michaelmagan/dnumb` / `bun add -d
187
+ @michaelmagan/dnumb`.
188
+
189
+ The digest format is specified in [`docs/digest.md`](docs/digest.md), shipped alongside this file
190
+ in the installed package — no network access needed to read it.
package/README.md CHANGED
@@ -20,7 +20,7 @@ e0 body @0,0 1024x181
20
20
  e1 h1.heading "Account settings" @24,24 976x25
21
21
  e2 div @24,65 976x44 [row] ↓16 [overflow-y +105px; clip]
22
22
  e3 button.button "Actions" @39,77 60x21
23
- e4 span "3 selected" @111,77 68x20 →12
23
+ e4 span "3 selected" @111,75 90x24 →12
24
24
  e5 nav.menu "Actions" @39,104 182x109 →-110 [clipped by e2 div; overflow:hidden, not scrollable; 103.8px below, 182x5 visible]
25
25
  e6 a.menuitem "Rename" @40,105 180x36 [clipped by e2 div; overflow:hidden, not scrollable; 31.6px below, 180x4 visible]
26
26
  e7 a.menuitem "Duplicate" @40,141 180x36 ↓0 [clipped by e2 div; overflow:hidden, not scrollable; 67.2px below, nothing visible]
@@ -41,12 +41,13 @@ other four are, only in this one header line and the `q color` verb — naming e
41
41
  pair on the page with a WCAG contrast ratio beside it, and never a pass/fail. See
42
42
  [`docs/digest.md`](docs/digest.md#color) for the full grammar.
43
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).
44
+ > **Status: published.** `npm install --save-dev @michaelmagan/dnumb`MIT, zero dependencies. The
45
+ > library — capture, digest, facts, clip analysis, bundles, the Playwright integration — is built
46
+ > and tested. The CLI (`capture`, `look`, `diff`, `q`) has a stable verb set, worked below in
47
+ > [From the CLI](#from-the-cli) and [Reading a diff](#reading-a-diff); `dnumb --help` stays the
48
+ > exhaustive, versioned option list. Build journal in [`LOG.md`](LOG.md); the plan is
49
+ > [`docs/00-PLAN-OF-RECORD.md`](docs/00-PLAN-OF-RECORD.md); the release history is
50
+ > [`docs/06-RELEASE.md`](docs/06-RELEASE.md).
50
51
 
51
52
  ## The idea
52
53
 
@@ -174,8 +175,23 @@ No test suite yet, or just want a bundle right now? The shortest path from a URL
174
175
  two commands:
175
176
 
176
177
  ```bash
177
- dnumb capture https://example.com/settings --name settings-desktop
178
- dnumb --from .dnumb/settings-desktop.json look
178
+ npx @michaelmagan/dnumb capture https://example.com/settings --name settings-desktop # no install
179
+ npx @michaelmagan/dnumb --from .dnumb/settings-desktop.json look
180
+ ```
181
+
182
+ On a bun project, `bunx` is the equivalent zero-install form: `bunx @michaelmagan/dnumb --help`.
183
+
184
+ Already installed it (`npm install --save-dev @michaelmagan/dnumb`, or `bun add -d
185
+ @michaelmagan/dnumb`)? Drop the scope — `npx dnumb` and `bunx dnumb` both resolve the local `dnumb`
186
+ bin the same way, since npm, pnpm, and bun all populate `node_modules/.bin/dnumb` identically.
187
+ Without a local install, either tool takes `dnumb` as a *package* name, not a bin name, and there is
188
+ no unscoped `dnumb` package on npm (see [`docs/06-RELEASE.md` §0](docs/06-RELEASE.md)) — so a bare
189
+ `npx dnumb` or `bunx dnumb` with nothing installed 404s.
190
+
191
+ ```bash
192
+ npx dnumb capture https://example.com/settings --name settings-desktop # after install
193
+ npx dnumb --from .dnumb/settings-desktop.json look
194
+ # or, in a bun project: bunx dnumb capture ... / bunx dnumb --from ... look
179
195
  ```
180
196
 
181
197
  `capture` launches a browser, navigates, captures, and writes `.dnumb/settings-desktop.json` —
@@ -274,7 +290,7 @@ empty body and a page that did not change would otherwise look identical.
274
290
 
275
291
  | Doc | What it is |
276
292
  |---|---|
277
- | [docs/digest.md](docs/digest.md) | **The digest format.** Every token, every fact form, the fold and collapse syntax, the parser contract |
293
+ | [docs/digest.md](docs/digest.md) | **The digest format.** Every token, every fact form, the fold and collapse syntax, the parser contract. Ships in the tarball |
278
294
  | [AGENTS.md](AGENTS.md) | For the model that will use it. Ships in the tarball |
279
295
  | [skills/dnumb/SKILL.md](skills/dnumb/SKILL.md) | The agent skill. Ships in the tarball, discoverable via `@tanstack/intent` |
280
296
  | [LOG.md](LOG.md) | Build journal, one entry per step |
package/dist/cli.cjs CHANGED
File without changes
@@ -151,10 +151,86 @@ function edgeDistance(nodeStart, nodeSize, prevStart, prevSize) {
151
151
  return -(nodeEnd - prevStart);
152
152
  return nodeStart - prevEnd;
153
153
  }
154
+ /**
155
+ * Which axis `gapToken` should measure and print, for one sibling pair.
156
+ *
157
+ * For `row` and `column` flex containers this is not a judgment call: flexbox
158
+ * has exactly one main axis, fixed by `flex-direction`, and every child is
159
+ * placed along it by the spec regardless of what the cross axis happens to
160
+ * measure between any two of them. `docs/digest.md`'s own pinned example
161
+ * (`clipped-menu.html`) is a real case where trusting the cross axis instead
162
+ * would have been wrong: `e5` (a dropdown flown out below the toolbar) and
163
+ * `e4` (a toolbar button) sit in a `[row]` container, genuinely 5px apart on
164
+ * y (the dropdown starts below the row, not overlapping it) and 110px
165
+ * overlapping on x -- the row's own axis. The declared axis wins there
166
+ * unconditionally, which is why this function never second-guesses `row` or
167
+ * `column`.
168
+ *
169
+ * `grid` is different in kind, not degree: CSS Grid is two-dimensional, and
170
+ * `analysis/neighbors.ts#siblingGaps` and `analysis/uniformity.ts#
171
+ * consecutiveGaps` already encode this ("`flow: 'grid'` has no single
172
+ * well-defined axis") by skipping grid containers outright for the analyses
173
+ * that need one global axis per container. The element line cannot skip --
174
+ * every non-first sibling gets a gap token -- so it asks the only question
175
+ * that has an honest answer for a *specific pair*: are these two boxes
176
+ * actually adjacent along x, along y, or is the relationship genuinely
177
+ * ambiguous? Two consecutive DOM-order children of a `grid-template-columns:
178
+ * repeat(N, ...)` row (the common case -- a stat-card row, a two-column
179
+ * article layout) sit at the same y with the same height and a real x gutter
180
+ * between them; before this function existed, `gapToken` always measured
181
+ * grid children vertically, so that real horizontal gutter (`ar.wikipedia.org`,
182
+ * real capture: a 24px column gutter between the TOC sidebar and the main
183
+ * content) came out as `↓-11195` and `↓-11167` -- values with the *shape* of
184
+ * a fabricated near-total vertical overlap, which is exactly the mechanism
185
+ * `docs/notes/07-refusal.md` §6 and a blind-read rater on `dashboard-
186
+ * defect.html`'s 4-column `stats-grid` (`↓-112`, one card's full height) both
187
+ * hit: a direction-agnostic negative number, printed on the one flow kind
188
+ * with no declared axis to check it against, read as a literal overlap
189
+ * claim.
190
+ *
191
+ * The rule, applied only when `flow === 'grid'`:
192
+ *
193
+ * - One axis shows a real separation (no overlap, i.e. `>= 0`) and the
194
+ * other shows overlap: the boxes are unambiguously adjacent along the
195
+ * separated axis (aligned/overlapping on the other is what "in the same
196
+ * row/column" looks like) -- print that axis. This is the fix for the
197
+ * stat-card and Wikipedia cases above.
198
+ * - Both axes show a real separation: a diagonal case with no overlap on
199
+ * either axis at all, the shape of a grid-auto-flow row-wrap boundary
200
+ * (last card of row N to first card of row N+1) -- the smaller of the two
201
+ * magnitudes is kept, since the wrap boundary's "distance" back across the
202
+ * whole row width is an artifact of printing one linear sequence for a
203
+ * 2-D layout, while the real row-to-row gap is usually the smaller number
204
+ * and is what a reader means by "the gap to the next one".
205
+ * - Both axes overlap: no separation anywhere to anchor a choice on (a
206
+ * decorative full-bleed overlay sibling, e.g., `linear-app`'s `e347`) --
207
+ * falls back to the same vertical default every other ambiguous flow kind
208
+ * (`column`, `none`) already uses, unchanged from before this fix.
209
+ *
210
+ * None of this touches `row`, `column`, or `none`: their gap arithmetic and
211
+ * axis were already correct (see `edgeDistance`'s own doc comment for the
212
+ * direction-agnostic RTL fix), and `07-refusal.md` §6 names the fabrication
213
+ * as specific to grid/row-flow siblings, not flex row/column.
214
+ */
215
+ function resolveGapAxis(node, previous, flow) {
216
+ if (flow !== 'grid')
217
+ return flow !== 'row';
218
+ const h = edgeDistance(node.rect.x, node.rect.width, previous.rect.x, previous.rect.width);
219
+ const v = edgeDistance(node.rect.y, node.rect.height, previous.rect.y, previous.rect.height);
220
+ const hSeparated = Number.isFinite(h) && h >= 0;
221
+ const vSeparated = Number.isFinite(v) && v >= 0;
222
+ if (hSeparated && !vSeparated)
223
+ return false;
224
+ if (vSeparated && !hSeparated)
225
+ return true;
226
+ if (hSeparated && vSeparated)
227
+ return Math.abs(v) <= Math.abs(h);
228
+ return true; // both overlap: no separated axis to anchor on, keep the old default.
229
+ }
154
230
  function gapToken(node, previous, flow) {
155
231
  if (!previous)
156
232
  return '';
157
- const vertical = flow !== 'row';
233
+ const vertical = resolveGapAxis(node, previous, flow);
158
234
  const value = vertical
159
235
  ? edgeDistance(node.rect.y, node.rect.height, previous.rect.y, previous.rect.height)
160
236
  : edgeDistance(node.rect.x, node.rect.width, previous.rect.x, previous.rect.width);
@@ -147,10 +147,86 @@ function edgeDistance(nodeStart, nodeSize, prevStart, prevSize) {
147
147
  return -(nodeEnd - prevStart);
148
148
  return nodeStart - prevEnd;
149
149
  }
150
+ /**
151
+ * Which axis `gapToken` should measure and print, for one sibling pair.
152
+ *
153
+ * For `row` and `column` flex containers this is not a judgment call: flexbox
154
+ * has exactly one main axis, fixed by `flex-direction`, and every child is
155
+ * placed along it by the spec regardless of what the cross axis happens to
156
+ * measure between any two of them. `docs/digest.md`'s own pinned example
157
+ * (`clipped-menu.html`) is a real case where trusting the cross axis instead
158
+ * would have been wrong: `e5` (a dropdown flown out below the toolbar) and
159
+ * `e4` (a toolbar button) sit in a `[row]` container, genuinely 5px apart on
160
+ * y (the dropdown starts below the row, not overlapping it) and 110px
161
+ * overlapping on x -- the row's own axis. The declared axis wins there
162
+ * unconditionally, which is why this function never second-guesses `row` or
163
+ * `column`.
164
+ *
165
+ * `grid` is different in kind, not degree: CSS Grid is two-dimensional, and
166
+ * `analysis/neighbors.ts#siblingGaps` and `analysis/uniformity.ts#
167
+ * consecutiveGaps` already encode this ("`flow: 'grid'` has no single
168
+ * well-defined axis") by skipping grid containers outright for the analyses
169
+ * that need one global axis per container. The element line cannot skip --
170
+ * every non-first sibling gets a gap token -- so it asks the only question
171
+ * that has an honest answer for a *specific pair*: are these two boxes
172
+ * actually adjacent along x, along y, or is the relationship genuinely
173
+ * ambiguous? Two consecutive DOM-order children of a `grid-template-columns:
174
+ * repeat(N, ...)` row (the common case -- a stat-card row, a two-column
175
+ * article layout) sit at the same y with the same height and a real x gutter
176
+ * between them; before this function existed, `gapToken` always measured
177
+ * grid children vertically, so that real horizontal gutter (`ar.wikipedia.org`,
178
+ * real capture: a 24px column gutter between the TOC sidebar and the main
179
+ * content) came out as `↓-11195` and `↓-11167` -- values with the *shape* of
180
+ * a fabricated near-total vertical overlap, which is exactly the mechanism
181
+ * `docs/notes/07-refusal.md` §6 and a blind-read rater on `dashboard-
182
+ * defect.html`'s 4-column `stats-grid` (`↓-112`, one card's full height) both
183
+ * hit: a direction-agnostic negative number, printed on the one flow kind
184
+ * with no declared axis to check it against, read as a literal overlap
185
+ * claim.
186
+ *
187
+ * The rule, applied only when `flow === 'grid'`:
188
+ *
189
+ * - One axis shows a real separation (no overlap, i.e. `>= 0`) and the
190
+ * other shows overlap: the boxes are unambiguously adjacent along the
191
+ * separated axis (aligned/overlapping on the other is what "in the same
192
+ * row/column" looks like) -- print that axis. This is the fix for the
193
+ * stat-card and Wikipedia cases above.
194
+ * - Both axes show a real separation: a diagonal case with no overlap on
195
+ * either axis at all, the shape of a grid-auto-flow row-wrap boundary
196
+ * (last card of row N to first card of row N+1) -- the smaller of the two
197
+ * magnitudes is kept, since the wrap boundary's "distance" back across the
198
+ * whole row width is an artifact of printing one linear sequence for a
199
+ * 2-D layout, while the real row-to-row gap is usually the smaller number
200
+ * and is what a reader means by "the gap to the next one".
201
+ * - Both axes overlap: no separation anywhere to anchor a choice on (a
202
+ * decorative full-bleed overlay sibling, e.g., `linear-app`'s `e347`) --
203
+ * falls back to the same vertical default every other ambiguous flow kind
204
+ * (`column`, `none`) already uses, unchanged from before this fix.
205
+ *
206
+ * None of this touches `row`, `column`, or `none`: their gap arithmetic and
207
+ * axis were already correct (see `edgeDistance`'s own doc comment for the
208
+ * direction-agnostic RTL fix), and `07-refusal.md` §6 names the fabrication
209
+ * as specific to grid/row-flow siblings, not flex row/column.
210
+ */
211
+ function resolveGapAxis(node, previous, flow) {
212
+ if (flow !== 'grid')
213
+ return flow !== 'row';
214
+ const h = edgeDistance(node.rect.x, node.rect.width, previous.rect.x, previous.rect.width);
215
+ const v = edgeDistance(node.rect.y, node.rect.height, previous.rect.y, previous.rect.height);
216
+ const hSeparated = Number.isFinite(h) && h >= 0;
217
+ const vSeparated = Number.isFinite(v) && v >= 0;
218
+ if (hSeparated && !vSeparated)
219
+ return false;
220
+ if (vSeparated && !hSeparated)
221
+ return true;
222
+ if (hSeparated && vSeparated)
223
+ return Math.abs(v) <= Math.abs(h);
224
+ return true; // both overlap: no separated axis to anchor on, keep the old default.
225
+ }
150
226
  function gapToken(node, previous, flow) {
151
227
  if (!previous)
152
228
  return '';
153
- const vertical = flow !== 'row';
229
+ const vertical = resolveGapAxis(node, previous, flow);
154
230
  const value = vertical
155
231
  ? edgeDistance(node.rect.y, node.rect.height, previous.rect.y, previous.rect.height)
156
232
  : edgeDistance(node.rect.x, node.rect.width, previous.rect.x, previous.rect.width);