Frontend architecture — SvelteKit 2 + Svelte 5
Frontend architecture — SvelteKit 2 + Svelte 5
Status
Draft v1 — 2026-07-31
Purpose
This document defines how the Rymflux desktop frontend is built. It is the
consolidated, authoritative home for frontend decisions that currently live
scattered across container-diagram.md, services/shell-room-framework.md,
api-specs/room-api.md, api-specs/tauri-commands.md,
api-specs/tauri-events.md, api-specs/data-models.md, and ADRs 005, 007, 009,
010. Where this document and an earlier file disagree, this document wins and
the disagreement is called out in Deviations and open items.
It covers: the runtime/build model, repository layout, the typed IPC bridge, the runes-based state layer, the shell and room framework implementation, component ownership, navigation, error/loading/empty conventions, logging, accessibility, i18n, performance targets, and the testing strategy.
The UI system design (design tokens, component library, wireframes, theme) is intentionally NOT here. It is a separate, prerequisite document (see Open items). This document defines how the frontend is engineered; the UI design doc defines what it looks like.
1. Platform decision: SvelteKit as a build-time tool, run as an SPA
Tauri is a static web host. It does not run a server, so server-side
rendering is impossible. SvelteKit is therefore used in SPA mode:
adapter-static with a single index.html fallback, SSR disabled, all data
fetched at runtime through Tauri IPC (never through SvelteKit load functions
or +server routes).
This is not a compromise. SvelteKit still earns its place:
| Capability | How Rymflux uses it |
|---|---|
Root +layout.svelte |
The shell frame (sidebar, mini-player, toast host). It persists across route changes — exactly the “always-present frame” property the room model requires. |
| File-based routing | Shell-level navigation destinations (/ Home, /rooms/<id>). Dynamic rooms resolve at runtime. |
| Code splitting | Each room route is its own chunk; the Music Room is not loaded until the user enters it (supports NFR-PERF-6 room switch and NFR-MAINT-3 startup). |
| Build tooling | Vite dev server (HMR), production bundling, svelte-check, ESLint/Prettier out of the box. |
$app/state + runes |
Reactive page/navigation APIs in the Svelte 5 runes model. |
The platform principle “the shell does not provide a router” (room-api.md) is honored by scoping SvelteKit routing to shell-level navigation only. Room-internal sub-views (album detail, search results, queue, now-playing) are deliberately not routes — they are a state-based navigation stack owned by the room (see Navigation model). Deep room views never touch the URL or the router.
1.1 Runtime & build configuration
// svelte.config.js
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ fallback: 'index.html' }), // SPA mode
alias: {
$lib: 'src/lib',
'@rymflux/shell': 'src/lib/shell',
'@rymflux/contracts': 'src/lib/contracts',
'@rymflux/ipc': 'src/lib/ipc',
'@rymflux/state': 'src/lib/state',
},
},
};
// src/routes/+layout.ts (root)
export const ssr = false; // Tauri cannot serve SSR
export const prerender = false; // SPA mode: single index.html fallback
fallback: 'index.html'is what switches SvelteKit into SPA mode. Every route (/,/rooms/music,/settings) resolves to the same shell HTML and renders client-side.- No
+server.js,+page.server.js, or+layout.server.jsfiles, ever. There is no server and no build-time data. All data enters through the IPC bridge (section 4). src-tauri/tauri.conf.json:build.frontendDistpoints at the SvelteKit build output (../build),build.devUrlat the Vite dev server (port 1420;strictPort: trueinvite.config.ts).- CSP: set once in
tauri.conf.json(app.security.csp), allowingselfandipc:(Tauri v2 IPC channel). Do not declare a separate web CSP — the two can conflict.app.withGlobalTauristaysfalse; the frontend imports@tauri-apps/apiexplicitly.
2. Repository layout
rymflux/
├── Cargo.toml # virtual workspace manifest (edition 2024, resolver "3")
├── Cargo.lock
├── crates/ # Rust workspace members — container-diagram services
│ ├── contracts/ # serde types mirroring api-specs/data-models.md
│ ├── plugin-runtime/
│ ├── search-manager/
│ ├── playback-engine/
│ └── library-service/
├── src-tauri/ # Tauri app crate (single binary; wires the crates)
│ ├── src/
│ ├── migrations/ # SQL migrations (001_*.sql …)
│ └── tauri.conf.json
├── svelte.config.js # adapter-static SPA + aliases (above)
├── vite.config.ts # sveltekit(), strictPort
├── vitest.config.ts # Vitest node + browser projects (section 13)
├── components.json # shadcn-svelte contract (ui alias → src/lib/ui)
├── package.json
├── tsconfig.json # extends .svelte-kit/tsconfig
├── eslint.config.js
├── .prettierrc
├── README.md
└── src/
├── app.html
├── app.css # resets, token import, base typography
├── routes/ # shell-level destinations only
│ ├── +layout.ts # ssr=false, prerender=false
│ ├── +layout.svelte # Shell frame (sidebar, mini-player, expansion, toasts)
│ ├── +page.svelte # Home (Lobby) — FR-HOME-1
│ ├── +error.svelte # fatal route error fallback
│ ├── rooms/
│ │ ├── [roomId]/
│ │ │ ├── +page.svelte # resolves ROOMS → renders room via RoomRenderer
│ │ │ └── +error.svelte # per-room recoverable error fallback
│ └── settings/
│ └── +page.svelte # plugin status + diagnostics (backlog M16)
├── lib/
│ ├── contracts/
│ │ └── types.ts # canonical cross-boundary TS types (mirror of data-models.md)
│ ├── ipc/
│ │ ├── commands.ts # typed invoke() wrappers (section 4)
│ │ ├── events.ts # typed event subscriptions (section 4)
│ │ └── bootstrap.ts # one-time platform event wiring (section 4.4)
│ ├── state/ # runes state layer (section 5)
│ │ ├── player.svelte.ts
│ │ ├── queue.svelte.ts
│ │ ├── search.svelte.ts
│ │ ├── library.svelte.ts
│ │ ├── rooms.svelte.ts
│ │ ├── plugins.svelte.ts
│ │ ├── app.svelte.ts
│ │ ├── toasts.svelte.ts
│ │ └── settings.svelte.ts
│ ├── shell/ # shell-owned components + room-author surface
│ │ ├── types.ts # @rymflux/shell re-exports (ComponentType, Room, RoomContext)
│ │ ├── rooms.ts # ROOMS registry (static array)
│ │ ├── RoomRenderer.svelte
│ │ ├── ErrorBoundary.svelte
│ │ ├── Sidebar.svelte
│ │ ├── MiniPlayer.svelte
│ │ ├── PlayerExpansion.svelte
│ │ └── ToastHost.svelte
│ ├── rooms/ # room implementations (one dir per room)
│ │ ├── music/
│ │ │ ├── MusicRoom.svelte
│ │ │ ├── MusicPlayerExpansion.svelte
│ │ │ ├── state.svelte.ts # preserved room state (view stack, drafts)
│ │ │ └── views/
│ │ │ ├── BrowseView.svelte # albums / artists / genres
│ │ │ ├── AlbumDetailView.svelte
│ │ │ ├── ArtistDetailView.svelte
│ │ │ ├── SearchView.svelte
│ │ │ ├── QueueView.svelte
│ │ │ └── NowPlayingView.svelte
│ ├── components/ # shared feature components (room-agnostic)
│ │ ├── TrackList.svelte
│ │ ├── TrackRow.svelte
│ │ ├── AlbumCard.svelte
│ │ ├── Artwork.svelte
│ │ ├── EmptyState.svelte
│ │ └── Skeleton.svelte
│ ├── ui/ # design-system primitives (token-backed)
│ │ ├── Button.svelte
│ │ ├── IconButton.svelte
│ │ ├── Input.svelte
│ │ ├── Slider.svelte
│ │ ├── Card.svelte
│ │ └── Menu.svelte
│ ├── styles/
│ │ └── tokens.css # design tokens (sourced from UI system design doc)
│ ├── i18n/
│ │ ├── index.ts # t() lookup (NFR-I18N-1)
│ │ └── en.json # English catalog (v1)
│ └── log.ts # level-gated console logger (section 10)
└── tests/ # Vitest integration fixtures/helpers; specs live beside code (section 13 traceability)
Docs-as-Code: the docs scaffold (AGENTS.md + docs/) lives in the sibling
rymflux-v2/ repository; the code repo (rymflux/) shown above does not carry
a docs/ directory.
Path notes:
- The Cargo workspace maps
container-diagram.md’s logical backend modules tocrates/(one binary insrc-tauri). Toolchain and commands:../development/dev-environment.md. src/lib/shell/rooms.tsis the ROOMS registry. (Earlier drafts referencedsrc/shell/rooms.ts; under SvelteKit everything undersrc/libis the shareable surface, so the shell moved undersrc/lib/shell.)@rymflux/shell/types.tsre-exportsComponentTypefromsvelteexactly as ADR-009 prescribes — room authors importRoomandComponentTypefrom the shell module, never fromsveltedirectly.
3. Layered architecture
flowchart TD
subgraph SPA["SvelteKit SPA (adapter-static, SPA mode)"]
V["Views — routes<br/>(Home, rooms/[id], settings)"]
R["Rooms — bespoke interaction models<br/>(MusicRoom + views + player expansion)"]
SH["Shell — frame<br/>(Sidebar, MiniPlayer, PlayerExpansion, ToastHost)"]
SL["State layer — .svelte.ts runes<br/>(player, queue, search, library, rooms, …)"]
IPC["IPC bridge — typed invoke + events<br/>(commands.ts, events.ts)"]
V --> R
R --> SL
V --> SH
SH --> SL
R --> SH
SL --> IPC
end
subgraph Rust["Rust Backend (Tauri, single process)"]
PR["Plugin Runtime"]
SM["Search Manager"]
PE["Playback Engine"]
LS["Library Service"]
end
subgraph Plugins["Plugin processes"]
P1["local-files (Python)"]
P2["third-party (any language)"]
end
IPC <-->|"commands (invoke) + events (listen)"| PR
IPC <-->|"commands + events"| SM
IPC <-->|"commands + events"| PE
IPC <-->|"commands + events"| LS
PR <-->|"stdin/stdout JSON-RPC"| P1
PR <-->|"stdin/stdout JSON-RPC"| P2
3.1 Layer rules
- Components never import
@tauri-apps/apidirectly. All IPC access goes throughsrc/lib/ipc/*. This is the single seam that keeps the frontend testable (mock the bridge, not the OS) and swappable. - Components never create shared state. Shared state lives in
src/lib/state/*.svelte.ts. Components receive state through props and the state modules. A component may hold local$statefor ephemeral UI detail only. - Rooms never subscribe to Tauri events. The shell (via
ipc/bootstrap.ts) subscribes once and feeds the state layer; rooms read state modules and use theRoomContexthandles (section 6). - State modules never render. They contain state, derived values, and async actions (which call the IPC bridge). No DOM, no components.
- The IPC bridge never knows about rooms or views. It is a typed,
framework-free adapter over
@tauri-apps/api.
4. The typed IPC bridge
The bridge is the only code that touches @tauri-apps/api. It has two halves —
commands (frontend → Rust) and events (Rust → frontend) — plus a one-time
bootstrap that wires events into the state layer.
4.1 Commands — src/lib/ipc/commands.ts
One exported object per command group, matching api-specs/tauri-commands.md
exactly. The wrapper is where command argument mapping happens (TypeScript
camelCase args → the exact Tauri command name and payload shape).
Contract types and payload objects mirror the canonical snake_case field names
from data-models.md/tauri-events.md verbatim (e.g. PlaySource.plugin_id,
QueueState.current_index). Wrapper parameters are camelCase; Tauri v2 maps
top-level camelCase argument keys to snake_case Rust parameters automatically
({ positionSecs } reaches position_secs), while nested object fields pass
through unchanged for strict serde deserialization.
// src/lib/ipc/commands.ts
import { invoke } from '@tauri-apps/api/core';
import type {
PlaySource, PlaybackState, QueueState, TrackStub, Track,
Playlist, SearchConfig, PluginInfo,
} from '@rymflux/contracts';
export const playback = {
play: (source: PlaySource) => invoke<void>('play_track', { source }),
pause: () => invoke<void>('pause'),
resume: () => invoke<void>('resume'),
seek: (positionSecs: number) => invoke<void>('seek', { positionSecs }),
setVolume: (level: number) => invoke<void>('set_volume', { level }),
stop: () => invoke<void>('stop'),
advance: () => invoke<void>('advance_queue'),
state: () => invoke<PlaybackState>('get_playback_state'),
};
export const queue = {
add: (item: TrackStub, position?: number) => invoke<void>('queue_add', { item, position }),
remove: (index: number) => invoke<void>('queue_remove', { index }),
reorder: (from: number, to: number) => invoke<void>('queue_reorder', { from, to }),
clear: () => invoke<void>('queue_clear'),
get: () => invoke<QueueState>('queue_get'),
};
export const library = { /* library_*, playlist_*, history_*, favorites_* per tauri-commands.md */ };
export const search = { start: (q: string) => invoke<string>('start_search', { query: q }),
cancel: () => invoke<void>('cancel_search'),
config: (c: SearchConfig) => invoke<void>('set_search_config', { config: c }) };
export const plugins = { list: () => invoke<PluginInfo[]>('get_plugins'),
logs: (pluginId: string) => invoke<string>('get_plugin_logs', { pluginId }) };
4.2 Events — src/lib/ipc/events.ts
One typed subscription function per event in api-specs/tauri-events.md.
Each returns the listen unlisten function for teardown.
// src/lib/ipc/events.ts
import { listen } from '@tauri-apps/api/event';
import type { UnlistenFn } from '@tauri-apps/api/event';
import type { PlaybackState, QueueState, SearchResultBatch,
PluginStatus, TrackStub } from '@rymflux/contracts';
export function onPlaybackStateChanged(cb: (s: PlaybackState) => void): Promise<UnlistenFn> {
return listen<PlaybackState>('playback_state_changed', (e) => cb(e.payload));
}
export function onQueueUpdated(cb: (q: QueueState) => void): Promise<UnlistenFn> {
return listen<QueueState>('queue_updated', (e) => cb(e.payload));
}
// …onTrackChanged, onSearchResult, onSearchDone, onSearchError,
// onPluginStatusChanged, onLibraryUpdated, onPlatformReady, onPlaybackError
4.3 Types — src/lib/contracts/types.ts
data-models.md is the canonical type specification (TypeScript-first, per the
docs). types.ts is the executable mirror of that spec plus the event/command
payload types (PlaybackState, QueueState, SearchResultBatch,
PluginStatus from tauri-events.md; PluginInfo from plugin-runtime.md).
Rust serde structs must
match exactly; a code review step in CI diffs types.ts against
data-models.md and tauri-events.md (ADR-007’s “shared TypeScript types”
mitigation, made concrete). Auto-generation from a single schema (e.g. ts-rs
or an OpenAPI-style source) is a v2 option — an ADR would record that change.
4.4 Bootstrap — src/lib/ipc/bootstrap.ts
The shell subscribes to the full platform event surface exactly once, when the
root layout mounts, and routes each event into the state layer. Rooms never
call listen().
export function subscribeToPlatformEvents(): () => void {
const unlisteners = [
onPlaybackStateChanged((s) => playerState.apply(s)),
onTrackChanged((t) => playerState.trackChanged(t)),
onQueueUpdated((q) => queueState.sync(q)),
onSearchResult((b) => searchState.onResult(b)),
onSearchDone((q) => searchState.onDone(q.query_id)),
onSearchError((e) => searchState.onError(e)),
onPluginStatusChanged((p) => pluginsState.onStatus(p)),
onLibraryUpdated((u) => libraryState.onUpdated(u)),
onPlatformReady(() => appState.onReady()),
onPlaybackError((e) => toasts.playbackError(e)),
];
return () => unlisteners.forEach((u) => u());
}
4.5 Request tracing
cross-cutting-concerns.md specifies that every invoke generates a
request_id (UUIDv4) propagated into Rust logs and JSON-RPC ids. Convention:
wrappers for multi-step, correlated commands (play_track, start_search)
accept/generate a request_id and pass it in the payload; the matching Rust
handler accepts request_id: Option<String> and threads it into tracing.
Remaining commands rely on Rust-side ids. Full end-to-end correlation for every
command is an open item (see Open items).
5. State layer — runes, not stores
Shared frontend state is built on Svelte 5 runes, in .svelte.ts modules.
Legacy writable/readable/derived stores are not used for new code. The
two reasons to reach for a legacy store — external store interop and
StartStopNotifier semantics — do not arise in v1; if one does, that is a
localized exception, not a precedent. This follows ADR-010’s existing
direction: the frontend already owns authoritative state (the queue) in
“Svelte state”.
5.1 Canonical module pattern
Each state module is a class with $state fields, exported as a singleton
instance. The instance is a stable reference, so every importer sees the same
reactive object — the runes-era equivalent of a store, without subscription
machinery.
// src/lib/state/queue.svelte.ts
import { queue } from '@rymflux/ipc';
import { toasts } from './toasts.svelte.ts';
import type { QueueState, TrackStub } from '@rymflux/contracts';
function friendly(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
class QueueStateModule {
items = $state<TrackStub[]>([]);
currentIndex = $state<number | null>(null);
lastError = $state<string | null>(null);
// Called by ipc/bootstrap.ts on 'queue_updated' (engine mirroring back).
sync(state: QueueState) {
this.items = state.items;
this.currentIndex = state.current_index;
}
// Mutations: frontend-authoritative (ADR-010). Invoke the engine; the
// engine mirrors the authoritative list back via 'queue_updated'.
async add(item: TrackStub, position?: number) {
try {
await queue.add(item, position);
this.lastError = null;
} catch (err) {
this.lastError = friendly(err);
toasts.commandError('queue_add', err);
}
}
async remove(index: number) { await queue.remove(index); }
async reorder(from: number, to: number) { await queue.reorder(from, to); }
async clear() { await queue.clear(); }
// Engine restart (ADR-010 re-push): the engine's working copy is empty, so
// re-push the authoritative list from the frontend via queue_add commands.
async rePush() {
for (const [index, item] of this.items.entries()) {
await queue.add(item, index);
}
}
}
export const queueState = new QueueStateModule();
Rules:
- Name the file
*.svelte.ts— mandatory for files that use runes at the top level (they run in module scope). $statefor fields,$derived/$derived.byfor computed values, methods for actions. Do not reach for$effectto sync state to state — that is what$derivedis for.$effectis reserved for genuine side effects (e.g. announcing now-playing to screen readers).- Exported singleton instances (
export const playerState = …), never exported raw$stateprimitives (that loses the stable-reference benefit and invites re-assignment bugs). - State modules may call the IPC bridge and other state modules. They must
not import components, routes, or
$app/*.
5.2 State module inventory
| Module | Holds | Written by | Read by |
|---|---|---|---|
app.svelte.ts |
ready, bootstrapping, platformError |
platform_ready |
Root layout (loading screen gate) |
player.svelte.ts |
PlaybackState mirror (status, position, duration, volume, track) + trackChanged |
playback_state_changed, track_changed, commands |
MiniPlayer, PlayerExpansion, any room |
queue.svelte.ts |
Authoritative queue (ADR-010) | commands, queue_updated |
QueueView, MiniPlayer, rooms |
search.svelte.ts |
Session search state: queryId, per-query result groups, done, per-plugin errors (session cache per cross-cutting-concerns.md) |
start_search, search_result/search_done/search_error |
SearchView, rooms |
library.svelte.ts |
Favorites, playlists, history, saved tracks + onUpdated |
commands, library_updated |
Library views, Home (Continue Listening / Recently Played) |
rooms.svelte.ts |
Room registry lookup, active room, per-room preserved state map | route param, room lifecycle | Shell (sidebar, expansion slot), RoomRenderer |
plugins.svelte.ts |
PluginInfo[], status map |
get_plugins, plugin_status_changed |
Sidebar indicator, settings, search empty state |
toasts.svelte.ts |
Toast queue (recoverable-error + info notifications) | every recoverable failure path | ToastHost |
settings.svelte.ts |
UI preferences (player expansion boolean, pinned rooms, volume) | user actions | Shell, Home |
5.3 Search flow (progressive results)
search.svelte.ts owns the entire session-search lifecycle so rooms get a
Promise<SearchResults>-style facade with none of the event plumbing:
searchState.start(query)→search.start(query)→ returnsqueryId.onResult(batch)appendsbatch.resultsto the group forbatch.query_id(and matches the dedup/normalization the backend already did — the frontend displays, it does not re-rank).onDone(queryId)marks the query complete; asearchDonederived flag drives “N results from M plugins”.onError(e)surfaces a per-plugin notification (recoverable).- A new search replaces the previous group (session cache invalidation rule).
The shell exposes this to rooms through RoomContext.search →
searchState.runForRoom(query, roomId), which runs the same lifecycle and
records roomId on the session so the room correlates its own result groups.
6. Shell & room framework implementation
6.1 Shell frame — root +layout.svelte
The root layout renders the application frame for every route:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import Sidebar from '$lib/shell/Sidebar.svelte';
import MiniPlayer from '$lib/shell/MiniPlayer.svelte';
import PlayerExpansion from '$lib/shell/PlayerExpansion.svelte';
import ToastHost from '$lib/shell/ToastHost.svelte';
import { subscribeToPlatformEvents } from '@rymflux/ipc';
import { appState } from '@rymflux/state/app';
import { roomsState } from '@rymflux/state/rooms';
import { settingsState } from '@rymflux/state/settings';
const activeRoom = $derived(roomsState.byId(page.params.roomId));
onMount(() => {
const teardown = subscribeToPlatformEvents();
return teardown;
});
</script>
{#if !appState.ready}
<!-- loading gate; removed on 'platform_ready' -->
{/if}
<div class="shell">
<Sidebar />
<main><slot /></main>
{#if settingsState.playerExpanded && activeRoom?.playerExpansion}
<PlayerExpansion component={activeRoom.playerExpansion} />
{/if}
<MiniPlayer />
</div>
<ToastHost />
Shell responsibilities (unchanged from shell-room-framework.md): provide the frame; navigate between rooms; subscribe to platform events and route them to the state layer; render the active room and the player-expansion slot. The shell does not know what an “album” is.
6.2 Room registry — src/lib/shell/rooms.ts
// src/lib/shell/rooms.ts
import { lazy } from 'svelte';
import type { Room } from './types';
export const ROOMS: Room[] = [
{
id: 'music',
displayName: 'Music',
icon: 'music',
component: lazy(() => import('$lib/rooms/music/MusicRoom.svelte')),
},
// Future experience rooms added here (Podcast, Audiobook, …)
];
Static, compiled in, no dynamic loading (v1, per room-api.md). The component
field is wrapped in Svelte 5’s lazy(), so the room’s bundle is a separate
chunk that loads only on first activation — the sidebar reads id,
displayName, and icon without pulling in the room’s code. This is what
makes the code-splitting claims in section 1
and section 12 concrete. The registry is also the
single mount point for a future plugin- or config-driven room mechanism.
6.3 Room lifecycle
Rooms are mounted/unmounted by SvelteKit route changes. RoomRenderer
translates mount/unmount into the documented lifecycle hooks:
<!-- src/lib/shell/RoomRenderer.svelte -->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/state';
import type { Room, RoomContext } from './types';
import { makeRoomContext } from './context';
let { room }: { room: Room } = $props();
let ctx = $state<RoomContext>(makeRoomContext(room.id));
let error = $state<Error | null>(null);
onMount(() => {
try {
room.onActivate?.(ctx);
} catch (e) {
error = e as Error;
}
});
onDestroy(() => {
room.onDeactivate?.();
});
</script>
<ErrorBoundary onCaught={(e) => (error = e)}>
<svelte:component this={room.component} active={true} ctx={ctx} />
</ErrorBoundary>
onActivate(ctx)— room sets up state, fetches initial data, subscribes to state modules.onDeactivate()— room unsubscribes and saves draft state. Optional.- The state-preservation guarantee the earlier design got from “keep
everything mounted, hide via CSS” is now delivered by the state layer: a
room’s
state.svelte.ts(view stack, search results, form drafts) survives unmount, so re-entering the room rehydrates it identically. See Deviations.
6.4 RoomContext — src/lib/shell/context.ts
import { searchState } from '@rymflux/state/search';
import { queueState } from '@rymflux/state/queue';
import { libraryState } from '@rymflux/state/library';
import { playerState } from '@rymflux/state/player';
import type { RoomContext } from './types';
export function makeRoomContext(roomId: string): RoomContext {
return {
search: (query) => searchState.runForRoom(query, roomId),
queue: queueHandle(), // imperative facade over queueState methods
library: libraryHandle(),
playback: playbackHandle(), // play/pause/seek/volume/state over playerState + playback commands
navigate: (view) => { /* shell-level; no-op in v1 (only one room) */ },
};
}
Handles are imperative (rooms call methods, not stores), exactly as shell-room-framework.md prescribes.
6.5 Player expansion slot
Shell-level boolean in settings.svelte.ts (playerExpanded), toggled by the
expand button on the MiniPlayer, persisted in Svelte state (not SQLite). When
expanded and the active room defines playerExpansion, the shell renders it
into the slot with current track metadata; otherwise the slot is empty (not
hidden by CSS), per room-api.md.
7. Component architecture
Ownership is a hard boundary:
| Tier | Lives in | Owns | May import |
|---|---|---|---|
| Shell-owned | src/lib/shell/ |
frame, navigation, mini-player, expansion slot, toasts, error boundary | ui/, state/, ipc/ |
| Room-owned | src/lib/rooms/<room>/ |
every view, layout, context menu, sorting/filtering inside the room | ui/, components/, state/, shell types only |
| Shared feature | src/lib/components/ |
room-agnostic domain pieces (TrackRow, AlbumCard, TrackList, Artwork) | ui/, contracts/ |
| UI primitives | src/lib/ui/ |
design-system atoms (Button, Input, Slider, Menu, Card) — token-backed | styles/tokens.css, i18n/ |
- A room may not import another room’s internals.
- Shell-owned components must be generic enough for any room (the shell does
not know what an album is). If a component encodes a music concept, it
belongs in
components/or the Music Room. ui/primitives acceptclasspassthrough and follow the design system’s token API (colors, spacing, radii come fromtokens.css, never inline literals).
8. Navigation model
Two kinds, never mixed (room-api.md):
- Shell-level — between Home, rooms, and settings. Implemented as
SvelteKit routes:
/(Home/Lobby),/rooms/<id>,/settings. The shell’s Sidebar renders links; the router gives history + keyboard back for free.RoomContext.navigate(view)is the programmatic seam and is a no-op in v1 (only one room exists). - Room-internal — sub-views inside a room (album detail → track → queue).
A local navigation stack, never the router. Stored in the room’s
state.svelte.tsso it survives room switches:
// src/lib/rooms/music/state.svelte.ts
type MusicView =
| { kind: 'search'; query: string }
| { kind: 'album_detail'; album_id: string }
| { kind: 'artist_detail'; artist_id: string }
| { kind: 'queue' }
| { kind: 'now_playing' };
class MusicRoomState {
viewStack = $state<MusicView[]>([{ kind: 'search', query: '' }]);
currentView = $derived(this.viewStack.at(-1)!);
canGoBack = $derived(this.viewStack.length > 1);
navigate(view: MusicView) { this.viewStack = [...this.viewStack, view]; }
goBack() { if (this.viewStack.length > 1) this.viewStack = this.viewStack.slice(0, -1); }
}
export const musicRoomState = new MusicRoomState();
No URL routing, no shell involvement. Rooms are interaction models, not pages.
9. Error, loading, and empty states
Mirrors the three-tier severity model in cross-cutting-concerns.md:
| Severity | Frontend treatment |
|---|---|
| Fatal (app cannot function) | Route +error.svelte (fallback) + crash dialog from Rust panic handler. Terminal only for core init failures. |
| Recoverable (feature degraded) | Toast (via toasts.svelte.ts) + graceful degradation; per-plugin search errors become inline per-plugin notifications; playback_error → toast with retry/auto-advance. Room sub-tree failures → room-level recoverable fallback via ErrorBoundary. |
| Informational | Logged at debug/warn; surfaced in diagnostics (settings view, M16). |
Conventions:
- Loading: skeleton components (
components/Skeleton.svelte), never spinners for content panels; the boot-time loading gate is the only full-screen spinner. - Empty:
components/EmptyState.sveltewith icon, message, and a primary action — e.g. “No search plugins installed” with a link to the plugin setup guide (FR-CORE-4, FR-MUSIC-1). ErrorBoundary.svelte: Svelte 5 has no native component error boundary, so this is a wrapper around a room’s component sub-tree that catches errors surfaced fromonActivate/async handlers and renders a recoverable fallback with a “Retry” action. It never unmounts the shell frame. Ifsvelte:boundaryships in a future Svelte release, adopt it inside this wrapper.
10. Logging & observability (frontend)
cross-cutting-concerns.md assigns the frontend console/debug. A tiny
src/lib/log.ts provides a level-gated logger so debug noise is dropped in
production builds:
const DEBUG = import.meta.env.DEV;
export const log = {
error: (msg: string, data?: unknown) => console.error(`[rymflux] ${msg}`, data),
warn: (msg: string, data?: unknown) => console.warn(`[rymflux] ${msg}`, data),
info: (msg: string, data?: unknown) => console.info(`[rymflux] ${msg}`, data),
debug: (msg: string, data?: unknown) => DEBUG && console.debug(`[rymflux] ${msg}`, data),
};
Frontend logs carry the same contextual fields where available (content_id,
plugin_id, query truncated at 200 chars) so ~/.rymflux/logs/app.log and
the webview console tell the same story. Backend RUST_LOG=rymflux=debug
remains the primary diagnostic tool; the webview console is supplementary.
11. Accessibility
NFR-A11Y-1 (keyboard) and NFR-A11Y-2 (screen reader) are the contract; the backlog demotes keyboard (S8) and screen-reader support (C4) beyond MVP, but the architecture is built now so the wiring is cheap later:
- Semantic HTML first. Real
<button>,<a>,<input type="range">where possible. - Custom controls announce their role. The seek bar and volume are
role="slider"witharia-valuemin/max/nowand Arrow keys handle stepping — regardless of when NFR-A11Y-1 is formally enabled. - Live regions. Now-playing metadata and transport state (playing/paused)
announce via an
aria-live="polite"region fed byplayerState(a legitimate$effectuse). - Focus management. The room switch moves focus into the room’s primary
region;
RoomRenderersetsautofocus-eligible tabindex and the sidebar exposesaria-current. - Language & i18n — every string goes through
i18n/t()so English strings are a single catalog (NFR-I18N-1);lang="en"onapp.html.
12. Performance & startup
Targets from NFR-PERF / NFR-MAINT:
| Target | How the architecture delivers |
|---|---|
| Room switch ≤ 300ms p95 (NFR-PERF-6) | Route-level code splitting; room components load via lazy() on first activation; room state rehydrates from memory, no IPC on switch. |
| Startup ≤ 3s cold (NFR-MAINT-3) | Shell renders immediately; data hydrates in parallel after platform_ready; no blocking await before first paint. |
| Search results displayed ≤ 2s p95 (NFR-PERF-4) | Progressive search_result events append live to the results list; searchDone only marks completion. |
| Idle memory | State layer holds plain data; no room mount = no component cost (unmounted rooms are zero-footprint). |
Hydration order (matches first-time-setup.md):
- Root layout mounts → subscribe to platform events → loading gate on.
platform_ready→appState.ready = true→ loading gate off, nav enabled.- In parallel:
queue.get(),playerState.state(),plugins.list(),library.history()for Home.
13. Testing strategy
| Layer | Tooling | What is tested |
|---|---|---|
| State layer (unit) | Vitest node project (runes run in .svelte.ts outside components) |
ADR-010 queue mutations, search state machine (progressive results, per-plugin error), player state transitions; mock the IPC bridge. |
| Components (unit) | Vitest Browser Mode — vitest-browser-svelte (real webview, chromium) |
UI primitives, EmptyState/Loading/Error fallbacks, MiniPlayer, RoomRenderer lifecycle hooks; @tauri-apps/api mocked. |
| Integration | Vitest Browser Mode, rendering components with a mocked ipc/ bridge |
Event → state → component flow (e.g. queue_updated re-renders QueueView); universal .svelte.ts state flushed with flushSync(). |
| E2E (later phase) | Tauri v2 WebDriver against the built app, or Playwright against the dev server | Full user journeys (journey-listener-music.md); deferred until the app is feature-complete enough for smoke tests. |
Traceability rule: every Must FR with a Gherkin scenario in functional.md
gets at least one test case filed under docs/internal/testing/test-cases/
referencing its FR id, and the corresponding Vitest spec lives beside the code
it covers (queue.svelte.ts → queue.svelte.test.ts). The backend contract
(the IPC bridge) is treated as a seam: tests assert the wrappers invoke the
documented command names with the documented payloads.
The full layer detail, FR→test-case matrix, and vitest config shape live in
../testing/strategy.md. The Browser Mode tooling choice is formalized in
ADR-017.
14. Security notes
- No secrets in the frontend. Plugins manage their own credentials (per
cross-cutting-concerns.md); the webview never sees or stores them. - CSP is declared once in Tauri (
app.security.csp); no duplicate web CSP. withGlobalTauri: false; all access via typed@tauri-apps/apiimports through the bridge — no rawwindow.__TAURI__usage.- IPC payloads are deserialized strictly on the Rust side (serde strict types, 10MB message limit per plugin-protocol.md); the frontend treats all IPC responses as untrusted input and validates at the boundary.
15. Conventions & code style
- File naming: state modules
*.svelte.ts; components PascalCase (MusicRoom.svelte); views*View.svelte. - Aliases:
@rymflux/shell,@rymflux/contracts,@rymflux/ipc,@rymflux/state, plus$lib. Room authors import from@rymflux/shelland@rymflux/contractsonly. - No
anyat the IPC boundary. Every command/event payload is a typed contract type. - No inline color/length literals in components — design tokens only.
- Runes over stores (section 5);
$derivedover$effectfor state-to-state computation. - English-first strings via
t()— no hardcoded user-visible text.
16. Deviations and open items
- Mount model (deviation from
room-lifecycle.md). The earlier doc keeps deactivated rooms mounted and hidden via CSS to preserve state. SvelteKit route navigation unmounts room components. This architecture preserves the user-visible contract (instant switch, state preserved) by hoisting room state intostate.svelte.tsmodules and rehydrating on mount, and drops the CSS-hidden mechanism. This is strictly better for idle memory (supports the app-level NFR-RESC-1 baseline) and simpler than lazy-mounting. Formalized in ADR-012, which supersedes the lifecycle note. request_idthreading (4.5) requires Rust handler signatures to accept the optional field on correlated commands. Align signatures when the backend commands are implemented; file an ADR only if the convention changes.- UI system design is now written — see
docs/internal/system-design/services/ui-design-system.md. It defines the design tokens (styles/tokens.css), theui/primitive inventory (shadcn- svelte Rhea), the theme system (incl. an optionalthemeIdadded toRoom), and the wireframes. Thetokens.cssandui/primitives can be finalized against it; this document continues to own how the frontend is engineered. - Type parity tooling (4.3) — hand-sync
types.tswithdata-models.md/tauri-events.mdin v1; evaluatets-rs/schema generation for v2 via ADR. - E2E/WebDriver testing is deferred (13). Add test cases to
docs/internal/testing/test-cases/as features land.
References
- Container diagram — C4 containers (Shell UI, Music Room, Rust backend)
- Shell and Room Framework specification
- Room framework API
- Tauri command surface
- Tauri event surface
- Data models
- Cross-cutting concerns — logging, caching, error model, request tracing
- ADR-005 (Tauri events), ADR-007 (search manager + shared types), ADR-009 (room API TS interface), ADR-010 (frontend-authoritative queue), ADR-012 (room lifecycle — unmount + state hoisting), ADR-013 (state management — runes), ADR-014 (typed IPC bridge + shared contracts), ADR-015 (design tokens), ADR-016 (ambient dominant color), ADR-017 (Browser Mode component testing)
- Room lifecycle state machine
- FR-ROOM-1/2/3, FR-CORE-1/2/3/4, FR-MUSIC-1/2, FR-HOME-1 (
functional.md) - NFR-PERF-4/6, NFR-MAINT-3, NFR-RESC-1, NFR-A11Y-1/2, NFR-I18N-1 (
non-functional.md)