feedBack/docs/plugin-visualization-contracts.md
Miguel_LZPF 1214a6c0a0
docs: extract plugin contracts into modular docs and slim CLAUDE.md
CLAUDE.md had grown to 545 lines / 50 KB — most of it plugin-author
content that other AI tools (Cursor, Copilot, Codex, Aider) and humans
without AI never reach. Extract the plugin surface into 10 focused
docs and a JSON Schema for plugin.json, then slim CLAUDE.md to a
156-line navigable index.

New docs (~999 lines total, all self-contained):

  docs/PLUGIN_AUTHORING.md            — entry point and quickstart
  docs/plugin-manifest.md             — plugin.json field reference
  docs/plugin-visualization-contracts.md — setRenderer / overlay / note-state
  docs/plugin-audio-mixer.md          — fader registration
  docs/plugin-logging.md              — context["log"] + env vars
  docs/plugin-diagnostics.md          — server_files / callable
  docs/plugin-keyboard-shortcuts.md   — registerShortcut + scopes
  docs/plugin-sibling-imports.md      — load_sibling pattern
  docs/websocket-protocol.md          — /ws/highway message reference
  docs/testing-plugins.md             — pytest fixtures + Playwright

  schema/plugin.schema.json           — Draft 2020-12 schema for
                                        plugin.json; license enum
                                        mirrors CONTRIBUTING's curated
                                        allowlist. Backs CI validation
                                        and the plugin-validate skill.

CLAUDE.md slim (581 lines changed, -485):

  - Removed ~300 lines of plugin-author prose (now in docs/).
  - Kept architecture quick reference, running the app, testing,
    git workflow, versioning, song formats, frontend/backend
    conventions, plugin authoring INDEX (table → docs/), first-hour
    pitfalls, "For AI agents" footer.
  - Anchor stubs preserved next to the new index entries so deep
    links from specs/001-slopsmith-platform/analyze.md still resolve.

Verification:
  python -c "import json,glob,jsonschema; s=json.load(open('schema/plugin.schema.json')); [jsonschema.validate(json.load(open(p)), s) for p in sorted(glob.glob('plugins/*/plugin.json'))]"
  # ok — validates highway_3d, app_tour_library, app_tour_settings
Signed-off-by: Miguel_LZPF <mgcdreamer@gmail.com>
2026-06-18 00:38:50 -07:00

22 KiB

Plugin visualization contracts

Slopsmith supports two ways for a plugin to participate in the main player's visuals. They coexist; the setRenderer contract is the default for any viz that draws a highway-shaped surface, and overlays handle layered decorations on top. A third orthogonal contract — note-state provider — lets scorers feed per-note judgments back into whichever renderer is active.

Pick the right shape:

  • Replacing the whole highway drawing on the existing highway canvas (your renderer owns its rendering context / resources; createHighway() still owns the canvas element and the rAF loop)? → setRenderer (section 1). Enters the viz picker. Works in both the main player and per-panel under splitscreen.
  • Adding a layer on top of whichever viz is active? → Overlay (section 2). Navbar toggle, not in the picker.
  • Scoring plugin that wants renderers to "light up" notes on correct hits? → Note-state provider (section 3). Orthogonal — can coexist with overlay HUDs.

