diff --git a/CHANGELOG.md b/CHANGELOG.md index 8335d8d..43250d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,37 @@ for how the document, manifest, and per-plugin versions relate. `events`), plus `kind` and `description`. The `compatibility` field is no longer a hard schema enum (its values are an open set); the observed values are documented instead. Purely additive and clarifying — every existing manifest still validates. +- §6 (Client surface) rewritten to document the client screen contract, ground-truthed against the + Host. Adds the **mount lifecycle** (§6.1 — Host-created container, `screen`-sourced markup, + self-executing `script` with **no Host-invoked entry point**, and a normative **idempotent + re-hydration MUST**), **screen activation/visibility** (§6.2), and a description of the + **Host-provided, Host-versioned runtime surface** (§6.3 — event bus, contribution registries, and + the forward-stable capability control plane; raw window globals documented as supported-but-legacy). + New **§6.4 "Performance and the shared main thread"** makes the hot-path rules normative + (SHOULD NOT do per-frame DOM/layout/IO; don't observe/mutate the shell — use contribution + registries; suspend work when hidden; keep state per-instance). Replaces the previous "client + runtime API is out of scope" placeholder. Settings/Styles/Static-assets renumbered to §6.5–6.7. +- Best-practices guide: added a **"Client screen & the shared main thread"** section grounded in + real feedBack performance regressions — no DOM/layout work on a per-frame path, don't + DOM-observe or mutate the app shell (use registration APIs instead of injecting into song/library + cards), no synchronous storage/network on hot or gameplay-event paths, idempotent re-hydration + (`plugin-runtime-idempotent.v1`), stop work when hidden, stay per-instance, and talk to other + plugins through the capability `claim`/`dispatch`/`release` flow rather than their globals. + Regrouped the guide (Getting started / Server routes / Client screen / Shipping) and expanded the + pre-publish checklist with a client-performance block. Docs only. +- Best-practices guide: added a **"Visualizations"** section for `type: "visualization"` plugins, + ground-truthed against the Host's renderer contract and recent splitscreen/settings fixes. Covers + the **factory pattern** (`window.feedBackViz_` returns a fresh renderer per call — required for + splitscreen's N simultaneous panels), the renderer interface (`draw`/`init`/`resize`/`destroy`/ + `contextType`), per-instance resource ownership and `destroy()` cleanup, treating the per-frame + bundle as read-only, self-detecting canvas size drift, and **communicating settings via + `applySetting(key, value)` per instance** (declare `settings` on the `visualization` capability; + the Host applies each change to the specific per-panel instance) — including the concrete failure + modes recent fixes addressed (apply-live-not-reload, no cross-setting leakage, deliberate + per-panel vs global key scoping, fan-out to all panels, settings panel loads before the renderer), + and persistence guidance (Host owns persistence — don't hand-roll `localStorage`; if self-managed, + stage an in-memory fallback before the quota-fallible `setItem` and keep it off the per-frame path), + plus the fail-safe auto-revert. Expanded the checklist with a Visualizations block. Docs only. ## [0.1.0] - 2026-07-05 diff --git a/examples/full-plugin/README.md b/examples/full-plugin/README.md index aa9c96f..a928bfe 100644 --- a/examples/full-plugin/README.md +++ b/examples/full-plugin/README.md @@ -19,5 +19,7 @@ python tools/validate.py examples/full-plugin ``` Target Host: written against the plugin runtime documented for feedBack as of spec v0.1.0. The -client runtime API (how `screen.js` mounts) is Host-provided and not yet pinned by the spec — see -[§6.1](../../spec/plugin-spec-v1.md#61-screen). +mount lifecycle is described in [§6.1](../../spec/plugin-spec-v1.md#61-screen-mount-lifecycle) and +the Host-provided (and Host-versioned) runtime surface in +[§6.3](../../spec/plugin-spec-v1.md#63-the-client-runtime-surface); the portable performance rules +are normative in [§6.4](../../spec/plugin-spec-v1.md#64-performance-and-the-shared-main-thread). diff --git a/spec/best-practices.md b/spec/best-practices.md index dabf22c..dade43b 100644 --- a/spec/best-practices.md +++ b/spec/best-practices.md @@ -9,7 +9,9 @@ and grow it toward [`examples/full-plugin`](../examples/full-plugin). --- -## 1. Start from the smallest thing that loads +## Getting started + +### 1. Start from the smallest thing that loads The smallest valid plugin is a folder with one file: @@ -22,26 +24,30 @@ Get *that* discovered first (folder name equal to `id`, dropped into a plugins d add one surface at a time — a screen, then settings, then routes. Adding surfaces incrementally means when something stops loading you know exactly which change caused it. -## 2. Treat the `id` as forever +### 2. Treat the `id` as forever The `id` keys your settings store, your routes namespace, and your capability declarations. Renaming it silently orphans every user's saved settings. Pick a lowercase, `-`/`_`-separated, descriptive `id` once (`drum_highway_3d`, not `dh3` or `DrumHighway`) and never change it. The folder name must match it exactly. -## 3. Keep the manifest declarative +### 3. Keep the manifest declarative Every file your plugin uses at load time should be *pointed at* from the manifest — `script`, `screen`, `styles`, `settings.html`, `routes`, `tour`. If the Host has to guess a filename, a future Host change can break you. Conversely, don't reference files you don't ship. -## 4. Version your plugin with semver +### 4. Version your plugin with semver Set `version` and bump it on every release: PATCH for fixes, MINOR for new optional behaviour, MAJOR when you change or remove behaviour users depend on. The plugin manager uses it to detect updates, and users use it to report bugs against a known build. -## 5. Server routes: do nothing at import time +--- + +## Server routes + +### 5. Do nothing at import time ```python # Good — all work happens inside setup() @@ -57,13 +63,13 @@ Importing your `routes` module must be cheap and side-effect-free: no network ca reads, no blocking. The Host imports every plugin's routes during startup; slow or failing imports slow or break the whole load. -## 6. Register routes only after you've validated +### 6. Register routes only after you've validated The Host may be unable to *un*-register a route once mounted. So validate configuration, open files, and check inputs **before** the first `app.get(...)` / `app.post(...)`. A `setup()` that mounts two routes and then throws leaves two half-working endpoints behind permanently. -## 7. Namespace everything under your `id` +### 7. Namespace everything under your `id` - Routes: `"/api/plugin/my-plugin/state"`, not `"/state"`. - CSS: scope selectors to your screen's root element, don't style bare `body`/`h1`. @@ -72,39 +78,243 @@ mounts two routes and then throws leaves two half-working endpoints behind perma Collisions with the Host or another plugin are silent and maddening; namespacing prevents them. -## 8. Persist state where the Host tells you +### 8. Persist state where the Host tells you Read and write only inside `context["config_dir"]` and any paths you declared in `settings.server_files`. Never write elsewhere in the Host's config/data tree, and never read another plugin's files. This is what keeps export/import, backups, and uninstall clean. -## 9. Fail soft, log clearly +--- + +## Client screen & the shared main thread + +Your `script` does **not** run in a sandbox. It executes in the app's own document and global +scope, on the **same main thread** as everything else — including a real-time note-highway that +renders at ~60 fps and mutates the DOM many times per second during play. Every millisecond your +screen spends on the main thread is a millisecond the render loop doesn't have. Most plugin +performance regressions in feedBack's history came from this one fact. The rules below are the +ones that were learned the hard way. + +### 9. Never touch the DOM or read layout on a per-frame / high-frequency path + +Do **not** call `querySelector` / `querySelectorAll`, read layout (`getBoundingClientRect`, +`offsetWidth`, `offsetHeight`), or write styles inside anything that fires at frame rate: +`requestAnimationFrame` callbacks, a `draw()` loop, a short `setInterval`, or a `MutationObserver` +callback. + +> A real profiled lag report in feedBack traced to **three plugins each doing a `querySelectorAll` +> every frame** — together ~18% of main-thread CPU, all of it stolen from the render loop. + +Resolve the elements you need **once, on mount**, cache the references, and re-resolve only when a +cached node has actually detached (`!el.isConnected`). Reading layout and writing styles in the +same high-frequency pass also forces synchronous reflow ("layout thrash") — batch all reads, then +all writes. + +### 10. Don't observe or mutate the app shell — use the registration APIs + +It is tempting to reach into the app's own UI — the song/library cards, the nav bar, the transport +— with `document.querySelector` and inject or rewrite nodes. Don't. The shell re-renders those +regions (the library grid is virtualized and repaints on every scroll frame and on score updates), +so anything you inject gets clobbered, and a `MutationObserver` watching a shared container fires +on **every** one of those repaints. + +> feedBack specifically removed a legacy pattern where several plugins added buttons to song cards +> by DOM-observing `.song-card`; it was replaced with a Host-provided **card-action registration +> API** for exactly this reason. + +So: + +- Contribute UI to a shared surface through the Host's registration API for that surface, not by + mutating its DOM. +- Never `MutationObserver.observe(document.body, { subtree: true })` (or any shared container with + `subtree: true`) — it turns every shell mutation into your work. +- Keep any predicate the Host calls per item (e.g. "does this card-action apply to this song?") + **O(1) and allocation-free** — it runs once per visible card on every re-render. + +### 11. No synchronous storage or awaited I/O on a hot or fan-out path + +`localStorage` is synchronous — it blocks the main thread while it runs. `fetch` is asynchronous, +but *awaiting* a network round-trip inside a hot handler still stalls that handler on I/O. Neither +a synchronous `localStorage` call nor an awaited `fetch` belongs in a handler that fires per note, +per frame, or on a high-frequency event. The worst offender is doing +this in response to a gameplay event (a song-complete / score event can arrive at the exact moment +the frame budget is tightest). Read settings once on mount and cache them; debounce writes to an +idle callback or a single `requestAnimationFrame`. + +### 12. Make re-hydration idempotent (`plugin-runtime-idempotent.v1`) -- Use `context["log"]` so your messages land in the Host log under your plugin's namespace. +The Host may **re-run your `script` mid-session** (for example when the plugin set reloads). If a +second run installs another event wrapper, timer, observer, DOM root, or capability participant, +you now have duplicates — a class of bug that has duplicated audio signal chains in practice. + +Make a second run a **no-op**. The established pattern is a stable singleton on `window`: + +```js +// Idempotent (plugin-runtime-idempotent.v1): re-hydration replaces impl, installs nothing twice. +const hooks = (window.__feedBackMyPluginHooks ||= { installed: false, impl: null }); +hooks.impl = makeImpl(); // always refresh the implementation +if (hooks.installed) return; // …but wire listeners/timers/wrappers only once +hooks.installed = true; +``` + +Only put `"plugin-runtime-idempotent.v1"` in your manifest `standards` once this is actually true +of your screen. + +### 13. Stop work when your screen is hidden, and stay per-instance + +- When your screen is not the active one, cancel your `requestAnimationFrame` loop and unsubscribe + from playback/gameplay events. A background plugin animating or listening during play is pure + waste on the hottest path. +- Keep all state **per screen instance**, not in module-level globals — feedBack can show two + screens at once (splitscreen), so a global counter or a single cached element handle will fight + itself across instances. + +### 14. Talk to other plugins through capabilities, not their globals + +To drive another plugin or a shared subsystem, use the capability pipeline's +`claim` / `dispatch` / `release` flow rather than reaching into another plugin's `window` globals +or internal objects. Declare your own participation honestly (`roles`, `ownership`, `safety`) and +service only the commands/events you actually implement. Reaching into foreign globals is exactly +the coupling the capability system exists to remove, and it breaks the moment that plugin reloads. + +--- + +## Visualizations + +A plugin whose manifest sets `"type": "visualization"` can replace the app's note-highway +renderer. The Host runs this renderer inside its own ~60 fps draw loop, and — critically — the app +can show **several highways at once** (splitscreen). Everything below exists so one renderer works +correctly when the Host makes many copies of it. The mechanism names here (the `feedBackViz_` +factory global, the renderer methods, `applySetting`) are the **current Host contract**; treat the +principles as stable and the exact API as Host-versioned. + +### 15. Always register a factory, never a singleton + +Expose your renderer as a **factory function** — a zero-argument function the Host calls to get a +**fresh renderer instance every time** — on the global `window.feedBackViz_` (where `` is +your `plugin.json` `id`). Do **not** assign a single shared renderer object. + +This is the whole reason splitscreen works: the Host creates one highway per panel and calls your +factory once per panel, so N panels get N independent renderers. A singleton would have every panel +fight over one WebGL context, one canvas, and one set of meshes — the classic splitscreen bug. + +```js +function createRenderer() { + // ALL state is per-instance closure state — one set per panel. + let canvas, gl, meshes, unsubscribe; + return { + contextType: 'webgl2', // '2d' (default) or 'webgl2'; read before init() + init(canvasEl, bundle) { canvas = canvasEl; /* acquire own context, build scene */ }, + draw(bundle) { /* render this frame from the snapshot */ }, // the only REQUIRED method + resize(w, h) { /* rebuild framebuffers */ }, + destroy() { unsubscribe?.(); /* free GL + DOM */ }, + }; +} +window.feedBackViz_my_viz = createRenderer; // the global IS the factory function +window.feedBackViz_my_viz.contextType = 'webgl2'; // optional static, read before constructing +``` + +The renderer interface: `draw(bundle)` is **required**; `init(canvas, bundle)`, `resize(w, h)`, +`destroy()`, `contextType`, and `readyPromise` are optional. The Host lifecycle is +`factory()` → `init(canvas, bundle)` → per-frame `draw(bundle)` → `resize` on canvas change → +`destroy()` on renderer swap or stop. + +### 16. Keep every resource per-instance and release it in `destroy()` + +Hold your context, buffers, meshes, DOM overlays, and event subscriptions in the factory's closure, +one set per instance — never in module-level globals or a single shared DOM node parented to "the" +panel. `destroy()` runs on every swap and on stop and MUST release everything (unsubscribe, free GL, +remove any DOM you added); a leak here multiplies by the number of panels. Resolve any DOM against +**your own** instance's container, never a global `document.querySelector` that could grab a sibling +panel's node. + +### 17. Treat the per-frame bundle as read-only, and keep `draw()` allocation-free + +The `bundle` the Host passes to `draw()` is a **snapshot object reused across frames** — its array +fields are live, read-only references, not copies. Never mutate it, and never cache its identity or +its arrays across frames. Because `draw()` runs ~60 times a second **per panel**, do no allocation +and no DOM/layout work inside it (see rule 9) — precompute on `init`/`resize`. + +### 18. Self-detect canvas size changes + +Don't assume the Host will call your `resize()`. Under splitscreen the host may resize the highway +without forwarding the call to your renderer, so check the canvas's width/height at the top of +`draw()` against the last size you applied and rebuild your framebuffers when it drifts. (This was a +real bug where 3D highways stayed framed for their pre-fullscreen size in splitscreen.) + +### 19. Communicate settings through `applySetting`, per instance — not a side channel + +For user-adjustable controls, declare a `settings` array on your `visualization` capability +(`{ key, label, type: "toggle" | "range" | "select", default, min/max/step, options }`) and +implement **`applySetting(key, value)`** on the renderer instance. The Host validates the +descriptors, renders the controls, owns persistence, and calls `applySetting` **on each specific +per-panel instance** — so a change reaches every panel and is inherently per-instance, with no +shared global keys and no canvas-to-panel lookup to get wrong. + +Hard-won rules this replaces — the ways settings communication actually broke: + +- **Apply live; never reload.** Applying a setting via `location.reload()` reboots the app and drops + the user out of the settings panel. Update the running renderer instead. +- **Don't let one setting leak into another.** If you migrate an old setting into a new one, back it + up **once** on load and persist it *without* re-broadcasting; a "mirror on every read" makes one + control silently overwrite another, and the render disagree with the UI. +- **Scope keys deliberately.** Only genuinely per-panel controls get per-panel storage; shared state + (a palette, an uploaded asset) stays global, so a stale per-panel override can't shadow a global + edit or duplicate a heavy asset per panel. +- **Reach every instance.** A settings change must fan out to all mounted panels, each re-reading in + its own scope — not just the panel that happens to be focused. +- **The settings panel loads before your renderer.** `settings.html` is injected before your + `script` runs, so guard any calls into your renderer's globals (`window.myViz && window.myViz…`) + and let the panel hydrate its own controls from persisted values/defaults independently. + +**On persistence and `localStorage`.** Under the `applySetting` contract the **Host owns +persistence** — declare the setting, apply values live, and let the Host store and replay them. +Prefer that: do **not** hand-roll settings into `localStorage`, which is what keeps export/import and +backups whole and stops per-panel copies from drifting. If your plugin nonetheless manages its own +persistence (a self-managed viz that predates the contract), two rules from the fixes apply: +`localStorage` is **synchronous and can throw** (quota / private mode), so stage the new value in an +in-memory fallback **before** the `setItem` and prefer that in-memory value on read — a failed write +must never leave the renderer showing a stale value while the UI claims the change applied. And +never touch `localStorage` on a per-frame path (rule 9) — read it once and cache it. + +### 20. Fail safe — the Host reverts a broken renderer + +If your `draw()` throws on several consecutive frames the Host automatically reverts to the built-in +renderer and emits a revert event. Guard `draw()` so a transient error degrades one frame rather +than tripping the auto-revert and dropping the user back to the default visualization. + +--- + +## Shipping & good citizenship + +### 21. Fail soft, log clearly + +- Use `context["log"]` (server) so your messages land in the Host log under your plugin's + namespace. - A missing config file should mean "use defaults", not a crash. Read tolerantly (see the [full example](../examples/full-plugin/routes.py)). - If a surface can't initialise, degrade to a reduced-but-working state rather than taking the whole plugin down. -## 10. Degrade gracefully across Host versions +### 22. Degrade gracefully across Host versions A plugin may run on a Host older than the one you developed against. Don't assume a `context` key or a client runtime API exists without a documented Host version guaranteeing it. If an optional surface isn't supported, your plugin's other surfaces must still work. -## 11. Only declare capabilities you actually implement +### 23. Only declare capabilities you actually implement `capabilities` and `standards` wire you into cross-plugin pipelines (diagnostics, capability inspection). Declaring a capability you don't service registers a phantom participant and breaks the pipeline. If you don't participate, omit both keys entirely. -## 12. Mind the security boundary +### 24. Mind the security boundary Your `routes` run arbitrary Python in the server process and your `script` runs in the app's renderer. Validate every route input, don't shell out on user data, and don't reach outside your plugin directory. Users installing your plugin are trusting it like an app extension — earn it. -## 13. Ship a README and a changelog +### 25. Ship a README and a changelog A plugin folder should carry a short `README.md` (what it does, which Host version it targets) and note changes per version. It costs little and saves every future reader — including you. @@ -120,5 +330,35 @@ note changes per version. It costs little and saves every future reader — incl - [ ] `routes.py` does no work at import time; `setup()` validates before registering. - [ ] Routes, CSS, and persisted files are namespaced under the `id`. - [ ] `version` is set and follows semver. -- [ ] Capabilities/standards declared only if actually implemented. + +**Client-screen performance (if you ship a `script`):** + +- [ ] No `querySelector`/layout reads/style writes inside `requestAnimationFrame`, `draw()`, short + `setInterval`, or `MutationObserver` callbacks — element refs resolved once on mount. +- [ ] No `MutationObserver` on a shared shell container with `subtree: true`; shell UI contributed + via registration APIs, not DOM injection. +- [ ] No synchronous `localStorage`, and no awaited `fetch`/network I/O, on a per-frame / per-note / + gameplay-event path. +- [ ] Re-running `script` is a no-op (idempotent hydration) if you declare + `plugin-runtime-idempotent.v1`. +- [ ] rAF loops and event subscriptions stop when the screen is hidden; state is per-instance. + +**Visualizations (if `type` is `"visualization"`):** + +- [ ] `window.feedBackViz_` is a **factory function** returning a fresh renderer per call, not a + shared object; all renderer state is per-instance closure state. +- [ ] `draw(bundle)` is implemented; the bundle is treated as read-only and never cached; `draw()` + allocates nothing. +- [ ] `destroy()` releases every context/DOM/subscription; DOM is resolved against the instance's + own container, not a global selector. +- [ ] Canvas size drift is self-detected in `draw()` (don't rely on `resize()` being called). +- [ ] Settings apply live via `applySetting(key, value)` on the instance (no reload, no cross-setting + leakage, no shared global keys for per-panel controls). +- [ ] Persistence is left to the Host (no hand-rolled `localStorage`); if self-managed, writes are + quota-safe (in-memory fallback staged before `setItem`) and never on a per-frame path. + +**Capabilities & shipping:** + +- [ ] Capabilities/standards declared only if actually implemented; cross-plugin calls go through + `claim`/`dispatch`/`release`, not foreign globals. - [ ] A `README.md` states purpose and target Host version. diff --git a/spec/plugin-spec-v1.md b/spec/plugin-spec-v1.md index c87bb94..450ae84 100644 --- a/spec/plugin-spec-v1.md +++ b/spec/plugin-spec-v1.md @@ -75,7 +75,7 @@ tuner/ # directory name == manifest "id" Only `plugin.json` is REQUIRED. Every other file exists **because the manifest points at it**; a file present but not referenced from the manifest is ignored by the Host (though it MAY still -be served as a static asset — see [§6.4](#64-static-assets)). +be served as a static asset — see [§6.7](#67-static-assets)). File and directory names inside a plugin SHOULD be lowercase with `-` or `_` separators. The directory name (the `id`) MUST match `^[a-z0-9][a-z0-9_-]*$` (see [§4.2](#42-id)). @@ -234,29 +234,124 @@ disabled because doing so would break core surfaces. ## 6. Client surface -### 6.1. Screen - -A plugin with a `script` and/or `screen` contributes a navigable screen. The Host adds a -navigation entry (labelled by `name`, iconed by `icon`) that activates the screen. - -`script` is loaded as a client-side JavaScript module. The runtime API available to that -module (how it mounts, reads settings, and talks to its own `routes`) is provided by the Host and -is **out of scope for this version of the spec** — it is documented by the Host's own developer -docs and is evolving. A future version of this spec SHOULD pin that API. Until then, a plugin -targeting a specific Host version SHOULD record which Host runtime it was written against. - -### 6.2. Settings panel +A plugin with a `script` and/or `screen` contributes a navigable screen that runs in the app's +renderer. This section pins the **portable, stable** rules a screen must follow +([§6.4](#64-performance-and-the-shared-main-thread) especially), and describes the current +**Host-provided runtime surface** ([§6.3](#63-the-client-runtime-surface)) — which is versioned by +the Host and is **not** frozen by this specification version. + +### 6.1. Screen mount lifecycle + +The mount **mechanism** is defined and versioned by the Host, not by this document; the mechanics +below describe the current Host and are given so plugin authors can reason about lifecycle and +idempotence. What is **normative and stable** is the idempotence requirement at the end of this +section. + +For each ready plugin that declares a `screen`, the Host: + +1. creates a container element it owns — currently a `
` with a deterministic, + `id`-derived identifier (of the form `plugin-`) — and inserts it into the app shell; +2. sets that container's markup from the plugin's `screen` file; +3. loads the plugin's `script` and executes it once. + +There is **no Host-invoked entry point**: the Host does not call a `mount()`, `init()`, or +`render()` export. A plugin's `script` is a self-executing module that runs on load, wires up its +own behaviour, and finds its own DOM by the identifiers the plugin authored inside its `screen` +markup. A plugin therefore SHOULD namespace those identifiers under its `id` so they don't collide +with the Host's or another plugin's — every plugin shares one document. + +**Re-hydration (normative).** The Host MAY execute a plugin's `script` more than once in a session +— for example when the plugin set reloads. A plugin's `script` **MUST** be idempotent: a second +(or later) execution MUST NOT install a second copy of any listener, timer, observer, DOM subtree, +capability participant, or wrapped Host function. The established pattern is a guard on a +well-known global: a second run refreshes its implementation but installs shared listeners, timers, +and wrappers only once. This suppresses duplicate **global** side effects from re-execution; it is +distinct from **per-screen-instance** state, which [§6.4](#64-performance-and-the-shared-main-thread) +says to keep per instance (the Host may mount several instances of a screen at once). A plugin that +declares the `plugin-runtime-idempotent.v1` standard (see [§8](#8-capabilities-and-standards)) +asserts exactly this property and MUST honour it. + +### 6.2. Screen activation and visibility + +A plugin's screen is not always visible. The Host activates exactly one screen at a time and +signals the transition; the current Host expresses activation by toggling an `active` class on the +screen container and emitting a `screen:changed` event (carrying the activated screen's id) on its +client event bus ([§6.3](#63-the-client-runtime-surface)). + +A plugin SHOULD react to activation/deactivation rather than assuming it is always on screen, and +SHOULD suspend background work (animation loops, high-frequency subscriptions) while its screen is +not active — see [§6.4](#64-performance-and-the-shared-main-thread). + +### 6.3. The client runtime surface + +Beyond mounting a screen, the Host exposes a runtime surface a plugin MAY use. **This surface is +provided and versioned by the Host, and is not frozen by this specification version.** It is +described here so authors know what exists and how stable each part is; a future version of this +spec MAY pin parts of it normatively. There is no single global "Host version" — individual runtime +objects each carry their own `version` sentinel, and a plugin SHOULD feature-detect (check that an +object and its `version` exist) rather than assume. + +The current surface has three tiers: + +- **A client event bus** — a Host object (an `EventTarget`) offering `on` / `off` / `emit`, plus + live state and transport helpers. The Host emits lifecycle events over it (screen activation, + song load/ready, playback transport, position, library change). A plugin subscribes to react to + app state. **Stable but general** — treat unknown events as optional. +- **Contribution registries** — Host APIs through which a plugin *registers* a contribution to a + shared surface instead of mutating the shell's DOM (for example, registering a library-card + action, or a renderer factory for a visualization). Using these is strongly preferred over + reaching into shell DOM (see [§6.4](#64-performance-and-the-shared-main-thread)). +- **The capability control plane** — the versioned `claim` / `dispatch` / `release` / + `registerParticipant` surface described in [§8](#8-capabilities-and-standards). This is the + **forward-stable, explicitly-versioned** surface (`capability-pipelines.v1`) and is the preferred + way for a plugin to drive or observe another plugin or a shared subsystem. + +The current Host also exposes a set of **legacy global functions** (navigation, playback, and +library actions). These are **supported but legacy** — the Host is migrating them behind the +capability control plane. A plugin SHOULD prefer the capability surface and the contribution +registries over calling legacy globals, and MUST NOT assume any legacy global exists without +feature-detecting it. + +### 6.4. Performance and the shared main thread + +A plugin's `script` runs, unsandboxed, on the app's **shared main thread** — the same thread as a +real-time render loop that draws the note highway at up to ~60 frames per second and mutates the +DOM many times per second during playback. Main-thread time a plugin spends is time the render loop +does not have. Accordingly: + +- A plugin **SHOULD NOT** perform DOM queries (`querySelector` / `querySelectorAll`), layout reads + (`getBoundingClientRect`, `offsetWidth`/`offsetHeight`), or style writes on a **per-frame or + high-frequency path** — inside a `requestAnimationFrame` loop, a render/`draw` callback, a short + `setInterval`, or a `MutationObserver` callback. A plugin SHOULD resolve the elements it needs + once, when its screen mounts, cache those references, and re-resolve only when a cached node has + actually detached. +- A plugin **SHOULD NOT** observe or mutate the app shell's DOM (for example the song/library cards + or the navigation bar) directly, and MUST NOT install a subtree `MutationObserver` on a shared + container. To contribute UI to a shared surface, a plugin SHOULD use the Host's contribution + registries ([§6.3](#63-the-client-runtime-surface)). +- A plugin **SHOULD NOT** perform synchronous storage (e.g. `localStorage`), blocking I/O, or + network I/O on a per-frame path or in a handler for a high-frequency or gameplay event. +- A plugin **SHOULD** suspend animation loops and high-frequency subscriptions while its screen is + not active ([§6.2](#62-screen-activation-and-visibility)), and **SHOULD** keep screen state + per-instance rather than in module-level globals, because the Host MAY mount more than one + instance of a screen at once (e.g. splitscreen), even though only one screen is *active* at a time + ([§6.2](#62-screen-activation-and-visibility)). + +These rules are portable and stable regardless of how the Host's runtime API evolves. The +non-normative [best-practices guide](best-practices.md) expands on them with examples. + +### 6.5. Settings panel When `settings.html` is declared, the Host renders it as the plugin's settings panel, grouped by `settings.category`. Settings values a plugin persists are stored by the Host, keyed under the plugin `id`. -### 6.3. Styles +### 6.6. Styles When `styles` is declared, the Host applies that CSS while the plugin's screen is active. Plugin CSS SHOULD be scoped to the plugin's own DOM to avoid leaking styles into the rest of the app. -### 6.4. Static assets +### 6.7. Static assets The Host MAY serve files inside a plugin directory as static assets (images, audio, fonts). A plugin MUST NOT rely on any file *outside* its own directory being served, and MUST NOT assume a