163 lines
5.2 KiB
TypeScript
163 lines
5.2 KiB
TypeScript
// 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<string, GroupColor> = 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<string, string>();
|
|
|
|
/** 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<string, string>();
|
|
|
|
/**
|
|
* 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;
|
|
}
|