diff --git a/docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md b/docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md new file mode 100644 index 00000000..10623586 --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-m7-362-theme-toggle.md @@ -0,0 +1,860 @@ +# 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 +