feedBack/docs/plugin-panes.md
topkoa 188bdaa837 feat(panes)!: move the real element, instead of rebuilding it
The first cut of this got the model wrong. A pane was a SECOND
implementation of the plugin's panel — its own sliders, its own styling,
driven over a cross-realm bridge (ctx, a state store, capability RPC,
mirrorGlobal, a stream sampler). Popping out gave you something that
resembled the panel you popped, and every feature it did not reimplement
(presets, tabs, EQ, language) was simply gone.

What a user wants from "pop this out" is the thing they popped out.

So: MOVE THE REAL ELEMENT. Same-origin windows can adopt each other's
nodes, and an adopted node keeps its event listeners and its closures.
The panel goes on running the plugin's own code, against the plugin's own
state, in the plugin's own realm — it is merely being DISPLAYED in another
window. Copy the app's stylesheets into that window and it looks identical
too, because it is identical.

The plugin's side collapses to two lines:

    feedBack.panes.register({ id, title, element: () => panelEl });
    feedBack.panes.attachChip(panelEl, id);

and everything comes along: the CSS, the listeners, the presets, the
state. Nothing to keep in step, because there is no second copy.

Deleted, all of it now pointless: pane-bridge (ctx + transports), pane-hub
(the cross-realm server), pane-runtime (the pane realm's boot), pane-streams
(the rAF sampler that existed because an AnalyserNode can't cross a window),
pane-mirror (mirrorGlobal), pane-plugins + the manifest `panes[]` key and its
server-side validation, panes.state(), and both built-in demo panes. ~1200
lines. None of it was wrong — it was all correct machinery for the wrong
problem.

Consequences worth knowing:

- The window MUST be opened by the renderer with window.open(), not by the
  desktop's main process: a window we did not open gives this realm no handle
  to its document, and without the handle there is nothing to adopt into.
  Electron turns the same-origin window.open() into a real BrowserWindow
  anyway (setWindowOpenHandler → 'allow'), so we get the OS window AND the
  live DOM link. The desktop side finds it by frame name.
- `.fb-paned` neutralises PLACEMENT only (position/inset/width/z-index/shadow).
  A plugin panel is nearly always a fixed overlay pinned to a corner of the
  app; alone in a 380px window that positioning is nonsense. Colours, borders,
  padding, fonts and the panel's own internal layout are untouched — the whole
  promise is that what you popped out is what you get.
- The element is returned to its EXACT home on dock: same parent, same position
  among its siblings.
- The plugin's code still runs in the main window. So a document.body
  .appendChild() inside a panel (a tooltip, a popover) lands in the main
  window, not the pane — anchor to the panel instead. And a continuously
  animating panel may run slowly while the main window is backgrounded, since
  its rAF lives there. Both documented.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 18:12:46 -04:00

5.9 KiB

Detachable panes (window.feedBack.panes)

Pop a panel out of the app into its own OS window, and leave it there: while you play, across song switches, on a second monitor, minimized to the system tray.

Panes exist because the player's rail popovers are exclusive — opening one closes the last. You cannot watch the mixer while riding the camera, and both vanish the moment you want to look at the highway.


The whole idea, in one sentence

We move the real element.

Not a copy of your panel. Not a re-implementation of it in the pop-out window. The actual DOM node. Same-origin windows can adopt each other's nodes, and an adopted node keeps its event listeners and its closures — so your panel goes on running your code, against your state, in your realm. The app's stylesheets are copied into the pane window, so it looks identical too.

What you popped out is what you get. That is the promise, and it is the reason there is no ctx, no state mirroring, no cross-window RPC and no second copy of your UI to keep in step with the first. Those are all solutions to a problem we simply do not have.


Adding a pane to your plugin

Two lines.

feedBack.panes.register({
    id: 'camera_director',
    title: 'Camera Director',
    icon: '🎥',
    element: () => panelEl,          // your existing panel, as it is
});

feedBack.panes.attachChip(panelEl, 'camera_director');

attachChip() injects the standard pop-out chip () — same glyph, same place, same behaviour in every plugin. Clicking it moves your panel into a window and leaves a "⇲ … is popped out" stub in its place; clicking the stub brings it back, to exactly the spot it left. Core owns the chip, the hiding and the stub, so you write no show/hide logic.

That's it. Your sliders, your presets, your tabs, your CSS, your event handlers, your state — all of it comes along, because none of it moved anywhere except into a different window's document.

element is a function for a reason

It is resolved at open time, not at registration. Plugins commonly build their panel lazily on first use, or rebuild it wholesale when something changes (Camera Director rebuilds its panel on every mode change). Asking for it when we need it means we always move the live one.

The one thing core changes about your element

.fb-paned is added while the pane is out, and it neutralises placement only:

position: static; inset: auto; width: 100%;
max-width: none; max-height: none; z-index: auto; box-shadow: none;

Your panel was almost certainly a fixed overlay pinned to a corner of the app (position:fixed; top:72px; right:18px; width:288px). Alone in its own window, every one of those is wrong — it would float 72px down from the top of a 380px window, still 288px wide, still casting a shadow over nothing. Colours, borders, radius, padding, fonts and your panel's own internal layout are untouched.

The class is removed the moment the element goes home.


Spec

feedBack.panes.register({
    id,                    // required, unique
    element,               // required — an Element, or a function returning one
    title,                 // shown in the pane window's title bar, the dock card, the tray
    icon,                  // one glyph, for the dock/tray/launcher lists
    width, height,         // the pane window's initial size (it remembers yours after that)
    defaultHost,           // 'window' (default) or 'dock'
    onHost,                // optional (hostId | null, el) => void — re-measure/re-anchor
});
feedBack.panes.attachChip(el, paneId, { header })   // → detach()
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()

attachChip puts the chip in el.querySelector('[data-pane-header]') if it finds one, or in the header element you pass, or at the top of el otherwise.


Hosts

detach(id) puts a pane in the best host available:

host
window 10 A real OS window. In the desktop app: remembered bounds, always-on-top, system tray.
dock 0 A card in the in-window stack. The floor — always available, so opening a pane can never fail.

You don't pick; you declare defaultHost and the router does the rest.

In the desktop app a pane you left popped out comes back popped out on next launch. In a browser it comes back docked — a browser blocks window.open() without a user gesture, so restoring it would only ever produce a "pop-up blocked" toast. The chip pops it out again on your next click.


Things worth knowing

  1. Your code still runs in the main window. The element is displayed in the pane window, but its closures, its timers and its document references all still belong to the main realm. That is exactly why everything keeps working — but it means a document.body.appendChild() inside your panel (a tooltip, a popover) lands in the main window, not the pane. Anchor such things to the panel itself, not to document.body.

  2. Chromium throttles a backgrounded window's requestAnimationFrame. While the user is looking at your pane, the main window may be in the background — and your rAF lives there. Event-driven panels (sliders, buttons, presets) are unaffected. A panel that animates continuously may run slowly while it's the only thing you're looking at.

  3. The element goes home exactly where it came from — same parent, same position among its siblings. Don't move it yourself while it's popped out.

  4. A pane the user closed with the window's X button is reaped (a crashed renderer never gets to say goodbye), and your element is docked back. Without that, your panel would be stranded in a dead document with no way back.

  5. Nothing here is required. On a host without the panes API, feedBack.panes is undefined, you skip both calls, and your panel behaves exactly as it does today.