icom-lan Frontend Architecture

ADR 2026-04-12 — Target architecture for unified V2/LCD runtime with skin/theme support

1. Проблема

V2 и LCD задумывались как две презентации над одним рантаймом. На практике runtime-ответственности утекли в компоненты.

НарушениеГдеПоследствие
Компоненты открывают WS-каналыSpectrumPanel, AudioSpectrumPanel, AmberLcdDisplayTransport lifecycle привязан к mount state
Прямой sendCommand()AmberLcdDisplay, MemoryPanelКоманды обходят command bus
Прямой audioManagerTxPanel, MobileRadioLayoutTX audio не централизован
Прямой fetch()StatusBar, LcdLayoutHTTP side effects в UI
Дублирование парсинга3 копии parseScopeFrameПротокольная логика размазана

Итого: 23 нарушения границ в 5 файлах. Достаточно для layout-зависимых регрессий даже при одном бэкенде.

2. Целевая архитектура: 4 слоя

Строгая однонаправленная зависимость. Каждый слой зависит только от слоя ниже.

Skin + Layout + Theme

Визуальная оболочка: amber-lcd, desktop-v2, mobile, будущие скины. CSS-темы.

4

Semantic Components

Контракты поведения: VfoDisplay, MeterPanel, RxAudioControl, ScopeSurface. Типизированные props + callbacks.

3

View-Model Adapters

Чистые функции: (State, Caps) → Props. Без side effects. Один адаптер на домен.

2

Runtime

Singleton. Владеет: WS, audio, scope, state, commands, reconnect. Без DOM.

1

3. Runtime: единый владелец side effects

FrontendRuntime

state
capabilities
connection
cmd()

Контроллеры

КонтроллерВладеетНе владеет
AudioController /api/v1/audio WS, AudioContext, RxPlayer, TxMic, codec negotiation AF level radio command (через cmd())
ScopeController /api/v1/scope WS, /api/v1/audio-scope WS, binary frame parsing Визуальный рендеринг (это skin)
SystemController HTTP: power, connect/disconnect, EIBI identify UI для этих действий

Ключевое: runtime — чистый TypeScript, без Svelte, без DOM. Можно тестировать юнитами.

4. Адаптеры: чистый маппинг state → props

RadioState
+
Capabilities
toVfoProps()
VfoViewModel

Каждый адаптер — чистая функция. Один файл на домен:

АдаптерВыходИспользуется в
vfo.tsVfoViewModel (freq, mode, filter, badges, callbacks)VfoDisplay
audio.tsRxAudioViewModel (monitorMode, volume, callbacks)RxAudioControl
meter.tsMeterViewModel (sMeter, swr, power, alc)MeterPanel
scope.tsScopeViewModel (available, frame, callbacks)ScopeSurface
rf.tsRfViewModel (att, preamp, nb, nr, callbacks)RfFrontEnd
tx.tsTxViewModel (pttActive, txSupported, callbacks)TxControl

Заменяет текущие state-adapter.ts + command-bus.ts — не дублирует, а рефакторит.

5. Три независимые ручки оформления

Theme

Меняет: цвета, шрифты, тени, радиусы
Механизм: CSS custom properties через data-theme
Примеры: dracula, nord, crt-green, amoled-black
20 тем уже есть — работают как есть

Skin

Меняет: визуальную реализацию компонентов
Механизм: конкретный Svelte-компонент для каждого semantic component
Примеры: AmberFrequency vs IndustrialFrequency
Не меняет поведение — только рендер

Layout

Меняет: расположение компонентов на экране
Механизм: grid/flex в top-level компоненте skin-а
Примеры: DesktopGrid, MobileTabs, LcdColumn
Не меняет transport ownership

6. Как работает skin

Skin — это Svelte-компонент, который получает FrontendRuntime как единственный prop:

App.svelte
resolveSkinId()
loadSkin(id)
<SkinComponent runtime />
Desktop V2 Skin
<DesktopSkin {runtime}>
  <VfoHeader>
    <IndustrialFrequency {vfo.freqHz} />
    <NeedleSMeter {meter.sMeter} />
  </VfoHeader>
  <SpectrumCanvas {scope.frame} />
  <LeftSidebar>
    <BandSelector ... />
    <ModeSelector ... />
  </LeftSidebar>
  <RightSidebar>
    <HardwareRxAudioPanel ... />
    <DspPanel ... />
  </RightSidebar>
</DesktopSkin>
Amber LCD Skin
<LcdSkin {runtime}>
  <LcdFrame>
    <AmberFrequency {vfo.freqHz} />
    <AmberSMeter {meter.sMeter} />
    <AmberAudioStrip
      mode={audio.monitorMode}
      onModeChange={audio.onModeChange}
    />
  </LcdFrame>
  <LeftSidebar>...</LeftSidebar>
  <RightSidebar>...</RightSidebar>
</LcdSkin>

Оба skin-а используют одни и те же адаптеры (toVfoProps, toMeterProps...) и один runtime. Различается только визуал.

7. Поток данных: от радио до пикселя

RX Audio (пример)

IC-7610
AudioBus
AudioBroadcaster (backend)
/api/v1/audio WS
AudioController
RxPlayer → AudioContext

User action (пример: смена режима мониторинга)

Click "LIVE"
onMonitorModeChange('live')
AudioController.setMonitorMode()
startRx() + resume AudioContext

UI-компонент не знает про WebSocket, AudioContext или codec. Он вызывает callback из props.

