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.
This commit is contained in:
@@ -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 `<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 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.
|
||||||
Reference in New Issue
Block a user