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 `