Per-School Theming
How one brand color becomes a full OKLch palette, injected before paint so each school looks like itself.
The Education Hub is one app serving many schools, and each one wants to feel like its own product. The product decision that makes that affordable is deliberately stingy: a school sets exactly one thing — its primary brand color — and the app derives the rest. There is no palette editor, no per-school stylesheet, no design sign-off for each tenant. Pick a color, and the whole UI — buttons, charts, sidebars, hover surfaces, both light and dark mode — recolors to match.
That works because the theme is computed, not configured. A single deterministic algorithm takes one color and emits all 38 design tokens, and a tiny React component injects them before the page paints. This page walks the two halves: the derivation, and the injection.
One color, a full palette
The engine is deriveTheme(primaryColor) in derive-theme.ts. Hand it a brand color in any CSS form — the curated seeds are hex — and it returns a light and a dark set of tokens, ready to become CSS variables.
export function deriveTheme(primaryColor: string): DerivedTheme {
const parsed = oklch(primaryColor)
if (!parsed) throw new Error(`Invalid color: ${primaryColor}`)
const H = Number.isFinite(parsed.h) ? (parsed.h as number) : 260
const seedL = Number.isFinite(parsed.l) ? parsed.l : L_ANCHOR
// ... compute light + dark primary L/C, then:
return {
light: buildMode('light', H, lpL, cpL, seedL),
dark: buildMode('dark', H, lpD, cpD, seedL),
}
}The key move is in those few elided lines: the algorithm keeps the brand's hue but throws away its lightness. A school's yellow arrives very light and its navy very dark — used directly, neither makes a legible button. So both get normalized to a fixed lightness anchor (L_ANCHOR = 0.635), and chroma is set relative to how much saturation sRGB actually allows at that lightness and hue (the capC helper), never a blind number. That's why any brand hue produces a usable theme instead of an out-of-gamut mess.
Everything is built on the culori library for the color math, and the whole thing is pure and memoized — feed it the same color twice and you get the same string back.
OKLch in brief
You'll see oklch(...) everywhere in this file, so here's the one-breath version. Every color is three numbers: L is lightness (0 to 1), C is chroma — saturation, measured relative to the gamut — and H is hue in degrees. The reason the whole system is built on it: OKLch is perceptually even, so a lightness ramp looks evenly stepped to the eye regardless of hue. That's what lets one formula stay readable for pink, teal, and slate alike.
The algorithm even bends hue slightly as the ramp darkens — a small torsion correction — because the blue region of OKLch is where its hue-linearity is weakest. You rarely need that detail; just know the ramps are tuned, not naive.
The lightness ladder is structural
Lightness encodes the UI's contrast hierarchy — a card is lighter than a border is lighter than text. So the L values come from a fixed lookup table and never depend on the brand color. Only hue and chroma flow from the school. Change a school's color and the layout's contrast stays exactly as designed.
Injecting before paint
Deriving the palette is half the job. The other half is getting those variables onto the page without a flash of the wrong colors — that ugly moment where a school's UI renders in the platform default and then snaps to brand. SchoolThemeStyle solves it with useInsertionEffect, which React runs before it paints.
const css = useMemo(() => safeDerive(primaryColor), [primaryColor])
useInsertionEffect(() => {
if (typeof document === 'undefined') return
let el = document.getElementById(STYLE_ID) as HTMLStyleElement | null
if (!el) {
el = document.createElement('style')
el.id = STYLE_ID
document.head.appendChild(el)
}
el.textContent = css
return () => { /* clear on unmount */ }
}, [css])There is exactly one hoisted style element, with id="school-theme". The effect finds it (or creates it once) and overwrites its text with the derived CSS — a :root { ... } block plus a .dark { ... } block of --token: oklch(...) overrides. Because it's a single element that gets rewritten, the theme can never get stuck on a previous school's colors: switch schools and the override is replaced wholesale.
Why useInsertionEffect, not useEffect
useEffect runs after paint, which is precisely when the flash happens. useInsertionEffect is React's hook for injecting style rules before any layout reads them, so the brand colors are present on the very first frame. This is the whole reason the component exists — don't downgrade it to useEffect.
An empty color is meaningful, not a bug: safeDerive returns an empty string for an unset color or the explicit system sentinel, and an invalid color falls back to empty too. Empty means no override, so the platform default theme from styles.css simply applies.
Wiring it to the signed-in school
SchoolThemeStyle is dumb on purpose — it just takes a color. The component that knows which color is AuthedSchoolTheme. It reads the logged-in user and feeds the school's primaryColor in.
export function AuthedSchoolTheme() {
const { me } = useMe()
const reference =
me && me.user.type !== 'platform_admin'
? (me.user.school?.reference ?? '')
: ''
const { school } = usePublicSchool(reference)
return <SchoolThemeStyle primaryColor={school?.primaryColor} />
}Platform operators belong to no school, so they get an empty reference, the school query stays disabled, and they see the default theme. Everyone else gets their school's brand. The me and school lookups are ordinary cached queries — see Client State for how that data gets there and stays fresh.
Fixed semantics
Here is the rule that trips people up, so internalize it: the semantic colors never recolor. destructive stays red, warning stays amber, info stays blue, success stays green — in every school, even a school whose brand hue is itself red or green. These tokens encode meaning, not branding, and a "delete" button that turned a school's brand color would be dangerous.
In the code, the semantics are copied verbatim into both modes instead of being derived.
const SEMANTIC_LIGHT: Tokens = {
destructive: 'oklch(0.6322 0.131 21.4751)',
warning: 'oklch(0.7535 0.1683 66.29)',
'warning-foreground': 'oklch(0.28 0.05 55)', // dark brown, not white
info: 'oklch(0.5488 0.1932 248.71)',
success: 'oklch(0.5488 0.1577 149.84)',
// ...
}Notice warning-foreground is a dark brown, not white — amber is too light to carry white text. That single line is the proof that foregrounds are computed for contrast, not assumed; the same logic picks white or near-black for the primary's foreground via pickForeground. When a school's brand hue lands close to a semantic hue, the design rule is to warn and differentiate by icon or label, never to recolor the semantic away from its meaning.
Presets
Most school admins are not designers, and an open color picker invites unreadable, out-of-gamut choices. So the actual UI offers a curated list of seeds from theme-presets.ts — eighteen named colors, ordered by hue so the picker reads warm to cool, plus a neutral slate and a system default.
export const THEME_PRESETS: Array<ThemePreset> = [
{ id: 'pink', name: 'Pink', primaryColor: '#DB2777' },
{ id: 'orange', name: 'Orange', primaryColor: '#EA580C' },
{ id: 'green', name: 'Green', primaryColor: '#16A34A' },
{ id: 'blue', name: 'Blue', primaryColor: '#2563EB' },
{ id: 'violet', name: 'Violet', primaryColor: '#7C3AED' },
// ... 18 in total, ordered by hue
]Every seed is hand-checked to be gamut-safe, so the derivation never has to degrade it. Because the presets are a small fixed set, the derivation cache stays tiny — only a handful of distinct palettes are ever computed across all schools. An admin picks a swatch, the school stores that one hex string, and the engine does the rest.
Where to go next
For the full formal rules — every token's lightness stop, the chroma headroom math, the hue-torsion table, and the golden round-trip that proves the algorithm reproduces the hand-tuned theme — read docs/superpowers/specs/2026-06-07-theme-color-derivation-design.md in the Education Hub repo.