1// ─── Section 2: Color Utilities ─────────────────────────────────────────────
2
3function isNeutralColor(color) {
4 if (!color || color === 'transparent') return true;
5
6 // rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range.
7 const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
8 if (rgb) {
9 return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
10 }
11
12 // oklch()/lch() — chroma is the second numeric component.
13 // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
14 // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats
15 // literally (it does NOT convert them to rgb).
16 const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i);
17 if (oklch) return parseFloat(oklch[1]) < 0.02;
18 const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i);
19 if (lch) return parseFloat(lch[1]) < 3;
20
21 // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
22 // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
23 const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
24 if (oklab) {
25 const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
26 return Math.hypot(a, b) < 0.02;
27 }
28 const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
29 if (lab) {
30 const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
31 return Math.hypot(a, b) < 3;
32 }
33
34 // hsl/hsla — saturation is the second numeric component (percent).
35 // Modern jsdom usually converts hsl() to rgb, but handle it directly for
36 // safety across versions and for any engine that preserves the format.
37 const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
38 if (hsl) return parseFloat(hsl[1]) < 10;
39
40 // hwb(hue whiteness% blackness%) — a pixel is fully gray when
41 // whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
42 const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
43 if (hwb) {
44 const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
45 return (1 - Math.min(100, w + b) / 100) < 0.1;
46 }
47
48 // Unknown / unrecognized format — err on the side of DETECTING rather
49 // than silently skipping. This is the opposite of the previous default,
50 // which was the root cause of the oklch bug.
51 return false;
52}
53
54function parseRgb(color) {
55 if (!color || color === 'transparent') return null;
56 const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
57 if (!m) return null;
58 return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
59}
60
61function relativeLuminance({ r, g, b }) {
62 const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
63 c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
64 );
65 return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
66}
67
68function contrastRatio(c1, c2) {
69 const l1 = relativeLuminance(c1);
70 const l2 = relativeLuminance(c2);
71 return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
72}
73
74function parseGradientColors(bgImage) {
75 if (!bgImage || !bgImage.includes('gradient')) return [];
76 const colors = [];
77 for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
78 const c = parseRgb(m[0]);
79 if (c) colors.push(c);
80 }
81 for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
82 const h = m[1];
83 if (h.length === 6) {
84 colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
85 } else {
86 colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 });
87 }
88 }
89 return colors;
90}
91
92function hasChroma(c, threshold = 30) {
93 if (!c) return false;
94 return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
95}
96
97function getHue(c) {
98 if (!c) return 0;
99 const r = c.r / 255, g = c.g / 255, b = c.b / 255;
100 const max = Math.max(r, g, b), min = Math.min(r, g, b);
101 if (max === min) return 0;
102 const d = max - min;
103 let h;
104 if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
105 else if (max === g) h = ((b - r) / d + 2) / 6;
106 else h = ((r - g) / d + 4) / 6;
107 return Math.round(h * 360);
108}
109
110function colorToHex(c) {
111 if (!c) return '?';
112 return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
113}
114
115export {
116 isNeutralColor,
117 parseRgb,
118 relativeLuminance,
119 contrastRatio,
120 parseGradientColors,
121 hasChroma,
122 getHue,
123 colorToHex,
124};