// Curated palette for Group cards. Colors are hand-picked and spaced evenly // around the hue wheel so no two are near-duplicates and none clash, per the // "vibrant colors from the same aesthetic family" requirement. Stored as a // palette *key* on the Group record (not a raw hex) so this list can be // retinted centrally without a data migration. // // Each color has a slightly deeper/less-saturated `dark` variant so a thick // border doesn't glare against a dark background. export type GroupColorKey = | "coral" | "amber" | "lime" | "teal" | "sky" | "indigo" | "violet" | "pink"; export interface GroupColor { key: GroupColorKey; label: string; light: string; dark: string; } export const GROUP_COLORS: GroupColor[] = [ { key: "coral", label: "Coral", light: "#f4574a", dark: "#ff7a6e" }, { key: "amber", label: "Amber", light: "#e8940f", dark: "#f5ad3d" }, { key: "lime", label: "Lime", light: "#4c9a2a", dark: "#78c94e" }, { key: "teal", label: "Teal", light: "#0f9488", dark: "#2dd4bf" }, { key: "sky", label: "Sky", light: "#2f7fe0", dark: "#5b9dee" }, { key: "indigo", label: "Indigo", light: "#5b5bd6", dark: "#8c8cf0" }, { key: "violet", label: "Violet", light: "#9440d1", dark: "#c084fc" }, { key: "pink", label: "Pink", light: "#d63f8f", dark: "#f472b6" }, ]; const GROUP_COLOR_MAP: Record = Object.fromEntries( GROUP_COLORS.map((c) => [c.key, c]) ); export const DEFAULT_GROUP_COLOR_KEY: GroupColorKey = GROUP_COLORS[0].key; export function getGroupColor(key: string): GroupColor { return GROUP_COLOR_MAP[key] ?? GROUP_COLORS[0]; } // --- Complementary accent (for the to-do progress pie on a Group card) --- // // Derived at runtime by rotating a base color's hue 180° and pulling the // result into a narrow saturation/lightness band, rather than a second // hand-picked palette -- so every group (including any added later) gets a // legible, opposing accent for free, without a parallel list to maintain // here. The band keeps the result a calm, consistent "accent" regardless of // how light, dark, or saturated the base color happens to be. function hexToRgb(hex: string): [number, number, number] { const clean = hex.replace("#", ""); return [ parseInt(clean.slice(0, 2), 16), parseInt(clean.slice(2, 4), 16), parseInt(clean.slice(4, 6), 16), ]; } function rgbToHsl(r: number, g: number, b: number): [number, number, number] { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const l = (max + min) / 2; const d = max - min; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)); let h = 0; if (d !== 0) { switch (max) { case r: h = ((g - b) / d) % 6; break; case g: h = (b - r) / d + 2; break; default: h = (r - g) / d + 4; break; } h *= 60; if (h < 0) h += 360; } return [h, s * 100, l * 100]; } function hslToHex(h: number, s: number, l: number): string { h = ((h % 360) + 360) % 360; s /= 100; l /= 100; const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2; let r = 0, g = 0, b = 0; if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0]; else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c]; else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; const toHex = (v: number) => Math.round((v + m) * 255) .toString(16) .padStart(2, "0"); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } const complementaryCache = new Map(); /** The opposing accent color for a given base hex, tuned per light/dark theme. */ export function getComplementaryColor(hex: string, variant: "light" | "dark"): string { const cacheKey = `${hex}:${variant}`; const cached = complementaryCache.get(cacheKey); if (cached) return cached; const [hue, saturation] = rgbToHsl(...hexToRgb(hex)); const result = hslToHex( hue + 180, Math.min(Math.max(saturation, 55), 75), variant === "dark" ? 68 : 46 ); complementaryCache.set(cacheKey, result); return result; } const brighterCache = new Map(); /** * A brighter tint of a Group's own color, same hue, for un-checked to-do * text. Deliberately distinct from the plain foreground color (which would * otherwise render pure white in dark mode) so the group title -- which * keeps the default foreground color -- stays the most prominent text on * the card instead of competing with its own to-dos. * * The lightness nudge is relative to the base color's *own* lightness * (not a flat target) so the result stays visibly tied to that specific * hue instead of every color washing out toward the same pale value. */ export function getBrighterColor(hex: string, variant: "light" | "dark"): string { const cacheKey = `${hex}:${variant}`; const cached = brighterCache.get(cacheKey); if (cached) return cached; const [hue, saturation, lightness] = rgbToHsl(...hexToRgb(hex)); const delta = variant === "dark" ? 8 : -8; const result = hslToHex( hue, saturation, Math.min(Math.max(lightness + delta, 15), 88) ); brighterCache.set(cacheKey, result); return result; }