type Rgb = readonly [number, number, number]; export type PublicThemeColors = Record; export interface ContrastPair { name: string; foreground: string; background: string } export interface AdminPalette { canvas: string; surface: string; surfaceElevated: string; text: string; muted: string; border: string; accent: string; accentText: string; accentForeground: string; success: string; warning: string; error: string; info: string; sidebar: string; sidebarText: string; overlay: string; focusRing: string; } export function deriveAdminPalette(colors: PublicThemeColors): AdminPalette { const publicBackground = colors.color_background ?? "#f8fafc"; const blackRatio = contrastRatio("#000000", publicBackground) ?? 21; const whiteRatio = contrastRatio("#ffffff", publicBackground) ?? 1; const dark = whiteRatio > blackRatio; const canvas = dark ? "#0b0f19" : "#f1f5f9"; const surface = dark ? "#151b27" : "#ffffff"; const surfaceElevated = dark ? "#1e2635" : "#f8fafc"; const text = dark ? "#f8fafc" : "#0f172a"; const muted = dark ? "#a8b3c7" : "#475569"; const accent = colors.color_primary ?? "#f59e0b"; return { canvas, surface, surfaceElevated, text: readableColor(text, [canvas, surface, surfaceElevated]), muted: readableColor(muted, [canvas, surface, surfaceElevated]), border: dark ? "#334155" : "#cbd5e1", accent, accentText: readableColor(accent, [canvas, surface, surfaceElevated]), accentForeground: readableColor("#ffffff", [accent]), success: dark ? "#4ade80" : "#15803d", warning: dark ? "#facc15" : "#a16207", error: dark ? "#f87171" : "#b91c1c", info: dark ? "#38bdf8" : "#0369a1", sidebar: dark ? "#101622" : "#172033", sidebarText: "#f8fafc", overlay: "rgba(2, 6, 23, 0.72)", focusRing: accent, }; } export function derivePublicForegrounds(c: PublicThemeColors) { const value = (key: string, fallback: string) => c[key] ?? fallback; const background = value("color_background", "#f8fafc"); const surface = value("color_surface", "#ffffff"); const dropdown = value("color_dropdown", surface); const navbar = value("color_navbar", surface); const primary = value("color_primary", "#f59e0b"); const accent = value("color_accent", "#10b981"); const make = (name: string, preferred: string, bg: string): ContrastPair => ({ name, foreground: readableColor(preferred, [bg]), background: bg, }); const pairs = [ make("body", value("color_text", "#0f172a"), background), make("surface", value("color_text", "#0f172a"), surface), make("muted", value("color_text_muted", "#64748b"), surface), make("subtle", value("color_text_muted", "#64748b"), surface), make("disabled", value("color_text_muted", "#64748b"), surface), make("sidebar", value("color_navbar_text", "#1e293b"), navbar), make("input", value("color_text", "#0f172a"), dropdown), make("link", value("link_color", primary), surface), make("link-hover", value("link_hover_color", primary), surface), make("navbar", value("color_navbar_text", "#1e293b"), navbar), make("dropdown", value("color_text", "#0f172a"), dropdown), make("primary", value("button_text_color", "#1e293b"), primary), make("accent", "#ffffff", accent), make("button-primary", value("button_text_color", "#1e293b"), value("button_primary_color", primary)), make("button-secondary", value("button_secondary_text_color", "#ffffff"), value("button_secondary_color", "#22c55e")), make("button-danger", value("button_danger_text_color", "#ffffff"), value("button_danger_color", "#ef4444")), make("button-outline", value("button_outline_text_color", "#1a1a2e"), surface), make("pill", value("color_text", "#0f172a"), surface), make("badge", value("button_text_color", "#1e293b"), primary), make("overlay", "#ffffff", "#111827"), ...(["success", "warning", "error", "info"] as const).flatMap((status) => { const statusColor = value(`color_${status}`, "#0f172a"); return [ make(`${status}-text`, statusColor, surface), make(`${status}-solid`, "#ffffff", statusColor), ]; }), ]; return { pairs, byName: Object.fromEntries(pairs.map((pair) => [pair.name, pair.foreground])) }; } export function contrastRatio(foreground: string, background: string): number | null { const fg = parseHex(foreground); const bg = parseHex(background); if (!fg || !bg) return null; const light = Math.max(relativeLuminance(fg), relativeLuminance(bg)); const dark = Math.min(relativeLuminance(fg), relativeLuminance(bg)); return (light + 0.05) / (dark + 0.05); } export function readableColor( preferred: string, backgrounds: readonly string[], minimumRatio = 4.5, ): string { const preferredRatios = backgrounds.map((background) => contrastRatio(preferred, background)); if (preferredRatios.some((ratio) => ratio === null)) return preferred; if (preferredRatios.every((ratio) => (ratio as number) >= minimumRatio)) return preferred; const candidates = ["#000000", "#ffffff"] as const; return candidates .map((color) => ({ color, minimum: Math.min(...backgrounds.map((background) => contrastRatio(color, background) ?? 0)), })) .sort((a, b) => b.minimum - a.minimum)[0].color; } function parseHex(value: string): Rgb | null { const match = /^#([0-9a-f]{6})$/i.exec(value.trim()); if (!match) return null; return [ Number.parseInt(match[1].slice(0, 2), 16), Number.parseInt(match[1].slice(2, 4), 16), Number.parseInt(match[1].slice(4, 6), 16), ]; } function relativeLuminance(rgb: Rgb): number { const [red, green, blue] = rgb.map((channel) => { const value = channel / 255; return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; }); return 0.2126 * red + 0.7152 * green + 0.0722 * blue; }