Engineering notes

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),
  };
}