merge: dark-mode theme toggle — tri-state Light/Dark/System, FOUC-safe (#59)
CI / web (push) Has been cancelled
CI / web (push) Has been cancelled
This commit is contained in:
@@ -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 `<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).
|
||||||
@@ -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 `<html>` 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) ──▶ <html class="dark"?> ──▶ 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 `<script>` in `<head>`, before the module script, that synchronously sets the class
|
||||||
|
so a dark reload never flashes light:
|
||||||
|
```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>
|
||||||
|
```
|
||||||
|
(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 `<button>`s in a row (`flex gap-1`), one per `Theme`, each with a lucide icon
|
||||||
|
(`Sun` → light, `Moon` → dark, `Monitor` → system), sized `size-4`/`h-4 w-4`.
|
||||||
|
- Active button highlighted; inactive `text-muted-foreground` (mirror `LangSwitch`). Each carries
|
||||||
|
`aria-pressed={theme === value}` and `aria-label={t("theme.<value>")}`.
|
||||||
|
- `onClick` → `setTheme(value)`.
|
||||||
|
- No raw color utilities (token classes only — passes `check:colors`).
|
||||||
|
|
||||||
|
### Mount — `web/src/shell/app-shell.tsx`
|
||||||
|
Insert `<ThemeSwitch />` in the header immediately before `<LangSwitch />`:
|
||||||
|
```tsx
|
||||||
|
<header className="flex items-center gap-4 border-b px-4 py-2">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<ThemeSwitch />
|
||||||
|
<LangSwitch />
|
||||||
|
<Button variant="ghost" size="sm" onClick={onSignOut}>{t("auth.signOut")}</Button>
|
||||||
|
</header>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 `<html>` 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 `<html>` 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).
|
||||||
@@ -4,6 +4,16 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Collection</title>
|
<title>Collection</title>
|
||||||
|
<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>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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" },
|
"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." },
|
"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." },
|
"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": {
|
"vocab": {
|
||||||
"newVocabulary": "New vocabulary", "key": "Key",
|
"newVocabulary": "New vocabulary", "key": "Key",
|
||||||
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
|
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
|
||||||
|
|||||||
@@ -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" },
|
"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." },
|
"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." },
|
"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": {
|
"vocab": {
|
||||||
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
||||||
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
|
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
|
||||||
|
|||||||
+2
-2
@@ -69,7 +69,7 @@
|
|||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.673 0.182 276.935);
|
--primary: oklch(0.72 0.18 277);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
--highlight-foreground: oklch(0.205 0 0);
|
--highlight-foreground: oklch(0.205 0 0);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--ring: oklch(0.673 0.182 276.935);
|
--ring: oklch(0.72 0.18 277);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useLogout } from "../api/queries";
|
import { useLogout } from "../api/queries";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LangSwitch } from "./lang-switch";
|
import { LangSwitch } from "./lang-switch";
|
||||||
|
import { ThemeSwitch } from "./theme-switch";
|
||||||
import { Sidebar } from "./sidebar";
|
import { Sidebar } from "./sidebar";
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
@@ -22,6 +23,7 @@ export function AppShell() {
|
|||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<header className="flex items-center gap-4 border-b px-4 py-2">
|
<header className="flex items-center gap-4 border-b px-4 py-2">
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
<ThemeSwitch />
|
||||||
<LangSwitch />
|
<LangSwitch />
|
||||||
<Button variant="ghost" size="sm" onClick={onSignOut}>
|
<Button variant="ghost" size="sm" onClick={onSignOut}>
|
||||||
{t("auth.signOut")}
|
{t("auth.signOut")}
|
||||||
|
|||||||
@@ -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<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()
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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(<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);
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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<Theme>(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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user