8. Целевая структура файлов

frontend/src/
├─ runtime/ — Layer 1: все side effects
│ ├─ index.ts — createRuntime(), singleton
│ ├─ types.ts — FrontendRuntime, controller interfaces
│ ├─ audio-controller.ts — AudioController (ex audio-manager)
│ ├─ scope-controller.ts — ScopeController
│ ├─ system-controller.ts — SystemController (HTTP calls)
│ ├─ command-dispatcher.ts — cmd() + optimistic patches
│ └─ connection-monitor.ts

├─ adapters/ — Layer 2: pure functions
│ ├─ vfo.ts, audio.ts, meter.ts, scope.ts
│ └─ rf.ts, tx.ts, memory.ts, system.ts

├─ semantic/ — Layer 3: behaviour contracts
│ ├─ VfoDisplay.svelte
│ ├─ RxAudioControl.svelte
│ ├─ MeterPanel.svelte, ScopeSurface.svelte
│ └─ TxControl.svelte, ...

├─ primitives/ — shared visual atoms
│ └─ SegmentedButton, Knob, LinearMeter, SevenSegment, ...

├─ skins/ — Layer 4: visual implementations
│ ├─ registry.ts — resolveSkinId(), loadSkin()
│ ├─ desktop-v2/ — DesktopSkin + visual components
│ ├─ amber-lcd/ — LcdSkin + AmberFrequency, AmberMeter...
│ └─ mobile/ — MobileSkin + CompactVfo...

├─ themes/ — CSS tokens (20+ тем, as-is)
├─ lib/ — renderers, utils, gestures, data
└─ App.svelte — skin resolver entry point

9. Архитектурные инварианты

Нарушение любого = регрессия.

INV-1
Единый путь RX playback
Все лейауты проходят через один AudioController. Codec, WS, decode, volume — layout-независимы.
INV-2
Единое владение scope
ScopeController владеет WS-каналами. Лейаут решает где рендерить — но не подключаться ли.
INV-3
scope=false + audio=true — first class
Отсутствие scope не влияет на audio. LCD fallback — решение презентации, не рантайма.
INV-4
Capability gating до презентации
Панели не решают есть ли capability. Runtime/adapters дают hasLiveAudio, hasScope, hasTx.
INV-5
Mount/unmount не меняет transport
Переключение лейаута не перезапускает WS. Вмонтирование scope surface не стартует стрим.
INV-6
Нет runtime-импортов в презентации
Semantic, skins, primitives не импортируют transport/audio-manager. Enforced eslint-ом.

10. Что это даёт

СценарийЧто менятьЧто НЕ менять
Новый skin (например, Nixie Tube) Новая папка skins/nixie/ Runtime, adapters, semantic — 0 изменений
Новая capability радио Runtime + adapter Skins — 0 изменений (если semantic component есть)
Audio bug (как #642) Один файл: runtime/audio-controller.ts Работает одинаково во всех скинах
Новая тема Один CSS-файл в themes/ Всё остальное
Behavioral parity Гарантирована архитектурно: один runtime, одни адаптеры, enforce eslint

11. Сейчас vs Target

АспектСейчасTarget
state-adapter + command-bus Зачатки, не enforced Полноценные adapters, eslint enforcement
AudioManager Синглтон, импортируется из 5 мест напрямую AudioController в runtime, доступен только через props
LCD layout Отдельный путь с дублированием Skin над теми же semantic components
Scope ownership getChannel() в 3 компонентах ScopeController, один WS, данные через props
Protocol parsing 3 копии parseScopeFrame Один раз в runtime controller
Темы 20 CSS тем, работают Остаются как есть
Boundary violations 23 нарушения 0 — enforced eslint rules

12. Фазы миграции

Строго последовательно. Runtime first, presentation second.

#647
Phase 0: Guardrails + диагностика

eslint import boundaries, diagnostic logging для audio failure

Gate: audio failure classified + guardrails active
#648
Phase 1: Runtime shell + adapter boundary

FrontendRuntime scaffold, controller interfaces, injected props

#649
Phase 2: Centralize audio + scope

AudioController, ScopeController — убрать getChannel из компонентов

Gate: no presentation-owned WS connections
#650
Phase 3: Semantic presentation layer

Semantic component APIs, skin registry, layout slots

Gate: semantic contracts stable for both desktop + LCD
#651
Phase 4: Migrate V2 desktop

Standard layout на unified architecture, feature flag

#652
Phase 5: Migrate LCD

LCD как skin + layout variant, validate scope=false+audio=true

#653
Phase 6: Cutover + cleanup

Удалить legacy paths, финальная проверка parity matrix

Gate: all 4 capability scenarios pass + manual HW validation

13. Анти-паттерны (запрещено)

#Анти-паттернПочему запрещён
1Skin импортирует runtime напрямуюПревращает skin в скрытый behavior fork
2Theme содержит behavior flagsTheme — только токены
3Layout решает transport по mount stateТекущий источник drift (scope/audio)
4Semantic component с escape hatches (rawWsData)Разрушает semantic boundary
5LCD как special case с if (isLcd)LCD = skin, не отдельный code path
6Дублирование protocol parsing в UIПарсинг — ответственность runtime

Итог

Не нужен полный rewrite. Нужно строгое завершение архитектуры, которая уже частично существует: один runtime → чистые адаптеры → semantic components → skins/themes/layouts.

ADR: docs/plans/2026-04-12-target-frontend-architecture.md