Files
biggus-dickus/docs/superpowers/specs/2026-06-07-dark-mode-theme-toggle-design.md
T

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)

  1. Tri-state model: "light" | "dark" | "system". Default (unset) is "system" — follows the OS via prefers-color-scheme and keeps re-tracking live until the user pins light or dark.
  2. Icon segmented control: three icon buttons (lucide Sun/Moon/Monitor), active one highlighted, mirroring LangSwitch styling, mounted in the header next to LangSwitch.
  3. FOUC prevention: a synchronous pre-React init applies the class before first paint.
  4. Dark --primary contrast 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" — returns theme unless "system", in which case matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light". Guards typeof window/matchMedia for non-DOM (test/SSR) safety → falls back to "light".
  • export function readTheme(): Theme — reads localStorage[THEME_KEY]; returns "system" if absent/invalid. Guards typeof localStorage.
  • export function applyTheme(theme: Theme): voiddocument.documentElement.classList.toggle("dark", resolveTheme(theme) === "dark"). Guards typeof document.

web/src/theme/use-theme.ts (new) — React hook (sibling of i18n/use-locale.ts)

  • useTheme(): { theme: Theme; setTheme: (t: Theme) => void }.
  • Holds theme in useState(readTheme).
  • setTheme(t): localStorage.setItem(THEME_KEY, t), setThemeState(t), applyTheme(t).
  • useEffect: when theme === "system", subscribe to matchMedia("(prefers-color-scheme: dark)") changeapplyTheme("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 per Theme, each with a lucide icon (Sun → light, Moon → dark, Monitor → system), sized size-4/h-4 w-4.
  • Active button highlighted; inactive text-muted-foreground (mirror LangSwitch). Each carries aria-pressed={theme === value} and aria-label={t("theme.<value>")}.
  • onClicksetTheme(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/document all guarded → safe in tests/SSR (fall back to light, no throw).
  • Invalid stored value → treated as "system".
  • The inline script is wrapped in try/catch so 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): resolveTheme maps light/dark verbatim and resolves system via a mocked matchMedia; readTheme returns system when unset and the stored value otherwise; applyTheme toggles the dark class on documentElement.
  • web/src/shell/theme-switch.test.tsx (renderApp): clicking Dark adds .dark to document.documentElement and sets localStorage.theme === "dark"; clicking Light removes it; clicking System with matchMedia mocked dark → class present, localStorage.theme === "system"; aria-pressed reflects the active mode. (Mock window.matchMedia in the test as jsdom lacks it.)
  • Storybook: theme-switch.stories.tsx rendering 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 in play/beforeEach.
  • Gate: pnpm typecheck && lint && test && build && check:size && check:colors. en/sv parity; no codename. check:size within 250 KB gz (three small lucide icons; Sun/Moon/Monitor — negligible, but confirm).

Acceptance criteria

  1. A tri-state theme toggle (Light/Dark/System) appears in the header; default is System.
  2. Choosing Dark applies .dark to <html> and persists; Light removes it; System follows the OS and live-updates via prefers-color-scheme until the user pins a value.
  3. No light flash on a dark reload (synchronous pre-React init).
  4. Dark --primary button-label contrast ≥ 4.5:1 (--ring kept in sync); light unchanged.
  5. en/sv parity for theme.*; aria-pressed + aria-label on the controls.
  6. typecheck/lint/test/build/check:size/check:colors green; no codename; no new npm dep.

Out of scope → follow-ups

  • Per-account / server-synced theme preference (add default_theme to ConfigView later, mirroring default_language).
  • Cross-tab sync via a storage event 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).