@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,167 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OVERFLOW_EPSILON = void 0;
4
+ exports.factsFor = factsFor;
5
+ exports.renderFacts = renderFacts;
6
+ exports.factsForAll = factsForAll;
7
+ /**
8
+ * Subpixel layout noise. Below this, an "overflow" is a rounding artefact.
9
+ * Exported so `render.ts`'s fold variance uses the same threshold rather than
10
+ * its own magic number -- a fold reporting a 1px spread that is really 0.2px
11
+ * of rounding is the same bug this constant exists to prevent here.
12
+ */
13
+ exports.OVERFLOW_EPSILON = 1;
14
+ /** A box narrower or shorter than this has collapsed, whatever it intended. */
15
+ const ZERO_EPSILON = 1;
16
+ const CLIPS = /^(hidden|clip)$/;
17
+ const SCROLLS = /^(auto|scroll|overlay)$/;
18
+ /** White-space values under which text will not wrap. */
19
+ const NO_WRAP = /^(nowrap|pre)$/;
20
+ const round = (n) => Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, '');
21
+ /**
22
+ * What happens to content that exceeds the box on this axis.
23
+ *
24
+ * Exactly one of `clip` / `scroll` / `visible` always applies -- it is a
25
+ * partition of the `overflow` values. The wrapping facts are additional, and
26
+ * `ellipsis` suppresses `wraps` because `text-overflow` only takes effect when
27
+ * wrapping is already off; printing both would be restating one CSS rule twice.
28
+ */
29
+ function consequences(el, axis) {
30
+ const overflow = axis === 'x' ? el.overflowX : el.overflowY;
31
+ const out = [];
32
+ const clips = CLIPS.test(overflow);
33
+ if (clips)
34
+ out.push('clip');
35
+ else if (SCROLLS.test(overflow))
36
+ out.push('scroll');
37
+ else
38
+ out.push('visible');
39
+ if (axis === 'x') {
40
+ const ellipsis = el.textOverflow === 'ellipsis' && (clips || SCROLLS.test(overflow));
41
+ if (ellipsis)
42
+ out.push('ellipsis');
43
+ else if (!NO_WRAP.test(el.whiteSpace))
44
+ out.push('wraps');
45
+ else
46
+ out.push('nowrap');
47
+ }
48
+ return out;
49
+ }
50
+ /**
51
+ * Whether a collapsed box is hiding anything.
52
+ *
53
+ * A `0x0` spacer is ordinary and uninteresting; a `0x1239` column with a page
54
+ * of content inside it is the bug the prototype measured and discarded. The
55
+ * difference is entirely in what the box contains, so that is what the fact
56
+ * carries.
57
+ */
58
+ function contentOf(el, byId) {
59
+ if (el.text)
60
+ return `"${el.text}"`;
61
+ let text = '';
62
+ let elements = 0;
63
+ const queue = [...el.childIds];
64
+ while (queue.length > 0 && text.length < 40) {
65
+ const child = byId.get(queue.shift() ?? '');
66
+ if (!child)
67
+ continue;
68
+ elements++;
69
+ if (child.text)
70
+ text = `${text} ${child.text}`.trim();
71
+ queue.push(...child.childIds);
72
+ }
73
+ if (text)
74
+ return `"${text.slice(0, 40)}"`;
75
+ return elements > 0 ? `${elements} element${elements === 1 ? '' : 's'}` : null;
76
+ }
77
+ /**
78
+ * How much of an element an ancestor hides, and whether anyone can get to it.
79
+ *
80
+ * `attachClipFacts` has populated `ElementRecord.clip` on both capture paths
81
+ * since step 4, and until now nothing rendered it — the digest emitted the
82
+ * three fact kinds above and silently dropped this one. The V1 cold read's
83
+ * single strongest result came from the step-2 spike's renderer, not from
84
+ * shipped code, and nobody noticed because the tests asserted on `clip` the
85
+ * field rather than on the output.
86
+ *
87
+ * `scrollable` is the counter-fact that carries the whole meaning. A box
88
+ * outside an `overflow:auto` ancestor is a scroll region working as intended;
89
+ * the same box outside an `overflow:hidden` ancestor is content nobody can
90
+ * reach. Printing the extent without the reachability is a measurement the
91
+ * reader cannot act on.
92
+ */
93
+ function clipFact(el) {
94
+ const clip = el.clip;
95
+ if (!clip)
96
+ return null;
97
+ const where = [
98
+ clip.hidden.top ? `${round(clip.hidden.top)}px above` : null,
99
+ clip.hidden.bottom ? `${round(clip.hidden.bottom)}px below` : null,
100
+ clip.hidden.left ? `${round(clip.hidden.left)}px left` : null,
101
+ clip.hidden.right ? `${round(clip.hidden.right)}px right` : null,
102
+ ]
103
+ .filter(Boolean)
104
+ .join(', ');
105
+ const visible = clip.visible.width < ZERO_EPSILON || clip.visible.height < ZERO_EPSILON
106
+ ? 'nothing visible'
107
+ : `${round(clip.visible.width)}x${round(clip.visible.height)} visible`;
108
+ return {
109
+ kind: 'clipped',
110
+ text: `clipped by ${clip.by} ${clip.byTag}; overflow:${clip.overflow}, ` +
111
+ `${clip.scrollable ? 'scrollable' : 'not scrollable'}; ${where}, ${visible}`,
112
+ };
113
+ }
114
+ /**
115
+ * Every fact this element has. Unconditional: no thresholds beyond subpixel
116
+ * noise, no severity, no suppression.
117
+ */
118
+ function factsFor(el, byId) {
119
+ const facts = [];
120
+ const overflowX = el.scrollWidth - el.clientWidth;
121
+ if (overflowX > exports.OVERFLOW_EPSILON) {
122
+ facts.push({
123
+ kind: 'overflow-x',
124
+ text: `overflow-x +${round(overflowX)}px; ${consequences(el, 'x').join(', ')}`,
125
+ });
126
+ }
127
+ const overflowY = el.scrollHeight - el.clientHeight;
128
+ if (overflowY > exports.OVERFLOW_EPSILON) {
129
+ facts.push({
130
+ kind: 'overflow-y',
131
+ text: `overflow-y +${round(overflowY)}px; ${consequences(el, 'y').join(', ')}`,
132
+ });
133
+ }
134
+ // Only for elements that are still in the layout. `display:none` collapses
135
+ // everything to 0x0 and saying so about every hidden node is noise.
136
+ const collapsed = el.display !== 'none' &&
137
+ (el.rect.width < ZERO_EPSILON || el.rect.height < ZERO_EPSILON) &&
138
+ !(el.rect.width < ZERO_EPSILON && el.rect.height < ZERO_EPSILON && !el.childIds.length);
139
+ if (collapsed) {
140
+ const content = contentOf(el, byId);
141
+ if (content) {
142
+ facts.push({
143
+ kind: 'zero-box',
144
+ text: `${round(el.rect.width)}x${round(el.rect.height)} zero-box; content ${content}`,
145
+ });
146
+ }
147
+ }
148
+ const clipped = clipFact(el);
149
+ if (clipped)
150
+ facts.push(clipped);
151
+ return facts;
152
+ }
153
+ /** `[fact] [fact]` — the form these take on a digest line. */
154
+ function renderFacts(facts) {
155
+ return facts.map((f) => `[${f.text}]`).join(' ');
156
+ }
157
+ /** Every element that has at least one fact, in document order. */
158
+ function factsForAll(elements) {
159
+ const byId = new Map(elements.map((e) => [e.id, e]));
160
+ const out = [];
161
+ for (const element of elements) {
162
+ const facts = factsFor(element, byId);
163
+ if (facts.length > 0)
164
+ out.push({ element, facts });
165
+ }
166
+ return out;
167
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Facts, with the facts that disambiguate them.
3
+ *
4
+ * This is the structural fix for how the prototype died. It shipped checks: a
5
+ * rule looked at `scrollWidth - clientWidth`, compared it to a threshold, and
6
+ * emitted a finding. On PR #4813 it measured `scrollWidth: 815, clientWidth:
7
+ * 668` -- a real 147px of truncated heading -- and threw the measurement away
8
+ * because the rule that owned it did not fire. Three separate false-positive
9
+ * classes in its final week all had the same shape: the check lacked a fact the
10
+ * DOM already had.
11
+ *
12
+ * So nothing here decides. A fact is emitted whenever the measurement exists,
13
+ * unconditionally, carrying the adjacent CSS that determines what the
14
+ * measurement *means*:
15
+ *
16
+ * [overflow-x +147px; clip, ellipsis] text is being cut off
17
+ * [overflow-x +227px; clip, wraps] same clip, text just got taller
18
+ * [overflow-x +90px; scroll] a scroll region, working as intended
19
+ *
20
+ * Same number, three different situations, and the difference is never in the
21
+ * number. The reader draws the conclusion; this file refuses to.
22
+ */
23
+ import type { ElementRecord } from "./types.cjs";
24
+ export type FactKind = 'overflow-x' | 'overflow-y' | 'zero-box' | 'clipped';
25
+ export interface Fact {
26
+ kind: FactKind;
27
+ /** The rendered body, without the surrounding brackets. */
28
+ text: string;
29
+ }
30
+ /**
31
+ * Subpixel layout noise. Below this, an "overflow" is a rounding artefact.
32
+ * Exported so `render.ts`'s fold variance uses the same threshold rather than
33
+ * its own magic number -- a fold reporting a 1px spread that is really 0.2px
34
+ * of rounding is the same bug this constant exists to prevent here.
35
+ */
36
+ export declare const OVERFLOW_EPSILON = 1;
37
+ /**
38
+ * Every fact this element has. Unconditional: no thresholds beyond subpixel
39
+ * noise, no severity, no suppression.
40
+ */
41
+ export declare function factsFor(el: ElementRecord, byId: Map<string, ElementRecord>): Fact[];
42
+ /** `[fact] [fact]` — the form these take on a digest line. */
43
+ export declare function renderFacts(facts: Fact[]): string;
44
+ /** Every element that has at least one fact, in document order. */
45
+ export declare function factsForAll(elements: ElementRecord[]): Array<{
46
+ element: ElementRecord;
47
+ facts: Fact[];
48
+ }>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Facts, with the facts that disambiguate them.
3
+ *
4
+ * This is the structural fix for how the prototype died. It shipped checks: a
5
+ * rule looked at `scrollWidth - clientWidth`, compared it to a threshold, and
6
+ * emitted a finding. On PR #4813 it measured `scrollWidth: 815, clientWidth:
7
+ * 668` -- a real 147px of truncated heading -- and threw the measurement away
8
+ * because the rule that owned it did not fire. Three separate false-positive
9
+ * classes in its final week all had the same shape: the check lacked a fact the
10
+ * DOM already had.
11
+ *
12
+ * So nothing here decides. A fact is emitted whenever the measurement exists,
13
+ * unconditionally, carrying the adjacent CSS that determines what the
14
+ * measurement *means*:
15
+ *
16
+ * [overflow-x +147px; clip, ellipsis] text is being cut off
17
+ * [overflow-x +227px; clip, wraps] same clip, text just got taller
18
+ * [overflow-x +90px; scroll] a scroll region, working as intended
19
+ *
20
+ * Same number, three different situations, and the difference is never in the
21
+ * number. The reader draws the conclusion; this file refuses to.
22
+ */
23
+ import type { ElementRecord } from "./types.js";
24
+ export type FactKind = 'overflow-x' | 'overflow-y' | 'zero-box' | 'clipped';
25
+ export interface Fact {
26
+ kind: FactKind;
27
+ /** The rendered body, without the surrounding brackets. */
28
+ text: string;
29
+ }
30
+ /**
31
+ * Subpixel layout noise. Below this, an "overflow" is a rounding artefact.
32
+ * Exported so `render.ts`'s fold variance uses the same threshold rather than
33
+ * its own magic number -- a fold reporting a 1px spread that is really 0.2px
34
+ * of rounding is the same bug this constant exists to prevent here.
35
+ */
36
+ export declare const OVERFLOW_EPSILON = 1;
37
+ /**
38
+ * Every fact this element has. Unconditional: no thresholds beyond subpixel
39
+ * noise, no severity, no suppression.
40
+ */
41
+ export declare function factsFor(el: ElementRecord, byId: Map<string, ElementRecord>): Fact[];
42
+ /** `[fact] [fact]` — the form these take on a digest line. */
43
+ export declare function renderFacts(facts: Fact[]): string;
44
+ /** Every element that has at least one fact, in document order. */
45
+ export declare function factsForAll(elements: ElementRecord[]): Array<{
46
+ element: ElementRecord;
47
+ facts: Fact[];
48
+ }>;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Subpixel layout noise. Below this, an "overflow" is a rounding artefact.
3
+ * Exported so `render.ts`'s fold variance uses the same threshold rather than
4
+ * its own magic number -- a fold reporting a 1px spread that is really 0.2px
5
+ * of rounding is the same bug this constant exists to prevent here.
6
+ */
7
+ export const OVERFLOW_EPSILON = 1;
8
+ /** A box narrower or shorter than this has collapsed, whatever it intended. */
9
+ const ZERO_EPSILON = 1;
10
+ const CLIPS = /^(hidden|clip)$/;
11
+ const SCROLLS = /^(auto|scroll|overlay)$/;
12
+ /** White-space values under which text will not wrap. */
13
+ const NO_WRAP = /^(nowrap|pre)$/;
14
+ const round = (n) => Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, '');
15
+ /**
16
+ * What happens to content that exceeds the box on this axis.
17
+ *
18
+ * Exactly one of `clip` / `scroll` / `visible` always applies -- it is a
19
+ * partition of the `overflow` values. The wrapping facts are additional, and
20
+ * `ellipsis` suppresses `wraps` because `text-overflow` only takes effect when
21
+ * wrapping is already off; printing both would be restating one CSS rule twice.
22
+ */
23
+ function consequences(el, axis) {
24
+ const overflow = axis === 'x' ? el.overflowX : el.overflowY;
25
+ const out = [];
26
+ const clips = CLIPS.test(overflow);
27
+ if (clips)
28
+ out.push('clip');
29
+ else if (SCROLLS.test(overflow))
30
+ out.push('scroll');
31
+ else
32
+ out.push('visible');
33
+ if (axis === 'x') {
34
+ const ellipsis = el.textOverflow === 'ellipsis' && (clips || SCROLLS.test(overflow));
35
+ if (ellipsis)
36
+ out.push('ellipsis');
37
+ else if (!NO_WRAP.test(el.whiteSpace))
38
+ out.push('wraps');
39
+ else
40
+ out.push('nowrap');
41
+ }
42
+ return out;
43
+ }
44
+ /**
45
+ * Whether a collapsed box is hiding anything.
46
+ *
47
+ * A `0x0` spacer is ordinary and uninteresting; a `0x1239` column with a page
48
+ * of content inside it is the bug the prototype measured and discarded. The
49
+ * difference is entirely in what the box contains, so that is what the fact
50
+ * carries.
51
+ */
52
+ function contentOf(el, byId) {
53
+ if (el.text)
54
+ return `"${el.text}"`;
55
+ let text = '';
56
+ let elements = 0;
57
+ const queue = [...el.childIds];
58
+ while (queue.length > 0 && text.length < 40) {
59
+ const child = byId.get(queue.shift() ?? '');
60
+ if (!child)
61
+ continue;
62
+ elements++;
63
+ if (child.text)
64
+ text = `${text} ${child.text}`.trim();
65
+ queue.push(...child.childIds);
66
+ }
67
+ if (text)
68
+ return `"${text.slice(0, 40)}"`;
69
+ return elements > 0 ? `${elements} element${elements === 1 ? '' : 's'}` : null;
70
+ }
71
+ /**
72
+ * How much of an element an ancestor hides, and whether anyone can get to it.
73
+ *
74
+ * `attachClipFacts` has populated `ElementRecord.clip` on both capture paths
75
+ * since step 4, and until now nothing rendered it — the digest emitted the
76
+ * three fact kinds above and silently dropped this one. The V1 cold read's
77
+ * single strongest result came from the step-2 spike's renderer, not from
78
+ * shipped code, and nobody noticed because the tests asserted on `clip` the
79
+ * field rather than on the output.
80
+ *
81
+ * `scrollable` is the counter-fact that carries the whole meaning. A box
82
+ * outside an `overflow:auto` ancestor is a scroll region working as intended;
83
+ * the same box outside an `overflow:hidden` ancestor is content nobody can
84
+ * reach. Printing the extent without the reachability is a measurement the
85
+ * reader cannot act on.
86
+ */
87
+ function clipFact(el) {
88
+ const clip = el.clip;
89
+ if (!clip)
90
+ return null;
91
+ const where = [
92
+ clip.hidden.top ? `${round(clip.hidden.top)}px above` : null,
93
+ clip.hidden.bottom ? `${round(clip.hidden.bottom)}px below` : null,
94
+ clip.hidden.left ? `${round(clip.hidden.left)}px left` : null,
95
+ clip.hidden.right ? `${round(clip.hidden.right)}px right` : null,
96
+ ]
97
+ .filter(Boolean)
98
+ .join(', ');
99
+ const visible = clip.visible.width < ZERO_EPSILON || clip.visible.height < ZERO_EPSILON
100
+ ? 'nothing visible'
101
+ : `${round(clip.visible.width)}x${round(clip.visible.height)} visible`;
102
+ return {
103
+ kind: 'clipped',
104
+ text: `clipped by ${clip.by} ${clip.byTag}; overflow:${clip.overflow}, ` +
105
+ `${clip.scrollable ? 'scrollable' : 'not scrollable'}; ${where}, ${visible}`,
106
+ };
107
+ }
108
+ /**
109
+ * Every fact this element has. Unconditional: no thresholds beyond subpixel
110
+ * noise, no severity, no suppression.
111
+ */
112
+ export function factsFor(el, byId) {
113
+ const facts = [];
114
+ const overflowX = el.scrollWidth - el.clientWidth;
115
+ if (overflowX > OVERFLOW_EPSILON) {
116
+ facts.push({
117
+ kind: 'overflow-x',
118
+ text: `overflow-x +${round(overflowX)}px; ${consequences(el, 'x').join(', ')}`,
119
+ });
120
+ }
121
+ const overflowY = el.scrollHeight - el.clientHeight;
122
+ if (overflowY > OVERFLOW_EPSILON) {
123
+ facts.push({
124
+ kind: 'overflow-y',
125
+ text: `overflow-y +${round(overflowY)}px; ${consequences(el, 'y').join(', ')}`,
126
+ });
127
+ }
128
+ // Only for elements that are still in the layout. `display:none` collapses
129
+ // everything to 0x0 and saying so about every hidden node is noise.
130
+ const collapsed = el.display !== 'none' &&
131
+ (el.rect.width < ZERO_EPSILON || el.rect.height < ZERO_EPSILON) &&
132
+ !(el.rect.width < ZERO_EPSILON && el.rect.height < ZERO_EPSILON && !el.childIds.length);
133
+ if (collapsed) {
134
+ const content = contentOf(el, byId);
135
+ if (content) {
136
+ facts.push({
137
+ kind: 'zero-box',
138
+ text: `${round(el.rect.width)}x${round(el.rect.height)} zero-box; content ${content}`,
139
+ });
140
+ }
141
+ }
142
+ const clipped = clipFact(el);
143
+ if (clipped)
144
+ facts.push(clipped);
145
+ return facts;
146
+ }
147
+ /** `[fact] [fact]` — the form these take on a digest line. */
148
+ export function renderFacts(facts) {
149
+ return facts.map((f) => `[${f.text}]`).join(' ');
150
+ }
151
+ /** Every element that has at least one fact, in document order. */
152
+ export function factsForAll(elements) {
153
+ const byId = new Map(elements.map((e) => [e.id, e]));
154
+ const out = [];
155
+ for (const element of elements) {
156
+ const facts = factsFor(element, byId);
157
+ if (facts.length > 0)
158
+ out.push({ element, facts });
159
+ }
160
+ return out;
161
+ }
@@ -0,0 +1,69 @@
1
+ "use strict";
2
+ /**
3
+ * Limits shared by both capture paths.
4
+ *
5
+ * They live here rather than in either collector because a capture has to mean
6
+ * the same thing whichever path produced it. The first benchmark caught them
7
+ * disagreeing: on a Wikipedia article the in-page walk stopped at 5,000
8
+ * elements and reported `truncated: true`, while the CDP path returned 6,927
9
+ * and hardcoded `truncated: false`. Two captures of one page that are not
10
+ * comparable, and a flag that silently means "maybe".
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.COLOR_TOKEN_CAP = exports.SKIPPED_TAGS = exports.TESTID_ATTRS = exports.NAME_LENGTH = exports.TEXT_PREVIEW_LENGTH = exports.MAX_ELEMENTS = void 0;
14
+ exports.capBackgroundImage = capBackgroundImage;
15
+ /**
16
+ * The element ceiling. Not a performance guard -- both paths are fast enough
17
+ * at this size -- but a token guard: a digest of 5,000 elements is already far
18
+ * past what any consumer will read, and past it the honest answer is
19
+ * `truncated: true` plus a narrower capture.
20
+ */
21
+ exports.MAX_ELEMENTS = 5000;
22
+ /** Own-text preview length, in characters. */
23
+ exports.TEXT_PREVIEW_LENGTH = 60;
24
+ /** Accessible-name length, in characters. */
25
+ exports.NAME_LENGTH = 80;
26
+ /** Attributes treated as a stable test id, in priority order. */
27
+ exports.TESTID_ATTRS = ['data-testid', 'data-test-id', 'data-test', 'data-qa'];
28
+ /** Tags whose subtrees carry no layout worth measuring. */
29
+ exports.SKIPPED_TAGS = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG'];
30
+ /**
31
+ * `background-image` / `text-shadow` string cap, in characters.
32
+ *
33
+ * A gradient or shadow's computed value can run to hundreds of characters
34
+ * (many stops, multiple shadows) — `docs/notes/03-color.md` §7 estimates real
35
+ * bundle growth at ~105 bytes/element for the naive uncapped fields, so this
36
+ * caps the two least-bounded ones at the point of capture rather than on
37
+ * render. `snapshot/color.ts` only ever reads the first and last gradient
38
+ * stop and the first shadow color — but "only reads the ends" is exactly
39
+ * why a *plain prefix* cut is wrong for `background-image`: a real 137-char
40
+ * gradient truncated at 120 chars loses its own last stop, the one the
41
+ * gradient token needs (verified against a real capture; A4). See
42
+ * `capBackgroundImage` below for the fix. `text-shadow` has no such gap —
43
+ * `firstShadowColor` only ever reads the *first* comma-separated segment's
44
+ * first color token, so a plain prefix cut is still exactly right for it.
45
+ */
46
+ exports.COLOR_TOKEN_CAP = 120;
47
+ /**
48
+ * Caps `raw` to `cap` characters while preserving *both* ends, joined by an
49
+ * ellipsis, instead of a plain prefix cut. `background-image`'s only two
50
+ * downstream readers (`resolveBackdrop`'s gradient-stop parse and `q color`'s
51
+ * display) need the *first* and *last* color token, never the middle — a
52
+ * plain `slice(0, cap)` can and does drop the last stop off a real multi-stop
53
+ * gradient whose computed string runs past `cap` (A4). Splitting the budget
54
+ * across both ends fixes that regardless of how many stops sit in the middle:
55
+ * the string might read `linear-gradient(...) 0%, …, rgb(255,255,255) 100%)`
56
+ * instead of every stop, but the two that matter always survive.
57
+ *
58
+ * `collect.ts` restates this inline (`page.evaluate` serializes only its own
59
+ * function source, so it cannot import this module) — kept in sync by
60
+ * `test/unit/limits.test.ts`, the same discipline `MAX_ELEMENTS` and the
61
+ * other capture-path constants already use.
62
+ */
63
+ function capBackgroundImage(raw, cap = exports.COLOR_TOKEN_CAP) {
64
+ if (raw.length <= cap)
65
+ return raw;
66
+ const ELLIPSIS = '…';
67
+ const half = Math.max(0, Math.floor((cap - ELLIPSIS.length) / 2));
68
+ return `${raw.slice(0, half)}${ELLIPSIS}${raw.slice(raw.length - half)}`;
69
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Limits shared by both capture paths.
3
+ *
4
+ * They live here rather than in either collector because a capture has to mean
5
+ * the same thing whichever path produced it. The first benchmark caught them
6
+ * disagreeing: on a Wikipedia article the in-page walk stopped at 5,000
7
+ * elements and reported `truncated: true`, while the CDP path returned 6,927
8
+ * and hardcoded `truncated: false`. Two captures of one page that are not
9
+ * comparable, and a flag that silently means "maybe".
10
+ */
11
+ /**
12
+ * The element ceiling. Not a performance guard -- both paths are fast enough
13
+ * at this size -- but a token guard: a digest of 5,000 elements is already far
14
+ * past what any consumer will read, and past it the honest answer is
15
+ * `truncated: true` plus a narrower capture.
16
+ */
17
+ export declare const MAX_ELEMENTS = 5000;
18
+ /** Own-text preview length, in characters. */
19
+ export declare const TEXT_PREVIEW_LENGTH = 60;
20
+ /** Accessible-name length, in characters. */
21
+ export declare const NAME_LENGTH = 80;
22
+ /** Attributes treated as a stable test id, in priority order. */
23
+ export declare const TESTID_ATTRS: readonly ["data-testid", "data-test-id", "data-test", "data-qa"];
24
+ /** Tags whose subtrees carry no layout worth measuring. */
25
+ export declare const SKIPPED_TAGS: readonly ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"];
26
+ /**
27
+ * `background-image` / `text-shadow` string cap, in characters.
28
+ *
29
+ * A gradient or shadow's computed value can run to hundreds of characters
30
+ * (many stops, multiple shadows) — `docs/notes/03-color.md` §7 estimates real
31
+ * bundle growth at ~105 bytes/element for the naive uncapped fields, so this
32
+ * caps the two least-bounded ones at the point of capture rather than on
33
+ * render. `snapshot/color.ts` only ever reads the first and last gradient
34
+ * stop and the first shadow color — but "only reads the ends" is exactly
35
+ * why a *plain prefix* cut is wrong for `background-image`: a real 137-char
36
+ * gradient truncated at 120 chars loses its own last stop, the one the
37
+ * gradient token needs (verified against a real capture; A4). See
38
+ * `capBackgroundImage` below for the fix. `text-shadow` has no such gap —
39
+ * `firstShadowColor` only ever reads the *first* comma-separated segment's
40
+ * first color token, so a plain prefix cut is still exactly right for it.
41
+ */
42
+ export declare const COLOR_TOKEN_CAP = 120;
43
+ /**
44
+ * Caps `raw` to `cap` characters while preserving *both* ends, joined by an
45
+ * ellipsis, instead of a plain prefix cut. `background-image`'s only two
46
+ * downstream readers (`resolveBackdrop`'s gradient-stop parse and `q color`'s
47
+ * display) need the *first* and *last* color token, never the middle — a
48
+ * plain `slice(0, cap)` can and does drop the last stop off a real multi-stop
49
+ * gradient whose computed string runs past `cap` (A4). Splitting the budget
50
+ * across both ends fixes that regardless of how many stops sit in the middle:
51
+ * the string might read `linear-gradient(...) 0%, …, rgb(255,255,255) 100%)`
52
+ * instead of every stop, but the two that matter always survive.
53
+ *
54
+ * `collect.ts` restates this inline (`page.evaluate` serializes only its own
55
+ * function source, so it cannot import this module) — kept in sync by
56
+ * `test/unit/limits.test.ts`, the same discipline `MAX_ELEMENTS` and the
57
+ * other capture-path constants already use.
58
+ */
59
+ export declare function capBackgroundImage(raw: string, cap?: number): string;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Limits shared by both capture paths.
3
+ *
4
+ * They live here rather than in either collector because a capture has to mean
5
+ * the same thing whichever path produced it. The first benchmark caught them
6
+ * disagreeing: on a Wikipedia article the in-page walk stopped at 5,000
7
+ * elements and reported `truncated: true`, while the CDP path returned 6,927
8
+ * and hardcoded `truncated: false`. Two captures of one page that are not
9
+ * comparable, and a flag that silently means "maybe".
10
+ */
11
+ /**
12
+ * The element ceiling. Not a performance guard -- both paths are fast enough
13
+ * at this size -- but a token guard: a digest of 5,000 elements is already far
14
+ * past what any consumer will read, and past it the honest answer is
15
+ * `truncated: true` plus a narrower capture.
16
+ */
17
+ export declare const MAX_ELEMENTS = 5000;
18
+ /** Own-text preview length, in characters. */
19
+ export declare const TEXT_PREVIEW_LENGTH = 60;
20
+ /** Accessible-name length, in characters. */
21
+ export declare const NAME_LENGTH = 80;
22
+ /** Attributes treated as a stable test id, in priority order. */
23
+ export declare const TESTID_ATTRS: readonly ["data-testid", "data-test-id", "data-test", "data-qa"];
24
+ /** Tags whose subtrees carry no layout worth measuring. */
25
+ export declare const SKIPPED_TAGS: readonly ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG"];
26
+ /**
27
+ * `background-image` / `text-shadow` string cap, in characters.
28
+ *
29
+ * A gradient or shadow's computed value can run to hundreds of characters
30
+ * (many stops, multiple shadows) — `docs/notes/03-color.md` §7 estimates real
31
+ * bundle growth at ~105 bytes/element for the naive uncapped fields, so this
32
+ * caps the two least-bounded ones at the point of capture rather than on
33
+ * render. `snapshot/color.ts` only ever reads the first and last gradient
34
+ * stop and the first shadow color — but "only reads the ends" is exactly
35
+ * why a *plain prefix* cut is wrong for `background-image`: a real 137-char
36
+ * gradient truncated at 120 chars loses its own last stop, the one the
37
+ * gradient token needs (verified against a real capture; A4). See
38
+ * `capBackgroundImage` below for the fix. `text-shadow` has no such gap —
39
+ * `firstShadowColor` only ever reads the *first* comma-separated segment's
40
+ * first color token, so a plain prefix cut is still exactly right for it.
41
+ */
42
+ export declare const COLOR_TOKEN_CAP = 120;
43
+ /**
44
+ * Caps `raw` to `cap` characters while preserving *both* ends, joined by an
45
+ * ellipsis, instead of a plain prefix cut. `background-image`'s only two
46
+ * downstream readers (`resolveBackdrop`'s gradient-stop parse and `q color`'s
47
+ * display) need the *first* and *last* color token, never the middle — a
48
+ * plain `slice(0, cap)` can and does drop the last stop off a real multi-stop
49
+ * gradient whose computed string runs past `cap` (A4). Splitting the budget
50
+ * across both ends fixes that regardless of how many stops sit in the middle:
51
+ * the string might read `linear-gradient(...) 0%, …, rgb(255,255,255) 100%)`
52
+ * instead of every stop, but the two that matter always survive.
53
+ *
54
+ * `collect.ts` restates this inline (`page.evaluate` serializes only its own
55
+ * function source, so it cannot import this module) — kept in sync by
56
+ * `test/unit/limits.test.ts`, the same discipline `MAX_ELEMENTS` and the
57
+ * other capture-path constants already use.
58
+ */
59
+ export declare function capBackgroundImage(raw: string, cap?: number): string;