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