// 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 ships four variants: // - `light` -- the stroke for light surfaces (default / Sunset themes) // - `dark` -- a slightly brighter stroke for dark surfaces (Dark / Ocean), // so the border reads against a deep background without glaring // - `soft` -- a calm, low-chroma tint of the same hue used as the card's // fill on light themes. Derived per-color (not a generic gray) // so every card carries a whisper of its own identity behind // the stroke. // - `softDark` -- the card fill on dark themes. It's the same hue as `soft` // but at a lightness that reads as a *subtle tint over a dark // surface* (blended with --card via color-mix), instead of the // near-white pastel that `soft` is -- which would otherwise // make every card a washed-out pale rectangle at night. export type GroupColorKey = | "coral" | "amber" | "lime" | "teal" | "sky" | "indigo" | "violet" | "pink"; export interface GroupColor { key: GroupColorKey; label: string; light: string; dark: string; soft: string; softDark: string; } export const GROUP_COLORS: GroupColor[] = [ { key: "coral", label: "Coral", light: "#e8574a", dark: "#ff8177", soft: "#fae4e1", softDark: "#3a2320" }, { key: "amber", label: "Amber", light: "#dd8f0e", dark: "#f2ab3e", soft: "#f8edd6", softDark: "#3a301c" }, { key: "lime", label: "Lime", light: "#4c9a2a", dark: "#7cc954", soft: "#e5f0dc", softDark: "#25331d" }, { key: "teal", label: "Teal", light: "#0f9488", dark: "#2dd4bf", soft: "#daf1ed", softDark: "#16332f" }, { key: "sky", label: "Sky", light: "#2f7fe0", dark: "#5b9dee", soft: "#e0eafa", softDark: "#1a2b40" }, { key: "indigo", label: "Indigo", light: "#5b5bd6", dark: "#8c8cf0", soft: "#e4e4f7", softDark: "#23233c" }, { key: "violet", label: "Violet", light: "#9440d1", dark: "#c084fc", soft: "#eddff9", softDark: "#2e2136" }, { key: "pink", label: "Pink", light: "#d63f8f", dark: "#f472b6", soft: "#fadfee", softDark: "#37202e" }, ]; // --- Cyberpunk neon palette --- // A secondary palette used ONLY when the "cyberpunk" theme is active. // These are high-saturation neon values (magenta, cyan, acid green, etc.) // that give group cards that classic "neon in the night" look. The // softDark fills are shifted well down in lightness so they read as a // subtle glow against the near-black surfaces instead of washed-out pastels. const CYBERPUNK_PALETTE: Record = { coral: { key: "coral", label: "Neon Pink", light: "#ff2a8f", dark: "#ff2a8f", soft: "#fadfee", softDark: "#3d1433" }, amber: { key: "amber", label: "Acid Lime", light: "#a8ff00", dark: "#a8ff00", soft: "#eeffe0", softDark: "#2d3a12" }, lime: { key: "lime", label: "Toxic Green", light: "#39ff14", dark: "#39ff14", soft: "#e0fde0", softDark: "#163d14" }, teal: { key: "teal", label: "Electric Cyan",light: "#00fff0", dark: "#00fff0", soft: "#dffffa", softDark: "#0f3d3a" }, sky: { key: "sky", label: "Voltage Blue",light: "#00aaff", dark: "#00aaff", soft: "#e0f0ff", softDark: "#12263d" }, indigo: { key: "indigo", label: "Neon Purple", light: "#bf40ff", dark: "#bf40ff", soft: "#f0e0ff", softDark: "#2e1640" }, violet: { key: "violet", label: "Neon Magenta",light: "#ff2aff", dark: "#ff2aff", soft: "#ffe0f5", softDark: "#3d1436" }, pink: { key: "pink", label: "Deep Violet", light: "#8a2be2", dark: "#8a2be2", soft: "#e8d6ff", softDark: "#261236" }, }; // --- Blueprint drafting-ink palette --- // Used ONLY when the "blueprint" theme is active. The cards are white // drafting paper on a cobalt board, so the strokes read as technical pen // inks -- cobalt and ultramarine for the primary lines, with the classic // annotation inks (proof red, sepia, green, cerulean) for the rest. Calm, // even-saturation inks, not neon: this is a drafting table, not a nightclub. const BLUEPRINT_PALETTE: Record = { coral: { key: "coral", label: "Proof Red", light: "#b83a4a", dark: "#b83a4a", soft: "#fbe5e7", softDark: "#3a2320" }, amber: { key: "amber", label: "Sepia Ink", light: "#a06325", dark: "#a06325", soft: "#f5ecda", softDark: "#3a301c" }, lime: { key: "lime", label: "Draft Green", light: "#3d7a46", dark: "#3d7a46", soft: "#e3efe1", softDark: "#25331d" }, teal: { key: "teal", label: "Cerulean Ink", light: "#1d6f74", dark: "#1d6f74", soft: "#ddefee", softDark: "#16332f" }, sky: { key: "sky", label: "Cobalt Ink", light: "#2456b4", dark: "#2456b4", soft: "#e0e9f8", softDark: "#1a2b40" }, indigo: { key: "indigo", label: "Ultramarine", light: "#43489f", dark: "#43489f", soft: "#e4e5f5", softDark: "#23233c" }, violet: { key: "violet", label: "Violet Ink", light: "#6d43a8", dark: "#6d43a8", soft: "#ece2f7", softDark: "#2e2136" }, pink: { key: "pink", label: "Magenta Ink", light: "#b03586", dark: "#b03586", soft: "#f7e1ef", softDark: "#37202e" }, }; // --- Vaporwave retro palette --- // Used ONLY when the "vaporwave" theme is active. Pastel synthwave sky with // a glossy 90s-desktop gloss, so the strokes are soft retro-neon rather than // full cyberpunk brightness: Miami pink, sunset gold, CRT mint, electric // aqua, chrome blue, laser purple. Saturated enough to read on the pale // card, muted enough to sit in the pastel sky. const VAPORWAVE_PALETTE: Record = { coral: { key: "coral", label: "Miami Pink", light: "#e64f9e", dark: "#e64f9e", soft: "#fbe0ef", softDark: "#3d1433" }, amber: { key: "amber", label: "Sunset Gold", light: "#e08a1f", dark: "#e08a1f", soft: "#fbeed6", softDark: "#3a301c" }, lime: { key: "lime", label: "CRT Mint", light: "#2fb584", dark: "#2fb584", soft: "#dcf3e9", softDark: "#163d14" }, teal: { key: "teal", label: "Electric Aqua", light: "#14a9c4", dark: "#14a9c4", soft: "#daf0f5", softDark: "#0f3d3a" }, sky: { key: "sky", label: "Chrome Blue", light: "#3f6fe0", dark: "#3f6fe0", soft: "#e0e8fb", softDark: "#1a2b40" }, indigo: { key: "indigo", label: "Laser Purple", light: "#7c4fe0", dark: "#7c4fe0", soft: "#e8e1fa", softDark: "#23233c" }, violet: { key: "violet", label: "Vapor Magenta", light: "#b455d4", dark: "#b455d4", soft: "#f3e2fa", softDark: "#2e2136" }, pink: { key: "pink", label: "Pastel Orchid", light: "#cb6ca8", dark: "#cb6ca8", soft: "#f9e4f1", softDark: "#37202e" }, }; // --- Notebook stationery palette --- // Used ONLY when the "notebook" theme is active. The cards are index cards // on a warm desk, so the strokes read like the pens and markers in the // desk drawer: blue ballpoint, red pencil, green pen, teal marker, purple // pen, rose highlighter. Muted, papery, hand-inked -- the opposite of // glowing; these are inks that dry on paper. const NOTEBOOK_PALETTE: Record = { coral: { key: "coral", label: "Red Pencil", light: "#c04a45", dark: "#c04a45", soft: "#fae6e2", softDark: "#3a2320" }, amber: { key: "amber", label: "Orange Marker", light: "#d9822b", dark: "#d9822b", soft: "#f9ecd7", softDark: "#3a301c" }, lime: { key: "lime", label: "Green Pen", light: "#46905b", dark: "#46905b", soft: "#e3f0e6", softDark: "#25331d" }, teal: { key: "teal", label: "Teal Marker", light: "#2a8f8b", dark: "#2a8f8b", soft: "#dcefec", softDark: "#16332f" }, sky: { key: "sky", label: "Blue Ballpoint", light: "#2f5cb8", dark: "#2f5cb8", soft: "#e2e9fa", softDark: "#1a2b40" }, indigo: { key: "indigo", label: "Purple Pen", light: "#5b4bb0", dark: "#5b4bb0", soft: "#e6e3f6", softDark: "#23233c" }, violet: { key: "violet", label: "Violet Ink", light: "#8552b8", dark: "#8552b8", soft: "#ede4f7", softDark: "#2e2136" }, pink: { key: "pink", label: "Rose Highlighter", light: "#c25a93", dark: "#c25a93", soft: "#f8e5f0", softDark: "#37202e" }, }; // --- Starfield starlight palette --- // Used ONLY when the "starfield" theme is active. The cards float in a // near-black indigo void, so the strokes read as starlight: bright but // soft, like distant stars and console glows -- star gold, aurora, ion // cyan, nebula violet. Deliberately gentler than cyberpunk's neon; these // are lights seen across a galaxy, not tubes in a night city. softDark // fills are low-light tints so they read as faint nebulae against the // void instead of washed-out pastels. const STARFIELD_PALETTE: Record = { coral: { key: "coral", label: "Red Giant", light: "#ff8592", dark: "#ff8592", soft: "#fadfee", softDark: "#3a2026" }, amber: { key: "amber", label: "Star Gold", light: "#ffc86b", dark: "#ffc86b", soft: "#eeffe0", softDark: "#3a3018" }, lime: { key: "lime", label: "Aurora", light: "#5fd6a0", dark: "#5fd6a0", soft: "#e0fde0", softDark: "#173a2b" }, teal: { key: "teal", label: "Ion Cyan", light: "#55c8f0", dark: "#55c8f0", soft: "#dffffa", softDark: "#14303d" }, sky: { key: "sky", label: "Polar Blue", light: "#7b9df5", dark: "#7b9df5", soft: "#e0f0ff", softDark: "#1b2542" }, indigo: { key: "indigo", label: "Nebula Violet", light: "#a48bfa", dark: "#a48bfa", soft: "#f0e0ff", softDark: "#271f42" }, violet: { key: "violet", label: "Nebula Pink", light: "#d18ff0", dark: "#d18ff0", soft: "#ffe0f5", softDark: "#33203a" }, pink: { key: "pink", label: "Rose Star", light: "#f591bb", dark: "#f591bb", soft: "#e8d6ff", softDark: "#3a2230" }, }; 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]; } /** Get the cyberpunk-neon variant of a group color. Used ONLY when the * "cyberpunk" theme is active so groups read as neon tubes in the night. */ export function getCyberpunkGroupColor(key: string): GroupColor { return CYBERPUNK_PALETTE[key] ?? CYBERPUNK_PALETTE["coral"]; } // Every theme-specific palette in one place, keyed by theme name (the // values in components/theme/theme-provider's THEMES). Themes without an // entry keep the base GROUP_COLORS above. const THEME_PALETTES: Partial>> = { cyberpunk: CYBERPUNK_PALETTE, blueprint: BLUEPRINT_PALETTE, vaporwave: VAPORWAVE_PALETTE, notebook: NOTEBOOK_PALETTE, starfield: STARFIELD_PALETTE, }; /** The group color for a given theme: that theme's palette if it has one * (cyberpunk, blueprint, vaporwave, notebook, starfield), otherwise the * base GROUP_COLORS entry. Used by the themed group cards so each * character's board carries the theme's own ink. */ export function getThemedGroupColor(theme: string, key: string): GroupColor { return THEME_PALETTES[theme]?.[key] ?? 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 surface. */ 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 near-white on dark themes) 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)); // Light surfaces: the to-do ink runs DEEPER than the group's stroke (not // lighter) so it clears WCAG AA (>=4.5:1) against the near-white pastel // card fills -- a flat -8 only reached ~3.3:1 for the warm hues. Dark // surfaces do the opposite: brighter ink over the dark tint. const delta = variant === "dark" ? 8 : -16; const result = hslToHex( hue, saturation, Math.min(Math.max(lightness + delta, 15), 88) ); brighterCache.set(cacheKey, result); return result; }