export const DEFAULT_ACCENT_HUE = 195; const ACCENT_CHROMA = 0.16; const LIGHT_PRIMARY_LIGHTNESS = 0.7; const DARK_PRIMARY_LIGHTNESS = 0.8; const DARK_FOREGROUND = "oklch(0.1 0 0)"; const LIGHT_FOREGROUND = "oklch(0.98 0 0)"; export type TeamAccentCssVariables = { "--accent-hue": string; "--team-primary-foreground-dark": string; "--team-primary-foreground-light": string; }; interface OklchColor { lightness: number; chroma: number; hue: number; } function clampChannel(value: number): number { return Math.min(1, Math.max(0, value)); } function relativeLuminance({ lightness, chroma, hue }: OklchColor): number { const radians = (hue * Math.PI) / 180; const a = chroma * Math.cos(radians); const b = chroma * Math.sin(radians); const lRoot = lightness + 0.3963377774 * a + 0.2158037573 * b; const mRoot = lightness - 0.1055613458 * a - 0.0638541728 * b; const sRoot = lightness - 0.0894841775 * a - 1.291485548 * b; const l = lRoot ** 3; const m = mRoot ** 3; const s = sRoot ** 3; const red = clampChannel( 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, ); const green = clampChannel( -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, ); const blue = clampChannel( -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, ); return 0.2126 * red + 0.7152 * green + 0.0722 * blue; } function contrastRatio(first: number, second: number): number { const lighter = Math.max(first, second); const darker = Math.min(first, second); return (lighter + 0.05) / (darker + 0.05); } export function normalizeAccentHue(hue: number): number { if (!Number.isFinite(hue)) { throw new Error("Accent hue must be a finite number"); } return ((hue % 360) + 360) % 360; } export function parseAccentHue(value: string | null): number | null { if (value === null || value.trim() === "") return null; const hue = Number(value); if (!Number.isFinite(hue) || hue < 0 || hue > 360) return null; return normalizeAccentHue(hue); } export function deriveContrastingForeground(background: OklchColor): string { const backgroundLuminance = relativeLuminance(background); const darkLuminance = relativeLuminance({ lightness: 0.1, chroma: 0, hue: 0, }); const lightLuminance = relativeLuminance({ lightness: 0.98, chroma: 0, hue: 0, }); return contrastRatio(backgroundLuminance, darkLuminance) >= contrastRatio(backgroundLuminance, lightLuminance) ? DARK_FOREGROUND : LIGHT_FOREGROUND; } const OKLCH_HUE = /^oklch\(\s*[0-9.%]+\s+[0-9.%]+\s+([0-9.]+)/i; const HEX_FULL = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i; const HEX_SHORT = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i; function srgbToLinear(channel: number): number { return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; } /** * Hue (in degrees, 0..360) of a `#rrggbb`/`#rgb` color, derived via the OKLab * transform so it lands on the same hue circle as the numeric `oklch()` colors * the palette produces. Returns null for an achromatic color (grey/black/white) * where hue is meaningless. */ function hexToAccentHue(hex: string): number | null { const full = HEX_FULL.exec(hex); const short = full ? null : HEX_SHORT.exec(hex); let r: number; let g: number; let b: number; if (full) { r = Number.parseInt(full[1], 16) / 255; g = Number.parseInt(full[2], 16) / 255; b = Number.parseInt(full[3], 16) / 255; } else if (short) { r = Number.parseInt(short[1] + short[1], 16) / 255; g = Number.parseInt(short[2] + short[2], 16) / 255; b = Number.parseInt(short[3] + short[3], 16) / 255; } else { return null; } const lr = srgbToLinear(r); const lg = srgbToLinear(g); const lb = srgbToLinear(b); const l = Math.cbrt( 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb, ); const m = Math.cbrt( 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb, ); const s = Math.cbrt( 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb, ); const labA = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const labB = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; // Achromatic: no meaningful hue. if (Math.abs(labA) < 1e-4 && Math.abs(labB) < 1e-4) return null; return normalizeAccentHue((Math.atan2(labB, labA) * 180) / Math.PI); } /** * Resolve a team's stored color (strict hex or numeric `oklch()`, e.g. from * `resolveTeamColor`) into an accent hue for the hue-based team-accent system. * Falls back to `DEFAULT_ACCENT_HUE` for unparseable or achromatic input so app * chrome always has a usable accent. */ export function getTeamAccentHue(color: string | null | undefined): number { if (typeof color !== "string") return DEFAULT_ACCENT_HUE; const trimmed = color.trim(); const oklch = OKLCH_HUE.exec(trimmed); if (oklch) { const hue = Number(oklch[1]); return Number.isFinite(hue) ? normalizeAccentHue(hue) : DEFAULT_ACCENT_HUE; } if (trimmed.startsWith("#")) { return hexToAccentHue(trimmed) ?? DEFAULT_ACCENT_HUE; } return DEFAULT_ACCENT_HUE; } export function getTeamAccentCssVariables(hue: number): TeamAccentCssVariables { const normalizedHue = normalizeAccentHue(hue); return { "--accent-hue": String(normalizedHue), "--team-primary-foreground-light": deriveContrastingForeground({ lightness: LIGHT_PRIMARY_LIGHTNESS, chroma: ACCENT_CHROMA, hue: normalizedHue, }), "--team-primary-foreground-dark": deriveContrastingForeground({ lightness: DARK_PRIMARY_LIGHTNESS, chroma: ACCENT_CHROMA, hue: normalizedHue, }), }; }