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

29 KiB
Raw Blame History

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: colorscolors.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.

{
  "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 fg to 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-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
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:

<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-primary is 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:

  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
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 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.