Files
hope-events/frontend/src/lib/colorScale.ts
T
joshuaandClaude Sonnet 5 86af7093ac Add admin-configurable branding (colors, logo, favicon) and generic default fallbacks
Site Settings -> Branding now supports a Primary/Secondary/Accent brand color
system applied site-wide (buttons, nav, hover states, links) and to outgoing
email header/CTA colors, plus a favicon upload alongside the existing logo
upload, a live preview panel (website/email x desktop/mobile), and
logo-based color suggestions. The setup wizard's Branding step got the same
treatment. Fixes two related bugs found along the way: the setup wizard's
logo/favicon upload was missing its auth token, and a static favicon.ico in
Next's special app/ convention path was silently overriding the dynamic one.

Also replaces every "Hope Events"/"Hope Family Church" default (org name,
email subjects, WhatsApp messages, report metadata, API docs) with a neutral
"Cross Code" placeholder, and the optional legal settings (operator name, IO
details, website URL, effective date) with obviously-generic placeholders
instead of defaulting to real personal/organisational details -- since this
platform is deployed for multiple organisations. Adds SETTINGS.md documenting
every setting's default behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:49:51 +02:00

209 lines
7.4 KiB
TypeScript

/**
* Framework-agnostic color math for the admin-configurable brand system.
* No DOM/window/document access — this module runs identically server-side
* (the SSR `<style id="brand-theme">` tag in layout.tsx) and client-side
* (the live Branding Preview panel, recomputing on every color-picker
* keystroke before the admin has saved anything).
*/
export type Hsl = { h: number; s: number; l: number };
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
export function isValidHex(v: string): boolean {
return HEX_RE.test((v || "").trim());
}
/** Expands 3-digit hex to 6-digit, lowercases, ensures a leading #. */
function normalizeHex(hex: string): string {
let h = (hex || "").trim();
if (!h.startsWith("#")) h = `#${h}`;
if (h.length === 4) {
h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
}
return h.toLowerCase();
}
export function hexToHsl(hex: string): Hsl {
const h6 = normalizeHex(hex);
const r = parseInt(h6.slice(1, 3), 16) / 255;
const g = parseInt(h6.slice(3, 5), 16) / 255;
const b = parseInt(h6.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: l * 100 };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let hue: number;
switch (max) {
case r: hue = ((g - b) / d + (g < b ? 6 : 0)); break;
case g: hue = (b - r) / d + 2; break;
default: hue = (r - g) / d + 4; break;
}
hue *= 60;
return { h: hue, s: s * 100, l: l * 100 };
}
export function hslToHex(h: number, s: number, l: number): string {
const hh = ((h % 360) + 360) % 360;
const ss = clamp01(s / 100);
const ll = clamp01(l / 100);
if (ss === 0) {
const v = Math.round(ll * 255);
return rgbToHex(v, v, v);
}
const q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss;
const p = 2 * ll - q;
const r = hueToRgb(p, q, hh / 360 + 1 / 3);
const g = hueToRgb(p, q, hh / 360);
const b = hueToRgb(p, q, hh / 360 - 1 / 3);
return rgbToHex(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255));
}
function hueToRgb(p: number, q: number, t: number): number {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
if (tt < 1 / 2) return q;
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
return p;
}
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (v: number) => clampByte(v).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
function clampByte(v: number): number {
return Math.min(255, Math.max(0, Math.round(v)));
}
function clamp01(v: number): number {
return Math.min(1, Math.max(0, v));
}
export function clampL(l: number): number {
return Math.min(100, Math.max(0, l));
}
/** Rounded "H S% L%" triple matching this codebase's existing shadcn CSS-variable convention (no hsl() wrapper — see globals.css). */
export function hexToHslTriple(hex: string): string {
const { h, s, l } = hexToHsl(hex);
return `${Math.round(h)} ${Math.round(s)}% ${Math.round(l)}%`;
}
/**
* WCAG-style relative luminance → picks readable foreground text/icon color
* for a solid fill of `hex`. Used for Primary (a solid button/nav fill needs
* real contrast, unlike Secondary/Accent which stay light tints — see
* buildSecondaryTokens/buildAccentTokens).
*/
export function contrastForeground(hex: string): "#ffffff" | "#0a0a0a" {
const h6 = normalizeHex(hex);
const chan = (v: number) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
const r = chan(parseInt(h6.slice(1, 3), 16));
const g = chan(parseInt(h6.slice(3, 5), 16));
const b = chan(parseInt(h6.slice(5, 7), 16));
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance > 0.45 ? "#0a0a0a" : "#ffffff";
}
/** Matches the shadcn-token foreground convention used elsewhere in globals.css (near-white / near-black HSL triples, not pure #fff/#000). */
export function contrastForegroundTriple(hex: string): string {
return contrastForeground(hex) === "#ffffff" ? "210 40% 98%" : "222.2 47.4% 11.2%";
}
const BRAND_SCALE_STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
export type BrandScaleStep = (typeof BRAND_SCALE_STEPS)[number];
/** Lightness offsets (percentage points) applied around the picked color, which is anchored exactly at step 600 — matches the site's pre-existing default (brand-600 #4F46E5). */
const BRAND_SCALE_OFFSETS: Record<BrandScaleStep, number> = {
"50": 38, "100": 32, "200": 24, "300": 16, "400": 8,
"500": 4, "600": 0, "700": -8, "800": -16, "900": -24,
};
/** Generates the full brand-50..900 hex scale from one admin-picked "Primary" hex, anchored at 600. */
export function buildBrandScale(primaryHex: string): Record<BrandScaleStep, string> {
const { h, s, l } = hexToHsl(primaryHex);
const scale = {} as Record<BrandScaleStep, string>;
for (const step of BRAND_SCALE_STEPS) {
if (step === "600") {
scale[step] = normalizeHex(primaryHex);
} else {
scale[step] = hslToHex(h, s, clampL(l + BRAND_SCALE_OFFSETS[step]));
}
}
return scale;
}
/**
* Secondary/Accent both stay light background washes (not bold fills) so
* they read the way shadcn's own default `--secondary`/`--accent` tokens do
* (subtle hover/outline surfaces, e.g. Button's `secondary`/`outline`/`ghost`
* variants) regardless of what hue the admin picks. Lightness is PINNED to a
* fixed target rather than offset from the input (like the primary scale is)
* — a relative offset would let a pastel input wash out to near-invisible.
* Saturation is capped so the tint doesn't read as a neon pastel.
*/
function buildLightTintTokens(hex: string): { bg: string; fg: string } {
const { h, s } = hexToHsl(hex);
const cappedS = Math.min(s, 60);
const bg = `${Math.round(h)} ${Math.round(cappedS)}% 96%`;
const fg = hexToHslTriple(hex);
return { bg, fg };
}
export function buildSecondaryTokens(secondaryHex: string): { bg: string; fg: string } {
return buildLightTintTokens(secondaryHex);
}
export function buildAccentTokens(accentHex: string): { bg: string; fg: string } {
return buildLightTintTokens(accentHex);
}
/**
* Builds the full CSS custom-property declaration block (no `:root{}` wrapper
* — callers embed this string themselves) for the 3-color brand system.
* Any input left undefined/invalid is simply omitted, so callers can pass a
* partial set (e.g. only Primary saved so far) without emitting broken CSS.
*/
export function buildThemeCssVars(input: { primary?: string; secondary?: string; accent?: string }): string {
const decls: string[] = [];
if (input.primary && isValidHex(input.primary)) {
const scale = buildBrandScale(input.primary);
for (const step of BRAND_SCALE_STEPS) {
decls.push(`--brand-${step}:${scale[step]}`);
}
decls.push(`--primary:${hexToHslTriple(input.primary)}`);
decls.push(`--primary-foreground:${contrastForegroundTriple(input.primary)}`);
decls.push(`--ring:${hexToHslTriple(input.primary)}`);
}
if (input.secondary && isValidHex(input.secondary)) {
const { bg, fg } = buildSecondaryTokens(input.secondary);
decls.push(`--secondary:${bg}`);
decls.push(`--secondary-foreground:${fg}`);
}
if (input.accent && isValidHex(input.accent)) {
const { bg, fg } = buildAccentTokens(input.accent);
decls.push(`--accent:${bg}`);
decls.push(`--accent-foreground:${fg}`);
}
return decls.join(";");
}