Files
minstrel/docs/superpowers/specs/2026-05-03-m7-362-theme-toggle-design.md
T
bvandeusen 1dcce4e304 docs(m7-362): theme toggle design spec
Web SPA dark/light/system theme. Token strategy: dual :root +
[data-theme="light"] blocks in tokens.generated.css; Tailwind
unchanged. Light palette is parchment/aged-paper. Adds
non-flipping --fs-on-action for action button labels. FOUC
prevented via inline app.html head script.
2026-05-03 13:31:18 -04:00

245 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<html>`.
### tokens.json schema change
Current shape (flat):
```json
{
"colors": {
"obsidian": "#0E1013",
"iron": "#14171A",
...
}
}
```
New shape:
```json
{
"colors": {
"dark": {
"obsidian": "#0E1013",
"iron": "#14171A",
"slate": "#1C1F23",
"pewter": "#2C313A",
"parchment": "#E8E2D2",
"vellum": "#C9C2AE",
"ash": "#8A8474"
},
"light": {
"obsidian": "#F8F5EE",
"iron": "#ECE6D5",
"slate": "#DCD3BD",
"pewter": "#B8AE94",
"parchment": "#14171A",
"vellum": "#2C313A",
"ash": "#5C6068"
},
"flat": {
"moss": "#5A7563",
"bronze": "#8C6A3F",
"oxblood": "#7A2E2E",
"warning": "#C99A4A",
"error": "#A04848",
"info": "#5A7A8A",
"accent": "#4A6B5C",
"on-action": "#E8E2D2"
}
}
}
```
`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-<name>: <value>;` 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 `#E8E2D2` (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 utility: `text-fs-on-action`.
### 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 `<head>`, **before** any CSS link:
```html
<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` | `#0E1013` | `#F8F5EE` | page bg |
| `--fs-iron` | `#14171A` | `#ECE6D5` | card/surface bg |
| `--fs-slate` | `#1C1F23` | `#DCD3BD` | hover surface |
| `--fs-pewter` | `#2C313A` | `#B8AE94` | borders |
| `--fs-parchment` | `#E8E2D2` | `#14171A` | primary text |
| `--fs-vellum` | `#C9C2AE` | `#2C313A` | secondary text |
| `--fs-ash` | `#8A8474` | `#5C6068` | muted text |
| `--fs-moss` | `#5A7563` | *(unchanged)* | secondary action bg |
| `--fs-bronze` | `#8C6A3F` | *(unchanged)* | warning action bg |
| `--fs-oxblood` | `#7A2E2E` | *(unchanged)* | destructive action bg |
| `--fs-warning` | `#C99A4A` | *(unchanged)* | semantic marker |
| `--fs-error` | `#A04848` | *(unchanged)* | semantic marker |
| `--fs-info` | `#5A7A8A` | *(unchanged)* | semantic marker |
| `--fs-accent` | `#4A6B5C` | *(unchanged)* | brand accent |
| `--fs-on-action` | `#E8E2D2` | *(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/src/app.html` — add inline FOUC script in `<head>`.
4. `web/src/routes/settings/+page.svelte` — add Appearance card with 3-way theme selector.
5. Action button label class audit — find every site using `text-fs-parchment` on a `bg-fs-moss` / `bg-fs-bronze` / `bg-fs-oxblood` button and replace with `text-fs-on-action`. Expected count: 510 sites.
**Create:**
6. `web/src/lib/stores/theme.svelte.ts` — theme preference + resolved-theme rune store with matchMedia listener and localStorage persistence.
**Generated (do not hand-edit):**
7. `web/src/lib/styles/tokens.generated.css` — regenerated by `npm run tokens` (or whatever script invokes `tokens-to-css.js`).
## 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 `<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)
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.