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.
This commit is contained in:
@@ -0,0 +1,244 @@
|
|||||||
|
# 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: 5–10 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.
|
||||||
Reference in New Issue
Block a user