mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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>
This commit is contained in:
+104
-206
@@ -1,249 +1,147 @@
|
||||
# Detachable panes (`window.feedBack.panes`)
|
||||
|
||||
A **pane** is live UI that stays put: a mixer, a camera rig, a readout, a settings
|
||||
board. You author it once, and the host decides where it lives — docked beside the
|
||||
player, or popped out into its own OS window that remembers where you put it and
|
||||
minimizes to the system tray.
|
||||
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. Panes are non-exclusive, and they survive
|
||||
song switches.
|
||||
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 two-line version
|
||||
## The whole idea, in one sentence
|
||||
|
||||
If your plugin already has a dialog, give it a pop-out chip:
|
||||
**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.
|
||||
|
||||
```js
|
||||
feedBack.panes.register({
|
||||
id: 'camera_director',
|
||||
title: 'Camera Director',
|
||||
icon: '🎥',
|
||||
mount(root, ctx) { root.appendChild(buildCameraUI(ctx)); }, // your existing builder
|
||||
unmount(root) { root.replaceChildren(); },
|
||||
element: () => panelEl, // your existing panel, as it is
|
||||
});
|
||||
|
||||
feedBack.panes.attachChip(myDialogEl, 'camera_director');
|
||||
feedBack.panes.attachChip(panelEl, 'camera_director');
|
||||
```
|
||||
|
||||
`attachChip()` injects **the** standard ⇱ button — same glyph, same place, same
|
||||
behaviour everywhere. Clicking it opens the pane in its host and **hides your
|
||||
dialog**, leaving a "⇲ … is popped out" stub in its place. Closing the pane
|
||||
un-hides your dialog and restores the chip.
|
||||
`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.
|
||||
|
||||
**You write no show/hide logic.** Core owns it, so that every plugin's pop-out
|
||||
behaves identically — which is the entire point.
|
||||
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
|
||||
|
||||
## The one rule
|
||||
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.
|
||||
|
||||
> **`mount(root, ctx)` runs in a realm that may not have the app in it.**
|
||||
### The one thing core changes about your element
|
||||
|
||||
Docked, your pane runs in the main window with everything present. Popped out, it
|
||||
runs in a **separate JS realm** — a different window, with no `window.highway`, no
|
||||
`window.feedBack.capabilities`, no `<audio>` element, and no audio graph. Same file,
|
||||
same `mount()`, different world.
|
||||
`.fb-paned` is added while the pane is out, and it neutralises **placement only**:
|
||||
|
||||
So: **everything your pane touches must come through `ctx`.** A pane that reaches
|
||||
for a global works docked and silently dies popped out. There is no way for core to
|
||||
paper over that, because a closure cannot cross a window boundary.
|
||||
|
||||
---
|
||||
|
||||
## `ctx`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `ctx.call(domain, command, payload)` → `Promise` | The capability bus. This is your only door into app services. Core builds the requester/origin/timeout envelope; you send a payload. Cross-realm calls have a 10 s deadline and reject with `PaneRpcTimeout`. |
|
||||
| `ctx.on(event, fn)` → `unsub` | The `feedBack` bus. Handlers get a `{ detail }` object, exactly as in the main window. Only allow-listed events are mirrored (see below). |
|
||||
| `ctx.subscribe(stream, fn)` → `unsub` | High-rate numerics: `'playhead'` → `{ t, duration, playing }`, `'meters'` → `{ master }`. |
|
||||
| `ctx.playhead()` → `number` | The current time in seconds, smoothed. **Use this, not a stream, for per-frame drawing** — see "The clock" below. |
|
||||
| `ctx.state.get(path)` / `.set(path, value)` / `.subscribe(fn)` | A dotted-path store, persisted per pane. The **main realm is authoritative**. |
|
||||
| `ctx.song()` | The current `feedBack.currentSong`, or `null`. |
|
||||
| `ctx.toast(opts)`, `ctx.close()` | |
|
||||
| `ctx.paneId`, `ctx.host`, `ctx.isRemote` | |
|
||||
|
||||
**`ctx` tracks every subscription it hands you and drops them on unmount.** You
|
||||
cannot leak a listener across a dock/undock cycle even if you try. Your `unmount()`
|
||||
only needs to clear your own DOM.
|
||||
|
||||
### Events mirrored by default
|
||||
|
||||
`song:loading`, `song:loaded`, `song:ready`, `song:play`, `song:pause`, `song:ended`,
|
||||
`song:stop`, `song:seek`, `song:arrangement-changed`, `screen:changed`,
|
||||
`theme:changed`, `library:changed`, `highway:canvas-replaced`, `highway:visibility`.
|
||||
|
||||
Widen with `spec.events: ['audio-mix:fader-value-changed', …]`.
|
||||
|
||||
**`song:position-changed` is deliberately not on the list.** It fires every 250 ms —
|
||||
too coarse to animate with, too chatty to mirror across a window. Use the `playhead`
|
||||
stream or `ctx.playhead()`.
|
||||
|
||||
---
|
||||
|
||||
## The 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
|
||||
backgrounded while the user is looking at your pane. A pane that renders the raw
|
||||
broadcast stutters at 1 fps.
|
||||
|
||||
`ctx.playhead()` solves this: it extrapolates between broadcasts
|
||||
(`anchor + observedRate × elapsed`, capped at 2 s). The rate is *learned from the
|
||||
broadcasts themselves*, so it tracks the speed slider without being told, and a dead
|
||||
main window decays into a frozen clock rather than one that confidently runs away.
|
||||
|
||||
**Draw from `ctx.playhead()` in your own rAF loop. Subscribe to `'playhead'` only
|
||||
for things that change slowly** (a time readout, a progress bar).
|
||||
|
||||
---
|
||||
|
||||
## Levels, and why they are numbers
|
||||
|
||||
An `AnalyserNode` **cannot cross a window boundary.** Your pane can never hold one.
|
||||
|
||||
So core samples the analyser in the realm that owns the audio graph and ships you
|
||||
plain numbers over `ctx.subscribe('meters', …)`. The stream stays **silent** when
|
||||
there is no analyser (no stems plugin) rather than reporting zeros — so "silent" and
|
||||
"actually silent" stay distinguishable, and you can render an honest "unavailable"
|
||||
state.
|
||||
|
||||
---
|
||||
|
||||
## Declaring a pane in `plugin.json` (preferred)
|
||||
|
||||
```json
|
||||
"panes": [{
|
||||
"id": "camera_director",
|
||||
"title": "Camera Director",
|
||||
"icon": "🎥",
|
||||
"script": "panes/camera.js",
|
||||
"defaultHost": "window",
|
||||
"mirrorGlobal": "__h3dCamCtl",
|
||||
"width": 380,
|
||||
"height": 560
|
||||
}]
|
||||
```css
|
||||
position: static; inset: auto; width: 100%;
|
||||
max-width: none; max-height: none; z-index: auto; box-shadow: none;
|
||||
```
|
||||
|
||||
Declaring it beats calling `panes.register()` from `screen.js`, because a
|
||||
manifest pane is **openable from the rail and the tray without your plugin's screen
|
||||
ever having been visited**. Core registers a stub and fetches the script only when
|
||||
the user opens it. (A pane you can only reach by first navigating to the screen it
|
||||
was meant to replace is not much of a pane.)
|
||||
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.
|
||||
|
||||
`script` is a relpath under your plugin's `src/`, served through the sandboxed
|
||||
`/api/plugins/<id>/src/…` route. It sets a factory global — the same shape the viz
|
||||
contract already uses:
|
||||
The class is removed the moment the element goes home.
|
||||
|
||||
---
|
||||
|
||||
## Spec
|
||||
|
||||
```js
|
||||
window.feedBackPane_camera_director = {
|
||||
mount(root, ctx) { … },
|
||||
unmount(root, ctx) { … },
|
||||
};
|
||||
```
|
||||
|
||||
**This exact file is what a pop-out window loads in its own realm.** Write it to the
|
||||
one rule above.
|
||||
|
||||
---
|
||||
|
||||
## Driving a pane from the main realm — `panes.state(id)`
|
||||
|
||||
Most plugins with a pane are the **authority** over what the pane controls: they
|
||||
clamp values, persist presets, emit events, and own the audio graph or the camera
|
||||
rig. Such a plugin should not have core splat the pane's values somewhere — it
|
||||
should *apply them itself*.
|
||||
|
||||
`panes.state(id)` is the main realm's handle on an open pane's store:
|
||||
|
||||
```js
|
||||
feedBack.on('panes:opened', (e) => {
|
||||
if (e.detail.id !== 'camera_director') return;
|
||||
const state = feedBack.panes.state('camera_director');
|
||||
|
||||
// Seed it, so the pane opens showing the live camera, not defaults.
|
||||
AXES.forEach((k) => state.set(k, myApi.getAxis(k)));
|
||||
|
||||
// …and apply whatever the pane sends back, through your own API — which
|
||||
// clamps, persists, and tells the rest of your plugin.
|
||||
state.subscribe((all, change) => {
|
||||
if (!change) return;
|
||||
myApi.setAxis(change.path, change.value);
|
||||
});
|
||||
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
|
||||
});
|
||||
```
|
||||
|
||||
The pane stays realm-agnostic (it only ever touches `ctx.state`), and your plugin
|
||||
remains the single source of truth. **Every write to the store is broadcast to the
|
||||
pane window**, whichever realm made it — so a value your code clamps or corrects
|
||||
shows up in the pane immediately, and there is exactly one way state reaches a pane.
|
||||
```js
|
||||
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()
|
||||
```
|
||||
|
||||
Guard against a write you just made coming straight back (compare against your
|
||||
current value before applying), or a clamp will ping-pong.
|
||||
|
||||
Returns `null` when the pane is closed.
|
||||
|
||||
## `mirrorGlobal` — for panes that drive a plain global
|
||||
|
||||
The 3D highways read their free camera from `window.__h3dCamCtl` once per frame.
|
||||
A camera panel in the main window just writes that object. A panel in a **pop-out
|
||||
window cannot** — `window.__h3dCamCtl` there is a different object in a different
|
||||
realm, and writing it moves nothing.
|
||||
|
||||
Declare `mirrorGlobal: '__h3dCamCtl'` and core copies your pane's state onto that
|
||||
global, **in the main realm, mutating the object in place** (a renderer may be
|
||||
holding the reference). You write `ctx.state.set('yaw', 0.3)`; the renderer keeps
|
||||
reading the plain global it always read; `highway_3d` is not modified and does not
|
||||
know panes exist.
|
||||
|
||||
---
|
||||
|
||||
## Rules that will bite you
|
||||
|
||||
1. **Never cache the renderer or the canvas.** `playSong()` calls `highway.stop()` →
|
||||
`destroy()`. Re-bind on `song:ready` / `highway:canvas-replaced` /
|
||||
`highway:visibility`.
|
||||
2. **Module top-level does not re-run** on screen re-entry (see
|
||||
[plugin-modules.md](plugin-modules.md)). Per-visit init belongs in `mount()`.
|
||||
3. **No per-frame `querySelector`.** Panes share the main thread with a 60 fps render
|
||||
loop. Resolve refs in `mount()`, cache them. (See the perf rules in
|
||||
[CLAUDE.md](../CLAUDE.md).)
|
||||
4. **A pane with no `script` can only ever be docked.** It exists solely as a closure
|
||||
in the main realm, and there is no honest way to move a closure across a window
|
||||
boundary — the window host declines it and the router falls back to the dock.
|
||||
5. **`localStorage` is shared with your pop-out window** (same origin). Use
|
||||
`ctx.state`, which has exactly one writer.
|
||||
6. **Persistence is on by default** (`persist: false` to opt out). State is keyed by
|
||||
pane id.
|
||||
`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
|
||||
|
||||
`panes.detach(id)` opens a pane in the best available host:
|
||||
`detach(id)` puts a pane in the best host available:
|
||||
|
||||
| host | priority | |
|
||||
| host | | |
|
||||
|---|---|---|
|
||||
| `desktop` | 20 | A real Electron `BrowserWindow`. Remembered geometry, always-on-top, system tray. |
|
||||
| `window` | 10 | A browser pop-up (`window.open`). No tray, no remembered bounds. |
|
||||
| `dock` | 0 | The in-window card stack. **The floor** — always available, so a pane can never fail to open. |
|
||||
| `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 do not choose; you declare `defaultHost` and the router does the rest. A pane
|
||||
popped out in the desktop app comes back popped out on next launch; in a browser it
|
||||
comes back **docked**, because a browser blocks `window.open()` without a user
|
||||
gesture and a "pop-up blocked" toast on every page load would be worse than useless.
|
||||
You don't pick; you declare `defaultHost` and the router does the rest.
|
||||
|
||||
## API
|
||||
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.
|
||||
|
||||
```js
|
||||
feedBack.panes.register(spec) -> unregister
|
||||
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()
|
||||
```
|
||||
---
|
||||
|
||||
`spec`: `{ id*, title, icon, mount*, unmount, script, events[], persist, initialState,
|
||||
defaultHost, mirrorGlobal, width, height }`.
|
||||
## 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.
|
||||
|
||||
Reference in New Issue
Block a user