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>
209 lines
7.4 KiB
TypeScript
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(";");
|
|
}
|