ADR 2026-04-12 — Target architecture for unified V2/LCD runtime with skin/theme support
V2 и LCD задумывались как две презентации над одним рантаймом. На практике runtime-ответственности утекли в компоненты.
| Нарушение | Где | Последствие |
|---|---|---|
| Компоненты открывают WS-каналы | SpectrumPanel, AudioSpectrumPanel, AmberLcdDisplay | Transport lifecycle привязан к mount state |
Прямой sendCommand() | AmberLcdDisplay, MemoryPanel | Команды обходят command bus |
Прямой audioManager | TxPanel, MobileRadioLayout | TX audio не централизован |
Прямой fetch() | StatusBar, LcdLayout | HTTP side effects в UI |
| Дублирование парсинга | 3 копии parseScopeFrame | Протокольная логика размазана |
Итого: 23 нарушения границ в 5 файлах. Достаточно для layout-зависимых регрессий даже при одном бэкенде.
Строгая однонаправленная зависимость. Каждый слой зависит только от слоя ниже.
Визуальная оболочка: amber-lcd, desktop-v2, mobile, будущие скины. CSS-темы.
4Контракты поведения: VfoDisplay, MeterPanel, RxAudioControl, ScopeSurface. Типизированные props + callbacks.
3Чистые функции: (State, Caps) → Props. Без side effects. Один адаптер на домен.
Singleton. Владеет: WS, audio, scope, state, commands, reconnect. Без DOM.
1| Контроллер | Владеет | Не владеет |
|---|---|---|
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. Можно тестировать юнитами.
Каждый адаптер — чистая функция. Один файл на домен:
| Адаптер | Выход | Используется в |
|---|---|---|
vfo.ts | VfoViewModel (freq, mode, filter, badges, callbacks) | VfoDisplay |
audio.ts | RxAudioViewModel (monitorMode, volume, callbacks) | RxAudioControl |
meter.ts | MeterViewModel (sMeter, swr, power, alc) | MeterPanel |
scope.ts | ScopeViewModel (available, frame, callbacks) | ScopeSurface |
rf.ts | RfViewModel (att, preamp, nb, nr, callbacks) | RfFrontEnd |
tx.ts | TxViewModel (pttActive, txSupported, callbacks) | TxControl |
Заменяет текущие state-adapter.ts + command-bus.ts — не дублирует, а рефакторит.
data-themeAmberFrequency vs IndustrialFrequencySkin — это Svelte-компонент, который получает FrontendRuntime как единственный prop:
<DesktopSkin {runtime}>
<VfoHeader>
<IndustrialFrequency {vfo.freqHz} />
<NeedleSMeter {meter.sMeter} />
</VfoHeader>
<SpectrumCanvas {scope.frame} />
<LeftSidebar>
<BandSelector ... />
<ModeSelector ... />
</LeftSidebar>
<RightSidebar>
<HardwareRxAudioPanel ... />
<DspPanel ... />
</RightSidebar>
</DesktopSkin>
<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. Различается только визуал.
UI-компонент не знает про WebSocket, AudioContext или codec. Он вызывает callback из props.
Нарушение любого = регрессия.
| Сценарий | Что менять | Что НЕ менять |
|---|---|---|
| Новый 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 | |
| Аспект | Сейчас | 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 |
Строго последовательно. Runtime first, presentation second.
eslint import boundaries, diagnostic logging для audio failure
FrontendRuntime scaffold, controller interfaces, injected props
AudioController, ScopeController — убрать getChannel из компонентов
Semantic component APIs, skin registry, layout slots
Standard layout на unified architecture, feature flag
LCD как skin + layout variant, validate scope=false+audio=true
Удалить legacy paths, финальная проверка parity matrix
| # | Анти-паттерн | Почему запрещён |
|---|---|---|
| 1 | Skin импортирует runtime напрямую | Превращает skin в скрытый behavior fork |
| 2 | Theme содержит behavior flags | Theme — только токены |
| 3 | Layout решает transport по mount state | Текущий источник drift (scope/audio) |
| 4 | Semantic component с escape hatches (rawWsData) | Разрушает semantic boundary |
| 5 | LCD как 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