feat(panes): core detachable pane system + pop-out chip

The option-heavy player UIs (mixer, camera director, viz, audio routing)
all live in the rail popovers, which are exclusive: openPopFor() closes
the last one before opening the next. You cannot watch the mixer while
riding the camera, and both vanish the moment you look at the highway.

Add `window.feedBack.panes` — a core registry for live UI that is
authored once as `mount(root, ctx)` and hosted anywhere. Panes are
non-exclusive, and they survive song switches structurally: the dock is a
body child outside every .screen, so the per-song teardown never sees it.

The adoption cost for a plugin is two calls:

    feedBack.panes.register({ id, title, icon, mount, unmount });
    feedBack.panes.attachChip(myExistingDialogEl, id);

attachChip injects THE standard pop-out chip. Clicking it opens the pane
and hides the plugin's dialog, leaving a stub to bring it back. Core owns
the hide/restore, so every plugin's pop-out looks and behaves the same —
which is the point. It hides via a dedicated .fb-pane-detached class, not
.hidden/[hidden], because the dialogs we attach to already toggle those.

Everything a pane may touch arrives through `ctx` — never a global. That
is what will let the same mount() run inside a pop-out window, a separate
JS realm with no window.feedBack, no window.highway and no audio graph:

  ctx.call(domain, cmd, payload)  -> the capability bus
  ctx.on(event, fn)              -> the feedBack bus (allowlisted)
  ctx.subscribe(stream, fn)      -> playhead / meters
  ctx.state.get/set              -> persisted, main realm is the only writer
  ctx.playhead(), ctx.song(), ctx.toast(), ctx.close()

ctx tracks every subscription it hands out and drops them on unmount, so
a pane cannot leak listeners across a dock/undock cycle.

Streams exist because an AnalyserNode cannot cross a window boundary:
levels are reduced to numbers in the realm that owns the audio graph.
One shared rAF loop, refcounted against live subscriptions, dirty-checked
before fan-out, and stopped dead when the last pane closes.

Hosts register themselves with the manager rather than being imported by
it — the dock lands at priority 0 (the floor, always available), so the
OS pane window can drop in later without this code changing.

Ships two built-in panes: Now Playing (the reference pane — reads the bus,
a stream, and levels, and touches no globals) and Mixer (the same faders
as the rail, via ctx.call('audio-mix', ...), with the chip attached to the
real #mixer-control). Plus a "Panes" rail popover to open panes that have
no dialog of their own; the system tray will mirror that list.

Note the dock sits at z-index 110, not on the docs/plugin-v3-ui.md ladder
(transport 20, rail 30, popovers 40) — those live INSIDE #player's
stacking context, and #player is itself fixed at z-index 100. A dock below
100 is invisible on the one screen panes exist for. Body-level ladder:
#player 100 < dock 110 < toasts 120 < modals 200.

Pop-out windows, the system tray, manifest-declared panes and mirrorGlobal
(the window.__h3dCamCtl proxy the camera director needs) follow.

Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
topkoa
2026-07-12 16:54:15 -04:00
parent ea0ca94742
commit e5cbea2e9f
10 changed files with 1472 additions and 0 deletions
+30
View File
@@ -99,6 +99,10 @@
<link rel="stylesheet" href="/static/tour-engine.css">
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
<link rel="stylesheet" href="/static/v3/v3.css">
<!-- Detachable panes: the pop-out chip, the dock, and the widgets built-in
panes render with. Hand-authored (not Tailwind-scanned) so a
runtime-installed plugin can use the chip without shipping its own CSS. -->
<link rel="stylesheet" href="/static/panes/panes.css">
<!-- EVERY external script below is `defer`. Do not add a plain one.
`defer` and `type="module"` scripts share a single "execute after
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
@@ -1048,6 +1052,10 @@
<span class="v3-rail-border"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z"/></svg>
</button>
<button class="v3-rail-icon" type="button" data-rail="panes" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-panes" title="Panes" aria-label="Panes">
<span class="v3-rail-border"></span>
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M19,4H5A2,2 0 0,0 3,6V18A2,2 0 0,0 5,20H19A2,2 0 0,0 21,18V6A2,2 0 0,0 19,4M13,18H5V6H13V18M19,18H15V6H19V18Z"/></svg>
</button>
<button class="v3-rail-icon" type="button" data-rail="plugins" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-plugins" title="Plugin controls" aria-label="Plugin controls">
<span class="v3-rail-border"></span>
<span class="v3-rail-badge" id="v3-plugin-count" hidden></span>
@@ -1064,6 +1072,15 @@
injects into #player-controls (the auto-hiding transport) into
this stable, always-reachable popover. See player-chrome.js
(rehoming MutationObserver). -->
<!-- Panes: open/close any registered detachable pane. Populated from
the pane registry by static/panes/pane-launcher.js — a plugin
that calls feedBack.panes.register() shows up here for free. -->
<div id="v3-rail-pop-panes" class="v3-rail-pop hidden" role="group" aria-label="Panes">
<div class="v3-pop-label">Panes</div>
<div id="v3-rail-panes-list" class="flex flex-col gap-1"></div>
<p class="text-xs text-gray-500 px-1 pb-1 leading-snug">Panes stay open while you play, and across song switches.</p>
</div>
<div id="v3-rail-pop-plugins" class="v3-rail-pop hidden" role="group" aria-label="Plugin controls">
<div class="v3-pop-label">Plugin controls</div>
<div id="v3-plugin-controls-slot" class="v3-plugin-slot"></div>
@@ -1300,6 +1317,19 @@
<script defer src="/static/v3/interface-size-nudge.js"></script>
<script defer src="/static/v3/feedbarcade.js"></script>
<script defer src="/static/v3/player-chrome.js"></script>
<!-- Detachable panes. Strict order: the bridge defines the ctx contract, the
sampler and the manager build on it, hosts register themselves with the
manager, and the built-in panes register last (they call panes.register
+ panes.attachChip at load). Everything here runs after app.js, so
window.feedBack and the capability bus already exist. -->
<script defer src="/static/panes/pane-bridge.js"></script>
<script defer src="/static/panes/pane-streams.js"></script>
<script defer src="/static/panes/pane-manager.js"></script>
<script defer src="/static/panes/pane-dock.js"></script>
<script defer src="/static/panes/pane-chip.js"></script>
<script defer src="/static/panes/pane-launcher.js"></script>
<script defer src="/static/panes/builtin/now-playing.js"></script>
<script defer src="/static/panes/builtin/mixer-pane.js"></script>
<script>
// Navbar scroll effect
window.addEventListener('scroll', () => {