Spec table's "Dark (current)" column now matches the actual tokens.json values. Added Tailwind text-action-fg alias as the mechanism for the non-flipping --fs-on-action token. Updated audit notes to use the real Tailwind class (text-text-primary) rather than the raw token (text-fs-parchment).
11 KiB
M7 #362 — Web SPA Theme Toggle (Dark / Light / System)
Status: Design Milestone: M7 (v1.0 web-first sweep) Issue: Fable #362 Scope: Web SPA only. Flutter mobile mirror is out of scope (separate task in Tier 1 catch-up).
Goal
Let a user choose Dark, Light, or follow-system theme for the Minstrel web SPA. Persist the choice across sessions. Apply it before first paint to avoid FOUC.
Out of scope
- Per-page or per-section theme overrides.
- Multi-color brand palettes beyond the dark + light pair.
- Time-of-day automatic switching independent of the OS preference.
- Cross-tab live sync (settings change in tab A doesn't propagate to tab B). Documented as a known limitation.
- Flutter client mirror.
- Animated transitions between modes (instant flip).
Architecture
Token strategy
Existing system: design tokens defined in web/src/lib/styles/tokens.json, generated to web/src/lib/styles/tokens.generated.css by web/scripts/tokens-to-css.js, and consumed by Tailwind via var(--fs-*) references in web/tailwind.config.js.
Theme is implemented by emitting two blocks of custom-property values in the generated CSS:
:root {
/* Dark palette (default) + flat tokens used in both modes */
--fs-obsidian: #0E1013;
...
--fs-moss: #5A7563;
--fs-on-action: #E8E2D2;
}
[data-theme="light"] {
/* Light overrides only — flat tokens are NOT repeated */
--fs-obsidian: #F8F5EE;
--fs-iron: #ECE6D5;
...
}
Tailwind config is not changed. The var(--fs-*) indirection means every utility class (bg-fs-iron, text-fs-parchment, etc.) automatically swaps when the [data-theme="light"] selector matches.
The active theme is selected by setting data-theme="light" (or absent / "dark") on <html>.
tokens.json schema change
Current shape (flat):
{
"colors": {
"obsidian": "#0E1013",
"iron": "#14171A",
...
}
}
New shape (dark values are the current tokens.json values, preserved):
{
"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"
}
}
}
flat tokens are emitted once in :root only. They do not flip.
Generator change
web/scripts/tokens-to-css.js is updated to:
- Walk
colors.dark+colors.flat, emit a:root { ... }block with--fs-<name>: <value>;lines. - Walk
colors.light, emit a[data-theme="light"] { ... }block. - Preserve existing non-color sections of
tokens.json(typography, spacing, etc.) — those continue to emit into:rootonly.
--fs-on-action token
A new flat token, value #E8E4D8 (frozen at the dark-mode parchment value). Used as the label color on action buttons whose backgrounds (--fs-moss, --fs-bronze, --fs-oxblood) do not flip. Without this, action button text would invert in light mode and lose contrast against the mid-tone earth backgrounds.
Tailwind exposes it as text-action-fg via a new entry in tailwind.config.js:
action: {
primary: 'var(--fs-moss)',
secondary: 'var(--fs-bronze)',
destructive: 'var(--fs-oxblood)',
fg: 'var(--fs-on-action)' // new
}
The audit step replaces text-text-primary with text-action-fg on every site where it pairs with bg-action-primary / bg-action-secondary / bg-action-destructive.
Theme store
New file: web/src/lib/stores/theme.svelte.ts.
Exports a small rune-state module:
type ThemePreference = 'dark' | 'light' | 'system';
type ResolvedTheme = 'dark' | 'light';
export const theme: { value: ThemePreference }; // user preference
export const resolvedTheme: { value: ResolvedTheme }; // what's actually applied
export function setTheme(pref: ThemePreference): void;
Implementation:
- On module load, read
localStorage['minstrel:theme'](default:'system'). - If
'system', resolve viamatchMedia('(prefers-color-scheme: light)')and subscribe tochangeevents to keepresolvedThemein sync. setTheme(pref)writes localStorage, updatestheme.value, recomputesresolvedTheme.value, and setsdocument.documentElement.dataset.theme = resolvedTheme.value.
FOUC prevention
web/src/app.html gains an inline script in <head>, before any CSS link:
<script>
(function () {
try {
var t = localStorage.getItem('minstrel:theme') || 'system';
var resolved = t === 'system'
? (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
: t;
document.documentElement.setAttribute('data-theme', resolved);
} catch (e) { /* localStorage unavailable — accept default dark */ }
})();
</script>
This runs synchronously before paint, so the correct theme is applied on first render. SSR/prerender paths see no theme attribute server-side; the script applies it client-side before paint.
Settings UI
web/src/routes/settings/+page.svelte gains an "Appearance" card at the top:
┌─ Appearance ─────────────────────┐
│ Theme: │
│ ( ) Dark (•) Light ( ) System │
│ │
│ System follows your device's │
│ light/dark preference. │
└──────────────────────────────────┘
Implemented as three radio inputs (or a segmented control component if one already exists in the project — implementer to check). Selection writes through setTheme().
Light palette spec
Full token map with values:
| Token | Dark (current) | Light (new) | Role |
|---|---|---|---|
--fs-obsidian |
#14171A |
#F8F5EE |
page bg |
--fs-iron |
#1E2228 |
#ECE6D5 |
card/surface bg |
--fs-slate |
#2C313A |
#DCD3BD |
hover surface |
--fs-pewter |
#3F4651 |
#B8AE94 |
borders |
--fs-parchment |
#E8E4D8 |
#14171A |
primary text |
--fs-vellum |
#C2BFB4 |
#2C313A |
secondary text |
--fs-ash |
#9C9A92 |
#5C6068 |
muted text |
--fs-moss |
#4A5D3F |
(unchanged) | secondary action bg |
--fs-bronze |
#8B7355 |
(unchanged) | warning action bg |
--fs-oxblood |
#6B2118 |
(unchanged) | destructive action bg |
--fs-warning |
#8B6F1E |
(unchanged) | semantic marker |
--fs-error |
#C04A1F |
(unchanged) | semantic marker |
--fs-info |
#3D5A6E |
(unchanged) | semantic marker |
--fs-accent |
#4A6B5C |
(unchanged) | brand accent |
--fs-on-action |
#E8E4D8 (new) |
(unchanged) | action button label |
Design rationale: light palette is "parchment / aged paper" — warm cream surfaces, dark text. Reads as an old book opened on a daylit desk; aligns with the FabledSword mythic register and preserves the bookish tone in light mode. Action and semantic colors are mid-tone earth/jewel hues that read on both light and dark backgrounds.
Files touched
Modify:
web/src/lib/styles/tokens.json— restructurecolorsintodark/light/flatsub-maps; addon-actiontoflat.web/scripts/tokens-to-css.js— emit dual:root+[data-theme="light"]blocks for colors; preserve other token sections in:root.web/tailwind.config.js— addcolors.action.fg = 'var(--fs-on-action)'entry.web/src/app.html— add inline FOUC script in<head>; remove the staticclass="dark"on<html>(theme is now driven bydata-theme).web/src/routes/settings/+page.svelte— add Appearance card with 3-way theme selector.- Action button label class audit — find every site using
text-text-primarypaired withbg-action-primary/bg-action-secondary/bg-action-destructiveand replacetext-text-primarywithtext-action-fg. Verified count via grep: ~25 sites.
Create:
web/src/lib/stores/theme.svelte.ts— theme preference + resolved-theme rune store with matchMedia listener and localStorage persistence.
Generated (do not hand-edit):
web/src/lib/styles/tokens.generated.css— regenerated bynpm run tokens.
Edge cases
- Action button text contrast — addressed by
--fs-on-actionnon-flipping token. Audit during implementation. - Cover-art images — external content, doesn't theme. Eyeball the missing-cover placeholder/glyph fallback in light mode.
- Focus rings — grep for
ring-offset-fs-*; if any uses a flipping token, switch to a non-flipping equivalent orring-offset-background. - Cross-tab sync — out of scope. Theme change in tab A doesn't update tab B until reload. Documented limitation.
- SSR — server doesn't know preference; inline head script applies it client-side before paint.
- Error pages — SvelteKit
+error.svelteand framework error fallback inheritdata-themeattribute. No special handling. - localStorage unavailable — try/catch around localStorage read; falls back to default
system.
Testing
Per project memory rule (no in-task tests; CI verifies), implementation tasks do not run tests. Plan does include the following test files for CI to execute:
- Token generator unit test (
web/scripts/tokens-to-css.test.js) — feed sample tokens.json withdark/light/flat, assert emitted CSS contains:rootblock with dark + flat values, and[data-theme="light"]block with light values only. - Theme store test (
web/src/lib/stores/theme.svelte.test.ts) — mockmatchMediaandlocalStorage. Verify (a) default issystemresolving via matchMedia, (b)setTheme('light')persists + updates resolved, (c) matchMediachangeevent updates resolvedTheme when insystemmode. - Settings Appearance card test (
web/src/routes/settings/Appearance.test.ts) — render, click each segment, assert localStorage write +data-themeattribute on<html>.
Manual verification checklist (pre-merge eyeball pass): home, library, search, playlists list, playlist detail, queue, history, settings, admin (users / library / quarantine / lidarr) — all in both modes.
Implementation order (informs plan)
- Restructure
tokens.json(no behavior change yet — generator still emits one block). - Update generator to emit dual blocks.
- Regenerate
tokens.generated.css. Verify no visual regression in dark mode. - Add
--fs-on-actionflat token. Audit + swap action button label classes. - Create
theme.svelte.tsstore. - Add FOUC script to
app.html. - Add Appearance card to Settings page, wire to store.
- Manual eyeball pass in both modes; fix any contrast/border surprises.