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.