docs(specs): dark-mode theme toggle — tri-state, icon segmented, FOUC-safe (#59)
This commit is contained in:
@@ -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 `<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): 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 `<script>` in `<head>`, before the module script, that synchronously sets the class
|
||||||
|
so a dark reload never flashes light:
|
||||||
|
```html
|
||||||
|
<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>")}`.
|
||||||
|
- `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 />`:
|
||||||
|
```tsx
|
||||||
|
<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).
|
||||||
Reference in New Issue
Block a user