const DARK_ACTION_FOREGROUND = "#18181B"; const LIGHT_ACTION_FOREGROUND = "#FFFFFF"; const MIN_TEXT_CONTRAST = 4.5; function parseHexColor(color: string): [number, number, number] | null { const value = color.trim().replace(/^#/, ""); const expanded = value.length === 3 ? value .split("") .map((character) => character.repeat(2)) .join("") : value.slice(0, 6); if (expanded.length !== 6 || !/^[0-9a-f]+$/i.test(expanded)) return null; return [ Number.parseInt(expanded.slice(0, 2), 16), Number.parseInt(expanded.slice(2, 4), 16), Number.parseInt(expanded.slice(4, 6), 16), ]; } function luminance(color: string): number | null { const rgb = parseHexColor(color); if (!rgb) return null; const channels = rgb.map((channel) => { const value = channel / 255; return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; }); return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!; } function contrastRatio(foreground: string, background: string): number | null { const foregroundLuminance = luminance(foreground); const backgroundLuminance = luminance(background); if (foregroundLuminance == null || backgroundLuminance == null) return null; const lighter = Math.max(foregroundLuminance, backgroundLuminance); const darker = Math.min(foregroundLuminance, backgroundLuminance); return (lighter + 0.05) / (darker + 0.05); } /** Chooses a readable action label/icon color while preserving a valid requested color. */ export function resolveActionForeground(background: string, requested: string): string { const requestedContrast = contrastRatio(requested, background); if (requestedContrast == null || requestedContrast >= MIN_TEXT_CONTRAST) { return requested; } const darkContrast = contrastRatio(DARK_ACTION_FOREGROUND, background) ?? 0; const lightContrast = contrastRatio(LIGHT_ACTION_FOREGROUND, background) ?? 0; return darkContrast >= lightContrast ? DARK_ACTION_FOREGROUND : LIGHT_ACTION_FOREGROUND; }