From 254e26bb3a2c6d44f99e1e0b27511bd63fdaf6bf Mon Sep 17 00:00:00 2001 From: topkoa Date: Sun, 12 Jul 2026 17:38:05 -0400 Subject: [PATCH] feat(panes): mirrorGlobal, manifest-declared panes, and the plugin docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a plugin needs before it can actually use panes. ## mirrorGlobal — the camera-director problem The 3D highways read their free camera from a plain global, `window.__h3dCamCtl` (highway_3d/FREECAM_BRIDGE.md), once per frame in _resolveFreeCam(). A camera panel in the main window just writes that object and the camera moves. A panel in a POP-OUT window cannot: window.__h3dCamCtl there is a different object in a different realm, and writing it moves nothing. So a pane declares one field — `mirrorGlobal: '__h3dCamCtl'` — and pane-mirror.js (main realm, where the renderers live) copies that pane's state onto the global whenever it changes. highway_3d, keys_highway_3d and drum_highway_3d are NOT modified and do not know panes exist. The rule that makes it work: MUTATE THE OBJECT, NEVER REPLACE IT. A renderer may be holding the reference, and swapping in a new object would leave it reading an orphan. Keys the pane doesn't set are left alone rather than deleted — the global may carry a renderer's own bookkeeping. Closing the pane deliberately leaves the global as-is: closing the camera panel should not snap the camera back to a default, which is exactly what happens today (nobody clears __h3dCamCtl). ## Manifest-declared panes "panes": [{ "id": "camera_director", "title": "Camera Director", "script": "panes/camera.js", "mirrorGlobal": "__h3dCamCtl" }] Declaring a pane beats calling panes.register() from screen.js because it becomes openable FROM THE RAIL OR THE TRAY WITHOUT THE PLUGIN'S SCREEN EVER HAVING BEEN VISITED — core registers a stub from the manifest 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. The script sets `window.feedBackPane_ = { mount, unmount }`, mirroring the existing window.feedBackViz_ convention, and the SAME file is what a pop-out window loads in its own realm. `script` is validated as a relpath under the plugin's src/ and served through the sandboxed /api/plugins//src/ route — the containment rule `styles` already has for assets/. Traversal, absolute paths, drive letters, backslashes and non-.js are rejected; a bad entry is dropped with a warning rather than failing the whole plugin, because one malformed pane should not cost the user a working plugin. Note the projection is written TWICE — _nav_entry() and the /api/plugins route re-project independently — so panes had to be added to both, plus the pending branch (a pane can be opened while its plugin is still installing deps; the script is fetched on open, not at discovery). ## docs/plugin-panes.md The contract, and the one rule it all hangs on: mount(root, ctx) runs in a realm that may not have the app in it. Everything comes through ctx, or the pane works docked and silently dies popped out. Verified: manifest validation rejects ../.., C:\, non-.js, dupes and missing fields while passing a good entry; /api/plugins projects panes[] for all 20 plugins. mirrorGlobal mutates the global IN PLACE — a reference held the way _resolveFreeCam holds it sees the change, and a renderer's own field on that object survives — both for a local write and for a write arriving over the channel from a pop-out realm. pytest: 2401 passed, 8 failed — all 8 reproduce on a clean main (including the one in tests/test_plugins.py) and are unrelated. Signed-off-by: topkoa --- docs/plugin-panes.md | 213 +++++++++++++++++++++++++++++++++++ plugins/__init__.py | 78 +++++++++++++ static/panes/pane-mirror.js | 80 +++++++++++++ static/panes/pane-plugins.js | 122 ++++++++++++++++++++ static/panes/pane-runtime.js | 15 ++- static/v3/index.html | 4 + 6 files changed, 510 insertions(+), 2 deletions(-) create mode 100644 docs/plugin-panes.md create mode 100644 static/panes/pane-mirror.js create mode 100644 static/panes/pane-plugins.js diff --git a/docs/plugin-panes.md b/docs/plugin-panes.md new file mode 100644 index 0000000..56085c3 --- /dev/null +++ b/docs/plugin-panes.md @@ -0,0 +1,213 @@ +# 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. + +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. + +--- + +## The two-line version + +If your plugin already has a dialog, give it a pop-out chip: + +```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(); }, +}); + +feedBack.panes.attachChip(myDialogEl, '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. + +**You write no show/hide logic.** Core owns it, so that every plugin's pop-out +behaves identically — which is the entire point. + +--- + +## The one rule + +> **`mount(root, ctx)` runs in a realm that may not have the app in it.** + +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 `