// Per-site accent: which of the family's named accents a site wears, or a // custom hex. Stored on Site.accent (site.json) as an accent id (`"brass"`) or // a `"#rrggbb"`; absent = DEFAULT_ACCENT (Signal). The palette itself — every // accent's value on every base ground, and the contrast rule — is lib/brand.ts; // this module turns a stored setting into colours. // // THE PUBLISHED ACCENT IS ALWAYS A HEX. Third-party hubs read other sites' // `/site.json`, `hub-sites.json` and the homepage summary, and draw the value as // a colour; an id means nothing to them. accentHex is the one mapping, and // every publisher (siteDescriptor, compose-hub, homepageSummary) goes through // it. The family's OWN summaries (homepage-summary.json, hub-summary.json) also // carry a named accent's id beside the hex (accentIdOf), so the homepage and // the hub can paint it per base (lib/siteColor.ts); the hex stays for every // other reader. import { ACCENTS, ACCENT_INK, BASE_GROUNDS, BASE_GROUND_IDS, DEFAULT_ACCENT, MIN_ACCENT_CONTRAST, contrastRatio, isAccentId, type AccentId, type BaseGround, } from "./brand"; const HEX_RE = /^#?([0-9a-f]{6})$/i; // Normalize a raw accent into a lowercase "#rrggbb", or undefined if it isn't a // 6-digit hex color. Mirrors parseSiteUrl/cloudflareProject optional handling. // A HEX parser only: an accent id is not a hex (parseAccentSetting takes both). export function parseAccent(input: unknown): string | undefined { if (typeof input !== "string") return undefined; const m = HEX_RE.exec(input.trim()); return m ? `#${m[1].toLowerCase()}` : undefined; } // A stored accent setting: an accent id (trimmed, any case → the lowercase id), // a custom "#rrggbb" (normalized by parseAccent), or undefined for anything // else. A hex is never mapped back to an id, even one equal to an accent's // value: the operator chose "custom". export function parseAccentSetting(input: unknown): string | undefined { if (typeof input !== "string") return undefined; const lower = input.trim().toLowerCase(); if (isAccentId(lower)) return lower; return parseAccent(lower); } export type ResolvedAccent = { id: AccentId | "custom"; light: string; dark: string; }; function toHex(r: number, g: number, b: number): string { return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`; } function meetsRule(hex: string, base: BaseGround): boolean { return ( contrastRatio(hex, BASE_GROUNDS[base]) >= MIN_ACCENT_CONTRAST && contrastRatio(hex, ACCENT_INK[base]) >= MIN_ACCENT_CONTRAST ); } // Fit a custom hex to one base: unchanged when it already meets the rule // (brand.ts MIN_ACCENT_CONTRAST against the ground AND the ink), otherwise // mixed toward black (light) or white (dark) in 1 % steps until it does. // Mixing keeps the hue; full black/white always passes, so this terminates. function fitAccent(hex: string, base: BaseGround): string { if (meetsRule(hex, base)) return hex; const n = parseInt(hex.slice(1), 16); const rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255]; const target = base === "dark" ? 255 : 0; for (let step = 1; step <= 100; step++) { const t = step / 100; const [r, g, b] = rgb.map((c) => c + (target - c) * t); const out = toHex(r, g, b); if (meetsRule(out, base)) return out; } return base === "dark" ? "#ffffff" : "#000000"; } // The colours a stored setting paints with, per base. A named accent reads its // row of the ACCENTS table; a custom hex is fitted to each ground; anything // else (absent, malformed) is DEFAULT_ACCENT. export function resolveAccent(input: unknown): ResolvedAccent { const setting = parseAccentSetting(input); if (setting && !isAccentId(setting)) { return { id: "custom", light: fitAccent(setting, "light"), dark: fitAccent(setting, "dark"), }; } const a = ACCENTS[setting && isAccentId(setting) ? setting : DEFAULT_ACCENT]; return { id: a.id, light: a.onLight, dark: a.onDark }; } // The PUBLISHED accent: always a hex. An id becomes its on-dark value (the // family's hub and homepage are dark, and for a NAMED accent that value is also // the lit line of the site's icon); a custom hex is published as stored — its // icon is lit with the dark-fitted resolveAccent().dark instead, which can // differ. Absent or malformed → undefined, so a publisher omits the key exactly // as before. export function accentHex(input: unknown): string | undefined { const setting = parseAccentSetting(input); if (!setting) return undefined; return isAccentId(setting) ? ACCENTS[setting].onDark : setting; } // The named accent a stored setting picks, or undefined for a custom hex and // for no (or a malformed) accent. The id the family's own summaries publish // beside the hex. export function accentIdOf(input: unknown): AccentId | undefined { const setting = parseAccentSetting(input); return setting && isAccentId(setting) ? setting : undefined; } // The inline `` a CUSTOM-hex site needs: its fitted value per base, // which the token sheet's `--swatch-custom` reads (export/app/layout.tsx puts it // on beside `data-accent="custom"`). Null for a named accent or no // accent — those are pure CSS (`data-accent`). export function customAccentVars(input: unknown): Record | null { const r = resolveAccent(input); if (r.id !== "custom") return null; const vars: Record = {}; for (const base of BASE_GROUND_IDS) vars[`--accent-custom-${base}`] = r[base]; return vars; }