1. setRenderer contract (slopsmith#36) — preferred

Plugins that want to replace the main highway's draw function (per panel, per session) export a renderer factory on window.slopsmithViz_<id> where <id> matches the id in plugin.json (type: "visualization" required). The factory returns an object matching this shape:

window.slopsmithViz_my_viz = function () {
    return {
        // Required canvas context type. Default '2d' if omitted.
        // highway.js reads this BEFORE calling init() so it can
        // replace the underlying <canvas> element if the current
        // one is locked to a different context type (see "Canvas
        // context-type swapping" below).
        contextType: '2d', // or 'webgl2'
        init(canvas, bundle) {
            // One-time setup. Own your getContext() call here —
            // acquire '2d' or 'webgl2' depending on the renderer.
            // The canvas you receive is guaranteed to either be
            // unbound or already bound to your declared contextType.
            this.ctx = canvas.getContext('2d');
        },
        draw(bundle) {
            // Called each requestAnimationFrame tick by the factory.
            // `bundle` is a snapshot with: currentTime, songInfo, isReady,
            // notes, chords, anchors (all difficulty-filter-aware),
            // beats, sections, chordTemplates, stringCount, lyrics,
            // toneChanges, toneBase, mastery, hasPhraseData, inverted,
            // lefty, renderScale, lyricsVisible, the 2D coordinate
            // helpers project and fretX, and getNoteState (see below).
            // `stringCount` is the active arrangement's string count (4
            // for bass, 6 for guitar, 7+ for extended-range GP imports —
            // size string-indexed geometry against this, not a hardcoded
            // 6). If your renderer needs lefty-aware text rendering, check
            // bundle.lefty and apply the mirror transform yourself —
            // a bundle-level helper isn't provided because it would
            // need your renderer's own context, not the factory's.
            //
            // bundle.getNoteState(note, chartTime) (slopsmith#254) — call
            // this per visible chart note / chord-note to find out whether
            // a scorer (note_detect) has flagged it 'hit' / 'active' (a
            // sustain currently being held correctly) / 'miss', so the gem
            // itself can light up / a held sustain can glow instead of
            // relying on an overlay ring. Returns null when no provider is
            // registered or it reports nothing for this note; otherwise
            // { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
            // For chord notes pass the chord's time (note_detect keys its
            // judgments by `${time}_${string}_${fret}`). 'hit' and 'active'
            // are both "lit" — a renderer may treat them identically; the
            // provider owns all fade timing via `alpha` and by simply
            // ceasing to return state when the effect should end.
        },
        resize(w, h) {
            // Optional. Canvas dims already updated; re-create WebGL
            // framebuffers / reset 2D transforms here.
        },
        destroy() {
            // Optional. Release resources, remove DOM nodes, null refs.
            // Called before setRenderer() swaps to another renderer
            // and on highway.stop().
        },
    };
};

Selecting this plugin in the main-player viz picker — or in splitscreen's per-panel picker — calls highway.setRenderer(factory()) on the existing highway instance. The built-in 2D highway is the default renderer and is restored by passing nullish — setRenderer(null) and setRenderer(undefined) both work (the implementation gates on r == null). Splitscreen panels create one createHighway() per panel and each independently consults the picker, so N panels can run different renderers (or N copies of the same renderer with different arrangements) without coordination.

Lifecycle contract

The factory returns a single renderer instance that may go through multiple init() → ... → destroy() cycles as the user navigates between songs or screens. Specifically:

  • init(canvas, bundle) runs when the highway has a canvas and the renderer takes over drawing. This is when to acquire getContext(), build shaders / meshes / DOM nodes, and register listeners.
  • draw(bundle) runs on every rAF frame once the WebSocket ready message has fired and until the renderer is replaced or the highway stops. It is not called during the loading / reconnect window (between api.init() + stop() and the next ready) — that would hand the renderer half-populated chart arrays. Renderers that want to show a "loading" state can read bundle.isReady inside a future-widened contract, but today the factory gates draw behind the ready flag and isReady is only informational once it does fire.
  • destroy() runs when the renderer is replaced via another setRenderer(...) call, OR when highway.stop() is called (e.g. the user navigates away from the player). It releases everything init() acquired.
  • After destroy(), the same instance may receive another init() call — this happens on playSong() which does stop()init() to reuse the same canvas element for the next song. Renderers must tolerate init() being called again on an instance that was previously destroyed. Practically: null your refs in destroy, re-acquire them in init.
  • destroy() is skipped when it would run on an un-init'd renderer — if a caller does setRenderer(x) before the highway ever init'd (possible when restoring a saved picker selection at page load), x.destroy() is not called until x.init() has run at least once.
  • resize(w, h) is optional; runs after init and whenever the canvas dimensions change.

Key rules

  • The factory returns a fresh object on each call — important for splitscreen, where multiple panels will each get an independent instance.
  • The renderer owns its own rendering context (2D or WebGL). Factory will not call getContext for you.
  • Canvas context-type swapping. Browsers lock a <canvas> to the first context type successfully acquired for its lifetime: once getContext('2d') succeeds, getContext('webgl2') on that same canvas returns null, and vice versa. To let arbitrary 2D ⇄ WebGL renderer swaps work mid-session, highway.setRenderer() reads the next renderer's contextType before calling its init() and, if it differs from the type currently bound, replaces the underlying <canvas> element with a fresh one via oldCanvas.cloneNode(false) followed by oldCanvas.replaceWith(newCanvas). The factory then calls the renderer's init(newCanvas, bundle) with the fresh element so its getContext() succeeds. Practical implications:
    • What survives the swap. cloneNode(false) preserves every HTML attribute on the element — id, class, inline style, all data-* and aria-* attributes, role, tabindex, the attribute form of width/height, and anything else a plugin attached. DOM position is preserved by replaceWith(), so siblings, parents, and surrounding layout are unaffected.
    • What does NOT survive. Event listeners attached via addEventListener are NOT cloned, and expando properties set imperatively on the JavaScript object (such as the bound rendering context, or any canvas._myPlugin = …-style data a plugin attached) are not carried over either. The bound rendering context being left behind on the detached element is exactly what allows the new canvas to start fresh and accept a different getContext() call. Note: canvas.width/canvas.height are reflected HTML attributes, so those values do survive the clone; api.resize() re-applies the backing-store dimensions on the new element after the swap regardless.
    • Renderers must declare contextType on the returned instance ('2d' or 'webgl2'; absent → '2d'). Factories may also expose it as a static (window.slopsmithViz_<id>.contextType = 'webgl2') so core can read it before constructing the renderer — used today by Auto-mode evaluation.
    • Plugins that hold a stale reference to the highway canvas across renderer swaps — including any code that registered listeners directly on the canvas element rather than on window/document — should listen for the highway:canvas-replaced event on window.slopsmith and re-acquire / re-register. window.slopsmith.emit dispatches a CustomEvent, so the payload { oldCanvas, newCanvas, contextType } lives on event.detail, not on the event object itself:
      window.slopsmith.on('highway:canvas-replaced', (event) => {
          const { oldCanvas, newCanvas, contextType } = event.detail;
          // re-acquire / re-register against newCanvas
      });
      
      Plugins that re-query document.getElementById('highway') lazily inside their own event handlers don't need this listener — they pick up the new element automatically (it keeps id="highway").
    • highway:visibility — fired on window.slopsmith whenever the highway canvas transitions between displayed and hidden. Detection is DOM-based via canvas.offsetParent === null (catches display:none on the canvas or any ancestor — e.g. splitscreen's #highway hide) or whatever a host explicitly sets via highway.setVisible(bool). While visible === false, core skips the rAF renderer.draw(bundle) call AND the default 2D draw, so renderers don't have to no-op themselves. The event is emitted only on transitions (including the first one after init()), not every frame. Payload { visible, canvas } lives on event.detail:
      window.slopsmith.on('highway:visibility', (event) => {
          const { visible, canvas } = event.detail;
          // Toggle any sibling DOM your renderer mounts. The 3D Highway
          // renderer hides its `.h3d-wrap` overlay here so `display:none`
          // on `#highway` actually hides the visible output.
      });
      
      Renderers that only paint to the slopsmith canvas don't need this listener — the rAF skip is enough. Renderers that mount sibling DOM (separate WebGL contexts, overlays, etc.) do.
    • highway.setVisible(bool | null) — forces the visibility state regardless of offsetParent. Pass null to clear the override and resume DOM-based detection. Useful when the host hides the highway via visibility:hidden, opacity:0, transforms, or clipping rather than display:none. The override re-emits any resulting transition immediately rather than waiting for the next rAF tick.
    • Default-renderer ctx is closure-cached. The replace path nulls the closure ctx so stale draw paths short-circuit; the next default-renderer init() re-acquires the 2D context from the new canvas cleanly.
  • draw(bundle) receives difficulty-filtered arrays — never read from _filteredNotes or other internals.
  • _drawHooks fire for the default 2D renderer (the factory calls them at the end of each frame). Custom WebGL renderers that maintain a 2D overlay canvas (like the bundled 3D highway) also call window.highway.fireDrawHooks(ctx, W, H) on that overlay so overlay plugins continue to work regardless of which renderer is active. Custom renderers without a 2D overlay context should not attempt to fire hooks.

Auto mode — matchesArrangement(songInfo) (optional)

The viz picker prepends an "Auto (match arrangement)" entry that is the default selection on fresh installs. When Auto is active, core evaluates registered viz factories on every song:ready and swaps the renderer to the first factory whose matchesArrangement(songInfo) predicate returns truthy. No match → the built-in 2D highway.

Declare the predicate as a static on the factory (not the instance) so core can evaluate it without constructing a throwaway renderer:

window.slopsmithViz_piano = function () { /* ... */ };
window.slopsmithViz_piano.matchesArrangement = function (songInfo) {
    return /keys|piano|synth/i.test((songInfo && songInfo.arrangement) || '');
};
  • songInfo is the highway's live song_info snapshot — arrangement, tuning, capo, arrangement_index, filename, artist, title, etc. May be {} before the first song loads.
  • Factories without matchesArrangement are skipped during auto-selection — the correct default for arrangement-agnostic viz (tabview, jumpingtab) that only make sense as manual picks.
  • Explicit picker selections override Auto and are persisted to localStorage.vizSelection, so the pinned choice survives page reloads until the user switches back to "Auto" (which also persists). Picking "Auto" re-evaluates against the current song immediately. In contexts where localStorage is unavailable (private mode, sandboxed iframes, some test runners) persistence falls back to the current picker <option> value, which still overrides Auto for as long as the page stays loaded.
  • When an Auto-selected renderer fails and core emits viz:reverted, the picker falls back to the built-in default and disables auto-switching until the user re-selects Auto.
  • First match wins (picker order), so the registration order of plugins is the tiebreaker. Keep predicates narrow to avoid stealing songs from more specialized viz.

WebGL viz in Auto mode. Auto evaluation runs on every song:ready regardless of which renderer is active. Auto-installing a WebGL renderer when the canvas is currently 2D — or reverting from a WebGL Auto pick to the default 2D — works without a reload because setRenderer swaps the canvas element when contextType differs (see "Canvas context-type swapping" above). WebGL viz can therefore safely declare matchesArrangement and rely on Auto. For 3D Highway specifically, _canRun3D() in app.js still gates Auto from picking it on machines without WebGL2 — that fallback is independent of canvas swapping.

2. Overlay contract — for add-on layers

Plugins that add a layer on top of whichever visualization is active — HUDs, fretboard diagrams, chord labels, practice feedback — don't replace the renderer. They manage their own canvas, their own rAF loop, and a toggle button somewhere visible (typically a navbar pill), reading public highway state via the getters:

  • highway.getTime() / highway.getBeats() — current playback position
  • highway.getNotes() / highway.getChords() — difficulty-filter-aware arrays
  • highway.getChordTemplates() — chord shape lookup table; index by chord.id from getChords() to get { name, fingers, frets }. fingers and frets are per-string arrays (length matches the tuning's string count); within fingers, -1 = unused, 0 = open string, n > 0 = finger number. RS XML sources populate real fingerings; GP imports currently emit all -1 since pre-import sources don't carry finger data. Not filter-aware: templates are static metadata, every chord_id referenced by getChords() is guaranteed valid.
  • highway.getSongInfo() — tuning, arrangement, capo
  • highway.getStringCount() — number of strings on the active arrangement (4 for bass, 6 for guitar, 7+ for extended-range GP imports). Derived server-side as max(notes-max-string + 1, name-based fallback, len(tuning)) where the tuning length only contributes when it isn't the RS-XML padded 6-string form (sloppak / GP-imported sources carry trimmed tuning lengths). The name-based fallback is 4 for arrangements containing "bass" (case-insensitive) and 6 otherwise. This combination handles partial-string-usage charts (a 6-string lead that never plays string 5), extended-range GP imports (5-string bass, 7-string guitar), and sloppaks that explicitly encode the instrument range — without requiring plugins to do their own arrangement-name matching.
  • highway.getLefty() / highway.getInverted() — mirror + invert state

Overlays do NOT appear in the viz picker and do NOT declare "type": "visualization" in plugin.json. They coexist with whichever renderer (default 2D, 3D highway, piano, ...) the user has picked.

Key rules

  • Own your rAF + canvas — don't piggyback on _drawHooks or on createHighway's rendering context. Draw hooks fire for the default 2D renderer and for custom renderers that explicitly call window.highway.fireDrawHooks(ctx, W, H) (e.g. the bundled 3D highway fires them on its 2D overlay canvas), but not for every custom renderer.
  • Re-read state every frame — overlay output must track whatever the current renderer is drawing. Don't cache note positions across frames.
  • Respect lefty + invert toggles — if the overlay depicts strings or frets, mirror using the same transforms the active renderer would.
  • If you position with highway.project / highway.fretX (the 2D-highway geometry), gate on highway.isDefaultRenderer() — those helpers describe the built-in 2D highway's depth curve and fret zoom. When a custom renderer (3D highway, piano, …) is active your draw hook still fires (on that renderer's 2D overlay layer), but those coordinates won't match its scene — markers land in arbitrary places. Skip rendering when isDefaultRenderer() is false; the custom renderer owns that feedback. Renderer-agnostic overlays (fretboard diagram, chord-label HUD — they use getNotes()/getChordTemplates() + their own layout) don't need this guard.
  • Clean up on toggle-off — cancel rAF and remove/hide the overlay canvas so inactive overlays aren't wasting frames.

Reference: fretboard plugin — canonical overlay implementation (navbar toggle, own canvas, 80ms active-note window).

Why two contracts?

setRenderer plugs into an existing highway — main-player or splitscreen-panel — reusing its WebSocket and data parsing, so the common "I want a different look for the same data" case is zero boilerplate AND multi-instance for free. Overlays compose with whatever renderer is active — they decorate rather than replace, so multiple can stack (fretboard + chord labels + practice feedback) without fighting over the canvas.

A previous standalone-pane contract (window.createMyVisualization({ container }) with its own WebSocket per pane) was used by splitscreen pre-Wave-C. It's been retired now that splitscreen calls setRenderer on per-panel createHighway() instances; if you find references in older plugin docs or external integration guides, those describe the legacy path.

3. Note-state provider — for scorers that want renderers to "light up" notes (slopsmith#254)

A scoring plugin (note_detect) can publish a per-note judgment so whichever renderer is active draws the gem itself lit on a correct hit, and keeps a sustain trail glowing while it's still being played correctly — instead of a separate overlay ring floating near the note.

// In the plugin (after resolving the highway instance):
highway.setNoteStateProvider((note, chartTime) => {
    // `note` is the chart note object ({ t, s, f, sus, ... }); for chord
    // notes `chartTime` is the chord's time. Return one of:
    //   - falsy  → no special state (render normally)
    //   - 'hit'    — struck correctly; renderer lights the gem
    //   - 'active' — a sustained note is right now being held correctly
    //   - 'miss'   — missed; renderer may red-wash the gem
    //   - { state: <one of the above>, alpha?: 0..1, color?: '#rrggbb' }
    // You own all fade timing: return a decaying `alpha` for a struck-note
    // glow, `alpha: 1` (or a bare string) for a held sustain, and stop
    // returning state when the effect should end. Keep it cheap — it's
    // called per visible note per renderer per frame.
});
// On teardown:  highway.setNoteStateProvider(null);
  • Only one provider is active at a time (last setNoteStateProvider wins). highway.getNoteStateProvider() returns the current one (or null).
  • The built-in 2D highway consults it in drawNote / drawSustains / the chord-frame path: 'hit'/'active' → bright string colour + additive halo + a contained "sizzle" (crackling sparks, throbbing core, a shockwave ring on a fresh strike) on the gem and a bright (vs dim) sustain trail; 'miss' → faint red wash. The bundled 3D highway reads the same data via bundle.getNoteState (bright string-tinted outline + bright body + glowing sustain + a contained sparkle hugging the note rect on hit/active; red outline + suppressed body on miss). Custom renderers that want it call bundle.getNoteState(note, chartTime) — it null-guards and returns the normalized { state, alpha, color } (or null).
  • This is orthogonal to the overlay contract: note_detect remains an overlay (HUD, diagnostic miss markers, the "currently detected" indicator) and a scorer that feeds this provider. A renderer that ignores getNoteState simply doesn't light gems — nothing breaks.