Shared Frontend Runtime Contract¶
Date: 2026-04-11
Status: Draft for discussion
Issue: #643
Based on: #641, #642
Base commit: f0e78cc0ea0fcfbafdadbb4a20167ba0bc110d6a
Objective¶
Define a strict frontend contract so:
v2main and LCD consume one shared runtime- layouts differ only in presentation and composition
- audio, scope, commands, capability gating, and connection lifecycle do not fork by mounted component path
Why this contract is needed¶
Current origin/main already has shared pieces:
- one app bootstrap
- shared stores
state-adaptercommand-bus- shared sidebars
But #641 showed that runtime ownership still leaks into presentation:
- WS channels opened by rendering components
audioManagerused directly in UI components- direct
sendCommand(...)in leaf components - direct backend
fetch(...)in layout components - duplicated protocol parsing in UI surfaces
That is enough to cause layout-dependent regressions even with one backend and one WS API.
Target architecture¶
The frontend should be split into four layers.
1. Runtime layer¶
Owns all stateful side effects:
- HTTP bootstrap
- control WS lifecycle
- scope/audio-scope WS lifecycle
- audio WS lifecycle
- browser playback lifecycle
- TX microphone lifecycle
- reconnect logic
- capability normalization
- command dispatch
- optimistic state updates
Examples of modules that belong here:
app runtime shellaudio controllerscope controllercontrol controller- shared stores
2. Adapter layer¶
Pure functions mapping runtime state into semantic view models.
Responsibilities:
- derive panel/view props
- normalize per-radio/runtime quirks into stable semantic values
- expose layout-independent callbacks supplied by runtime/controllers
This is where "what does the UI mean?" lives, but not "how does it talk to the backend?".
3. Semantic UI layer¶
Presentation components for radio concepts, not transport details.
Examples:
- VFO display
- RX audio controls
- TX controls
- meter panel
- memory panel
- scope surface
These components render view models and call provided callbacks.
4. Layout layer¶
Composes semantic UI into concrete screens:
- standard desktop
- LCD desktop
- mobile
Layouts may decide placement, visibility, grouping, and composition. Layouts must not own runtime side effects.
Ownership rules¶
Runtime owns¶
WebSocketcreation and teardown- endpoint paths like
/api/v1/ws,/api/v1/audio,/api/v1/scope,/api/v1/audio-scope audioManageror its replacement- browser
AudioContext/ decoder lifecycle - protocol parsing for binary frames
- backend HTTP commands
- optimistic patches to shared state
Adapters own¶
- mapping
ServerState + Capabilities + UiState -> semantic props - capability-based visibility decisions that affect behavior
- semantic labels and normalized enums used by more than one layout
Presentation/layout owns¶
- markup
- CSS/theme classes
- panel composition
- local ephemeral UI state that does not affect shared behavior
- popover open/close
- drag reorder of panel positions
- temporary expanded/collapsed UI state
Allowed imports and forbidden imports¶
Allowed in presentational components¶
- adapter-derived prop types
- stateless formatting helpers
- semantic callback props
- theme tokens/styles
- purely visual child components
Forbidden in presentational components¶
$lib/transport/ws-clientsendCommand(...)getChannel(...)$lib/audio/audio-manager- direct
fetch(...)to backend endpoints - binary protocol parsers for scope/audio frames
- capability stores when the check changes behavior rather than just visibility
Invariants¶
These must hold after migration.
Invariant 1¶
There is exactly one RX playback path for all layouts.
Consequence:
LIVEin standard and LCD must route through the same audio controller- codec negotiation, WS subscription, decoding, volume, and mute logic are layout-independent
Invariant 2¶
There is exactly one scope ownership path per scope type.
Consequence:
- hardware scope and audio FFT are mounted by runtime/controller ownership
- layouts only choose whether and where to render the resulting view surface
Invariant 3¶
scope=false + audio=true is a first-class supported state.
Consequence:
- absence of hardware scope must never alter audio behavior
- LCD fallback and standard layout selection are presentation concerns, not audio runtime concerns
Invariant 4¶
Capability gating for behavior happens before presentation.
Consequence:
- panels should not independently decide whether backend behavior exists
- adapters/runtime expose stable booleans like
hasLiveAudio,hasHardwareScope,hasTx
Invariant 5¶
Mounted component choice must not change transport ownership.
Consequence:
- swapping layouts cannot change which WS is connected
- mounting/unmounting a visual scope surface cannot be the thing that starts or stops the underlying scope stream
Proposed runtime shape¶
FrontendRuntime¶
A single runtime shell should expose:
statecapabilitiesconnectionStatusaudioStatescopeStateactions
actions¶
Runtime-level actions should include:
- tuning actions
- mode/filter actions
- TX/PTT actions
- RX audio monitor actions
- scope actions
- system actions
These are the only callbacks adapters/layouts should receive.
Proposed controller split¶
controlController¶
- owns
/api/v1/ws - sends commands
- applies optimistic updates
audioController¶
- owns
/api/v1/audio - owns browser playback and TX mic lifecycle
- exposes monitor mode / volume / mute / tx-audio actions
scopeController¶
- owns
/api/v1/scopeand/api/v1/audio-scope - parses binary frames once
- exposes normalized scope models
systemController¶
- owns power/connect/disconnect/eibi HTTP actions
Migration principles¶
Principle 1¶
Move ownership before moving visuals.
Do not rewrite layouts first. First extract transport/audio/command ownership into runtime/controllers.
Principle 2¶
Keep adapters pure.
If logic needs network, timers, browser APIs, or shared stores with side effects, it is not adapter logic.
Principle 3¶
Replace direct imports with injected callbacks or view models.
If a panel currently imports sendCommand, audioManager, or getChannel, that is a migration target.
Principle 4¶
Migrate by behavior slice, not by file tree.
Preferred order:
- audio ownership
- scope ownership
- system/backend HTTP actions
- remaining direct command dispatch
Principle 5¶
Guardrails must enforce the contract.
After migration, lint/tests should fail when presentational components import forbidden runtime modules.
Anti-drift rules for model-generated changes¶
Any future change must answer:
- Which layer owns this behavior?
- Does this component need side effects, or only a view model?
- If a new layout is added, would this logic fork?
If the answer implies layout-dependent behavior, the change belongs in runtime/controllers or adapters, not in a panel/layout component.
Out of scope¶
This contract does not define:
- skin/theme/token architecture
- component composition flexibility across skins
That belongs in #646.
Provisional conclusion¶
The codebase does not need a second frontend rewrite. It needs strict completion of the architecture it already started:
- one runtime shell
- one controller-owned side-effect layer
- pure adapters
- presentational panels/layouts only
That is the minimum contract required to keep v2, LCD, and future skins behaviorally identical while still allowing radically different presentation.