/** * Color parsing + perceptual distance for nearest-token suggestions. * * Every supported notation normalizes to 8-bit sRGB before comparison, so a * raw `#7c3acc` can match a token authored as `oklch(0.54 0.2 295)` and vice * versa. Only fully-opaque colors parse — a nearest suggestion that changes * opacity is worse than no suggestion. Unsupported notations (named colors, * lab/lch, color()) return null and simply get no nearest hint. */ export interface Rgb { r: number; g: number; b: number; } /** Parse a fully-opaque CSS color literal (hex, rgb/rgba, hsl/hsla, oklch) to sRGB. */ export function parseOpaqueColor(value: string): Rgb | null { const lower = value.trim().toLowerCase(); return ( parseHex(lower) ?? parseRgbFunction(lower) ?? parseHslFunction(lower) ?? parseOklch(lower) ); } /** * Redmean — a cheap perceptually-weighted RGB distance (range 0–~765). * Good enough to separate "same color family, different shade" from * "different hue" without a full CIE pipeline. */ export function colorDistance(a: Rgb, b: Rgb): number { const rMean = (a.r + b.r) / 2; const dr = a.r - b.r; const dg = a.g - b.g; const db = a.b - b.b; return Math.sqrt( (2 + rMean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rMean) / 256) * db * db ); } function parseHex(lower: string): Rgb | null { const hex = /^#([0-9a-f]{3,8})$/.exec(lower)?.[1]; if (!hex) return null; if (hex.length === 3 || hex.length === 4) { if (hex.length === 4 && hex[3] !== "f") return null; return { r: parseInt(hex[0] + hex[0], 16), g: parseInt(hex[1] + hex[1], 16), b: parseInt(hex[2] + hex[2], 16), }; } if (hex.length === 6 || hex.length === 8) { if (hex.length === 8 && hex.slice(6) !== "ff") return null; return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16), }; } return null; } /** A number with an optional `%` or `deg` suffix, used by every function notation. */ const COMPONENT = String.raw`[+-]?(?:\d+\.?\d*|\.\d+)(?:%|deg)?`; /** * Split a CSS color function's arguments, accepting both legacy comma syntax * (`rgb(1, 2, 3)`, `rgba(1, 2, 3, 1)`) and modern space syntax with an * optional `/ alpha` (`rgb(1 2 3 / 1)`). Returns the channel components plus * the alpha component (null when omitted). */ function splitColorArgs( lower: string, fn: string ): { components: string[]; alpha: string | null } | null { const match = new RegExp(`^${fn}a?\\(([^)]+)\\)$`).exec(lower); if (!match) return null; const body = match[1].trim(); let channelPart = body; let alpha: string | null = null; if (body.includes("/")) { const [channels, alphaPart, ...rest] = body.split("/"); if (rest.length > 0) return null; channelPart = channels.trim(); alpha = alphaPart.trim(); } const components = channelPart.includes(",") ? channelPart.split(",").map((part) => part.trim()) : channelPart.split(/\s+/); if (alpha === null && components.length === 4) { alpha = components.pop() ?? null; } if (components.length !== 3) return null; if (!components.every((part) => new RegExp(`^${COMPONENT}$`).test(part))) return null; if (alpha !== null && !new RegExp(`^${COMPONENT}$`).test(alpha)) return null; return { components, alpha }; } /** True when an alpha component is omitted or fully opaque (`1`, `1.0`, `100%`). */ function isOpaqueAlpha(alpha: string | null): boolean { if (alpha === null) return true; const value = alpha.endsWith("%") ? Number(alpha.slice(0, -1)) / 100 : Number(alpha); return value === 1; } function numberOrPercent(part: string, percentScale: number): number { return part.endsWith("%") ? (Number(part.slice(0, -1)) / 100) * percentScale : Number(part); } function clampByte(value: number): number { return Math.min(255, Math.max(0, Math.round(value))); } function parseRgbFunction(lower: string): Rgb | null { const args = splitColorArgs(lower, "rgb"); if (!args || !isOpaqueAlpha(args.alpha)) return null; const [r, g, b] = args.components.map((part) => numberOrPercent(part, 255)); if ([r, g, b].some((channel) => Number.isNaN(channel) || channel < 0 || channel > 255)) { return null; } return { r: Math.round(r), g: Math.round(g), b: Math.round(b) }; } function parseHslFunction(lower: string): Rgb | null { const args = splitColorArgs(lower, "hsl"); if (!args || !isOpaqueAlpha(args.alpha)) return null; const h = Number(args.components[0].replace(/deg$/, "")); const s = numberOrPercent(args.components[1], 1); const l = numberOrPercent(args.components[2], 1); if ([h, s, l].some(Number.isNaN) || s < 0 || s > 1 || l < 0 || l > 1) return null; const hue = ((h % 360) + 360) % 360; const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); const m = l - c / 2; const sector = Math.floor(hue / 60); const [r1, g1, b1] = [ [c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x], ][sector] ?? [0, 0, 0]; return { r: clampByte((r1 + m) * 255), g: clampByte((g1 + m) * 255), b: clampByte((b1 + m) * 255), }; } /** * oklch(L C H [/ alpha]) → sRGB via OKLab. L accepts 0–1 or a percentage; C * accepts a number or a percentage of 0.4 (per spec); H is degrees. Out-of-gamut * results clamp to sRGB — fine for nearness, which only needs "is this the same * color family", not colorimetric fidelity. */ function parseOklch(lower: string): Rgb | null { const args = splitColorArgs(lower, "oklch"); if (!args || !isOpaqueAlpha(args.alpha)) return null; const l = numberOrPercent(args.components[0], 1); const c = numberOrPercent(args.components[1], 0.4); const h = Number(args.components[2].replace(/deg$/, "")); if ([l, c, h].some(Number.isNaN) || l < 0 || l > 1 || c < 0) return null; const hRad = (h * Math.PI) / 180; const labA = c * Math.cos(hRad); const labB = c * Math.sin(hRad); const l_ = l + 0.3963377774 * labA + 0.2158037573 * labB; const m_ = l - 0.1055613458 * labA - 0.0638541728 * labB; const s_ = l - 0.0894841775 * labA - 1.291485548 * labB; const lc = l_ * l_ * l_; const mc = m_ * m_ * m_; const sc = s_ * s_ * s_; const linearR = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc; const linearG = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc; const linearB = -0.0041960863 * lc - 0.7034186147 * mc + 1.707614701 * sc; return { r: clampByte(srgbGamma(linearR) * 255), g: clampByte(srgbGamma(linearG) * 255), b: clampByte(srgbGamma(linearB) * 255), }; } function srgbGamma(linear: number): number { const clamped = Math.min(1, Math.max(0, linear)); return clamped <= 0.0031308 ? 12.92 * clamped : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; }