/** * Stable user-id → color mapping for visual grouping in lists (e.g. * colour every row a quoter claimed so the team can see at a glance * who owns what). * * The mapping is deterministic — the same user id always resolves to * the same colour across reloads and across users. We use a 32-bit * FNV-1a hash so the palette selection is well-distributed without * importing a hash library. * * The palette is hand-tuned for accessibility: every colour is * distinct at a glance AND pairs with a matching tinted background * (5–10 % opacity) so the row highlight reads well in both light and * dark mode. */ export interface UserColor { /** Foreground accent — left border, dot, chip text. */ accent: string; /** Background tint — used for row highlight, ~6–10% opacity. */ tint: string; /** Solid chip background — used for the user-name pill. */ chip: string; /** Foreground that reads on `chip`. */ chipFg: string; } // Curated palette of 12 visually distinct hues. Going past 12 starts to // cycle back to colours too close to neighbours. If the team grows past // that, append more entries rather than re-ordering — existing user // colour assignments must stay stable. const PALETTE: UserColor[] = [ { accent: "#2563eb", tint: "rgba(37, 99, 235, 0.10)", chip: "rgba(37, 99, 235, 0.18)", chipFg: "#1d4ed8" }, // blue { accent: "#dc2626", tint: "rgba(220, 38, 38, 0.10)", chip: "rgba(220, 38, 38, 0.18)", chipFg: "#b91c1c" }, // red { accent: "#16a34a", tint: "rgba(22, 163, 74, 0.10)", chip: "rgba(22, 163, 74, 0.18)", chipFg: "#15803d" }, // green { accent: "#ea580c", tint: "rgba(234, 88, 12, 0.10)", chip: "rgba(234, 88, 12, 0.18)", chipFg: "#c2410c" }, // orange { accent: "#9333ea", tint: "rgba(147, 51, 234, 0.10)", chip: "rgba(147, 51, 234, 0.18)", chipFg: "#7e22ce" }, // purple { accent: "#0891b2", tint: "rgba(8, 145, 178, 0.10)", chip: "rgba(8, 145, 178, 0.18)", chipFg: "#0e7490" }, // cyan { accent: "#db2777", tint: "rgba(219, 39, 119, 0.10)", chip: "rgba(219, 39, 119, 0.18)", chipFg: "#be185d" }, // pink { accent: "#ca8a04", tint: "rgba(202, 138, 4, 0.10)", chip: "rgba(202, 138, 4, 0.18)", chipFg: "#a16207" }, // amber { accent: "#0d9488", tint: "rgba(13, 148, 136, 0.10)", chip: "rgba(13, 148, 136, 0.18)", chipFg: "#0f766e" }, // teal { accent: "#7c3aed", tint: "rgba(124, 58, 237, 0.10)", chip: "rgba(124, 58, 237, 0.18)", chipFg: "#6d28d9" }, // violet { accent: "#65a30d", tint: "rgba(101, 163, 13, 0.10)", chip: "rgba(101, 163, 13, 0.18)", chipFg: "#4d7c0f" }, // lime { accent: "#475569", tint: "rgba(71, 85, 105, 0.10)", chip: "rgba(71, 85, 105, 0.18)", chipFg: "#334155" }, // slate ]; /** * FNV-1a 32-bit hash. Returns an unsigned 32-bit int. Fast enough for * per-row colour assignment; no need for crypto strength. */ function fnv1a(s: string): number { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); // Multiply by FNV prime, keeping within 32 bits via `>>> 0`. h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; } return h >>> 0; } /** * Resolve a user id (PB record id) to a stable colour. Returns `null` * for empty input so callers can short-circuit. */ export function userColor(userId: string | null | undefined): UserColor | null { if (!userId) return null; const idx = fnv1a(userId) % PALETTE.length; return PALETTE[idx]; } /** * Best-effort display name for a user. Prefers `name` (the PB auth * collection's human-readable field), falls back to the local-part of * `email`, then to "User". */ export function userDisplayName( user: { name?: string; email?: string } | null | undefined ): string { if (!user) return "—"; const name = (user.name || "").trim(); if (name) return name; const email = (user.email || "").trim(); if (!email) return "—"; return email.split("@")[0] || email; }