Theme state and scoped writes
The module that validates theme updates and returns the CSS variables affected by each change.
Source: lib/theme/active-theme.ts
This file is part of the site codebase and is shown with its project imports. Read the design writeup for context.
/**
* Active Theme — the single owner of every write to the Theme currently
* applied in the visitor's browser.
*
* This module is pure: no React, no DOM, no clock. Every operation takes the
* current state and returns the next state plus the exact set of CSS custom
* properties that changed. The React layer in `context.tsx` holds the state
* and hands the variable map to `apply.ts`; nothing else may write a Theme.
*
* See CONTEXT.md for the domain terms, and
* docs/adr/0002-clamp-invalid-theme-values-silently.md for the validation
* policy implemented in `clampColour` and friends.
*/
import type {
ThemeConfig,
ThemeColors,
ThemeColorKey,
ThemeFonts,
ThemeShadows,
ThemeLayout,
ColorMode,
} from './types';
import { CAMEL_TO_CSS_VAR } from './types';
import {
parseOklch,
formatOklch,
hexToOklch,
srgbToLinear,
linearSrgbToOklab,
} from './color-utils';
import { DEFAULT_THEME } from './presets';
// ============================================================================
// State
// ============================================================================
/**
* The Active Theme and the Colour Mode it is being shown in.
*
* Colour Mode is not part of the Theme — every Theme defines both palettes —
* so switching mode is not an edit.
*/
export interface ActiveThemeState {
theme: ThemeConfig;
mode: ColorMode;
}
/**
* The result of any operation: the next state, plus exactly the CSS custom
* properties that need writing to reach it.
*/
export interface ThemeWrite {
state: ActiveThemeState;
vars: Record<string, string>;
}
// ============================================================================
// Validation policy (ADR-0002)
// ============================================================================
/**
* Upper bound on OKLCH chroma. True sRGB gamut is hue-dependent, but a flat
* ceiling keeps this pure, fast, and testable — and every value it lets
* through is one the browser can render.
*/
export const CHROMA_MAX = 0.4;
/** Radius beyond this stops reading as a corner and starts reading as a pill. */
export const RADIUS_MAX = 1.5;
const clamp = (value: number, min: number, max: number): number =>
Math.max(min, Math.min(max, value));
/**
* Converts an sRGB triple in 0..1 to an OKLCH string.
*/
function srgbToOklchString(r: number, g: number, b: number): string {
const lab = linearSrgbToOklab(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b));
const c = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
let h = (Math.atan2(lab.b, lab.a) * 180) / Math.PI;
if (h < 0) h += 360;
return formatOklch(clamp(lab.l, 0, 1), clamp(c, 0, CHROMA_MAX), h);
}
/**
* Parses `rgb(r g b)` / `rgb(r, g, b)`, with or without percentages.
*/
function rgbStringToOklch(value: string): string | null {
const match = value.match(
/^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/i
);
if (!match) return null;
const channel = (raw: string): number =>
raw.endsWith('%') ? parseFloat(raw) / 100 : parseFloat(raw) / 255;
const [r, g, b] = [channel(match[1]), channel(match[2]), channel(match[3])];
if ([r, g, b].some((n) => Number.isNaN(n))) return null;
return srgbToOklchString(clamp(r, 0, 1), clamp(g, 0, 1), clamp(b, 0, 1));
}
/**
* Parses `hsl(h s% l%)` / `hsl(h, s%, l%)`.
*/
function hslStringToOklch(value: string): string | null {
const match = value.match(
/^hsla?\(\s*(-?[\d.]+)(?:deg)?[\s,]+([\d.]+)%?[\s,]+([\d.]+)%?/i
);
if (!match) return null;
// CSS hue is modulo 360 and may be negative. Normalising here matters more
// than it looks: the conversion below only wraps once, so an un-normalised
// `hsl(720 ...)` produces black rather than red — and black is in gamut, so
// the clamp would wave it through onto the page.
const degrees = parseFloat(match[1]);
const s = clamp(parseFloat(match[2]) / 100, 0, 1);
const l = clamp(parseFloat(match[3]) / 100, 0, 1);
if ([degrees, s, l].some((n) => Number.isNaN(n))) return null;
const h = (((degrees % 360) + 360) % 360) / 360;
// Standard HSL -> RGB.
const hueToChannel = (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;
};
if (s === 0) return srgbToOklchString(l, l, l);
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return srgbToOklchString(
hueToChannel(p, q, h + 1 / 3),
hueToChannel(p, q, h),
hueToChannel(p, q, h - 1 / 3)
);
}
/**
* Brings any colour a visitor can plausibly type into a renderable OKLCH
* value, clamping into gamut rather than rejecting.
*
* Out-of-gamut OKLCH is clamped per channel. Other colour spaces are
* converted — pasting a hex code is the most likely thing a client does in
* the Theme Editor, and honouring it is closer to what was asked for than
* discarding it. Only genuinely unparseable input falls back, and it falls
* back to the *previous* value: there is nothing closer to honour.
*/
export function clampColour(value: unknown, previous: string): string {
if (typeof value !== 'string') return previous;
const input = value.trim();
if (input === '') return previous;
// `oklch(L C H / A)`. The shared parser in color-utils.ts does not read the
// alpha form, and Themes do use it (translucent borders in dark palettes),
// so it is handled here rather than silently falling through to `previous`.
const alpha = input.match(
/^oklch\(\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\/\s*([\d.]+%?)\s*\)$/i
);
if (alpha) {
const [l, c, h] = [parseFloat(alpha[1]), parseFloat(alpha[2]), parseFloat(alpha[3])];
const fixed = clampComponents(l, c, h);
return fixed === null ? input : `oklch(${fixed} / ${alpha[4]})`;
}
const oklch = parseOklch(input);
if (oklch) {
const fixed = clampComponents(oklch.l, oklch.c, oklch.h);
return fixed === null ? input : `oklch(${fixed})`;
}
const hex = hexToOklch(input);
if (hex) {
return formatOklch(clamp(hex.l, 0, 1), clamp(hex.c, 0, CHROMA_MAX), hex.h);
}
return rgbStringToOklch(input) ?? hslStringToOklch(input) ?? previous;
}
/**
* Corrects OKLCH components, or returns null when they were already in range.
*
* Null means "leave the string exactly as the author wrote it". Reformatting
* every in-range colour to four decimals would churn share links, CSS exports,
* and preset values for no gain.
*/
function clampComponents(l: number, c: number, h: number): string | null {
const fl = clamp(l, 0, 1);
const fc = clamp(c, 0, CHROMA_MAX);
const fh = ((h % 360) + 360) % 360;
if (fl === l && fc === c && fh === h) return null;
return `${fl.toFixed(4)} ${fc.toFixed(4)} ${fh.toFixed(4)}`;
}
/** Clamps radius into the range the editor's slider can express. */
export function clampRadius(value: unknown, previous: number): number {
if (typeof value !== 'number' || Number.isNaN(value)) return previous;
return clamp(value, 0, RADIUS_MAX);
}
/** Clamps a shadow patch, field by field, against the current shadows. */
export function clampShadows(
patch: Partial<ThemeShadows>,
previous: ThemeShadows
): ThemeShadows {
const num = (value: unknown, fallback: number, min: number, max: number): number =>
typeof value === 'number' && !Number.isNaN(value)
? clamp(value, min, max)
: fallback;
return {
x: num(patch.x, previous.x, -50, 50),
y: num(patch.y, previous.y, -50, 50),
blur: num(patch.blur, previous.blur, 0, 100),
spread: num(patch.spread, previous.spread, -50, 50),
opacity: num(patch.opacity, previous.opacity, 0, 1),
color: patch.color === undefined
? previous.color
: clampColour(patch.color, previous.color),
};
}
/**
* A font family list is free-form — there is no gamut to clamp it into — so
* the policy degrades to the same previous-value fallback the colours use.
*/
export function clampFont(value: unknown, previous: string): string {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : previous;
}
/**
* Runs the whole policy over a Theme arriving from outside — a share link, an
* import, a Preset, or the assistant.
*
* ADR-0002 is untrusted-by-default: Presets go through this too. Values
* already in range come back byte-identical, so this costs nothing visible.
*/
export function clampTheme(theme: ThemeConfig, fallback: ThemeConfig): ThemeConfig {
const colours = (next: ThemeColors, previous: ThemeColors): ThemeColors => {
const out = {} as ThemeColors;
for (const key of Object.keys(CAMEL_TO_CSS_VAR) as ThemeColorKey[]) {
out[key] = clampColour(next?.[key], previous[key]);
}
return out;
};
return {
...theme,
light: colours(theme.light, fallback.light),
dark: colours(theme.dark, fallback.dark),
fonts: {
sans: clampFont(theme.fonts?.sans, fallback.fonts.sans),
serif: clampFont(theme.fonts?.serif, fallback.fonts.serif),
mono: clampFont(theme.fonts?.mono, fallback.fonts.mono),
},
radius: clampRadius(theme.radius, fallback.radius),
shadows: clampShadows(theme.shadows ?? {}, fallback.shadows),
};
}
// ============================================================================
// Identity
// ============================================================================
/**
* A Custom Theme's id, derived from what the Theme *is* rather than from when
* it arrived. Two imports of the same Theme are the same Theme.
*/
export function hashTheme(theme: ThemeConfig): string {
const material = JSON.stringify([
theme.light,
theme.dark,
theme.fonts,
theme.shadows,
theme.radius,
theme.spacing,
theme.tracking,
theme.layout,
]);
// FNV-1a. Short, stable, and dependency-free — this is a naming scheme, not
// a security boundary.
let hash = 0x811c9dc5;
for (let i = 0; i < material.length; i++) {
hash ^= material.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(36);
}
/**
* Records that a Theme has diverged from whatever it started as.
*
* Idempotent: a Theme that is already Custom keeps the identity it has, so
* editing Ocean twice gives "Custom Ocean", never "Custom Custom Ocean".
* Divergence is judged by what happened, not by what the values are — dialling
* every value back to match the Preset does not restore preset identity.
*/
export function markCustom(theme: ThemeConfig, name?: string): ThemeConfig {
if (theme.id.startsWith('custom-')) {
return name === undefined ? theme : { ...theme, name };
}
return {
...theme,
id: `custom-${theme.id}`,
name: name ?? `Custom ${theme.name}`,
};
}
// ============================================================================
// Variable maps
// ============================================================================
/** The 32 colour variables for one palette. */
export function colourVars(colors: ThemeColors): Record<string, string> {
const vars: Record<string, string> = {};
for (const [camelKey, cssVar] of Object.entries(CAMEL_TO_CSS_VAR)) {
const value = colors[camelKey as ThemeColorKey];
if (value) vars[cssVar] = value;
}
return vars;
}
/** The variable for a single colour. */
export function colourVar(key: ThemeColorKey, value: string): Record<string, string> {
return { [CAMEL_TO_CSS_VAR[key]]: value };
}
/** The 3 font variables. */
export function fontVars(fonts: ThemeFonts): Record<string, string> {
return {
'--font-sans': fonts.sans,
'--font-serif': fonts.serif,
'--font-mono': fonts.mono,
};
}
const FONT_VAR: Record<keyof ThemeFonts, string> = {
sans: '--font-sans',
serif: '--font-serif',
mono: '--font-mono',
};
/** The variable for a single font slot. */
export function fontVar(key: keyof ThemeFonts, value: string): Record<string, string> {
return { [FONT_VAR[key]]: value };
}
export function radiusVar(radius: number): Record<string, string> {
return { '--radius': `${radius}rem` };
}
function spacingVar(spacing: number): Record<string, string> {
return { '--spacing': `${spacing}rem` };
}
function trackingVar(tracking: number): Record<string, string> {
return { '--tracking-normal': `${tracking}em` };
}
/**
* The 14 shadow variables. All of them derive from the same five numbers plus
* a colour, which is why editing one shadow field costs 14 writes rather than 1.
*/
export function shadowVars(shadows: ThemeShadows): Record<string, string> {
const { x, y, blur, spread, opacity } = shadows;
const withOpacity = (alpha: number): string =>
`rgb(from ${shadows.color} r g b / ${clamp(alpha, 0, 1)})`;
const base = `${x}px ${y}px ${blur}px ${spread}px ${withOpacity(opacity)}`;
return {
'--shadow-x': `${x}`,
'--shadow-y': `${y}px`,
'--shadow-blur': `${blur}px`,
'--shadow-spread': `${spread}px`,
'--shadow-opacity': `${opacity}`,
'--shadow-color': shadows.color,
'--shadow-2xs': `0 1px ${Math.max(1, blur * 0.33)}px 0px ${withOpacity(opacity * 0.5)}`,
'--shadow-xs': `0 1px ${Math.max(1, blur * 0.33)}px 0px ${withOpacity(opacity * 0.5)}`,
'--shadow-sm': `${base}, 0 1px 2px -1px ${withOpacity(opacity)}`,
'--shadow': `${base}, 0 1px 2px -1px ${withOpacity(opacity)}`,
'--shadow-md': `${base}, 0 ${Math.round(y * 2)}px ${Math.round(blur * 1.33)}px -1px ${withOpacity(opacity)}`,
'--shadow-lg': `${base}, 0 ${Math.round(y * 4)}px ${Math.round(blur * 2)}px -1px ${withOpacity(opacity)}`,
'--shadow-xl': `${base}, 0 ${Math.round(y * 8)}px ${Math.round(blur * 3.33)}px -1px ${withOpacity(opacity)}`,
'--shadow-2xl': `0 1px ${Math.max(1, blur * 0.33)}px 0px ${withOpacity(opacity * 2.5)}`,
};
}
/** Every variable a Theme contributes in a given Colour Mode — all 52. */
export function themeVars(theme: ThemeConfig, mode: ColorMode): Record<string, string> {
return {
...colourVars(mode === 'dark' ? theme.dark : theme.light),
...fontVars(theme.fonts),
...radiusVar(theme.radius),
...shadowVars(theme.shadows),
...spacingVar(theme.spacing),
...trackingVar(theme.tracking),
};
}
/**
* Drops variables whose value already matches what is on the DOM.
*
* This is what keeps a page load from paying twice when two callers both
* settle the Colour Mode on mount, and what makes a slider dragged back to
* its starting value free. It filters the *write* only — state and identity
* always update, because re-picking a Preset resets identity even when every
* value already matches.
*/
export function changedVars(
next: Record<string, string>,
current: Record<string, string>
): Record<string, string> {
const changed: Record<string, string> = {};
for (const [name, value] of Object.entries(next)) {
if (current[name] !== value) changed[name] = value;
}
return changed;
}
// ============================================================================
// Operations
// ============================================================================
const edit = (
state: ActiveThemeState,
theme: ThemeConfig,
vars: Record<string, string>
): ThemeWrite => ({
state: { theme: markCustom(theme), mode: state.mode },
vars,
});
/**
* Adopt a Theme Preset. Not an edit — identity resets to the Preset, even if
* the values already matched.
*/
export function applyPreset(state: ActiveThemeState, preset: ThemeConfig): ThemeWrite {
const theme = clampTheme(preset, DEFAULT_THEME);
return {
state: { theme, mode: state.mode },
vars: themeVars(theme, state.mode),
};
}
/**
* Adopt a Theme from outside — a share link, an import, or the assistant.
* Not an edit: the Theme arrives with its own identity.
*/
export function loadTheme(state: ActiveThemeState, theme: ThemeConfig): ThemeWrite {
const clamped = clampTheme(theme, DEFAULT_THEME);
return {
state: { theme: clamped, mode: state.mode },
vars: themeVars(clamped, state.mode),
};
}
/** Edit one colour in the Colour Mode currently showing. */
export function editColour(
state: ActiveThemeState,
key: ThemeColorKey,
value: string
): ThemeWrite {
const clamped = clampColour(value, state.theme[state.mode][key]);
const theme: ThemeConfig = {
...state.theme,
[state.mode]: { ...state.theme[state.mode], [key]: clamped },
};
return edit(state, theme, colourVar(key, clamped));
}
/**
* Edit one colour in both Colour Modes at once — "make the primary blue",
* which is what the assistant means and what two separate edits cannot say.
*/
export function editColourInBothModes(
state: ActiveThemeState,
key: ThemeColorKey,
value: string
): ThemeWrite {
const light = clampColour(value, state.theme.light[key]);
const dark = clampColour(value, state.theme.dark[key]);
const theme: ThemeConfig = {
...state.theme,
light: { ...state.theme.light, [key]: light },
dark: { ...state.theme.dark, [key]: dark },
};
// Only the showing palette is on the DOM, so this still costs one write.
return edit(state, theme, colourVar(key, state.mode === 'dark' ? dark : light));
}
export function editFont(
state: ActiveThemeState,
key: keyof ThemeFonts,
value: string
): ThemeWrite {
const clamped = clampFont(value, state.theme.fonts[key]);
const theme: ThemeConfig = {
...state.theme,
fonts: { ...state.theme.fonts, [key]: clamped },
};
return edit(state, theme, fontVar(key, clamped));
}
export function editFonts(state: ActiveThemeState, fonts: ThemeFonts): ThemeWrite {
const clamped: ThemeFonts = {
sans: clampFont(fonts?.sans, state.theme.fonts.sans),
serif: clampFont(fonts?.serif, state.theme.fonts.serif),
mono: clampFont(fonts?.mono, state.theme.fonts.mono),
};
const theme: ThemeConfig = { ...state.theme, fonts: clamped };
return edit(state, theme, fontVars(clamped));
}
export function editRadius(state: ActiveThemeState, radius: number): ThemeWrite {
const clamped = clampRadius(radius, state.theme.radius);
const theme: ThemeConfig = { ...state.theme, radius: clamped };
return edit(state, theme, radiusVar(clamped));
}
export function editShadow(
state: ActiveThemeState,
patch: Partial<ThemeShadows>
): ThemeWrite {
const shadows = clampShadows(patch, state.theme.shadows);
const theme: ThemeConfig = { ...state.theme, shadows };
return edit(state, theme, shadowVars(shadows));
}
/**
* Layout is structural — navigation position and homepage variant have no CSS
* custom properties. It is still an edit: the Theme has diverged.
*/
export function editLayout(
state: ActiveThemeState,
patch: Partial<ThemeLayout>
): ThemeWrite {
const theme: ThemeConfig = {
...state.theme,
layout: { ...state.theme.layout, ...patch },
};
return edit(state, theme, {});
}
/**
* Show a Colour Mode. Not an edit — every Theme defines both palettes, so
* identity is untouched and only the 32 colour variables change.
*
* This is the shape both `adoptColourMode` and `switchColourMode` share; the
* difference between them is persistence, which lives in the React layer.
*/
function showColourMode(state: ActiveThemeState, mode: ColorMode): ThemeWrite {
return {
state: { theme: state.theme, mode },
vars: colourVars(mode === 'dark' ? state.theme.dark : state.theme.light),
};
}
/**
* The visitor chose this Colour Mode. Persisted — it is a preference now.
*/
export function switchColourMode(state: ActiveThemeState, mode: ColorMode): ThemeWrite {
return showColourMode(state, mode);
}
/**
* A Colour Mode was derived rather than chosen — read from the operating
* system, or settled during initialisation.
*
* Deliberately *not* persisted. Writing a derived mode to storage would turn
* "no preference yet" into an explicit choice, which permanently stops the
* visitor's OS theme from propagating.
*/
export function adoptColourMode(state: ActiveThemeState, mode: ColorMode): ThemeWrite {
return showColourMode(state, mode);
}
export function toggleColourMode(state: ActiveThemeState): ThemeWrite {
return switchColourMode(state, state.mode === 'dark' ? 'light' : 'dark');
}
/** Back to the default Theme, keeping the Colour Mode the visitor chose. */
export function resetToDefault(state: ActiveThemeState): ThemeWrite {
return {
state: { theme: DEFAULT_THEME, mode: state.mode },
vars: themeVars(DEFAULT_THEME, state.mode),
};
}