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