feat(panes): pop-out windows — the pane realm, hub, and remote transport

A pane can now leave the main window entirely. Same `mount(root, ctx)`,
same file, different JS realm — which is what the ctx-only contract in the
previous commit was for.

## A purpose-built document, not the app shell with a flag on it

`GET /pane` serves static/panes/pane.html: the bridge, the runtime, and
the pane's own script. No highway, no library, no v3 shell, no <audio>,
no Tailwind.

The splitscreen follower takes the other road — it reloads the whole app
at `/?ssFollower=1` and hides what it doesn't want — and pays for it with
an anti-flash block that must run before any script parses (index.html),
bail-outs in app.js and shell.js, and ~40 lines of CSS hiding core
elements by id. It loads the entire app to throw it away. A pane window
has nothing to throw away, so it boots in milliseconds and there is
nothing to flash.

The cost is that `window.feedBack` in a pane realm is a deliberate,
documented SUBSET. The runtime installs exactly what a pane is promised —
`panes.register`, and the no-op chip/dock calls a shared script may make
at load — so a pane reaching for something it was never given fails
loudly at authoring time instead of subtly at runtime.

## The channel

BroadcastChannel('feedback-panes'), same origin. This works only because
Electron's setWindowOpenHandler returns `action: 'allow'` for same-origin
URLs: `deny` would push the window to the system browser, a different
Chromium instance, where BroadcastChannel cannot reach it and the pane
would silently never sync. That flag is load-bearing.

  hello -> snapshot   resync-on-open, always. The snapshot is the only way
                      the pane realm learns anything.
  state               main is authoritative. A pane's write is a REQUEST;
                      main applies it and echoes to every realm, so a
                      losing write self-corrects instead of splitting brain.
  rpc / rpc:reply     ctx.call() -> the capability bus, with a 10s deadline.
                      Without one, a main window that died mid-call leaves
                      the pane's promise pending forever.
  event               allowlisted bus events, JSON-safe. A CustomEvent
                      carrying a DOM node (highway:canvas-replaced does)
                      would throw on postMessage and take the channel down
                      for everyone, so detail is round-tripped through JSON.
  stream              one coalesced message per pane per frame, OVERWRITING
                      anything not yet flushed. Queueing would build a
                      backlog: Chromium throttles a backgrounded window, and
                      the main window is exactly what's backgrounded while
                      the user looks at the pane.
  sub / unsub         refcounts the main-realm sampler.
  bye                 both directions.

## The follower clock

The pane extrapolates between broadcasts: anchor + observedRate * elapsed,
capped at 2s. observedRate is learned from the broadcasts themselves
(dt/dwall) so it tracks the speed slider without being told about it, and
seeks/pauses are excluded from the fit — a jump is not a tempo. Capping it
means a dead main window decays into a frozen clock rather than one that
confidently runs away. This is splitscreen's hard-won trick, generalized:
panes just call ctx.playhead().

## Failure modes, all of them

- Main window closes -> `bye {main-closed}` and the pane says so plainly,
  rather than showing a frozen playhead that looks live. The host also
  closes its windows outright; a pane that cannot be fed should not be on
  screen.
- Pane window X'd or crashed -> a `closed` poll reaps it (a crashed
  renderer never sends `bye`), the pane closes, and the chip's dialog comes
  back. Without this the user's dialog stays hidden with no way back.
- Popup blocked -> a toast, and we bail BEFORE the manager records
  anything, so the caller's dialog stays exactly where it was.
- Nobody answers `hello` in 5s -> the window says so instead of spinning.
- A pane with no `script` is a closure in this realm and cannot honestly
  cross a window boundary. The window host declines it (canHost) and the
  router falls back to the dock.
- A browser blocks window.open() outside a user gesture, so a popped-out
  pane cannot be auto-restored on page load — it would only ever produce a
  "blocked" toast. Such a pane comes back in the DOCK, and the chip pops it
  out again on the next click. (autoRestore: false. The desktop host will
  set it true.)

Hosts may now declare `remote: true`, meaning the pane's mount() runs in
another realm: the manager then owns only the authoritative state store and
never calls mount() itself. That is the seam the Electron BrowserWindow +
tray host drops into next, with no change here.

