18 KiB
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/--ringcontrast 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:
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:
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
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:
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
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-levelthemenamespace (place after thelabelsentry):
"theme": { "light": "Light", "dark": "Dark", "system": "System" },
In web/src/i18n/sv.json, the matching entry:
"theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" },
- Step 2: Write the failing test —
web/src/shell/theme-switch.test.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:
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:
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
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. Inweb/src/shell/app-shell.tsx, add the import:
import { ThemeSwitch } from "./theme-switch";
and render it in the header immediately before <LangSwitch />:
<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:
<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.htmlis valid
Run: cd web && pnpm build
Expected: built successfully (Vite processes the inline script).
- Step 5: Commit
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 vsoklch(0.205 0 0)is ≥4.5:1. A good starting point isoklch(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.darkblock ofweb/src/index.css, update BOTH--primaryand--ring(they must match) to the chosen value:
--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:
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.
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
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;
.darktokens 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).