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

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.js files, 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.frontendDist points at the SvelteKit build output (../build), build.devUrl at the Vite dev server (port 1420; strictPort: true in vite.config.ts).
  • CSP: set once in tauri.conf.json (app.security.csp), allowing self and ipc: (Tauri v2 IPC channel). Do not declare a separate web CSP — the two can conflict. app.withGlobalTauri stays false; the frontend imports @tauri-apps/api explicitly.

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 to crates/ (one binary in src-tauri). Toolchain and commands: ../development/dev-environment.md.
  • src/lib/shell/rooms.ts is the ROOMS registry. (Earlier drafts referenced src/shell/rooms.ts; under SvelteKit everything under src/lib is the shareable surface, so the shell moved under src/lib/shell.)
  • @rymflux/shell/types.ts re-exports ComponentType from svelte exactly as ADR-009 prescribes — room authors import Room and ComponentType from the shell module, never from svelte directly.

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

  1. Components never import @tauri-apps/api directly. All IPC access goes through src/lib/ipc/*. This is the single seam that keeps the frontend testable (mock the bridge, not the OS) and swappable.
  2. 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 $state for ephemeral UI detail only.
  3. 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 the RoomContext handles (section 6).
  4. State modules never render. They contain state, derived values, and async actions (which call the IPC bridge). No DOM, no components.
  5. 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).
  • $state for fields, $derived/$derived.by for computed values, methods for actions. Do not reach for $effect to sync state to state — that is what $derived is for. $effect is reserved for genuine side effects (e.g. announcing now-playing to screen readers).
  • Exported singleton instances (export const playerState = …), never exported raw $state primitives (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:

  1. searchState.start(query)search.start(query) → returns queryId.
  2. onResult(batch) appends batch.results to the group for batch.query_id (and matches the dedup/normalization the backend already did — the frontend displays, it does not re-rank).
  3. onDone(queryId) marks the query complete; a searchDone derived flag drives “N results from M plugins”.
  4. onError(e) surfaces a per-plugin notification (recoverable).
  5. A new search replaces the previous group (session cache invalidation rule).

The shell exposes this to rooms through RoomContext.searchsearchState.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 accept class passthrough and follow the design system’s token API (colors, spacing, radii come from tokens.css, never inline literals).

8. Navigation model

Two kinds, never mixed (room-api.md):

  1. 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).
  2. 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.ts so 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.svelte with 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 from onActivate/async handlers and renders a recoverable fallback with a “Retry” action. It never unmounts the shell frame. If svelte:boundary ships 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" with aria-valuemin/max/now and 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 by playerState (a legitimate $effect use).
  • Focus management. The room switch moves focus into the room’s primary region; RoomRenderer sets autofocus-eligible tabindex and the sidebar exposes aria-current.
  • Language & i18n — every string goes through i18n/t() so English strings are a single catalog (NFR-I18N-1); lang="en" on app.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):

  1. Root layout mounts → subscribe to platform events → loading gate on.
  2. platform_readyappState.ready = true → loading gate off, nav enabled.
  3. 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.tsqueue.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/api imports through the bridge — no raw window.__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/shell and @rymflux/contracts only.
  • No any at 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); $derived over $effect for state-to-state computation.
  • English-first strings via t() — no hardcoded user-visible text.

16. Deviations and open items

  1. 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 into state.svelte.ts modules 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.
  2. request_id threading (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.
  3. UI system design is now written — see docs/internal/system-design/services/ui-design-system.md. It defines the design tokens (styles/tokens.css), the ui/ primitive inventory (shadcn- svelte Rhea), the theme system (incl. an optional themeId added to Room), and the wireframes. The tokens.css and ui/ primitives can be finalized against it; this document continues to own how the frontend is engineered.
  4. Type parity tooling (4.3) — hand-sync types.ts with data-models.md/tauri-events.md in v1; evaluate ts-rs/schema generation for v2 via ADR.
  5. E2E/WebDriver testing is deferred (13). Add test cases to docs/internal/testing/test-cases/ as features land.

References

Was this page helpful?