Files
minstrel/docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md
T
bvandeusen a9fc0c6fe8 docs(m7-362): theme toggle implementation plan (9 tasks)
Tokens schema split + dual-block generator, Tailwind text-action-fg
alias, action-button audit, theme rune store, FOUC script, Settings
Appearance card. Plus generator/store/UI tests for CI. No in-task
test runs per memory rule.
2026-05-03 13:37:33 -04:00

861 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M7 #362 — Web SPA 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 Dark / Light / System theme toggle for the Minstrel web SPA, persisted to localStorage and applied before first paint.
**Architecture:** CSS custom-property indirection via existing `--fs-*` tokens. `tokens.json` grows from a flat `colors` map to `colors.dark` + `colors.light` + `colors.flat`. Generator emits `:root { ... }` (dark + flat) and `[data-theme="light"] { ... }` (light overrides only). Theme is applied by setting `data-theme` on `<html>`; an inline `<head>` script does this before paint to avoid FOUC.
**Tech Stack:** SvelteKit 2 / Svelte 5 (runes mode), Tailwind CSS, vanilla `matchMedia` + `localStorage`. No new dependencies.
**Spec:** `docs/superpowers/specs/2026-05-03-m7-362-theme-toggle-design.md` (commit `1d6a71a` on `dev`).
**Test policy:** No in-task test runs. Tests are written as files for CI to execute (vitest under `web/src/**/*.test.ts` and `web/scripts/*.test.js`); the implementer never invokes `npm test`, `vitest`, `npm run build`, or `npm run check` during this work. Codegen scripts (`npm run tokens`) ARE allowed.
---
## File Structure
**Modified:**
- `web/src/lib/styles/tokens.json` — schema change: `colors``colors.dark` + `colors.light` + `colors.flat`; add `on-action` flat token.
- `web/scripts/tokens-to-css.js` — emit `:root` (dark + flat) and `[data-theme="light"]` blocks.
- `web/src/lib/styles/tokens.generated.css` — regenerated artifact (do not hand-edit).
- `web/tailwind.config.js` — add `colors.action.fg = 'var(--fs-on-action)'`.
- `web/src/app.html` — inline FOUC script in `<head>`; remove static `class="dark"` on `<html>`.
- `web/src/routes/settings/+page.svelte` — add Appearance card.
- ~25 component/route files — replace `text-text-primary` with `text-action-fg` on action-button labels.
**Created:**
- `web/src/lib/stores/theme.svelte.ts` — theme preference + resolved-theme state with matchMedia listener.
- `web/src/lib/stores/theme.svelte.test.ts` — store unit test (CI).
- `web/scripts/tokens-to-css.test.js` — generator unit test (CI).
- `web/src/routes/settings/Appearance.test.ts` — Appearance card render test (CI).
---
## Task 1: Restructure tokens.json + dual-block generator
**Files:**
- Modify: `web/src/lib/styles/tokens.json`
- Modify: `web/scripts/tokens-to-css.js`
- Modify: `web/src/lib/styles/tokens.generated.css` (regenerated)
- [ ] **Step 1: Restructure `tokens.json` colors map**
Replace the entire `colors` object at the top of `web/src/lib/styles/tokens.json`. The other top-level sections (`radii`, `fonts`, `fontStacks`) stay untouched.
```json
{
"colors": {
"dark": {
"obsidian": "#14171A",
"iron": "#1E2228",
"slate": "#2C313A",
"pewter": "#3F4651",
"parchment": "#E8E4D8",
"vellum": "#C2BFB4",
"ash": "#9C9A92"
},
"light": {
"obsidian": "#F8F5EE",
"iron": "#ECE6D5",
"slate": "#DCD3BD",
"pewter": "#B8AE94",
"parchment": "#14171A",
"vellum": "#2C313A",
"ash": "#5C6068"
},
"flat": {
"moss": "#4A5D3F",
"bronze": "#8B7355",
"oxblood": "#6B2118",
"warning": "#8B6F1E",
"error": "#C04A1F",
"info": "#3D5A6E",
"accent": "#4A6B5C",
"on-action": "#E8E4D8"
}
},
"radii": { "sm": "4px", "md": "8px", "lg": "12px", "xl": "16px" },
"fonts": { "display": "Fraunces", "body": "Inter", "mono": "JetBrains Mono" },
"fontStacks": {
"display": "'Fraunces', Georgia, serif",
"body": "'Inter', system-ui, sans-serif",
"mono": "'JetBrains Mono', ui-monospace, monospace"
}
}
```
- [ ] **Step 2: Rewrite the generator to emit dual blocks**
Replace the entire body of `web/scripts/tokens-to-css.js` with:
```js
#!/usr/bin/env node
// Reads ../src/lib/styles/tokens.json and emits tokens.generated.css.
// Single source of truth for FabledSword tokens shared with the Flutter
// client. Keep output deterministic — Tailwind reads tokens.json directly,
// the .css emission is for raw CSS consumers and theme switching.
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const tokensPath = resolve(here, '../src/lib/styles/tokens.json');
const outPath = resolve(here, '../src/lib/styles/tokens.generated.css');
export function emit(tokens) {
const lines = ['/* GENERATED — do not edit. Source: tokens.json */', ':root {'];
for (const [name, value] of Object.entries(tokens.colors.dark)) {
lines.push(` --fs-${name}: ${value};`);
}
for (const [name, value] of Object.entries(tokens.colors.flat)) {
lines.push(` --fs-${name}: ${value};`);
}
for (const [name, value] of Object.entries(tokens.radii)) {
lines.push(` --fs-radius-${name}: ${value};`);
}
lines.push(` --fs-font-display: ${tokens.fontStacks.display};`);
lines.push(` --fs-font-body: ${tokens.fontStacks.body};`);
lines.push(` --fs-font-mono: ${tokens.fontStacks.mono};`);
lines.push('}');
lines.push('[data-theme="light"] {');
for (const [name, value] of Object.entries(tokens.colors.light)) {
lines.push(` --fs-${name}: ${value};`);
}
lines.push('}');
lines.push('[data-fs-app="minstrel"] {', ` --fs-accent: ${tokens.colors.flat.accent};`, '}', '');
return lines.join('\n');
}
// Run as script: read tokens.json + write generated CSS
if (import.meta.url === `file://${process.argv[1]}`) {
const tokens = JSON.parse(readFileSync(tokensPath, 'utf8'));
writeFileSync(outPath, emit(tokens));
console.log(`wrote ${outPath}`);
}
```
Note the new `export function emit(tokens)` so the unit test (Task 7) can import it directly without spawning a subprocess. The CLI behavior at the bottom only fires when invoked as a script.
- [ ] **Step 3: Regenerate `tokens.generated.css`**
```bash
cd web && npm run tokens
```
Expected output: `wrote /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web/src/lib/styles/tokens.generated.css`
The new file content should look like:
```css
/* GENERATED — do not edit. Source: tokens.json */
:root {
--fs-obsidian: #14171A;
--fs-iron: #1E2228;
--fs-slate: #2C313A;
--fs-pewter: #3F4651;
--fs-parchment: #E8E4D8;
--fs-vellum: #C2BFB4;
--fs-ash: #9C9A92;
--fs-moss: #4A5D3F;
--fs-bronze: #8B7355;
--fs-oxblood: #6B2118;
--fs-warning: #8B6F1E;
--fs-error: #C04A1F;
--fs-info: #3D5A6E;
--fs-accent: #4A6B5C;
--fs-on-action: #E8E4D8;
--fs-radius-sm: 4px;
--fs-radius-md: 8px;
--fs-radius-lg: 12px;
--fs-radius-xl: 16px;
--fs-font-display: 'Fraunces', Georgia, serif;
--fs-font-body: 'Inter', system-ui, sans-serif;
--fs-font-mono: 'JetBrains Mono', ui-monospace, monospace;
}
[data-theme="light"] {
--fs-obsidian: #F8F5EE;
--fs-iron: #ECE6D5;
--fs-slate: #DCD3BD;
--fs-pewter: #B8AE94;
--fs-parchment: #14171A;
--fs-vellum: #2C313A;
--fs-ash: #5C6068;
}
[data-fs-app="minstrel"] {
--fs-accent: #4A6B5C;
}
```
- [ ] **Step 4: Commit**
```bash
git add web/src/lib/styles/tokens.json web/scripts/tokens-to-css.js web/src/lib/styles/tokens.generated.css
git commit -m "feat(web/m7-362): tokens.json dark/light/flat split + dual-block CSS generator"
```
---
## Task 2: Add `text-action-fg` Tailwind alias
**Files:**
- Modify: `web/tailwind.config.js`
- [ ] **Step 1: Add `fg` to the action color group**
Edit `web/tailwind.config.js`. In the `action` block under `theme.extend.colors`, add the `fg` entry:
```js
action: {
primary: 'var(--fs-moss)',
secondary: 'var(--fs-bronze)',
destructive: 'var(--fs-oxblood)',
fg: 'var(--fs-on-action)'
},
```
The full file after edit:
```js
export default {
content: ['./src/**/*.{html,js,ts,svelte}'],
darkMode: 'class',
theme: {
extend: {
colors: {
background: 'var(--fs-obsidian)',
surface: {
DEFAULT: 'var(--fs-iron)',
hover: 'var(--fs-slate)'
},
border: {
DEFAULT: 'var(--fs-pewter)'
},
text: {
primary: 'var(--fs-parchment)',
secondary: 'var(--fs-vellum)',
muted: 'var(--fs-ash)'
},
action: {
primary: 'var(--fs-moss)',
secondary: 'var(--fs-bronze)',
destructive: 'var(--fs-oxblood)',
fg: 'var(--fs-on-action)'
},
accent: {
DEFAULT: 'var(--fs-accent)',
tint: 'color-mix(in srgb, var(--fs-accent) 12%, transparent)'
},
warning: 'var(--fs-warning)',
error: 'var(--fs-error)',
info: 'var(--fs-info)'
},
borderRadius: {
sm: 'var(--fs-radius-sm)',
md: 'var(--fs-radius-md)',
lg: 'var(--fs-radius-lg)',
xl: 'var(--fs-radius-xl)'
},
fontFamily: {
display: ['var(--fs-font-display)'],
sans: ['var(--fs-font-body)'],
mono: ['var(--fs-font-mono)']
}
}
},
plugins: []
};
```
- [ ] **Step 2: Commit**
```bash
git add web/tailwind.config.js
git commit -m "feat(web/m7-362): add Tailwind text-action-fg alias for non-flipping label color"
```
---
## Task 3: Replace action-button label class across the codebase
**Files:**
- Modify: every site where a `bg-action-primary` / `bg-action-secondary` / `bg-action-destructive` element also carries `text-text-primary`
This is a mechanical audit + replace. The grep performed during planning found ~25 sites; the implementer should re-grep to catch any new sites since.
- [ ] **Step 1: Run the audit grep**
```bash
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel/web
grep -rn "bg-action-\(primary\|secondary\|destructive\)" src/ | grep "text-text-primary"
```
Expected output: a list of file:line entries, each containing both classes on the same element. Confirmed at planning time:
```
src/lib/components/AddToPlaylistMenu.svelte:113
src/lib/components/DiscoverResultCard.svelte:77
src/lib/components/FlagPopover.svelte:91
src/routes/admin/+page.svelte:239
src/routes/admin/+page.svelte:302
src/routes/admin/+page.svelte:312
src/routes/admin/integrations/+page.svelte:276
src/routes/admin/integrations/+page.svelte:293
src/routes/admin/integrations/+page.svelte:360
src/routes/admin/integrations/+page.svelte:368
src/routes/admin/quarantine/+page.svelte:293
src/routes/admin/quarantine/+page.svelte:304
src/routes/admin/quarantine/+page.svelte:315
src/routes/admin/quarantine/+page.svelte:364
src/routes/admin/quarantine/+page.svelte:426
src/routes/admin/requests/+page.svelte:273
src/routes/admin/requests/+page.svelte:282
src/routes/admin/requests/+page.svelte:355
src/routes/admin/requests/+page.svelte:407
src/routes/discover/+page.svelte:221
src/routes/discover/+page.svelte:228
src/routes/playlists/+page.svelte:46
src/routes/playlists/+page.svelte:81
src/routes/playlists/[id]/+page.svelte:216
```
- [ ] **Step 2: Replace `text-text-primary` with `text-action-fg` at each site**
For each file:line above, edit the element so `text-text-primary` becomes `text-action-fg`. Other classes on the same element (rounded, px, py, hover:opacity-90, disabled:opacity-50, etc.) stay.
Example: `web/src/lib/components/DiscoverResultCard.svelte:77` before:
```svelte
<button class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary">
```
after:
```svelte
<button class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg">
```
For lines like `src/routes/admin/+page.svelte:302` and `:312` where `text-text-primary` is concatenated in a template literal (e.g., `'bg-action-secondary text-text-primary'`), the same swap applies inside the literal:
before:
```svelte
'bg-action-secondary text-text-primary'
```
after:
```svelte
'bg-action-secondary text-action-fg'
```
- [ ] **Step 3: Re-run the audit grep to confirm zero hits**
```bash
grep -rn "bg-action-\(primary\|secondary\|destructive\)" src/ | grep "text-text-primary"
```
Expected: empty output. If any site remains, edit it the same way.
- [ ] **Step 4: Confirm `text-text-primary` is still legitimately used on non-action surfaces**
```bash
grep -rln "text-text-primary" src/
```
Expected: a non-empty list (page text, surface card headings, form labels, etc.). These keep `text-text-primary` because their backgrounds DO flip with the theme — that's the whole point of the alias.
- [ ] **Step 5: Commit**
```bash
git add -u src/
git commit -m "refactor(web/m7-362): action-button labels use non-flipping text-action-fg"
```
---
## Task 4: Theme rune store
**Files:**
- Create: `web/src/lib/stores/theme.svelte.ts`
- [ ] **Step 1: Create the store file**
Write the following to `web/src/lib/stores/theme.svelte.ts`:
```ts
const STORAGE_KEY = 'minstrel:theme';
export type ThemePreference = 'dark' | 'light' | 'system';
export type ResolvedTheme = 'dark' | 'light';
function readPref(): ThemePreference {
if (typeof localStorage === 'undefined') return 'system';
try {
const v = localStorage.getItem(STORAGE_KEY);
return v === 'dark' || v === 'light' || v === 'system' ? v : 'system';
} catch {
return 'system';
}
}
function systemResolved(): ResolvedTheme {
if (typeof window === 'undefined' || !window.matchMedia) return 'dark';
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
}
function resolve(pref: ThemePreference): ResolvedTheme {
return pref === 'system' ? systemResolved() : pref;
}
function applyToDOM(resolved: ResolvedTheme): void {
if (typeof document === 'undefined') return;
document.documentElement.setAttribute('data-theme', resolved);
}
let _theme = $state<ThemePreference>(readPref());
let _resolved = $state<ResolvedTheme>(resolve(_theme));
export const theme = {
get value(): ThemePreference {
return _theme;
}
};
export const resolvedTheme = {
get value(): ResolvedTheme {
return _resolved;
}
};
export function setTheme(pref: ThemePreference): void {
_theme = pref;
_resolved = resolve(pref);
if (typeof localStorage !== 'undefined') {
try { localStorage.setItem(STORAGE_KEY, pref); } catch { /* ignore quota / unavailable */ }
}
applyToDOM(_resolved);
}
// Subscribe to OS-level changes only when in 'system' mode.
if (typeof window !== 'undefined' && window.matchMedia) {
const mq = window.matchMedia('(prefers-color-scheme: light)');
mq.addEventListener('change', () => {
if (_theme === 'system') {
_resolved = systemResolved();
applyToDOM(_resolved);
}
});
}
```
This matches the project pattern in `web/src/lib/auth/store.svelte.ts`: module-scoped `$state`, exported `{ get value() }` for reads, exported function for mutations.
- [ ] **Step 2: Commit**
```bash
git add web/src/lib/stores/theme.svelte.ts
git commit -m "feat(web/m7-362): theme preference + resolved-theme rune store"
```
---
## Task 5: FOUC script + drop static dark class on `<html>`
**Files:**
- Modify: `web/src/app.html`
- [ ] **Step 1: Edit `web/src/app.html`**
Replace the entire file with:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Minstrel</title>
<script>
(function () {
try {
var t = localStorage.getItem('minstrel:theme') || 'system';
var resolved = t === 'system'
? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
: t;
document.documentElement.setAttribute('data-theme', resolved);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500&family=Inter:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap"
/>
%sveltekit.head%
</head>
<body class="bg-background text-text-primary">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
```
Two changes vs. the previous file:
1. `<html lang="en" class="dark">``<html lang="en">` (no static class).
2. Inline `<script>` block added immediately after `<title>` — must run synchronously before any CSS link so `data-theme` is set before paint.
- [ ] **Step 2: Commit**
```bash
git add web/src/app.html
git commit -m "feat(web/m7-362): pre-paint theme application via inline FOUC script"
```
---
## Task 6: Settings → Appearance card
**Files:**
- Modify: `web/src/routes/settings/+page.svelte`
- [ ] **Step 1: Add imports**
At the top of the `<script>` block in `web/src/routes/settings/+page.svelte`, after the existing imports, add:
```ts
import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte';
```
- [ ] **Step 2: Add the Appearance section as the first section in the page**
Inside the outer `<div class="space-y-6">`, immediately after the `<h1>Settings</h1>`, insert the Appearance section BEFORE the existing ListenBrainz section:
```svelte
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Appearance</h2>
<fieldset class="space-y-2">
<legend class="block text-sm text-text-secondary">Theme</legend>
<div class="flex gap-2">
{#each ['dark', 'light', 'system'] as opt (opt)}
<label
class="flex cursor-pointer items-center gap-2 rounded border border-border px-3 py-1.5 text-sm
{theme.value === opt ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'}"
>
<input
type="radio"
name="theme"
value={opt}
checked={theme.value === opt}
onchange={() => setTheme(opt as ThemePreference)}
class="sr-only"
/>
<span class="capitalize">{opt}</span>
</label>
{/each}
</div>
</fieldset>
<p class="text-xs text-text-secondary">
System follows your device's light/dark preference.
</p>
</section>
```
The styling pattern matches the existing ListenBrainz section directly below: same `space-y-3 rounded border border-border bg-surface p-4` shell, same heading weight.
- [ ] **Step 3: Commit**
```bash
git add web/src/routes/settings/+page.svelte
git commit -m "feat(web/m7-362): Settings → Appearance card with dark/light/system selector"
```
---
## Task 7: Token generator unit test
**Files:**
- Create: `web/scripts/tokens-to-css.test.js`
- [ ] **Step 1: Create the test**
Write the following to `web/scripts/tokens-to-css.test.js`:
```js
import { describe, expect, test } from 'vitest';
import { emit } from './tokens-to-css.js';
const sample = {
colors: {
dark: { obsidian: '#000', parchment: '#FFF' },
light: { obsidian: '#FFF', parchment: '#000' },
flat: { accent: '#4A6B5C', 'on-action': '#E8E4D8' }
},
radii: { sm: '4px' },
fontStacks: {
display: "'Fraunces', serif",
body: "'Inter', sans-serif",
mono: "'JetBrains Mono', monospace"
}
};
describe('tokens-to-css emit()', () => {
test('emits :root block with dark and flat tokens', () => {
const css = emit(sample);
expect(css).toMatch(/:root \{[\s\S]*--fs-obsidian: #000;[\s\S]*--fs-parchment: #FFF;[\s\S]*--fs-accent: #4A6B5C;[\s\S]*--fs-on-action: #E8E4D8;[\s\S]*\}/);
});
test('emits [data-theme="light"] block with light tokens only', () => {
const css = emit(sample);
expect(css).toMatch(/\[data-theme="light"\] \{[\s\S]*--fs-obsidian: #FFF;[\s\S]*--fs-parchment: #000;[\s\S]*\}/);
});
test('flat tokens are not duplicated into the light block', () => {
const css = emit(sample);
const lightBlock = css.match(/\[data-theme="light"\] \{([\s\S]*?)\}/)[1];
expect(lightBlock).not.toContain('--fs-accent');
expect(lightBlock).not.toContain('--fs-on-action');
});
test('preserves radii and font stacks in :root', () => {
const css = emit(sample);
expect(css).toContain('--fs-radius-sm: 4px;');
expect(css).toContain("--fs-font-display: 'Fraunces', serif;");
});
});
```
- [ ] **Step 2: Confirm vitest picks up the new file**
The vitest config globs JS test files by default. Verify by reading `web/vitest.config.ts` — if `include` is restricted to `src/**`, this test won't run. If so, broaden the include glob to also match `scripts/**/*.test.js`:
```bash
cat web/vitest.config.ts
```
If editing the config is needed (only if scripts/ isn't in `test.include`), add the glob and commit it as part of this task.
- [ ] **Step 3: Commit**
```bash
git add web/scripts/tokens-to-css.test.js
# If vitest.config.ts was edited:
git add web/vitest.config.ts
git commit -m "test(web/m7-362): tokens-to-css generator unit test"
```
---
## Task 8: Theme store unit test
**Files:**
- Create: `web/src/lib/stores/theme.svelte.test.ts`
- [ ] **Step 1: Create the test**
Write the following to `web/src/lib/stores/theme.svelte.test.ts`:
```ts
import { describe, expect, test, beforeEach, vi } from 'vitest';
// Each test imports the store fresh via dynamic import + vi.resetModules,
// because the store reads localStorage and matchMedia at module load.
function setupLocalStorage(initial: Record<string, string> = {}) {
const store: Record<string, string> = { ...initial };
vi.stubGlobal('localStorage', {
getItem: (k: string) => (k in store ? store[k] : null),
setItem: (k: string, v: string) => { store[k] = v; },
removeItem: (k: string) => { delete store[k]; }
});
return store;
}
function setupMatchMedia(prefersLight: boolean) {
const listeners: ((e: { matches: boolean }) => void)[] = [];
const mql = {
matches: prefersLight,
addEventListener: (_: string, cb: (e: { matches: boolean }) => void) => listeners.push(cb),
removeEventListener: () => {}
};
vi.stubGlobal('window', {
matchMedia: () => mql
});
return { mql, listeners, fire: (matches: boolean) => listeners.forEach((cb) => cb({ matches })) };
}
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
// jsdom's document is left in place; reset its data-theme between tests.
if (typeof document !== 'undefined') {
document.documentElement.removeAttribute('data-theme');
}
});
describe('theme store', () => {
test('default preference is system; resolves via matchMedia', async () => {
setupLocalStorage();
setupMatchMedia(false); // prefers-dark
const mod = await import('./theme.svelte');
expect(mod.theme.value).toBe('system');
expect(mod.resolvedTheme.value).toBe('dark');
});
test('explicit preference from localStorage is honored', async () => {
setupLocalStorage({ 'minstrel:theme': 'light' });
setupMatchMedia(false);
const mod = await import('./theme.svelte');
expect(mod.theme.value).toBe('light');
expect(mod.resolvedTheme.value).toBe('light');
});
test('setTheme persists, updates resolved, sets data-theme attribute', async () => {
const store = setupLocalStorage();
setupMatchMedia(true);
const mod = await import('./theme.svelte');
mod.setTheme('dark');
expect(store['minstrel:theme']).toBe('dark');
expect(mod.theme.value).toBe('dark');
expect(mod.resolvedTheme.value).toBe('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
test('matchMedia change updates resolved while in system mode', async () => {
setupLocalStorage();
const mm = setupMatchMedia(false); // prefers-dark
const mod = await import('./theme.svelte');
expect(mod.resolvedTheme.value).toBe('dark');
mm.fire(true); // OS flipped to light
expect(mod.resolvedTheme.value).toBe('light');
});
test('matchMedia change does NOT update resolved when explicit preference set', async () => {
setupLocalStorage({ 'minstrel:theme': 'dark' });
const mm = setupMatchMedia(false);
const mod = await import('./theme.svelte');
expect(mod.resolvedTheme.value).toBe('dark');
mm.fire(true); // OS prefers light, but user explicitly chose dark
expect(mod.resolvedTheme.value).toBe('dark');
});
});
```
- [ ] **Step 2: Commit**
```bash
git add web/src/lib/stores/theme.svelte.test.ts
git commit -m "test(web/m7-362): theme store unit test (default/persist/matchMedia)"
```
---
## Task 9: Settings Appearance render test
**Files:**
- Create: `web/src/routes/settings/Appearance.test.ts`
- [ ] **Step 1: Create the test**
Write the following to `web/src/routes/settings/Appearance.test.ts`:
```ts
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
// Mock collaborators that the Settings page imports but aren't under test here.
vi.mock('$lib/api/listenbrainz', () => ({
createLBStatusQuery: () => ({
subscribe: () => () => {},
isPending: false,
isError: false,
data: { token_set: false, enabled: false }
}),
createTokenMutation: () => ({ subscribe: () => () => {}, mutate: vi.fn(), isPending: false }),
createEnabledMutation: () => ({ subscribe: () => () => {}, mutate: vi.fn(), isPending: false })
}));
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) };
});
beforeEach(() => {
// Fresh localStorage + DOM theme attribute per test
globalThis.localStorage.clear();
document.documentElement.removeAttribute('data-theme');
});
import SettingsPage from './+page.svelte';
describe('Settings → Appearance', () => {
test('renders three theme options', () => {
render(SettingsPage);
expect(screen.getByText(/^Appearance$/)).toBeInTheDocument();
expect(screen.getByLabelText('dark', { exact: false })).toBeInTheDocument();
expect(screen.getByLabelText('light', { exact: false })).toBeInTheDocument();
expect(screen.getByLabelText('system', { exact: false })).toBeInTheDocument();
});
test('clicking light writes localStorage and sets data-theme', async () => {
render(SettingsPage);
const lightRadio = screen.getByLabelText('light', { exact: false }) as HTMLInputElement;
await fireEvent.click(lightRadio);
expect(localStorage.getItem('minstrel:theme')).toBe('light');
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
});
test('clicking dark writes localStorage and sets data-theme', async () => {
render(SettingsPage);
const darkRadio = screen.getByLabelText('dark', { exact: false }) as HTMLInputElement;
await fireEvent.click(darkRadio);
expect(localStorage.getItem('minstrel:theme')).toBe('dark');
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
});
```
- [ ] **Step 2: Commit**
```bash
git add web/src/routes/settings/Appearance.test.ts
git commit -m "test(web/m7-362): Settings Appearance card render + selection test"
```
---
## Done criteria
After all 9 tasks have green commits on `dev`:
- `npm run tokens` regenerates `tokens.generated.css` with `:root` (dark + flat) and `[data-theme="light"]` blocks.
- `<html>` has no static `class="dark"`; `data-theme` is set by the inline FOUC script before paint.
- Settings → Appearance lets the user pick Dark / Light / System; choice persists across reloads via `localStorage['minstrel:theme']`.
- Action buttons (`bg-action-primary` / `secondary` / `destructive`) use `text-action-fg`, which doesn't flip with the theme.
- CI runs the three new test files and they pass.
- Manual eyeball pass (post-merge, not a task here): home, library, search, playlists, playlist detail, queue, history, settings, admin in both modes.
---
## Self-review (writing-plans)
**Spec coverage:**
- Token strategy → Task 1 ✓
- `--fs-on-action` token + Tailwind alias → Tasks 1, 2 ✓
- Action button label audit → Task 3 ✓
- Theme rune store → Task 4 ✓
- FOUC inline script → Task 5 ✓
- Settings Appearance card → Task 6 ✓
- Generator/store/UI tests → Tasks 79 ✓
- Edge cases 1 (action contrast) → Tasks 2, 3 ✓
- Edge cases 4 (cross-tab sync), 5 (SSR), 6 (error pages) — documented out-of-scope in spec, no tasks needed ✓
- Edge case 7 (localStorage unavailable) → Task 4 store implementation handles via try/catch + fallback default ✓
**Placeholders:** none. All hex values, file paths, code blocks, and grep patterns are concrete.
**Type consistency:** `ThemePreference` and `ResolvedTheme` types are defined in Task 4 and re-imported in Task 6. `setTheme(pref: ThemePreference)` signature matches between definition (Task 4) and call sites (Tasks 6, 8, 9). `theme.value` getter shape matches the auth store pattern referenced in Task 4.