feat(panes): mirrorGlobal, manifest-declared panes, and the plugin docs

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_<id> = { mount, unmount }`, mirroring
the existing window.feedBackViz_<id> 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/<id>/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 <topkoa@gmail.com>
This commit is contained in:
topkoa 2026-07-12 17:38:05 -04:00
parent d508380532
commit 254e26bb3a
6 changed files with 510 additions and 2 deletions

213
docs/plugin-panes.md Normal file
View File

@ -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 `<audio>` element, and no audio graph. Same file,
same `mount()`, different world.
So: **everything your pane touches must come through `ctx`.** A pane that reaches
for a global works docked and silently dies popped out. There is no way for core to
paper over that, because a closure cannot cross a window boundary.
---
## `ctx`
| | |
|---|---|
| `ctx.call(domain, command, payload)``Promise` | The capability bus. This is your only door into app services. Core builds the requester/origin/timeout envelope; you send a payload. Cross-realm calls have a 10 s deadline and reject with `PaneRpcTimeout`. |
| `ctx.on(event, fn)``unsub` | The `feedBack` bus. Handlers get a `{ detail }` object, exactly as in the main window. Only allow-listed events are mirrored (see below). |
| `ctx.subscribe(stream, fn)``unsub` | High-rate numerics: `'playhead'``{ t, duration, playing }`, `'meters'``{ master }`. |
| `ctx.playhead()``number` | The current time in seconds, smoothed. **Use this, not a stream, for per-frame drawing** — see "The clock" below. |
| `ctx.state.get(path)` / `.set(path, value)` / `.subscribe(fn)` | A dotted-path store, persisted per pane. The **main realm is authoritative**. |
| `ctx.song()` | The current `feedBack.currentSong`, or `null`. |
| `ctx.toast(opts)`, `ctx.close()` | |
| `ctx.paneId`, `ctx.host`, `ctx.isRemote` | |
**`ctx` tracks every subscription it hands you and drops them on unmount.** You
cannot leak a listener across a dock/undock cycle even if you try. Your `unmount()`
only needs to clear your own DOM.
### Events mirrored by default
`song:loading`, `song:loaded`, `song:ready`, `song:play`, `song:pause`, `song:ended`,
`song:stop`, `song:seek`, `song:arrangement-changed`, `screen:changed`,
`theme:changed`, `library:changed`, `highway:canvas-replaced`, `highway:visibility`.
Widen with `spec.events: ['audio-mix:fader-value-changed', …]`.
**`song:position-changed` is deliberately not on the list.** It fires every 250 ms —
too coarse to animate with, too chatty to mirror across a window. Use the `playhead`
stream or `ctx.playhead()`.
---
## The clock
The main window broadcasts the playhead every frame. But **Chromium throttles a
backgrounded window's rAF to ~1 Hz** — and the main window is exactly what's
backgrounded while the user is looking at your pane. A pane that renders the raw
broadcast stutters at 1 fps.
`ctx.playhead()` solves this: it extrapolates between broadcasts
(`anchor + observedRate × elapsed`, capped at 2 s). The rate is *learned from the
broadcasts themselves*, so it tracks the speed slider without being told, and a dead
main window decays into a frozen clock rather than one that confidently runs away.
**Draw from `ctx.playhead()` in your own rAF loop. Subscribe to `'playhead'` only
for things that change slowly** (a time readout, a progress bar).
---
## Levels, and why they are numbers
An `AnalyserNode` **cannot cross a window boundary.** Your pane can never hold one.
So core samples the analyser in the realm that owns the audio graph and ships you
plain numbers over `ctx.subscribe('meters', …)`. The stream stays **silent** when
there is no analyser (no stems plugin) rather than reporting zeros — so "silent" and
"actually silent" stay distinguishable, and you can render an honest "unavailable"
state.
---
## Declaring a pane in `plugin.json` (preferred)
```json
"panes": [{
"id": "camera_director",
"title": "Camera Director",
"icon": "🎥",
"script": "panes/camera.js",
"defaultHost": "window",
"mirrorGlobal": "__h3dCamCtl",
"width": 380,
"height": 560
}]
```
Declaring it beats calling `panes.register()` from `screen.js`, because a
manifest pane is **openable from the rail and the tray without your plugin's screen
ever having been visited**. Core registers a stub 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.)
`script` is a relpath under your plugin's `src/`, served through the sandboxed
`/api/plugins/<id>/src/…` route. It sets a factory global — the same shape the viz
contract already uses:
```js
window.feedBackPane_camera_director = {
mount(root, ctx) { … },
unmount(root, ctx) { … },
};
```
**This exact file is what a pop-out window loads in its own realm.** Write it to the
one rule above.
---
## `mirrorGlobal` — for panes that drive a plain global
The 3D highways read their free camera from `window.__h3dCamCtl` once per frame.
A camera panel in the main window just writes that object. A panel in a **pop-out
window cannot** — `window.__h3dCamCtl` there is a different object in a different
realm, and writing it moves nothing.
Declare `mirrorGlobal: '__h3dCamCtl'` and core copies your pane's state onto that
global, **in the main realm, mutating the object in place** (a renderer may be
holding the reference). You write `ctx.state.set('yaw', 0.3)`; the renderer keeps
reading the plain global it always read; `highway_3d` is not modified and does not
know panes exist.
---
## Rules that will bite you
1. **Never cache the renderer or the canvas.** `playSong()` calls `highway.stop()`
`destroy()`. Re-bind on `song:ready` / `highway:canvas-replaced` /
`highway:visibility`.
2. **Module top-level does not re-run** on screen re-entry (see
[plugin-modules.md](plugin-modules.md)). Per-visit init belongs in `mount()`.
3. **No per-frame `querySelector`.** Panes share the main thread with a 60 fps render
loop. Resolve refs in `mount()`, cache them. (See the perf rules in
[CLAUDE.md](../CLAUDE.md).)
4. **A pane with no `script` can only ever be docked.** It exists solely as a closure
in the main realm, and there is no honest way to move a closure across a window
boundary — the window host declines it and the router falls back to the dock.
5. **`localStorage` is shared with your pop-out window** (same origin). Use
`ctx.state`, which has exactly one writer.
6. **Persistence is on by default** (`persist: false` to opt out). State is keyed by
pane id.
---
## Hosts
`panes.detach(id)` opens a pane in the best available host:
| host | priority | |
|---|---|---|
| `desktop` | 20 | A real Electron `BrowserWindow`. Remembered geometry, always-on-top, system tray. |
| `window` | 10 | A browser pop-up (`window.open`). No tray, no remembered bounds. |
| `dock` | 0 | The in-window card stack. **The floor** — always available, so a pane can never fail to open. |
You do not choose; you declare `defaultHost` and the router does the rest. A pane
popped out in the desktop app comes back popped out on next launch; in a browser it
comes back **docked**, because a browser blocks `window.open()` without a user
gesture and a "pop-up blocked" toast on every page load would be worse than useless.
## API
```js
feedBack.panes.register(spec) -> unregister
feedBack.panes.attachChip(el, paneId, { header }) -> detach
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
```
`spec`: `{ id*, title, icon, mount*, unmount, script, events[], persist, initialState,
defaultHost, mirrorGlobal, width, height }`.

View File

@ -277,6 +277,66 @@ def _normalize_string_list(value) -> list[str]:
return [stripped for item in value if isinstance(item, str) and (stripped := item.strip())]
def _normalize_panes(value, plugin_id: str) -> list[dict]:
"""Validate a manifest's `panes` list (detachable panes — window.feedBack.panes).
Each entry: {id, title, icon?, script, defaultHost?, mirrorGlobal?, width?, height?}.
`script` is a relpath served through the sandboxed /api/plugins/<id>/src/
route, so it MUST live under the plugin's src/ tree — the same containment
rule `styles` has for assets/. A pane whose script could escape that route
would be an arbitrary-file-read dressed up as a manifest key.
A bad entry is dropped with a warning rather than failing the whole plugin:
one malformed pane should not cost the user their working plugin.
"""
if not isinstance(value, list):
return []
out: list[dict] = []
seen: set[str] = set()
for entry in value:
if not isinstance(entry, dict):
log.warning("[Plugin] %s: `panes` entry is not an object, skipping", plugin_id)
continue
pane_id = entry.get("id")
script = entry.get("script")
if not isinstance(pane_id, str) or not pane_id.strip():
log.warning("[Plugin] %s: pane is missing `id`, skipping", plugin_id)
continue
pane_id = pane_id.strip()
if not isinstance(script, str) or not script.strip():
log.warning("[Plugin] %s: pane %r is missing `script`, skipping", plugin_id, pane_id)
continue
script = script.strip().lstrip("/")
# Reject anything that could climb out of src/: absolute paths, drive
# letters, backslashes, and .. segments — the same checks settings.server_files
# applies to its relpaths.
if (
"\\" in script
or ".." in script.split("/")
or ":" in script
or not script.endswith(".js")
):
log.warning("[Plugin] %s: pane %r has an unsafe `script` %r, skipping", plugin_id, pane_id, script)
continue
if pane_id in seen:
log.warning("[Plugin] %s: duplicate pane id %r, skipping", plugin_id, pane_id)
continue
seen.add(pane_id)
pane = {
"id": pane_id,
"title": entry.get("title") if isinstance(entry.get("title"), str) else pane_id,
"icon": entry.get("icon") if isinstance(entry.get("icon"), str) else None,
"script": script,
"defaultHost": entry.get("defaultHost") if entry.get("defaultHost") in ("window", "dock") else None,
"mirrorGlobal": entry.get("mirrorGlobal") if isinstance(entry.get("mirrorGlobal"), str) else None,
"width": entry.get("width") if isinstance(entry.get("width"), int) else None,
"height": entry.get("height") if isinstance(entry.get("height"), int) else None,
}
out.append(pane)
return out
def _normalize_manifest_mapping(value) -> dict:
return value if isinstance(value, dict) else {}
@ -1442,6 +1502,14 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
# client can build the asset URL without a second manifest read.
"has_styles": bool(manifest.get("styles")),
"styles": manifest.get("styles"),
# Detachable panes (window.feedBack.panes). Declaring them in the
# manifest — rather than calling panes.register() from screen.js —
# is what lets a pane be OPENED FROM THE TRAY OR THE RAIL WITHOUT THE
# PLUGIN'S SCREEN EVER HAVING BEEN VISITED: the host registers a stub
# from this projection and only fetches the pane's script when the
# user actually opens it. Validated (id + script required, script
# sandboxed under src/) so one bad entry can't take the list down.
"panes": _normalize_panes(manifest.get("panes"), plugin_id),
"standards": _normalize_string_list(manifest.get("standards")),
"capabilities": _validated_capabilities,
"capability_validation_warnings": _capability_validation_warnings,
@ -2164,6 +2232,11 @@ def register_plugin_api(app: FastAPI):
# _nav_entry() at discovery and carried through graduation.
# The `.get()` fallbacks keep stubbed test entries (which build
# rows directly without going through _nav_entry) working.
# Detachable panes. Key-presence check (not truthiness) like
# `capabilities` below: _nav_entry() may legitimately have
# validated the list down to empty, and an `or` would wrongly
# re-derive the unvalidated manifest value.
"panes": p["panes"] if "panes" in p else _normalize_panes((p.get("_manifest") or {}).get("panes"), p.get("id", "plugin")),
"standards": p.get("standards") or _normalize_string_list((p.get("_manifest") or {}).get("standards")),
"capabilities": p["capabilities"] if "capabilities" in p else _normalize_manifest_mapping((p.get("_manifest") or {}).get("capabilities")),
"capability_validation_warnings": p.get("capability_validation_warnings", []),
@ -2211,6 +2284,11 @@ def register_plugin_api(app: FastAPI):
"has_tour": e.get("has_tour", False),
"has_styles": e.get("has_styles", False),
"styles": e.get("styles"),
# Pending entries come from _nav_entry() too, so their panes are
# already validated. Surfacing them here is what lets a pane be
# opened while its plugin is still installing its dependencies —
# the pane's script is fetched on open, not now.
"panes": e.get("panes", []),
# Pending entries are built from _nav_entry() too, so they
# carry the same capability-pipelines.v1 metadata — surface it
# so the Inspector can show still-installing plugins.

View File

@ -0,0 +1,80 @@
/*
* fee[dB]ack global mirroring for panes.
*
* The camera-director problem, solved without touching a single renderer.
*
* The 3D highways read their free-camera state from a plain global
* `window.__h3dCamCtl = { enabled, heightMul, distMul, yaw, pitch, panX, panY }`
* (plugins/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 manifest field:
*
* panes.register({ id: 'camera_director', mirrorGlobal: '__h3dCamCtl', ... })
*
* and this file which runs in the MAIN realm, where the renderers live copies
* that pane's state onto the global whenever it changes. The pane writes
* ctx.state, the state store is authoritative here, and the renderers keep reading
* the plain global they always read. highway_3d, keys_highway_3d and
* drum_highway_3d are not modified, and do not know panes exist.
*
* The one rule that makes it work: MUTATE THE OBJECT, NEVER REPLACE IT. A renderer
* may be holding the reference (_resolveFreeCam caches it), and swapping in a new
* object would leave it writing to and reading from an orphan.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
const bus = window.feedBack;
if (!panes || !bus) return;
// paneId -> unsubscribe
const mirrors = new Map();
function _target(name) {
// Create the global if the plugin that owns it hasn't yet (a pane can be
// restored at boot before its renderer ever runs). Reuse it if it exists —
// see the rule above.
if (!window[name] || typeof window[name] !== 'object') window[name] = {};
return window[name];
}
function _apply(name, state) {
const target = _target(name);
// Copy the pane's whole state root onto the global. Keys the pane doesn't
// set are left alone rather than deleted — the global may carry fields the
// pane knows nothing about (a renderer's own bookkeeping), and clearing
// them would be a silent, spooky breakage.
Object.keys(state).forEach((k) => { target[k] = state[k]; });
}
bus.on('panes:opened', (e) => {
const id = e.detail && e.detail.id;
const spec = panes.get(id);
if (!spec || !spec.mirrorGlobal || mirrors.has(id)) return;
const entry = panes._entry(id);
if (!entry) return;
// Sync once on open: a pane's persisted state is the user's last camera,
// and it should take effect the moment the pane exists — not on their next
// nudge of a slider.
_apply(spec.mirrorGlobal, entry.state.all());
mirrors.set(id, entry.state.subscribe((all) => _apply(spec.mirrorGlobal, all)));
});
bus.on('panes:closed', (e) => {
const id = e.detail && e.detail.id;
const unsub = mirrors.get(id);
if (!unsub) return;
unsub();
mirrors.delete(id);
// The global is deliberately LEFT AS IT IS. Closing the camera panel should
// not snap the camera back to a default — that is exactly what happens
// today (nobody clears __h3dCamCtl), and it is the behaviour users expect.
});
})();

View File

@ -0,0 +1,122 @@
/*
* fee[dB]ack manifest-declared plugin panes.
*
* A plugin can ship a pane by declaring it in plugin.json, with no code in
* screen.js at all:
*
* "panes": [{
* "id": "camera_director",
* "title": "Camera Director",
* "icon": "🎥",
* "script": "panes/camera.js",
* "mirrorGlobal": "__h3dCamCtl"
* }]
*
* The point of declaring it rather than calling panes.register() is that the
* pane becomes openable from the rail, from the tray WITHOUT THE PLUGIN'S
* SCREEN EVER HAVING BEEN VISITED. We register a stub from the manifest and
* fetch the script only when the user actually opens it. A pane you can only
* reach by first navigating to the screen it was supposed to replace is not
* much of a pane.
*
* The script sets a factory global, the same shape the viz contract already uses
* (`window.feedBackViz_<id>`):
*
* window.feedBackPane_camera_director = {
* mount(root, ctx) { ... },
* unmount(root, ctx) { ... },
* };
*
* It is loaded from the sandboxed /api/plugins/<plugin>/src/<script> route, and
* the SAME file is what a pop-out window loads in its own realm so it must not
* assume the app is there. `ctx` is everything it gets. See docs/plugin-panes.md.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes) return;
// Loaded factories, so re-opening a pane doesn't re-fetch its script.
const loading = new Map(); // paneId -> Promise<factory>
function _factory(paneId) {
return window['feedBackPane_' + paneId] || null;
}
function _load(paneId, url) {
const already = _factory(paneId);
if (already) return Promise.resolve(already);
if (loading.has(paneId)) return loading.get(paneId);
const p = new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.onload = () => {
const f = _factory(paneId);
if (f) resolve(f);
else reject(new Error('pane script loaded but set no window.feedBackPane_' + paneId));
};
s.onerror = () => reject(new Error('failed to load pane script: ' + url));
document.head.appendChild(s);
});
loading.set(paneId, p);
p.catch(() => loading.delete(paneId)); // let a transient failure be retried
return p;
}
function register(plugin, def) {
const url = '/api/plugins/' + plugin.id + '/src/' + def.script;
panes.register({
id: def.id,
title: def.title || def.id,
icon: def.icon || '▣',
// The pop-out realm loads exactly the same file.
script: url,
defaultHost: def.defaultHost || 'window',
mirrorGlobal: def.mirrorGlobal || null,
width: def.width || undefined,
height: def.height || undefined,
// The stub. Docked, the script is fetched on first open and its
// factory takes over from here; the root is already on screen, so a
// slow fetch shows an empty card rather than blocking the click.
mount(root, ctx) {
_load(def.id, url).then((factory) => {
if (!root.isConnected) return; // closed again while we were fetching
factory.mount(root, ctx);
root.__fbPaneFactory = factory;
}).catch((err) => {
console.error('[panes]', def.id, err);
const oops = document.createElement('div');
oops.className = 'fb-pane-dim';
oops.textContent = 'This pane failed to load.';
root.appendChild(oops);
});
},
unmount(root, ctx) {
const factory = root.__fbPaneFactory;
delete root.__fbPaneFactory;
// Only the factory that actually mounted gets to unmount. A pane
// closed before its script arrived never mounted, and calling
// unmount() on it would hand the plugin a root it has never seen.
if (factory && typeof factory.unmount === 'function') {
try { factory.unmount(root, ctx); } catch (e) { console.error('[panes]', def.id, 'unmount threw', e); }
}
root.replaceChildren();
},
});
}
fetch('/api/plugins')
.then((r) => r.json())
.then((list) => {
(Array.isArray(list) ? list : []).forEach((plugin) => {
if (plugin.enabled === false || !Array.isArray(plugin.panes)) return;
plugin.panes.forEach((def) => register(plugin, def));
});
})
.catch((err) => console.error('[panes] could not read the plugin list', err));
})();

View File

@ -126,11 +126,22 @@
});
loadScript(snap.spec.script).then(() => {
if (!captured) { fail('The pane script loaded but registered nothing.'); return; }
// Two ways a script can hand us a pane, and the same file must work in
// both realms:
// - a plugin pane sets window.feedBackPane_<id> (the factory global,
// mirroring the existing window.feedBackViz_<id> contract), because
// the main realm loads it lazily and must not have to re-register;
// - a core built-in calls feedBack.panes.register(), which our shim
// above captured.
const pane = window['feedBackPane_' + paneId] || captured;
if (!pane || typeof pane.mount !== 'function') {
fail('The pane script loaded but provided no pane.');
return;
}
statusEl.hidden = true;
rootEl.hidden = false;
try {
captured.mount(rootEl, ctx);
pane.mount(rootEl, ctx);
mounted = true;
} catch (err) {
console.error('[pane] mount() threw', err);

View File

@ -1333,9 +1333,13 @@
<script defer src="/static/panes/pane-desktop-host.js"></script>
<script defer src="/static/panes/pane-hub.js"></script>
<script defer src="/static/panes/pane-chip.js"></script>
<script defer src="/static/panes/pane-mirror.js"></script>
<script defer src="/static/panes/pane-launcher.js"></script>
<script defer src="/static/panes/builtin/now-playing.js"></script>
<script defer src="/static/panes/builtin/mixer-pane.js"></script>
<!-- Last: registers panes declared in plugin manifests, so a plugin's pane is
openable from the rail or the tray without its screen ever being visited. -->
<script defer src="/static/panes/pane-plugins.js"></script>
<script>
// Navbar scroll effect
window.addEventListener('scroll', () => {