521 lines
18 KiB
Markdown
521 lines
18 KiB
Markdown
# 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 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).
|