Files
biggus-dickus/docs/superpowers/plans/2026-06-07-dark-mode-theme-toggle.md
T

521 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Dark-Mode Theme Toggle Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a tri-state (Light/Dark/System) theme toggle that activates the existing `.dark` token set, persists to `localStorage`, defaults to System (live-tracking the OS), and never flashes on reload.
**Architecture:** Client-only theming over CSS custom properties — no new dependency. A framework-free core (`theme.ts`) resolves/reads/applies the theme; a `useTheme` hook mirrors `use-locale`; a synchronous inline script in `index.html` applies the class before first paint; an icon segmented `ThemeSwitch` lives in the header next to `LangSwitch`. The `.dark` class on `<html>` activates the dark tokens migrated in #49.
**Tech Stack:** React 19 + TS + pnpm, Tailwind v4 (OKLCH tokens in `index.css`), lucide-react (already a dep), Vitest + RTL + MSW + Storybook. Test runner: `pnpm test` (vitest, single pass).
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; en/sv parity; source double-quote/semicolon, stories single-quote/no-semicolon; token classes only (no raw colors — `check:colors` must pass); guard DOM globals (`window`/`localStorage`/`matchMedia`/`document`) for jsdom/test safety.
**Spec:** `docs/superpowers/specs/2026-06-07-dark-mode-theme-toggle-design.md`
**File structure:**
- `web/src/theme/theme.ts` (new) — `THEME_KEY`, `Theme`, `resolveTheme`, `readTheme`, `applyTheme`.
- `web/src/theme/theme.test.ts` (new) — unit tests for the core.
- `web/src/theme/use-theme.ts` (new) — `useTheme()` hook.
- `web/src/shell/theme-switch.tsx` (new) — the icon segmented control.
- `web/src/shell/theme-switch.test.tsx` (new) — interaction tests.
- `web/src/shell/theme-switch.stories.tsx` (new) — Storybook story.
- `web/src/shell/app-shell.tsx` (modify) — mount `<ThemeSwitch />`.
- `web/src/i18n/en.json`, `web/src/i18n/sv.json` (modify) — `theme.*` keys.
- `web/index.html` (modify) — inline FOUC-prevention script.
- `web/src/index.css` (modify) — dark `--primary`/`--ring` contrast tweak.
---
# Task 1: Theme core (`theme.ts`) + unit tests
**Files:**
- Create: `web/src/theme/theme.ts`
- Create: `web/src/theme/theme.test.ts`
- [ ] **Step 1: Write the failing tests**`web/src/theme/theme.test.ts`:
```ts
import { afterEach, expect, test, vi } from "vitest";
import { applyTheme, readTheme, resolveTheme, THEME_KEY } from "./theme";
function mockMatchMedia(matches: boolean) {
vi.stubGlobal("matchMedia", (query: string) => ({
matches,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
document.documentElement.classList.remove("dark");
});
test("resolveTheme returns explicit values verbatim", () => {
expect(resolveTheme("light")).toBe("light");
expect(resolveTheme("dark")).toBe("dark");
});
test("resolveTheme maps system via prefers-color-scheme", () => {
mockMatchMedia(true);
expect(resolveTheme("system")).toBe("dark");
mockMatchMedia(false);
expect(resolveTheme("system")).toBe("light");
});
test("readTheme defaults to system when unset or invalid", () => {
expect(readTheme()).toBe("system");
localStorage.setItem(THEME_KEY, "bogus");
expect(readTheme()).toBe("system");
localStorage.setItem(THEME_KEY, "dark");
expect(readTheme()).toBe("dark");
});
test("applyTheme toggles the dark class on documentElement", () => {
mockMatchMedia(false);
applyTheme("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
applyTheme("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
mockMatchMedia(true);
applyTheme("system");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd web && pnpm vitest run src/theme/theme.test.ts`
Expected: FAIL — cannot import from `./theme` (module not found).
- [ ] **Step 3: Implement**`web/src/theme/theme.ts`:
```ts
export const THEME_KEY = "theme";
export type Theme = "light" | "dark" | "system";
const THEMES: readonly Theme[] = ["light", "dark", "system"];
function prefersDark(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
);
}
export function resolveTheme(theme: Theme): "light" | "dark" {
if (theme === "light" || theme === "dark") return theme;
return prefersDark() ? "dark" : "light";
}
export function readTheme(): Theme {
if (typeof localStorage === "undefined") return "system";
const stored = localStorage.getItem(THEME_KEY);
return THEMES.includes(stored as Theme) ? (stored as Theme) : "system";
}
export function applyTheme(theme: Theme): void {
if (typeof document === "undefined") return;
document.documentElement.classList.toggle("dark", resolveTheme(theme) === "dark");
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `cd web && pnpm vitest run src/theme/theme.test.ts`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add web/src/theme/theme.ts web/src/theme/theme.test.ts
git commit -m "feat(web): theme core — resolve/read/apply tri-state theme (#59)"
```
---
# Task 2: `useTheme` hook
**Files:**
- Create: `web/src/theme/use-theme.ts`
(No standalone unit test — the hook is exercised by `theme-switch.test.tsx` in Task 3, which drives it through real UI per the project's testing style. `theme.ts` carries the logic and is unit-tested in Task 1.)
- [ ] **Step 1: Implement**`web/src/theme/use-theme.ts`:
```ts
import { useEffect, useState } from "react";
import { applyTheme, readTheme, type Theme } from "./theme";
export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void } {
const [theme, setThemeState] = useState<Theme>(readTheme);
const setTheme = (next: Theme) => {
if (typeof localStorage !== "undefined") localStorage.setItem("theme", next);
setThemeState(next);
applyTheme(next);
};
useEffect(() => {
applyTheme(theme);
if (theme !== "system") return;
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => applyTheme("system");
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [theme]);
return { theme, setTheme };
}
```
Note: import `THEME_KEY` from `./theme` and use it instead of the literal `"theme"` for the
`localStorage.setItem` key (DRY with the core). Update the import line to
`import { applyTheme, readTheme, THEME_KEY, type Theme } from "./theme";` and use
`localStorage.setItem(THEME_KEY, next)`.
- [ ] **Step 2: Typecheck**
Run: `cd web && pnpm typecheck`
Expected: PASS (no errors).
- [ ] **Step 3: Commit**
```bash
git add web/src/theme/use-theme.ts
git commit -m "feat(web): useTheme hook with live system tracking (#59)"
```
---
# Task 3: `ThemeSwitch` UI + i18n + tests + story
**Files:**
- Create: `web/src/shell/theme-switch.tsx`
- Create: `web/src/shell/theme-switch.test.tsx`
- Create: `web/src/shell/theme-switch.stories.tsx`
- Modify: `web/src/i18n/en.json`, `web/src/i18n/sv.json`
- [ ] **Step 1: Add i18n keys.** In `web/src/i18n/en.json`, add a top-level `theme` namespace (place after the `labels` entry):
```json
"theme": { "light": "Light", "dark": "Dark", "system": "System" },
```
In `web/src/i18n/sv.json`, the matching entry:
```json
"theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" },
```
- [ ] **Step 2: Write the failing test**`web/src/shell/theme-switch.test.tsx`:
```tsx
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { ThemeSwitch } from "./theme-switch";
beforeEach(() => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
document.documentElement.classList.remove("dark");
});
test("selecting Dark applies the dark class and persists", async () => {
renderApp(<ThemeSwitch />);
await userEvent.click(screen.getByRole("button", { name: /dark/i }));
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(localStorage.getItem("theme")).toBe("dark");
expect(screen.getByRole("button", { name: /dark/i })).toHaveAttribute(
"aria-pressed",
"true",
);
});
test("selecting Light removes the dark class and persists", async () => {
localStorage.setItem("theme", "dark");
renderApp(<ThemeSwitch />);
await userEvent.click(screen.getByRole("button", { name: /light/i }));
expect(document.documentElement.classList.contains("dark")).toBe(false);
expect(localStorage.getItem("theme")).toBe("light");
});
test("selecting System resolves via prefers-color-scheme", async () => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: true,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
renderApp(<ThemeSwitch />);
await userEvent.click(screen.getByRole("button", { name: /system/i }));
expect(localStorage.getItem("theme")).toBe("system");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
```
- [ ] **Step 3: Run to verify it fails**
Run: `cd web && pnpm vitest run src/shell/theme-switch.test.tsx`
Expected: FAIL — cannot import `ThemeSwitch`.
- [ ] **Step 4: Implement**`web/src/shell/theme-switch.tsx`:
```tsx
import { Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "../theme/use-theme";
import type { Theme } from "../theme/theme";
import { cn } from "@/lib/utils";
const OPTIONS: { value: Theme; Icon: typeof Sun }[] = [
{ value: "light", Icon: Sun },
{ value: "dark", Icon: Moon },
{ value: "system", Icon: Monitor },
];
export function ThemeSwitch() {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
return (
<div className="flex gap-1">
{OPTIONS.map(({ value, Icon }) => {
const active = theme === value;
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
aria-pressed={active}
aria-label={t(`theme.${value}`)}
title={t(`theme.${value}`)}
className={cn(
"rounded-md p-1 transition-colors",
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="h-4 w-4" aria-hidden />
</button>
);
})}
</div>
);
}
```
(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<typeof ThemeSwitch>
export default meta
type Story = StoryObj<typeof meta>
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 `<html>`
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 `<LangSwitch />`:
```tsx
<div className="flex-1" />
<ThemeSwitch />
<LangSwitch />
```
(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 `<head>`
BEFORE the `<script type="module" src="/src/main.tsx">` tag, add:
```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>
```
- [ ] **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(<chosen-L> <chosen-C> 277);
...
--ring: oklch(<chosen-L> <chosen-C> 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 `<html>` (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 16 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).