Files
minstrel/docs/superpowers/specs/2026-05-03-m7-362-theme-toggle-design.md
T
bvandeusen 1d6a71a825 docs(m7-362): correct dark hex values + Tailwind alias
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).
2026-05-03 13:34:40 -04:00

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:

  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 #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 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:

<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:

  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 <head>; remove the static class="dark" on <html> (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:

  1. web/src/lib/stores/theme.svelte.ts — theme preference + resolved-theme rune store with matchMedia listener and localStorage persistence.

Generated (do not hand-edit):

  1. 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 <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.