From df9dc481ef2a8ae626ce7400a538ba9c69e8d4cd Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Sun, 5 Jul 2026 21:07:44 -0400 Subject: [PATCH 1/6] =?UTF-8?q?spec=20=C2=A76:=20document=20the=20client?= =?UTF-8?q?=20screen=20contract=20+=20normative=20hot-path=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "client runtime API is out of scope" placeholder in §6 with a real, ground-truthed contract, split into a portable normative layer and a descriptive (Host-versioned, not-frozen) layer. - §6.1 Screen mount lifecycle: Host creates an id-derived container, sources its markup from `screen`, and executes `script` once. There is NO Host-invoked entry point — `script` self-executes and finds its own DOM. Normative: re-hydration MUST be idempotent (a second execution installs nothing twice); declaring plugin-runtime-idempotent.v1 asserts this. - §6.2 Screen activation and visibility: one screen active at a time; the Host signals transitions; plugins should react and suspend background work when hidden. - §6.3 The client runtime surface (descriptive, explicitly not frozen): the event bus, contribution registries, and the forward-stable capability control plane (capability-pipelines.v1); raw window globals documented as supported-but-legacy. No global Host version — per-object `version` sentinels, feature-detect. - §6.4 Performance and the shared main thread (NORMATIVE): SHOULD NOT do per-frame DOM queries / layout reads / IO; don't observe or mutate the shell (use contribution registries); suspend loops when hidden; keep state per-instance. This anchors the best-practices performance guidance in the contract. Renumber Settings/Styles/Static-assets to §6.5–6.7 and fix the §3 cross-ref. Update the full-plugin README pointer and CHANGELOG [Unreleased]. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: K. O. A. --- CHANGELOG.md | 10 +++ examples/full-plugin/README.md | 6 +- spec/plugin-spec-v1.md | 121 +++++++++++++++++++++++++++++---- 3 files changed, 120 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8335d8d..2f256ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ 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. ## [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/plugin-spec-v1.md b/spec/plugin-spec-v1.md index c87bb94..6b2214f 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,120 @@ 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 own and namespace those identifiers (see +[§6.4](#64-performance-and-the-shared-main-thread)). + +**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 stable singleton +guard on a well-known global that the second run detects and returns early from. 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 or blocking/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 display more than one + screen at once. + +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 From 145a383fd854a176bcdd8ec9c4f7e6e2bac1d868 Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Mon, 6 Jul 2026 00:24:17 -0400 Subject: [PATCH 2/6] =?UTF-8?q?spec=20=C2=A76:=20address=20review=20(rehyd?= =?UTF-8?q?ration=20scope,=20xref,=20blocking=20I/O,=20one-active-screen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §6.1: drop misleading §6.4 cross-ref on identifier namespacing; namespace under id. - §6.1: clarify the re-hydration guard suppresses global side effects, distinct from per-instance screen state. - §6.4: spell out 'blocking I/O or network I/O' with a localStorage example. - §6.4: reword to 'mount more than one instance of a screen' so it doesn't contradict §6.2's one-active-screen statement. Signed-off-by: K. O. A. --- spec/plugin-spec-v1.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/spec/plugin-spec-v1.md b/spec/plugin-spec-v1.md index 6b2214f..450ae84 100644 --- a/spec/plugin-spec-v1.md +++ b/spec/plugin-spec-v1.md @@ -257,14 +257,17 @@ For each ready plugin that declares a `screen`, the Host: 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 own and namespace those identifiers (see -[§6.4](#64-performance-and-the-shared-main-thread)). +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 stable singleton -guard on a well-known global that the second run detects and returns early from. A plugin that +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. @@ -326,12 +329,13 @@ does not have. Accordingly: 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 or blocking/network I/O on a per-frame path - or in a handler for a high-frequency or gameplay event. +- 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 display more than one - screen at once. + 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. From f99c7b2a3fc554afa38b48785ecc1d1e4eae7e9d Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Sun, 5 Jul 2026 20:58:17 -0400 Subject: [PATCH 3/6] best-practices: add client hot-path performance guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Client screen & the shared main thread" section to the best-practices guide, grounded in real feedBack performance regressions rather than generic advice. The screen runs unsandboxed on the same main thread as a ~60fps note highway, and most historical plugin perf problems came from that. New rules: - Never do DOM queries / layout reads / style writes on a per-frame or high-frequency path (rAF, draw(), short setInterval, MutationObserver) — resolve refs once on mount; batch reads then writes. (A profiled lag report was three plugins each doing a per-frame querySelectorAll.) - Don't DOM-observe or mutate the app shell — especially the song/library cards. Contribute UI via the Host registration APIs; no subtree MutationObserver on shared containers; keep per-item predicates O(1). (Replaces the legacy .song-card DOM-observation pattern.) - No synchronous localStorage / await fetch on a hot or gameplay-event path. - Make re-hydration idempotent (plugin-runtime-idempotent.v1) with a stable window singleton; a second script run must be a no-op. - Stop rAF loops / event subscriptions when hidden; keep state per-instance (splitscreen runs multiple instances). - Reach other plugins via capability claim/dispatch/release, not their globals. Regroup the guide (Getting started / Server routes / Client screen / Shipping) and expand the pre-publish checklist with a client-performance block. Docs only. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: K. O. A. --- CHANGELOG.md | 8 +++ spec/best-practices.md | 147 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 140 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f256ec..d33aea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,14 @@ for how the document, manifest, and per-plugin versions relate. (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. ## [0.1.0] - 2026-07-05 diff --git a/spec/best-practices.md b/spec/best-practices.md index dabf22c..9522a55 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,135 @@ 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 network or storage on a hot or fan-out path + +`localStorage` is synchronous; `fetch`/`await` stalls your handler on I/O. Neither 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`. -- Use `context["log"]` so your messages land in the Host log under your plugin's namespace. +### 12. Make re-hydration idempotent (`plugin-runtime-idempotent.v1`) + +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. + +--- + +## Shipping & good citizenship + +### 15. 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 +### 16. 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 +### 17. 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 +### 18. 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 +### 19. 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 +222,20 @@ 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` or `await fetch` 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. + +**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. From 1f05a2177a721252d73e0225f42bcea6e449af05 Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Mon, 6 Jul 2026 00:25:07 -0400 Subject: [PATCH 4/6] best-practices: fix 'synchronous network' wording (fetch is async) Rule 11 + checklist: distinguish synchronous localStorage from awaited network I/O; fetch itself is asynchronous. Signed-off-by: K. O. A. --- spec/best-practices.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/spec/best-practices.md b/spec/best-practices.md index 9522a55..c8b8a0a 100644 --- a/spec/best-practices.md +++ b/spec/best-practices.md @@ -131,10 +131,12 @@ So: - 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 network or storage on a hot or fan-out path +### 11. No synchronous storage or awaited I/O on a hot or fan-out path -`localStorage` is synchronous; `fetch`/`await` stalls your handler on I/O. Neither belongs in a -handler that fires per note, per frame, or on a high-frequency event. The worst offender is doing +`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`. @@ -229,7 +231,8 @@ note changes per version. It costs little and saves every future reader — incl `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` or `await fetch` on a per-frame / per-note / gameplay-event path. +- [ ] 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. From dbc49abfd4be629220e5bd1d193ec000c8aa3c0d Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Sun, 5 Jul 2026 21:22:53 -0400 Subject: [PATCH 5/6] best-practices: add Visualizations section (factory pattern + settings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Visualizations" section to the best-practices guide for type: "visualization" plugins, ground-truthed against the Host's renderer contract and the recent splitscreen/settings fixes. - Factory pattern (rule 15): window.feedBackViz_ MUST be a factory function returning a fresh renderer per call — this is what makes splitscreen's N simultaneous panels work; a singleton shares one context/canvas/meshes across panels and collides. Includes the renderer interface (draw required; init/resize/destroy/contextType/readyPromise optional) and lifecycle. - Per-instance resources + destroy() cleanup (16); read-only per-frame bundle, allocation-free draw() (17); self-detect canvas size drift (18). - Settings communication (19): declare `settings` on the visualization capability and implement applySetting(key, value) on the instance — the Host applies each change to the specific per-panel instance (inherently per-panel). Documents the concrete failure modes recent fixes addressed: apply-live-never- reload, no cross-setting leakage (backfill once, don't mirror on read), deliberate per-panel vs global key scoping, fan-out to all panels, and the settings panel loading before the renderer. - Fail-safe auto-revert (20): guard draw() or the Host reverts to default after repeated throws. Renumber the Shipping section to 21-25 and add a Visualizations checklist block. Docs only. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: K. O. A. --- CHANGELOG.md | 11 ++++ spec/best-practices.md | 118 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 124 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d33aea7..61c46c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,17 @@ for how the document, manifest, and per-plugin versions relate. 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), + plus the fail-safe auto-revert. Expanded the checklist with a Visualizations block. Docs only. ## [0.1.0] - 2026-07-05 diff --git a/spec/best-practices.md b/spec/best-practices.md index c8b8a0a..75514b0 100644 --- a/spec/best-practices.md +++ b/spec/best-practices.md @@ -179,9 +179,105 @@ the coupling the capability system exists to remove, and it breaks the moment th --- +## 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. + +### 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 -### 15. Fail soft, log clearly +### 21. Fail soft, log clearly - Use `context["log"]` (server) so your messages land in the Host log under your plugin's namespace. @@ -190,25 +286,25 @@ the coupling the capability system exists to remove, and it breaks the moment th - If a surface can't initialise, degrade to a reduced-but-working state rather than taking the whole plugin down. -### 16. 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. -### 17. 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. -### 18. 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. -### 19. 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. @@ -237,6 +333,18 @@ note changes per version. It costs little and saves every future reader — incl `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). + **Capabilities & shipping:** - [ ] Capabilities/standards declared only if actually implemented; cross-plugin calls go through From b2040e8f5fabcf4fd9b13d9bdba72cd064ca0f36 Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Sun, 5 Jul 2026 21:27:14 -0400 Subject: [PATCH 6/6] best-practices: add persistence/localStorage guidance to viz settings (rule 19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 19 said "the Host owns persistence" but never addressed localStorage. Make it explicit: prefer Host-owned persistence (declare the setting, apply live, let the Host store/replay) and do not hand-roll settings into localStorage — that is what keeps export/import and backups whole and stops per-panel copies drifting. For a self-managed viz that predates the contract, add the quota-safe rule from the fixes: localStorage is synchronous and can throw (quota / private mode), so stage the value in an in-memory fallback before setItem and prefer it on read, and never touch localStorage on a per-frame path. Add a matching checklist item. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: K. O. A. --- CHANGELOG.md | 2 ++ spec/best-practices.md | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c46c5..43250d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ for how the document, manifest, and per-plugin versions relate. 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/spec/best-practices.md b/spec/best-practices.md index 75514b0..dade43b 100644 --- a/spec/best-practices.md +++ b/spec/best-practices.md @@ -267,6 +267,16 @@ Hard-won rules this replaces — the ways settings communication actually broke: `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 @@ -344,6 +354,8 @@ note changes per version. It costs little and saves every future reader — incl - [ ] 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:**