export interface RgbColor { readonly r: number; readonly g: number; readonly b: number; } export interface HslColor { readonly h: number; readonly s: number; readonly l: number; } export function canonicalizeRgbHex(value: unknown): string | undefined { if (typeof value !== 'string' || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)) { return undefined; } if (value.length === 4) { return `#${value[1]}${value[1]}${value[2]}${value[2]}${value[3]}${value[3]}`.toLowerCase(); } return value.toLowerCase(); } export function canonicalizeRgbOrRgbaHex(value: unknown): string | undefined { return typeof value === 'string' && /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(value) ? value.toLowerCase() : undefined; } export function parseCssColor(value: unknown): RgbColor | undefined { if (typeof value !== 'string') return undefined; const hex = /^#([0-9a-f]{6})(?:[0-9a-f]{2})?$/i.exec(value); if (hex) { return { r: Number.parseInt(hex[1].slice(0, 2), 16) / 255, g: Number.parseInt(hex[1].slice(2, 4), 16) / 255, b: Number.parseInt(hex[1].slice(4, 6), 16) / 255, }; } const rgba = /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i.exec( value, ); if (!rgba) return undefined; const channels = rgba.slice(1, 4).map(Number); if (channels.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 255)) { return undefined; } return { r: channels[0] / 255, g: channels[1] / 255, b: channels[2] / 255 }; } /** Convert a canonical `#RRGGBB` color to HSL. */ export function hexToHsl(hex: string): HslColor { const r = Number.parseInt(hex.slice(1, 3), 16) / 255; const g = Number.parseInt(hex.slice(3, 5), 16) / 255; const b = Number.parseInt(hex.slice(5, 7), 16) / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const delta = max - min; const l = (max + min) / 2; let h = 0; let s = 0; if (delta !== 0) { s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min); switch (max) { case r: h = (g - b) / delta + (g < b ? 6 : 0); break; case g: h = (b - r) / delta + 2; break; default: h = (r - g) / delta + 4; break; } h *= 60; } return { h, s, l }; } /** Convert HSL (`h` in degrees, `s`/`l` in 0–1) to canonical `#RRGGBB`. */ export function hslToHex({ h, s, l }: HslColor): string { const c = (1 - Math.abs(2 * l - 1)) * s; const hp = (((h % 360) + 360) % 360) / 60; const x = c * (1 - Math.abs((hp % 2) - 1)); let r = 0; let g = 0; let b = 0; if (hp < 1) [r, g, b] = [c, x, 0]; else if (hp < 2) [r, g, b] = [x, c, 0]; else if (hp < 3) [r, g, b] = [0, c, x]; else if (hp < 4) [r, g, b] = [0, x, c]; else if (hp < 5) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; const m = l - c / 2; const toHex = (value: number) => Math.round((value + m) * 255) .toString(16) .padStart(2, '0'); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } export function toLinearSrgbChannel(channel: number): number { return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; } export function relativeLuminance(color: RgbColor): number { return ( 0.2126 * toLinearSrgbChannel(color.r) + 0.7152 * toLinearSrgbChannel(color.g) + 0.0722 * toLinearSrgbChannel(color.b) ); } export function contrastRatio(left: RgbColor, right: RgbColor): number { const leftLuminance = relativeLuminance(left); const rightLuminance = relativeLuminance(right); return ( (Math.max(leftLuminance, rightLuminance) + 0.05) / (Math.min(leftLuminance, rightLuminance) + 0.05) ); }