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.
29 KiB
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; addon-actionflat 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— addcolors.action.fg = 'var(--fs-on-action)'.web/src/app.html— inline FOUC script in<head>; remove staticclass="dark"on<html>.web/src/routes/settings/+page.svelte— add Appearance card.- ~25 component/route files — replace
text-text-primarywithtext-action-fgon 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.jsoncolors 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.
{
"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:
#!/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
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:
/* 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
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
fgto the action color group
Edit web/tailwind.config.js. In the action block under theme.extend.colors, add the fg entry:
action: {
primary: 'var(--fs-moss)',
secondary: 'var(--fs-bronze)',
destructive: 'var(--fs-oxblood)',
fg: 'var(--fs-on-action)'
},
The full file after edit:
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
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-destructiveelement also carriestext-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
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-primarywithtext-action-fgat 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:
<button class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-text-primary">
after:
<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:
'bg-action-secondary text-text-primary'
after:
'bg-action-secondary text-action-fg'
- Step 3: Re-run the audit grep to confirm zero hits
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-primaryis still legitimately used on non-action surfaces
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
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:
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
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:
<!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:
<html lang="en" class="dark">→<html lang="en">(no static class).- Inline
<script>block added immediately after<title>— must run synchronously before any CSS link sodata-themeis set before paint.
- Step 2: Commit
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:
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:
<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
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:
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:
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
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:
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
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:
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
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 tokensregeneratestokens.generated.csswith:root(dark + flat) and[data-theme="light"]blocks.<html>has no staticclass="dark";data-themeis 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) usetext-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-actiontoken + 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 7–9 ✓
- 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.