a9fc0c6fe8
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.
861 lines
29 KiB
Markdown
861 lines
29 KiB
Markdown
# 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.
|