# 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 ``; an inline `` 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 ``; remove static `class="dark"` on ``. - `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