Target Frontend Architecture — ADR¶
Date: 2026-04-12
Status: Approved for implementation
Synthesizes: #643 (runtime contract), #646 (presentation architecture)
Informed by: #641 (audit), #642 (audio trace), #644 (parity matrix), #645 (migration plan)
Base commit: d87f50a (feat/642-audio-debug-logging)
Purpose¶
Single authoritative reference for the unified frontend architecture. All implementation issues (#647–#653) should be judged against this document.
This ADR is executable: it contains concrete TypeScript interfaces, Svelte 5 patterns, file layout, and enforcement rules. It is not a vision doc — it is a build spec.
Architecture overview¶
Four layers, strict one-way dependency:
┌────────────��─────────────��──────────────────────────┐
│ SKIN + LAYOUT + THEME │ ← visual shell
│ (amber-lcd, desktop-v2, mobile, future skins) │
├─────────────────��───────────────────────────��───────┤
│ SEMANTIC COMPONENTS │ ← behavior contracts
│ (VfoDisplay, MeterPanel, RxAudioControl, ...) │
├───────────────���─────────────────────────��───────────┤
│ VIEW-MODEL ADAPTERS │ ← pure functions
│ (toVfoProps, toMeterProps, toRxAudioProps, ...) │
├───────────────────────────────────────────────���─────┤
│ RUNTIME │ ← singleton, no DOM
│ (transport, audio, scope, state, commands) │
└─────────────────────────────────────────────────────┘
Rule: each layer depends only on the layer below. Never upward, never across.
Layer 1: Runtime¶
What it owns¶
All stateful side effects:
- HTTP bootstrap (polling, capabilities fetch)
- Control WebSocket (
/api/v1/ws) lifecycle - Audio WebSocket (
/api/v1/audio) lifecycle - Scope WebSocket (
/api/v1/scope,/api/v1/audio-scope) lifecycle - Browser
AudioContextand playback/mic lifecycle - Binary frame parsing (scope, audio)
- Command dispatch and optimistic state patches
- Reconnect logic and connection health
- Capability normalization
What it must not do¶
- Import Svelte components
- Reference DOM elements
- Know about skins, themes, or layouts
- Contain presentation logic
FrontendRuntime interface¶
interface FrontendRuntime {
// Reactive state (Svelte 5 $state internally)
readonly state: RadioState;
readonly capabilities: Capabilities;
readonly connection: ConnectionStatus;
readonly layoutPreference: 'auto' | 'lcd' | 'standard';
readonly isMobile: boolean;
// Controllers
readonly audio: AudioController;
readonly scope: ScopeController;
readonly system: SystemController;
// Single command dispatch entry point
cmd(command: string, params?: Record<string, unknown>): void;
}
AudioController¶
interface AudioController {
readonly rxEnabled: boolean;
readonly txEnabled: boolean;
readonly monitorMode: 'live' | 'radio' | 'mute';
readonly volume: number; // 0..1
readonly wsConnected: boolean;
readonly txSupported: boolean;
setMonitorMode(mode: 'live' | 'radio' | 'mute'): void;
setVolume(v: number): void;
startTx(): Promise<string | null>;
stopTx(): void;
}
Owns: /api/v1/audio WS, RxPlayer, TxMic, codec negotiation, AudioContext lifecycle.
Does not own: AF level radio command — that goes through cmd().
ScopeController¶
interface ScopeController {
readonly hardwareScopeAvailable: boolean;
readonly audioScopeAvailable: boolean;
readonly activeScope: 'hardware' | 'audio-fft' | null;
readonly scopeFrame: ScopeFrame | null;
readonly audioScopeFrame: AudioScopeFrame | null;
}
Owns: /api/v1/scope and /api/v1/audio-scope WS connections, binary frame parsing.
Presentation components consume scopeFrame/audioScopeFrame — they never open channels.
SystemController¶
interface SystemController {
powerOn(): Promise<void>;
powerOff(): Promise<void>;
connect(): Promise<void>;
disconnect(): Promise<void>;
identifyFrequency(freqHz: number): Promise<EibiResult | null>;
}
Owns: all HTTP calls to backend (/api/v1/radio/power, /api/v1/eibi/identify, etc.).
Layer 2: View-model adapters¶
Contract¶
Adapters are pure functions. No side effects, no subscriptions, no imports from transport/audio/WS.
type Adapter<T> = (
state: RadioState,
caps: Capabilities,
audio: AudioController,
receiver?: 'main' | 'sub',
) => T;
Each adapter returns a typed view-model with:
- derived display values
- semantic callbacks (bound from runtime controllers)
Examples¶
interface VfoViewModel {
freqHz: number;
mode: string;
filter: number;
dataMode: boolean;
isActive: boolean;
badges: Badge[];
onFreqChange: (hz: number) => void;
onModeChange: (mode: string) => void;
onFilterChange: (filter: number) => void;
}
interface RxAudioViewModel {
monitorMode: 'live' | 'radio' | 'mute';
hasLiveAudio: boolean;
volume: number;
muted: boolean;
onMonitorModeChange: (mode: string) => void;
onVolumeChange: (v: number) => void;
}
interface MeterViewModel {
sMeter: number;
swr: number | null;
power: number | null;
alc: number | null;
comp: number | null;
activeTxMeter: 'swr' | 'power' | 'alc' | 'comp';
}
interface ScopeViewModel {
available: boolean;
frame: ScopeFrame | null;
onTuneToFreq: (hz: number) => void;
onSpanChange: (span: number) => void;
}
Adapter location¶
frontend/src/adapters/ — one file per domain (vfo, audio, meter, scope, rf, tx, memory, system).
What adapters replace¶
The existing state-adapter.ts and command-bus.ts are the embryonic form of this layer. Migration should refactor them into the adapter pattern, not duplicate alongside.
Layer 3: Semantic components¶
Contract¶
A semantic component represents a radio concept with a stable typed interface.
Rules:
- receives only semantic props and callbacks
- no transport imports (
sendCommand,getChannel,audioManager) - no backend endpoint knowledge
- no concrete skin assumptions
- framework-level logic only (event handling, conditional rendering, a11y)
Semantic component list¶
| Component | Props (key fields) | Callbacks |
|---|---|---|
VfoDisplay |
freq, mode, filter, dataMode, badges | onFreqChange, onModeChange, onFilterChange |
RxAudioControl |
monitorMode, hasLiveAudio, volume, muted | onMonitorModeChange, onVolumeChange |
TxControl |
pttActive, txEnabled, txSupported | onPttToggle, onStartTx, onStopTx |
MeterPanel |
sMeter, swr, power, alc, comp | — |
ScopeSurface |
available, frame | onTuneToFreq, onSpanChange |
BandSelector |
currentBand, bands | onBandChange |
ModeSelector |
currentMode, modes | onModeChange |
FilterSelector |
currentFilter, filters | onFilterChange |
RfFrontEnd |
att, preamp, nb, nr, rfGain, squelch | onChange per field |
DspControl |
notch, tpf, contour | onChange per field |
MemoryControl |
channels, activeChannel | onRecall, onStore, onClear |
StatusIndicator |
connected, radioReady, wsHealth | onReconnect |
Svelte 5 pattern¶
Semantic components use children: Snippet or typed props — no <slot>. Skins provide concrete rendering.
<!-- semantic/RxAudioControl.svelte -->
<script lang="ts">
interface Props {
monitorMode: 'live' | 'radio' | 'mute';
hasLiveAudio: boolean;
volume: number;
muted: boolean;
onMonitorModeChange: (mode: string) => void;
onVolumeChange: (v: number) => void;
}
let { monitorMode, hasLiveAudio, volume, muted,
onMonitorModeChange, onVolumeChange }: Props = $props();
</script>
<!-- Semantic components can render directly or delegate to skin -->
<!-- The key constraint: no runtime imports above -->
Layer 4: Presentation (Skin + Theme + Layout)¶
Three independent knobs¶
| Knob | Changes | Mechanism | Example |
|---|---|---|---|
| Theme | Colors, fonts, spacing, shadows | CSS custom properties | dracula.css, nord.css, crt-green.css |
| Skin | Visual implementation of semantic components | Concrete Svelte components | AmberFrequency vs IndustrialFrequency |
| Layout | Spatial arrangement of components on screen | Grid/flex composition in skin top-level | DesktopGrid vs MobileTabs vs LcdColumn |
Theme does not change component identity. Skin does not change behavior contracts. Layout does not change transport ownership.
Skin implementation (Svelte 5)¶
Each skin is a concrete Svelte component receiving FrontendRuntime as its single prop:
<!-- skins/amber-lcd/LcdSkin.svelte -->
<script lang="ts">
import type { FrontendRuntime } from '../../runtime/types';
import { toVfoProps, toRxAudioProps, toMeterProps } from '../../adapters';
import AmberFrequency from './AmberFrequency.svelte';
import AmberMeter from './AmberMeter.svelte';
import AmberAudioStrip from './AmberAudioStrip.svelte';
import LcdFrame from './LcdFrame.svelte';
interface Props { runtime: FrontendRuntime }
let { runtime }: Props = $props();
const vfo = $derived(toVfoProps(runtime.state, runtime.capabilities, runtime.audio, 'main'));
const audio = $derived(toRxAudioProps(runtime.state, runtime.capabilities, runtime.audio));
const meter = $derived(toMeterProps(runtime.state, runtime.capabilities));
</script>
<LcdFrame>
<AmberFrequency freq={vfo.freqHz} mode={vfo.mode} />
<AmberMeter sMeter={meter.sMeter} />
<AmberAudioStrip
mode={audio.monitorMode}
volume={audio.volume}
onModeChange={audio.onMonitorModeChange}
onVolumeChange={audio.onVolumeChange}
/>
</LcdFrame>
Skin resolution and lazy loading¶
// skins/registry.ts
type SkinId = 'desktop-v2' | 'amber-lcd' | 'mobile';
function resolveSkinId(ctx: {
capabilities: Capabilities;
userPreference: 'auto' | 'lcd' | 'standard';
isMobile: boolean;
}): SkinId {
if (ctx.isMobile) return 'mobile';
if (ctx.userPreference === 'lcd') return 'amber-lcd';
if (ctx.userPreference === 'standard') return 'desktop-v2';
return ctx.capabilities.scope ? 'desktop-v2' : 'amber-lcd';
}
const SKIN_LOADERS: Record<SkinId, () => Promise<{ default: Component }>> = {
'desktop-v2': () => import('./desktop-v2/DesktopSkin.svelte'),
'amber-lcd': () => import('./amber-lcd/LcdSkin.svelte'),
'mobile': () => import('./mobile/MobileSkin.svelte'),
};
App entry point (target)¶
<!-- App.svelte -->
<script lang="ts">
import { runtime } from './runtime';
import { resolveSkinId, loadSkin } from './skins/registry';
const skinId = $derived(resolveSkinId({
capabilities: runtime.capabilities,
userPreference: runtime.layoutPreference,
isMobile: runtime.isMobile,
}));
let SkinComponent = $state(null);
$effect(() => {
loadSkin(skinId).then(c => { SkinComponent = c; });
});
</script>
{#if SkinComponent}
<SkinComponent {runtime} />
{:else}
<div class="loading">Loading...</div>
{/if}
Layout slot model¶
Each skin's layout arranges semantic components into named zones:
| Slot | Desktop V2 | LCD | Mobile |
|---|---|---|---|
header |
VfoHeader + StatusBar | StatusBar | Compact VFO + status |
leftRail |
Band, mode, filter, RF groups | LeftSidebar | — (tabbed) |
centerPrimary |
Hardware scope | AmberLcdDisplay | Scope or LCD |
centerSecondary |
Audio FFT / waterfall | Audio FFT (if available) | — |
rightRail |
Audio, DSP, TX, CW groups | RightSidebar | — (tabbed) |
bottomDock |
Meters, DX cluster | — | Bottom bar |
overlay |
Modals, freq entry, settings | Same | Same |
Skins are not required to fill all slots. Empty slots render nothing.
Theme architecture¶
Unchanged from current system — 20+ CSS variable themes, applied via data-theme attribute:
[data-theme="dracula"] {
--bg: #282a36;
--panel: #44475a;
--text: #f8f8f2;
--accent: #bd93f9;
/* ... */
}
Themes are orthogonal to skins. Any theme can be applied to any skin (within reason — amber-lcd may have its own locked theme token set).
Target file structure¶
frontend/src/
├── runtime/ # Layer 1
│ ├── index.ts # createRuntime(), singleton export
│ ├── types.ts # FrontendRuntime, controller interfaces
│ ├── audio-controller.ts # AudioController implementation
│ ├── scope-controller.ts # ScopeController implementation
│ ├── system-controller.ts # SystemController implementation
│ ├── command-dispatcher.ts # cmd() + optimistic patches
│ ├── connection-monitor.ts # health, reconnect, backoff
│ └── capability-resolver.ts # normalize caps into stable flags
│
├── adapters/ # Layer 2
│ ├── index.ts # re-export all adapters
│ ├── vfo.ts
│ ├── audio.ts
│ ├── meter.ts
│ ├── scope.ts
│ ├── rf.ts
│ ├── tx.ts
│ ├── memory.ts
│ └── system.ts
│
├── semantic/ # Layer 3
│ ├── VfoDisplay.svelte
│ ├── RxAudioControl.svelte
│ ├── TxControl.svelte
│ ├── MeterPanel.svelte
│ ├── ScopeSurface.svelte
│ ├── BandSelector.svelte
│ ├── ModeSelector.svelte
│ ├── FilterSelector.svelte
│ ├── RfFrontEnd.svelte
│ ├── DspControl.svelte
│ ├── MemoryControl.svelte
│ └── StatusIndicator.svelte
│
├── primitives/ # Shared visual atoms
│ ├── SegmentedButton.svelte
│ ├── Knob.svelte
│ ├── LinearMeter.svelte
│ ├── SevenSegment.svelte
│ ├── FrequencyDigits.svelte
│ ├── CanvasRenderer.svelte
│ ├── PanelFrame.svelte
│ └── StatusPill.svelte
│
├── skins/ # Layer 4 — visual implementations
│ ├── registry.ts # resolveSkinId(), loadSkin()
│ ├── desktop-v2/
│ │ ├── DesktopSkin.svelte
│ │ ├── DesktopVfo.svelte
│ │ ├── DesktopMeter.svelte
│ │ └── ...
│ ├── amber-lcd/
│ │ ├── LcdSkin.svelte
│ │ ├── AmberFrequency.svelte
│ │ ├── AmberMeter.svelte
│ │ └── ...
│ └── mobile/
│ ├── MobileSkin.svelte
│ ├── CompactVfo.svelte
│ └── ...
│
├── themes/ # CSS tokens
│ ├── tokens.css # base design tokens
│ ├── dark.css
│ ├── dracula.css
│ ├── nord.css
│ └── ...
│
├── stores/ # Svelte 5 reactive stores (kept)
│ └── ... # migrated into runtime/ over time
│
├── lib/ # Shared utilities (non-runtime)
│ ├── renderers/ # Canvas engines (framework-agnostic)
│ ├── utils/ # Formatting, smoothing, etc.
│ ├── data/ # ARRL band plan, etc.
│ └── gestures/ # Touch gesture recognizers
│
└── App.svelte # Entry point → skin resolver
Architectural invariants¶
These must hold at all times after migration. Violation = regression.
INV-1: Single RX playback path¶
All layouts route through the same AudioController. Codec negotiation, WS subscription, decoding, volume, and mute are layout-independent.
INV-2: Single scope ownership per type¶
Hardware scope and audio FFT are owned by ScopeController. Layouts choose whether and where to render — never whether to connect.
INV-3: scope=false + audio=true is first-class¶
Absence of hardware scope must never alter audio behavior. LCD fallback is a presentation concern, not a runtime concern.
INV-4: Capability gating precedes presentation¶
Panels do not independently decide whether backend capability exists. Runtime/adapters expose stable booleans (hasLiveAudio, hasHardwareScope, hasTx).
INV-5: Mount/unmount does not change transport¶
Swapping layouts or mounting/unmounting visual components must not start or stop transports.
INV-6: No runtime imports in presentation¶
Presentation components (semantic, skins, primitives) must not import from runtime/, $lib/transport/, or $lib/audio/audio-manager. Enforced by eslint.
Import boundary rules¶
Allowed¶
| Component type | May import from |
|---|---|
| Runtime | $lib/transport, $lib/audio, stores, types |
| Adapters | Runtime types (read-only), utilities |
| Semantic | Adapter types, primitives, Svelte, types |
| Skins | Semantic, primitives, adapters, themes, Svelte |
| Primitives | Themes, Svelte, types |
Forbidden¶
| Component type | Must NOT import from |
|---|---|
| Semantic | runtime/, $lib/transport/, $lib/audio/audio-manager |
| Skins | runtime/ (except FrontendRuntime type via props), transport, audio |
| Primitives | Runtime, adapters, transport, audio, stores |
eslint enforcement¶
{
"rules": {
"no-restricted-imports": ["error", {
"patterns": [{
"group": ["$lib/transport/*", "$lib/audio/audio-manager"],
"message": "Use adapter props. See ADR 2026-04-12."
}]
}]
},
"overrides": [{
"files": ["src/runtime/**", "src/adapters/**"],
"rules": { "no-restricted-imports": "off" }
}]
}
Anti-patterns¶
1. Skin imports runtime directly¶
Turns skins into hidden behavior forks. Skin receives FrontendRuntime as a prop from App.svelte — it never imports the singleton.
2. Theme contains behavior flags¶
Theme is for tokens only. Feature decisions belong in runtime/adapters.
3. Layout decides transport based on mount state¶
This is the current root cause of drift. Runtime owns transport lifecycle; layout only renders data.
4. Semantic component accepts escape hatches¶
Props like rawWsData or onRawCommand destroy the semantic boundary. If a semantic component needs new behavior, extend the adapter.
5. LCD treated as special case¶
LCD is a skin + layout variant over shared runtime. It is not a separate code path. No if (isLcd) in runtime or adapters.
6. Duplicated protocol parsing¶
Scope frame parsing, audio header parsing — all binary protocol logic lives in runtime controllers. Never in rendering components.
Migration coexistence¶
During Phases 4-5, old and new code coexist:
- New skins live in
skins/— existingcomponents-v2/untouched initially - Feature flag
?skin=unifiedinApp.svelteactivates new path - Old layouts remain default until parity gate passes
- Phase 6 removes old layouts after confirmation
Evidence gates¶
| After phase | Gate |
|---|---|
| Phase 0 | RX audio failure classified with evidence; eslint guardrails active |
| Phase 2 | One controller-owned audio/scope path; no presentation-owned WS connections |
| Phase 3 | Semantic component contracts stable for both desktop and LCD |
| Before Phase 6 | All 4 capability scenarios pass; Scenario B (scope=false + audio=true) explicitly verified; manual hardware validation |
Relationship to existing code¶
| Current code | Target equivalent | Migration action |
|---|---|---|
lib/audio/audio-manager.ts |
runtime/audio-controller.ts |
Refactor into controller with same internal logic |
components-v2/wiring/state-adapter.ts |
adapters/*.ts |
Split into per-domain adapters |
components-v2/wiring/command-bus.ts |
runtime/command-dispatcher.ts + adapter callbacks |
Extract command dispatch to runtime, bind callbacks in adapters |
lib/stores/*.svelte.ts |
runtime/ internal state |
Stores become implementation detail of runtime |
lib/transport/ws-client.ts |
runtime/ internal |
Transport is private to runtime |
components-v2/layout/RadioLayout.svelte |
skins/desktop-v2/DesktopSkin.svelte |
Rewrite as skin |
components-v2/layout/LcdLayout.svelte |
skins/amber-lcd/LcdSkin.svelte |
Rewrite as skin |
components-v2/panels/*.svelte |
semantic/*.svelte + skins/*/ visual parts |
Split semantic contract from visual implementation |
components-v2/theme/ |
themes/ |
Move as-is |
Summary¶
This architecture guarantees:
- New skin = new directory in
skins/, zero changes to runtime/adapters/semantic - New radio capability = changes in runtime + adapter, zero changes to skins
- Audio bug = fixed in one place (
runtime/audio-controller.ts), works identically in all skins - Behavioral parity = enforced architecturally (one runtime, one adapter set) and by eslint
- No code duplication = protocol parsing once in runtime, semantic logic once in adapters, visual variants only in skins