From 330995588c39de9aa88d2b9c56014f195593575c Mon Sep 17 00:00:00 2001 From: topkoa Date: Sun, 12 Jul 2026 17:58:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(panes):=20panes.state(id)=20=E2=80=94=20le?= =?UTF-8?q?t=20a=20plugin=20apply=20its=20own=20pane's=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mirrorGlobal covers the case where a pane drives a plain global that some renderer reads each frame. It does not cover the far more common one: a plugin whose MAIN-realm code is the authority — it clamps, it persists, it emits events, it owns the audio graph or the camera rig — and which must therefore APPLY the pane's values itself rather than have core splat them somewhere. Camera Director is the case that forced this. Its brain is the sole writer of the camera store, the sole broadcaster on splitscreen's channel, and the only thing that clamps an axis to its legal range. A pane cannot write window.__h3dCamCtl behind its back without desynchronising its presets, its persistence, and the panel's own sliders — and running the brain inside the pane realm would make it a SECOND store writer and a second broadcaster, racing the real one. So: `panes.state(id)` hands the main realm the open pane's store (get/set/all/subscribe). A plugin seeds it on `panes:opened`, subscribes, and applies what comes back through its own API. The pane stays realm-agnostic — it only ever touches ctx.state — and the plugin stays the single source of truth. For that to work, the hub now broadcasts EVERY change to the store, not just the ones a pane asked for: it subscribes to the store on connect rather than echoing pane-originated writes by hand. A value the plugin clamps or corrects therefore reaches the pane window immediately, and there is exactly one path by which state arrives in a pane — so it cannot drift. Signed-off-by: topkoa --- docs/plugin-panes.md | 36 ++++++++++++++++++++++++++++++++++++ static/panes/pane-hub.js | 25 ++++++++++++++++++++----- static/panes/pane-manager.js | 23 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/docs/plugin-panes.md b/docs/plugin-panes.md index 56085c3..fe28bda 100644 --- a/docs/plugin-panes.md +++ b/docs/plugin-panes.md @@ -150,6 +150,42 @@ 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); + }); +}); +``` + +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. + +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. diff --git a/static/panes/pane-hub.js b/static/panes/pane-hub.js index 5ea55a8..0472a99 100644 --- a/static/panes/pane-hub.js +++ b/static/panes/pane-hub.js @@ -109,7 +109,20 @@ function _connect(paneId) { if (conns.has(paneId)) _disconnect(paneId); // a reloaded pane window says hello again - conns.set(paneId, { streams: new Map(), pending: null, rafId: null }); + const entry = panes._entry(paneId); + + // Broadcast EVERY change to the authoritative store, whoever made it — + // not just the ones a pane asked for. A plugin's main-realm code is often + // the real authority (it clamps, it persists, it owns the rig), and when + // it corrects or seeds a value through panes.state(id).set(), the pane + // window has to see that too. Subscribing here means the pane's own write + // is echoed by the same path that carries a main-realm write, so there is + // exactly one way state reaches a pane, and it cannot drift. + const unsubState = entry ? entry.state.subscribe((_all, change) => { + if (change) send('state', paneId, change); + }) : null; + + conns.set(paneId, { streams: new Map(), pending: null, rafId: null, unsubState }); const spec = panes.get(paneId); if (spec) spec.events.forEach(_hookEvent); } @@ -118,6 +131,7 @@ const conn = conns.get(paneId); if (!conn) return; conn.streams.forEach((unsub) => unsub()); + if (conn.unsubState) conn.unsubState(); if (conn.rafId != null) cancelAnimationFrame(conn.rafId); conns.delete(paneId); const spec = panes.get(paneId); @@ -183,10 +197,11 @@ case 'state': { const entry = panes._entry(paneId); if (!entry) return; - // The main realm is authoritative: apply, then echo to everyone. - // A pane's own optimistic paint is corrected by the echo, so two - // panes racing on one key converge instead of diverging. - if (entry.state.set(p.path, p.value)) send('state', paneId, { path: p.path, value: p.value }); + // The main realm is authoritative: a pane's write is a request. We + // apply it here; the store subscription in _connect() does the + // echoing, so a pane's optimistic paint is corrected by exactly the + // same path that carries a main-realm write. + entry.state.set(p.path, p.value); break; } diff --git a/static/panes/pane-manager.js b/static/panes/pane-manager.js index 67581de..53cc955 100644 --- a/static/panes/pane-manager.js +++ b/static/panes/pane-manager.js @@ -330,6 +330,29 @@ // participate without a private import. registerHost, + // The main realm's handle on an open pane's state. + // + // `mirrorGlobal` covers the case where a pane drives a plain global that + // some renderer reads. It does NOT cover the far more common one: a + // plugin whose main-realm code is the authority — it clamps, it persists, + // it emits events, it owns the audio graph or the camera rig — and which + // therefore needs to APPLY the pane's values itself rather than have core + // splat them somewhere. + // + // Such a plugin subscribes here on `panes:opened`, seeds the store with + // its current values, and applies whatever comes back. The pane stays + // realm-agnostic (it only ever touches ctx.state) and the plugin keeps + // being the single source of truth. Returns null when the pane is closed. + state: (id) => { + const entry = open.get(id); + return entry ? { + get: (path) => entry.state.get(path), + set: (path, value) => entry.state.set(path, value), + all: () => entry.state.all(), + subscribe: (fn) => entry.state.subscribe(fn), + } : null; + }, + // Host-internal. pane-hub.js serves a pop-out realm from the // authoritative state store, which only lives here. Not part of the pane // API — panes must never reach for this.