From 5d63f0686384189f88a83cdd5a68270a2aa979d9 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 15:19:29 +0200 Subject: [PATCH] =?UTF-8?q?docs(specs):=20dark-mode=20theme=20toggle=20?= =?UTF-8?q?=E2=80=94=20tri-state,=20icon=20segmented,=20FOUC-safe=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-06-07-dark-mode-theme-toggle-design.md | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-dark-mode-theme-toggle-design.md diff --git a/docs/superpowers/specs/2026-06-07-dark-mode-theme-toggle-design.md b/docs/superpowers/specs/2026-06-07-dark-mode-theme-toggle-design.md new file mode 100644 index 0000000..836d8d8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-dark-mode-theme-toggle-design.md @@ -0,0 +1,149 @@ +# 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 `` 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) ──▶ ──▶ 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): void` — `document.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)")` + `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 ` +``` +(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 ` + +``` + +### 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 `` 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 `` 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).