Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions docs/DESIGN_SYSTEM.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions src-tauri/src/commands/appearance.rs
Original file line number Diff line number Diff line change
@@ -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>) -> 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<AppState>) -> Result<String, AppError> {
let conn = state.db.get()?;
Ok(resolve_theme(settings::get(&conn, SETTING_THEME)?))
}

#[tauri::command]
pub fn appearance_set_theme(state: State<AppState>, 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");
}
}
1 change: 1 addition & 0 deletions src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod appearance;
pub mod device;
pub mod library;
pub mod playback;
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
3 changes: 3 additions & 0 deletions src/lib/api/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,7 @@ export const commands = {
invoke<DeviceSyncResult>("device_perform_sync", { musicSubfolder, mode, preview }),
deviceCancelSync: () => invoke<void>("device_cancel_sync"),
deviceCancelPreview: () => invoke<void>("device_cancel_preview"),

appearanceGetTheme: () => invoke<string>("appearance_get_theme"),
appearanceSetTheme: (theme: string) => invoke<void>("appearance_set_theme", { theme }),
};
8 changes: 4 additions & 4 deletions src/lib/components/album/AlbumHeader.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -128,20 +128,20 @@
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;
justify-content: center;
}

.art-modal-close:hover {
background: rgba(0, 0, 0, 0.8);
background: var(--scrim-strong);
}

.art-modal-img {
Expand Down
33 changes: 21 additions & 12 deletions src/lib/components/browser/AlbumGrid.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -239,23 +239,26 @@
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 {
position: absolute;
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;
padding: 3px 8px;
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;
}

Expand All @@ -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 {
Expand All @@ -299,16 +308,16 @@
}

.title {
font-weight: 600;
font-size: 0.9em;
font-weight: 500;
font-size: 1em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.subtitle {
color: var(--text-secondary);
font-size: 0.9em;
font-size: 1em;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
Expand Down
Loading
Loading