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..58478dd 100644 --- a/docs/plugin-panes.md +++ b/docs/plugin-panes.md @@ -153,28 +153,202 @@ 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); +``` + +**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 + +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` lies about a panel that is a pane + +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 +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 +``` + +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). + +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. +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. + +### 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. + +### 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: + +```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.