8.5 KiB
Dark-Mode Theme Toggle — Design
Date: 2026-06-07 Status: Approved (brainstorming) — ready for implementation planning. Issue: #59.
Context
web/src/index.css defines a complete .dark token set (24 tokens — background/foreground,
card, popover, primary, secondary, muted, accent, destructive, success, warning, highlight,
border, input, ring + their -foreground variants), and the ui/* components carry dark:
variants. After #49, the feature screens route through the semantic tokens, so the app now
adapts to .dark. But nothing ever applies the .dark class and there is no theme toggle,
so dark mode can't activate. This milestone ships the toggle (the issue's "ship it" path).
Theme is client-only, mirroring the locale mechanism: localStorage persistence, read at
startup, applied to the DOM. /api/config (ConfigView) carries no theme field; a per-instance
server default is out of scope (could add default_theme later, like default_language).
Decisions (from brainstorming)
- Tri-state model:
"light" | "dark" | "system". Default (unset) is"system"— follows the OS viaprefers-color-schemeand keeps re-tracking live until the user pins light or dark. - Icon segmented control: three icon buttons (lucide
Sun/Moon/Monitor), active one highlighted, mirroringLangSwitchstyling, mounted in the header next toLangSwitch. - FOUC prevention: a synchronous pre-React init applies the class before first paint.
- Dark
--primarycontrast tweak (parked from #49) folded in here.
Architecture
Plain client-side theming over CSS custom properties (no next-themes dependency). The .dark
class on <html> activates the existing dark token block; Tailwind utilities reference the tokens,
so no per-component work is needed beyond what #49 already did.
localStorage["theme"] ──read──▶ resolve(theme) ──▶ <html class="dark"?> ──▶ tokens ──▶ UI
▲ ▲
setTheme() (toggle) matchMedia listener (when theme === "system")
Components
web/src/theme/theme.ts (new) — core, framework-free
export const THEME_KEY = "theme";export type Theme = "light" | "dark" | "system";export function resolveTheme(theme: Theme): "light" | "dark"— returnsthemeunless"system", in which casematchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light". Guardstypeof window/matchMediafor non-DOM (test/SSR) safety → falls back to"light".export function readTheme(): Theme— readslocalStorage[THEME_KEY]; returns"system"if absent/invalid. Guardstypeof localStorage.export function applyTheme(theme: Theme): void—document.documentElement.classList.toggle("dark", resolveTheme(theme) === "dark"). Guardstypeof document.
web/src/theme/use-theme.ts (new) — React hook (sibling of i18n/use-locale.ts)
useTheme(): { theme: Theme; setTheme: (t: Theme) => void }.- Holds
themeinuseState(readTheme). setTheme(t):localStorage.setItem(THEME_KEY, t),setThemeState(t),applyTheme(t).useEffect: whentheme === "system", subscribe tomatchMedia("(prefers-color-scheme: dark)")change→applyTheme("system"); clean up on change/unmount. (No-op subscription when pinned.)- On mount it also calls
applyTheme(theme)once (covers the case where the hook mounts without the pre-React init, e.g. tests) — idempotent with the inline script.
Pre-React init (FOUC prevention) — web/index.html
A tiny inline <script> in <head>, before the module script, that synchronously sets the class
so a dark reload never flashes light:
<script>
try {
var t = localStorage.getItem("theme") || "system";
var dark = t === "dark" || (t === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
} catch (e) {}
</script>
(Kept inline + defensive; it must run before paint, so it cannot be a module import.)
web/src/shell/theme-switch.tsx (new) — the UI
- Renders three
<button>s in a row (flex gap-1), one perTheme, each with a lucide icon (Sun→ light,Moon→ dark,Monitor→ system), sizedsize-4/h-4 w-4. - Active button highlighted; inactive
text-muted-foreground(mirrorLangSwitch). Each carriesaria-pressed={theme === value}andaria-label={t("theme.<value>")}. onClick→setTheme(value).- No raw color utilities (token classes only — passes
check:colors).
Mount — web/src/shell/app-shell.tsx
Insert <ThemeSwitch /> in the header immediately before <LangSwitch />:
<header className="flex items-center gap-4 border-b px-4 py-2">
<div className="flex-1" />
<ThemeSwitch />
<LangSwitch />
<Button variant="ghost" size="sm" onClick={onSignOut}>{t("auth.signOut")}</Button>
</header>
i18n — web/src/i18n/{en,sv}.json
Add a theme namespace (en/sv parity):
- en:
{ "light": "Light", "dark": "Dark", "system": "System" } - sv:
{ "light": "Ljust", "dark": "Mörkt", "system": "System" }
Dark --primary contrast tweak — web/src/index.css
Current dark --primary: oklch(0.673 0.182 276.935) with near-black --primary-foreground: oklch(0.205 0 0) yields ~3.21:1 for button-label text. Lower the dark --primary lightness so the
near-black foreground reaches ≥4.5:1 (e.g. around oklch(0.62 0.20 277) — implementer computes
the exact value with a WCAG contrast check against --primary-foreground, keeping it a recognizable
indigo and --ring in sync). Light mode is unchanged (already 8.3:1).
Data flow
First load: inline script applies class from localStorage/system before paint → React mounts →
useTheme seeds from localStorage and (when system) attaches the media listener. Toggle click →
setTheme persists + re-applies. OS theme change while in system → listener re-applies. Other
tabs are not synced (out of scope; a storage listener could be a later nicety).
Error handling / edges
localStorage/matchMedia/documentall guarded → safe in tests/SSR (fall back to light, no throw).- Invalid stored value → treated as
"system". - The inline script is wrapped in
try/catchso a storage exception never blocks render. - Pinning light/dark removes the system listener so it stops following the OS.
Testing
web/src/theme/theme.test.ts(unit):resolveThememaps light/dark verbatim and resolvessystemvia a mockedmatchMedia;readThemereturnssystemwhen unset and the stored value otherwise;applyThemetoggles thedarkclass ondocumentElement.web/src/shell/theme-switch.test.tsx(renderApp): clicking Dark adds.darktodocument.documentElementand setslocalStorage.theme === "dark"; clicking Light removes it; clicking System withmatchMediamocked dark → class present,localStorage.theme === "system";aria-pressedreflects the active mode. (Mockwindow.matchMediain the test as jsdom lacks it.)- Storybook:
theme-switch.stories.tsxrendering the three-state control (a play test asserting the three buttons + aria-pressed). Note: toggling theme in a story mutates<html>globally — keep the story render-only or reset the class inplay/beforeEach. - Gate:
pnpm typecheck && lint && test && build && check:size && check:colors. en/sv parity; no codename.check:sizewithin 250 KB gz (three small lucide icons;Sun/Moon/Monitor— negligible, but confirm).
Acceptance criteria
- A tri-state theme toggle (Light/Dark/System) appears in the header; default is System.
- Choosing Dark applies
.darkto<html>and persists; Light removes it; System follows the OS and live-updates viaprefers-color-schemeuntil the user pins a value. - No light flash on a dark reload (synchronous pre-React init).
- Dark
--primarybutton-label contrast ≥ 4.5:1 (--ringkept in sync); light unchanged. - en/sv parity for
theme.*;aria-pressed+aria-labelon the controls. typecheck/lint/test/build/check:size/check:colorsgreen; no codename; no new npm dep.
Out of scope → follow-ups
- Per-account / server-synced theme preference (add
default_themetoConfigViewlater, mirroringdefault_language). - Cross-tab sync via a
storageevent listener. - Header redesign / wayfinding (#54); a full dark-mode visual QA pass across every screen (the tokens make it adapt, but a dedicated screenshot review is a separate effort).