Skip to content
Rymflux Documentation
Esc
navigateopen⌘Jpreview
On this page

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-*"]
  1. 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.
  2. 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.
  3. Layer 3 — Tailwind bindings: @theme inline maps 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 to transparent; 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 --ring retained 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 --background is 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 Tailwind dark variant.

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

  1. User’s explicit per-room override (Settings → Themes, per room).
  2. Active room’s themeId (registry).
  3. 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 accepts class passthrough 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
  • SeekBarui/Slider bound to playerState.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.
  • VolumeSliderui/Slider (0.0–1.0) + a mute IconButton; 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.
  • null when 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-css utilities (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-none on all animated surfaces; tw-animate-css respects prefers-reduced-motion globally.
  • 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 toggles settingsState.playerExpanded; when expanded and the active room defines playerExpansion, the expansion view occupies the main region (overlaying the room view), per frontend-architecture.md §6.5.
  • Sidebar is <nav aria-label="Room navigation"> with aria-current on 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 → AlbumDetailView pushes 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.ts appends 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 invoke queue_* 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:

  • LoadingSkeleton blocks (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.
  • EmptyEmptyState.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).
  • ErrorErrorBoundary.svelte renders 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-foreground on 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 --ring at 2px, outline-offset: 2px, applied globally via the base layer (outline-ring/50). Every interactive element must show a visible focus state — never outline: none without 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 (from ui/Slider). Space toggles play/pause; Ctrl/Cmd+K focuses search; Alt+Left/Right shell nav (sidebar ↔ rooms).
  • Screen reader. Now-playing metadata and transport state announce via an aria-live="polite" region fed by playerState (the legitimate $effect in frontend-architecture.md §11). Transport follows the WAI-ARIA media player pattern (aria-label on play/pause/skip).
  • Semantics first. Real <button>, <a>, <input> everywhere; custom controls announce role/state.
  • Reduced motion. prefers-reduced-motion respected 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.

  1. Tokens + base layertokens.css, app.css restructure (§3.7). Gate: svelte-check clean, no regressions on existing shell.
  2. Theme storetheme.svelte.ts, boot script, Room.themeId (+ shell/types.ts, shell/rooms.ts), Settings→Themes + preview. Gate: theme switch persists, no flash on reload.
  3. ui/ primitives — install shadcn (Rhea) into src/lib/ui/; add IconButton wrapper, icon registry. Gate: primitives render, hover/active/ focus/disabled states verified in dark + light.
  4. Shared compositions — SeekBar, VolumeSlider, PlaybackControls. Gate: mini-player controls fully operable with keyboard + mouse.
  5. Shared feature pieces — TrackRow, TrackList, AlbumCard, Artwork, EmptyState, Skeleton. Gate: render from mock Track data.
  6. 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.
  7. Ambient color — backend extraction + cover_dominant_color field, then NowPlayingView/expansion tint. Gate: tint appears only on now-playing surfaces; text contrast unchanged.


14. Open items

  1. cover_dominant_color — the data-model field (Track and TrackStub) and the library service cache need the backend migration + contracts/types.ts update (§6.2). Not yet written; the UI side treats the field as nullable and tolerant.
  2. Theme count for launch — the 4 launch themes are a starting set. Final count confirmed when the Music Room passes a visual review.
  3. 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-color channel already support it.
  4. shadcn-svelte version pinning — Rhea style + Tailwind v4 compat is verified at install time (step 3); record the resolved versions in package.json and this doc if the style changes.

Was this page helpful?