@michaelmagan/dnumb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +297 -0
  4. package/dist/analysis/cluster.cjs +114 -0
  5. package/dist/analysis/cluster.d.cts +68 -0
  6. package/dist/analysis/cluster.d.ts +68 -0
  7. package/dist/analysis/cluster.js +109 -0
  8. package/dist/analysis/distances.cjs +124 -0
  9. package/dist/analysis/distances.d.cts +58 -0
  10. package/dist/analysis/distances.d.ts +58 -0
  11. package/dist/analysis/distances.js +119 -0
  12. package/dist/analysis/geometry.cjs +60 -0
  13. package/dist/analysis/geometry.d.cts +34 -0
  14. package/dist/analysis/geometry.d.ts +34 -0
  15. package/dist/analysis/geometry.js +53 -0
  16. package/dist/analysis/layout-tree.cjs +245 -0
  17. package/dist/analysis/layout-tree.d.cts +45 -0
  18. package/dist/analysis/layout-tree.d.ts +45 -0
  19. package/dist/analysis/layout-tree.js +242 -0
  20. package/dist/analysis/region-query.cjs +71 -0
  21. package/dist/analysis/region-query.d.cts +25 -0
  22. package/dist/analysis/region-query.d.ts +25 -0
  23. package/dist/analysis/region-query.js +67 -0
  24. package/dist/analysis/types.cjs +7 -0
  25. package/dist/analysis/types.d.cts +143 -0
  26. package/dist/analysis/types.d.ts +143 -0
  27. package/dist/analysis/types.js +6 -0
  28. package/dist/bundle.cjs +182 -0
  29. package/dist/bundle.d.cts +91 -0
  30. package/dist/bundle.d.ts +91 -0
  31. package/dist/bundle.js +175 -0
  32. package/dist/cli.cjs +755 -0
  33. package/dist/cli.d.cts +2 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +719 -0
  36. package/dist/diff/changes.cjs +656 -0
  37. package/dist/diff/changes.d.cts +2 -0
  38. package/dist/diff/changes.d.ts +2 -0
  39. package/dist/diff/changes.js +653 -0
  40. package/dist/diff/identity.cjs +167 -0
  41. package/dist/diff/identity.d.cts +62 -0
  42. package/dist/diff/identity.d.ts +62 -0
  43. package/dist/diff/identity.js +159 -0
  44. package/dist/diff/index.cjs +84 -0
  45. package/dist/diff/index.d.cts +76 -0
  46. package/dist/diff/index.d.ts +76 -0
  47. package/dist/diff/index.js +68 -0
  48. package/dist/diff/match.cjs +156 -0
  49. package/dist/diff/match.d.cts +17 -0
  50. package/dist/diff/match.d.ts +17 -0
  51. package/dist/diff/match.js +152 -0
  52. package/dist/diff/render.cjs +540 -0
  53. package/dist/diff/render.d.cts +2 -0
  54. package/dist/diff/render.d.ts +2 -0
  55. package/dist/diff/render.js +537 -0
  56. package/dist/diff/types.cjs +2 -0
  57. package/dist/diff/types.d.cts +211 -0
  58. package/dist/diff/types.d.ts +211 -0
  59. package/dist/diff/types.js +1 -0
  60. package/dist/digest/index.cjs +8 -0
  61. package/dist/digest/index.d.cts +4 -0
  62. package/dist/digest/index.d.ts +4 -0
  63. package/dist/digest/index.js +2 -0
  64. package/dist/digest/parse.cjs +163 -0
  65. package/dist/digest/parse.d.cts +80 -0
  66. package/dist/digest/parse.d.ts +80 -0
  67. package/dist/digest/parse.js +160 -0
  68. package/dist/digest/render.cjs +1575 -0
  69. package/dist/digest/render.d.cts +41 -0
  70. package/dist/digest/render.d.ts +41 -0
  71. package/dist/digest/render.js +1571 -0
  72. package/dist/digest/types.cjs +10 -0
  73. package/dist/digest/types.d.cts +86 -0
  74. package/dist/digest/types.d.ts +86 -0
  75. package/dist/digest/types.js +9 -0
  76. package/dist/index.cjs +61 -0
  77. package/dist/index.d.cts +27 -0
  78. package/dist/index.d.ts +27 -0
  79. package/dist/index.js +19 -0
  80. package/dist/page.cjs +35 -0
  81. package/dist/page.d.cts +40 -0
  82. package/dist/page.d.ts +40 -0
  83. package/dist/page.js +31 -0
  84. package/dist/playwright.cjs +138 -0
  85. package/dist/playwright.d.cts +69 -0
  86. package/dist/playwright.d.ts +69 -0
  87. package/dist/playwright.js +131 -0
  88. package/dist/query.cjs +1152 -0
  89. package/dist/query.d.cts +47 -0
  90. package/dist/query.d.ts +47 -0
  91. package/dist/query.js +1146 -0
  92. package/dist/snapshot/capture.cjs +80 -0
  93. package/dist/snapshot/capture.d.cts +34 -0
  94. package/dist/snapshot/capture.d.ts +34 -0
  95. package/dist/snapshot/capture.js +77 -0
  96. package/dist/snapshot/cdp.cjs +450 -0
  97. package/dist/snapshot/cdp.d.cts +36 -0
  98. package/dist/snapshot/cdp.d.ts +36 -0
  99. package/dist/snapshot/cdp.js +447 -0
  100. package/dist/snapshot/clip.cjs +100 -0
  101. package/dist/snapshot/clip.d.cts +26 -0
  102. package/dist/snapshot/clip.d.ts +26 -0
  103. package/dist/snapshot/clip.js +96 -0
  104. package/dist/snapshot/collect.cjs +358 -0
  105. package/dist/snapshot/collect.d.cts +18 -0
  106. package/dist/snapshot/collect.d.ts +18 -0
  107. package/dist/snapshot/collect.js +355 -0
  108. package/dist/snapshot/color.cjs +1039 -0
  109. package/dist/snapshot/color.d.cts +316 -0
  110. package/dist/snapshot/color.d.ts +316 -0
  111. package/dist/snapshot/color.js +1026 -0
  112. package/dist/snapshot/facts.cjs +167 -0
  113. package/dist/snapshot/facts.d.cts +48 -0
  114. package/dist/snapshot/facts.d.ts +48 -0
  115. package/dist/snapshot/facts.js +161 -0
  116. package/dist/snapshot/limits.cjs +69 -0
  117. package/dist/snapshot/limits.d.cts +59 -0
  118. package/dist/snapshot/limits.d.ts +59 -0
  119. package/dist/snapshot/limits.js +65 -0
  120. package/dist/snapshot/not-measured.cjs +44 -0
  121. package/dist/snapshot/not-measured.d.cts +14 -0
  122. package/dist/snapshot/not-measured.d.ts +14 -0
  123. package/dist/snapshot/not-measured.js +41 -0
  124. package/dist/snapshot/types.cjs +2 -0
  125. package/dist/snapshot/types.d.cts +238 -0
  126. package/dist/snapshot/types.d.ts +238 -0
  127. package/dist/snapshot/types.js +1 -0
  128. package/dist/spec/index.cjs +9 -0
  129. package/dist/spec/index.d.cts +15 -0
  130. package/dist/spec/index.d.ts +15 -0
  131. package/dist/spec/index.js +6 -0
  132. package/package.json +141 -0
  133. package/skills/dnumb/SKILL.md +136 -0
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Limits shared by both capture paths.
3
+ *
4
+ * They live here rather than in either collector because a capture has to mean
5
+ * the same thing whichever path produced it. The first benchmark caught them
6
+ * disagreeing: on a Wikipedia article the in-page walk stopped at 5,000
7
+ * elements and reported `truncated: true`, while the CDP path returned 6,927
8
+ * and hardcoded `truncated: false`. Two captures of one page that are not
9
+ * comparable, and a flag that silently means "maybe".
10
+ */
11
+ /**
12
+ * The element ceiling. Not a performance guard -- both paths are fast enough
13
+ * at this size -- but a token guard: a digest of 5,000 elements is already far
14
+ * past what any consumer will read, and past it the honest answer is
15
+ * `truncated: true` plus a narrower capture.
16
+ */
17
+ export const MAX_ELEMENTS = 5000;
18
+ /** Own-text preview length, in characters. */
19
+ export const TEXT_PREVIEW_LENGTH = 60;
20
+ /** Accessible-name length, in characters. */
21
+ export const NAME_LENGTH = 80;
22
+ /** Attributes treated as a stable test id, in priority order. */
23
+ export const TESTID_ATTRS = ['data-testid', 'data-test-id', 'data-test', 'data-qa'];
24
+ /** Tags whose subtrees carry no layout worth measuring. */
25
+ export const SKIPPED_TAGS = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG'];
26
+ /**
27
+ * `background-image` / `text-shadow` string cap, in characters.
28
+ *
29
+ * A gradient or shadow's computed value can run to hundreds of characters
30
+ * (many stops, multiple shadows) — `docs/notes/03-color.md` §7 estimates real
31
+ * bundle growth at ~105 bytes/element for the naive uncapped fields, so this
32
+ * caps the two least-bounded ones at the point of capture rather than on
33
+ * render. `snapshot/color.ts` only ever reads the first and last gradient
34
+ * stop and the first shadow color — but "only reads the ends" is exactly
35
+ * why a *plain prefix* cut is wrong for `background-image`: a real 137-char
36
+ * gradient truncated at 120 chars loses its own last stop, the one the
37
+ * gradient token needs (verified against a real capture; A4). See
38
+ * `capBackgroundImage` below for the fix. `text-shadow` has no such gap —
39
+ * `firstShadowColor` only ever reads the *first* comma-separated segment's
40
+ * first color token, so a plain prefix cut is still exactly right for it.
41
+ */
42
+ export const COLOR_TOKEN_CAP = 120;
43
+ /**
44
+ * Caps `raw` to `cap` characters while preserving *both* ends, joined by an
45
+ * ellipsis, instead of a plain prefix cut. `background-image`'s only two
46
+ * downstream readers (`resolveBackdrop`'s gradient-stop parse and `q color`'s
47
+ * display) need the *first* and *last* color token, never the middle — a
48
+ * plain `slice(0, cap)` can and does drop the last stop off a real multi-stop
49
+ * gradient whose computed string runs past `cap` (A4). Splitting the budget
50
+ * across both ends fixes that regardless of how many stops sit in the middle:
51
+ * the string might read `linear-gradient(...) 0%, …, rgb(255,255,255) 100%)`
52
+ * instead of every stop, but the two that matter always survive.
53
+ *
54
+ * `collect.ts` restates this inline (`page.evaluate` serializes only its own
55
+ * function source, so it cannot import this module) — kept in sync by
56
+ * `test/unit/limits.test.ts`, the same discipline `MAX_ELEMENTS` and the
57
+ * other capture-path constants already use.
58
+ */
59
+ export function capBackgroundImage(raw, cap = COLOR_TOKEN_CAP) {
60
+ if (raw.length <= cap)
61
+ return raw;
62
+ const ELLIPSIS = '…';
63
+ const half = Math.max(0, Math.floor((cap - ELLIPSIS.length) / 2));
64
+ return `${raw.slice(0, half)}${ELLIPSIS}${raw.slice(raw.length - half)}`;
65
+ }
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatNotMeasured = formatNotMeasured;
4
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
5
+ function iframeSegment(iframes) {
6
+ const total = iframes.sameOrigin + iframes.crossOrigin + iframes.unknownOrigin;
7
+ if (total === 0)
8
+ return null;
9
+ const detail = [];
10
+ if (iframes.crossOrigin > 0)
11
+ detail.push(`${iframes.crossOrigin} cross-origin`);
12
+ if (iframes.unknownOrigin > 0)
13
+ detail.push(`${iframes.unknownOrigin} origin unknown`);
14
+ return `${plural(total, 'iframe')}${detail.length > 0 ? ` (${detail.join(', ')})` : ''}`;
15
+ }
16
+ function shadowRootSegment(shadowRoots) {
17
+ // `closed: 'unknown'` is a constant property of the *capture path*, not a
18
+ // measurement of *this page* -- every walk capture and every legacy bundle
19
+ // carries it regardless of what the page contains, so it does not belong on
20
+ // this counted, page-specific line. That permanent caveat belongs on the
21
+ // digest identity line's strategy segment instead (`— walk (closed shadow
22
+ // roots undetectable)`, in `digest/render.ts`) -- see the `NotMeasured` doc
23
+ // comment for why the count is genuinely unknowable here. This line reports
24
+ // only what was actually counted: the real open-root total, when there is
25
+ // one.
26
+ if (shadowRoots.closed === 'unknown') {
27
+ if (shadowRoots.open === 0)
28
+ return null;
29
+ return plural(shadowRoots.open, 'open shadow root');
30
+ }
31
+ const total = shadowRoots.open + shadowRoots.closed;
32
+ if (total === 0)
33
+ return null;
34
+ const detail = shadowRoots.closed > 0 ? ` (${shadowRoots.closed} closed)` : '';
35
+ return `${plural(total, 'shadow root')}${detail}`;
36
+ }
37
+ /** `"not measured: 2 iframes (1 cross-origin), 14 shadow roots"`, or `null` when there is nothing to disclose. */
38
+ function formatNotMeasured(notMeasured) {
39
+ const segments = [
40
+ iframeSegment(notMeasured.iframes),
41
+ shadowRootSegment(notMeasured.shadowRoots),
42
+ ].filter((segment) => segment !== null);
43
+ return segments.length > 0 ? `not measured: ${segments.join(', ')}` : null;
44
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Renders `NotMeasured` into the one line both the digest header and the CLI
3
+ * query verbs show. One formatter so the two never describe the same counts
4
+ * in different words -- the failure mode `digest/render.ts`'s own header
5
+ * lines are built to avoid for every other fact this package prints.
6
+ *
7
+ * Returns `null` when there is nothing to disclose, so every call site can
8
+ * treat "print nothing" and "call this" as the same decision -- a page with
9
+ * no shadow DOM and no iframes must not gain a line just because the field
10
+ * exists on the record.
11
+ */
12
+ import type { NotMeasured } from "./types.cjs";
13
+ /** `"not measured: 2 iframes (1 cross-origin), 14 shadow roots"`, or `null` when there is nothing to disclose. */
14
+ export declare function formatNotMeasured(notMeasured: NotMeasured): string | null;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Renders `NotMeasured` into the one line both the digest header and the CLI
3
+ * query verbs show. One formatter so the two never describe the same counts
4
+ * in different words -- the failure mode `digest/render.ts`'s own header
5
+ * lines are built to avoid for every other fact this package prints.
6
+ *
7
+ * Returns `null` when there is nothing to disclose, so every call site can
8
+ * treat "print nothing" and "call this" as the same decision -- a page with
9
+ * no shadow DOM and no iframes must not gain a line just because the field
10
+ * exists on the record.
11
+ */
12
+ import type { NotMeasured } from "./types.js";
13
+ /** `"not measured: 2 iframes (1 cross-origin), 14 shadow roots"`, or `null` when there is nothing to disclose. */
14
+ export declare function formatNotMeasured(notMeasured: NotMeasured): string | null;
@@ -0,0 +1,41 @@
1
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
2
+ function iframeSegment(iframes) {
3
+ const total = iframes.sameOrigin + iframes.crossOrigin + iframes.unknownOrigin;
4
+ if (total === 0)
5
+ return null;
6
+ const detail = [];
7
+ if (iframes.crossOrigin > 0)
8
+ detail.push(`${iframes.crossOrigin} cross-origin`);
9
+ if (iframes.unknownOrigin > 0)
10
+ detail.push(`${iframes.unknownOrigin} origin unknown`);
11
+ return `${plural(total, 'iframe')}${detail.length > 0 ? ` (${detail.join(', ')})` : ''}`;
12
+ }
13
+ function shadowRootSegment(shadowRoots) {
14
+ // `closed: 'unknown'` is a constant property of the *capture path*, not a
15
+ // measurement of *this page* -- every walk capture and every legacy bundle
16
+ // carries it regardless of what the page contains, so it does not belong on
17
+ // this counted, page-specific line. That permanent caveat belongs on the
18
+ // digest identity line's strategy segment instead (`— walk (closed shadow
19
+ // roots undetectable)`, in `digest/render.ts`) -- see the `NotMeasured` doc
20
+ // comment for why the count is genuinely unknowable here. This line reports
21
+ // only what was actually counted: the real open-root total, when there is
22
+ // one.
23
+ if (shadowRoots.closed === 'unknown') {
24
+ if (shadowRoots.open === 0)
25
+ return null;
26
+ return plural(shadowRoots.open, 'open shadow root');
27
+ }
28
+ const total = shadowRoots.open + shadowRoots.closed;
29
+ if (total === 0)
30
+ return null;
31
+ const detail = shadowRoots.closed > 0 ? ` (${shadowRoots.closed} closed)` : '';
32
+ return `${plural(total, 'shadow root')}${detail}`;
33
+ }
34
+ /** `"not measured: 2 iframes (1 cross-origin), 14 shadow roots"`, or `null` when there is nothing to disclose. */
35
+ export function formatNotMeasured(notMeasured) {
36
+ const segments = [
37
+ iframeSegment(notMeasured.iframes),
38
+ shadowRootSegment(notMeasured.shadowRoots),
39
+ ].filter((segment) => segment !== null);
40
+ return segments.length > 0 ? `not measured: ${segments.join(', ')}` : null;
41
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The record shape everything downstream reads. `collect.ts` builds
3
+ * `ElementRecord[]` inside the page; nothing in this file depends on the DOM or
4
+ * on Playwright, so pure Node-side code — the digest, the diff, the query
5
+ * verbs — can import it freely.
6
+ *
7
+ * Ported from the prototype's `snapshot/types.ts`, with the fields the step-2
8
+ * spike proved load-bearing added: a *computed* role and accessible name (the
9
+ * prototype captured `getAttribute('role')`, which is null on nearly every real
10
+ * element), and the clip fact.
11
+ */
12
+ import type { BoxSides, Rect } from "../analysis/types.cjs";
13
+ export type { BoxSides, Rect };
14
+ /**
15
+ * Why an element is not fully on screen, and the facts needed to judge whether
16
+ * that matters.
17
+ *
18
+ * Carrying `overflow` and `scrollable` beside the hidden extents is the
19
+ * facts-with-counter-facts model: a clipped box inside `overflow:auto` is a
20
+ * scroll region, and the same box inside `overflow:hidden` is content nobody
21
+ * can reach. The tool reports both and does not decide which one this is.
22
+ */
23
+ export interface ClipFact {
24
+ /** Id of the nearest ancestor whose `overflow` is not `visible`. */
25
+ by: string;
26
+ byTag: string;
27
+ /** The clipper's `overflow-x`, or `x/y` when the two axes differ. */
28
+ overflow: string;
29
+ /** How far the element extends past the clipper on each side, in px. */
30
+ hidden: BoxSides;
31
+ /** The part of the element that survives the clip. */
32
+ visible: {
33
+ width: number;
34
+ height: number;
35
+ };
36
+ /**
37
+ * Whether a user could scroll the clipper to reveal the hidden part.
38
+ * Not the same question as `scrollHeight > clientHeight`: an
39
+ * `overflow:hidden` box has scroll extent and no reachability.
40
+ */
41
+ scrollable: boolean;
42
+ }
43
+ export interface ElementRecord {
44
+ /** Stable within one capture only — not a DOM id, not stable across runs. */
45
+ id: string;
46
+ tag: string;
47
+ /** `data-testid` / `data-test-id` / `data-test` / `data-qa`, first one present. */
48
+ testId: string | null;
49
+ /** The authored `id` attribute, unfiltered. Identity applies its own guard. */
50
+ domId: string | null;
51
+ /** Computed role — explicit `role` if present, otherwise the implicit one. */
52
+ role: string | null;
53
+ /** Computed accessible name. */
54
+ name: string | null;
55
+ className: string;
56
+ /** From the tag (`h1`-`h6`) or `aria-level`; `null` for non-headings. */
57
+ headingLevel: number | null;
58
+ rect: Rect;
59
+ scrollWidth: number;
60
+ scrollHeight: number;
61
+ clientWidth: number;
62
+ clientHeight: number;
63
+ overflowX: string;
64
+ overflowY: string;
65
+ position: string;
66
+ display: string;
67
+ visibility: string;
68
+ opacity: number;
69
+ margin: BoxSides;
70
+ padding: BoxSides;
71
+ /** Row/column gap in px; only meaningful on grid and flex containers. */
72
+ gap: {
73
+ row: number;
74
+ column: number;
75
+ };
76
+ border: BoxSides;
77
+ color: string;
78
+ backgroundColor: string;
79
+ /**
80
+ * Computed `background-image`, capped to ~120 chars (both ends preserved —
81
+ * see `limits.ts#capBackgroundImage`), `'none'` when unset. Not captured
82
+ * for its own sake — it is the counter-fact that invalidates a resolved
83
+ * backdrop: a gradient or `url()` background means no single color exists
84
+ * behind text painted over it. See `snapshot/color.ts` and
85
+ * `docs/notes/03-color.md` §2–3.
86
+ *
87
+ * Required, like every other field on this interface — both real capture
88
+ * paths always set it to a string. `null` is a distinct, legitimate third
89
+ * state: "this specific capture never measured this field at all" — a
90
+ * bundle written before color capture existed, read back through
91
+ * `bundle.ts#normalizeElement`, which backfills `null` here rather than the
92
+ * false positive claim `'none'` used to be (A3). `resolveBackdrop` and
93
+ * `hasPaint` in `snapshot/color.ts` treat `null` the same way they treat an
94
+ * unresolvable non-ancestor overlay: refuse to guess, report
95
+ * `unmeasured (not-captured ...)`, never silently assume "no image here".
96
+ *
97
+ * A record built by hand (not via `capture()`/`parseBundle`) that omits
98
+ * this field entirely (`undefined`, not `null`), or any of the other three
99
+ * color-capture fields below, gets a clear thrown error naming the missing
100
+ * field the first time `snapshot/color.ts` needs it, rather than an
101
+ * internal `TypeError`; see `requireColorField` there.
102
+ */
103
+ backgroundImage: string | null;
104
+ /**
105
+ * Computed `text-shadow`, capped to ~120 chars, `'none'` when unset. A
106
+ * counter-fact only — dnumb cannot measure what a shadow does perceptually
107
+ * to a low-contrast ratio, so it discloses that one exists rather than
108
+ * guessing. Required — see `backgroundImage`'s doc comment above.
109
+ */
110
+ textShadow: string;
111
+ /**
112
+ * Computed `-webkit-text-stroke-width` / `-color`. `'0px'` when unset.
113
+ * Required — see `backgroundImage`'s doc comment above.
114
+ */
115
+ textStrokeWidth: string;
116
+ textStrokeColor: string;
117
+ fontSize: string;
118
+ fontWeight: number;
119
+ textAlign: string;
120
+ flexDirection: string;
121
+ gridTemplateColumns: string;
122
+ /**
123
+ * The three properties that decide what overflowing content *does*. Without
124
+ * them a measurement of 147px past the box is unreadable: it is truncation if
125
+ * the box clips, a scroll region if it scrolls, harmless paint-over if the
126
+ * overflow is visible, and not a text problem at all if the text can wrap.
127
+ */
128
+ textOverflow: string;
129
+ whiteSpace: string;
130
+ overflowWrap: string;
131
+ /** First ~60 chars of the element's own direct text content, trimmed. */
132
+ text: string;
133
+ parentId: string | null;
134
+ childIds: string[];
135
+ /** `false` if `display:none`, `visibility:hidden`, `opacity:0`, or zero size. */
136
+ visible: boolean;
137
+ /** Present only when the element extends past a clipping ancestor. */
138
+ clip?: ClipFact;
139
+ ariaExpanded: string | null;
140
+ /**
141
+ * `document.elementsFromPoint` at this element's own box centre, front to
142
+ * back, resolved to the ids of elements this capture knows about -- the
143
+ * `walk` path's raw input for detecting a non-ancestor element painted over
144
+ * or behind this one's text (`snapshot/color.ts`'s overlap check). Computed
145
+ * only for a visible element with its own direct text (the same gate the
146
+ * color fact itself uses -- no text, no ink-on-backdrop question to
147
+ * probe), `null` otherwise. Always `null` on a `cdp` capture, which derives
148
+ * the same signal from `paintOrder` plus rect intersection instead: see
149
+ * `docs/notes/03-color.md` §3 and §7 for why the two paths take different
150
+ * inputs to the identical question.
151
+ */
152
+ hitStackIds: string[] | null;
153
+ /**
154
+ * CDP `DOMSnapshot`'s paint order for this node. Combined Node-side with
155
+ * rect intersection as the `cdp` path's overlap signal, in place of
156
+ * `hitStackIds` -- see that field's doc comment. `null` on a `walk`
157
+ * capture, and on a `cdp` node with no layout row (nothing painted).
158
+ */
159
+ paintOrder: number | null;
160
+ }
161
+ /**
162
+ * `<html>` (`documentElement`)'s own computed `background-color` and
163
+ * `color-scheme` — the two facts that decide what the browser's default
164
+ * canvas fill actually is, which is the *true* terminal layer a compositing
165
+ * walk that runs out of ancestors reaches (A2). Neither capture path visits
166
+ * `<html>` as part of the element tree (both start at `<body>` — see
167
+ * `collect.ts`/`cdp.ts`), so without this, `resolveBackdrop`'s ancestor walk
168
+ * has no way to see it and no honest way to know what is behind a
169
+ * fully-transparent `<body>`: guessing white is exactly the fabrication this
170
+ * package exists to refuse, since `color-scheme: dark` with no explicit
171
+ * background anywhere measures as `rgb(18, 18, 18)` on real Chromium, not
172
+ * white (verified, `docs/notes/03-color.md` and this feature's own probes).
173
+ *
174
+ * `colorScheme` is the raw computed string (`'normal'`, `'light'`, `'dark'`,
175
+ * `'light dark'`, ...) rather than a resolved color, because a page that
176
+ * lists both `light` and `dark` genuinely has no single determinable default
177
+ * — it depends on the reader's OS preference, which this capture cannot see
178
+ * — and `snapshot/color.ts#canvasBackground` reports that honestly as
179
+ * `unmeasured (canvas)` rather than picking one.
180
+ */
181
+ export interface DocumentBackground {
182
+ backgroundColor: string;
183
+ colorScheme: string;
184
+ }
185
+ /**
186
+ * Content this capture walked past without measuring it: shadow DOM and iframe
187
+ * documents that no `ElementRecord` in `elements` describes. Neither capture
188
+ * path enters an iframe, and the portable walk does not enter a shadow root
189
+ * either — so an element living inside one is not folded, not dropped, not
190
+ * marked invisible; it is simply absent, with nothing on this record to say
191
+ * so. That absence is indistinguishable from "does not exist" unless it is
192
+ * disclosed here.
193
+ *
194
+ * A closed shadow root has no signal at all from outside it after the fact —
195
+ * `Element.shadowRoot` is `null` for both "no shadow root" and "closed shadow
196
+ * root", and nothing else in the DOM tells them apart post-hoc. `'unknown'` on
197
+ * that one field is not a placeholder for zero; a portable-walk capture
198
+ * genuinely cannot say whether the count is 0 or 40, and reporting 0 would be
199
+ * exactly the false zero this package exists to refuse. Every other field here
200
+ * is always a real, counted number.
201
+ */
202
+ export interface NotMeasured {
203
+ shadowRoots: {
204
+ open: number;
205
+ closed: number | 'unknown';
206
+ };
207
+ iframes: {
208
+ sameOrigin: number;
209
+ crossOrigin: number;
210
+ /** Encountered, but this capture path could not resolve its origin. */
211
+ unknownOrigin: number;
212
+ };
213
+ }
214
+ export interface Snapshot {
215
+ elements: ElementRecord[];
216
+ /** True when the collector hit its element cap before finishing the tree. */
217
+ truncated: boolean;
218
+ viewport: {
219
+ width: number;
220
+ height: number;
221
+ };
222
+ /**
223
+ * Optional only so a hand-built `Snapshot` in a test fixture need not name a
224
+ * value it has no opinion about. Both real capture paths — `collect.ts` and
225
+ * `cdp.ts` — always set it; `Bundle.notMeasured` is the field that actually
226
+ * has to be trusted, and it is not optional there.
227
+ */
228
+ notMeasured?: NotMeasured;
229
+ /**
230
+ * See `DocumentBackground`'s doc comment. Optional for the same reason
231
+ * `notMeasured` is: both real capture paths always set it; a hand-built
232
+ * `Snapshot` need not. `| null` because `Bundle.documentBackground` (which
233
+ * structurally widens this same field — see `bundle.ts`) uses `null` for
234
+ * "a real bundle that predates color capture", and the two need to stay
235
+ * assignable to each other.
236
+ */
237
+ documentBackground?: DocumentBackground | null;
238
+ }
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The record shape everything downstream reads. `collect.ts` builds
3
+ * `ElementRecord[]` inside the page; nothing in this file depends on the DOM or
4
+ * on Playwright, so pure Node-side code — the digest, the diff, the query
5
+ * verbs — can import it freely.
6
+ *
7
+ * Ported from the prototype's `snapshot/types.ts`, with the fields the step-2
8
+ * spike proved load-bearing added: a *computed* role and accessible name (the
9
+ * prototype captured `getAttribute('role')`, which is null on nearly every real
10
+ * element), and the clip fact.
11
+ */
12
+ import type { BoxSides, Rect } from "../analysis/types.js";
13
+ export type { BoxSides, Rect };
14
+ /**
15
+ * Why an element is not fully on screen, and the facts needed to judge whether
16
+ * that matters.
17
+ *
18
+ * Carrying `overflow` and `scrollable` beside the hidden extents is the
19
+ * facts-with-counter-facts model: a clipped box inside `overflow:auto` is a
20
+ * scroll region, and the same box inside `overflow:hidden` is content nobody
21
+ * can reach. The tool reports both and does not decide which one this is.
22
+ */
23
+ export interface ClipFact {
24
+ /** Id of the nearest ancestor whose `overflow` is not `visible`. */
25
+ by: string;
26
+ byTag: string;
27
+ /** The clipper's `overflow-x`, or `x/y` when the two axes differ. */
28
+ overflow: string;
29
+ /** How far the element extends past the clipper on each side, in px. */
30
+ hidden: BoxSides;
31
+ /** The part of the element that survives the clip. */
32
+ visible: {
33
+ width: number;
34
+ height: number;
35
+ };
36
+ /**
37
+ * Whether a user could scroll the clipper to reveal the hidden part.
38
+ * Not the same question as `scrollHeight > clientHeight`: an
39
+ * `overflow:hidden` box has scroll extent and no reachability.
40
+ */
41
+ scrollable: boolean;
42
+ }
43
+ export interface ElementRecord {
44
+ /** Stable within one capture only — not a DOM id, not stable across runs. */
45
+ id: string;
46
+ tag: string;
47
+ /** `data-testid` / `data-test-id` / `data-test` / `data-qa`, first one present. */
48
+ testId: string | null;
49
+ /** The authored `id` attribute, unfiltered. Identity applies its own guard. */
50
+ domId: string | null;
51
+ /** Computed role — explicit `role` if present, otherwise the implicit one. */
52
+ role: string | null;
53
+ /** Computed accessible name. */
54
+ name: string | null;
55
+ className: string;
56
+ /** From the tag (`h1`-`h6`) or `aria-level`; `null` for non-headings. */
57
+ headingLevel: number | null;
58
+ rect: Rect;
59
+ scrollWidth: number;
60
+ scrollHeight: number;
61
+ clientWidth: number;
62
+ clientHeight: number;
63
+ overflowX: string;
64
+ overflowY: string;
65
+ position: string;
66
+ display: string;
67
+ visibility: string;
68
+ opacity: number;
69
+ margin: BoxSides;
70
+ padding: BoxSides;
71
+ /** Row/column gap in px; only meaningful on grid and flex containers. */
72
+ gap: {
73
+ row: number;
74
+ column: number;
75
+ };
76
+ border: BoxSides;
77
+ color: string;
78
+ backgroundColor: string;
79
+ /**
80
+ * Computed `background-image`, capped to ~120 chars (both ends preserved —
81
+ * see `limits.ts#capBackgroundImage`), `'none'` when unset. Not captured
82
+ * for its own sake — it is the counter-fact that invalidates a resolved
83
+ * backdrop: a gradient or `url()` background means no single color exists
84
+ * behind text painted over it. See `snapshot/color.ts` and
85
+ * `docs/notes/03-color.md` §2–3.
86
+ *
87
+ * Required, like every other field on this interface — both real capture
88
+ * paths always set it to a string. `null` is a distinct, legitimate third
89
+ * state: "this specific capture never measured this field at all" — a
90
+ * bundle written before color capture existed, read back through
91
+ * `bundle.ts#normalizeElement`, which backfills `null` here rather than the
92
+ * false positive claim `'none'` used to be (A3). `resolveBackdrop` and
93
+ * `hasPaint` in `snapshot/color.ts` treat `null` the same way they treat an
94
+ * unresolvable non-ancestor overlay: refuse to guess, report
95
+ * `unmeasured (not-captured ...)`, never silently assume "no image here".
96
+ *
97
+ * A record built by hand (not via `capture()`/`parseBundle`) that omits
98
+ * this field entirely (`undefined`, not `null`), or any of the other three
99
+ * color-capture fields below, gets a clear thrown error naming the missing
100
+ * field the first time `snapshot/color.ts` needs it, rather than an
101
+ * internal `TypeError`; see `requireColorField` there.
102
+ */
103
+ backgroundImage: string | null;
104
+ /**
105
+ * Computed `text-shadow`, capped to ~120 chars, `'none'` when unset. A
106
+ * counter-fact only — dnumb cannot measure what a shadow does perceptually
107
+ * to a low-contrast ratio, so it discloses that one exists rather than
108
+ * guessing. Required — see `backgroundImage`'s doc comment above.
109
+ */
110
+ textShadow: string;
111
+ /**
112
+ * Computed `-webkit-text-stroke-width` / `-color`. `'0px'` when unset.
113
+ * Required — see `backgroundImage`'s doc comment above.
114
+ */
115
+ textStrokeWidth: string;
116
+ textStrokeColor: string;
117
+ fontSize: string;
118
+ fontWeight: number;
119
+ textAlign: string;
120
+ flexDirection: string;
121
+ gridTemplateColumns: string;
122
+ /**
123
+ * The three properties that decide what overflowing content *does*. Without
124
+ * them a measurement of 147px past the box is unreadable: it is truncation if
125
+ * the box clips, a scroll region if it scrolls, harmless paint-over if the
126
+ * overflow is visible, and not a text problem at all if the text can wrap.
127
+ */
128
+ textOverflow: string;
129
+ whiteSpace: string;
130
+ overflowWrap: string;
131
+ /** First ~60 chars of the element's own direct text content, trimmed. */
132
+ text: string;
133
+ parentId: string | null;
134
+ childIds: string[];
135
+ /** `false` if `display:none`, `visibility:hidden`, `opacity:0`, or zero size. */
136
+ visible: boolean;
137
+ /** Present only when the element extends past a clipping ancestor. */
138
+ clip?: ClipFact;
139
+ ariaExpanded: string | null;
140
+ /**
141
+ * `document.elementsFromPoint` at this element's own box centre, front to
142
+ * back, resolved to the ids of elements this capture knows about -- the
143
+ * `walk` path's raw input for detecting a non-ancestor element painted over
144
+ * or behind this one's text (`snapshot/color.ts`'s overlap check). Computed
145
+ * only for a visible element with its own direct text (the same gate the
146
+ * color fact itself uses -- no text, no ink-on-backdrop question to
147
+ * probe), `null` otherwise. Always `null` on a `cdp` capture, which derives
148
+ * the same signal from `paintOrder` plus rect intersection instead: see
149
+ * `docs/notes/03-color.md` §3 and §7 for why the two paths take different
150
+ * inputs to the identical question.
151
+ */
152
+ hitStackIds: string[] | null;
153
+ /**
154
+ * CDP `DOMSnapshot`'s paint order for this node. Combined Node-side with
155
+ * rect intersection as the `cdp` path's overlap signal, in place of
156
+ * `hitStackIds` -- see that field's doc comment. `null` on a `walk`
157
+ * capture, and on a `cdp` node with no layout row (nothing painted).
158
+ */
159
+ paintOrder: number | null;
160
+ }
161
+ /**
162
+ * `<html>` (`documentElement`)'s own computed `background-color` and
163
+ * `color-scheme` — the two facts that decide what the browser's default
164
+ * canvas fill actually is, which is the *true* terminal layer a compositing
165
+ * walk that runs out of ancestors reaches (A2). Neither capture path visits
166
+ * `<html>` as part of the element tree (both start at `<body>` — see
167
+ * `collect.ts`/`cdp.ts`), so without this, `resolveBackdrop`'s ancestor walk
168
+ * has no way to see it and no honest way to know what is behind a
169
+ * fully-transparent `<body>`: guessing white is exactly the fabrication this
170
+ * package exists to refuse, since `color-scheme: dark` with no explicit
171
+ * background anywhere measures as `rgb(18, 18, 18)` on real Chromium, not
172
+ * white (verified, `docs/notes/03-color.md` and this feature's own probes).
173
+ *
174
+ * `colorScheme` is the raw computed string (`'normal'`, `'light'`, `'dark'`,
175
+ * `'light dark'`, ...) rather than a resolved color, because a page that
176
+ * lists both `light` and `dark` genuinely has no single determinable default
177
+ * — it depends on the reader's OS preference, which this capture cannot see
178
+ * — and `snapshot/color.ts#canvasBackground` reports that honestly as
179
+ * `unmeasured (canvas)` rather than picking one.
180
+ */
181
+ export interface DocumentBackground {
182
+ backgroundColor: string;
183
+ colorScheme: string;
184
+ }
185
+ /**
186
+ * Content this capture walked past without measuring it: shadow DOM and iframe
187
+ * documents that no `ElementRecord` in `elements` describes. Neither capture
188
+ * path enters an iframe, and the portable walk does not enter a shadow root
189
+ * either — so an element living inside one is not folded, not dropped, not
190
+ * marked invisible; it is simply absent, with nothing on this record to say
191
+ * so. That absence is indistinguishable from "does not exist" unless it is
192
+ * disclosed here.
193
+ *
194
+ * A closed shadow root has no signal at all from outside it after the fact —
195
+ * `Element.shadowRoot` is `null` for both "no shadow root" and "closed shadow
196
+ * root", and nothing else in the DOM tells them apart post-hoc. `'unknown'` on
197
+ * that one field is not a placeholder for zero; a portable-walk capture
198
+ * genuinely cannot say whether the count is 0 or 40, and reporting 0 would be
199
+ * exactly the false zero this package exists to refuse. Every other field here
200
+ * is always a real, counted number.
201
+ */
202
+ export interface NotMeasured {
203
+ shadowRoots: {
204
+ open: number;
205
+ closed: number | 'unknown';
206
+ };
207
+ iframes: {
208
+ sameOrigin: number;
209
+ crossOrigin: number;
210
+ /** Encountered, but this capture path could not resolve its origin. */
211
+ unknownOrigin: number;
212
+ };
213
+ }
214
+ export interface Snapshot {
215
+ elements: ElementRecord[];
216
+ /** True when the collector hit its element cap before finishing the tree. */
217
+ truncated: boolean;
218
+ viewport: {
219
+ width: number;
220
+ height: number;
221
+ };
222
+ /**
223
+ * Optional only so a hand-built `Snapshot` in a test fixture need not name a
224
+ * value it has no opinion about. Both real capture paths — `collect.ts` and
225
+ * `cdp.ts` — always set it; `Bundle.notMeasured` is the field that actually
226
+ * has to be trusted, and it is not optional there.
227
+ */
228
+ notMeasured?: NotMeasured;
229
+ /**
230
+ * See `DocumentBackground`'s doc comment. Optional for the same reason
231
+ * `notMeasured` is: both real capture paths always set it; a hand-built
232
+ * `Snapshot` need not. `| null` because `Bundle.documentBackground` (which
233
+ * structurally widens this same field — see `bundle.ts`) uses `null` for
234
+ * "a real bundle that predates color capture", and the two need to stay
235
+ * assignable to each other.
236
+ */
237
+ documentBackground?: DocumentBackground | null;
238
+ }
@@ -0,0 +1 @@
1
+ export {};