Verified: popped Now Playing and Mixer into real windows. The pane realm has
no window.highway, no capability bus and no <audio>, yet the Mixer renders
its faders via ctx.call('audio-mix','list-faders') across the channel — and
dragging that fader IN THE PANE WINDOW moved the main window's song volume
to 55 and persisted it. Closing the pane window un-hid the mixer dialog,
removed the stub and restored the chip, while the other pane window stayed
open.

Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
topkoa
2026-07-12 17:03:21 -04:00
parent e5cbea2e9f
commit fefb9051a4
11 changed files with 829 additions and 16 deletions
+162
View File
@@ -101,6 +101,7 @@
// ── Local transport (main realm) ─────────────────────────────────────────
const CALL_TIMEOUT_MS = 2100; // matches core's own audio-mix calls
const RPC_TIMEOUT_MS = 10000; // cross-realm deadline; generous, but never infinite
function createLocalTransport(paneId) {
return {
@@ -159,6 +160,162 @@
};
}
// ── Envelopes ────────────────────────────────────────────────────────────
//
// { v, type, paneId, hostId, seq, payload }
//
// type: hello pane→main the pane realm booted; main replies `snapshot`
// snapshot main→pane spec + state + current song. Resync-on-open, always.
// state both { path, value }. Main is authoritative: a pane's
// write is a request; main applies it and echoes to
// every realm, so a losing write self-corrects.
// rpc pane→main { seq, domain, command, payload }
// rpc:reply main→pane { seq, ok, result | error }
// event main→pane a mirrored feedBack bus event { name, detail }
// stream main→pane coalesced numerics (playhead, meters)
// sub/unsub pane→main drives the main-realm sampler's refcount
// bye both clean teardown (main-closed / pane-closed)
function envelope(type, paneId, hostId, payload) {
return { v: PROTOCOL_VERSION, type: type, paneId: paneId, hostId: hostId, payload: payload };
}
function openChannel() {
if (typeof BroadcastChannel !== 'function') return null;
return new BroadcastChannel(CHANNEL_NAME);
}
// ── Remote transport (pane realm) ────────────────────────────────────────
const MAX_EXTRAP_S = 2.0; // never extrapolate the clock further than this
function createRemoteTransport(paneId, hostId, channel) {
const listeners = new Map(); // event name -> Set<fn>
const streamSubs = new Map(); // stream name -> Set<fn>
const pending = new Map(); // rpc seq -> { resolve, reject, timer }
let rpcSeq = 0;
let song = null;
// The follower clock. The main window broadcasts the playhead every frame
// — but Chromium throttles a BACKGROUNDED window's rAF to ~1 Hz, and the
// main window is exactly what's in the background while the user looks at
// this pane. So we extrapolate between messages instead of rendering 1 Hz
// stutter: anchor + observedRate * elapsed.
//
// observedRate is measured from the broadcasts themselves (Δt / Δwall), so
// it tracks the speed slider without being told about it. Capped at
// MAX_EXTRAP_S so a dead main window decays into a frozen clock rather
// than a clock that confidently runs away.
let anchorT = 0, anchorWall = 0, observedRate = 1, playing = false, duration = 0;
function _onPlayhead(p) {
const now = performance.now();
if (anchorWall && p.playing && playing) {
const dt = p.t - anchorT;
const dw = (now - anchorWall) / 1000;
// Ignore seeks and pauses when learning the rate: a jump is not a
// tempo. Only smooth, forward-moving deltas teach us anything.
if (dw > 0.05 && dt > 0 && dt < dw * 4) {
const r = dt / dw;
observedRate = observedRate * 0.8 + r * 0.2; // light smoothing
}
}
if (!p.playing) observedRate = 1; // a paused clock has no rate to learn
anchorT = p.t;
anchorWall = now;
playing = p.playing;
duration = p.duration;
}
function playhead() {
if (!anchorWall) return anchorT;
if (!playing) return anchorT;
const elapsed = Math.min(MAX_EXTRAP_S, (performance.now() - anchorWall) / 1000);
return anchorT + observedRate * elapsed;
}
function handle(msg) {
const p = msg.payload || {};
switch (msg.type) {
case 'event': {
const set_ = listeners.get(p.name);
// Shaped like a CustomEvent so a pane's handler is identical in
// both realms — `e.detail`, not `e`.
if (set_) set_.forEach((fn) => { try { fn({ detail: p.detail }); } catch (e) { console.error('[pane] event handler threw', e); } });
if (p.name === 'song:loaded') song = p.detail || null;
break;
}
case 'stream': {
if (p.playhead) _onPlayhead(p.playhead);
for (const name in p) {
const set_ = streamSubs.get(name);
if (set_) set_.forEach((fn) => { try { fn(p[name]); } catch (e) { console.error('[pane] stream handler threw', e); } });
}
break;
}
case 'rpc:reply': {
const call = pending.get(p.seq);
if (!call) return; // already timed out
pending.delete(p.seq);
clearTimeout(call.timer);
if (p.ok) call.resolve(p.result);
else call.reject(new Error(p.error || 'pane rpc failed'));
break;
}
}
}
return {
kind: 'remote',
handle: handle,
setSong: (s) => { song = s; },
call(domain, command, payload) {
if (!channel) return Promise.reject(new Error('pane ctx.call: no channel'));
const seq = ++rpcSeq;
return new Promise((resolve, reject) => {
// Every call gets a deadline. Without one, a main window that
// died mid-call leaves the pane's promise pending forever and
// its UI stuck on "Pending".
const timer = setTimeout(() => {
pending.delete(seq);
reject(new Error('PaneRpcTimeout: ' + domain + '/' + command));
}, RPC_TIMEOUT_MS);
pending.set(seq, { resolve, reject, timer });
channel.postMessage(envelope('rpc', paneId, hostId, { seq, domain, command, payload: payload || {} }));
});
},
on(name, fn) {
let set_ = listeners.get(name);
if (!set_) { set_ = new Set(); listeners.set(name, set_); }
set_.add(fn);
return () => set_.delete(fn);
},
subscribe(stream, fn) {
let set_ = streamSubs.get(stream);
if (!set_) {
set_ = new Set();
streamSubs.set(stream, set_);
if (channel) channel.postMessage(envelope('sub', paneId, hostId, { stream }));
}
set_.add(fn);
return () => {
set_.delete(fn);
if (!set_.size && channel) channel.postMessage(envelope('unsub', paneId, hostId, { stream }));
};
},
playhead: playhead,
song: () => song,
// No toast stack in a pane window, and routing it back to the main
// window would pop the message up somewhere the user isn't looking.
toast(opts) { console.info('[pane]', (opts && opts.title) || '', (opts && opts.message) || ''); },
};
}
// ── ctx ──────────────────────────────────────────────────────────────────
// What a pane's mount() actually receives. Every subscription it hands out
// is tracked, so unmount() can drop them all — a pane physically cannot
@@ -229,8 +386,13 @@
CHANNEL_NAME,
DEFAULT_EVENTS,
CALL_TIMEOUT_MS,
RPC_TIMEOUT_MS,
MAX_EXTRAP_S,
envelope,
openChannel,
createStateStore,
createLocalTransport,
createRemoteTransport,
createCtx,
};
})();