UI System Design: Rymflux v2
UI System Design: Rymflux v2
Status
Draft v1 — 2026-07-31
Purpose
This document defines what the frontend looks like: the design tokens
(styles/tokens.css), the component library (src/lib/ui/ and its
compositions), the theme system, the wireframes for the v1 surfaces, and the
accessibility/i18n conventions that govern all of it. It is the prerequisite
called out in frontend-architecture.md
§16 item 3 — design tokens and ui/ primitives cannot be finalized until this
exists.
It is intentionally not about how the frontend is engineered. Runtime
model, repo layout, layer rules, the state layer, the IPC bridge, the shell and
room framework, navigation, error/loading/empty conventions, logging, and
testing are owned by frontend-architecture.md. This document builds on that
architecture; where this document references a mechanism (e.g. Room,
RoomContext, settings.svelte.ts, t()), it means the definition already
given in frontend-architecture.md and the API specs.
The guiding rule for every visual decision here:
The interface fades when you don’t need it; the content provides the color.
1. Design philosophy
| Principle | How it shows up |
|---|---|
| Content-first | Album art, artist photos, and metadata drive the visual hierarchy. UI chrome is muted and secondary. Grids of cover art carry the browse experience; chrome never competes with it. |
| Ambient color | The now-playing surfaces pull a dominant color from the current track’s cover art and subtly tint their background. The accent is teal/cyan — vibrant enough to guide interaction, calm enough for hours of listening. |
| Low friction | One click from any screen to start playing. Transport controls are always reachable via the persistent mini-player, with Space play/pause app-wide. |
| Room-aware | Each room declares a default theme so Music feels distinct from a future Audiobook or Podcast room. The user can override per-room or accept the global default. |
| Accessible by default | shadcn-svelte’s Bits UI primitives provide keyboard navigation, focus management, and ARIA attributes. Color token pairs meet WCAG AA. Semantics come first; styling second. |
Two principles inherited from the platform philosophy
(../../../../vision/rooms-explained/project-foundation.md) constrain what this
document may not do:
- Rooms own experiences. The UI design system provides the tokens and primitives; it does not define what an album grid, a queue, or a search result list must look like. Rooms compose those from the primitives. This document’s wireframes for the Music Room are the reference implementation — the pattern room authors copy — not a mandate on every room.
- Plugins provide capabilities, not UI. Plugins never ship styling or components. Artwork and metadata arrive as data; the frontend (rooms + primitives) renders them. This keeps plugin output renderable by any room.
2. Technology stack
| Layer | Choice | Rationale |
|---|---|---|
| Framework | SvelteKit 2 + Svelte 5 | SPA mode (adapter-static, ssr=false, index.html fallback). See frontend-architecture.md §1. |
| Component library | shadcn-svelte (Rhea style) | Accessible (Bits UI), themable, CSS-variable-driven. Provides Button, Input, Slider, Card, DropdownMenu, and the rest of the shadcn set as a token-backed base. |
| Styling | Tailwind CSS v4 | Utility-first, CSS-first config. shadcn binds semantic tokens via @theme inline in oklch. |
| Icons | Lucide (@lucide/svelte) |
Used by shadcn internally (chevrons, X) and by our icon registry for nav/transport affordances. |
| Typography | Inter + JetBrains Mono | Inter for UI, JetBrains Mono for diagnostics. Self-hosted via @fontsource (Tauri is a static/offline host — no Google Fonts CDN). |
| Animation | tw-animate-css |
Enter/leave utilities (animate-in/animate-out) for the Sheet, Menu, Popover, and Toast transitions; respects prefers-reduced-motion. |
| Ambient color | Rust-side dominant-color extraction, DB-cached | Decided. Runs once per unique cover art, cached in the library service, served to the frontend as a field on Track (§6). |
3. Design tokens — styles/tokens.css
src/lib/styles/tokens.css is the only place color, spacing, radius, and
typography values are defined (three-layer architecture, ADR-015). Components
never contain inline color or length
literals (frontend-architecture.md §15). Tokens are loaded by the root layout
alongside app.css.
3.1 Token architecture — three layers
flowchart LR
P["Layer 1 · Primitives<br/>--color-teal-500, --color-neutral-900 …<br/>(raw oklch palette, never referenced<br/>by components)"] --> S["Layer 2 · Semantic<br/>--primary, --background, --card,<br/>--muted, --border, --ring, --radius …<br/>(referenced by components)"]
S --> T["Layer 3 · Tailwind bindings<br/>@theme inline → bg-primary,<br/>text-muted-foreground, rounded-*"]
- Layer 1 — primitives: a raw oklch palette (
--color-teal-500,--color-neutral-*, per-theme hues). Never referenced directly by components; exists so semantic tokens can point at stable values and themes can restyle by overriding Layer 2 only. - Layer 2 — semantic tokens: the shadcn token set plus a small set of Rymflux additions (§3.3). Components reference only Layer 2. Themes override Layer 2 values.
- Layer 3 — Tailwind bindings:
@theme inlinemaps Layer 2 onto utilities (bg-primary,text-muted-foreground,border-border,rounded-lg), so Tailwind classes always resolve to the active theme.
3.2 Launch accent: teal
The default accent is a saturated teal: oklch(0.6 0.2 200).
Hue 200° = cyan-teal. Not blue (generic/corporate), not green (alert-like).
Chroma 0.2 = saturated enough to guide interaction, calm enough for hours of use.
Lightness 0.6 = readable on both dark and light backgrounds.
| Property | Blue-500 (rejected) | Teal (launch accent) |
|---|---|---|
| oklch | oklch(0.546 0.245 262.881) |
oklch(0.6 0.2 200) |
| Vibe | Corporate, generic | Fresh, audio-focused |
| Emotional association | Professional / tools | Sound / water / depth |
The teal is the single accent. Secondary emphasis comes from content (cover art, artwork), not from more colors.
3.3 Semantic token set
The standard shadcn tokens plus three Rymflux additions:
--ambient-color— the dominant-color channel for ambient tinting. Defaults totransparent; set by now-playing surfaces from the track’s dominant color (§6.3).--chrome/--chrome-foreground— the persistent shell surfaces (sidebar, mini-player bar). Kept as distinct tokens so the chrome can read differently from the room content surface without per-room special-casing.--focus-ring— an alias for--ringretained for clarity in base-layer resets.
| Token | Dark (default) | Light (warm cream) | Used for |
|---|---|---|---|
--background |
oklch(0.129 0.042 264.695) |
oklch(0.98 0.015 90) |
Main content area, room surfaces |
--foreground |
oklch(0.984 0.003 247.858) |
oklch(0.129 0.042 264.695) |
Body text |
--card |
oklch(0.155 0.031 264.695) |
oklch(0.967 0.003 264.542) |
Sidebar, cards, popovers, menus |
--card-foreground |
oklch(0.984 0.003 247.858) |
oklch(0.129 0.042 264.695) |
Text on cards |
--popover |
oklch(0.155 0.031 264.695) |
oklch(1 0 0) |
Menus, tooltips |
--popover-foreground |
oklch(0.984 0.003 247.858) |
oklch(0.129 0.042 264.695) |
Text in popovers |
--primary |
oklch(0.6 0.2 200) |
oklch(0.55 0.18 200) |
Accent, primary buttons, active states |
--primary-foreground |
oklch(0.984 0.003 247.858) |
oklch(1 0 0) |
Text on primary |
--secondary |
oklch(0.216 0.030 264.695) |
oklch(0.883 0.010 258.338) |
Secondary buttons, chips |
--secondary-foreground |
oklch(0.984 0.003 247.858) |
oklch(0.129 0.042 264.695) |
Text on secondary |
--muted |
oklch(0.216 0.030 264.695) |
oklch(0.967 0.003 264.542) |
Hover states, input backgrounds |
--muted-foreground |
oklch(0.548 0.027 264.364) |
oklch(0.446 0.030 264.364) |
Secondary text, metadata |
--accent |
oklch(0.6 0.2 200) |
oklch(0.55 0.18 200) |
Ambient emphasis, selection |
--accent-foreground |
oklch(0.984 0.003 247.858) |
oklch(1 0 0) |
Text on accent |
--destructive |
oklch(0.577 0.245 27.325) |
oklch(0.577 0.245 27.325) |
Destructive actions, errors |
--destructive-foreground |
oklch(0.984 0.003 247.858) |
oklch(1 0 0) |
Text on destructive |
--border |
oklch(0.216 0.030 264.695 / 0.5) |
oklch(0.842 0.006 264.542 / 0.5) |
Hairline dividers, card borders |
--input |
oklch(0.216 0.030 264.695) |
oklch(0.842 0.006 264.542) |
Input backgrounds |
--ring |
oklch(0.6 0.2 200) |
oklch(0.55 0.18 200) |
Focus rings |
--chrome |
oklch(0.155 0.031 264.695) |
oklch(0.967 0.003 264.542) |
Sidebar, mini-player bar |
--chrome-foreground |
oklch(0.984 0.003 247.858) |
oklch(0.129 0.042 264.695) |
Text in chrome |
--radius |
0.625rem |
0.625rem |
Card/button corner radius |
Light mode notes:
- The light
--backgroundis a warm cream (oklch(0.98 0.015 90)), not pure white. It reduces eye strain during long listening sessions and gives the light theme its own character — it is not “dark inverted”. - All token pairs are checked against WCAG AA (4.5:1 text, 3:1 large text) on both variants before shipping, verified against the Rhea base defaults.
3.4 Typography
Typefaces
| Usage | Typeface | Weights | Why |
|---|---|---|---|
| UI text (labels, buttons, nav) | Inter | 400, 500, 600 | Highly readable at small sizes |
| Headings / display | Inter | 600, 700 | Same family, semantic weight hierarchy |
| Time / progress numerals | Inter | 400 tabular-nums |
Monospaced digits keep the seek bar and timers from jittering |
| Code / diagnostics | JetBrains Mono | 400, 500 | Developer panel, event log, plugin diagnostics |
| Fallback | system-ui, -apple-system, sans-serif |
— | Zero-cost fallback; no FOUT |
Size scale (Tailwind v4 defaults; token-backed via --text-* in @theme)
| Class | rem | Used for |
|---|---|---|
text-xs |
0.75rem | Metadata, timestamps, badges |
text-sm |
0.875rem | Sidebar links, button labels, secondary text |
text-base |
1rem | Body text, table cells, card descriptions |
text-lg |
1.125rem | Track titles, section headers |
text-xl |
1.25rem | Card titles, list headers |
text-2xl |
1.5rem | Page titles, now-playing track name |
text-3xl |
1.875rem | Hero / empty-state headings |
Loading strategy
Self-hosted so the SPA works offline and never flashes:
pnpm add @fontsource/inter @fontsource/jetbrains-mono
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "@fontsource/inter/700.css";
@import "@fontsource/jetbrains-mono/400.css";
@import "@fontsource/jetbrains-mono/500.css";
Only the weights actually used are bundled (four Inter, two JetBrains Mono).
3.5 Spacing & radius
The spacing scale is Tailwind v4’s default 4px grid — no custom spacing tokens.
Components use p-*/gap-*/m-* classes exclusively; the meaningful rhythm:
| Token | rem | px | Tailwind |
|---|---|---|---|
| 1 | 0.25rem | 4px | p-1 / gap-1 |
| 2 | 0.5rem | 8px | p-2 / gap-2 |
| 3 | 0.75rem | 12px | p-3 / gap-3 |
| 4 | 1rem | 16px | p-4 / gap-4 |
| 6 | 1.5rem | 24px | p-6 / gap-6 |
| 8 | 2rem | 32px | p-8 / gap-8 |
3.6 Layout constants
| Element | Size (desktop) |
|---|---|
| Sidebar | 240px (w-60) |
| Mini-player bar | 72px (h-18) |
| Album/artist grid | Auto-fill, minmax(180px, 1fr) cards |
| Sidebar on narrow windows | Full-screen Sheet |
| Content max width | max-w-6xl, centered, generous side padding |
These are shell/layout conventions, not primitives — a room may choose a denser grid for its content type. The shell frame itself uses them as constants so the chrome stays stable across rooms.
3.7 app.css structure
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn-svelte/tailwind.css";
@import "./styles/tokens.css";
/* shadcn theme tokens — dark is the default (Midnight palette) */
:root, [data-theme="dark"] { /* …Layer 2 block from §3.3… */ }
[data-theme="light"] { /* …Layer 2 light block… */ }
@custom-variant dark (&:is([data-theme="dark"] *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chrome: var(--chrome);
--color-chrome-foreground: var(--chrome-foreground);
--color-ambient: var(--ambient-color);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
--font-mono: "JetBrains Mono", monospace;
}
@layer base {
* { @apply border-border outline-ring/50; }
body { @apply bg-background text-foreground font-sans; }
}
Theme palettes are applied at runtime on top of these defaults by setting Layer
2 properties on document.documentElement (§4.5). The CSS defaults are always
Midnight dark, so first paint is correct even before JS runs.
4. Theming system
4.1 Theme object model
// src/lib/state/theme.svelte.ts
interface RymfluxTheme {
id: string;
name: string;
description: string;
/** Four swatch colors: [background, surface, accent, text] — for palette previews. */
palette: [string, string, string, string];
/** Layer 2 token overrides per variant (key = token name, value = oklch/color). */
variants: {
light: Record<string, string>;
dark: Record<string, string>;
};
}
A theme is only a Layer 2 token override. It never adds layout or
typography rules. Swatch order [background, surface, accent, text] is fixed
so previews and authoring stay consistent.
4.2 Launch themes (4)
| Theme ID | Name | Palette (dark) | Mood | Best for |
|---|---|---|---|---|
midnight |
Midnight | #111827, #1f2937, #0d9488, #ffffff |
Deep, focused | Default; music browsing |
dawn |
Dawn | #faf6f0, #f3ede3, #0f766e, #1a1a1a |
Warm, easy on eyes | Light mode / daytime |
cascade |
Cascade | #0f172a, #1e293b, #06b6d4, #f8fafc |
Cool, deep blue-teal | Night listening |
ember |
Ember | #1c1917, #292524, #d97706, #fafaf9 |
Warm amber, charcoal | Cozy / evening |
Themes are curated for the mood of listening (a reading/long-session
environment), not code-editor palettes. Each ships both light and dark
variants; midnight dark is the app default. The four palettes above are
the launch set — more can be added to the availableThemes registry without
architecture change.
4.3 Variants
- The variant (light/dark) is a global user preference, independent of the theme palette. It is toggled from the sidebar (sun/moon) and Settings.
- Default: dark. First launch shows Midnight dark before any JS (CSS defaults, §3.7); the persisted preference upgrades it.
data-theme="dark" | "light"on<html>drives the Tailwinddarkvariant.
4.4 Per-room theme defaults
Per the decision to extend the Room interface (recorded in ADR-009’s extension note), a room declares its default theme in the registry. This is a shell/registry concern — the room component itself never touches theming.
// src/lib/shell/types.ts (addition to the existing Room interface)
interface Room {
id: string;
displayName: string;
icon: string;
component: ComponentType<RoomProps>;
/** Default theme when this room is active; optional, falls back to global. */
themeId?: string;
onActivate?(ctx: RoomContext): void;
onDeactivate?(): void;
onSearch?(query: string): SearchHandler | void;
playerExpansion?: ComponentType<PlayerExpansionProps>;
}
// src/lib/shell/rooms.ts
export const ROOMS: Room[] = [
{
id: 'music',
displayName: 'Music',
icon: 'music',
themeId: 'midnight',
component: lazy(() => import('$lib/rooms/music/MusicRoom.svelte')),
},
];
The Music Room’s themeId: 'midnight' is a no-op against the global default in
v1 — it exists to establish the contract a second room will rely on (e.g. a
future Audiobook room declaring ember).
Precedence (highest wins):
- User’s explicit per-room override (Settings → Themes, per room).
- Active room’s
themeId(registry). - Global default theme (
midnight).
On room activation the shell recomputes the resolved theme and applies it (§4.5). The chrome (sidebar, mini-player) follows the resolved theme with the content surface — with one room this is indistinguishable from a global theme, and it stays simple when more rooms arrive.
4.5 Theme store & application
Theme state lives in a dedicated state module — an addition to the state
inventory in frontend-architecture.md §5.2. It owns no UI, mutates
documentElement (a genuine $effect), and persists to localStorage (the
boot script reads the same key, so localStorage is the single source of
truth — not SQLite, matching the player-expansion precedent).
// src/lib/state/theme.svelte.ts
import type { RymfluxTheme } from './theme.types';
class ThemeStateModule {
availableThemes = $state<RymfluxTheme[]>(/* launch 4 */);
activeThemeId = $state('midnight'); // resolved, post-precedence
variant = $state<'light' | 'dark'>('dark');
perRoomOverrides = $state<Record<string, string>>({}); // roomId → themeId
// Precedence: user per-room override > room themeId > global default.
resolveForRoom(room: Room | undefined) {
const override = room ? this.perRoomOverrides[room.id] : undefined;
const next = override ?? room?.themeId ?? 'midnight';
if (next) this.activeThemeId = next;
}
$effect(() => {
const theme = this.availableThemes.find((t) => t.id === this.activeThemeId)
?? this.availableThemes[0];
for (const [key, value] of Object.entries(theme.variants[this.variant])) {
document.documentElement.style.setProperty(`--${key}`, value);
}
document.documentElement.setAttribute('data-theme', this.variant);
localStorage.setItem('rymflux.theme', JSON.stringify({
themeId: this.activeThemeId,
variant: this.variant,
}));
});
}
export const themeState = new ThemeStateModule();
The $effect makes theme application declarative: it runs whenever the theme
id, variant, or per-room overrides change, and it persists. The shell calls
themeState.resolveForRoom(page.params.roomId) from the root layout when the
room changes (cheap: a few setProperty calls, well within the 300ms room
switch budget, NFR-PERF-6).
4.6 Anti-FOUC boot script
A blocking inline script in app.html <head> sets the variant before first
paint so a dark-preference user never sees a flash of the light theme:
<script>
(function () {
try {
var saved = JSON.parse(localStorage.getItem('rymflux.theme') || '{}');
document.documentElement.setAttribute('data-theme', saved.variant === 'light' ? 'light' : 'dark');
} catch (e) { /* first run: CSS defaults (Midnight dark) apply */ }
})();
</script>
The palette itself can’t be applied pre-JS; the CSS defaults are Midnight dark
(§3.7), so a first paint is always correct. The theme $effect upgrades to the
persisted palette as soon as the state module mounts.
4.7 Theme preview
In Settings → Themes, each theme is a card showing its four swatches (the
palette tuple). Hovering applies the theme temporarily (variant respected);
leaving restores the previous theme; clicking commits it to activeThemeId.
Preview never writes to localStorage — commit does.
5. Component library
5.1 Base: shadcn-svelte (Rhea)
shadcn-svelte is the base for the ui/ primitives. The ui/ directory in
frontend-architecture.md §2 (src/lib/ui/) is the ownership boundary for
primitives, and shadcn components are installed into it (src/lib/ui/), so
the architecture’s tier rules hold unchanged.
src/lib/ui/
├── Button.svelte # shadcn Button
├── IconButton.svelte # thin wrapper: <Button variant="ghost" size="icon">
├── Input.svelte # shadcn Input
├── Slider.svelte # shadcn Slider (Ranger) — interactive, not display-only
├── Card.svelte # shadcn Card
├── Menu.svelte # shadcn DropdownMenu (exposed as Menu)
└── …(shadcn set as needed: Badge, Separator, Skeleton, Sheet, Switch, Tabs, Tooltip)
Notes:
- IconButton is not a new primitive — it is
<Button variant="ghost" size="icon">, wrapped under a stable name so callers don’t repeat the variant/size pair. - Slider is interactive. shadcn-svelte’s Slider (Ranger) provides drag-to-seek, keyboard stepping, and ARIA slider semantics out of the box. There is no “display-only Progress” gap in the base.
- Every
ui/component acceptsclasspassthrough and uses only token-backed classes (frontend-architecture.md§7) — no inline color/length literals.
5.2 Compositions — where custom controls live
Interactive controls that compose multiple atoms are compositions, not new
primitives. They live in the shared-feature tier (src/lib/components/) so
both the shell mini-player and the Music Room’s now-playing surfaces can use
them, and they remain room-agnostic (no music concepts):
src/lib/components/
├── TrackList.svelte
├── TrackRow.svelte
├── AlbumCard.svelte
├── Artwork.svelte
├── EmptyState.svelte
├── Skeleton.svelte
├── SeekBar.svelte # composition of ui/Slider + time labels + aria live
├── VolumeSlider.svelte # ui/Slider + mute IconButton
└── PlaybackControls.svelte # IconButton group (skip/play/next) + speed toggle + shortcuts
- SeekBar —
ui/Sliderbound toplayerState.position/duration, drag-scrubbing,role="slider"semantics from the base,aria-valuetext= “1:23 of 3:45”, and the keyboard stepping the base provides. Loading / unknown duration renders the thumb at 0 with no fill. - VolumeSlider —
ui/Slider(0.0–1.0) + a muteIconButton; the button toggles the last non-zero level and swaps icons. - PlaybackControls — a group of
IconButtons (skip back, play/pause, skip forward) plus a speed toggle. Composition and keyboard shortcuts are custom; the buttons themselves are the primitive.
These are added to the components/ tier — a documented extension of the
inventory in frontend-architecture.md §2, not a change to the tier rules.
5.3 Component inventory by tier
| Tier | Components | Owns |
|---|---|---|
UI primitives (src/lib/ui/) |
Button, IconButton, Input, Slider, Card, Menu, Badge, Separator, Skeleton, Sheet, Switch, Tabs, Tooltip | Atoms, tokens, ARIA from base |
Shell-owned (src/lib/shell/) |
Sidebar, MiniPlayer, PlayerExpansion, ToastHost, RoomRenderer, ErrorBoundary | Frame, chrome, transport bar, toasts |
Shared feature (src/lib/components/) |
TrackRow, TrackList, AlbumCard, Artwork, EmptyState, Skeleton, SeekBar, VolumeSlider, PlaybackControls | Room-agnostic domain pieces + player compositions |
Room-owned (src/lib/rooms/music/) |
MusicRoom, MusicPlayerExpansion, BrowseView, AlbumDetailView, ArtistDetailView, SearchView, QueueView, NowPlayingView | Every view, layout, sorting/filtering, context menus |
The shared-feature pieces encode no music concepts beyond what the name
implies (a “track” is a core-domain concept, not a Music-Room concept — see
data-models.md). If a piece becomes music-specific it moves into the room.
5.4 Icons — Lucide registry
Room.icon is a string identifier (frontend-architecture.md §6.2). A tiny
shell-level registry maps identifiers to Lucide components so the sidebar
renders icons without importing any room code (protects the room-switch
budget and code-splitting):
// src/lib/shell/icons.ts
import { Home, Music, Settings, … } from '@lucide/svelte';
export const ICONS = {
home: Home,
music: Music,
settings: Settings,
// …future rooms add an entry here, not in room code
} as const;
export type IconName = keyof typeof ICONS;
The sidebar resolves Room.icon through this registry. Unknown identifiers
fall back to a generic disc icon and log a warning (log.warn).
6. Ambient color
6.1 Concept
The now-playing surfaces are where users spend the most time. A static dark background behind gray artwork feels dead. Ambient color pulls the dominant color from the current track’s cover art and applies it as a subtle tint — felt, not noticed (~8% over the card surface, blended in oklch).
Ambient tint applies to the now-playing surfaces only: the Music Room’s
NowPlayingView and the MusicPlayerExpansion view. Browse/detail views keep
the static theme background — their artwork grids already provide color.
6.2 Extraction — Rust side, DB-cached
Decided: Rust-side extraction (ADR-016) in the backend, cached in the library service. Image decoding and dominant-color computation are added to the library service as part of this change; a 1×1 resize + oklch average costs almost nothing and is computed once per unique cover, then served from cache for every subsequent display.
The JS canvas alternative was considered and rejected — it runs on the render thread and can cause a visible layout shift when the cover resolves after the view mounts.
Data-model change. data-models.md gains a nullable field on both Track
and TrackStub, and the TypeScript/Rust mirror (contracts/types.ts, serde
structs) updates with it. TrackStub is the type that rides the playback
events (track_changed, PlaybackState.track) — the now-playing surfaces read
the color from that stub, so the field must exist there, not only on the full
Track.
interface Track {
// …existing fields…
cover_art_url: string | null;
/** Dominant oklch color of the cover art, computed once server-side. */
cover_dominant_color: string | null;
}
interface TrackStub {
// …existing fields…
/** Same field on the stub — events deliver `TrackStub`, not `Track`. */
cover_dominant_color: string | null;
}
- The value is a string like
"oklch(0.55 0.12 45)". - Computed when the cover is first indexed/resolved, stored with the resolved metadata, invalidated only when the cover URL changes.
nullwhen there is no cover art — the surface then uses the static theme background (no tint). The UI must not attempt client-side extraction as a fallback in v1.- The library service owns the cache (it already owns content resolution —
library-service.md); the exact column/table is a backend detail recorded there and in the migrations.
6.3 Application
NowPlayingView and MusicPlayerExpansion set --ambient-color on their own
root from the track’s dominant color and let the base layer build the gradient:
<script lang="ts">
let { track }: { track: TrackStub | null } = $props();
let ambient = $derived(
track?.cover_dominant_color
? `--ambient-color: ${track.cover_dominant_color};`
: ''
);
</script>
<div
class="now-playing bg-card"
style={ambient}
>
<!-- album art, title, transport, queue -->
</div>
/* tokens.css base layer — the tint lives here, not in components */
@layer base {
.now-playing, .player-expansion {
background: linear-gradient(
135deg,
color-mix(in oklch, var(--card) 92%, var(--ambient-color, transparent) 8%),
color-mix(in oklch, var(--card) 100%, var(--ambient-color, transparent) 0%)
);
}
}
Components stay token-clean: they set the data channel (--ambient-color);
the design system owns the blend. The 8% tint keeps text contrast within the
card surface’s AA guarantee — ambient is a whisper, never a backdrop.
7. Motion & transitions
- Enter/leave for Sheet (mobile sidebar), Menu, Tooltip, and Toast use
tw-animate-cssutilities (animate-in/animate-out), scoped to the component. - No ambient idle motion. Nothing animates continuously; motion is reserved for user- or state-driven transitions (open menu, toast in, expansion slide).
- Reduced motion.
motion-reduce:animate-noneon all animated surfaces;tw-animate-cssrespectsprefers-reduced-motionglobally. - Player expansion slides in (translate) with a short fade — a state-driven transition, 150–200ms.
8. Wireframes
ASCII blueprints, not pixel specs. Exact spacing derives from the tokens (§3.5); these fix structure, hierarchy, and placement. Muted chrome (dimmed chrome labels below) is part of the design: chrome recedes, content carries color.
8.1 Shell frame (every route)
┌──────────────────────────────────────────────────────────────────────┐
│ Sidebar (240) │ Main content (route) │
│ │ │
│ ◉ Home │ ┌────────────────────────────────────────────┐ │
│ ♫ Music │ │ │ │
│ ⚙ Settings │ │ Room view renders here (below) │ │
│ ───────────────── │ │ │ │
│ Plugin status ● │ │ │ │
│ │ └────────────────────────────────────────────┘ │
│ [☀/☾] theme toggle│ │
├────────────────────┴──────────────────────────────────────────────────┤
│ ⏮ ▶ ⏭ ————●—————— 1:23 / 3:45 🔇────●──── [⤢ expand] │
│ Mini-player (72px) │
└──────────────────────────────────────────────────────────────────────┘
- The mini-player is the always-present transport bar (
MiniPlayer.svelte). - The
⤢expand button togglessettingsState.playerExpanded; when expanded and the active room definesplayerExpansion, the expansion view occupies the main region (overlaying the room view), perfrontend-architecture.md§6.5. - Sidebar is
<nav aria-label="Room navigation">witharia-currenton the active destination.
8.2 Home / Lobby — +page.svelte (FR-HOME-1)
┌──────────────────────────────────────────────────────────────────────┐
│ Home │
│ │
│ Continue Listening │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │art │ │art │ │art │ cover + title + artist + progress % │
│ └────┘ └────┘ └────┘ │
│ │
│ Recently Played │
│ ▶ Track title — Artist · album · ▶ 1:23/3:45 │
│ ▶ Track title — Artist · album · ▶ 2:01/4:12 │
│ │
│ Pinned Rooms │
│ [♫ Music] [+ Pin] │
│ │
│ Recent Searches │
│ "imagine dragons" "jazz 2023" │
└──────────────────────────────────────────────────────────────────────┘
- First launch (empty): Continue Listening is hidden; a “Getting Started” card shows plugin-installation instructions with a primary action linking to the plugin setup guide (FR-HOME-1 empty state).
- Data comes from
library.svelte.ts(history, favorites) +rooms.svelte.ts(pinned).
8.3 Music Room — Browse (BrowseView.svelte, FR-MUSIC-1)
┌──────────────────────────────────────────────────────────────────────┐
│ ♫ Music ⌕ Search (room chrome) │
│ ┌────────┬─────────┬────────┐ │
│ │Albums │ Artists │ Genres │ │
│ └────────┴─────────┴────────┘ (Tabs — grid/list + sort on the right) │
│ │
│ Albums │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │art │ │art │ │art │ │art │ │art │ │art │ grid: auto-fill, │
│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ min 180px cards │
│ Title Title Title Title Title Title │
│ Artist Artist Artist Artist Artist Artist │
│ │
│ (Artists: name + image, list/grid · Genres: chip cloud → filtered) │
└──────────────────────────────────────────────────────────────────────┘
- Empty (no plugins):
EmptyState— “No music sources installed” + a primary action linking to the plugin setup guide (FR-MUSIC-1). - Navigating album →
AlbumDetailViewpushes onto the room’s state view stack (musicRoomState.navigate(...)); never the router.
8.4 Music Room — Album detail (AlbumDetailView.svelte)
┌──────────────────────────────────────────────────────────────────────┐
│ ‹ Back ♫ Music / Albums │
│ │
│ ┌──────────────┐ Title (text-2xl, bold) │
│ │ │ Artist · Year · N tracks · ▣ Total │
│ │ Cover art │ │
│ │ (large) │ [▶ Play] [♡ Save] │
│ └──────────────┘ │
│ │
│ # Title Duration E │
│ 1 Track title 3:45 [E] (explicit badge) │
│ 2 Track title 4:12 │
│ 3 Track title 3:03 [E] │
│ …hover a row → ▶ play hint, row highlights │
└──────────────────────────────────────────────────────────────────────┘
8.5 Music Room — Artist detail (ArtistDetailView.svelte)
┌──────────────────────────────────────────────────────────────────────┐
│ ‹ Back ♫ Music / Artists │
│ ┌──────────┐ Artist name (text-2xl, bold) │
│ │ portrait │ N albums · M tracks │
│ └──────────┘ [▶ Play all] [♡ Save] │
│ │
│ Albums │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ (same grid as Browse) │
│ └────┘ └────┘ └────┘ └────┘ │
└──────────────────────────────────────────────────────────────────────┘
8.6 Music Room — Search (SearchView.svelte)
┌──────────────────────────────────────────────────────────────────────┐
│ ♫ Music ⌕ [imagine dragons ] (Search input, autofocus) │
│ │
│ Results (progressive — appends as batches arrive) │
│ ● Album Imagine Dragons — Origins [▶] │
│ ● Track Believer — Imagine Dragons · Smoke + Mirrors [▶] │
│ ● Track Radioactive — Imagine Dragons · Night Visions [▶] │
│ ⚠ spotify: 2 plugins did not respond (inline per-plugin error) │
│ │
│ 3 results from 2 plugins · done ✓ │
└──────────────────────────────────────────────────────────────────────┘
- Progressive results:
search.svelte.tsappends live batches (NFR-PERF-4); the view renders them as they arrive, skeletons for the still-open query. - Per-plugin failures become inline notifications (recoverable severity), not toasts.
8.7 Music Room — Queue (QueueView.svelte)
┌──────────────────────────────────────────────────────────────────────┐
│ ‹ Back Queue │
│ │
│ Now Playing │
│ ▶ Title — Artist · album · 1:23/3:45 [≡] (drag handle)│
│ Up Next │
│ Title — Artist · album · 3:45 [≡] │
│ Title — Artist · album · 4:12 [≡] │
│ │
│ [Clear queue] │
└──────────────────────────────────────────────────────────────────────┘
- Current track is visually distinct (accent icon + highlighted row).
- Reorder via the drag handle (
[≡]); remove via a ghost icon button on the row. Queue is frontend-authoritative (ADR-010) — edits invokequeue_*and the engine mirrors back.
8.8 NowPlaying / Player expansion (NowPlayingView.svelte, MusicPlayerExpansion.svelte, FR-MUSIC-2)
┌──────────────────────────────────────────────────────────────────────┐
│ ← ambient gradient over --card, tinted by --ambient-color → │
│ │
│ ┌──────────────┐ │
│ │ │ │
│ │ Cover art │ ← the content provides color │
│ │ │ │
│ └──────────────┘ │
│ Track Title (text-2xl, bold) │
│ Artist Name │
│ │
│ 1:23 ——————●———————————— 3:45 (SeekBar) │
│ ⏮ ▶ ⏭ 1.0× (PlaybackControls + speed) │
│ │
│ [Queue] [♡ Save] [▭ Add to playlist] │
│ │
│ Queue below (accessible): │
│ ▶ Title — Artist 1:23/3:45 │
│ Title — Artist 3:45 │
└──────────────────────────────────────────────────────────────────────┘
This is the expansion view the shell renders into the PlayerExpansion slot
when expanded (FR-MUSIC-2: artwork + now-playing metadata + accessible queue),
and the same composition fills NowPlayingView for the full-room player. The
mini-player and this surface both use SeekBar/VolumeSlider/PlaybackControls.
8.9 Settings → Themes
┌──────────────────────────────────────────────────────────────────────┐
│ Settings │
│ ┌──────────┬────────────┐ │
│ │ Plugins │ Themes │ (Tabs) [☀ / ☾] variant toggle (global) │
│ └──────────┴────────────┘ │
│ │
│ Theme cards (grid): hover previews, click commits │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌────────┐ │
│ │ ▣▣▣▣ Midnight │ │ ▣▣▣▣ Dawn │ │ ▣▣▣▣ Cascade │ │ ▣▣ Ember│ │
│ │ [swatches] │ │ [swatches] │ │ [swatches] │ │ ... │ │
│ └───────────────┘ └───────────────┘ └───────────────┘ └────────┘ │
│ │
│ Per-room overrides (v1: Music only) │
│ Music Room: [Midnight ▾] (default: Midnight) │
└──────────────────────────────────────────────────────────────────────┘
9. Loading, empty, and error states
Mirrors frontend-architecture.md §9. Visual treatment:
| Severity | Visual treatment |
|---|---|
| Fatal | +error.svelte fallback + crash dialog (Rust panic handler). |
| Recoverable | Toast (via toasts.svelte.ts); per-plugin search errors → inline; playback_error → toast with retry/auto-advance; room sub-tree → ErrorBoundary fallback with “Retry”. |
| Informational | Logged; surfaced in Settings diagnostics. |
Conventions:
- Loading —
Skeletonblocks (shadcn) shaped like the eventual content (album-card skeletons in grids, row skeletons in lists). Never spinners for content panels; the boot-time loading gate is the only full-screen spinner. - Empty —
EmptyState.svelte: icon + message + primary action. Canonical v1 instances: Home “Getting Started” (FR-HOME-1), Music browse “No music sources installed” with plugin-setup link (FR-MUSIC-1). - Error —
ErrorBoundary.svelterenders a muted surface with a message and “Retry”; it never unmounts the shell frame.
10. Accessibility
NFR-A11Y-1 (keyboard) and NFR-A11Y-2 (screen reader) are the contract. The
architecture builds the wiring now even though the backlog demotes formal
enablement past MVP (frontend-architecture.md §11); this document sets the
visual and semantic rules so enablement is cheap.
- Contrast. All Layer 2 token pairs are checked against WCAG AA (4.5:1
text, 3:1 large text / UI components) in both variants before shipping; the
default shadcn Rhea base passes, and custom launch themes are checked against
the same threshold. Note:
--primary-foregroundon the teal accent is borderline (~3:1 in both variants) — confirm with a real computation at implementation and, if a button label fails 4.5:1, deepen the teal primary toward L≈0.45–0.5. - Focus. Teal
--ringat 2px,outline-offset: 2px, applied globally via the base layer (outline-ring/50). Every interactive element must show a visible focus state — neveroutline: nonewithout a replacement. - Keyboard. All transport controls, room navigation, and search operable
by keyboard alone (NFR-A11Y-1). The seek bar and volume are
role="slider"with Arrow-key stepping (fromui/Slider).Spacetoggles play/pause;Ctrl/Cmd+Kfocuses search;Alt+Left/Rightshell nav (sidebar ↔ rooms). - Screen reader. Now-playing metadata and transport state announce via an
aria-live="polite"region fed byplayerState(the legitimate$effectinfrontend-architecture.md§11). Transport follows the WAI-ARIA media player pattern (aria-labelon play/pause/skip). - Semantics first. Real
<button>,<a>,<input>everywhere; custom controls announce role/state. - Reduced motion.
prefers-reduced-motionrespected globally (§7). - Navigation landmarks. Sidebar is
<nav aria-label="Room navigation">; room content is<main>; the mini-player is a labeled region.
11. Internationalization
NFR-I18N-1: every user-visible string goes through t() (English-only catalog
in v1, src/lib/i18n/en.json). Design implications:
- No hardcoded strings in components — enforced by
frontend-architecture.md§15; the design system’s own strings (tooltips, empty states, a11y labels) follow the same rule. - Layout is length-tolerant. Cards, buttons, and rows must not clip with longer translations. Grids use min-width cards that wrap; labels ellipsize at a token-controlled point, never overflow.
- RTL is explicitly out of scope for v1 (English catalog only). Tokens and layout avoid hard left/right assumptions where cheap (icon + label pairs are order-flexible) so a future RTL pass is CSS-mostly.
- Dates/durations render through formatters in
i18n/, not template string concatenation.
12. Implementation order
Unblocks styles/tokens.css and ui/ primitives (frontend-arch §16 item 3),
then the Music Room (backlog M12). Each step is independently testable.
- Tokens + base layer —
tokens.css,app.cssrestructure (§3.7). Gate: svelte-check clean, no regressions on existing shell. - Theme store —
theme.svelte.ts, boot script,Room.themeId(+shell/types.ts,shell/rooms.ts), Settings→Themes + preview. Gate: theme switch persists, no flash on reload. ui/primitives — install shadcn (Rhea) intosrc/lib/ui/; add IconButton wrapper, icon registry. Gate: primitives render, hover/active/ focus/disabled states verified in dark + light.- Shared compositions — SeekBar, VolumeSlider, PlaybackControls. Gate: mini-player controls fully operable with keyboard + mouse.
- Shared feature pieces — TrackRow, TrackList, AlbumCard, Artwork,
EmptyState, Skeleton. Gate: render from mock
Trackdata. - Music Room views — Browse → detail → search → queue → now-playing → expansion, in dependency order. Gate: FR-MUSIC-1/2 scenarios pass in Vitest component tests + manual pass.
- Ambient color — backend extraction +
cover_dominant_colorfield, thenNowPlayingView/expansion tint. Gate: tint appears only on now-playing surfaces; text contrast unchanged.
13. Related docs
- Frontend architecture — the engineering doc this design serves; §16 item 3 is this document.
- Shell and Room Framework specification — shell responsibilities, player expansion slot.
- Room framework API —
Room/RoomContext(extended withthemeIdin §4.4). - Data models —
Track(gainscover_dominant_color, §6.2). - Container diagram — Shell UI / Music Room containers.
- Functional requirements — FR-HOME-1, FR-MUSIC-1, FR-MUSIC-2, FR-ROOM-1/2/3.
- Non-functional requirements — NFR-A11Y-1/2, NFR-I18N-1, NFR-PERF-6, NFR-MAINT-3.
- Vision — rooms
and
project-foundation.md— the platform philosophy this design follows. - Personas — Listener (Alex), Room author (Morgan), Plugin developer (Priya).
14. Open items
cover_dominant_color— the data-model field (TrackandTrackStub) and the library service cache need the backend migration +contracts/types.tsupdate (§6.2). Not yet written; the UI side treats the field as nullable and tolerant.- Theme count for launch — the 4 launch themes are a starting set. Final count confirmed when the Music Room passes a visual review.
- Ambient extent — tint limited to now-playing surfaces in v1. Whether
future rooms tint their browse surfaces is a per-room decision; the tokens
and
--ambient-colorchannel already support it. - shadcn-svelte version pinning — Rhea style + Tailwind v4 compat is
verified at install time (step 3); record the resolved versions in
package.jsonand this doc if the style changes.