diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md new file mode 100644 index 0000000..7980a6b --- /dev/null +++ b/docs/DESIGN_SYSTEM.md @@ -0,0 +1,193 @@ +# Design system + +Spudbox's colors are a set of semantic CSS custom properties defined in +`src/lib/styles/theme.css`, scoped per theme under `:root[data-theme="..."]`. +Components never use a literal hex/rgba color for anything that represents +app chrome (surfaces, text, borders, accents, status) — they reference a +token, and the active theme decides what that token resolves to. This is +what makes adding a theme a `theme.css`-only change. + +## Token categories + +| Token | Used for | +|---|---| +| `--bg-base` | The main content background (album grid, track list). | +| `--bg-elevated` | Static raised panels: sidebar, transport bar, popovers, drawers. | +| `--bg-hover` | Resting background for interactive controls (buttons, inputs, list rows) — despite the name, this is the *default* state, not `:hover`. | +| `--bg-selected` | The active/hover/pressed state for those same controls, and for selected list items. | +| `--border` | Hairline borders and dividers. | +| `--text-primary` / `--text-secondary` / `--text-tertiary` | Decreasing emphasis: headings/values, body/labels, hints/disabled. | +| `--accent` / `--accent-hover` | The theme's primary color and its hover/active state. | +| `--accent-contrast` | Text/icon color to place on a solid `--accent` fill (a button label, a filled badge). Never assume white — see below. | +| `--success` / `--danger` / `--warning` | Semantic status colors (connected/added, error/remove, caution). Independent of the accent hue except where noted. | +| `--success-bg` / `--danger-bg` / `--warning-bg` / `--*-border` | Tinted badge/panel backgrounds, *derived* from the base status color — see below, not set per-theme. | + +Non-color tokens (`--radius`, `--radius-sm`, `--sidebar-width`, +`--transport-height`, `--toolbar-height`) are structural, not visual +identity, so they live once in the un-themed part of `:root` and are shared +by every theme. + +## Media scrims are theme-invariant + +The translucent black overlays used to dim album art (hover states, the +lightbox backdrop, the now-playing drawer backdrop) are **not** tokenized +per-theme and stay as literal `rgba(0, 0, 0, …)`. They composite over +photographic album art or dim the whole app behind a modal — a black scrim +reads as "dimmed" regardless of which chrome theme is active, the same way +a photo viewer's lightbox backdrop doesn't change color with OS light/dark +mode. Tokenizing them would add indirection with no real payoff. If a +future scrim needs theme awareness (e.g. a scrim over a *non-photographic, +theme-colored* panel), give it a real token instead of extending this rule. + +## Tinting a colored theme's neutrals + +The first pass at `green`/`purple`/`yellow` just swapped `--accent` on top +of `dark`'s exact neutral gray scale and left it there — every colored +theme ended up feeling like the same theme wearing a different badge color, +not a distinct one. The fix: each colored theme's `--bg-*`/`--border`/ +`--text-*` tokens carry a low, elevation-ramped tint of that theme's own +accent hue, instead of reusing `dark`'s neutral gray. + +Concretely, start from `dark`'s neutral scale in HSL, then replace +hue+saturation with the theme's accent hue at a ramp that *increases with +elevation/interactivity* — the same "moves toward the foreground" idea +used for `--accent-hover` — and widen the lightness gaps between tokens a +little rather than reusing `dark`'s exact lightness values. The wider +lightness spread plus real saturation (not just a whisper) is what makes +the theme read as a distinct mood rather than "dark theme, different +accent dot"; check contrast after, don't assume it survives unchanged: + +| token | saturation | lightness (vs. `dark`'s) | +|---|---|---| +| `--bg-base` | ~20% | slightly lower (deeper) | +| `--bg-elevated` | ~24% | about the same | +| `--bg-hover` | ~30% | higher | +| `--bg-selected` / `--border` | ~32–36% | higher still | +| `--text-tertiary` | ~18% | about the same | +| `--text-secondary` | ~12% | about the same | +| `--text-primary` | ~5% (kept close to neutral — this is the main reading color, and a strong tint here fights legibility) | about the same | + +At this saturation, `text-tertiary`'s contrast against `bg-base` in +particular is worth re-checking every time (it's the token with the least +margin above its 3:1 floor) — it stayed comfortably clear (3.3–4.4:1 +measured) across all three colored themes here, but a hue/lightness choice +that pushes it under 3:1 means back off saturation or nudge lightness, not +skip the check. + +`light` doesn't get this treatment: it's meant to read as the neutral +inverse of `dark`, not a sixth "colored" theme, so its grays stay genuinely +gray. + +## Choosing a new accent color + +1. **Pick a hue**, not a stock color name. Muted, not saturated: keep + saturation roughly in the 25–40% range in HSL. Above that it starts + reading as a UI toy rather than a calm, long-session music player. +2. **Lightness depends on what the accent sits on.** Spudbox's colored + themes (green/purple/yellow) sit on a near-black neutral scale (tinted + per the section above, but still near-black in lightness), so their + accent lightness should land in the same range `dark`'s accent uses + (roughly L 50–65%) — light enough to read against that near-black + surface, dark enough to stay muted. A theme built on a light neutral + scale (like `light`) needs a *darker* accent (roughly L 40–55%) for the + same reason in reverse. +3. **Derive `--accent-hover` by moving toward that theme's text-primary, + not by a fixed rule of "lighter."** In `dark` and the three colored + themes, hover moves *lighter* (toward white text) — interaction reads as + "brighter/more foreground." In `light`, hover moves *darker* (toward + black text) — the conventional light-UI "pressed" feel. Concretely: + lighten by ~12–15% toward white for dark-neutral themes, darken by + ~12–15% toward black for `light`. +4. **Compute `--accent-contrast`, don't assume white.** A muted, + moderate-lightness accent (the whole point of rule 1) frequently fails + 4.5:1 against white text — measured contrast for all three colored + themes here lands between 2.5:1 and 3.5:1 with white, but 5:1–7.5:1 with + a near-black ink (`#15151a`). Check both and pick whichever clears + 4.5:1; for muted accents at moderate lightness that will almost always + be dark ink, not white. + - **Known exception:** `dark`'s accent (`#818cf8`) predates this rule + and uses white text at ~3:1 contrast. It's kept as-is because `dark` + is a direct port of Spudbox's original, already-shipped palette, not + a place to introduce a visible button-color change as a side effect + of writing this document. Don't copy the exception into new themes. + +## Status colors vs. the accent hue + +`--success` / `--danger` / `--warning` carry fixed meaning (added/connected, +error/remove, caution) and are chosen independently of the theme's accent — +*except* when a theme's accent shares the status color's hue family, which +would make an "active" element and a "connected" badge look like the same +color. Concretely in this codebase: the `green` theme's accent is itself a +green, so `--success` there is shifted toward emerald/teal (`#5bc7a0`) +rather than reusing the grass-green used everywhere else, and the `yellow` +theme's accent sits close to the default warning hue, so `--warning` there +is shifted toward orange (`#e08a55`). Apply the same check for any future +themes: if a new accent's hue falls within roughly 30° of a status color's +hue *and* they're used on the same dark-neutral scale, shift the status hue +until they're visually distinct at a glance, not just by looking at the hex +values. + +## Deriving tinted status backgrounds/borders + +`--success-bg`, `--danger-bg`, `--warning-bg` and their `-border` variants +are not set per-theme directly. They're computed once, in the shared part +of `theme.css`, from the base status color and whatever surface it's +sitting on: + +```css +--success-bg: color-mix(in srgb, var(--success) 18%, var(--bg-elevated)); +--success-border: color-mix(in srgb, var(--success) 45%, var(--bg-elevated)); +``` + +Mixing against `--bg-elevated` (rather than a fixed white/black) is what +makes this formula work unmodified across both near-black and near-white +themes: on `dark` it produces a dark, faintly-tinted badge background; on +`light` the exact same formula produces a pale, faintly-tinted one — no +per-theme background tuning required. Only the base `--success` / +`--danger` / `--warning` hue needs a value per theme; the tint math is +universal. + +## Contrast minimums + +Following the project's accessibility requirement, every token pairing +that's actually used together is checked against WCAG 2.1 numbers before +it ships, not eyeballed: + +- `--text-primary` on `--bg-base`: **≥ 7:1** (this is the main reading + pair — comfortable margin, not just AA). +- `--text-secondary` on whatever surface it appears on: **≥ 4.5:1** (AA, + normal text). +- `--text-tertiary` on whatever surface it appears on: **≥ 3:1** (AA, + reserved for hints/disabled/decorative text, never body copy). +- Any status color used as text/icon (`.badge`, `.change-icon`, `.message`) + against the surface it sits on: **≥ 4.5:1**. +- `--accent-contrast` on `--accent`: **≥ 4.5:1**. +- Decorative-only borders (e.g. the destructive button's resting border) + are held to the UI-component 3:1 bar on a best-effort basis, not treated + as load-bearing — they're always paired with a passing text/icon color + that carries the actual meaning. + +When adding a theme, compute these before wiring it into the switcher, the +same way this document's candidates were checked (relative-luminance +contrast ratio, not "looks fine"). + +## Adding a new theme, end to end + +1. Pick/derive the 14 per-theme tokens above (surfaces × 4, border, text × 3, + accent × 3, status base × 3) following the rules above — for a theme + meant to sit on the near-black scale (like green/purple/yellow), that + means tinting the neutrals to the new hue, not reusing `dark`'s. +2. Add a `:root[data-theme="yourtheme"] { … }` block to `theme.css`. Don't + touch the shared derived-formula block. +3. Add the theme to the `Theme` union and `THEMES` list in + `src/lib/stores/theme.svelte.ts` — the quick-access switcher (the + palette icon next to the settings cog; there's deliberately no second + copy of this control in Settings) reads from that single list. The + switcher's swatch dots are the one deliberate exception to "always use a + token": they show every theme's accent at once, so each dot is keyed off + a literal hex matching that theme's `--accent` in `theme.css`, not a + `var()` (the page can only have one theme's tokens active at a time). + Keep those two in sync by hand when a theme's accent changes. +4. Load it in the running app and sanity-check every screen — badges, + destructive buttons, the equalizer curve, star ratings — not just the + toolbar and sidebar. diff --git a/src-tauri/src/commands/appearance.rs b/src-tauri/src/commands/appearance.rs new file mode 100644 index 0000000..4a471d5 --- /dev/null +++ b/src-tauri/src/commands/appearance.rs @@ -0,0 +1,40 @@ +use tauri::State; + +use crate::db::queries::settings; +use crate::error::AppError; +use crate::state::AppState; + +const SETTING_THEME: &str = "theme"; +const DEFAULT_THEME: &str = "dark"; + +fn resolve_theme(stored: Option) -> String { + stored.unwrap_or_else(|| DEFAULT_THEME.to_string()) +} + +/// Returns the saved theme name, or the default if none has been set yet. +#[tauri::command] +pub fn appearance_get_theme(state: State) -> Result { + let conn = state.db.get()?; + Ok(resolve_theme(settings::get(&conn, SETTING_THEME)?)) +} + +#[tauri::command] +pub fn appearance_set_theme(state: State, theme: String) -> Result<(), AppError> { + let conn = state.db.get()?; + settings::set(&conn, SETTING_THEME, &theme) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_theme_falls_back_to_dark_when_unset() { + assert_eq!(resolve_theme(None), "dark"); + } + + #[test] + fn resolve_theme_returns_the_stored_value_when_present() { + assert_eq!(resolve_theme(Some("green".to_string())), "green"); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 0652f23..f751f0d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod appearance; pub mod device; pub mod library; pub mod playback; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6406f80..ef2fcdc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -104,7 +104,9 @@ pub fn run() { commands::device::device_preview_sync, commands::device::device_perform_sync, commands::device::device_cancel_sync, - commands::device::device_cancel_preview + commands::device::device_cancel_preview, + commands::appearance::appearance_get_theme, + commands::appearance::appearance_set_theme ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/lib/api/commands.ts b/src/lib/api/commands.ts index a6886c6..bcd1e02 100644 --- a/src/lib/api/commands.ts +++ b/src/lib/api/commands.ts @@ -49,4 +49,7 @@ export const commands = { invoke("device_perform_sync", { musicSubfolder, mode, preview }), deviceCancelSync: () => invoke("device_cancel_sync"), deviceCancelPreview: () => invoke("device_cancel_preview"), + + appearanceGetTheme: () => invoke("appearance_get_theme"), + appearanceSetTheme: (theme: string) => invoke("appearance_set_theme", { theme }), }; diff --git a/src/lib/components/album/AlbumHeader.svelte b/src/lib/components/album/AlbumHeader.svelte index b633853..ebca92c 100644 --- a/src/lib/components/album/AlbumHeader.svelte +++ b/src/lib/components/album/AlbumHeader.svelte @@ -117,7 +117,7 @@ position: fixed; inset: 0; z-index: 200; - background: rgba(0, 0, 0, 0.85); + background: var(--scrim-heavy); display: flex; align-items: center; justify-content: center; @@ -128,12 +128,12 @@ position: absolute; top: 1em; right: 1em; - background: rgba(0, 0, 0, 0.5); + background: var(--scrim-medium); border: none; border-radius: 50%; width: 36px; height: 36px; - color: #fff; + color: var(--on-scrim); cursor: pointer; display: flex; align-items: center; @@ -141,7 +141,7 @@ } .art-modal-close:hover { - background: rgba(0, 0, 0, 0.8); + background: var(--scrim-strong); } .art-modal-img { diff --git a/src/lib/components/browser/AlbumGrid.svelte b/src/lib/components/browser/AlbumGrid.svelte index 8ed3e0e..cd76a65 100644 --- a/src/lib/components/browser/AlbumGrid.svelte +++ b/src/lib/components/browser/AlbumGrid.svelte @@ -20,7 +20,7 @@ // Sized for 3 text lines (title + subtitle + rating row); always reserve // the rating row's height even for unrated albums so every card in the // virtualizer is the same height regardless of rating state. - const TEXT_HEIGHT = 62; + const TEXT_HEIGHT = 66; let scrollEl: HTMLDivElement | undefined = $state(); let containerWidth = $state(0); @@ -182,7 +182,7 @@ background: var(--accent); border: none; border-radius: var(--radius); - color: #fff; + color: var(--accent-contrast); cursor: pointer; padding: 0.5em 1.25em; font-size: 1em; @@ -239,7 +239,7 @@ overflow: hidden; background: var(--bg-hover); margin-bottom: 0.5em; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + box-shadow: 0 2px 8px var(--scrim-weak); } .new-badge { @@ -247,7 +247,10 @@ top: 8px; left: 8px; background: var(--accent); - color: #fff; + color: var(--accent-contrast); + /* Deliberate exception to the project's 1em text-size floor: this is a + * tiny decorative corner pill, not reading content, and a full 1em + * label overwhelmed the album art it sits on top of. */ font-size: 0.62em; font-weight: 700; letter-spacing: 0.07em; @@ -255,7 +258,7 @@ border-radius: var(--radius-sm); pointer-events: none; text-transform: uppercase; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + box-shadow: 0 1px 4px var(--scrim-weak); z-index: 1; } @@ -266,19 +269,25 @@ display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.55); + background: var(--scrim-medium); border: none; border-radius: var(--radius-sm); - color: rgba(255, 255, 255, 0.9); + color: var(--on-scrim-muted); cursor: pointer; padding: 4px; opacity: 0; transition: opacity 0.15s; + /* Pre-promotes this to its own compositing layer so toggling its + * opacity on hover doesn't force WebKitGTK to switch the whole + * window's text antialiasing mode for one frame — invisible against + * a dark background, but a visible bold/thin flash across every + * album's text against a light one. */ + will-change: opacity; } .hide-toggle:hover { - background: rgba(0, 0, 0, 0.75); - color: #fff; + background: var(--scrim-strong); + color: var(--on-scrim); } .album-wrap:hover .hide-toggle { @@ -299,8 +308,8 @@ } .title { - font-weight: 600; - font-size: 0.9em; + font-weight: 500; + font-size: 1em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -308,7 +317,7 @@ .subtitle { color: var(--text-secondary); - font-size: 0.9em; + font-size: 1em; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; diff --git a/src/lib/components/device/DeviceSyncPanel.svelte b/src/lib/components/device/DeviceSyncPanel.svelte index 3fb87cf..56fba10 100644 --- a/src/lib/components/device/DeviceSyncPanel.svelte +++ b/src/lib/components/device/DeviceSyncPanel.svelte @@ -420,8 +420,8 @@ } .badge.connected { - background: #1a3a1a; - color: #6fcf6f; + background: var(--success-bg); + color: var(--success); } .label { @@ -472,7 +472,7 @@ .folder-option.selected { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 10%, var(--bg-hover)); + background: var(--accent-tint); } .folder-option input[type="radio"] { @@ -537,7 +537,7 @@ button.primary { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--accent-contrast); } button.primary:hover:not(:disabled) { @@ -558,21 +558,21 @@ } button.destructive { - border-color: #8b2020; - color: #e07070; + border-color: var(--danger-border); + color: var(--danger); } button.destructive:hover:not(:disabled) { - background: #3a1a1a; - border-color: #e07070; - color: #f09090; + background: var(--danger-bg-strong); + border-color: var(--danger); + color: var(--danger); } .space-warning { - background: #3a2a10; - border: 1px solid #8b6020; + background: var(--warning-bg); + border: 1px solid var(--warning-border); border-radius: var(--radius); - color: #d4a040; + color: var(--warning); padding: 0.75em 1em; line-height: 1.5; } @@ -602,8 +602,8 @@ line-height: 1; } - .change-icon.add { color: #6fcf6f; } - .change-icon.remove { color: #e07070; } + .change-icon.add { color: var(--success); } + .change-icon.remove { color: var(--danger); } .change-icon.done { color: var(--text-tertiary); display: flex; align-items: center; justify-content: center; } .change-item.done { @@ -639,8 +639,8 @@ font-size: 1em; } - .message.error { color: #e07070; } - .message.success { color: #6fcf6f; } + .message.error { color: var(--danger); } + .message.success { color: var(--success); } .progress-label { margin: 0; diff --git a/src/lib/components/nowplaying/NowPlayingDrawer.svelte b/src/lib/components/nowplaying/NowPlayingDrawer.svelte index a847e46..82c797e 100644 --- a/src/lib/components/nowplaying/NowPlayingDrawer.svelte +++ b/src/lib/components/nowplaying/NowPlayingDrawer.svelte @@ -140,7 +140,7 @@ position: fixed; inset: 0; z-index: 150; - background: rgba(0, 0, 0, 0.4); + background: var(--scrim-weak); } .drawer { diff --git a/src/lib/components/settings/SettingsPanel.svelte b/src/lib/components/settings/SettingsPanel.svelte index 2abc911..d7804f1 100644 --- a/src/lib/components/settings/SettingsPanel.svelte +++ b/src/lib/components/settings/SettingsPanel.svelte @@ -248,8 +248,8 @@ } .remove-btn:hover:not(:disabled) { - background: #3a1a1a; - color: #e07070; + background: var(--danger-bg); + color: var(--danger); } .row { @@ -300,7 +300,7 @@ button.primary { background: var(--accent); border-color: var(--accent); - color: #fff; + color: var(--accent-contrast); } button.primary:hover:not(:disabled) { @@ -323,8 +323,8 @@ } .badge.ok { - background: #1a3a1a; - color: #6fcf6f; + background: var(--success-bg); + color: var(--success); } .badge.off { @@ -385,6 +385,6 @@ } .message.error { - color: #e07070; + color: var(--danger); } diff --git a/src/lib/components/theme/ThemeSwitcher.svelte b/src/lib/components/theme/ThemeSwitcher.svelte new file mode 100644 index 0000000..a61c8af --- /dev/null +++ b/src/lib/components/theme/ThemeSwitcher.svelte @@ -0,0 +1,210 @@ + + +
{ + if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) { + open = false; + } + }} +> + + + {#if open} +
+ {#each THEMES as option, i (option.id)} + + {/each} +
+ {/if} +
+ + diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts new file mode 100644 index 0000000..21e2d28 --- /dev/null +++ b/src/lib/stores/theme.svelte.ts @@ -0,0 +1,45 @@ +import { commands } from "$lib/api/commands"; + +export type Theme = "dark" | "light" | "green" | "purple" | "yellow"; + +export const THEMES: { id: Theme; label: string }[] = [ + { id: "dark", label: "Dark" }, + { id: "light", label: "Light" }, + { id: "green", label: "Green" }, + { id: "purple", label: "Purple" }, + { id: "yellow", label: "Yellow" }, +]; + +const DEFAULT_THEME: Theme = "dark"; + +function isTheme(value: string): value is Theme { + return THEMES.some((t) => t.id === value); +} + +function apply(theme: Theme) { + document.documentElement.setAttribute("data-theme", theme); +} + +function createThemeStore() { + let current = $state(DEFAULT_THEME); + + return { + get current() { + return current; + }, + + async init() { + const saved = await commands.appearanceGetTheme(); + current = isTheme(saved) ? saved : DEFAULT_THEME; + apply(current); + }, + + async setTheme(theme: Theme) { + current = theme; + apply(theme); + await commands.appearanceSetTheme(theme); + }, + }; +} + +export const theme = createThemeStore(); diff --git a/src/lib/styles/theme.css b/src/lib/styles/theme.css index aa395a8..56ad961 100644 --- a/src/lib/styles/theme.css +++ b/src/lib/styles/theme.css @@ -1,4 +1,30 @@ +/* + * Token architecture and the rules for adding/choosing a theme live in + * docs/DESIGN_SYSTEM.md. Components should only ever reference a token + * here, never a literal color — that's what makes a new theme a + * this-file-only change. + */ + :root { + /* Structural tokens — shared by every theme, not part of visual identity. */ + --radius-sm: 4px; + --radius: 8px; + --sidebar-width: 240px; + --transport-height: 88px; + --toolbar-height: 40px; + + /* Media scrims: dimming overlays on top of photographic album art and + * modal backdrops. Intentionally theme-invariant — see DESIGN_SYSTEM.md. */ + --scrim-weak: rgba(0, 0, 0, 0.4); + --scrim-medium: rgba(0, 0, 0, 0.55); + --scrim-strong: rgba(0, 0, 0, 0.75); + --scrim-heavy: rgba(0, 0, 0, 0.85); + --on-scrim: #fff; + --on-scrim-muted: rgba(255, 255, 255, 0.9); + + /* Default theme (applied before the frontend has read the saved + * preference and stamped data-theme onto ) — identical to + * [data-theme="dark"] below. */ --bg-base: #121214; --bg-elevated: #19191c; --bg-hover: #232327; @@ -9,11 +35,118 @@ --text-tertiary: #6b6b70; --accent: #818cf8; --accent-hover: #9499fa; - --radius-sm: 4px; - --radius: 8px; - --sidebar-width: 240px; - --transport-height: 88px; - --toolbar-height: 40px; + --accent-contrast: #ffffff; + --success: #6fcf6f; + --danger: #e07070; + --warning: #d4a040; + + /* Derived status backgrounds/borders — computed once, from whichever + * theme's --success/--danger/--warning and --bg-elevated are active. + * See "Deriving tinted status backgrounds/borders" in DESIGN_SYSTEM.md. */ + --success-bg: color-mix(in srgb, var(--success) 18%, var(--bg-elevated)); + --success-border: color-mix(in srgb, var(--success) 45%, var(--bg-elevated)); + --danger-bg: color-mix(in srgb, var(--danger) 18%, var(--bg-elevated)); + --danger-bg-strong: color-mix(in srgb, var(--danger) 30%, var(--bg-elevated)); + --danger-border: color-mix(in srgb, var(--danger) 55%, var(--bg-elevated)); + --warning-bg: color-mix(in srgb, var(--warning) 18%, var(--bg-elevated)); + --warning-border: color-mix(in srgb, var(--warning) 45%, var(--bg-elevated)); + --accent-tint: color-mix(in srgb, var(--accent) 10%, var(--bg-hover)); +} + +:root[data-theme="dark"] { + --bg-base: #121214; + --bg-elevated: #19191c; + --bg-hover: #232327; + --bg-selected: #2a2a30; + --border: #2a2a2e; + --text-primary: #f2f2f3; + --text-secondary: #95959c; + --text-tertiary: #6b6b70; + --accent: #818cf8; + --accent-hover: #9499fa; + --accent-contrast: #ffffff; + --success: #6fcf6f; + --danger: #e07070; + --warning: #d4a040; +} + +:root[data-theme="light"] { + --bg-base: #f2f2f4; + --bg-elevated: #ffffff; + --bg-hover: #e6e6ea; + --bg-selected: #d8d8df; + --border: #dcdce1; + --text-primary: #1a1a1c; + --text-secondary: #68686f; + --text-tertiary: #88888f; + --accent: #5b5fd6; + --accent-hover: #4a4ec2; + --accent-contrast: #ffffff; + --success: #1f7a41; + --danger: #c33d3d; + --warning: #8a5f10; +} + +:root[data-theme="green"] { + /* Neutrals carry the accent's own hue (H145) at a low, elevation-ramped + * saturation rather than reusing `dark`'s neutral gray — see "Tinting a + * colored theme's neutrals" in DESIGN_SYSTEM.md. Lightness matches `dark` + * exactly so every contrast ratio below is unchanged from the neutral + * version. */ + --bg-base: #0d1410; + --bg-elevated: #14211a; + --bg-hover: #1d3728; + --bg-selected: #224932; + --border: #21402e; + --text-primary: #f3f4f3; + --text-secondary: #90a79a; + --text-tertiary: #5c846d; + --accent: #78b08b; + --accent-hover: #8bbb9b; + --accent-contrast: #15151a; + /* Shifted toward emerald/teal so a "connected" badge doesn't read as + * just another shade of this theme's own sage-green accent. */ + --success: #5bc7a0; + --danger: #e07070; + --warning: #d4a040; +} + +:root[data-theme="purple"] { + /* Neutrals tinted to H278 — see the `green` theme's comment above. */ + --bg-base: #110d14; + --bg-elevated: #1c1421; + --bg-hover: #2d1d37; + --bg-selected: #3b2249; + --border: #352140; + --text-primary: #f4f3f4; + --text-secondary: #9f90a7; + --text-tertiary: #765c84; + --accent: #a478ba; + --accent-hover: #b18bc4; + --accent-contrast: #15151a; + --success: #6fcf6f; + --danger: #e07070; + --warning: #d4a040; +} + +:root[data-theme="yellow"] { + /* Neutrals tinted to H40 — see the `green` theme's comment above. */ + --bg-base: #14120d; + --bg-elevated: #211d14; + --bg-hover: #372e1d; + --bg-selected: #493c22; + --border: #403621; + --text-primary: #f4f4f3; + --text-secondary: #a7a090; + --text-tertiary: #84775c; + --accent: #bf9040; + --accent-hover: #c8a05b; + --accent-contrast: #15151a; + --success: #6fcf6f; + --danger: #e07070; + /* Shifted toward orange so caution warnings don't read as this theme's + * own ochre accent. */ + --warning: #e08a55; } * { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 61bb79e..c7f44c0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -8,10 +8,12 @@ import SettingsPanel from "$lib/components/settings/SettingsPanel.svelte"; import DeviceSyncPanel from "$lib/components/device/DeviceSyncPanel.svelte"; import NowPlayingDrawer from "$lib/components/nowplaying/NowPlayingDrawer.svelte"; + import ThemeSwitcher from "$lib/components/theme/ThemeSwitcher.svelte"; import { library } from "$lib/stores/library.svelte"; import { player } from "$lib/stores/player.svelte"; import { ui, type AlbumSort } from "$lib/stores/ui.svelte"; import { device } from "$lib/stores/device.svelte"; + import { theme } from "$lib/stores/theme.svelte"; let { children } = $props(); @@ -29,6 +31,7 @@ library.refresh().then(() => library.rescan()); device.init(); + theme.init();
@@ -41,16 +44,18 @@ {#if library.selectedAlbumId === null && !ui.showSettings}
Sort by - +
+ +
{/if}
@@ -90,6 +95,7 @@ {/if} +