Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
One-time setup that gathers your project's design context and saves it to CLAUDE.md for future sessions.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/detector/shared/color.mjs
1// ─── Section 2: Color Utilities ─────────────────────────────────────────────23function isNeutralColor(color) {4if (!color || color === 'transparent') return true;56// rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0–255 range.7const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);8if (rgb) {9return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;10}1112// 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 formats15// literally (it does NOT convert them to rgb).16const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i);17if (oklch) return parseFloat(oklch[1]) < 0.02;18const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i);19if (lch) return parseFloat(lch[1]) < 3;2021// 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.23const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);24if (oklab) {25const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);26return Math.hypot(a, b) < 0.02;27}28const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);29if (lab) {30const a = parseFloat(lab[1]), b = parseFloat(lab[2]);31return Math.hypot(a, b) < 3;32}3334// hsl/hsla — saturation is the second numeric component (percent).35// Modern jsdom usually converts hsl() to rgb, but handle it directly for36// safety across versions and for any engine that preserves the format.37const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);38if (hsl) return parseFloat(hsl[1]) < 10;3940// hwb(hue whiteness% blackness%) — a pixel is fully gray when41// whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.42const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);43if (hwb) {44const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);45return (1 - Math.min(100, w + b) / 100) < 0.1;46}4748// Unknown / unrecognized format — err on the side of DETECTING rather49// than silently skipping. This is the opposite of the previous default,50// which was the root cause of the oklch bug.51return false;52}5354function parseRgb(color) {55if (!color || color === 'transparent') return null;56const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);57if (!m) return null;58return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };59}6061function relativeLuminance({ r, g, b }) {62const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>63c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.464);65return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;66}6768function contrastRatio(c1, c2) {69const l1 = relativeLuminance(c1);70const l2 = relativeLuminance(c2);71return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);72}7374function parseGradientColors(bgImage) {75if (!bgImage || !bgImage.includes('gradient')) return [];76const colors = [];77for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {78const c = parseRgb(m[0]);79if (c) colors.push(c);80}81for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {82const h = m[1];83if (h.length === 6) {84colors.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 {86colors.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}89return colors;90}9192function hasChroma(c, threshold = 30) {93if (!c) return false;94return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;95}9697function getHue(c) {98if (!c) return 0;99const r = c.r / 255, g = c.g / 255, b = c.b / 255;100const max = Math.max(r, g, b), min = Math.min(r, g, b);101if (max === min) return 0;102const d = max - min;103let h;104if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;105else if (max === g) h = ((b - r) / d + 2) / 6;106else h = ((r - g) / d + 4) / 6;107return Math.round(h * 360);108}109110function colorToHex(c) {111if (!c) return '?';112return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');113}114115export {116isNeutralColor,117parseRgb,118relativeLuminance,119contrastRatio,120parseGradientColors,121hasChroma,122getHue,123colorToHex,124};125