From 5d63f0686384189f88a83cdd5a68270a2aa979d9 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 15:19:29 +0200 Subject: [PATCH 1/7] =?UTF-8?q?docs(specs):=20dark-mode=20theme=20toggle?= =?UTF-8?q?=20=E2=80=94=20tri-state,=20icon=20segmented,=20FOUC-safe=20(#5?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...026-06-07-dark-mode-theme-toggle-design.md | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-dark-mode-theme-toggle-design.md 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 ` + +``` + +### i18n — `web/src/i18n/{en,sv}.json` +Add a `theme` namespace (en/sv parity): +- en: `{ "light": "Light", "dark": "Dark", "system": "System" }` +- sv: `{ "light": "Ljust", "dark": "Mörkt", "system": "System" }` + +### Dark `--primary` contrast tweak — `web/src/index.css` +Current dark `--primary: oklch(0.673 0.182 276.935)` with near-black `--primary-foreground: +oklch(0.205 0 0)` yields ~3.21:1 for button-label text. Lower the dark `--primary` lightness so the +near-black foreground reaches **≥4.5:1** (e.g. around `oklch(0.62 0.20 277)` — implementer computes +the exact value with a WCAG contrast check against `--primary-foreground`, keeping it a recognizable +indigo and `--ring` in sync). Light mode is unchanged (already 8.3:1). + +## Data flow +First load: inline script applies class from `localStorage`/system before paint → React mounts → +`useTheme` seeds from `localStorage` and (when `system`) attaches the media listener. Toggle click → +`setTheme` persists + re-applies. OS theme change while in `system` → listener re-applies. Other +tabs are not synced (out of scope; a `storage` listener could be a later nicety). + +## Error handling / edges +- `localStorage`/`matchMedia`/`document` all guarded → safe in tests/SSR (fall back to light, no throw). +- Invalid stored value → treated as `"system"`. +- The inline script is wrapped in `try/catch` so a storage exception never blocks render. +- Pinning light/dark removes the system listener so it stops following the OS. + +## Testing +- **`web/src/theme/theme.test.ts`** (unit): `resolveTheme` maps light/dark verbatim and resolves + `system` via a mocked `matchMedia`; `readTheme` returns `system` when unset and the stored value + otherwise; `applyTheme` toggles the `dark` class on `documentElement`. +- **`web/src/shell/theme-switch.test.tsx`** (renderApp): clicking **Dark** adds `.dark` to + `document.documentElement` and sets `localStorage.theme === "dark"`; clicking **Light** removes it; + clicking **System** with `matchMedia` mocked dark → class present, `localStorage.theme === "system"`; + `aria-pressed` reflects the active mode. (Mock `window.matchMedia` in the test as jsdom lacks it.) +- **Storybook:** `theme-switch.stories.tsx` rendering the three-state control (a play test asserting + the three buttons + aria-pressed). Note: toggling theme in a story mutates `` globally — + keep the story render-only or reset the class in `play`/`beforeEach`. +- Gate: `pnpm typecheck && lint && test && build && check:size && check:colors`. en/sv parity; no + codename. `check:size` within 250 KB gz (three small lucide icons; `Sun`/`Moon`/`Monitor` — + negligible, but confirm). + +## Acceptance criteria +1. A tri-state theme toggle (Light/Dark/System) appears in the header; default is System. +2. Choosing Dark applies `.dark` to `` and persists; Light removes it; System follows the OS + and live-updates via `prefers-color-scheme` until the user pins a value. +3. No light flash on a dark reload (synchronous pre-React init). +4. Dark `--primary` button-label contrast ≥ 4.5:1 (`--ring` kept in sync); light unchanged. +5. en/sv parity for `theme.*`; `aria-pressed` + `aria-label` on the controls. +6. `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no codename; no new npm dep. + +## Out of scope → follow-ups +- Per-account / server-synced theme preference (add `default_theme` to `ConfigView` later, mirroring + `default_language`). +- Cross-tab sync via a `storage` event listener. +- Header redesign / wayfinding (#54); a full dark-mode visual QA pass across every screen (the tokens + make it adapt, but a dedicated screenshot review is a separate effort). From 5e7a80e3775ec11b4ee40f28b4ad75cf5aec2035 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 15:25:42 +0200 Subject: [PATCH 2/7] =?UTF-8?q?docs(plans):=20dark-mode=20theme=20toggle?= =?UTF-8?q?=20=E2=80=94=205-task=20plan=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-07-dark-mode-theme-toggle.md | 520 ++++++++++++++++++ 1 file changed, 520 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-dark-mode-theme-toggle.md diff --git a/docs/superpowers/plans/2026-06-07-dark-mode-theme-toggle.md b/docs/superpowers/plans/2026-06-07-dark-mode-theme-toggle.md new file mode 100644 index 0000000..d694447 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-dark-mode-theme-toggle.md @@ -0,0 +1,520 @@ +# 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 `` 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 ``. +- `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(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(); + 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(); + 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(); + 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 ( +
+ {OPTIONS.map(({ value, Icon }) => { + const active = theme === value; + return ( + + ); + })} +
+ ); +} +``` + +(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). From e5c03383fe901d8445ca054e4e49183b45de9f78 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 16:28:21 +0200 Subject: [PATCH 3/7] =?UTF-8?q?feat(web):=20theme=20core=20=E2=80=94=20res?= =?UTF-8?q?olve/read/apply=20tri-state=20theme=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- web/src/theme/theme.test.ts | 52 +++++++++++++++++++++++++++++++++++++ web/src/theme/theme.ts | 33 +++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 web/src/theme/theme.test.ts create mode 100644 web/src/theme/theme.ts diff --git a/web/src/theme/theme.test.ts b/web/src/theme/theme.test.ts new file mode 100644 index 0000000..92c4d33 --- /dev/null +++ b/web/src/theme/theme.test.ts @@ -0,0 +1,52 @@ +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); +}); diff --git a/web/src/theme/theme.ts b/web/src/theme/theme.ts new file mode 100644 index 0000000..3869139 --- /dev/null +++ b/web/src/theme/theme.ts @@ -0,0 +1,33 @@ +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"); +} From d452dd9b350fb7ba07c6d28f985f542a4e087cdf Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 16:29:59 +0200 Subject: [PATCH 4/7] feat(web): useTheme hook with live system tracking (#59) --- web/src/theme/use-theme.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 web/src/theme/use-theme.ts diff --git a/web/src/theme/use-theme.ts b/web/src/theme/use-theme.ts new file mode 100644 index 0000000..cbd194e --- /dev/null +++ b/web/src/theme/use-theme.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; + +import { applyTheme, readTheme, THEME_KEY, type Theme } from "./theme"; + +export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void } { + const [theme, setThemeState] = useState(readTheme); + + const setTheme = (next: Theme) => { + if (typeof localStorage !== "undefined") localStorage.setItem(THEME_KEY, 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 }; +} From 6d17e5f84dd1527c8cfa9fa17ee0177c481e7ab0 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 16:33:16 +0200 Subject: [PATCH 5/7] feat(web): ThemeSwitch icon segmented control + theme.* i18n (#59) --- web/src/i18n/en.json | 1 + web/src/i18n/sv.json | 1 + web/src/shell/theme-switch.stories.tsx | 20 +++++++++ web/src/shell/theme-switch.test.tsx | 57 ++++++++++++++++++++++++++ web/src/shell/theme-switch.tsx | 43 +++++++++++++++++++ 5 files changed, 122 insertions(+) create mode 100644 web/src/shell/theme-switch.stories.tsx create mode 100644 web/src/shell/theme-switch.test.tsx create mode 100644 web/src/shell/theme-switch.tsx diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index f853041..ae7c008 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -9,6 +9,7 @@ "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" }, "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, "labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept." }, + "theme": { "light": "Light", "dark": "Dark", "system": "System" }, "vocab": { "newVocabulary": "New vocabulary", "key": "Key", "create": "Create", "selectPrompt": "Select a vocabulary to manage its terms", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index e160c7e..fdfac42 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -9,6 +9,7 @@ "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" }, "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls." }, + "theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" }, "vocab": { "newVocabulary": "Ny vokabulär", "key": "Nyckel", "create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer", diff --git a/web/src/shell/theme-switch.stories.tsx b/web/src/shell/theme-switch.stories.tsx new file mode 100644 index 0000000..ae227c5 --- /dev/null +++ b/web/src/shell/theme-switch.stories.tsx @@ -0,0 +1,20 @@ +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() + }, +} diff --git a/web/src/shell/theme-switch.test.tsx b/web/src/shell/theme-switch.test.tsx new file mode 100644 index 0000000..deaa226 --- /dev/null +++ b/web/src/shell/theme-switch.test.tsx @@ -0,0 +1,57 @@ +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(); + 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(); + 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(); + await userEvent.click(screen.getByRole("button", { name: /system/i })); + expect(localStorage.getItem("theme")).toBe("system"); + expect(document.documentElement.classList.contains("dark")).toBe(true); +}); diff --git a/web/src/shell/theme-switch.tsx b/web/src/shell/theme-switch.tsx new file mode 100644 index 0000000..e366376 --- /dev/null +++ b/web/src/shell/theme-switch.tsx @@ -0,0 +1,43 @@ +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 ( +
+ {OPTIONS.map(({ value, Icon }) => { + const active = theme === value; + return ( + + ); + })} +
+ ); +} From 4f3db60ed2373528552415ea393d9281c338e398 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 16:37:04 +0200 Subject: [PATCH 6/7] feat(web): mount ThemeSwitch in header + pre-paint theme init (#59) --- web/index.html | 10 ++++++++++ web/src/shell/app-shell.tsx | 2 ++ 2 files changed, 12 insertions(+) diff --git a/web/index.html b/web/index.html index 3773bd7..f3064e9 100644 --- a/web/index.html +++ b/web/index.html @@ -4,6 +4,16 @@ Collection +
diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index 9262273..d7ff942 100644 --- a/web/src/shell/app-shell.tsx +++ b/web/src/shell/app-shell.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { useLogout } from "../api/queries"; import { Button } from "@/components/ui/button"; import { LangSwitch } from "./lang-switch"; +import { ThemeSwitch } from "./theme-switch"; import { Sidebar } from "./sidebar"; export function AppShell() { @@ -22,6 +23,7 @@ export function AppShell() {
+