# 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: ```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 ``. ### tokens.json schema change Current shape (flat): ```json { "colors": { "obsidian": "#0E1013", "iron": "#14171A", ... } } ``` New shape (dark values are the current `tokens.json` values, preserved): ```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" } } } ``` `flat` tokens are emitted once in `:root` only. They do not flip. ### Generator change `web/scripts/tokens-to-css.js` is updated to: 1. Walk `colors.dark` + `colors.flat`, emit a `:root { ... }` block with `--fs-: ;` lines. 2. Walk `colors.light`, emit a `[data-theme="light"] { ... }` block. 3. Preserve existing non-color sections of `tokens.json` (typography, spacing, etc.) — those continue to emit into `:root` only. ### `--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`: ```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: ```ts 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 via `matchMedia('(prefers-color-scheme: light)')` and subscribe to `change` events to keep `resolvedTheme` in sync. - `setTheme(pref)` writes localStorage, updates `theme.value`, recomputes `resolvedTheme.value`, and sets `document.documentElement.dataset.theme = resolvedTheme.value`. ### FOUC prevention `web/src/app.html` gains an inline script in ``, **before** any CSS link: ```html ``` 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:** 1. `web/src/lib/styles/tokens.json` — restructure `colors` into `dark`/`light`/`flat` sub-maps; add `on-action` to `flat`. 2. `web/scripts/tokens-to-css.js` — emit dual `:root` + `[data-theme="light"]` blocks for colors; preserve other token sections in `:root`. 3. `web/tailwind.config.js` — add `colors.action.fg = 'var(--fs-on-action)'` entry. 4. `web/src/app.html` — add inline FOUC script in ``; remove the static `class="dark"` on `` (theme is now driven by `data-theme`). 5. `web/src/routes/settings/+page.svelte` — add Appearance card with 3-way theme selector. 6. Action button label class audit — find every site using `text-text-primary` paired with `bg-action-primary` / `bg-action-secondary` / `bg-action-destructive` and replace `text-text-primary` with `text-action-fg`. Verified count via grep: ~25 sites. **Create:** 7. `web/src/lib/stores/theme.svelte.ts` — theme preference + resolved-theme rune store with matchMedia listener and localStorage persistence. **Generated (do not hand-edit):** 8. `web/src/lib/styles/tokens.generated.css` — regenerated by `npm run tokens`. ## Edge cases 1. **Action button text contrast** — addressed by `--fs-on-action` non-flipping token. Audit during implementation. 2. **Cover-art images** — external content, doesn't theme. Eyeball the missing-cover placeholder/glyph fallback in light mode. 3. **Focus rings** — grep for `ring-offset-fs-*`; if any uses a flipping token, switch to a non-flipping equivalent or `ring-offset-background`. 4. **Cross-tab sync** — out of scope. Theme change in tab A doesn't update tab B until reload. Documented limitation. 5. **SSR** — server doesn't know preference; inline head script applies it client-side before paint. 6. **Error pages** — SvelteKit `+error.svelte` and framework error fallback inherit `data-theme` attribute. No special handling. 7. **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 with `dark`/`light`/`flat`, assert emitted CSS contains `:root` block with dark + flat values, and `[data-theme="light"]` block with light values only. - **Theme store test** (`web/src/lib/stores/theme.svelte.test.ts`) — mock `matchMedia` and `localStorage`. Verify (a) default is `system` resolving via matchMedia, (b) `setTheme('light')` persists + updates resolved, (c) matchMedia `change` event updates resolvedTheme when in `system` mode. - **Settings Appearance card test** (`web/src/routes/settings/Appearance.test.ts`) — render, click each segment, assert localStorage write + `data-theme` attribute on ``. **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) 1. Restructure `tokens.json` (no behavior change yet — generator still emits one block). 2. Update generator to emit dual blocks. 3. Regenerate `tokens.generated.css`. Verify no visual regression in dark mode. 4. Add `--fs-on-action` flat token. Audit + swap action button label classes. 5. Create `theme.svelte.ts` store. 6. Add FOUC script to `app.html`. 7. Add Appearance card to Settings page, wire to store. 8. Manual eyeball pass in both modes; fix any contrast/border surprises.