@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,96 @@
1
+ /** `overflow` values that actually clip. Everything else lets content escape. */
2
+ const CLIPS = /^(hidden|scroll|auto|clip|overlay)$/;
3
+ /** …and of those, the ones a user can scroll to reveal what is hidden. */
4
+ const REACHABLE = /^(auto|scroll|overlay)$/;
5
+ /**
6
+ * The nearest ancestor that establishes this element's containing block.
7
+ *
8
+ * - `position: fixed` escapes every scroll container: its containing block is
9
+ * the viewport, so nothing in the DOM chain clips it.
10
+ * - `position: absolute` skips ancestors that are `position: static`.
11
+ * - everything else is clipped by its DOM ancestors, in order.
12
+ */
13
+ function* clippingAncestors(el, byId) {
14
+ if (el.position === 'fixed')
15
+ return;
16
+ const absolute = el.position === 'absolute';
17
+ let cursor = el.parentId;
18
+ while (cursor) {
19
+ const ancestor = byId.get(cursor);
20
+ if (!ancestor)
21
+ return;
22
+ if (!absolute || ancestor.position !== 'static')
23
+ yield ancestor;
24
+ cursor = ancestor.parentId;
25
+ }
26
+ }
27
+ function overflowLabel(el) {
28
+ return el.overflowX === el.overflowY ? el.overflowX : `${el.overflowX}/${el.overflowY}`;
29
+ }
30
+ /**
31
+ * How far `el` extends past `clipper` on each side. All four are >= 0; a zero
32
+ * on every side means the element is fully inside.
33
+ */
34
+ function hiddenExtents(el, clipper) {
35
+ return {
36
+ top: Math.max(0, clipper.rect.y - el.rect.y),
37
+ left: Math.max(0, clipper.rect.x - el.rect.x),
38
+ bottom: Math.max(0, el.rect.y + el.rect.height - (clipper.rect.y + clipper.rect.height)),
39
+ right: Math.max(0, el.rect.x + el.rect.width - (clipper.rect.x + clipper.rect.width)),
40
+ };
41
+ }
42
+ /**
43
+ * The clip fact for one element, or `undefined` when nothing hides any of it.
44
+ *
45
+ * The returned fact deliberately carries `overflow` and `scrollable` beside the
46
+ * hidden extents. A box outside an `overflow:auto` ancestor is a scroll region;
47
+ * the same box outside an `overflow:hidden` ancestor is content nobody can
48
+ * reach. Reporting the extent without the reachability would be a measurement
49
+ * the reader cannot act on.
50
+ */
51
+ export function clipFactFor(el, byId) {
52
+ if (!el.visible)
53
+ return undefined;
54
+ for (const ancestor of clippingAncestors(el, byId)) {
55
+ const clipsX = CLIPS.test(ancestor.overflowX);
56
+ const clipsY = CLIPS.test(ancestor.overflowY);
57
+ if (!clipsX && !clipsY)
58
+ continue;
59
+ const hidden = hiddenExtents(el, ancestor);
60
+ // An axis that does not clip cannot hide anything on that axis.
61
+ if (!clipsX) {
62
+ hidden.left = 0;
63
+ hidden.right = 0;
64
+ }
65
+ if (!clipsY) {
66
+ hidden.top = 0;
67
+ hidden.bottom = 0;
68
+ }
69
+ if (!hidden.top && !hidden.bottom && !hidden.left && !hidden.right)
70
+ return undefined;
71
+ const visibleWidth = Math.max(0, el.rect.width - hidden.left - hidden.right);
72
+ const visibleHeight = Math.max(0, el.rect.height - hidden.top - hidden.bottom);
73
+ return {
74
+ by: ancestor.id,
75
+ byTag: ancestor.tag,
76
+ overflow: overflowLabel(ancestor),
77
+ hidden,
78
+ visible: { width: visibleWidth, height: visibleHeight },
79
+ // Not the same question as `scrollHeight > clientHeight`: an
80
+ // `overflow:hidden` box has scroll extent and no reachability.
81
+ scrollable: (REACHABLE.test(ancestor.overflowY) && ancestor.scrollHeight - ancestor.clientHeight > 1) ||
82
+ (REACHABLE.test(ancestor.overflowX) && ancestor.scrollWidth - ancestor.clientWidth > 1),
83
+ };
84
+ }
85
+ return undefined;
86
+ }
87
+ /** Attach `clip` to every element it applies to. Mutates and returns `elements`. */
88
+ export function attachClipFacts(elements) {
89
+ const byId = new Map(elements.map((el) => [el.id, el]));
90
+ for (const el of elements) {
91
+ const clip = clipFactFor(el, byId);
92
+ if (clip)
93
+ el.clip = clip;
94
+ }
95
+ return elements;
96
+ }
@@ -0,0 +1,358 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectSnapshot = collectSnapshot;
4
+ function collectSnapshot() {
5
+ // Inlined, not imported: `page.evaluate` serializes only this function's own
6
+ // source, so nothing from module scope survives the trip into the page.
7
+ // Kept in sync with `limits.ts` by `test/unit/limits.test.ts`.
8
+ const MAX_ELEMENTS = 5000;
9
+ const SKIPPED_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG']);
10
+ const TEXT_PREVIEW_LENGTH = 60;
11
+ const NAME_LENGTH = 80;
12
+ const TESTID_ATTRS = ['data-testid', 'data-test-id', 'data-test', 'data-qa'];
13
+ // Kept in sync with `limits.ts#COLOR_TOKEN_CAP` by `test/unit/limits.test.ts` --
14
+ // duplicated, not imported, for the same reason `MAX_ELEMENTS` is: this
15
+ // function's source is serialized whole into the page, and a runtime import
16
+ // from another module would not survive that trip.
17
+ const COLOR_TOKEN_CAP = 120;
18
+ // Kept in sync with `limits.ts#capBackgroundImage` -- same reason, same
19
+ // discipline. Preserves both ends of a long gradient string rather than a
20
+ // plain prefix cut, so the *last* color stop survives capture; see that
21
+ // function's doc comment (A4).
22
+ function capBackgroundImage(raw) {
23
+ if (raw.length <= COLOR_TOKEN_CAP)
24
+ return raw;
25
+ const ELLIPSIS = '…';
26
+ const half = Math.max(0, Math.floor((COLOR_TOKEN_CAP - ELLIPSIS.length) / 2));
27
+ return `${raw.slice(0, half)}${ELLIPSIS}${raw.slice(raw.length - half)}`;
28
+ }
29
+ // ---- computed role ------------------------------------------------------
30
+ // The prototype captured `getAttribute('role')`, which is null on nearly
31
+ // every real element, so the identity ladder's strongest rung was dead on
32
+ // arrival. This is the implicit-role mapping that fixes it.
33
+ // biome-ignore format: a lookup table reads as a table; one tag per line is 37
34
+ // lines of noise for the same information.
35
+ const IMPLICIT_ROLE = {
36
+ ARTICLE: 'article', ASIDE: 'complementary', BUTTON: 'button', DIALOG: 'dialog',
37
+ FIELDSET: 'group', FIGURE: 'figure', FOOTER: 'contentinfo', FORM: 'form',
38
+ H1: 'heading', H2: 'heading', H3: 'heading', H4: 'heading', H5: 'heading',
39
+ H6: 'heading', HEADER: 'banner', HR: 'separator', IMG: 'img', LI: 'listitem',
40
+ MAIN: 'main', NAV: 'navigation', OL: 'list', OPTION: 'option', OUTPUT: 'status',
41
+ P: 'paragraph', PROGRESS: 'progressbar', SECTION: 'region', SELECT: 'combobox',
42
+ SUMMARY: 'button', TABLE: 'table', TBODY: 'rowgroup', TD: 'cell',
43
+ TEXTAREA: 'textbox', TFOOT: 'rowgroup', TH: 'columnheader', THEAD: 'rowgroup',
44
+ TR: 'row', UL: 'list',
45
+ };
46
+ // biome-ignore format: see above.
47
+ const INPUT_ROLE = {
48
+ button: 'button', checkbox: 'checkbox', email: 'textbox', image: 'button',
49
+ number: 'spinbutton', radio: 'radio', range: 'slider', reset: 'button',
50
+ search: 'searchbox', submit: 'button', tel: 'textbox', text: 'textbox',
51
+ url: 'textbox',
52
+ };
53
+ /** Roles whose accessible name comes from their own content. */
54
+ // biome-ignore format: see above.
55
+ const NAME_FROM_CONTENT = new Set([
56
+ 'button', 'cell', 'checkbox', 'columnheader', 'gridcell', 'heading', 'link',
57
+ 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row',
58
+ 'rowheader', 'switch', 'tab', 'tooltip', 'treeitem',
59
+ ]);
60
+ function computedRole(el) {
61
+ const explicit = (el.getAttribute('role') ?? '').trim().split(/\s+/)[0];
62
+ if (explicit)
63
+ return explicit;
64
+ const tag = el.tagName.toUpperCase();
65
+ if (tag === 'A' || tag === 'AREA')
66
+ return el.hasAttribute('href') ? 'link' : null;
67
+ if (tag === 'INPUT') {
68
+ const type = (el.getAttribute('type') ?? 'text').toLowerCase();
69
+ return INPUT_ROLE[type] ?? 'textbox';
70
+ }
71
+ // `header` and `footer` are landmarks only at the top level.
72
+ if ((tag === 'HEADER' || tag === 'FOOTER') && el.closest('article,aside,main,nav,section')) {
73
+ return null;
74
+ }
75
+ // `section` is a region only when it has an accessible name.
76
+ if (tag === 'SECTION' &&
77
+ !el.getAttribute('aria-label') &&
78
+ !el.getAttribute('aria-labelledby')) {
79
+ return null;
80
+ }
81
+ return IMPLICIT_ROLE[tag] ?? null;
82
+ }
83
+ function trimName(value) {
84
+ const text = (value ?? '').replace(/\s+/g, ' ').trim();
85
+ return text ? text.slice(0, NAME_LENGTH) : null;
86
+ }
87
+ function accessibleName(el, role) {
88
+ const label = trimName(el.getAttribute('aria-label'));
89
+ if (label)
90
+ return label;
91
+ const labelledby = el.getAttribute('aria-labelledby');
92
+ if (labelledby) {
93
+ const parts = [];
94
+ for (const id of labelledby.split(/\s+/)) {
95
+ parts.push(document.getElementById(id)?.textContent ?? '');
96
+ }
97
+ const joined = trimName(parts.join(' '));
98
+ if (joined)
99
+ return joined;
100
+ }
101
+ const tag = el.tagName.toUpperCase();
102
+ if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
103
+ if (el.id) {
104
+ const forLabel = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
105
+ const text = trimName(forLabel?.textContent);
106
+ if (text)
107
+ return text;
108
+ }
109
+ const wrapping = trimName(el.closest('label')?.textContent);
110
+ if (wrapping)
111
+ return wrapping;
112
+ const placeholder = trimName(el.getAttribute('placeholder'));
113
+ if (placeholder)
114
+ return placeholder;
115
+ }
116
+ if (tag === 'IMG')
117
+ return trimName(el.getAttribute('alt'));
118
+ if (role && NAME_FROM_CONTENT.has(role)) {
119
+ const text = trimName(el.textContent);
120
+ if (text)
121
+ return text;
122
+ }
123
+ return trimName(el.getAttribute('title'));
124
+ }
125
+ // ---- geometry / style ---------------------------------------------------
126
+ function childrenOf(el) {
127
+ const result = [];
128
+ for (let i = 0; i < el.children.length; i++) {
129
+ const child = el.children[i];
130
+ if (child)
131
+ result.push(child);
132
+ }
133
+ return result;
134
+ }
135
+ function boxSides(cs, prefix) {
136
+ const suffix = prefix === 'border' ? 'Width' : '';
137
+ return {
138
+ top: Number.parseFloat(cs.getPropertyValue(`${prefix}-top${suffix}`)) || 0,
139
+ right: Number.parseFloat(cs.getPropertyValue(`${prefix}-right${suffix}`)) || 0,
140
+ bottom: Number.parseFloat(cs.getPropertyValue(`${prefix}-bottom${suffix}`)) || 0,
141
+ left: Number.parseFloat(cs.getPropertyValue(`${prefix}-left${suffix}`)) || 0,
142
+ };
143
+ }
144
+ function headingLevel(el) {
145
+ const ariaLevel = el.getAttribute('aria-level');
146
+ if (ariaLevel && Number.isFinite(Number(ariaLevel)))
147
+ return Number(ariaLevel);
148
+ const match = /^H([1-6])$/.exec(el.tagName);
149
+ return match?.[1] ? Number(match[1]) : null;
150
+ }
151
+ function ownText(el) {
152
+ let text = '';
153
+ for (const node of el.childNodes) {
154
+ if (node.nodeType === 3 /* TEXT_NODE */)
155
+ text += node.textContent ?? '';
156
+ }
157
+ return text.replace(/\s+/g, ' ').trim().slice(0, TEXT_PREVIEW_LENGTH);
158
+ }
159
+ const elements = [];
160
+ const byId = new Map();
161
+ // The DOM element behind each captured record, kept only long enough for
162
+ // the second, post-walk pass below to hit-test it -- `visit` is a pre-order
163
+ // walk, so an *unrelated* overlay elsewhere in the document (a modal
164
+ // appended near the end of `<body>`, positioned over content captured
165
+ // earlier) would not have an id yet if this ran inline during the walk.
166
+ const domById = new Map();
167
+ let nextId = 0;
168
+ let truncated = false;
169
+ // `NotMeasured` counters. See the doc comment on the type: this walk never
170
+ // enters a shadow root or an iframe document, so these are the only record
171
+ // of what it walked past.
172
+ let shadowRootsOpen = 0;
173
+ let iframesSameOrigin = 0;
174
+ let iframesUnknownOrigin = 0;
175
+ function visit(el, parentId) {
176
+ if (elements.length >= MAX_ELEMENTS) {
177
+ truncated = true;
178
+ return;
179
+ }
180
+ // SVG-namespace elements report a lowercase `tagName`, unlike HTML
181
+ // elements which are always uppercase — so this comparison must normalize
182
+ // case or the skip never fires for SVG internals.
183
+ const tagName = el.tagName.toUpperCase();
184
+ if (SKIPPED_TAGS.has(tagName))
185
+ return;
186
+ // `NotMeasured`: this walk reads `el.children`, which is light-DOM only --
187
+ // it never descends into a shadow root or an iframe document, so this is
188
+ // the only place either is ever seen at all.
189
+ //
190
+ // An *open* shadow root is detectable after the fact: `el.shadowRoot` is
191
+ // the live reference `attachShadow({mode:'open'})` returned. A *closed*
192
+ // one returns `null` from that same property -- indistinguishable from
193
+ // "no shadow root here" -- so closed roots stay uncounted; see the
194
+ // `NotMeasured` doc comment for why the header reports that as 'unknown'
195
+ // rather than guessing zero.
196
+ if (el.shadowRoot)
197
+ shadowRootsOpen++;
198
+ if (tagName === 'IFRAME') {
199
+ // `contentDocument` is `null` and does not throw whenever this document
200
+ // cannot read the frame's document -- but that is not only true
201
+ // cross-origin. It is also `null` for a same-origin frame on `file://`
202
+ // (no origin to compare against), a `sandbox`-ed frame missing
203
+ // `allow-same-origin` (an opaque origin, even when the `src` is the same
204
+ // server), and a frame that has not navigated yet. All four look
205
+ // identical from here, so a `null` reading can only ever mean "this walk
206
+ // cannot resolve the origin", never a positive cross-origin
207
+ // classification -- that is what `unknownOrigin` is for. A non-null
208
+ // `contentDocument` *is* a reliable same-origin signal, so that half
209
+ // stays a real count.
210
+ if (el.contentDocument !== null)
211
+ iframesSameOrigin++;
212
+ else
213
+ iframesUnknownOrigin++;
214
+ }
215
+ const cs = getComputedStyle(el);
216
+ const rect = el.getBoundingClientRect();
217
+ const opacity = Number.parseFloat(cs.getPropertyValue('opacity'));
218
+ const display = cs.getPropertyValue('display');
219
+ const visibility = cs.getPropertyValue('visibility');
220
+ const visible = display !== 'none' &&
221
+ visibility !== 'hidden' &&
222
+ !(Number.isFinite(opacity) && opacity === 0) &&
223
+ rect.width > 0 &&
224
+ rect.height > 0;
225
+ const id = `e${nextId}`;
226
+ nextId++;
227
+ let testId = null;
228
+ for (const attr of TESTID_ATTRS) {
229
+ const value = el.getAttribute(attr);
230
+ if (value) {
231
+ testId = value;
232
+ break;
233
+ }
234
+ }
235
+ const role = computedRole(el);
236
+ const record = {
237
+ id,
238
+ tag: el.tagName.toLowerCase(),
239
+ testId,
240
+ domId: el.id || null,
241
+ role,
242
+ name: accessibleName(el, role),
243
+ className: el.getAttribute('class') ?? '',
244
+ headingLevel: headingLevel(el),
245
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
246
+ scrollWidth: el.scrollWidth,
247
+ scrollHeight: el.scrollHeight,
248
+ clientWidth: el.clientWidth,
249
+ clientHeight: el.clientHeight,
250
+ overflowX: cs.getPropertyValue('overflow-x'),
251
+ overflowY: cs.getPropertyValue('overflow-y'),
252
+ position: cs.getPropertyValue('position'),
253
+ display,
254
+ visibility,
255
+ opacity: Number.isFinite(opacity) ? opacity : 1,
256
+ margin: boxSides(cs, 'margin'),
257
+ padding: boxSides(cs, 'padding'),
258
+ gap: {
259
+ row: Number.parseFloat(cs.getPropertyValue('row-gap')) || 0,
260
+ column: Number.parseFloat(cs.getPropertyValue('column-gap')) || 0,
261
+ },
262
+ border: boxSides(cs, 'border'),
263
+ color: cs.getPropertyValue('color'),
264
+ backgroundColor: cs.getPropertyValue('background-color'),
265
+ backgroundImage: capBackgroundImage(cs.getPropertyValue('background-image')),
266
+ textShadow: cs.getPropertyValue('text-shadow').slice(0, COLOR_TOKEN_CAP),
267
+ textStrokeWidth: cs.getPropertyValue('-webkit-text-stroke-width'),
268
+ textStrokeColor: cs.getPropertyValue('-webkit-text-stroke-color'),
269
+ fontSize: cs.getPropertyValue('font-size'),
270
+ fontWeight: Number.parseFloat(cs.getPropertyValue('font-weight')) || 400,
271
+ textAlign: cs.getPropertyValue('text-align'),
272
+ flexDirection: cs.getPropertyValue('flex-direction'),
273
+ gridTemplateColumns: cs.getPropertyValue('grid-template-columns'),
274
+ textOverflow: cs.getPropertyValue('text-overflow'),
275
+ whiteSpace: cs.getPropertyValue('white-space'),
276
+ overflowWrap: cs.getPropertyValue('overflow-wrap'),
277
+ text: ownText(el),
278
+ parentId,
279
+ childIds: [],
280
+ visible,
281
+ ariaExpanded: el.getAttribute('aria-expanded'),
282
+ // Filled in by the second pass below, once every id in the document
283
+ // exists to resolve a hit-test against -- see `domById`'s doc comment.
284
+ hitStackIds: null,
285
+ paintOrder: null,
286
+ };
287
+ elements.push(record);
288
+ byId.set(id, record);
289
+ domById.set(id, el);
290
+ if (parentId)
291
+ byId.get(parentId)?.childIds.push(id);
292
+ for (const child of childrenOf(el)) {
293
+ if (elements.length >= MAX_ELEMENTS) {
294
+ truncated = true;
295
+ break;
296
+ }
297
+ visit(child, id);
298
+ }
299
+ }
300
+ visit(document.body, null);
301
+ // The overlap signal `snapshot/color.ts` needs to tell "a real ancestor
302
+ // backdrop" from "some unrelated element painted over or behind this
303
+ // text" -- see `ElementRecord.hitStackIds`'s doc comment for why this is a
304
+ // second pass rather than done inline above. Gated on the same "visible,
305
+ // has its own text" condition the color fact itself uses (§4 of
306
+ // `docs/notes/03-color.md`): no text, no ink-on-backdrop question to probe,
307
+ // so no reason to pay for a hit test.
308
+ const reverseId = new Map();
309
+ for (const [id, domEl] of domById)
310
+ reverseId.set(domEl, id);
311
+ for (const [id, domEl] of domById) {
312
+ const record = byId.get(id);
313
+ if (!record?.visible || !record.text)
314
+ continue;
315
+ const rect = domEl.getBoundingClientRect();
316
+ const cx = rect.x + rect.width / 2;
317
+ const cy = rect.y + rect.height / 2;
318
+ const stack = document.elementsFromPoint(cx, cy);
319
+ const ids = [];
320
+ for (const hit of stack) {
321
+ const hitId = reverseId.get(hit);
322
+ // An entry this capture never assigned an id to (an `SVG` internal, a
323
+ // node past the element cap) carries no signal `color.ts` can act on --
324
+ // dropped rather than guessed at, the same discipline `NotMeasured`
325
+ // uses for what a capture walked past without measuring.
326
+ if (hitId)
327
+ ids.push(hitId);
328
+ }
329
+ record.hitStackIds = ids;
330
+ }
331
+ // `<html>` (`documentElement`) is never part of `elements` -- this walk
332
+ // starts at `<body>` (see this function's own `visit(document.body, null)`
333
+ // call below) -- so without reading it here, a compositing walk that runs
334
+ // off the top of the ancestor chain has no way to know the browser's real
335
+ // default canvas fill and no honest option but to guess (A2). See
336
+ // `DocumentBackground`'s doc comment.
337
+ const htmlStyle = getComputedStyle(document.documentElement);
338
+ return {
339
+ elements,
340
+ truncated,
341
+ viewport: { width: window.innerWidth, height: window.innerHeight },
342
+ notMeasured: {
343
+ shadowRoots: { open: shadowRootsOpen, closed: 'unknown' },
344
+ iframes: {
345
+ sameOrigin: iframesSameOrigin,
346
+ // This walk has no positive signal for cross-origin -- see the check
347
+ // above -- so it never asserts one. An unreachable frame is always
348
+ // counted as `unknownOrigin`, never guessed into `crossOrigin`.
349
+ crossOrigin: 0,
350
+ unknownOrigin: iframesUnknownOrigin,
351
+ },
352
+ },
353
+ documentBackground: {
354
+ backgroundColor: htmlStyle.getPropertyValue('background-color'),
355
+ colorScheme: htmlStyle.getPropertyValue('color-scheme'),
356
+ },
357
+ };
358
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `collectSnapshot` is a function reference, not a call site — the capture path
3
+ * passes it straight to `page.evaluate(collectSnapshot)`. Its body executes
4
+ * inside the page, so it must not close over or import anything that only
5
+ * exists on the Node side; only type-only imports (erased at compile time) are
6
+ * safe, and every constant the walk needs lives inside the function body.
7
+ *
8
+ * This is the **portable** capture path: it works in Chromium, Firefox and
9
+ * WebKit alike. The CDP fast path in `cdp.ts` is ~6x quicker and returns facts
10
+ * this cannot (paint order, real line boxes, blended backgrounds) but is
11
+ * Chromium-only and experimental, so this one is not going away.
12
+ *
13
+ * Ported from the prototype's `snapshot/collect.ts`, with the computed role and
14
+ * accessible name that step 2 proved rung 3 cannot work without. Clipping is
15
+ * deliberately *not* computed here — see `clip.ts`.
16
+ */
17
+ import type { Snapshot } from "./types.cjs";
18
+ export declare function collectSnapshot(): Snapshot;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `collectSnapshot` is a function reference, not a call site — the capture path
3
+ * passes it straight to `page.evaluate(collectSnapshot)`. Its body executes
4
+ * inside the page, so it must not close over or import anything that only
5
+ * exists on the Node side; only type-only imports (erased at compile time) are
6
+ * safe, and every constant the walk needs lives inside the function body.
7
+ *
8
+ * This is the **portable** capture path: it works in Chromium, Firefox and
9
+ * WebKit alike. The CDP fast path in `cdp.ts` is ~6x quicker and returns facts
10
+ * this cannot (paint order, real line boxes, blended backgrounds) but is
11
+ * Chromium-only and experimental, so this one is not going away.
12
+ *
13
+ * Ported from the prototype's `snapshot/collect.ts`, with the computed role and
14
+ * accessible name that step 2 proved rung 3 cannot work without. Clipping is
15
+ * deliberately *not* computed here — see `clip.ts`.
16
+ */
17
+ import type { Snapshot } from "./types.js";
18
+ export declare function collectSnapshot(): Snapshot;