@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,68 @@
1
+ /**
2
+ * 1-D numeric clustering and spacing-grid detection — pure arithmetic over
3
+ * plain `number[]`, no `LayoutNode`/`ElementRecord` knowledge at all (the
4
+ * caller decides whether the numbers are x-coordinates, gaps, or anything
5
+ * else). Same boundary rules as the rest of `src/analysis/`: no imports
6
+ * beyond `../snapshot/types.js` and sibling `analysis/` modules — this file
7
+ * needs neither, so it has no imports.
8
+ */
9
+ export interface EdgeCluster {
10
+ /** Representative value for the cluster: the mean of its members: cheap,
11
+ * stable under this module's adjacent-gap grouping (a cluster's members
12
+ * are already known to be within `tolerance` px of their neighbors, so
13
+ * the mean can never land more than `tolerance` px from any one member),
14
+ * and doesn't require picking a media convention for even-sized groups. */
15
+ value: number;
16
+ count: number;
17
+ /** Original indices into the input `values` array that belong to this
18
+ * cluster, in the order they were grouped (ascending by value, not
19
+ * necessarily the input order). */
20
+ members: number[];
21
+ }
22
+ /**
23
+ * Groups `values` into 1-D clusters using adjacent-gap chaining: sort
24
+ * ascending, then start a new cluster whenever the gap between two
25
+ * consecutive sorted values exceeds `tolerance`. This is deliberately
26
+ * "adjacent" rather than "each within `tolerance` of the cluster's running
27
+ * mean" — a run of values 2px apart (0, 2, 4, 6, 8) chains into one cluster
28
+ * under either rule, but the two rules diverge once a cluster's span grows
29
+ * past `tolerance`; adjacent-gap chaining is the simpler rule to reason
30
+ * about and to test, and matches "group any within tolerance px of each
31
+ * other" read as a chain of pairwise closeness rather than a fixed-radius
32
+ * ball around one center.
33
+ */
34
+ export declare function clusterEdges(values: number[], tolerance?: number): Array<{
35
+ value: number;
36
+ count: number;
37
+ members: number[];
38
+ }>;
39
+ export interface BaseUnitResult {
40
+ unit: number | null;
41
+ onGridShare: number;
42
+ offGrid: number[];
43
+ }
44
+ /**
45
+ * Picks the candidate spacing unit (default `[4, 8]`) that best explains
46
+ * `gaps`: for each candidate, the fraction of non-zero gaps within 1px of a
47
+ * multiple of that unit. Zero-valued gaps are excluded from both the sample
48
+ * count and the coverage fraction — a touching pair (gap 0) is trivially "on
49
+ * every grid" and would inflate the score without saying anything about the
50
+ * page's actual spacing rhythm.
51
+ *
52
+ * Requires at least `MIN_GAP_SAMPLES` non-zero gaps and at least
53
+ * `MIN_ON_GRID_SHARE` coverage from the best candidate; otherwise returns
54
+ * `{unit: null, onGridShare: 0, offGrid: gaps}` (the *original*, unfiltered
55
+ * `gaps` — a caller with too little data to trust gets everything back as
56
+ * "off-grid" rather than a partially-filtered list) instead of a
57
+ * low-confidence guess. Ties are broken toward the smaller candidate — the
58
+ * candidates are tried in the order given and a later candidate only wins by
59
+ * strictly exceeding the current best share.
60
+ */
61
+ export declare function detectBaseUnit(gaps: number[], candidates?: number[]): BaseUnitResult;
62
+ export interface SpacingStats {
63
+ distinct: Record<string, number>;
64
+ }
65
+ /** Histogram of `gaps`, each value rounded to the nearest px before being
66
+ * used as the key — the digest and the (future) spacing check use this to
67
+ * summarize "column gaps: 24×4" rather than listing every raw float. */
68
+ export declare function spacingStats(gaps: number[]): SpacingStats;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 1-D numeric clustering and spacing-grid detection — pure arithmetic over
3
+ * plain `number[]`, no `LayoutNode`/`ElementRecord` knowledge at all (the
4
+ * caller decides whether the numbers are x-coordinates, gaps, or anything
5
+ * else). Same boundary rules as the rest of `src/analysis/`: no imports
6
+ * beyond `../snapshot/types.js` and sibling `analysis/` modules — this file
7
+ * needs neither, so it has no imports.
8
+ */
9
+ export interface EdgeCluster {
10
+ /** Representative value for the cluster: the mean of its members: cheap,
11
+ * stable under this module's adjacent-gap grouping (a cluster's members
12
+ * are already known to be within `tolerance` px of their neighbors, so
13
+ * the mean can never land more than `tolerance` px from any one member),
14
+ * and doesn't require picking a media convention for even-sized groups. */
15
+ value: number;
16
+ count: number;
17
+ /** Original indices into the input `values` array that belong to this
18
+ * cluster, in the order they were grouped (ascending by value, not
19
+ * necessarily the input order). */
20
+ members: number[];
21
+ }
22
+ /**
23
+ * Groups `values` into 1-D clusters using adjacent-gap chaining: sort
24
+ * ascending, then start a new cluster whenever the gap between two
25
+ * consecutive sorted values exceeds `tolerance`. This is deliberately
26
+ * "adjacent" rather than "each within `tolerance` of the cluster's running
27
+ * mean" — a run of values 2px apart (0, 2, 4, 6, 8) chains into one cluster
28
+ * under either rule, but the two rules diverge once a cluster's span grows
29
+ * past `tolerance`; adjacent-gap chaining is the simpler rule to reason
30
+ * about and to test, and matches "group any within tolerance px of each
31
+ * other" read as a chain of pairwise closeness rather than a fixed-radius
32
+ * ball around one center.
33
+ */
34
+ export declare function clusterEdges(values: number[], tolerance?: number): Array<{
35
+ value: number;
36
+ count: number;
37
+ members: number[];
38
+ }>;
39
+ export interface BaseUnitResult {
40
+ unit: number | null;
41
+ onGridShare: number;
42
+ offGrid: number[];
43
+ }
44
+ /**
45
+ * Picks the candidate spacing unit (default `[4, 8]`) that best explains
46
+ * `gaps`: for each candidate, the fraction of non-zero gaps within 1px of a
47
+ * multiple of that unit. Zero-valued gaps are excluded from both the sample
48
+ * count and the coverage fraction — a touching pair (gap 0) is trivially "on
49
+ * every grid" and would inflate the score without saying anything about the
50
+ * page's actual spacing rhythm.
51
+ *
52
+ * Requires at least `MIN_GAP_SAMPLES` non-zero gaps and at least
53
+ * `MIN_ON_GRID_SHARE` coverage from the best candidate; otherwise returns
54
+ * `{unit: null, onGridShare: 0, offGrid: gaps}` (the *original*, unfiltered
55
+ * `gaps` — a caller with too little data to trust gets everything back as
56
+ * "off-grid" rather than a partially-filtered list) instead of a
57
+ * low-confidence guess. Ties are broken toward the smaller candidate — the
58
+ * candidates are tried in the order given and a later candidate only wins by
59
+ * strictly exceeding the current best share.
60
+ */
61
+ export declare function detectBaseUnit(gaps: number[], candidates?: number[]): BaseUnitResult;
62
+ export interface SpacingStats {
63
+ distinct: Record<string, number>;
64
+ }
65
+ /** Histogram of `gaps`, each value rounded to the nearest px before being
66
+ * used as the key — the digest and the (future) spacing check use this to
67
+ * summarize "column gaps: 24×4" rather than listing every raw float. */
68
+ export declare function spacingStats(gaps: number[]): SpacingStats;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 1-D numeric clustering and spacing-grid detection — pure arithmetic over
3
+ * plain `number[]`, no `LayoutNode`/`ElementRecord` knowledge at all (the
4
+ * caller decides whether the numbers are x-coordinates, gaps, or anything
5
+ * else). Same boundary rules as the rest of `src/analysis/`: no imports
6
+ * beyond `../snapshot/types.js` and sibling `analysis/` modules — this file
7
+ * needs neither, so it has no imports.
8
+ */
9
+ /**
10
+ * Groups `values` into 1-D clusters using adjacent-gap chaining: sort
11
+ * ascending, then start a new cluster whenever the gap between two
12
+ * consecutive sorted values exceeds `tolerance`. This is deliberately
13
+ * "adjacent" rather than "each within `tolerance` of the cluster's running
14
+ * mean" — a run of values 2px apart (0, 2, 4, 6, 8) chains into one cluster
15
+ * under either rule, but the two rules diverge once a cluster's span grows
16
+ * past `tolerance`; adjacent-gap chaining is the simpler rule to reason
17
+ * about and to test, and matches "group any within tolerance px of each
18
+ * other" read as a chain of pairwise closeness rather than a fixed-radius
19
+ * ball around one center.
20
+ */
21
+ export function clusterEdges(values, tolerance = 2) {
22
+ if (values.length === 0)
23
+ return [];
24
+ const sorted = values.map((value, index) => ({ value, index })).sort((a, b) => a.value - b.value);
25
+ const clusters = [];
26
+ let current = [];
27
+ function flush() {
28
+ if (current.length === 0)
29
+ return;
30
+ const sum = current.reduce((total, entry) => total + entry.value, 0);
31
+ clusters.push({
32
+ value: sum / current.length,
33
+ count: current.length,
34
+ members: current.map((entry) => entry.index),
35
+ });
36
+ current = [];
37
+ }
38
+ for (const entry of sorted) {
39
+ const prev = current.at(-1);
40
+ if (prev !== undefined && entry.value - prev.value > tolerance)
41
+ flush();
42
+ current.push(entry);
43
+ }
44
+ flush();
45
+ return clusters;
46
+ }
47
+ const MIN_GAP_SAMPLES = 5;
48
+ const MIN_ON_GRID_SHARE = 0.5;
49
+ const GRID_TOLERANCE = 1;
50
+ /**
51
+ * Picks the candidate spacing unit (default `[4, 8]`) that best explains
52
+ * `gaps`: for each candidate, the fraction of non-zero gaps within 1px of a
53
+ * multiple of that unit. Zero-valued gaps are excluded from both the sample
54
+ * count and the coverage fraction — a touching pair (gap 0) is trivially "on
55
+ * every grid" and would inflate the score without saying anything about the
56
+ * page's actual spacing rhythm.
57
+ *
58
+ * Requires at least `MIN_GAP_SAMPLES` non-zero gaps and at least
59
+ * `MIN_ON_GRID_SHARE` coverage from the best candidate; otherwise returns
60
+ * `{unit: null, onGridShare: 0, offGrid: gaps}` (the *original*, unfiltered
61
+ * `gaps` — a caller with too little data to trust gets everything back as
62
+ * "off-grid" rather than a partially-filtered list) instead of a
63
+ * low-confidence guess. Ties are broken toward the smaller candidate — the
64
+ * candidates are tried in the order given and a later candidate only wins by
65
+ * strictly exceeding the current best share.
66
+ */
67
+ export function detectBaseUnit(gaps, candidates = [4, 8]) {
68
+ const nonZero = gaps.filter((gap) => gap !== 0);
69
+ if (nonZero.length < MIN_GAP_SAMPLES) {
70
+ return { unit: null, onGridShare: 0, offGrid: gaps };
71
+ }
72
+ let bestUnit = null;
73
+ let bestShare = 0;
74
+ let bestOffGrid = gaps;
75
+ for (const unit of candidates) {
76
+ if (unit <= 0)
77
+ continue;
78
+ const offGrid = [];
79
+ let onGrid = 0;
80
+ for (const gap of nonZero) {
81
+ const nearestMultiple = Math.round(gap / unit) * unit;
82
+ if (Math.abs(gap - nearestMultiple) <= GRID_TOLERANCE)
83
+ onGrid++;
84
+ else
85
+ offGrid.push(gap);
86
+ }
87
+ const share = onGrid / nonZero.length;
88
+ if (share > bestShare) {
89
+ bestShare = share;
90
+ bestUnit = unit;
91
+ bestOffGrid = offGrid;
92
+ }
93
+ }
94
+ if (bestUnit === null || bestShare < MIN_ON_GRID_SHARE) {
95
+ return { unit: null, onGridShare: 0, offGrid: gaps };
96
+ }
97
+ return { unit: bestUnit, onGridShare: bestShare, offGrid: bestOffGrid };
98
+ }
99
+ /** Histogram of `gaps`, each value rounded to the nearest px before being
100
+ * used as the key — the digest and the (future) spacing check use this to
101
+ * summarize "column gaps: 24×4" rather than listing every raw float. */
102
+ export function spacingStats(gaps) {
103
+ const distinct = {};
104
+ for (const gap of gaps) {
105
+ const key = String(Math.round(gap));
106
+ distinct[key] = (distinct[key] ?? 0) + 1;
107
+ }
108
+ return { distinct };
109
+ }
@@ -0,0 +1,124 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.distance = distance;
4
+ exports.nearest = nearest;
5
+ exports.proximities = proximities;
6
+ /**
7
+ * Geometric distance queries over a `LayoutNode[]` tree — the piece that
8
+ * lets an agent go from a digest's element ids to on-demand geometric
9
+ * answers (`query`'s `distance`/`nearest` verbs) instead of re-deriving
10
+ * arithmetic by hand from raw rects. Pure, tree-shaped, no `ElementRecord` —
11
+ * same boundary rules as the rest of `src/analysis/`.
12
+ */
13
+ const geometry_js_1 = require("./geometry.cjs");
14
+ /** Distance between two rects along every axis a query needs: signed
15
+ * per-axis gaps, the Euclidean closest-edge gap, and center-to-center. */
16
+ function distance(a, b) {
17
+ const dx = (0, geometry_js_1.gapAlong)(a, b, 'x');
18
+ const dy = (0, geometry_js_1.gapAlong)(a, b, 'y');
19
+ const edge = Math.sqrt(Math.max(dx, 0) ** 2 + Math.max(dy, 0) ** 2);
20
+ const ca = (0, geometry_js_1.center)(a);
21
+ const cb = (0, geometry_js_1.center)(b);
22
+ const centerDistance = Math.sqrt((ca.x - cb.x) ** 2 + (ca.y - cb.y) ** 2);
23
+ return { dx, dy, edge, center: centerDistance };
24
+ }
25
+ /** Flattens the tree once, tracking each node's immediate parent id (for
26
+ * sibling exclusion in `proximities`) and full ancestor-id set (for
27
+ * ancestor/descendant exclusion in both `nearest` and `proximities`) — the
28
+ * same lineage-tracking shape `neighbors.ts#overlaps` uses internally,
29
+ * reimplemented here rather than imported since `neighbors.ts` doesn't
30
+ * export it and this module also needs `parentId`, which that shape
31
+ * doesn't carry. */
32
+ function flattenWithLineage(tree) {
33
+ const entries = [];
34
+ function visit(node, parentId, ancestorIds) {
35
+ entries.push({ node, parentId, ancestorIds });
36
+ const withSelf = new Set(ancestorIds);
37
+ withSelf.add(node.elementId);
38
+ for (const child of node.children)
39
+ visit(child, node.elementId, withSelf);
40
+ }
41
+ for (const root of tree)
42
+ visit(root, null, new Set());
43
+ return entries;
44
+ }
45
+ function isAncestorPair(a, b) {
46
+ return a.ancestorIds.has(b.node.elementId) || b.ancestorIds.has(a.node.elementId);
47
+ }
48
+ function idCompare(a, b) {
49
+ return a < b ? -1 : a > b ? 1 : 0;
50
+ }
51
+ /** `fixed`/`sticky` elements are deliberately layered over other content
52
+ * (persistent chrome like Charming's floating feedback launcher) — mirrors
53
+ * `checks/overflow.ts#UNCONTAINED_POSITIONS` and
54
+ * `neighbors.ts#UNCONTAINED_POSITIONS`. A `fixed` element reads as "near"
55
+ * whatever page content sits under it at the moment of capture, which is an
56
+ * artifact of scroll position, not a real proximity relationship between
57
+ * two page elements — the same reasoning that excludes it from `overlaps`
58
+ * applies here. */
59
+ const UNCONTAINED_POSITIONS = new Set(['fixed', 'sticky']);
60
+ const DEFAULT_K = 4;
61
+ /** The `k` layout nodes nearest `id` by edge distance (ascending, ties
62
+ * broken by id for determinism), excluding `id` itself and — by default —
63
+ * its ancestors and descendants. Returns `[]` if `id` isn't found. */
64
+ function nearest(tree, id, k = DEFAULT_K, opts = {}) {
65
+ const excludeAncestors = opts.excludeAncestors ?? true;
66
+ const entries = flattenWithLineage(tree);
67
+ const target = entries.find((e) => e.node.elementId === id);
68
+ if (!target)
69
+ return [];
70
+ const results = [];
71
+ for (const entry of entries) {
72
+ if (entry.node.elementId === id)
73
+ continue;
74
+ if (excludeAncestors && isAncestorPair(target, entry))
75
+ continue;
76
+ const d = distance(target.node.rect, entry.node.rect);
77
+ results.push({ id: entry.node.elementId, edge: d.edge, dx: d.dx, dy: d.dy });
78
+ }
79
+ results.sort((x, y) => x.edge - y.edge || idCompare(x.id, y.id));
80
+ return results.slice(0, k);
81
+ }
82
+ const DEFAULT_MAX_EDGE = 4;
83
+ /**
84
+ * Every pair of nodes that are NOT ancestor/descendant of each other AND
85
+ * NOT direct siblings (siblings are already covered by
86
+ * `neighbors.ts#siblingGaps` — this is specifically the cross-container
87
+ * near-touch case, e.g. a badge sitting on the wrong card) AND NOT
88
+ * `fixed`/`sticky` (see `UNCONTAINED_POSITIONS`) whose edge distance is at
89
+ * or below `opts.maxEdge` (default 4px).
90
+ *
91
+ * Implementation note: this is a straightforward O(n²) pass over the
92
+ * flattened node list, not the sort-by-x sweep the plan calls "ideal" —
93
+ * post-collapse layout trees are expected to be dozens to low hundreds of
94
+ * nodes (confirmed: the real owner-profile golden collapses 54 raw
95
+ * elements to 47 layout nodes), not thousands, so the quadratic pass is
96
+ * fast enough in practice and simpler to get right within this step's
97
+ * budget. Revisit with a sweep if a future corpus produces trees large
98
+ * enough for this to matter.
99
+ */
100
+ function proximities(tree, opts = {}) {
101
+ const maxEdge = opts.maxEdge ?? DEFAULT_MAX_EDGE;
102
+ const entries = flattenWithLineage(tree);
103
+ const found = [];
104
+ for (let i = 0; i < entries.length; i++) {
105
+ for (let j = i + 1; j < entries.length; j++) {
106
+ const a = entries[i];
107
+ const b = entries[j];
108
+ if (!a || !b)
109
+ continue;
110
+ if (isAncestorPair(a, b))
111
+ continue;
112
+ if (a.parentId !== null && a.parentId === b.parentId)
113
+ continue; // direct siblings
114
+ if (UNCONTAINED_POSITIONS.has(a.node.position) || UNCONTAINED_POSITIONS.has(b.node.position))
115
+ continue;
116
+ const d = distance(a.node.rect, b.node.rect);
117
+ if (d.edge <= maxEdge) {
118
+ found.push({ a: a.node.elementId, b: b.node.elementId, edge: d.edge, dx: d.dx, dy: d.dy });
119
+ }
120
+ }
121
+ }
122
+ found.sort((x, y) => x.edge - y.edge || idCompare(x.a, y.a) || idCompare(x.b, y.b));
123
+ return found;
124
+ }
@@ -0,0 +1,58 @@
1
+ import type { LayoutNode, ProximityPair, Rect } from "./types.cjs";
2
+ export interface DistanceResult {
3
+ /** Signed x-axis edge gap (`geometry.ts#gapAlong`) — negative means the
4
+ * two rects overlap on the x axis. */
5
+ dx: number;
6
+ /** Signed y-axis edge gap — negative means overlap on the y axis. */
7
+ dy: number;
8
+ /** Euclidean gap between the closest edges, 0 when touching or
9
+ * overlapping on either axis (`dx`/`dy` are clamped to 0 before the
10
+ * Euclidean combination, so a real overlap never reads as a negative
11
+ * "closer than touching" edge distance). */
12
+ edge: number;
13
+ /** Straight center-to-center Euclidean distance — always positive,
14
+ * meaningful even when the two rects overlap (unlike `edge`). */
15
+ center: number;
16
+ }
17
+ /** Distance between two rects along every axis a query needs: signed
18
+ * per-axis gaps, the Euclidean closest-edge gap, and center-to-center. */
19
+ export declare function distance(a: Rect, b: Rect): DistanceResult;
20
+ export interface NearestOptions {
21
+ /** A child is always at distance 0 from its parent and is never useful
22
+ * information — excludes both ancestors AND descendants of the target
23
+ * node when true (the default). */
24
+ excludeAncestors?: boolean;
25
+ }
26
+ export interface NearestResult {
27
+ id: string;
28
+ edge: number;
29
+ dx: number;
30
+ dy: number;
31
+ }
32
+ /** The `k` layout nodes nearest `id` by edge distance (ascending, ties
33
+ * broken by id for determinism), excluding `id` itself and — by default —
34
+ * its ancestors and descendants. Returns `[]` if `id` isn't found. */
35
+ export declare function nearest(tree: LayoutNode[], id: string, k?: number, opts?: NearestOptions): NearestResult[];
36
+ export interface ProximitiesOptions {
37
+ /** Only pairs at or below this edge distance (px) are returned. Default
38
+ * 4 — genuinely close but not necessarily touching. */
39
+ maxEdge?: number;
40
+ }
41
+ /**
42
+ * Every pair of nodes that are NOT ancestor/descendant of each other AND
43
+ * NOT direct siblings (siblings are already covered by
44
+ * `neighbors.ts#siblingGaps` — this is specifically the cross-container
45
+ * near-touch case, e.g. a badge sitting on the wrong card) AND NOT
46
+ * `fixed`/`sticky` (see `UNCONTAINED_POSITIONS`) whose edge distance is at
47
+ * or below `opts.maxEdge` (default 4px).
48
+ *
49
+ * Implementation note: this is a straightforward O(n²) pass over the
50
+ * flattened node list, not the sort-by-x sweep the plan calls "ideal" —
51
+ * post-collapse layout trees are expected to be dozens to low hundreds of
52
+ * nodes (confirmed: the real owner-profile golden collapses 54 raw
53
+ * elements to 47 layout nodes), not thousands, so the quadratic pass is
54
+ * fast enough in practice and simpler to get right within this step's
55
+ * budget. Revisit with a sweep if a future corpus produces trees large
56
+ * enough for this to matter.
57
+ */
58
+ export declare function proximities(tree: LayoutNode[], opts?: ProximitiesOptions): ProximityPair[];
@@ -0,0 +1,58 @@
1
+ import type { LayoutNode, ProximityPair, Rect } from "./types.js";
2
+ export interface DistanceResult {
3
+ /** Signed x-axis edge gap (`geometry.ts#gapAlong`) — negative means the
4
+ * two rects overlap on the x axis. */
5
+ dx: number;
6
+ /** Signed y-axis edge gap — negative means overlap on the y axis. */
7
+ dy: number;
8
+ /** Euclidean gap between the closest edges, 0 when touching or
9
+ * overlapping on either axis (`dx`/`dy` are clamped to 0 before the
10
+ * Euclidean combination, so a real overlap never reads as a negative
11
+ * "closer than touching" edge distance). */
12
+ edge: number;
13
+ /** Straight center-to-center Euclidean distance — always positive,
14
+ * meaningful even when the two rects overlap (unlike `edge`). */
15
+ center: number;
16
+ }
17
+ /** Distance between two rects along every axis a query needs: signed
18
+ * per-axis gaps, the Euclidean closest-edge gap, and center-to-center. */
19
+ export declare function distance(a: Rect, b: Rect): DistanceResult;
20
+ export interface NearestOptions {
21
+ /** A child is always at distance 0 from its parent and is never useful
22
+ * information — excludes both ancestors AND descendants of the target
23
+ * node when true (the default). */
24
+ excludeAncestors?: boolean;
25
+ }
26
+ export interface NearestResult {
27
+ id: string;
28
+ edge: number;
29
+ dx: number;
30
+ dy: number;
31
+ }
32
+ /** The `k` layout nodes nearest `id` by edge distance (ascending, ties
33
+ * broken by id for determinism), excluding `id` itself and — by default —
34
+ * its ancestors and descendants. Returns `[]` if `id` isn't found. */
35
+ export declare function nearest(tree: LayoutNode[], id: string, k?: number, opts?: NearestOptions): NearestResult[];
36
+ export interface ProximitiesOptions {
37
+ /** Only pairs at or below this edge distance (px) are returned. Default
38
+ * 4 — genuinely close but not necessarily touching. */
39
+ maxEdge?: number;
40
+ }
41
+ /**
42
+ * Every pair of nodes that are NOT ancestor/descendant of each other AND
43
+ * NOT direct siblings (siblings are already covered by
44
+ * `neighbors.ts#siblingGaps` — this is specifically the cross-container
45
+ * near-touch case, e.g. a badge sitting on the wrong card) AND NOT
46
+ * `fixed`/`sticky` (see `UNCONTAINED_POSITIONS`) whose edge distance is at
47
+ * or below `opts.maxEdge` (default 4px).
48
+ *
49
+ * Implementation note: this is a straightforward O(n²) pass over the
50
+ * flattened node list, not the sort-by-x sweep the plan calls "ideal" —
51
+ * post-collapse layout trees are expected to be dozens to low hundreds of
52
+ * nodes (confirmed: the real owner-profile golden collapses 54 raw
53
+ * elements to 47 layout nodes), not thousands, so the quadratic pass is
54
+ * fast enough in practice and simpler to get right within this step's
55
+ * budget. Revisit with a sweep if a future corpus produces trees large
56
+ * enough for this to matter.
57
+ */
58
+ export declare function proximities(tree: LayoutNode[], opts?: ProximitiesOptions): ProximityPair[];
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Geometric distance queries over a `LayoutNode[]` tree — the piece that
3
+ * lets an agent go from a digest's element ids to on-demand geometric
4
+ * answers (`query`'s `distance`/`nearest` verbs) instead of re-deriving
5
+ * arithmetic by hand from raw rects. Pure, tree-shaped, no `ElementRecord` —
6
+ * same boundary rules as the rest of `src/analysis/`.
7
+ */
8
+ import { center, gapAlong } from "./geometry.js";
9
+ /** Distance between two rects along every axis a query needs: signed
10
+ * per-axis gaps, the Euclidean closest-edge gap, and center-to-center. */
11
+ export function distance(a, b) {
12
+ const dx = gapAlong(a, b, 'x');
13
+ const dy = gapAlong(a, b, 'y');
14
+ const edge = Math.sqrt(Math.max(dx, 0) ** 2 + Math.max(dy, 0) ** 2);
15
+ const ca = center(a);
16
+ const cb = center(b);
17
+ const centerDistance = Math.sqrt((ca.x - cb.x) ** 2 + (ca.y - cb.y) ** 2);
18
+ return { dx, dy, edge, center: centerDistance };
19
+ }
20
+ /** Flattens the tree once, tracking each node's immediate parent id (for
21
+ * sibling exclusion in `proximities`) and full ancestor-id set (for
22
+ * ancestor/descendant exclusion in both `nearest` and `proximities`) — the
23
+ * same lineage-tracking shape `neighbors.ts#overlaps` uses internally,
24
+ * reimplemented here rather than imported since `neighbors.ts` doesn't
25
+ * export it and this module also needs `parentId`, which that shape
26
+ * doesn't carry. */
27
+ function flattenWithLineage(tree) {
28
+ const entries = [];
29
+ function visit(node, parentId, ancestorIds) {
30
+ entries.push({ node, parentId, ancestorIds });
31
+ const withSelf = new Set(ancestorIds);
32
+ withSelf.add(node.elementId);
33
+ for (const child of node.children)
34
+ visit(child, node.elementId, withSelf);
35
+ }
36
+ for (const root of tree)
37
+ visit(root, null, new Set());
38
+ return entries;
39
+ }
40
+ function isAncestorPair(a, b) {
41
+ return a.ancestorIds.has(b.node.elementId) || b.ancestorIds.has(a.node.elementId);
42
+ }
43
+ function idCompare(a, b) {
44
+ return a < b ? -1 : a > b ? 1 : 0;
45
+ }
46
+ /** `fixed`/`sticky` elements are deliberately layered over other content
47
+ * (persistent chrome like Charming's floating feedback launcher) — mirrors
48
+ * `checks/overflow.ts#UNCONTAINED_POSITIONS` and
49
+ * `neighbors.ts#UNCONTAINED_POSITIONS`. A `fixed` element reads as "near"
50
+ * whatever page content sits under it at the moment of capture, which is an
51
+ * artifact of scroll position, not a real proximity relationship between
52
+ * two page elements — the same reasoning that excludes it from `overlaps`
53
+ * applies here. */
54
+ const UNCONTAINED_POSITIONS = new Set(['fixed', 'sticky']);
55
+ const DEFAULT_K = 4;
56
+ /** The `k` layout nodes nearest `id` by edge distance (ascending, ties
57
+ * broken by id for determinism), excluding `id` itself and — by default —
58
+ * its ancestors and descendants. Returns `[]` if `id` isn't found. */
59
+ export function nearest(tree, id, k = DEFAULT_K, opts = {}) {
60
+ const excludeAncestors = opts.excludeAncestors ?? true;
61
+ const entries = flattenWithLineage(tree);
62
+ const target = entries.find((e) => e.node.elementId === id);
63
+ if (!target)
64
+ return [];
65
+ const results = [];
66
+ for (const entry of entries) {
67
+ if (entry.node.elementId === id)
68
+ continue;
69
+ if (excludeAncestors && isAncestorPair(target, entry))
70
+ continue;
71
+ const d = distance(target.node.rect, entry.node.rect);
72
+ results.push({ id: entry.node.elementId, edge: d.edge, dx: d.dx, dy: d.dy });
73
+ }
74
+ results.sort((x, y) => x.edge - y.edge || idCompare(x.id, y.id));
75
+ return results.slice(0, k);
76
+ }
77
+ const DEFAULT_MAX_EDGE = 4;
78
+ /**
79
+ * Every pair of nodes that are NOT ancestor/descendant of each other AND
80
+ * NOT direct siblings (siblings are already covered by
81
+ * `neighbors.ts#siblingGaps` — this is specifically the cross-container
82
+ * near-touch case, e.g. a badge sitting on the wrong card) AND NOT
83
+ * `fixed`/`sticky` (see `UNCONTAINED_POSITIONS`) whose edge distance is at
84
+ * or below `opts.maxEdge` (default 4px).
85
+ *
86
+ * Implementation note: this is a straightforward O(n²) pass over the
87
+ * flattened node list, not the sort-by-x sweep the plan calls "ideal" —
88
+ * post-collapse layout trees are expected to be dozens to low hundreds of
89
+ * nodes (confirmed: the real owner-profile golden collapses 54 raw
90
+ * elements to 47 layout nodes), not thousands, so the quadratic pass is
91
+ * fast enough in practice and simpler to get right within this step's
92
+ * budget. Revisit with a sweep if a future corpus produces trees large
93
+ * enough for this to matter.
94
+ */
95
+ export function proximities(tree, opts = {}) {
96
+ const maxEdge = opts.maxEdge ?? DEFAULT_MAX_EDGE;
97
+ const entries = flattenWithLineage(tree);
98
+ const found = [];
99
+ for (let i = 0; i < entries.length; i++) {
100
+ for (let j = i + 1; j < entries.length; j++) {
101
+ const a = entries[i];
102
+ const b = entries[j];
103
+ if (!a || !b)
104
+ continue;
105
+ if (isAncestorPair(a, b))
106
+ continue;
107
+ if (a.parentId !== null && a.parentId === b.parentId)
108
+ continue; // direct siblings
109
+ if (UNCONTAINED_POSITIONS.has(a.node.position) || UNCONTAINED_POSITIONS.has(b.node.position))
110
+ continue;
111
+ const d = distance(a.node.rect, b.node.rect);
112
+ if (d.edge <= maxEdge) {
113
+ found.push({ a: a.node.elementId, b: b.node.elementId, edge: d.edge, dx: d.dx, dy: d.dy });
114
+ }
115
+ }
116
+ }
117
+ found.sort((x, y) => x.edge - y.edge || idCompare(x.a, y.a) || idCompare(x.b, y.b));
118
+ return found;
119
+ }
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.intersect = intersect;
4
+ exports.contains = contains;
5
+ exports.gapAlong = gapAlong;
6
+ exports.area = area;
7
+ exports.center = center;
8
+ /**
9
+ * The overlapping rectangle between `a` and `b`, or `null` if they don't
10
+ * overlap on both axes. Touching edges count as no overlap — the intersection
11
+ * would have zero area, and a zero-area rect is not a thing anything on screen
12
+ * can be inside of.
13
+ */
14
+ function intersect(a, b) {
15
+ const x = Math.max(a.x, b.x);
16
+ const y = Math.max(a.y, b.y);
17
+ const right = Math.min(a.x + a.width, b.x + b.width);
18
+ const bottom = Math.min(a.y + a.height, b.y + b.height);
19
+ const width = right - x;
20
+ const height = bottom - y;
21
+ if (width <= 0 || height <= 0)
22
+ return null;
23
+ return { x, y, width, height };
24
+ }
25
+ /** True if `inner` is fully within `outer`. Edges touching counts as contained. */
26
+ function contains(outer, inner) {
27
+ return (inner.x >= outer.x &&
28
+ inner.y >= outer.y &&
29
+ inner.x + inner.width <= outer.x + outer.width &&
30
+ inner.y + inner.height <= outer.y + outer.height);
31
+ }
32
+ /**
33
+ * Signed edge-to-edge distance between `a` and `b` along one axis — positive is
34
+ * a real gap, negative is how far the two rects overlap along that axis.
35
+ *
36
+ * Unlike `intersect`, this is per-axis: two rects side by side in a row overlap
37
+ * on `y` and have a positive gap on `x`, which is exactly the question a gutter
38
+ * measurement asks.
39
+ */
40
+ function gapAlong(a, b, axis) {
41
+ const size = axis === 'x' ? 'width' : 'height';
42
+ const aStart = a[axis];
43
+ const aEnd = aStart + a[size];
44
+ const bStart = b[axis];
45
+ const bEnd = bStart + b[size];
46
+ if (bStart >= aEnd)
47
+ return bStart - aEnd;
48
+ if (aStart >= bEnd)
49
+ return aStart - bEnd;
50
+ // Overlapping along this axis: negative distance, magnitude of the overlap.
51
+ return Math.max(aStart, bStart) - Math.min(aEnd, bEnd);
52
+ }
53
+ /** Rect area in px². */
54
+ function area(r) {
55
+ return r.width * r.height;
56
+ }
57
+ /** Rect center point. */
58
+ function center(r) {
59
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
60
+ }