Panel system plan — VSCode-style modular panels with custom snap layouts¶
Status: proposed plan, not yet implemented (drafted 2026-07-19).
This document is the working reference for the panel-system effort;
decision stamps and docs/decisions/README.md rows land with the implementing commits, not here.
Context¶
The app today is three hard-coded pillars (LayoutShell.tsx: ATC audio | FR24 / video grid) with two drag dividers.
The goal is VSCode-style modularity:
first-class panels for audio, weather (currently nested inside the audio column), FR24, and each video stream;
free rearrangement;
custom "snap" layouts;
and videos that fit or stretch to their panel.
This aligns with two existing backlog items ("Named layout profiles", "Stream add/remove management UI" — the latter stays out of scope).
Direction settled with the owner on 2026-07-19:
- Custom drag-to-arrange with NO new dependency.
- Snaps = BOTH a template gallery and named saved layouts, and switching must not reload video streams.
- Per-video-panel Fit/Fill toggle (default Fit, persisted per feed).
- No tabs in v1.
- Pop-outs stay the multi-window mechanism.
Exploration summary (verified against the code)¶
react-resizable-panelsv4.12.2,zustand,zod, andelectron-storeare all already shipped and in use. Phases 0–6 are merged; only the v0.1.0 tag remains.- Weather is fully built (
src/main/weather.ts,weatherPoller.ts,src/shared/weather.ts,WeatherPanel.tsxmounted insideAudioPanel.tsx) — promotion is re-homing, not new data work. - The audio engine is a module singleton (bottom of
src/renderer/src/audio/engine.ts) surviving unmount; the audio UI can move freely. VideoTile.tsxcreates a realYT.Playerin an effect and destroys it on unmount, so any React re-parent or keyed sibling reorder (insertBefore) reloads the stream. Today's "fill" mode actually destroys the other six players — the new design must beat that.- FR24 is the only
WebContentsView(src/main/fr24.ts), a child ofmainWindow.contentView, painting above all DOM; occlusion is onlysetVisible(false). Renderer-side,Fr24Panel.tsxself-measures its region (getBoundingClientRect+ ResizeObserver + window resize +FR24_RELAYOUT_EVENT, rAF-throttled,boundsEqual-deduped) and sendsfr24:setBounds. The store fieldoverlay: OverlayKind | null(renamed from the booleanoverlayOpenby the channel-manager work) drivessetVisible(overlay === null). - Session:
session.jsonvia electron-store (src/main/session.ts), typed contract insrc/shared/ipc.ts, sanitizers insrc/shared/session.tsthat never throw; 500 ms debounced atomic flush; hydrated pre-mount (sessionBootstrap.ts) so there is no restore flash. - A design review read the rrp v4 dist source: the Group is uncontrolled;
an in-lifetime layout cache outranks
defaultLayouton re-bootstrap;defaultLayoutis discarded on any panel-id-set mismatch and on zero-size mount deferral. Driving a dynamic, profile-switched tree through rrp means fighting three interacting size mechanisms plus remount discipline.
Architecture: single-container canvas (not rrp + a media layer)¶
Render the whole panel area as one absolutely-positioned canvas:
- A pure serializable layout tree (splits with orientation + percentage sizes; leaves = panel ids) lives in the store and session.
- One pure function maps tree + container size to leaf rects + splitter segments (guillotine partition) —
the same shape as the existing, e2e-proven
computeVideoLayoutpattern. - All leaves render as stable-keyed siblings in one container, in fixed id-sorted DOM order, forever. Visual placement is style-only. React therefore never reorders or reparents panel DOM, so video iframes structurally cannot reload on drag-move, snap switch, close/reopen of other panels, or maximize.
- A custom splitter component (~150–250 lines: pointer capture, pure clamp math,
role="separator", arrow-key resize, ≥10 px hit area). Fr24Paneldrops in essentially unchanged — its self-measuring region div is simply positioned by the canvas.- Maximize = the maximized leaf gets the full rect and all other leaves get
visibility: hidden(players keep running — strictly better than today's fill mode).
Why not recursive rrp Groups plus a portal "media layer":
the rrp v4 uncontrolled-layout edges above;
a profile switch that keeps the same panel set but different sizes would silently not apply without extra remount tricks;
and the media layer metastasizes, because all video chrome (overlays, badges, controls) would have to migrate into it.
The canvas deletes both problem spaces at the cost of one well-tested splitter.
After this lands, react-resizable-panels has no consumers left (pop-outs use the plain CSS grid) — remove the dependency in the final PR with a decision stamp.
Load-bearing invariant (comment loudly at the render site):
leaves render sorted by panel id, never by tree position.
A keyed reorder executes insertBefore and reloads every iframe.
Data model and persistence¶
New pure domain module src/shared/panelLayout.ts (Electron/DOM-free — it compiles in both tsconfigs):
type PanelId = 'audio' | 'weather' | 'fr24' | `video:${string}`
interface LayoutLeaf { type: 'leaf'; id: PanelId }
interface LayoutSplit { type: 'split'; id: string; orientation: 'horizontal' | 'vertical';
children: LayoutNode[]; sizes: number[] /* pct, sum ~100 */ }
type LayoutNode = LayoutLeaf | LayoutSplit
interface LayoutProfile { name: string; tree: LayoutNode }
type VideoFitMode = 'fit' | 'fill'
interface PanelLayoutSession { tree: LayoutNode; maximizedPanelId: PanelId | null;
videoFit: Record<string, VideoFitMode>; profiles: LayoutProfile[] }
Session changes (src/shared/ipc.ts + src/shared/session.ts):
- Add
panelLayout: PanelLayoutSession | nulltoSessionStateandSessionPatch(whole-section replace, likewindow). - Remove
SessionState.layout(the rrpLayoutStoragestrings — library-owned, no longer read) and the top-levelSessionState.video. KeepVideoLayoutStateandPopoutState.video— pop-outs still use uniform/emphasized/fill. sanitizePanelLayoutSession/sanitizeLayoutTree: never throw; unknown leaf kinds and duplicate leaves dropped; 1-child splits collapsed; non-finite sizes renormalized (else equal); depth cap;maximizedPanelIdnot in tree becomes null; anything malformed becomesnull(= use the default tree).- Migration is drop-and-default.
Old sessions load with
panelLayout: null; legacy keys stop being read and fall off on the next flush. At hydrate, prunevideo:leaves whose feed id is not indefaultFeedsand against restored pop-outs; an emptied tree falls back tobuildDefaultTree. Downgrade to an old build is safe but loses the new layout section (document it).
Pure-function inventory (all guardian-tested; template tests/unit/videoLayout.test.ts)¶
In src/shared/panelLayout.ts:
collectLeafIds, normalizeTree, removePanel, splitPanel(tree, targetId, newLeaf, edge, sharePct), swapPanels, movePanel(tree, id, DropTarget), insertPanelBalanced (reopen path), updateSplitSizes(tree, splitId, sizes) (epsilon no-op returns the same reference), buildBalancedGrid (7 feeds → rows [3,2,2]), buildDefaultTree (mirrors today's 22/78 + 62/38 arrangement with weather split below audio), pruneVideoLeaves, treesEqual, the sanitizers,
plus computeLayoutRects(tree, containerRect, splitterPx) returning { leaves: {id, rect}[], splitters: {splitId, index, orientation, rect}[] },
and a min-px clamp helper for splitter drags.
src/shared/layoutTemplates.ts: template catalog (default,2x2,big-left,tall-right,three-columns) as zone trees, plusinstantiateTemplate(template, zoneAssignment, openVideoIds). Unassigned zones collapse; avideo-restzone becomesbuildBalancedGridof unassigned feeds.src/shared/videoGeometry.ts:computeAspectRect(containerSize, mode, aspect = 16/9)— fit = largest inscribed 16:9 centered; fill = smallest covering 16:9 centered (cropped byoverflow: hidden); integer output, never NaN.src/renderer/src/layout/dropZones.ts(pure, vitest-importable via@renderer):hitTestDropZone(point, leafRects, rootRect)returning an edge/center/root-edgeDropTarget;dropHighlightRect;withHysteresis(the current target sticks until the pointer exits its highlight rect + 8 px).
File inventory¶
New renderer files (src/renderer/src/layout/):
PanelCanvas.tsx— container ResizeObserver → size state; renders id-sorted leaves + splitters fromcomputeLayoutRects; ephemeral local sizes during splitter drags, store commit on release.LeafFrame.tsx—.panel-headchrome (title, fit toggle for video, pop out, move, maximize, close); body hosts AudioPanel / WeatherPanel / Fr24Panel / VideoTile.Splitter.tsx,useHeaderDrag.ts(drag state machine),DragOverlay.tsx(drop-zone highlight + light ghost).MovePanelModal.tsx— deterministic keyboard/e2e move path, theoverlaypattern (register a'move-panel'OverlayKind).LayoutManagerModal.tsx— template gallery + zone assignment + named profiles.panelMeta.ts(PanelId → title/kind, joinsdefaultFeeds),menuBridge.ts(store ↔ native menu IPC).
New main-process file: src/main/menu.ts —
application menu with a Layout menu (manager, save, reset, profile radios with CmdOrCtrl+Alt+1..9) and a Panels menu (open/close checkboxes),
rebuilt on layout:menuSync, clicks forwarded as layout:command.
Native menus paint above FR24 (the sanctioned surface).
Modified:
src/shared/ipc.ts (two new channels layout:menuSync / layout:command, AppApi.layout, session types),
src/shared/session.ts,
src/preload/index.ts,
src/main/ipc.ts (narrowing),
src/main/index.ts (menu wiring),
state/store.ts (slice swap below),
state/sessionBootstrap.ts (delete layoutStorage / hydrateVideoLayout / startVideoLayoutPersistence; add hydratePanelLayout + startPanelLayoutPersistence + pop-out↔tree reconcile),
components/LayoutShell.tsx (header + PanelCanvas + overlays + FR24 rule),
components/Fr24Panel.tsx (unchanged measurement; becomes a leaf body),
components/VideoTile.tsx (optional props: fitMode; emphasize/fill buttons render only for pop-outs),
components/VideoGrid.tsx (deleted from the main window; computeVideoLayout + the grid path stay for PopoutApp.tsx),
audio/AudioPanel.tsx (remove the nested WeatherPanel),
assets/main.css,
src/renderer/src/main.tsx.
Store slice (state/store.ts)¶
Remove videoLayoutMode / emphasizedFeedId / fillPanelFeedId and their actions
(just typecheck then lists every stale consumer to burn down).
Add panelTree, layoutRevision (bumped on structural commits), maximizedPanelId, dragPanelId, videoFit, layoutProfiles, activeProfileName (store-only; cleared by any structural edit),
plus actions delegating to the pure ops
(applyTree, movePanel, closePanel, openPanel, updateSplitSizes, toggleMaximize, setVideoFit, setDragPanelId, profile CRUD).
The overlay: OverlayKind | null field already exists (introduced by the channel-manager work, replacing the old boolean overlayOpen); this phase only extends the OverlayKind union with 'move-panel' and 'layout-manager' — it does not re-add an overlay flag.
Consolidated FR24 visibility rule (replaces the overlay === null-only effect):
fr24Visible ⟺ treeHasLeaf('fr24') ∧ overlay === null ∧ dragPanelId === null
∧ (maximizedPanelId ∈ {null, 'fr24'})
Single-writer sequencing:
hide transitions apply immediately (synchronously at drag start — the native view eats pointer events, so hiding during drags is required, not cosmetic);
the hidden→visible transition waits two rAF ticks after the tree commit so the freshly measured fr24:setBounds lands first (both are ordered fire-and-forget sends).
This also fixes the existing latent stale-bounds flash on modal close.
Mirror the flag as data-fr24-hidden on .app-shell for e2e.
Keep dispatching FR24_RELAYOUT_EVENT during splitter drags and after structural commits (layoutRevision effect) — Fr24Panel's existing rAF-throttled listener does the rest.
Key interactions¶
- Splitter drag: pointer capture on the splitter; pure clamp (min px per leaf, e.g. audio/fr24 200, weather 160, video 120);
ephemeral sizes during the drag; store commit (
updateSplitSizes) on release; relayout event per move. - Header drag-to-dock: pointerdown on
.panel-head(not on buttons) → 4 px slop → dragging;setPointerCapture; hit-test against computed rects from state (neverelementFromPoint); zones = center 40% box (swap), nearest edge (split at 50%),.app-bodyedges within 24 px (root dock at 25%); hysteresis; video hosts getpointer-events: noneduring drags; Escape cancels. Commit = one storeset(tree +dragPanelId = null). - Move without drag: every header has a "Move panel…" button opening
MovePanelModal(target + placement radios) committing the samemovePanelop — the accessible and e2e-deterministic path, landed before drag exists. - Maximize: header/tile double-click or button; Escape restores;
others get
visibility: hidden(streams keep playing — document that an unmuted hidden feed stays audible, consistent with the ATC mix philosophy). - Snaps:
LayoutManagerModalunder theoverlaypattern (a'layout-manager'OverlayKind) — template cards (mini flex previews) → per-zone assignment dropdowns (each panel once;video-restfor the remainder; sane default pre-fill) →applyTree. Named profiles: save (deep copy), apply, rename, delete; persisted insession.panelLayout.profiles; accelerators via the native menu. - Snap switching and players: the canvas diffs leaf sets — feeds present in both keep their DOM (no reload, the requirement); feeds absent from the new tree unmount (player destroyed — that profile excludes them); new feeds mount fresh.
- Pop-out interplay: a popped-out feed triggers
removePanel; closing the pop-out triggersinsertPanelBalanced; hydrate prunes against restored pop-outs.PopoutApp, thewindows:*channels, andPopoutStateare untouched. - Video fit/fill:
LeafFrameappliescomputeAspectRect(leafRect, videoFit[feedId])to a.video-tile-stagewrapper inside the overflow-hidden leaf body; toggle in the header,data-fit-modeattribute.
CSS (additions to assets/main.css, reusing .panel, .panel-head, .separator*, .modal-*)¶
.panel-canvas (relative container),
.leaf-frame (absolute; column flex; data-panel-id),
.panel-head--draggable/--dragging, .panel-head-actions/-btn,
.panel-slot (relative, overflow: hidden),
.video-tile-stage (absolute fit/fill target — .video-tile-player keeps inset: 0 within it),
.splitter.-vertical/-horizontal (≥10 px hit area, visible 1 px line via the existing separator look),
.drag-layer (fixed, z-60) + .dropzone-highlight (data-zone) + .drag-ghost (z-70),
.leaf-frame--hidden (visibility: hidden),
and template-gallery/profile classes inside the existing .modal shell.
Z-order: leaves are normal flow < drag layer 60 < modals 100; the FR24 native view is handled solely by the visibility rule.
Test plan¶
Guardian unit suites (tests/unit/):
panelLayoutTree.test.ts— ops, normalize idempotence, remove-last → null, move-onto-self identity plus untouched-subtree reference preservation, balanced grid shapes n=1..9, default tree mirrors today, prune.panelLayoutSanitize.test.ts— garbage, duplicate leaves, bad sizes, depth bomb, and a legacy-session fixture sanitizing topanelLayout: nullwith everything else intact; extendsession.test.tsfor the patch plumbing.layoutRects.test.ts— partition exactness (leaves + splitters tile the container), min-px clamping, splitter segments.layoutTemplates.test.ts,videoGeometry.test.ts(fit inscribed / fill covering / centered / integer / zero-safe),dropZones.test.ts(zones, root edges, hysteresis), plus a render-order guardian (canvasRenderOrderis arrangement-independent).
E2E (Playwright-Electron, existing e2eEnv() + YouTube network block + FR24_URL_OVERRIDE=about:blank):
panels.spec.ts— default layout has alldata-panel-idframes; weather is its own panel; close removes a frame; reopen restores; maximize via double-click setsdata-maximizedand Escape restores; the fit toggle flipsdata-fit-mode.layoutProfiles.spec.ts— apply the 2×2 template with assignments (structure asserted via rect geometry / DOM attributes); save a profile, perturb via MovePanelModal, re-apply; stream-survival proxy: anelementHandlecaptured inside a tile before the switch is stillisConnectedafter.panelDrag.spec.ts— the single pointer-simulation test: header drag to another panel's east half,dropzone-highlight[data-zone="east"]visible, drop, assert structure and thatdata-fr24-hiddentoggled true during the drag; FR24 native-view checks viaelectronApp.evaluate→contentView.children[0].getVisible()/getBounds().video.spec.ts— rewritten for per-feed panels (drop the grid-mode assertions, keep label/badge coverage).
Docs, decisions, changelog (stamps + rows land with the implementing commits)¶
- New
docs/design/Layout.md(what/why): panel model, tiled tree with no tabs in v1, snaps = templates + profiles, maximize semantics including hidden-but-audible feeds, drag docking, pop-out interplay. docs/development/TechStack.md: a "Panel layout system" section (how): tree in session, canvas render, splitters, menu IPC; update thereact-resizable-panelsrow when the dependency is removed.- Decision stamps to record:
(1) panel layout is a serializable split tree in
session.panelLayout, rendered by a single-container canvas, retiring rrp and itsLayoutStoragestrings; (2) leaves render in fixed id-sorted DOM order so rearrangement can never reload embeds; (3) main-window uniform/emphasized/fill retired in favor of maximize + per-feed fit/fill (pop-outs keep grid modes); (4) custom drag-to-dock on pointer events + pure hit-testing, with native menus + a DOM Move-panel modal as the FR24-safe/accessible fallbacks; (5)react-resizable-panelsdependency removed (final PR). docs/Implementation-Plan.md: move "Named layout profiles" out of Backlog into this phase; progress-log rows per PR.CHANGELOG.mdunder[Unreleased].
PR slicing (branches feature/* → PRs to develop, merge commits, app working after each)¶
feature/panel-layout-core— all pure modules (panelLayout.tsincludingcomputeLayoutRects,layoutTemplates.ts,videoGeometry.ts) + the additive session schema (legacy fields still written) + guardian suites + decision stamp 1. Zero behavior change.feature/panel-canvas-shell(keystone) — PanelCanvas/LeafFrame/Splitter, LayoutShell rewrite, weather promoted, store slice swap, sessionBootstrap swap (legacylayout/top-levelvideoremoved), VideoGrid deleted (main window), VideoTile prop split, consolidated FR24 rule + two-rAF reshow,video.spec.tsrewrite +panels.spec.tsbasics, stamps 2–3. The default layout looks identical to today.feature/panel-maximize-fit— maximize + Escape, fit/fill toggle + persistence, e2e.feature/panel-menu-move— close/reopen, MovePanelModal,layout:menuSync/layout:command+ preload +src/main/menu.ts+ narrowing, Panels menu.feature/layout-snaps— LayoutManagerModal (gallery + assignment + profiles), accelerators,layoutProfiles.spec.ts, the Implementation-Plan backlog move.feature/panel-drag-dock— useHeaderDrag, dropZones + guardian suite, DragOverlay, drag FR24 sequencing,panelDrag.spec.ts, stamp 4, and removal ofreact-resizable-panels(stamp 5). Pure polish risk — everything is already reachable via PR4's modal.
Risks and mitigations¶
- DOM-order invariant violated later → all streams reload.
Loud comment at the render site + a render-order guardian + the
isConnectede2e. - FR24 show-before-bounds race → the single-writer rule + two-rAF reshow;
e2e asserts
data-fr24-hiddentransitions and native-view bounds. - Custom splitter quality → pure clamp/normalize guardians, ≥10 px hit areas,
role="separator"+ arrow keys; skip collapse/double-click-reset in v1. - Drag hit-testing over live iframes / the native view → pointer capture + rect math from state; hosts pointer-inert during drags; FR24 hidden for the whole drag; one manual live-stream test before the show.
- Sanitizer bugs on corrupt or hand-edited sessions → a never-throw recursive sanitizer defaulting to the built-in tree, with fixture-based guardians.
Verification¶
- Per PR:
just lint,just typecheck,just fmt,just testclean;just e2efor the renderer-affecting PRs. - After PR2:
just dev— the default layout matches today's arrangement; splitters drag (FR24 tracks live); relaunch restores sizes; weather appears as its own panel; audio keeps playing while resizing. - After PR5: create a 2×2 snap, save two named profiles, switch via menu hotkeys — video placeholders must not remount (and with real network, streams must not rebuffer when present in both profiles).
- After PR6: header-drag a video panel onto FR24's east edge; FR24 disappears during the drag and reappears at correct bounds; Escape cancels mid-drag.
- Manual FR24 checks (real site, not the e2e stub): open the About modal, drag panels, maximize audio — the map must never paint over DOM, and there must be no stale-bounds flash on reappear.