Shell and Room Framework specification
Shell and Room Framework specification
Why the shell doesn’t own presentation
A shell that controls layout, navigation, and metadata rendering produces a one-size-fits-all UI — exactly what the vision rejects. The shell’s job is limited to:
- Providing the application frame (titlebar, sidebar, mini-player).
- Managing navigation between rooms.
- Subscribing to platform events and routing them to the active room.
- Rendering the active room’s component and the player expansion slot.
The shell does not know what an “album” is. It does not know what a “podcast episode” is. It does not render search results differently for music vs. audiobooks. Rooms own all of that. The shell is a frame, not an application.
Responsibility
Shell: top-level Tauri webview, navigation, mini-player, room registry, event dispatch. Room Framework: room registration, lifecycle, context injection.
Room registry
Static array compiled into the binary:
// src/shell/rooms.ts
const ROOMS: Room[] = [
MusicRoom,
// Future experience rooms (Podcast, Audiobook, Radio, etc.) added here
];
No dynamic loading in v1. Rooms are Svelte components in the same repository.
Lifecycle
Inactive → shell activates → Active (onActivate called)
Active → shell deactivates → Inactive (onDeactivate called)
V1 only implements Inactive ↔ Active. No Suspended state — deactivated rooms remain mounted in the DOM (hidden via CSS). This makes reactivation instant and avoids state serialization.
onActivate(ctx): room receivesRoomContextwith platform handles. Room should set up its state, fetch initial data, subscribe to events.onDeactivate(): room should unsubscribe from events, save draft state. Component remains mounted.- Rooms that don’t need cleanup don’t implement
onDeactivate.
RoomContext
interface RoomContext {
search: (query: string) => Promise<SearchResults>;
queue: QueueHandle;
library: LibraryHandle;
playback: PlaybackHandle;
navigate: (view: string) => void;
}
Handles are imperative — rooms call methods directly, not through stores.
Player expansion slot
The shell reserves a region in its layout. When the player is in expanded state and the active room defines playerExpansion, the shell renders the room’s component into that region.
- Expansion state: shell-level boolean, toggled by expand button
- Persisted in: Svelte state (not SQLite)
- Rooms without
playerExpansion: the slot is empty, not hidden by CSS
<!-- Shell layout (simplified) -->
<div class="shell">
<nav><!-- room navigation --></nav>
<main><!-- active room component --></main>
{#if playerExpanded && activeRoom?.playerExpansion}
<div class="player-expansion-slot">
<svelte:component this={activeRoom.playerExpansion} {...expansionProps} />
</div>
{/if}
<MiniPlayer />
</div>
Room-internal navigation
Rooms manage their own sub-view navigation. The shell does not provide a router. The expected pattern is a local navigation stack (see Navigation: shell-level vs room-internal for the full definition and examples).
Key rule: if a navigate call crosses a room boundary, it is shell-level
navigation. If it stays within the room, it is room-internal. In v1 with only
one room, shell-level navigate is a no-op stub — there is no other room to
switch to.
Event subscriptions
The shell subscribes to all Tauri events and provides derived state to rooms through RoomContext handles. Rooms do not subscribe to Tauri events directly.
Events the shell subscribes to:
playback_state_changed→ updates mini-player, propagates to active roomtrack_changed→ updates queue displayqueue_updated→ updates queue displaysearch_result/search_done/search_error→ provides to active roomplugin_status_changed→ updates plugin status displaylibrary_updated→ refreshes library viewsplatform_ready→ removes loading screen, enables navigationplayback_error→ shows toast notification
References
- ADR-009 (Room API TypeScript interface)
- FR-ROOM-1, FR-ROOM-2, FR-ROOM-3 (functional.md)