+ );
+}
+```
+
+(Verify the `cn` import path matches the project — other `ui/*` files import `cn` from `@/lib/utils`. If `lib/utils` is absent, mirror whatever `button.tsx` uses.)
+
+- [ ] **Step 5: Run to verify it passes**
+
+Run: `cd web && pnpm vitest run src/shell/theme-switch.test.tsx`
+Expected: PASS (3 tests).
+
+- [ ] **Step 6: Write the Storybook story** — `web/src/shell/theme-switch.stories.tsx`:
+
+```tsx
+import type { Meta, StoryObj } from '@storybook/react-vite'
+import { expect } from 'storybook/test'
+
+import { ThemeSwitch } from './theme-switch'
+
+const meta = {
+ component: ThemeSwitch,
+ tags: ['ai-generated'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ play: async ({ canvas }) => {
+ await expect(canvas.getByRole('button', { name: /light/i })).toBeInTheDocument()
+ await expect(canvas.getByRole('button', { name: /dark/i })).toBeInTheDocument()
+ await expect(canvas.getByRole('button', { name: /system/i })).toBeInTheDocument()
+ },
+}
+```
+
+(Note: the story exercises rendering only — it does not click options, to avoid mutating ``
+globally across the browser-mode test run.)
+
+- [ ] **Step 7: Run the story as a test + lint**
+
+Run: `cd web && pnpm vitest run src/shell/theme-switch.stories.tsx && pnpm lint`
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add web/src/shell/theme-switch.tsx web/src/shell/theme-switch.test.tsx web/src/shell/theme-switch.stories.tsx web/src/i18n/en.json web/src/i18n/sv.json
+git commit -m "feat(web): ThemeSwitch icon segmented control + theme.* i18n (#59)"
+```
+
+---
+
+# Task 4: Mount in the header + FOUC inline script
+
+**Files:**
+- Modify: `web/src/shell/app-shell.tsx`
+- Modify: `web/index.html`
+
+- [ ] **Step 1: Mount `ThemeSwitch`.** In `web/src/shell/app-shell.tsx`, add the import:
+
+```tsx
+import { ThemeSwitch } from "./theme-switch";
+```
+
+and render it in the header immediately before ``:
+
+```tsx
+
+
+
+```
+
+(Match the existing header's exact JSX; only insert the one line. Do not change other markup.)
+
+- [ ] **Step 2: Add the FOUC-prevention inline script.** In `web/index.html`, inside ``
+ BEFORE the `
+```
+
+- [ ] **Step 3: Verify the app-shell test still passes** (the header now has an extra control):
+
+Run: `cd web && pnpm vitest run src/shell/app-shell.test.tsx`
+Expected: PASS (the existing "language switch" test is unaffected — ThemeSwitch buttons have distinct accessible names).
+
+- [ ] **Step 4: Build to verify `index.html` is valid**
+
+Run: `cd web && pnpm build`
+Expected: built successfully (Vite processes the inline script).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web/src/shell/app-shell.tsx web/index.html
+git commit -m "feat(web): mount ThemeSwitch in header + pre-paint theme init (#59)"
+```
+
+---
+
+# Task 5: Dark `--primary` contrast tweak + final verification
+
+**Files:**
+- Modify: `web/src/index.css`
+
+- [ ] **Step 1: Compute the new dark `--primary`.** The dark button label uses `--primary-foreground:
+ oklch(0.205 0 0)` (near-black) on `--primary: oklch(0.673 0.182 276.935)` (~3.21:1). Lower the
+ lightness (and keep it a recognizable indigo) until WCAG contrast vs `oklch(0.205 0 0)` is **≥4.5:1**.
+ A good starting point is `oklch(0.62 0.20 277)`; compute the exact value with a contrast check
+ (convert both to sRGB relative luminance, `(L1+0.05)/(L2+0.05) ≥ 4.5`). In the `.dark` block of
+ `web/src/index.css`, update BOTH `--primary` and `--ring` (they must match) to the chosen value:
+
+```css
+ --primary: oklch( 277);
+ ...
+ --ring: oklch( 277);
+```
+
+Leave `--primary-foreground: oklch(0.205 0 0)` and the entire `:root` (light) block unchanged.
+
+- [ ] **Step 2: Verify the contrast.** State the computed ratio in the commit body (must be ≥4.5:1).
+ Sanity-check the value is still visibly indigo (hue ~277, chroma not flattened to gray).
+
+- [ ] **Step 3: Full gate (single test pass).**
+
+Run:
+```bash
+cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors
+```
+Expected: all green. `check:colors` passes (icons are not color utilities). `check:size` within 250 KB
+gz (three lucide icons are negligible). Tests run exactly ONCE (no concurrent runs).
+
+- [ ] **Step 4: Codename + status checks.**
+
+```bash
+git grep -in 'biggus\|dickus' -- web/src web/index.html; echo "codename-exit=$?"
+git status --short
+```
+Expected: no codename matches; working tree shows only intended changes.
+
+- [ ] **Step 5: Manual smoke (recommended).** `pnpm dev`, toggle Light/Dark/System; confirm the app
+ switches, a dark reload doesn't flash light, primary buttons are legible in dark, and switching the
+ OS theme while in System updates the app live.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web/src/index.css
+git commit -m "fix(web): raise dark --primary contrast to AA for button labels (#59)"
+```
+
+---
+
+## Self-Review (completed)
+
+**Spec coverage:** tri-state model + System default (T1 `resolveTheme`/`readTheme`, T3 UI); persisted
+to localStorage (T2 `setTheme`, T3 tests); `.dark` on `` (T1 `applyTheme`); live system tracking
+(T2 `useEffect` matchMedia listener); FOUC prevention (T4 inline script); icon segmented control next
+to LangSwitch (T3 + T4 mount); en/sv `theme.*` (T3); aria-pressed/aria-label (T3); dark `--primary`
+contrast ≥4.5:1 + `--ring` sync (T5); gate incl. check:colors/check:size + no codename + no new dep
+(T5). All acceptance criteria 1–6 mapped. ✓
+
+**Placeholder scan:** the only "computed" value is the exact dark `--primary` OKLCH — a genuine WCAG
+measurement step with a concrete starting point and an explicit acceptance threshold (≥4.5:1), not a
+TODO. All code blocks are complete. ✓
+
+**Type consistency:** `Theme` type defined in `theme.ts` (T1), imported by `use-theme.ts` (T2) and
+`theme-switch.tsx` (T3); `THEME_KEY` from `theme.ts` used in T2's setter; `resolveTheme`/`readTheme`/
+`applyTheme` signatures consistent across tasks; i18n keys `theme.light/dark/system` defined in T3 and
+referenced by `t(\`theme.${value}\`)` in T3's component. ✓
+
+## Notes
+- No new dependency (lucide-react already present; `.dark` tokens already exist from #49).
+- The inline FOUC script is intentionally plain ES5-ish + try/catch — it runs before the bundle and
+ must never throw.
+- Cross-tab sync and per-account/server theme default are explicit follow-ups (not in this plan).
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 `