How to plug a new color theme into the Ontology Playground.
Theming is CSS-variable based. A theme is a named set of CSS custom-property values that override the defaults, plus a small amount of registration in the Zustand store so the theme appears in the picker and is remembered across sessions. You do not touch the graph, designer, or learn components to add a theme — they read their colors from the CSS variables at runtime.
There are four moving parts. The first three live in
src/store/appStore.ts; the fourth lives in
src/styles/app.css.
| Part | What it does |
|---|---|
ThemeId union |
The set of valid theme ids. |
THEME_OPTIONS |
Drives the header theme picker (id, label, swatch). |
themeClass(theme) |
Maps a theme id → the CSS class(es) on the themed root. |
| CSS token block | The actual color values, keyed on that class. |
Two helpers tie it together:
isDarkTheme(theme)/DARK_BASED_THEMES— declares whether a theme uses the dark base palette. This sets the store's deriveddarkModeflag, which drives graph render fallbacks, node-label backplates, and the PNG-export background.setTheme(theme)— persists the choice tolocalStorage['theme']and updates{ theme, darkMode }.
themeClass(theme) is attached to the root element of each top-level surface —
not to <body>:
src/App.tsx→app-container ${themeClass(theme)}src/components/OntologyDesigner.tsx→designer-page ${themeClass(theme)}src/components/LearnPage.tsx→learn-page ${themeClass(theme)}
:root (and therefore <body>) always carries the Dark defaults. See
Gotcha 1 for why that matters.
export function themeClass(theme: ThemeId): string {
switch (theme) {
case 'light': return 'light-theme';
case 'aurora': return 'theme-aurora';
case 'crimson': return 'light-theme theme-crimson'; // layered
default: return ''; // dark = :root defaults
}
}crimson returns two classes. .light-theme supplies the full set of light
values; .theme-crimson only overrides the handful of tokens that make it
crimson. A light-based theme should do the same so you don't re-specify every
token. A dark-based theme can return a single class because :root is already
the dark base.
:root in src/styles/app.css defines every token.
A theme block overrides the subset that should change. Anything you omit falls
back to :root (for dark-based themes) or to .light-theme (for themes layered
over it).
| Group | Tokens | Notes |
|---|---|---|
| Accent | --ms-blue, --ms-blue-dark, --ms-blue-light, --info |
Primary accent used across buttons, links, highlights. Override these to re-brand the accent color. |
| Accent foreground | --on-accent |
Text/icon color placed on an accent-colored fill (e.g. .btn-primary). Must clear 4.5:1 against --ms-blue. White suits a dark accent; a light accent (e.g. Aurora's mint) needs a dark value. Inherited from :root (white) unless overridden. |
| Stat tiles | --stat-blue, --stat-green, --stat-purple |
Icon + value color for the Ontology-Insights metric tiles, which lay a translucent tint over --bg-secondary. Each must clear 3:1 against its composited tile. Dark-based themes inherit the bright :root set; light-based themes inherit the darker .light-theme set — override only if your sidebar uses an unusual background. |
| Amber foreground | --ms-yellow-fg |
Color for amber text + icons — header points/badges, quest points, the pathfinder warning, the active designer-ID toggle. As text it must clear 4.5:1. Dark-based themes inherit the bright :root #FFB900; light-based themes inherit the darker .light-theme gold (#8A6A00), because bright amber fails (~1.7:1) on white. Distinct from the brand fill --ms-yellow (paired with black text) — don't conflate them. |
| Progress fill | --progress-from, --progress-to |
The two gradient stops of .progress-fill, painted over the --bg-tertiary track. Both must clear 3:1 against that track (SC 1.4.11). Bright on dark-based themes; the accent/violet pair on .light-theme; Aurora and Crimson override --progress-from to keep their signature hue. |
| Surfaces | --bg-primary, --bg-secondary, --bg-tertiary, --bg-elevated |
Page and panel backgrounds, lightest → most elevated. |
| Text | --text-primary, --text-secondary, --text-tertiary |
Foreground text, primary → muted. |
| Borders | --border-color, --border-subtle |
Panel/control borders. |
| Shadow | --shadow-glow |
Accent glow; tint it to match the accent. |
| Graph | --graph-bg, --graph-node-text, --graph-edge-color, --graph-edge-text, --graph-edge-label-bg |
Read at runtime by the graph + designer preview. --graph-edge-label-bg is the opaque chip behind edge labels; edge text must clear 4.5:1 against it. Tune all of these for contrast — see Accessibility. |
| Canvas checker | --chess-square-dark, --chess-square-light |
The graph canvas backdrop pattern. --chess-square-light is the solid base — see Gotcha 1. |
| About links | --about-link-color, --about-link-hover-color |
Links on the About screen. |
The accent tokens are historically named
--ms-*. Treat them as generic accent variables — the name is incidental.
The running example is a dark-based theme called Indigo. Where a light-based theme differs, it's called out inline.
export type ThemeId = 'dark' | 'light' | 'aurora' | 'crimson' | 'indigo';export const THEME_OPTIONS: { id: ThemeId; label: string; swatch: string }[] = [
{ id: 'dark', label: 'Dark', swatch: '#1B1B1B' },
{ id: 'light', label: 'Light', swatch: '#F5F5F5' },
{ id: 'aurora', label: 'Aurora', swatch: '#2AAA92' },
{ id: 'crimson', label: 'Crimson', swatch: '#D6002A' },
{ id: 'indigo', label: 'Indigo', swatch: '#4F46E5' }, // swatch = the dot in the picker
];The swatch is the color dot shown next to the label in the picker menu — use
the theme's signature accent.
Dark-based themes go in DARK_BASED_THEMES:
const DARK_BASED_THEMES: ThemeId[] = ['dark', 'aurora', 'indigo'];A light-based theme is left out of this list (so darkMode becomes false).
export function themeClass(theme: ThemeId): string {
switch (theme) {
case 'light': return 'light-theme';
case 'aurora': return 'theme-aurora';
case 'crimson': return 'light-theme theme-crimson';
case 'indigo': return 'theme-indigo'; // dark-based: single class
// a light-based theme would return 'light-theme theme-<id>'
default: return '';
}
}Copy the nearest existing block (.theme-aurora for dark, .theme-crimson for
light) and retune. Place it after the existing theme blocks.
/* Indigo theme — deep indigo base with violet accents (dark) */
.theme-indigo {
--ms-blue: #4F46E5;
--ms-blue-dark: #4338CA;
--ms-blue-light: #6366F1;
--info: #4F46E5;
--on-accent: #FFFFFF; /* white clears 4.5:1 on this indigo accent */
--ms-yellow-fg: #FFB900; /* amber text/icons; a light theme needs ~#8A6A00 */
--progress-from: #818CF8; /* both progress stops need 3:1 on --bg-tertiary */
--progress-to: #C4B5FD;
--bg-primary: #15172B;
--bg-secondary: #1C1F3A;
--bg-tertiary: #262A4D;
--bg-elevated: #20233F;
--text-primary: #EEF0FF;
--text-secondary: #B9BEE6;
--text-tertiary: #8489B8;
--border-color: #343A66;
--border-subtle: #262A4D;
--shadow-glow: 0 0 20px rgba(99, 102, 241, 0.35);
--graph-bg: #14162A;
--graph-node-text: #CDD2FF;
--graph-edge-color: #686DAE;
--graph-edge-text: #9AA0D6;
--graph-edge-label-bg: #181A2E; /* opaque chip behind edge labels */
--chess-square-dark: rgba(40, 44, 90, 0.85);
--chess-square-light: rgba(30, 33, 70, 0.65);
--about-link-color: #A5B4FC;
--about-link-hover-color: #C7D2FE;
}For a light-based theme, override .theme-<id> to layer over .light-theme,
and make --chess-square-light an opaque light color (see Gotcha 1).
Run the testing checklist.
That's it — the graph, designer preview, and learn pages pick up the new
--graph-* and surface tokens automatically.
The theme class is applied to .app-container / .designer-page /
.learn-page, not <body>. <body> keeps the :root (Dark) background
(#1B1B1B). Any element with a transparent or semi-transparent background
lets that dark body show through.
This bites the graph canvas. Its backdrop is the chess pattern in
.graph-container, whose solid base is --chess-square-light:
.graph-container {
background-image: /* squares painted with var(--chess-square-dark) */;
background-color: var(--chess-square-light); /* the solid base */
}If --chess-square-light is translucent on a light theme, the dark body bleeds
through and the canvas reads dark — making dark node labels unreadable. (This was
a real bug in Crimson.)
Rule: for a light theme, --chess-square-light must be an opaque light
color. Compare:
/* WRONG — translucent base, dark body shows through */
--chess-square-light: rgba(214, 0, 42, 0.03);
/* RIGHT — opaque light base */
--chess-square-light: #FBF7F8;Dark-based themes can use translucent values because the body behind them is already dark.
OntologyGraph and the designer's GraphPreview read --graph-node-text,
--graph-edge-color, --graph-edge-text, and --graph-edge-label-bg from
getComputedStyle and re-read them when the active theme changes. So
defining the --graph-* tokens in your theme block is all you need — no
component or TypeScript edits. Tune them for contrast against your --graph-bg
(and edge text against --graph-edge-label-bg); see
Accessibility.
isDarkTheme() feeds the store's darkMode flag, which sets the initial graph
fallback color, the node-label backplate (text-background-color), and the PNG
export background. If a dark theme is missing from DARK_BASED_THEMES, the graph
will briefly fall back to light defaults and labels can get a light backplate.
Returning 'light-theme theme-<id>' from themeClass lets your block inherit
every light token and override only what's distinctive. Returning a single class
means you must re-specify the full token set or inherit Dark defaults by mistake.
Every theme must meet WCAG 2.1 Level AA contrast — the standard Microsoft's Accessibility Insights verifies. A deterministic test enforces it for all themes, so a new theme or a token tweak can't silently ship a failing color:
- Body text (
--text-primary,--text-secondary) and muted text (--text-tertiary) need 4.5:1 against the surfaces they sit on (--bg-primary,--bg-secondary,--bg-elevated). - Accent button labels (
--on-accent) need 4.5:1 against--ms-blue. - Graph labels need 4.5:1 — node text against
--graph-bg, edge text against the opaque--graph-edge-label-bgchip. - Graph edge lines (
--graph-edge-color) are graphical objects and need 3:1 against--graph-bg(SC 1.4.11 Non-text Contrast). - Stat-card tiles — the metric icons and bold values (
--stat-blue,--stat-green,--stat-purple) are graphical objects / large text and need 3:1 against the translucent tile, composited over--bg-secondary. Keep the icons at full opacity (a0.7fade drops them below the floor). - Amber text + icons (
--ms-yellow-fg) — header points/badges, quest points, the pathfinder warning, the active designer-ID toggle — are normal text and need 4.5:1 against their surface. Bright amber only clears this on dark backgrounds, so light-based themes drop it to a darker gold. - Progress-bar fill (
--progress-from,--progress-to) — both gradient stops are graphical objects and need 3:1 against the--bg-tertiarytrack (SC 1.4.11).
Thresholds are floors — 4.49:1 fails.
npm run test:a11yThe suite (src/a11y/themeContrast.test.ts)
parses src/styles/app.css, resolves each theme's
tokens, and asserts every text/non-text pair clears its threshold. It runs in CI
(the “Accessibility — WCAG 2.1 AA contrast” step) and inside npm test, so a
regression fails the build. A failure prints the exact ratio and both tokens, e.g.
--text-tertiary (#808080) on --bg-secondary (#2D2D2D) = 3.49:1, needs >= 4.5:1.
Nudge the failing token lighter/darker until it clears, then re-run.
Light accents need dark label text. White on a light accent fails AA (Aurora's mint
#2AAA92+ white = 2.89:1). That's what--on-accentis for: set it dark for a light accent (Aurora uses#08221D), white for a dark accent. Accent-filled controls usecolor: var(--on-accent).
npm run dev # then switch to the new theme in the header pickerCheck, in the new theme:
- Main graph (home, ontology loaded): node labels, edge labels, and edges
are legible against
--graph-bg; the canvas backdrop matches the theme (not a stray dark/black panel — Gotcha 1). - Designer (
#/designer, load a template, Graph tab): preview is legible. - Learn pages (
#/learn): surfaces and text read correctly. - Picker: the new swatch + label appear and the check mark tracks selection.
- Persistence: reload the page — the theme is remembered.
- No regressions: Dark, Light, Aurora, and Crimson still look correct.
- Contrast:
npm run test:a11yis green for the new theme (WCAG 2.1 AA).
Then:
npm run test:a11y # WCAG 2.1 AA contrast for every theme (also runs in CI)
npx tsc --noEmit # type-check (the ThemeId union change is covered here)
npm run build # full production build
npm run buildregeneratespublic/learn.jsonwith a fresh timestamp. If that is the only change there, revert it before committing:git checkout -- public/learn.json.
| File | Change |
|---|---|
src/store/appStore.ts |
ThemeId, THEME_OPTIONS, DARK_BASED_THEMES, themeClass() |
src/styles/app.css |
one .theme-<id> token block |
Everything else (graph, designer, learn, picker UI) adapts automatically.