@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,242 @@
1
+ /**
2
+ * `ElementRecord.tag` is always `el.tagName.toLowerCase()` (see
3
+ * `snapshot/collect.ts`) — HTML and SVG tags are stored in the same case,
4
+ * so there is no "starts lowercase" signal left in the data to check by the
5
+ * time it reaches this module (the step-6 fix already drops the `<svg>`
6
+ * subtree, root included, at collection time). This set is the practical
7
+ * replacement: a fixed list of SVG-only tag names, kept as a defensive net
8
+ * in case `collectSnapshot`'s `SKIPPED_TAGS` ever changes to stop dropping
9
+ * the `<svg>` root itself. `svg` is deliberately absent — the root svg
10
+ * element is a leaf "icon" box per the classification pass below, not
11
+ * dropped.
12
+ */
13
+ const SVG_INTERNAL_TAGS = new Set([
14
+ 'g',
15
+ 'path',
16
+ 'rect',
17
+ 'circle',
18
+ 'ellipse',
19
+ 'line',
20
+ 'polyline',
21
+ 'polygon',
22
+ 'defs',
23
+ 'clippath',
24
+ 'lineargradient',
25
+ 'radialgradient',
26
+ 'stop',
27
+ 'use',
28
+ 'symbol',
29
+ 'mask',
30
+ 'pattern',
31
+ 'text',
32
+ 'tspan',
33
+ 'foreignobject',
34
+ ]);
35
+ const REPLACED_TAGS = new Set(['img', 'input', 'textarea', 'select', 'button']);
36
+ /** ~1px tolerance for the "rect equals parent's content box" collapse
37
+ * check — real layouts carry subpixel rounding. */
38
+ const COLLAPSE_TOLERANCE = 1;
39
+ function contentBox(el) {
40
+ return {
41
+ x: el.rect.x + el.padding.left + el.border.left,
42
+ y: el.rect.y + el.padding.top + el.border.top,
43
+ width: el.rect.width - el.padding.left - el.padding.right - el.border.left - el.border.right,
44
+ height: el.rect.height - el.padding.top - el.padding.bottom - el.border.top - el.border.bottom,
45
+ };
46
+ }
47
+ function rectsMatch(a, b, tolerance = COLLAPSE_TOLERANCE) {
48
+ return (Math.abs(a.x - b.x) <= tolerance &&
49
+ Math.abs(a.y - b.y) <= tolerance &&
50
+ Math.abs(a.width - b.width) <= tolerance &&
51
+ Math.abs(a.height - b.height) <= tolerance);
52
+ }
53
+ function hasVisibleBorder(el) {
54
+ return el.border.top > 0 || el.border.right > 0 || el.border.bottom > 0 || el.border.left > 0;
55
+ }
56
+ function sameBorder(a, b) {
57
+ return (a.border.top === b.border.top &&
58
+ a.border.right === b.border.right &&
59
+ a.border.bottom === b.border.bottom &&
60
+ a.border.left === b.border.left);
61
+ }
62
+ /** `rgba(r, g, b, 0)` / `transparent` are the only two forms
63
+ * `getComputedStyle` returns for "no background" — anything else paints
64
+ * something, even a translucent color. */
65
+ function hasVisibleBackground(el) {
66
+ const bg = el.backgroundColor.trim();
67
+ if (bg === '' || bg === 'transparent')
68
+ return false;
69
+ const alphaMatch = /rgba?\([^)]*,\s*([\d.]+)\s*\)/.exec(bg);
70
+ if (alphaMatch?.[1] !== undefined && Number.parseFloat(alphaMatch[1]) === 0)
71
+ return false;
72
+ return true;
73
+ }
74
+ /**
75
+ * Leaf if: has non-empty own text, is a replaced element or an SVG root, or
76
+ * carries a visible border/background distinct from its parent's (a
77
+ * decorated box like a card or a chip is treated as one atomic layout unit
78
+ * — its internal structure isn't relevant to gap/overlap/uniformity
79
+ * analysis at the level above it). `parent` is `null` only at a document
80
+ * root, where "distinct from parent" degrades to "has one at all".
81
+ */
82
+ function isLeaf(el, parent) {
83
+ if (el.text.trim().length > 0)
84
+ return true;
85
+ if (REPLACED_TAGS.has(el.tag))
86
+ return true;
87
+ if (el.tag === 'svg')
88
+ return true;
89
+ const ownBorder = hasVisibleBorder(el);
90
+ const ownBackground = hasVisibleBackground(el);
91
+ if (!parent)
92
+ return ownBorder || ownBackground;
93
+ if (ownBorder && !sameBorder(el, parent))
94
+ return true;
95
+ if (ownBackground && el.backgroundColor !== parent.backgroundColor)
96
+ return true;
97
+ return false;
98
+ }
99
+ /**
100
+ * Every flex container reported `row` until this took `flex-direction` into
101
+ * account, and `column` -- a value the grammar, the parser and the `Flow` type
102
+ * all carried -- was unreachable.
103
+ *
104
+ * It was not only a mislabelled token. The digest measures a child's gap along
105
+ * its parent's flow axis, so every `flex-direction: column` container had its
106
+ * children measured *horizontally*: a stack of full-width rows reported `→0`
107
+ * between each pair instead of the vertical gap that is actually there.
108
+ */
109
+ function flowFromDisplay(display, flexDirection) {
110
+ if (display.includes('grid'))
111
+ return 'grid';
112
+ if (display.includes('flex')) {
113
+ return flexDirection.startsWith('column') ? 'column' : 'row';
114
+ }
115
+ return 'none';
116
+ }
117
+ export function buildLayoutTree(elements, options = {}) {
118
+ const byId = new Map(elements.map((el) => [el.id, el]));
119
+ const keepIds = options.keepIds;
120
+ let svgInternal = 0;
121
+ let invisible = 0;
122
+ let wrappersFolded = 0;
123
+ function keep(el) {
124
+ // `keepIds` has to be the first check, not the second: an SVG `<path>`
125
+ // (or any other tag in `SVG_INTERNAL_TAGS`) that `factsFor` found
126
+ // something to say about -- most commonly `clipped`, on an icon
127
+ // partially hidden by an ancestor's `overflow` -- used to be dropped
128
+ // here before this function ever looked at `keepIds`, because the
129
+ // SVG-internal check ran first and returned unconditionally. Checking
130
+ // `keepIds` first makes the override total, the same way the doc
131
+ // comment on `BuildLayoutTreeOptions.keepIds` already promises ("the
132
+ // digest passes every element carrying a fact") rather than true only
133
+ // for the one reason (`!el.visible`) that happened to be checked after
134
+ // it.
135
+ if (keepIds?.has(el.id))
136
+ return true;
137
+ if (SVG_INTERNAL_TAGS.has(el.tag)) {
138
+ svgInternal++;
139
+ return false;
140
+ }
141
+ if (!el.visible) {
142
+ invisible++;
143
+ return false;
144
+ }
145
+ return true;
146
+ }
147
+ // Pass 1: filter + reparent. A dropped element's surviving children are
148
+ // spliced up to its nearest surviving ancestor's child list — dropping a
149
+ // node never deletes a subtree, only the one uninformative box.
150
+ function buildChildren(childIds) {
151
+ const result = [];
152
+ for (const id of childIds) {
153
+ const el = byId.get(id);
154
+ if (!el)
155
+ continue;
156
+ if (keep(el)) {
157
+ result.push({ el, children: buildChildren(el.childIds) });
158
+ }
159
+ else {
160
+ result.push(...buildChildren(el.childIds));
161
+ }
162
+ }
163
+ return result;
164
+ }
165
+ // Pass 2 (collapse) + pass 3 (classify), combined per node bottom-up: a
166
+ // node's children must already be finalized before we can test whether
167
+ // this node has exactly one surviving child whose rect matches this
168
+ // node's own content box.
169
+ function build(raw, parent) {
170
+ let children = raw.children.map((child) => build(child, raw.el));
171
+ let wrapped = 0;
172
+ // The representative element for identity/classification purposes —
173
+ // starts as this node's own element, and becomes the innermost folded
174
+ // child's element as wrapper levels collapse into it. The box test
175
+ // itself always uses `raw.el`'s own content box (fixed for this node
176
+ // regardless of how many levels have folded in) — that's the box every
177
+ // absorbed child proved it matched.
178
+ let inner = raw.el;
179
+ const box = contentBox(raw.el);
180
+ while (children.length === 1) {
181
+ // A wrapper that itself carries a fact (`keepIds` -- the same set pass
182
+ // 1's `keep()` checks above, threaded in from `renderDigest` as every
183
+ // id `factsFor` found something to say about) must not have its
184
+ // identity silently swapped out for its child's here. Before this
185
+ // check, `inner`'s reassignment below was unconditional: the wrapper's
186
+ // own id -- and with it, the only thing that could carry a fact
187
+ // computed for the wrapper itself, most commonly `<body>`'s own
188
+ // `overflow-y` when `<body>` has exactly one child filling it -- simply
189
+ // stopped being reachable from any `LayoutNode` in the tree, with no
190
+ // line anywhere disclosing that it happened. `keepIds` already exists
191
+ // for exactly this guarantee ("the digest passes every element
192
+ // carrying a fact") but was only ever consulted by pass 1's filter;
193
+ // this is the same guarantee at the one other point in the pipeline
194
+ // that can erase an element's identity. Stopping the fold at this
195
+ // level -- rather than hoisting the fact onto the surviving child, or
196
+ // collapsing anyway with a disclosure -- keeps the fix inside this
197
+ // module and inside `keepIds`'s existing contract: the wrapper gets its
198
+ // own line, exactly the "direct" route every other fact-bearing
199
+ // element already uses, so no new disclosure format is needed and no
200
+ // fact ever needs re-attributing to an id that didn't measure it.
201
+ if (keepIds?.has(inner.id))
202
+ break;
203
+ const only = children[0];
204
+ if (only && rectsMatch(only.rect, box)) {
205
+ const onlyEl = byId.get(only.elementId);
206
+ if (!onlyEl)
207
+ break;
208
+ wrapped += 1 + only.wrapped;
209
+ wrappersFolded += 1;
210
+ inner = onlyEl;
211
+ children = only.children;
212
+ }
213
+ else {
214
+ break;
215
+ }
216
+ }
217
+ // `kind` is classification metadata only — it does not prune `children`.
218
+ // A card with a background distinct from its parent is a `leaf` in the
219
+ // sense that a digest/query consumer can treat it as one atomic visual
220
+ // unit, but `overlaps`/`siblingGaps`/`siblingUniformity` still need its
221
+ // real children (e.g. an absolutely-positioned badge nested inside a
222
+ // colored card) to be present in the tree to find them.
223
+ const leaf = isLeaf(inner, parent);
224
+ return {
225
+ elementId: inner.id,
226
+ tag: inner.tag,
227
+ kind: leaf ? 'leaf' : 'container',
228
+ flow: flowFromDisplay(inner.display, inner.flexDirection),
229
+ position: inner.position,
230
+ wrapped,
231
+ rect: inner.rect,
232
+ children,
233
+ };
234
+ }
235
+ const rootIds = elements.filter((el) => el.parentId === null).map((el) => el.id);
236
+ const rawRoots = buildChildren(rootIds);
237
+ const tree = rawRoots.map((raw) => build(raw, null));
238
+ return {
239
+ tree,
240
+ dropped: { svgInternal, invisible, wrappers: wrappersFolded },
241
+ };
242
+ }
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nodesAtPoint = nodesAtPoint;
4
+ exports.nodesInRegion = nodesInRegion;
5
+ /**
6
+ * Point/region lookup over an already-built layout tree — the geometric
7
+ * half of `query`'s `at`/`within` verbs (Step 17). Pure, tree-shaped, no
8
+ * `ElementRecord`, same boundary rules as the rest of `src/analysis/`. This
9
+ * is the shared helper both verbs dispatch through in `query.ts`, so a point
10
+ * query and a region query use the exact same rect-arithmetic and traversal
11
+ * rather than two hand-rolled walks that could quietly diverge.
12
+ *
13
+ * Both functions identify nodes by rect, not by `selector` — the join key
14
+ * `digest/render.ts#findBySelector` is stuck with and the one Step 16 found
15
+ * is not globally unique on real pages (duplicate `nth-of-type` selectors
16
+ * under a repeated table structure). A point or region is geometry from the
17
+ * start, so there is no selector-collision risk here at all.
18
+ */
19
+ const geometry_js_1 = require("./geometry.cjs");
20
+ /** Flattens the tree into every node at every depth, root-first, parent
21
+ * before children — same shape `distances.ts#flattenWithLineage` builds,
22
+ * reimplemented here since this module only needs the flat node list, not
23
+ * lineage. */
24
+ function flatten(tree) {
25
+ const out = [];
26
+ function visit(node) {
27
+ out.push(node);
28
+ for (const child of node.children)
29
+ visit(child);
30
+ }
31
+ for (const root of tree)
32
+ visit(root);
33
+ return out;
34
+ }
35
+ /** True if the point `(x, y)` falls within `rect`, edges inclusive (a point
36
+ * exactly on a border counts as contained — the same "touching counts"
37
+ * convention `geometry.ts#contains` uses for rect-in-rect). */
38
+ function containsPoint(rect, x, y) {
39
+ return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
40
+ }
41
+ /**
42
+ * Every `LayoutNode` (at any depth) whose rect contains the point `(x, y)`,
43
+ * sorted most-specific first (ascending area — the smallest containing rect,
44
+ * typically a leaf, comes before its containers, all the way out to a page
45
+ * root). Empty array if the point is outside every node, including the page
46
+ * roots. Callers wanting a shorter "leaf plus a bit of context" list should
47
+ * slice the result themselves — this returns every ancestor rank, uncapped.
48
+ */
49
+ function nodesAtPoint(tree, x, y) {
50
+ return flatten(tree)
51
+ .filter((node) => containsPoint(node.rect, x, y))
52
+ .sort((a, b) => (0, geometry_js_1.area)(a.rect) - (0, geometry_js_1.area)(b.rect));
53
+ }
54
+ /**
55
+ * Every `LayoutNode` (at any depth) whose rect intersects `region` —
56
+ * partial overlap counts, not just full containment (a region query is
57
+ * meant to catch things partially inside it too; see
58
+ * `geometry.ts#intersect`, which is the same primitive `overlap`/`query
59
+ * overlap` already use). Sorted by intersection area descending
60
+ * (most-overlapping first). Empty array if nothing overlaps.
61
+ */
62
+ function nodesInRegion(tree, region) {
63
+ const matches = [];
64
+ for (const node of flatten(tree)) {
65
+ const overlap = (0, geometry_js_1.intersect)(node.rect, region);
66
+ if (overlap)
67
+ matches.push({ node, intersectionArea: (0, geometry_js_1.area)(overlap) });
68
+ }
69
+ matches.sort((a, b) => b.intersectionArea - a.intersectionArea);
70
+ return matches;
71
+ }
@@ -0,0 +1,25 @@
1
+ import type { LayoutNode, Rect } from "./types.cjs";
2
+ /**
3
+ * Every `LayoutNode` (at any depth) whose rect contains the point `(x, y)`,
4
+ * sorted most-specific first (ascending area — the smallest containing rect,
5
+ * typically a leaf, comes before its containers, all the way out to a page
6
+ * root). Empty array if the point is outside every node, including the page
7
+ * roots. Callers wanting a shorter "leaf plus a bit of context" list should
8
+ * slice the result themselves — this returns every ancestor rank, uncapped.
9
+ */
10
+ export declare function nodesAtPoint(tree: LayoutNode[], x: number, y: number): LayoutNode[];
11
+ export interface RegionMatch {
12
+ node: LayoutNode;
13
+ /** Area (px²) of the overlap between `node.rect` and the queried region —
14
+ * what the result is sorted by (descending, most-overlapping first). */
15
+ intersectionArea: number;
16
+ }
17
+ /**
18
+ * Every `LayoutNode` (at any depth) whose rect intersects `region` —
19
+ * partial overlap counts, not just full containment (a region query is
20
+ * meant to catch things partially inside it too; see
21
+ * `geometry.ts#intersect`, which is the same primitive `overlap`/`query
22
+ * overlap` already use). Sorted by intersection area descending
23
+ * (most-overlapping first). Empty array if nothing overlaps.
24
+ */
25
+ export declare function nodesInRegion(tree: LayoutNode[], region: Rect): RegionMatch[];
@@ -0,0 +1,25 @@
1
+ import type { LayoutNode, Rect } from "./types.js";
2
+ /**
3
+ * Every `LayoutNode` (at any depth) whose rect contains the point `(x, y)`,
4
+ * sorted most-specific first (ascending area — the smallest containing rect,
5
+ * typically a leaf, comes before its containers, all the way out to a page
6
+ * root). Empty array if the point is outside every node, including the page
7
+ * roots. Callers wanting a shorter "leaf plus a bit of context" list should
8
+ * slice the result themselves — this returns every ancestor rank, uncapped.
9
+ */
10
+ export declare function nodesAtPoint(tree: LayoutNode[], x: number, y: number): LayoutNode[];
11
+ export interface RegionMatch {
12
+ node: LayoutNode;
13
+ /** Area (px²) of the overlap between `node.rect` and the queried region —
14
+ * what the result is sorted by (descending, most-overlapping first). */
15
+ intersectionArea: number;
16
+ }
17
+ /**
18
+ * Every `LayoutNode` (at any depth) whose rect intersects `region` —
19
+ * partial overlap counts, not just full containment (a region query is
20
+ * meant to catch things partially inside it too; see
21
+ * `geometry.ts#intersect`, which is the same primitive `overlap`/`query
22
+ * overlap` already use). Sorted by intersection area descending
23
+ * (most-overlapping first). Empty array if nothing overlaps.
24
+ */
25
+ export declare function nodesInRegion(tree: LayoutNode[], region: Rect): RegionMatch[];
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Point/region lookup over an already-built layout tree — the geometric
3
+ * half of `query`'s `at`/`within` verbs (Step 17). Pure, tree-shaped, no
4
+ * `ElementRecord`, same boundary rules as the rest of `src/analysis/`. This
5
+ * is the shared helper both verbs dispatch through in `query.ts`, so a point
6
+ * query and a region query use the exact same rect-arithmetic and traversal
7
+ * rather than two hand-rolled walks that could quietly diverge.
8
+ *
9
+ * Both functions identify nodes by rect, not by `selector` — the join key
10
+ * `digest/render.ts#findBySelector` is stuck with and the one Step 16 found
11
+ * is not globally unique on real pages (duplicate `nth-of-type` selectors
12
+ * under a repeated table structure). A point or region is geometry from the
13
+ * start, so there is no selector-collision risk here at all.
14
+ */
15
+ import { area, intersect } from "./geometry.js";
16
+ /** Flattens the tree into every node at every depth, root-first, parent
17
+ * before children — same shape `distances.ts#flattenWithLineage` builds,
18
+ * reimplemented here since this module only needs the flat node list, not
19
+ * lineage. */
20
+ function flatten(tree) {
21
+ const out = [];
22
+ function visit(node) {
23
+ out.push(node);
24
+ for (const child of node.children)
25
+ visit(child);
26
+ }
27
+ for (const root of tree)
28
+ visit(root);
29
+ return out;
30
+ }
31
+ /** True if the point `(x, y)` falls within `rect`, edges inclusive (a point
32
+ * exactly on a border counts as contained — the same "touching counts"
33
+ * convention `geometry.ts#contains` uses for rect-in-rect). */
34
+ function containsPoint(rect, x, y) {
35
+ return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
36
+ }
37
+ /**
38
+ * Every `LayoutNode` (at any depth) whose rect contains the point `(x, y)`,
39
+ * sorted most-specific first (ascending area — the smallest containing rect,
40
+ * typically a leaf, comes before its containers, all the way out to a page
41
+ * root). Empty array if the point is outside every node, including the page
42
+ * roots. Callers wanting a shorter "leaf plus a bit of context" list should
43
+ * slice the result themselves — this returns every ancestor rank, uncapped.
44
+ */
45
+ export function nodesAtPoint(tree, x, y) {
46
+ return flatten(tree)
47
+ .filter((node) => containsPoint(node.rect, x, y))
48
+ .sort((a, b) => area(a.rect) - area(b.rect));
49
+ }
50
+ /**
51
+ * Every `LayoutNode` (at any depth) whose rect intersects `region` —
52
+ * partial overlap counts, not just full containment (a region query is
53
+ * meant to catch things partially inside it too; see
54
+ * `geometry.ts#intersect`, which is the same primitive `overlap`/`query
55
+ * overlap` already use). Sorted by intersection area descending
56
+ * (most-overlapping first). Empty array if nothing overlaps.
57
+ */
58
+ export function nodesInRegion(tree, region) {
59
+ const matches = [];
60
+ for (const node of flatten(tree)) {
61
+ const overlap = intersect(node.rect, region);
62
+ if (overlap)
63
+ matches.push({ node, intersectionArea: area(overlap) });
64
+ }
65
+ matches.sort((a, b) => b.intersectionArea - a.intersectionArea);
66
+ return matches;
67
+ }
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ /**
3
+ * Geometry primitives. Deliberately structural and tree-unaware: every
4
+ * function in `geometry.ts` takes plain rects, so the collector, the digest,
5
+ * and the diff all share one arithmetic implementation rather than three.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Geometry primitives. Deliberately structural and tree-unaware: every
3
+ * function in `geometry.ts` takes plain rects, so the collector, the digest,
4
+ * and the diff all share one arithmetic implementation rather than three.
5
+ */
6
+ /** A CSS-pixel rectangle in viewport coordinates. */
7
+ export interface Rect {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ }
13
+ /** The four sides of a box property (`margin`, `padding`, `border`). */
14
+ export interface BoxSides {
15
+ top: number;
16
+ right: number;
17
+ bottom: number;
18
+ left: number;
19
+ }
20
+ export type Flow = 'row' | 'column' | 'grid' | 'none';
21
+ export type LayoutNodeKind = 'leaf' | 'container';
22
+ /**
23
+ * One node in the layout tree `buildLayoutTree` produces. `elementId`/`tag`/`rect`
24
+ * identify the *representative* underlying element — for a
25
+ * folded wrapper chain (see `wrapped`) that's the innermost element, since
26
+ * it carries the meaningful content (text, replaced-element type, or a
27
+ * distinct border/background); the outer wrapper's box is what proved the
28
+ * fold was safe (its content box matched), but it has nothing worth
29
+ * reporting on its own once absorbed.
30
+ */
31
+ export interface LayoutNode {
32
+ elementId: string;
33
+ tag: string;
34
+ kind: LayoutNodeKind;
35
+ flow: Flow;
36
+ /** Computed CSS `position` of the representative element (`static`,
37
+ * `relative`, `absolute`, `fixed`, `sticky`) — carried through from
38
+ * `ElementRecord.position` so `Analysis`-level geometry (`overlaps`,
39
+ * `proximities`) can exclude `fixed`/`sticky` chrome the same way
40
+ * `checks/overflow.ts#UNCONTAINED_POSITIONS` already does at the
41
+ * `ElementRecord` level. */
42
+ position: string;
43
+ /** Count of wrapper levels folded into this node (0 if none). */
44
+ wrapped: number;
45
+ rect: Rect;
46
+ children: LayoutNode[];
47
+ }
48
+ export interface LayoutTreeDropped {
49
+ svgInternal: number;
50
+ invisible: number;
51
+ wrappers: number;
52
+ }
53
+ export interface LayoutTreeResult {
54
+ tree: LayoutNode[];
55
+ dropped: LayoutTreeDropped;
56
+ }
57
+ export interface SiblingGapPair {
58
+ a: string;
59
+ b: string;
60
+ gap: number;
61
+ }
62
+ export interface SiblingGapGroup {
63
+ containerId: string;
64
+ axis: 'x' | 'y';
65
+ pairs: SiblingGapPair[];
66
+ }
67
+ /**
68
+ * `SiblingGapGroup` plus base-unit detection over the group's own gap
69
+ * values (`pairs[].gap`) — `null` unit means no candidate explained enough
70
+ * of the gaps (see `cluster.ts#detectBaseUnit`). This is what `Analysis.gaps`
71
+ * actually holds: extending `SiblingGapGroup` rather than a parallel array
72
+ * keyed by `containerId`, since every consumer that already has a gap group
73
+ * (the digest renderer, the future spacing check) needs the group's own
74
+ * spacing stats in the same place it reads `pairs`, and a parallel array
75
+ * would just be a second lookup by the same key for no benefit — see
76
+ * LOG.md step 9. `siblingGaps()` in `neighbors.ts` itself still returns the
77
+ * plain `SiblingGapGroup` (it has no candidates/tolerance to detect a base
78
+ * unit with); `analyze()` is what adds these fields on top.
79
+ */
80
+ export interface SpacingGapGroup extends SiblingGapGroup {
81
+ baseUnit: number | null;
82
+ onGridShare: number;
83
+ offGrid: number[];
84
+ distinct: Record<string, number>;
85
+ }
86
+ export interface OverlapPair {
87
+ a: string;
88
+ b: string;
89
+ area: number;
90
+ rect: Rect;
91
+ }
92
+ export interface ProximityPair {
93
+ a: string;
94
+ b: string;
95
+ edge: number;
96
+ dx: number;
97
+ dy: number;
98
+ }
99
+ export interface EdgeClusterSet {
100
+ left: Array<{
101
+ value: number;
102
+ count: number;
103
+ members: number[];
104
+ }>;
105
+ right: Array<{
106
+ value: number;
107
+ count: number;
108
+ members: number[];
109
+ }>;
110
+ top: Array<{
111
+ value: number;
112
+ count: number;
113
+ members: number[];
114
+ }>;
115
+ }
116
+ export interface NearMiss {
117
+ id: string;
118
+ axis: 'left' | 'right' | 'top';
119
+ actual: number;
120
+ nearestClusterValue: number;
121
+ delta: number;
122
+ /** Count of `nearestClusterValue`'s members that are container-scoped to
123
+ * this node — nearest common ancestor no more than `index.ts`'s
124
+ * `MAX_ANCESTOR_DISTANCE` combined steps away — not the cluster's raw
125
+ * page-wide `count` in `Analysis.edges`. `checkAlignment`'s
126
+ * `minClusterSize` gate reads this field instead of looking up
127
+ * `Analysis.edges`, so an unrelated page-wide cluster (large `count`,
128
+ * but zero members actually related to this node) can't pass the gate
129
+ * on size alone. See LOG.md Step 11b. */
130
+ scopedMemberCount: number;
131
+ }
132
+ export interface UniformityDeviant {
133
+ id: string;
134
+ reason: string;
135
+ }
136
+ export interface SiblingUniformityGroup {
137
+ containerId: string;
138
+ children: number;
139
+ medianWidth: number;
140
+ medianHeight: number;
141
+ medianGap: number;
142
+ deviants: UniformityDeviant[];
143
+ }