+ );
+}
+```
+
+(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).