From 67e6b25c4333327827b082e22ccb013237ec324e Mon Sep 17 00:00:00 2001 From: topkoa Date: Sun, 12 Jul 2026 18:49:56 -0400 Subject: [PATCH 1/2] docs(panes): best practices for plugin authors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every rule here is something that has already gone wrong on this feature — mostly in core's own code, twice in the two plugins that adopted it first. They are cheap to get right up front and miserable to diagnose later, because a broken pane almost always LOOKS perfect. The traps, and why each one is easy to walk into: - Your code still runs in the main window. That is exactly why moving the element works at all — and exactly why `document.body.appendChild(tooltip)` inside a popped-out panel lands in the window the user is NOT looking at. - Don't hide your own panel when it pops out. Core hides it and leaves a stub. A plugin that also hides it hides the node that just moved — which is precisely how core's own chip shipped a blank pop-out window. - Use `hidden` or a class, not inline `display`, for show/hide. `.fb-paned` forces the panel visible while it is out; when it docks and that class is removed, an inline `display:none` reasserts itself and the panel returns invisible. - `isConnected` does not mean "docked". A panel in a pane window IS connected, just not to this document. The test you meant is `el.ownerDocument === document`. - `element` is a function so it can be resolved late: return the LIVE node, and re-attach the chip if you rebuild your panel (Camera Director rebuilds on every mode change). - rAF is throttled while the main window is backgrounded — which it is, whenever the user is looking at your pane. Event-driven panels don't care; continuously animating ones will stutter exactly when they are the only thing on screen. - Don't synchronise anything. One realm, one panel. Writing sync code means you have misunderstood the model. Also states what core guarantees back, including the one that cost the most to learn: the element is evacuated BEFORE the pane window's document is destroyed, so it comes home alive rather than as a photograph of a panel with every listener in its subtree silently gone. Signed-off-by: topkoa --- CLAUDE.md | 34 ++++++++++++ docs/plugin-panes.md | 127 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 141 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cd45a3f..9f6f973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -465,6 +465,40 @@ window.feedBack.diagnostics.contribute('my_plugin', { Loaded from `static/diagnostics.js` ASAP in `` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers. +### Detachable panes — pop your panel out into its own window + +If your plugin has a floating panel that sits over the player — a mixer, a camera rig, a settings board — you can let the user pop it out into its own OS window and leave it there: while they play, across song switches, on a second monitor, minimized to the tray. Two calls: + +```js +feedBack.panes.register({ + id: 'camera_director', + title: 'Camera Director', + icon: '🎥', + element: () => panelEl, // your existing panel, exactly as it is +}); +feedBack.panes.attachChip(panelEl, 'camera_director'); +``` + +**The host moves your real element.** Not a copy, not a re-render — the actual DOM node, adopted into the pop-out window, keeping its listeners and its closures. Your panel goes on running *your* code against *your* state. It looks and behaves like what was popped out because it **is** what was popped out. Nothing to mirror, nothing to keep in sync. + +The rules below are all things that have already gone wrong. Full contract: **[docs/plugin-panes.md](docs/plugin-panes.md)**. + +- **Your code still runs in the main window.** The element is *displayed* elsewhere; its closures, timers and `document` references still belong to the main realm. That is exactly why everything keeps working — and exactly why `document.body.appendChild(myPopover)` lands in the **main window, not the pane**. Anchor tooltips, popovers and menus to your panel, not to `document.body`. Measure with `el.ownerDocument.defaultView`, never a cached `window`. + +- **Don't hide your panel yourself when it pops out.** Core hides it and leaves a "bring it back" stub. If you also hide it, you will hide the node that just moved — and blank the pane window. + +- **Prefer `hidden` or a class over inline `display` for show/hide.** While popped out, core neutralises *placement* with `.fb-paned` (`position`, `inset`, `width`, `z-index`, `box-shadow`). An inline `display:none` on your panel reasserts itself the moment the pane docks back and the class is removed, so your panel returns invisible. + +- **`element` is a function so it can be resolved late.** Return the *live* node. If you rebuild your panel (Camera Director rebuilds on every mode change), re-run `attachChip` — it returns a `detach()`; call it before re-attaching, and again in your teardown. + +- **`isConnected` does not mean "docked".** A panel sitting in a pane window is very much connected — just not to *this* document. Test `el.ownerDocument === document`, or take the `onHost(hostId, el)` callback. + +- **rAF is throttled while the main window is backgrounded** — and it will be, whenever the user is looking at your pane. Event-driven panels (sliders, buttons) are unaffected. A panel that *animates continuously* may run slowly while it is the only thing on screen. + +- **Don't reach for BroadcastChannel, `postMessage`, or a second copy of your state.** There is one realm and one panel. If you find yourself synchronising, you have misunderstood the model. + +- **Nothing is required.** No panes API on the host → skip both calls, and your panel behaves exactly as it does today. + ### Keyboard Shortcuts Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel. diff --git a/docs/plugin-panes.md b/docs/plugin-panes.md index 93f0df2..a234c25 100644 --- a/docs/plugin-panes.md +++ b/docs/plugin-panes.md @@ -153,28 +153,115 @@ launch. In a **browser** it comes back **docked** — a browser blocks --- -## Things worth knowing +## Best practices -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`. +Every item below is something that has already gone wrong, in this codebase, on +this feature. They are cheap to get right up front and confusing to diagnose later +— a broken pane usually *looks* perfect. -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. +### 1. Your code still runs in the main window -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. +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 precisely why +everything keeps working** — and it has one sharp consequence: -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. +```js +// WRONG — lands in the MAIN window, not the pane the user is looking at. +document.body.appendChild(myTooltip); -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. +// RIGHT — anchored to the panel, so it travels with it. +panelEl.appendChild(myTooltip); +``` + +Same for measuring. `window.innerWidth` is the *main* window's. Use +`el.ownerDocument.defaultView` when you need the window your panel is actually in. + +### 2. Don't hide your panel yourself + +Core hides it and leaves a "bring it back" stub. If your plugin *also* hides it, +you are hiding the node that just moved — and the pane window renders nothing. +(This is not hypothetical: core's own chip did exactly this, and the first +pop-out shipped blank because of it.) + +### 3. Prefer `hidden` or a class for show/hide + +Core makes your panel visible while it's hosted — it clears `hidden`, and clears an +inline `display: none` if that's how you hide — and **restores both on dock**. So +either style works. + +`hidden` is still the better choice: it composes with everything, and it leaves +your panel's `display` mode (`flex`, `grid`, whatever it is) entirely alone. Core +deliberately does not override `display` for exactly that reason. + +```js +panel.hidden = true; // best +panel.style.display = 'none'; // works — core saves and restores it +``` + +### 4. `element` is a function — return the *live* node + +It is resolved when the pane opens, not when you register. Plugins build panels +lazily, and rebuild them wholesale (Camera Director rebuilds on every mode +change). If you rebuild yours, **re-attach the chip**: + +```js +if (chipDetach) chipDetach(); // attachChip returns a detach() +chipDetach = feedBack.panes.attachChip(panel, PANE_ID, { header: toolsEl }); +``` + +Call `chipDetach()` in your teardown too, or you leave a stub pointing at DOM that +no longer exists. + +### 5. `isConnected` does not mean "docked" + +A panel sitting in a pane window is **connected** — just not to *this* document. +Code that asks "am I still mounted?" with `isConnected` will get `true` and act on +a panel that is somewhere else entirely. + +```js +const docked = el.ownerDocument === document; // this is the question you meant +``` + +Or take the optional `onHost(hostId, el)` callback, which fires on both moves. + +### 6. Expect rAF to be throttled while your pane has focus + +Chromium throttles a **backgrounded** window's `requestAnimationFrame` — and the +main window is exactly what's backgrounded while the user is looking at your pane. +Your rAF lives in the main window. + +Event-driven panels (sliders, buttons, presets) don't care. A panel that +*animates continuously* may run slowly precisely when it's the only thing on +screen. Drive such animation from data you already have, or accept the stutter. + +### 7. Don't synchronise anything + +No `BroadcastChannel`, no `postMessage`, no second copy of your state, no mirrored +UI. There is **one** realm and **one** panel. If you find yourself writing sync +code, you have misunderstood the model — the whole point is that there is nothing +to sync. + +### 8. Nothing here is required + +On a host without the panes API, `feedBack.panes` is `undefined`. Skip both calls +and your panel behaves exactly as it does today. Guard, don't depend: + +```js +const panes = window.feedBack && window.feedBack.panes; +if (!panes || typeof panes.register !== 'function') return; +``` + +--- + +## Things core guarantees + +- **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. +- **It comes home alive.** Core evacuates the element *before* the pane window's + document is destroyed. (Get this wrong — dock after the window dies — and the + node returns looking perfect with every listener in its subtree silently gone. + That bug is why this section exists.) +- **A pane window the user closes, or that crashes, is reaped** and the element + docked back. Your panel is never stranded in a dead document. +- **The app's stylesheets are copied into the pane window**, so your panel looks + identical — including your plugin's own `styles` sheet. From a0278bd3a71014b50edd1b2b0c68a8493a8c382c Mon Sep 17 00:00:00 2001 From: topkoa Date: Sun, 12 Jul 2026 22:58:10 -0400 Subject: [PATCH 2/2] =?UTF-8?q?docs(panes):=20the=20lifecycle=20traps=20?= =?UTF-8?q?=E2=80=94=20isConnected=20lies,=20and=20re-injection=20duplicat?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more rules, all learned by shipping the bug first. Every one of them produced a symptom that pointed nowhere near its cause. RULE 5 REWRITTEN — `isConnected` lies about a panel that is a pane, in BOTH directions: - true when the panel is not here (it is in a pane window) - FALSE when the panel is perfectly fine — the host detaches the element the moment a pop-out starts, before the new window has loaded Code that rebuilds on that `false` builds a SECOND panel while the host still holds the first. Docking brings both home. The one the user can see is the original, which the module no longer points at — so its close button closes the other, invisible panel ("the X doesn't work"), and the chip gets re-attached to the impostor ("the pop-out icon vanished"). Two baffling symptoms, one duplicate, nothing in the stack trace. Ask the pane system where the element is (`panes.isOpen(id)`), not the DOM. RULE 6 (new) — a plugin that can be re-injected must be able to remove itself. Without a teardown the second run duplicates every observer, timer and listener — and leaves a stale pane registration, which is worse than untidy: `element` is resolved LAZILY at open time, so the host gets a node from a dead instance. Pop out, and it moves a panel nobody owns. Includes the teardown people forget: panes.unregister(). RULE 1 EXTENDED — panel-internal id lookups. document.getElementById returns null once the panel has moved, so every update it guards silently stops happening while the user is looking at the panel. Search from the panel instead. Elements outside the panel never move and are fine as they are — audit which is which. Signed-off-by: topkoa --- docs/plugin-panes.md | 109 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 11 deletions(-) diff --git a/docs/plugin-panes.md b/docs/plugin-panes.md index a234c25..58478dd 100644 --- a/docs/plugin-panes.md +++ b/docs/plugin-panes.md @@ -173,8 +173,27 @@ document.body.appendChild(myTooltip); panelEl.appendChild(myTooltip); ``` -Same for measuring. `window.innerWidth` is the *main* window's. Use -`el.ownerDocument.defaultView` when you need the window your panel is actually in. +**And every lookup for something inside your panel.** Once the panel has moved, +`document.getElementById('my-panel-thing')` returns `null` — so every update it +guards silently stops happening, precisely while the user is looking at the panel. +No error. Just a UI that quietly goes dead. + +```js +// WRONG — null once the panel is popped out. +document.getElementById('my-panel-hint').textContent = msg; + +// RIGHT — search FROM the panel; works in either document. +panelEl.querySelector('#my-panel-hint').textContent = msg; +``` + +Elements that live outside your panel (your plugin's *screen*, host chrome) never +move, and should keep using `document.getElementById`. Audit which is which — in +the stem mixer, four ids were inside the panel and a dozen were not. + +Same for measuring and popovers. `window.innerWidth` is the *main* window's, and a +dismiss listener on `window` watches a window the user isn't clicking in. Use +`el.ownerDocument` / `el.ownerDocument.defaultView` when you need the window your +panel is actually in. ### 2. Don't hide your panel yourself @@ -212,19 +231,87 @@ chipDetach = feedBack.panes.attachChip(panel, PANE_ID, { header: toolsEl }); Call `chipDetach()` in your teardown too, or you leave a stub pointing at DOM that no longer exists. -### 5. `isConnected` does not mean "docked" +### 5. `isConnected` lies about a panel that is a pane -A panel sitting in a pane window is **connected** — just not to *this* document. -Code that asks "am I still mounted?" with `isConnected` will get `true` and act on -a panel that is somewhere else entirely. +This one has cost more debugging than everything else on this page combined, and +it lies in **both directions**. + +**It says `true` when your panel is not here.** A panel sitting in a pane window is +`isConnected` — just not to *this* document. Code asking "am I still mounted?" gets +`true` and then acts on a panel that is somewhere else entirely. + +**It says `false` when your panel is perfectly fine.** The host *detaches* the +element the moment a pop-out starts, before the new window has even loaded. In that +gap `isConnected` is `false` — and any code that rebuilds on that basis builds a +**second panel**, while the host is still holding the first. + +That second panel is the one your module variables now point at. The one the user +can *see* is the original, owned by nobody. So: + +- its close button closes the *other*, invisible panel — "the X doesn't work" +- your chip gets re-attached to the impostor — "the pop-out icon vanished" + +Two baffling symptoms, one duplicate, and nothing in the stack trace to suggest it. + +**Ask the pane system, not the DOM.** It knows where your element is: ```js -const docked = el.ownerDocument === document; // this is the question you meant +function paneOwnsPanel() { + const panes = window.feedBack && window.feedBack.panes; + return !!(panes && panes.isOpen && panes.isOpen(MY_PANE_ID)); +} + +// "Is my panel gone?" — not "is it in this document?" +if (panel && (panel.isConnected || paneOwnsPanel())) return panel; // alive; possibly elsewhere ``` -Or take the optional `onHost(hostId, el)` callback, which fires on both moves. +Every `isConnected` check on a panel that can be a pane needs this. In the stem +mixer that was `ensureMixerPanel()` (which rebuilt) *and* the MutationObserver's +fast path (which decided the UI was unmounted and swept on every mutation). -### 6. Expect rAF to be throttled while your pane has focus +For "which document is it in right now", use `el.ownerDocument === document`, or +take the optional `onHost(hostId, el)` callback, which fires on both moves. + +### 6. If your plugin can be re-injected, it must be able to remove itself + +The host may run your script more than once — a screen re-entry, a version change. +Without a teardown, the second run builds a second panel while the first one is +still on screen, and every module variable in the new instance points at the new, +invisible one. The user clicks the panel they can see; nothing happens. + +Everything stateful duplicates: observers, timers, listeners. And one thing is +worse than duplicated — **your pane registration**: + +```js +panes.register({ id, element: () => panel }); // resolved LAZILY, at open time +``` + +First registration wins, so a stale one hands the host `panel` from a **dead +instance**. Popping out then moves a panel nobody owns. + +So publish a teardown handle and call it at the top of your script: + +```js +if (window.__myPluginInstance?.destroy) { + try { window.__myPluginInstance.destroy(); } catch (e) { /* tear down what we can */ } +} + +window.__myPluginInstance = { + destroy() { + observer?.disconnect(); + clearTimeout(myTimer); + chipDetach?.(); // attachChip() returned this + panes?.unregister?.(MY_PANE_ID); // ← the one people forget + document.querySelectorAll('#my-panel').forEach((n) => n.remove()); + }, +}; +``` + +Belt and braces: when you build your panel, remove any node carrying its id that +isn't yours. A zombie panel is worse than no panel — it looks alive and does +nothing. + +### 7. Expect rAF to be throttled while your pane has focus Chromium throttles a **backgrounded** window's `requestAnimationFrame` — and the main window is exactly what's backgrounded while the user is looking at your pane. @@ -234,14 +321,14 @@ Event-driven panels (sliders, buttons, presets) don't care. A panel that *animates continuously* may run slowly precisely when it's the only thing on screen. Drive such animation from data you already have, or accept the stutter. -### 7. Don't synchronise anything +### 8. Don't synchronise anything No `BroadcastChannel`, no `postMessage`, no second copy of your state, no mirrored UI. There is **one** realm and **one** panel. If you find yourself writing sync code, you have misunderstood the model — the whole point is that there is nothing to sync. -### 8. Nothing here is required +### 9. Nothing here is required On a host without the panes API, `feedBack.panes` is `undefined`. Skip both calls and your panel behaves exactly as it does today. Guard, don't depend: