Theme system¶
Phase 58 (v1.41.0) replaced the old light/dark pair with a system
of six classic themes on a single data-theme dimension, plus an
auto choice that follows the operating system. Phase 63
(v1.63.0) added six recommended WCAG AA presets, so the picker
carries 12 themes in total.
Recommended presets (Phase 63 / v1.63.0)¶
The picker under Settings → Appearance leads with a Recommended sub-tab:
- Light:
catppuccin-latte,supabase,graphite - Dark:
catppuccin-mocha,soft-pop,amethyst-haze
They were generated from tweakcn presets via
scripts/generate_preset_themes.py as complete 44-token themes,
with computationally enforced WCAG AA (contrast.test.ts
across all 12 themes). The classic six (light, dark, ocean,
forest, high-contrast, sepia) remain unchanged.
How it works¶
- Canonical color tokens live in
frontend/src/styles/themes/theme-<id>.css, one block perdata-themevalue (light,dark,ocean,forest,high-contrast,sepia). Each file defines the complete semantic token set - there is no fall-through to light. - Theme-agnostic tokens (spacing, radius, fonts, the brand
method palette) and the legacy aliases (
--bg,--surface,--fg,--danger, ...) live instyles/global.css :root. The aliases resolve through the canonical tokens and thus follow the active theme automatically. - The theme files are imported in
main.tsx, light first, so the active theme wins the specificity tie against:root. frontend/src/lib/themes.tsis the registry:THEMES, the typesThemeId/ThemeChoice,resolveTheme(choice, prefersDark)for theautomapping, and the preview swatches.frontend/src/hooks/useTheme.tsowns the applieddata-themeattribute and stores the choice underadaptive-learner.theme(migrating the oldadaptive-learner-themekey once).index.htmlcontains a small inline script that applies the stored theme before the first paint (no flicker). It mirrors the hook's resolution; keep both in sync.- Charts (Recharts) cannot read CSS variables in SVG attributes,
so
lib/chartTheme.ts+useChartThemeread the computed token values and re-read on adata-themechange.
Token set (defined by every theme)¶
Backgrounds (--bg-primary/secondary/surface/elevated/overlay),
text (--fg-primary/secondary/muted/inverse), borders
(--border-primary/subtle/accent), interactive
(--interactive-bg/hover/active/disabled), accent
(--accent, -hover, -fg, -subtle, -rgb), status pairs
(--success/-bg, --error/-bg, --warning/-bg, --info/-bg),
exercise feedback (--exercise-correct/-wrong/-selected/-matched),
--star, chart series (--chart-1..6) and shadows
(--shadow-card/-elevated/-md).
styles/themes/themes.test.ts fails if a theme is missing one of
these tokens or has an extra one;
styles/contrast.test.ts checks WCAG 2.1 AA across all 12 themes.
The full token reference is in
Design token architecture.
Adding a new theme¶
- Copy an existing file, e.g.
cp theme-dark.css theme-midnight.css, and change the selector to[data-theme="midnight"]. Keep every token - change only the values. No component styles here. - Register it in
lib/themes.ts: add aThemeMetaentry toTHEMES(id, Englishlabel,familylight|dark and aswatchfor the settings preview) and add the id to theThemeIdunion. - Import it in
main.tsxaftertheme-light.css(the order only matters relative to light). - Allow it in the pre-paint guard: add the id to the
validarray in the inline<script>inindex.html. - i18n: add
ui.themes.midnightto all eight catalogs underbackend/config/i18n/*.yamland runmake sync-i18n. - Check:
npx vitest run src/styles/themes src/styles/contrast - the completeness and contrast pins must stay green (adjust the values until the contrast in the new theme meets AA).
That's it - the ThemePicker, the pre-paint script, the charts and every component pick up the new theme automatically, because they all read the canonical tokens.
Rules¶
- No hardcoded colors in components.
styles/no-hardcoded-colors.test.tsenforces this for.tsxstyles (a documented allowlist covers chart resolvers, decorative confetti and data colors). - Every theme defines every token. No gaps with inheritance from light - that was the F1 audit bug (undefined tokens that showed light hex in dark mode).
- Theme switching is instant - a
data-themeswap, never a reload.