@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,1026 @@
1
+ const clamp01 = (n) => Math.min(1, Math.max(0, n));
2
+ const isPercent = (s) => s.trim().endsWith('%');
3
+ const num = (s) => Number.parseFloat(s);
4
+ function parseAlphaToken(s) {
5
+ if (s === undefined)
6
+ return 1;
7
+ const trimmed = s.trim();
8
+ if (trimmed === '' || trimmed.toLowerCase() === 'none')
9
+ return 1;
10
+ return isPercent(trimmed) ? clamp01(num(trimmed) / 100) : clamp01(num(trimmed));
11
+ }
12
+ /** Splits a CSS color function's parenthesized body into components + alpha,
13
+ * handling both the legacy comma syntax (`rgba(0, 0, 0, 0.5)`) and the
14
+ * modern space/slash syntax (`rgb(0 0 0 / 50%)`) -- `getComputedStyle`
15
+ * returns the former for `rgb()`/`hsl()` (verified,
16
+ * `docs/notes/03-color.md` §2) and the latter is cheap to also accept. */
17
+ function splitComponents(inner) {
18
+ const slash = inner.indexOf('/');
19
+ const main = slash === -1 ? inner : inner.slice(0, slash);
20
+ let alpha = slash === -1 ? undefined : inner.slice(slash + 1).trim();
21
+ const parts = main.includes(',')
22
+ ? main.split(',').map((s) => s.trim())
23
+ : main.trim().split(/\s+/).filter(Boolean);
24
+ if (alpha === undefined && parts.length === 4)
25
+ alpha = parts.pop();
26
+ return { parts, alpha };
27
+ }
28
+ function channelFromToken(s) {
29
+ const v = isPercent(s) ? (num(s) / 100) * 255 : num(s);
30
+ return Math.round(clamp01(v / 255) * 255);
31
+ }
32
+ function parseRgbFn(inner) {
33
+ const { parts, alpha } = splitComponents(inner);
34
+ if (parts.length !== 3)
35
+ return null;
36
+ const [r, g, b] = parts;
37
+ return {
38
+ r: channelFromToken(r),
39
+ g: channelFromToken(g),
40
+ b: channelFromToken(b),
41
+ a: parseAlphaToken(alpha),
42
+ };
43
+ }
44
+ function hslToRgb(h, s, l) {
45
+ const c = (1 - Math.abs(2 * l - 1)) * s;
46
+ const hp = (((h % 360) + 360) % 360) / 60;
47
+ const x = c * (1 - Math.abs((hp % 2) - 1));
48
+ let [r1, g1, b1] = [0, 0, 0];
49
+ if (hp < 1)
50
+ [r1, g1, b1] = [c, x, 0];
51
+ else if (hp < 2)
52
+ [r1, g1, b1] = [x, c, 0];
53
+ else if (hp < 3)
54
+ [r1, g1, b1] = [0, c, x];
55
+ else if (hp < 4)
56
+ [r1, g1, b1] = [0, x, c];
57
+ else if (hp < 5)
58
+ [r1, g1, b1] = [x, 0, c];
59
+ else
60
+ [r1, g1, b1] = [c, 0, x];
61
+ const m = l - c / 2;
62
+ return {
63
+ r: Math.round(clamp01(r1 + m) * 255),
64
+ g: Math.round(clamp01(g1 + m) * 255),
65
+ b: Math.round(clamp01(b1 + m) * 255),
66
+ };
67
+ }
68
+ function parseHslFn(inner) {
69
+ const { parts, alpha } = splitComponents(inner);
70
+ if (parts.length !== 3)
71
+ return null;
72
+ const [hs, ss, ls] = parts;
73
+ const h = num(hs.replace(/deg$/i, ''));
74
+ const s = isPercent(ss) ? num(ss) / 100 : num(ss);
75
+ const l = isPercent(ls) ? num(ls) / 100 : num(ls);
76
+ if (!Number.isFinite(h) || !Number.isFinite(s) || !Number.isFinite(l))
77
+ return null;
78
+ return { ...hslToRgb(h, s, l), a: parseAlphaToken(alpha) };
79
+ }
80
+ function parseHex(raw) {
81
+ const m = /^#([0-9a-f]{3,8})$/i.exec(raw.trim());
82
+ if (!m?.[1])
83
+ return null;
84
+ let h = m[1];
85
+ if (h.length === 3 || h.length === 4) {
86
+ h = h
87
+ .split('')
88
+ .map((c) => c + c)
89
+ .join('');
90
+ }
91
+ if (h.length !== 6 && h.length !== 8)
92
+ return null;
93
+ const r = Number.parseInt(h.slice(0, 2), 16);
94
+ const g = Number.parseInt(h.slice(2, 4), 16);
95
+ const b = Number.parseInt(h.slice(4, 6), 16);
96
+ const a = h.length === 8 ? Number.parseInt(h.slice(6, 8), 16) / 255 : 1;
97
+ return { r, g, b, a };
98
+ }
99
+ // ---- wide-gamut / perceptual functions -------------------------------------
100
+ // Every matrix below is verified against real Chromium canvas readback in
101
+ // `test/browser/color-space-conversion.test.ts`, not trusted from memory of
102
+ // the spec text alone -- see that file and the probe this module's header
103
+ // points to.
104
+ /** Björn Ottosson's OKLab <-> linear sRGB matrices. */
105
+ function oklabToLinearSrgb(L, a, b) {
106
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
107
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
108
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b;
109
+ const l = l_ ** 3;
110
+ const m = m_ ** 3;
111
+ const s = s_ ** 3;
112
+ return {
113
+ r: 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
114
+ g: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
115
+ b: -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
116
+ };
117
+ }
118
+ function linearToSrgbChannel(c) {
119
+ const abs = Math.abs(c);
120
+ return abs <= 0.0031308 ? c * 12.92 : Math.sign(c) * (1.055 * abs ** (1 / 2.4) - 0.055);
121
+ }
122
+ function srgbTransferToLinear(c) {
123
+ const abs = Math.abs(c);
124
+ return abs <= 0.04045 ? c / 12.92 : Math.sign(c) * ((abs + 0.055) / 1.055) ** 2.4;
125
+ }
126
+ /** Linear-light values (any gamut already mapped to linear sRGB primaries)
127
+ * -> 0-255 sRGB, clamping out-of-gamut channels rather than gamut-mapping
128
+ * perceptually -- the same simple per-channel clamp Chromium's own canvas
129
+ * rasterizer uses (verified: `color(display-p3 1 0 0)` reads back as pure
130
+ * `rgb(255,0,0)`, which only a hard clamp -- not perceptual remapping --
131
+ * produces). Disclosed as `clamped` so a caller can tell a lossy conversion
132
+ * from an exact one. */
133
+ function linearToRgb255(lin, alpha) {
134
+ let clamped = false;
135
+ const chan = (c) => {
136
+ const s = linearToSrgbChannel(c);
137
+ const cl = clamp01(s);
138
+ if (Math.abs(cl - s) > 1e-9)
139
+ clamped = true;
140
+ return Math.round(cl * 255);
141
+ };
142
+ return { rgba: { r: chan(lin.r), g: chan(lin.g), b: chan(lin.b), a: clamp01(alpha) }, clamped };
143
+ }
144
+ function oklchComponents(L, C, H) {
145
+ const l = isPercent(L) ? num(L) / 100 : num(L);
146
+ const c = isPercent(C) ? (num(C) / 100) * 0.4 : num(C);
147
+ const h = ((num(H.replace(/deg$/i, '')) % 360) + 360) % 360;
148
+ return [l, c, h];
149
+ }
150
+ function oklabComponents(L, a, b) {
151
+ const l = isPercent(L) ? num(L) / 100 : num(L);
152
+ const ac = isPercent(a) ? (num(a) / 100) * 0.4 : num(a);
153
+ const bc = isPercent(b) ? (num(b) / 100) * 0.4 : num(b);
154
+ return [l, ac, bc];
155
+ }
156
+ function parseOklchFn(inner) {
157
+ const { parts, alpha } = splitComponents(inner);
158
+ if (parts.length !== 3)
159
+ return null;
160
+ const [Ls, Cs, Hs] = parts;
161
+ const [L, C, H] = oklchComponents(Ls, Cs, Hs);
162
+ if (!Number.isFinite(L) || !Number.isFinite(C) || !Number.isFinite(H))
163
+ return null;
164
+ const hRad = (H * Math.PI) / 180;
165
+ return linearToRgb255(oklabToLinearSrgb(L, C * Math.cos(hRad), C * Math.sin(hRad)), parseAlphaToken(alpha));
166
+ }
167
+ function parseOklabFn(inner) {
168
+ const { parts, alpha } = splitComponents(inner);
169
+ if (parts.length !== 3)
170
+ return null;
171
+ const [Ls, As, Bs] = parts;
172
+ const [L, a, b] = oklabComponents(Ls, As, Bs);
173
+ if (!Number.isFinite(L) || !Number.isFinite(a) || !Number.isFinite(b))
174
+ return null;
175
+ return linearToRgb255(oklabToLinearSrgb(L, a, b), parseAlphaToken(alpha));
176
+ }
177
+ /** CSS Color 4's Lab -> D50 XYZ, then a Bradford-adapted D50->linear-sRGB
178
+ * matrix combined into one step (verified against Chromium canvas readback:
179
+ * `lab(50 40 59.5)` -> `rgb(191,87,0)` exactly, out-of-gamut and clamped). */
180
+ function labToXyzD50(L, a, b) {
181
+ const k = 24389 / 27;
182
+ const e = 216 / 24389;
183
+ const fy = (L + 16) / 116;
184
+ const fx = fy + a / 500;
185
+ const fz = fy - b / 200;
186
+ const fx3 = fx ** 3;
187
+ const fz3 = fz ** 3;
188
+ const xr = fx3 > e ? fx3 : (116 * fx - 16) / k;
189
+ const yr = L > k * e ? ((L + 16) / 116) ** 3 : L / k;
190
+ const zr = fz3 > e ? fz3 : (116 * fz - 16) / k;
191
+ const Xn = 0.3457 / 0.3585;
192
+ const Yn = 1;
193
+ const Zn = (1 - 0.3457 - 0.3585) / 0.3585;
194
+ return { X: xr * Xn, Y: yr * Yn, Z: zr * Zn };
195
+ }
196
+ function xyzD50ToLinearSrgb({ X, Y, Z }) {
197
+ return {
198
+ r: 3.1341359569 * X - 1.6172472247 * Y - 0.4906994307 * Z,
199
+ g: -0.9787684156 * X + 1.9161415014 * Y + 0.0334540657 * Z,
200
+ b: 0.0719453189 * X - 0.2289913338 * Y + 1.4052427493 * Z,
201
+ };
202
+ }
203
+ function labComponents(L, a, b) {
204
+ const l = num(L);
205
+ const ac = isPercent(a) ? (num(a) / 100) * 125 : num(a);
206
+ const bc = isPercent(b) ? (num(b) / 100) * 125 : num(b);
207
+ return [l, ac, bc];
208
+ }
209
+ function parseLabFn(inner) {
210
+ const { parts, alpha } = splitComponents(inner);
211
+ if (parts.length !== 3)
212
+ return null;
213
+ const [Ls, As, Bs] = parts;
214
+ const [L, a, b] = labComponents(Ls, As, Bs);
215
+ if (!Number.isFinite(L) || !Number.isFinite(a) || !Number.isFinite(b))
216
+ return null;
217
+ return linearToRgb255(xyzD50ToLinearSrgb(labToXyzD50(L, a, b)), parseAlphaToken(alpha));
218
+ }
219
+ function parseLchFn(inner) {
220
+ const { parts, alpha } = splitComponents(inner);
221
+ if (parts.length !== 3)
222
+ return null;
223
+ const [Ls, Cs, Hs] = parts;
224
+ const l = num(Ls);
225
+ const c = isPercent(Cs) ? (num(Cs) / 100) * 150 : num(Cs);
226
+ const h = num(Hs.replace(/deg$/i, ''));
227
+ if (!Number.isFinite(l) || !Number.isFinite(c) || !Number.isFinite(h))
228
+ return null;
229
+ const hRad = (h * Math.PI) / 180;
230
+ return linearToRgb255(xyzD50ToLinearSrgb(labToXyzD50(l, c * Math.cos(hRad), c * Math.sin(hRad))), parseAlphaToken(alpha));
231
+ }
232
+ /** linear-P3 -> linear-sRGB (CSS Color 4's matrix; verified against Chromium
233
+ * canvas readback -- see this file's header). */
234
+ function p3LinearToSrgbLinear(r, g, b) {
235
+ return {
236
+ r: 1.2249401762 * r - 0.2249401762 * g + 0.0 * b,
237
+ g: -0.0420569547 * r + 1.0420569547 * g + 0.0 * b,
238
+ b: -0.0196375546 * r - 0.078636097 * g + 1.0982736516 * b,
239
+ };
240
+ }
241
+ function parseColorFn(space, inner) {
242
+ const { parts, alpha } = splitComponents(inner);
243
+ if (parts.length !== 3)
244
+ return null;
245
+ const chan = (s) => (isPercent(s) ? num(s) / 100 : num(s));
246
+ const [r, g, b] = parts.map(chan);
247
+ if (!Number.isFinite(r) || !Number.isFinite(g) || !Number.isFinite(b))
248
+ return null;
249
+ const a = parseAlphaToken(alpha);
250
+ switch (space) {
251
+ case 'srgb':
252
+ // `color(srgb r g b)` components are already gamma-encoded sRGB in the
253
+ // 0-1 range -- no transfer function, just clamp and scale to 8-bit.
254
+ return {
255
+ rgba: {
256
+ r: Math.round(clamp01(r) * 255),
257
+ g: Math.round(clamp01(g) * 255),
258
+ b: Math.round(clamp01(b) * 255),
259
+ a,
260
+ },
261
+ clamped: r < 0 || r > 1 || g < 0 || g > 1 || b < 0 || b > 1,
262
+ };
263
+ case 'srgb-linear':
264
+ return linearToRgb255({ r, g, b }, a);
265
+ case 'display-p3': {
266
+ const linear = p3LinearToSrgbLinear(srgbTransferToLinear(r), srgbTransferToLinear(g), srgbTransferToLinear(b));
267
+ return linearToRgb255(linear, a);
268
+ }
269
+ default:
270
+ return null;
271
+ }
272
+ }
273
+ /**
274
+ * Parse a computed-style color string to sRGB, or refuse honestly.
275
+ *
276
+ * Every space `docs/notes/03-color.md` §3 calls out as a real capture
277
+ * problem -- `oklch`, `oklab`, `lab`, `lch`, `color(srgb ...)`,
278
+ * `color(srgb-linear ...)`, `color(display-p3 ...)` -- converts. Anything
279
+ * else (a future space, a syntax this parser does not recognise) returns the
280
+ * raw string verbatim rather than guessing: rule 4 of the design note's §5
281
+ * applies to color parsing exactly as it does to the ratio itself.
282
+ */
283
+ export function parseCssColor(raw) {
284
+ const trimmed = raw.trim();
285
+ if (trimmed === '' || trimmed.toLowerCase() === 'transparent') {
286
+ return { kind: 'resolved', rgba: { r: 0, g: 0, b: 0, a: 0 } };
287
+ }
288
+ const hex = parseHex(trimmed);
289
+ if (hex)
290
+ return { kind: 'resolved', rgba: hex };
291
+ const fn = /^([a-z-]+)\(([^)]*)\)$/i.exec(trimmed);
292
+ if (fn?.[1] && fn[2] !== undefined) {
293
+ const name = fn[1].toLowerCase();
294
+ const inner = fn[2];
295
+ if (name === 'rgb' || name === 'rgba') {
296
+ const rgba = parseRgbFn(inner);
297
+ if (rgba)
298
+ return { kind: 'resolved', rgba };
299
+ }
300
+ else if (name === 'hsl' || name === 'hsla') {
301
+ const rgba = parseHslFn(inner);
302
+ if (rgba)
303
+ return { kind: 'resolved', rgba };
304
+ }
305
+ else if (name === 'oklch') {
306
+ const parsed = parseOklchFn(inner);
307
+ if (parsed)
308
+ return { kind: 'resolved', rgba: parsed.rgba };
309
+ }
310
+ else if (name === 'oklab') {
311
+ const parsed = parseOklabFn(inner);
312
+ if (parsed)
313
+ return { kind: 'resolved', rgba: parsed.rgba };
314
+ }
315
+ else if (name === 'lab') {
316
+ const parsed = parseLabFn(inner);
317
+ if (parsed)
318
+ return { kind: 'resolved', rgba: parsed.rgba };
319
+ }
320
+ else if (name === 'lch') {
321
+ const parsed = parseLchFn(inner);
322
+ if (parsed)
323
+ return { kind: 'resolved', rgba: parsed.rgba };
324
+ }
325
+ else if (name === 'color') {
326
+ const spaceMatch = /^\s*([a-z0-9-]+)\s+(.*)$/i.exec(inner);
327
+ if (spaceMatch?.[1] && spaceMatch[2] !== undefined) {
328
+ const parsed = parseColorFn(spaceMatch[1].toLowerCase(), spaceMatch[2]);
329
+ if (parsed)
330
+ return { kind: 'resolved', rgba: parsed.rgba };
331
+ }
332
+ }
333
+ }
334
+ return { kind: 'verbatim', raw: trimmed };
335
+ }
336
+ // ---------------------------------------------------------------------------
337
+ // Formatting
338
+ // ---------------------------------------------------------------------------
339
+ function channelHex(n) {
340
+ return Math.max(0, Math.min(255, Math.round(n)))
341
+ .toString(16)
342
+ .padStart(2, '0');
343
+ }
344
+ /** `#rrggbb`, or `#rrggbbaa` when `alpha` is given and < 1. */
345
+ export function toHex(rgb, alpha) {
346
+ const base = `#${channelHex(rgb.r)}${channelHex(rgb.g)}${channelHex(rgb.b)}`;
347
+ return alpha === undefined ? base : `${base}${channelHex(alpha * 255)}`;
348
+ }
349
+ /** A resolved color's display form: hex (with an alpha suffix below full
350
+ * opacity), or the raw CSS string verbatim when the space could not be
351
+ * converted -- "Colors print as lowercase hex ..., or verbatim when a color
352
+ * space could not be converted" (design note §4). */
353
+ function displayColor(raw) {
354
+ const parsed = parseCssColor(raw);
355
+ if (parsed.kind === 'verbatim')
356
+ return parsed.raw;
357
+ return toHex(parsed.rgba, parsed.rgba.a < 0.999 ? parsed.rgba.a : undefined);
358
+ }
359
+ // ---------------------------------------------------------------------------
360
+ // WCAG 2.x contrast
361
+ // ---------------------------------------------------------------------------
362
+ function srgbToLinearChannel(c8) {
363
+ const c = c8 / 255;
364
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
365
+ }
366
+ /** WCAG 2.x relative luminance. The name of a formula, never a threshold. */
367
+ export function relativeLuminance(rgb) {
368
+ return (0.2126 * srgbToLinearChannel(rgb.r) +
369
+ 0.7152 * srgbToLinearChannel(rgb.g) +
370
+ 0.0722 * srgbToLinearChannel(rgb.b));
371
+ }
372
+ /** WCAG 2.x contrast ratio between two opaque sRGB colors. */
373
+ export function contrastRatio(a, b) {
374
+ const la = relativeLuminance(a);
375
+ const lb = relativeLuminance(b);
376
+ const lighter = Math.max(la, lb);
377
+ const darker = Math.min(la, lb);
378
+ return (lighter + 0.05) / (darker + 0.05);
379
+ }
380
+ function compositeOver(top, bottomOpaque) {
381
+ const a = clamp01(top.a);
382
+ return {
383
+ r: top.r * a + bottomOpaque.r * (1 - a),
384
+ g: top.g * a + bottomOpaque.g * (1 - a),
385
+ b: top.b * a + bottomOpaque.b * (1 - a),
386
+ };
387
+ }
388
+ // ---------------------------------------------------------------------------
389
+ // `background-image`: the counter-fact that invalidates a backdrop
390
+ // ---------------------------------------------------------------------------
391
+ const COLOR_TOKEN_RE = /rgba?\([^)]*\)|hsla?\([^)]*\)|color\([^)]*\)|oklch\([^)]*\)|oklab\([^)]*\)|lch\([^)]*\)|lab\([^)]*\)|#[0-9a-fA-F]{3,8}\b/g;
392
+ /**
393
+ * Refuse rather than let a missing field fail deep inside
394
+ * `parseBackgroundImageKind`/`firstShadowColor`/`parsePx` as an unreadable
395
+ * `TypeError: Cannot read properties of undefined (reading 'trim')` -- named
396
+ * after the field that is missing and what would have supplied it, the same
397
+ * quality of diagnosis `cli.ts#readBundle` gives for a malformed bundle file.
398
+ * A bundle read through `parseBundle` always satisfies this by construction
399
+ * (`bundle.ts#normalizeElement` backfills all four for a bundle written
400
+ * before they existed); this only ever fires for a record assembled by hand
401
+ * -- directly, or via `capture()`'s in-memory result mutated before use --
402
+ * that skipped one.
403
+ */
404
+ function requireColorField(elementId, field, value, opts) {
405
+ // `null` is a legitimate, distinct third state on `backgroundImage` only
406
+ // (A3): "this specific capture never measured this field", not "the
407
+ // caller skipped a required field". `resolveBackdrop`/`hasPaint` are the
408
+ // code that actually acts on that `null`; this check only needs to let it
409
+ // through rather than treat it as the construction error `undefined` is.
410
+ if (value === null && opts?.allowNull)
411
+ return;
412
+ if (typeof value !== 'string') {
413
+ throw new Error(`dnumb: element "${elementId}" has no "${field}" (added for color capture -- every real ` +
414
+ 'capture sets it, and parseBundle backfills it for a bundle written before this field ' +
415
+ 'existed). A hand-built ElementRecord must include it -- see the type exported from ' +
416
+ '"@michaelmagan/dnumb/spec", or produce the record with capture()/parseBundle instead of by hand.');
417
+ }
418
+ }
419
+ /**
420
+ * `none`, a gradient (first and last color stop, `docs/notes/03-color.md`
421
+ * §2's "the stops are real, printable facts even though no ratio is"), or
422
+ * anything else that paints (`url()`, and any other CSS image function this
423
+ * parser does not specifically recognise, treated the same conservative
424
+ * way): all invalidate a resolved backdrop.
425
+ */
426
+ function parseBackgroundImageKind(raw, elementId) {
427
+ requireColorField(elementId, 'backgroundImage', raw);
428
+ const trimmed = raw.trim();
429
+ if (trimmed === '' || trimmed.toLowerCase() === 'none')
430
+ return { kind: 'none' };
431
+ if (/-gradient\(/i.test(trimmed)) {
432
+ const tokens = [...trimmed.matchAll(COLOR_TOKEN_RE)].map((m) => m[0]);
433
+ const first = tokens[0];
434
+ const last = tokens[tokens.length - 1];
435
+ if (first && last) {
436
+ return { kind: 'gradient', from: displayColor(first), to: displayColor(last) };
437
+ }
438
+ // A gradient syntax this scan could not find a color token in (a bare
439
+ // keyword stop like `red`, or a syntax this regex missed) is still a
440
+ // gradient -- disclosed as an unresolvable one rather than silently
441
+ // treated as `none`, which would wrongly let compositing continue past it.
442
+ return { kind: 'gradient', from: 'unknown', to: 'unknown' };
443
+ }
444
+ return { kind: 'url' };
445
+ }
446
+ /**
447
+ * The browser's real default canvas fill, resolved from `<html>`'s own
448
+ * background-color and color-scheme -- or `null` when it genuinely cannot be
449
+ * determined from the page alone. Verified against real Chromium pixel
450
+ * readback (A2): `color-scheme: dark` with no background set anywhere reads
451
+ * back as `rgb(18, 18, 18)`, not white; an explicit opaque `<html>`
452
+ * background-color wins outright, regardless of color-scheme.
453
+ */
454
+ function canvasBackdrop(canvas) {
455
+ const parsedBg = parseCssColor(canvas.backgroundColor);
456
+ if (parsedBg.kind === 'verbatim')
457
+ return null;
458
+ if (parsedBg.rgba.a >= OPAQUE_EPSILON)
459
+ return parsedBg.rgba;
460
+ const tokens = canvas.colorScheme.trim().toLowerCase().split(/\s+/).filter(Boolean);
461
+ const hasDark = tokens.includes('dark');
462
+ const hasLight = tokens.includes('light') || tokens.includes('normal') || tokens.length === 0;
463
+ // Both listed (`light dark`) genuinely depends on the reader's own OS
464
+ // preference, which this capture cannot see -- honestly unresolvable,
465
+ // not a coin flip. Anything this parser does not recognise gets the same
466
+ // "cannot say" treatment rather than a guess.
467
+ const uaDefault = hasDark && !hasLight
468
+ ? { r: 18, g: 18, b: 18 }
469
+ : hasLight && !hasDark
470
+ ? { r: 255, g: 255, b: 255 }
471
+ : null;
472
+ if (uaDefault === null)
473
+ return parsedBg.rgba.a > 0 ? null : null;
474
+ return parsedBg.rgba.a > 0 ? compositeOver(parsedBg.rgba, uaDefault) : uaDefault;
475
+ }
476
+ const OPAQUE_EPSILON = 0.999;
477
+ /**
478
+ * Standard Porter-Duff "over": `top` (already-resolved, itself possibly
479
+ * translucent) painted over `bottom` (also possibly translucent), both in
480
+ * *straight* (non-premultiplied) alpha. Generalizes `compositeOver` above
481
+ * (which assumes an opaque bottom -- still used for the simpler alpha-chain
482
+ * case) to a bottom that is not yet fully resolved either, which is what
483
+ * folding CSS `opacity` into the walk needs (A5): an ancestor's own
484
+ * `background-color` is one input, that same ancestor's `opacity` is
485
+ * another, independent one, and both apply at the exact same position.
486
+ */
487
+ function overStep(top, bottom) {
488
+ const a = clamp01(top.a + bottom.a * (1 - top.a));
489
+ if (a <= 0)
490
+ return { r: 0, g: 0, b: 0, a: 0 };
491
+ return {
492
+ r: (top.r * top.a + bottom.r * bottom.a * (1 - top.a)) / a,
493
+ g: (top.g * top.a + bottom.g * bottom.a * (1 - top.a)) / a,
494
+ b: (top.b * top.a + bottom.b * bottom.a * (1 - top.a)) / a,
495
+ a,
496
+ };
497
+ }
498
+ /**
499
+ * Walk up from `el` accumulating translucent `background-color` layers
500
+ * *and* CSS `opacity` until something fully opaque is reached, then report
501
+ * the composited result -- an incremental left-to-right fold rather than
502
+ * "collect layers, then composite once at the end", specifically so
503
+ * `opacity` folds in correctly (A5): unlike a plain `background-color`
504
+ * alpha, `opacity` scales *everything already composited so far* as one
505
+ * group at the exact point it is applied, so each ancestor's own
506
+ * contribution has to be resolved against what is beneath it before the
507
+ * next ancestor's `opacity` scales the combined result again. The pure
508
+ * `background-color`-only case (no `opacity < 1` anywhere in the chain)
509
+ * reduces to exactly the old flat-stack composite -- *verified* exact
510
+ * against Chromium's own `blendedBackgroundColors` on an alpha chain
511
+ * (`test/browser/color-backdrop.test.ts`) -- and the opacity-aware general
512
+ * case is verified against real Chromium canvas pixel readback for both a
513
+ * single opacity boundary and nested multi-level opacity (see this
514
+ * module's header and `test/browser/color-backdrop.test.ts`'s A5 cases).
515
+ *
516
+ * Aborts to a `gradient`/`image`/`not-captured` backdrop the instant any
517
+ * layer's own `backgroundImage` says so (A3), and to `canvas` when the walk
518
+ * runs off the top of `<body>` with no way to know the real default fill
519
+ * (A2, see `canvasBackdrop`). A non-ancestor overlay is checked by the
520
+ * caller before this ever runs (see `colorFactFor`), since an overlay makes
521
+ * the ancestor chain irrelevant regardless of what it would have found.
522
+ */
523
+ export function resolveBackdrop(el, byId, canvas) {
524
+ const chain = [];
525
+ let cursor = el;
526
+ let lastId = el.id;
527
+ let isFirst = true;
528
+ let acc = { r: 0, g: 0, b: 0, a: 0 };
529
+ let layerCount = 0;
530
+ while (cursor) {
531
+ lastId = cursor.id;
532
+ if (cursor.backgroundImage === null) {
533
+ chain.push({ id: cursor.id, color: 'not-captured', alpha: 1 });
534
+ return { backdrop: { kind: 'not-captured', via: cursor.id }, rgb: null, chain };
535
+ }
536
+ const img = parseBackgroundImageKind(cursor.backgroundImage, cursor.id);
537
+ if (img.kind === 'gradient') {
538
+ chain.push({ id: cursor.id, color: `gradient(${img.from}→${img.to})`, alpha: 1 });
539
+ return {
540
+ backdrop: { kind: 'gradient', from: img.from, to: img.to, via: cursor.id },
541
+ rgb: null,
542
+ chain,
543
+ };
544
+ }
545
+ if (img.kind === 'url') {
546
+ chain.push({ id: cursor.id, color: 'image', alpha: 1 });
547
+ const coverage = {
548
+ boxWidth: Math.round(cursor.rect.width),
549
+ boxHeight: Math.round(cursor.rect.height),
550
+ ofWidth: Math.round(el.rect.width),
551
+ ofHeight: Math.round(el.rect.height),
552
+ };
553
+ return { backdrop: { kind: 'image', via: cursor.id, coverage }, rgb: null, chain };
554
+ }
555
+ const parsed = parseCssColor(cursor.backgroundColor);
556
+ if (parsed.kind === 'verbatim') {
557
+ // A color this parser cannot convert closes the walk right here: it
558
+ // might be opaque or translucent and there is no honest way to know
559
+ // which, so compositing cannot continue past it. Printed verbatim per
560
+ // §4 ("or verbatim when a color space could not be converted");
561
+ // `colorFactFor` reads `rgb === null` off this to force `ratio
562
+ // unmeasured (color-space)`.
563
+ chain.push({ id: cursor.id, color: parsed.raw, alpha: 1 });
564
+ return {
565
+ backdrop: {
566
+ kind: 'opaque',
567
+ color: parsed.raw,
568
+ via: cursor.id,
569
+ self: isFirst,
570
+ layers: layerCount,
571
+ },
572
+ rgb: null,
573
+ chain,
574
+ };
575
+ }
576
+ const ownContributes = parsed.rgba.a > 0 || cursor.opacity < 1;
577
+ const combined = overStep(acc, parsed.rgba);
578
+ acc = { r: combined.r, g: combined.g, b: combined.b, a: clamp01(combined.a * cursor.opacity) };
579
+ if (acc.a >= OPAQUE_EPSILON) {
580
+ chain.push({ id: cursor.id, color: toHex(acc), alpha: acc.a });
581
+ return {
582
+ backdrop: {
583
+ kind: 'opaque',
584
+ color: toHex(acc),
585
+ via: cursor.id,
586
+ self: isFirst,
587
+ layers: layerCount,
588
+ },
589
+ rgb: acc,
590
+ chain,
591
+ };
592
+ }
593
+ if (ownContributes) {
594
+ layerCount++;
595
+ chain.push({ id: cursor.id, color: toHex(acc), alpha: acc.a });
596
+ }
597
+ isFirst = false;
598
+ cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;
599
+ }
600
+ // The chain never met an opaque layer: what is really behind everything
601
+ // is the browser's own default canvas fill -- resolved from `<html>`'s
602
+ // own background-color/color-scheme when a caller supplied one (A2),
603
+ // never assumed to be white. `canvas` omitted (a caller not yet updated
604
+ // to pass one -- see this module's header) and `canvas` supplied but
605
+ // unresolvable (an unconvertible color, or an ambiguous `light dark`
606
+ // scheme) are both reported the same honest way: `unmeasured (canvas)`,
607
+ // never a guessed color.
608
+ const canvasRgb = canvas ? canvasBackdrop(canvas) : null;
609
+ if (canvasRgb === null) {
610
+ chain.push({ id: lastId, color: 'canvas (unresolved)', alpha: 1 });
611
+ return { backdrop: { kind: 'canvas' }, rgb: null, chain };
612
+ }
613
+ const final = overStep(acc, { ...canvasRgb, a: 1 });
614
+ chain.push({ id: lastId, color: `canvas ${toHex(canvasRgb)}`, alpha: 1 });
615
+ return {
616
+ backdrop: {
617
+ kind: 'opaque',
618
+ color: toHex(final),
619
+ via: 'canvas',
620
+ self: false,
621
+ layers: layerCount,
622
+ },
623
+ rgb: final,
624
+ chain,
625
+ };
626
+ }
627
+ /**
628
+ * Folds `startAcc` (the ink itself, or nothing) through `cursor`'s ancestors
629
+ * up to the resolved canvas, applying each level's own
630
+ * (`background-color`, `opacity`) exactly like `resolveBackdrop`'s main
631
+ * loop -- reused for the ink so it fades through the *same* opacity chain
632
+ * the backdrop does, toward the *same* outside reference, rather than a
633
+ * flat multiply against the backdrop's own already-resolved color, which
634
+ * `test/browser/color-backdrop.test.ts`'s nested-opacity case proves gives a
635
+ * different (wrong) answer (A5). Only ever called once `resolveBackdrop`
636
+ * has already proven this exact suffix of the chain -- starting at `el`'s
637
+ * parent, a subset of `el`'s own chain -- contains no gradient/image/
638
+ * not-captured/unconvertible-color abort, so none of those cases need
639
+ * handling here.
640
+ */
641
+ function foldOverAncestors(startAcc, cursor, byId, canvasRgb) {
642
+ let acc = startAcc;
643
+ while (cursor) {
644
+ const parsed = parseCssColor(cursor.backgroundColor);
645
+ const bottom = parsed.kind === 'resolved' ? parsed.rgba : { r: 0, g: 0, b: 0, a: 0 };
646
+ const combined = overStep(acc, bottom);
647
+ acc = { r: combined.r, g: combined.g, b: combined.b, a: clamp01(combined.a * cursor.opacity) };
648
+ if (acc.a >= OPAQUE_EPSILON)
649
+ return acc;
650
+ cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;
651
+ }
652
+ const base = canvasRgb ?? { r: 255, g: 255, b: 255 };
653
+ return overStep(acc, { ...base, a: 1 });
654
+ }
655
+ // ---------------------------------------------------------------------------
656
+ // The overlap check: a non-ancestor painted over or behind the text
657
+ // ---------------------------------------------------------------------------
658
+ function ancestorIdsOf(el, byId) {
659
+ const ids = new Set();
660
+ let cursor = el.parentId ? byId.get(el.parentId) : undefined;
661
+ while (cursor) {
662
+ ids.add(cursor.id);
663
+ cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;
664
+ }
665
+ return ids;
666
+ }
667
+ function isDescendantOf(candidate, ancestorId, byId) {
668
+ let cursor = candidate.parentId ? byId.get(candidate.parentId) : undefined;
669
+ while (cursor) {
670
+ if (cursor.id === ancestorId)
671
+ return true;
672
+ cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;
673
+ }
674
+ return false;
675
+ }
676
+ /** Whether an element paints anything a viewer could actually see covering
677
+ * what is behind it -- a non-transparent `background-color`, or any
678
+ * `background-image`. An unresolvable color is treated as "might paint": a
679
+ * false-positive overlay (an honest `unmeasured`) is the safe direction to
680
+ * err in, never a ratio that silently ignores something real. */
681
+ function hasPaint(el) {
682
+ // `backgroundImage === null` (A3): this specific capture never measured
683
+ // it, so it might be a gradient or image painting over/behind the text --
684
+ // treated as "might paint", the same safe direction this function's own
685
+ // doc comment already takes for an unresolvable color.
686
+ if (el.backgroundImage === null)
687
+ return true;
688
+ if (parseBackgroundImageKind(el.backgroundImage, el.id).kind !== 'none')
689
+ return true;
690
+ const parsed = parseCssColor(el.backgroundColor);
691
+ return parsed.kind === 'verbatim' || parsed.rgba.a > 0;
692
+ }
693
+ function rectsIntersect(a, b) {
694
+ return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
695
+ }
696
+ /**
697
+ * A non-ancestor element painted `under` (in front of, obscuring the text
698
+ * itself) or `behind` (between the text and where the ancestor walk would
699
+ * have looked) this one -- the two `incompleteData` cases
700
+ * `blendedBackgroundColors` gets silently wrong (design note §3): a
701
+ * `rgba(255,0,0,0.8)` sibling scrim painted directly over text reads back as
702
+ * `rgb(255,255,255)`, the page background, as if the overlay were not there.
703
+ *
704
+ * Two independent input signals, one per capture path (design note §7):
705
+ * `el.hitStackIds` (the `walk` path's `elementsFromPoint` result, already
706
+ * ordered front-to-back) when present, else `paintOrder` plus rect
707
+ * intersection (the `cdp` path, Node-side, whole-rect rather than
708
+ * centre-point). Neither present -- a hand-built fixture, a bundle written
709
+ * before this capability existed -- reports no overlay rather than guessing:
710
+ * rule 4 of the design note's §5 (refuse rather than fabricate) applies here
711
+ * exactly as it does to the ratio itself.
712
+ */
713
+ export function detectOverlay(el, byId, elements) {
714
+ const ancestors = ancestorIdsOf(el, byId);
715
+ // `Array.isArray`/`typeof ... === 'number'`, not `!== null`: a bundle
716
+ // written before these two fields existed has them simply absent from its
717
+ // JSON (`undefined`, not the explicit `null` a current capture writes for
718
+ // "not computed") -- `test/fixtures/real-sites/*.json` are real examples.
719
+ // `undefined !== null` is true, so a bare null-check would have walked
720
+ // straight into `undefined.indexOf`, treating a bundle with NO signal at
721
+ // all as if it carried an empty-but-present one.
722
+ if (Array.isArray(el.hitStackIds)) {
723
+ const stack = el.hitStackIds;
724
+ const elIdx = stack.indexOf(el.id);
725
+ const ancIdx = stack.findIndex((id) => ancestors.has(id));
726
+ // Anything at or past the first ancestor in the stack is behind the wall
727
+ // our composite walk would stop at anyway -- irrelevant to check.
728
+ const boundary = ancIdx === -1 ? stack.length : ancIdx;
729
+ for (let i = 0; i < boundary; i++) {
730
+ const id = stack[i];
731
+ if (id === undefined || id === el.id)
732
+ continue;
733
+ const candidate = byId.get(id);
734
+ if (!candidate?.visible)
735
+ continue;
736
+ if (isDescendantOf(candidate, el.id, byId))
737
+ continue;
738
+ if (!hasPaint(candidate))
739
+ continue;
740
+ // Before `el` in the front-to-back stack: painted on top of, obscuring,
741
+ // the text pixel itself. After `el` (or `el` not found at all, which
742
+ // should not happen for a visible text element but is handled the same
743
+ // conservative way): sits behind the text but ahead of where the
744
+ // ancestor wall is.
745
+ return { reason: elIdx !== -1 && i < elIdx ? 'under' : 'behind', via: candidate.id };
746
+ }
747
+ return null;
748
+ }
749
+ if (typeof el.paintOrder === 'number') {
750
+ let ancestorMax = Number.NEGATIVE_INFINITY;
751
+ for (const id of ancestors) {
752
+ const po = byId.get(id)?.paintOrder;
753
+ if (typeof po === 'number')
754
+ ancestorMax = Math.max(ancestorMax, po);
755
+ }
756
+ const candidates = elements
757
+ .filter((c) => c.id !== el.id &&
758
+ c.visible &&
759
+ typeof c.paintOrder === 'number' &&
760
+ !ancestors.has(c.id) &&
761
+ !isDescendantOf(c, el.id, byId) &&
762
+ rectsIntersect(c.rect, el.rect) &&
763
+ hasPaint(c))
764
+ .sort((a, b) => (b.paintOrder ?? 0) - (a.paintOrder ?? 0));
765
+ for (const candidate of candidates) {
766
+ const po = candidate.paintOrder ?? Number.NEGATIVE_INFINITY;
767
+ if (po > (el.paintOrder ?? Number.POSITIVE_INFINITY))
768
+ return { reason: 'under', via: candidate.id };
769
+ if (po > ancestorMax)
770
+ return { reason: 'behind', via: candidate.id };
771
+ }
772
+ return null;
773
+ }
774
+ return null;
775
+ }
776
+ // ---------------------------------------------------------------------------
777
+ // Effective opacity: the CSS `opacity` property compounding through ancestors
778
+ // ---------------------------------------------------------------------------
779
+ /** `opacity` on an ancestor scales everything painted inside it, and it
780
+ * compounds -- two nested `opacity: 0.5` elements render at an effective
781
+ * 0.25. Computed Node-side over the existing `opacity` field every element
782
+ * already carries; no new capture is needed for this one (design note §7's
783
+ * "the walk multiplies ancestor opacities itself" happens here instead, over
784
+ * records, matching this module's whole architecture). */
785
+ function effectiveOpacity(el, byId) {
786
+ let opacity = el.opacity;
787
+ let cursor = el.parentId ? byId.get(el.parentId) : undefined;
788
+ while (cursor) {
789
+ opacity *= cursor.opacity;
790
+ cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined;
791
+ }
792
+ return opacity;
793
+ }
794
+ // ---------------------------------------------------------------------------
795
+ // Counter-facts: text-shadow, text-stroke
796
+ // ---------------------------------------------------------------------------
797
+ /** The first shadow's color, or `null` when there is no shadow. Chromium
798
+ * serializes the color first in each comma-separated shadow (*verified*:
799
+ * `text-shadow: 2px 2px 4px rgba(0,0,0,0.8)` computes to
800
+ * `"rgba(0, 0, 0, 0.8) 2px 2px 4px"`), so the first color-shaped token in the
801
+ * first comma segment is the shadow's own color -- dnumb cannot measure what
802
+ * a shadow does perceptually, so this discloses that one exists and in what
803
+ * color, nothing more (design note §2). */
804
+ function firstShadowColor(raw, elementId) {
805
+ requireColorField(elementId, 'textShadow', raw);
806
+ const trimmed = raw.trim();
807
+ if (trimmed === '' || trimmed.toLowerCase() === 'none')
808
+ return null;
809
+ const firstSegment = trimmed.split(',')[0] ?? trimmed;
810
+ const token = COLOR_TOKEN_RE.exec(firstSegment)?.[0];
811
+ COLOR_TOKEN_RE.lastIndex = 0;
812
+ return token ? displayColor(token) : null;
813
+ }
814
+ function parsePx(raw) {
815
+ if (raw === undefined)
816
+ return null;
817
+ const n = Number.parseFloat(raw);
818
+ return Number.isFinite(n) ? n : null;
819
+ }
820
+ /**
821
+ * The color fact for one element, or `null` when the measurement gate does
822
+ * not apply: "every visible element with its own text" (design note §4) --
823
+ * no text, no ink-on-backdrop question to answer.
824
+ *
825
+ * A6 note: a closed `<sl-dialog>` (`opacity: 0` on an ancestor) still gets a
826
+ * fact here for each visible-by-its-own-styling descendant -- `el.visible`
827
+ * only reflects the element's *own* opacity/display/visibility/size, not an
828
+ * ancestor's. That fact's `ratio` genuinely does come out `1.00` (ink
829
+ * composited to exactly equal its own backdrop at zero *effective* opacity)
830
+ * -- mathematically correct, not fabricated, and never suppressed here:
831
+ * "facts, with counter-facts, never adjudicate" means this module does not
832
+ * get to decide the fact is uninteresting and drop it. `effectiveOpacity`
833
+ * below is the counter-fact that explains it (`opacity ×0`, kept on
834
+ * `ColorFact` and, via `ColorPair.effectiveOpacity`, on `q color`'s grouped
835
+ * rows too -- see that field's doc comment) so a reader sees *why* the
836
+ * ratio is round, rather than the fact disappearing and the explanation
837
+ * disappearing with it.
838
+ */
839
+ export function colorFactFor(el, byId, elements, canvas) {
840
+ if (!el.visible || !el.text)
841
+ return null;
842
+ const opacity = effectiveOpacity(el, byId);
843
+ // Fail fast, once, naming the first missing field -- rather than let it
844
+ // surface later as a `TypeError` inside `resolveBackdrop`/`firstShadowColor`/
845
+ // `parsePx`. See `requireColorField`'s doc comment. `backgroundImage` alone
846
+ // allows `null` -- see `requireColorField` and `ElementRecord.backgroundImage`.
847
+ requireColorField(el.id, 'backgroundImage', el.backgroundImage, { allowNull: true });
848
+ requireColorField(el.id, 'textShadow', el.textShadow);
849
+ requireColorField(el.id, 'textStrokeWidth', el.textStrokeWidth);
850
+ requireColorField(el.id, 'textStrokeColor', el.textStrokeColor);
851
+ const fgParsed = parseCssColor(el.color);
852
+ const overlay = detectOverlay(el, byId, elements);
853
+ let backdrop;
854
+ let bgRgb;
855
+ if (overlay) {
856
+ backdrop = { kind: 'overlay', reason: overlay.reason, via: overlay.via };
857
+ bgRgb = null;
858
+ }
859
+ else {
860
+ const resolved = resolveBackdrop(el, byId, canvas);
861
+ backdrop = resolved.backdrop;
862
+ bgRgb = resolved.rgb;
863
+ }
864
+ let ratioReason = null;
865
+ if (backdrop.kind === 'gradient')
866
+ ratioReason = 'gradient';
867
+ else if (backdrop.kind === 'image')
868
+ ratioReason = 'image';
869
+ else if (backdrop.kind === 'overlay')
870
+ ratioReason = backdrop.reason;
871
+ else if (backdrop.kind === 'not-captured')
872
+ ratioReason = 'not-captured';
873
+ else if (backdrop.kind === 'canvas')
874
+ ratioReason = 'canvas';
875
+ else if (bgRgb === null || fgParsed.kind !== 'resolved')
876
+ ratioReason = 'color-space';
877
+ let fg;
878
+ let ratio = null;
879
+ if (fgParsed.kind === 'verbatim') {
880
+ fg = fgParsed.raw;
881
+ }
882
+ else if (ratioReason === null && bgRgb) {
883
+ // The backdrop fully resolved: fold the ink through the *same*
884
+ // ancestor opacity chain the backdrop itself was resolved through,
885
+ // toward the *same* outside reference, rather than a flat multiply
886
+ // against the backdrop's own already-resolved color (A5) -- see
887
+ // `foldOverAncestors`'s doc comment for why the two give different
888
+ // answers whenever more than one opacity boundary is involved, and
889
+ // this module's header for the real-Chromium verification. The result
890
+ // is the ink's true rendered color -- opaque, no alpha suffix, because
891
+ // it no longer represents "this element's own raw CSS color" but "what
892
+ // is actually painted at this pixel", the same register `bgRgb` itself
893
+ // is already in.
894
+ const startAcc = {
895
+ ...fgParsed.rgba,
896
+ a: clamp01(fgParsed.rgba.a * el.opacity),
897
+ };
898
+ const parentEl = el.parentId ? byId.get(el.parentId) : undefined;
899
+ const canvasRgb = canvas ? canvasBackdrop(canvas) : null;
900
+ const foldedInk = foldOverAncestors(startAcc, parentEl, byId, canvasRgb);
901
+ fg = toHex(foldedInk);
902
+ ratio = Math.round(contrastRatio(foldedInk, bgRgb) * 100) / 100;
903
+ }
904
+ else {
905
+ const effAlpha = clamp01(fgParsed.rgba.a * opacity);
906
+ fg = toHex(fgParsed.rgba, effAlpha < 0.999 ? effAlpha : undefined);
907
+ }
908
+ const fontSizePx = parsePx(el.fontSize) ?? 0;
909
+ const strokeWidthPx = parsePx(el.textStrokeWidth);
910
+ const hasStroke = strokeWidthPx !== null && strokeWidthPx > 0;
911
+ return {
912
+ elementId: el.id,
913
+ fg,
914
+ backdrop,
915
+ ratio,
916
+ ratioReason,
917
+ fontSizePx,
918
+ fontWeight: el.fontWeight,
919
+ effectiveOpacity: opacity < 0.999 ? opacity : null,
920
+ shadowColor: firstShadowColor(el.textShadow, el.id),
921
+ strokeWidthPx: hasStroke ? strokeWidthPx : null,
922
+ strokeColor: hasStroke ? displayColor(el.textStrokeColor) : null,
923
+ };
924
+ }
925
+ /** Every color fact the capture supports, in document order -- the gate from
926
+ * `colorFactFor` applied once per element. */
927
+ export function colorFactsForAll(elements, canvas) {
928
+ const byId = new Map(elements.map((el) => [el.id, el]));
929
+ const out = [];
930
+ for (const el of elements) {
931
+ const fact = colorFactFor(el, byId, elements, canvas);
932
+ if (fact)
933
+ out.push(fact);
934
+ }
935
+ return out;
936
+ }
937
+ // ---------------------------------------------------------------------------
938
+ // Rendering: the §4 grammar
939
+ // ---------------------------------------------------------------------------
940
+ const px = (n) => Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, '');
941
+ const twoDp = (n) => n.toFixed(2);
942
+ const trimmedDecimal = (n) => String(Math.round(n * 100) / 100);
943
+ /** Just the `<bg>` half of the grammar -- reused by `q color`'s pair table
944
+ * and the digest header's `lowest` clause, which both need it without the
945
+ * rest of the line. */
946
+ export function backdropLabel(backdrop) {
947
+ switch (backdrop.kind) {
948
+ case 'opaque':
949
+ return backdrop.self
950
+ ? `${backdrop.color} self`
951
+ : `${backdrop.color} via ${backdrop.via}${backdrop.layers > 0 ? ` (α×${backdrop.layers})` : ''}`;
952
+ case 'gradient':
953
+ return `gradient(${backdrop.from}→${backdrop.to}) ${backdrop.via}`;
954
+ case 'image': {
955
+ const c = backdrop.coverage;
956
+ return `image ${backdrop.via} (${c.boxWidth}x${c.boxHeight} of ${c.ofWidth}x${c.ofHeight})`;
957
+ }
958
+ case 'overlay':
959
+ return `unknown (${backdrop.reason} ${backdrop.via})`;
960
+ case 'not-captured':
961
+ return `unknown (not captured ${backdrop.via})`;
962
+ case 'canvas':
963
+ return 'unknown (canvas)';
964
+ }
965
+ }
966
+ const RATIO_UNMEASURED_LABEL = {
967
+ 'color-space': 'unmeasured (color-space)',
968
+ 'not-captured': 'unmeasured (not-captured)',
969
+ canvas: 'unmeasured (canvas)',
970
+ };
971
+ function ratioLabel(fact) {
972
+ if (fact.ratio !== null)
973
+ return twoDp(fact.ratio);
974
+ if (fact.ratioReason && RATIO_UNMEASURED_LABEL[fact.ratioReason]) {
975
+ return RATIO_UNMEASURED_LABEL[fact.ratioReason];
976
+ }
977
+ return 'unmeasured';
978
+ }
979
+ /**
980
+ * `color <fg> on <bg>; ratio <r>; <size>px/<weight>[; opacity ×<o>][; shadow
981
+ * <color>][; stroke <w>px <color>]` -- `docs/notes/03-color.md` §4, exactly.
982
+ */
983
+ export function renderColorFact(fact) {
984
+ const head = `color ${fact.fg} on ${backdropLabel(fact.backdrop)}; ratio ${ratioLabel(fact)}; ${px(fact.fontSizePx)}px/${fact.fontWeight}`;
985
+ const tail = [];
986
+ if (fact.effectiveOpacity !== null)
987
+ tail.push(`opacity ×${trimmedDecimal(fact.effectiveOpacity)}`);
988
+ if (fact.shadowColor !== null)
989
+ tail.push(`shadow ${fact.shadowColor}`);
990
+ if (fact.strokeWidthPx !== null && fact.strokeColor !== null) {
991
+ tail.push(`stroke ${px(fact.strokeWidthPx)}px ${fact.strokeColor}`);
992
+ }
993
+ return tail.length > 0 ? `${head}; ${tail.join('; ')}` : head;
994
+ }
995
+ /**
996
+ * One row per (ink, backdrop) pair, not per element -- "thousands of
997
+ * elements compress to dozens of rows" (design note §6). `fg` and the
998
+ * rendered `<bg>` form together are the grouping key: two elements with the
999
+ * same ink over the same backdrop are, by construction, the same measured
1000
+ * ratio.
1001
+ */
1002
+ export function groupColorFacts(facts) {
1003
+ const groups = new Map();
1004
+ for (const fact of facts) {
1005
+ const bgLabel = backdropLabel(fact.backdrop);
1006
+ const key = `${fact.fg}\u0000${bgLabel}`;
1007
+ let group = groups.get(key);
1008
+ if (!group) {
1009
+ group = {
1010
+ fg: fact.fg,
1011
+ bgLabel,
1012
+ ratio: fact.ratio,
1013
+ ratioReason: fact.ratioReason,
1014
+ elementIds: [],
1015
+ fontSizesPx: [],
1016
+ weights: [],
1017
+ effectiveOpacity: fact.effectiveOpacity,
1018
+ };
1019
+ groups.set(key, group);
1020
+ }
1021
+ group.elementIds.push(fact.elementId);
1022
+ group.fontSizesPx.push(fact.fontSizePx);
1023
+ group.weights.push(fact.fontWeight);
1024
+ }
1025
+ return [...groups.values()];
1026
+ }