feedBack/static/v3/plugins-page.js
Byron Gamatos 4b4c156fce
refactor(ui): defer every classic script, keep boot() on DOMContentLoaded (R3a) (#872)
Puts every external `<script>` in the v3 shell into the deferred queue, and
keeps each script's boot() firing at DOMContentLoaded exactly as it does today.
Behaviourally a no-op; it is what makes the ES-module flips safe.

WHY. `type="module"` defers execution to after HTML parse. Classic-`defer` and
module scripts share ONE "execute after parsing" list and run in DOCUMENT ORDER,
but a plain classic script runs DURING parse — ahead of all of them. So the
moment capabilities.js becomes a module while app.js is still plain, app.js runs
FIRST, and its 11 top-level `window.feedBack.on(...)` calls (app.js:6245-6722)
hit a bare `{}` — `_ensureFeedBackEventBus()` (capabilities.js:33), which
attaches .on/.emit/.off, would not have run yet. TypeError, app.js dies
mid-parse. Deferring everything now keeps document order == execution order
through the rest of the migration.

THE CATCH (Codex preflight caught this — a real ordering change). 22 scripts
guard their boot with `if (document.readyState === 'loading')`. A deferred
script runs at readyState 'interactive', so that test is FALSE and the else-branch
fires boot() immediately, at the script's position in document order — instead of
at DOMContentLoaded, after every script has evaluated.

That matters far more than one call site: a scan of the shell's scripts found
**43 forward references** where a script's boot() reads a global that a LATER
script defines (shell.js -> profile.js's window.v3Onboarding, songs.js ->
settings.js's window._confirmDialog, badges.js -> songs.js's
window.displayTuningName, ...). Every one of them resolves today only because
all boots happen at DOMContentLoaded. So the guards now treat 'interactive' as
not-ready (`!== 'complete'`), restoring that exactly.

Codex's specific finding (first-run onboarding silently skipped) did NOT
reproduce — shell.js's boot() awaits /api/profile, and that yield lets the
remaining deferred scripts run first. But the race it described is real, the
guard is silent when it fails (`&& window.v3Onboarding`), and the other 42
forward refs have no such await protecting them. Fixed at the root rather than
at the one site.

VERIFIED. A/B against origin/main on a fresh profile, 13 probes (onboarding
overlay, v3Onboarding/v3Songs/v3Profile/fbNotify/v3Badges/uiPrompt/showScreen,
bus, capabilities.version, createHighway, plugin scripts, mounted screens):
IDENTICAL, zero console/page errors on both. pytest 2396, node 1028/1028,
ESLint 0 errors, Codex 0.

New guard: test_every_external_script_defers_so_document_order_is_execution_order
fails if any external tag is plain classic — verified to fail on a single
reverted tag, so it actually bites.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:57:04 +02:00

587 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* fee[dB]ack v0.3.0 — Plugins page (#v3-plugins), "Pedalboard" layout.
*
* Plugins are grouped by category onto guitar-pedalboard surfaces; each plugin
* renders as a stompbox pedal (thumbnail + name + short description). Pedals are
* free-form draggable within their board (positions persist in localStorage),
* and decorative patch cables (pedal-cables.js) sag/swing between them. Clicking
* (not dragging) a pedal opens that plugin's own SCREEN (its page); plugins with
* no screen fall back to their settings panel. Data comes from the enriched /api/plugins
* (now carrying description/category/icon — see plugins/__init__.py::_nav_entry).
* The bundled Capability Inspector still owns the live capability graph; we
* surface a deep-link to it rather than rebuilding it.
*/
(function () {
'use strict';
var sm = window.feedBack;
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]; }); };
var LS_KEY = 'v3.pedalboard.layout';
var DEFAULT_THUMB = '/static/v3/pedal-default.svg';
// Pool of pedal "skin" photos (static/v3/pedals/). Each plugin is assigned
// one at random on first sight and keeps it across sessions (persisted in
// localStorage). Add more by dropping files here and listing them.
var FRAMES_KEY = 'v3.pedalboard.frames';
var PEDAL_FRAMES = [
'pedal-001.png', 'pedal-002.png', 'pedal-003.png', 'pedal-004.png', 'pedal-005.png',
'pedal-006.png', 'pedal-007.png', 'pedal-008.png', 'pedal-009.png', 'pedal-010.png',
'pedal-011.png', 'pedal-012.png', 'pedal-013.png', 'pedal-014.png', 'pedal-015.png',
'pedal-016.png', 'pedal-017.png', 'pedal-018.png', 'pedal-019.png',
];
// Bump when the skin images change on disk — busts the browser cache so the
// new artwork shows without a manual hard-reload (filenames are reused).
var FRAMES_VERSION = 8;
// Recursively re-home parsed JSON onto NULL-prototype objects, so manifest-
// controlled keys (category / plugin id) like '__proto__' / 'toString' /
// 'constructor' — at ANY nesting level — resolve to undefined instead of an
// inherited member (the layout map nests category → id → {x,y}).
function _nullProto(o) {
if (!o || typeof o !== 'object') return o;
if (Array.isArray(o)) return o.map(_nullProto);
var out = Object.create(null);
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) out[k] = _nullProto(o[k]);
return out;
}
function _loadMap(key) {
try {
var r = window.localStorage.getItem(key);
var o = r ? JSON.parse(r) : null;
return (o && typeof o === 'object' && !Array.isArray(o)) ? _nullProto(o) : Object.create(null);
} catch (e) { return Object.create(null); }
}
function loadFrames() { return _loadMap(FRAMES_KEY); }
function saveFrames(o) { try { window.localStorage.setItem(FRAMES_KEY, JSON.stringify(o)); } catch (e) { /* */ } }
function pickFrame() { return PEDAL_FRAMES.length ? PEDAL_FRAMES[Math.floor(Math.random() * PEDAL_FRAMES.length)] : null; }
// Return the persisted skin for a plugin, assigning (and recording into
// `frames`) a fresh random one if absent or no longer in the pool.
function frameFor(id, frames) {
var f = frames[id];
if (!f || PEDAL_FRAMES.indexOf(f) === -1) { f = pickFrame(); if (f) frames[id] = f; }
return f;
}
function frameUrl(f) { return f ? '/static/v3/pedals/' + f + '?v=' + FRAMES_VERSION : '/static/v3/pedal-frame.svg'; }
// Persisted collapsed state per board (category → true when collapsed).
var COLLAPSE_KEY = 'v3.pedalboard.collapsed';
function loadCollapsed() { return _loadMap(COLLAPSE_KEY); }
function saveCollapsed(o) { try { window.localStorage.setItem(COLLAPSE_KEY, JSON.stringify(o)); } catch (e) { /* */ } }
// Board order + human labels. Only non-empty boards render.
var BOARD_ORDER = ['audio', 'creation', 'practice', 'game', 'tools', 'other'];
// Null-prototype lookup maps: keys are manifest-controlled (category / plugin
// id), so `BOARD_LABEL[cat]` / `CURATED[id]` must not resolve inherited members
// for keys like '__proto__' / 'toString' / 'constructor'.
var BOARD_LABEL = Object.assign(Object.create(null), {
audio: 'Audio', creation: 'Creation', practice: 'Practice',
game: 'Games', tools: 'Tools', other: 'Other',
});
// Curated plugin-id → category map. Keyed on loader ids (which differ from
// repo names, e.g. midi_amp / rig_builder / note_detect). A manifest
// `category` overrides this; unknown ids fall through to deriveFromType then
// 'other'. Tune freely — it only affects which board a pedal sits on.
var CURATED = Object.assign(Object.create(null), {
// audio — tone / amp / stems
nam_tone: 'audio', rig_builder: 'audio', nam_rig_builder: 'audio',
stems: 'audio', stem_mixer: 'audio', midi_amp: 'audio', midi: 'audio',
tones: 'audio', note_detect: 'audio', notedetect: 'audio', backingtrack: 'audio',
tuner: 'audio',
// creation — highways / visualizers / authoring
highway_3d: 'creation', drum_highway_3d: 'creation', drums: 'creation',
piano: 'creation', jumpingtab: 'creation', invert_highway: 'creation',
splitscreen: 'creation', studio: 'creation', multiplayer: 'creation',
// practice — drills / theory / reference
metronome: 'practice', practice_journal: 'practice', practice: 'practice',
section_map: 'practice', sectionmap: 'practice', stepmode: 'practice',
guitar_theory: 'practice', fretboard: 'practice', tabview: 'practice',
lyrics_karaoke: 'practice', chordgem: 'practice', player_guide: 'practice',
the_daily: 'practice', tutorials: 'practice', virtuoso: 'practice',
// games
flappy_bend: 'game', minigames: 'game', chord_sprint: 'game',
// tools — import / manage / utility
editor: 'tools', tabimport: 'tools', sloppak_converter: 'tools',
profileimport: 'tools', find_more: 'tools', themes: 'tools',
update_manager: 'tools', song_preview: 'tools', setlist: 'tools',
transpose_chords: 'tools', discextract: 'tools', rs1extract: 'tools',
loosefolder: 'tools', cf: 'tools',
});
function deriveFromType(p) {
if (p && p.type === 'visualization') return 'creation';
return null;
}
function categoryOf(p) {
// A manifest `category` is authoritative (an unknown value just becomes
// its own board, rendered after the known ones). Otherwise fall back to
// the curated id map, then a type-derived guess, then 'other'.
if (p && typeof p.category === 'string' && p.category.trim()) return p.category.trim().toLowerCase();
return (p && CURATED[p.id]) || deriveFromType(p) || 'other';
}
function thumbUrl(p) {
if (p && typeof p.icon === 'string' && p.icon) {
var rel = p.icon.replace(/^assets\//, '');
return '/api/plugins/' + encodeURIComponent(p.id) + '/assets/' + encodeURI(rel);
}
return DEFAULT_THUMB;
}
function openable(p) {
// A plugin's screen is openable only if a #plugin-<id> screen exists
// (declared via nav/has_screen, or already injected by a script plugin).
return !!(p.nav || p.has_screen || (p.has_script && document.getElementById('plugin-' + p.id)));
}
// Decide what a pedal click should open. A plugin's own screen (its full
// "page") is the primary surface, so clicking the pedal opens it when one
// exists — matching the stompbox metaphor (step on the pedal → see the
// pedal). Plugins with no screen fall back to their settings panel; the
// settings of a screen+settings plugin (e.g. audio_engine) stay reachable
// from the main Settings screen. Pure — unit-tested.
function settingsTarget(p) {
if (openable(p)) return { kind: 'screen', id: p.id };
if (p && p.has_settings) return { kind: 'settings', id: p.id };
return { kind: 'none', id: p && p.id };
}
// ---- layout persistence (pure-ish, tested) ----------------------------
function loadLayout() { return _loadMap(LS_KEY); }
function saveLayout(obj) {
try { window.localStorage.setItem(LS_KEY, JSON.stringify(obj)); } catch (e) { /* quota / private mode */ }
}
function clampToBoard(pos, boardW, pedalW) {
var maxX = Math.max(0, boardW - pedalW);
var x = Math.min(Math.max(0, pos.x || 0), maxX);
var y = Math.max(0, pos.y || 0);
return { x: x, y: y };
}
// The column count adapts to the board width so pedals stay near TARGET_W
// (more columns on a wide window, fewer when narrow); their pixel width is
// then computed to fill the row evenly. GAP_X must exceed 2×PLUG_BOOT
// (pedal-cables.js) so facing plugs leave room for cable. Height = width /
// PEDAL_ASPECT.
var TARGET_W = 220, PEDAL_ASPECT = 0.6, PAD = 24, GAP_X = 96, GAP_Y = 40;
function pedalDims(boardW) {
var cols = Math.max(1, Math.round((boardW - PAD * 2 + GAP_X) / (TARGET_W + GAP_X)));
var w = Math.max(120, Math.floor((boardW - PAD * 2 - (cols - 1) * GAP_X) / cols));
return { w: w, h: Math.round(w / PEDAL_ASPECT), cols: cols };
}
// Default flow slot for the Nth pedal on a board of width boardW.
// GAP_X must exceed 2×PLUG_BOOT (pedal-cables.js) so two facing side-jack
// plugs leave room for a visible cable between adjacent pedals. PEDAL_W/H
// must match .v3-pedal's rendered size in v3.css.
function defaultSlot(index, boardW) {
var d = pedalDims(boardW);
var col = index % d.cols, row = Math.floor(index / d.cols);
return { x: PAD + col * (d.w + GAP_X), y: PAD + row * (d.h + GAP_Y) };
}
// ---- rendering --------------------------------------------------------
function statusPill(p) {
var s = p.status || 'ready';
if (s === 'failed') return '<span class="v3-pedal-pill v3-pill-bad">Failed</span>';
if (s === 'installing') return '<span class="v3-pedal-pill v3-pill-wait">Installing</span>';
return '';
}
function pedalHtml(p) {
var failed = p.status === 'failed';
var desc = p.description || '';
// The skin photo (knobs, footswitch, jacks all baked in) is the card
// background; the plugin's identity overlays in a label panel.
// Skin goes on a CSS var so it lives on the ::before layer (which dims
// when disabled) while the glow ring stays full-colour.
var style = p._frameUrl ? ' style="--skin:url(\'' + esc(p._frameUrl) + '\')"' : '';
var off = p.enabled === false;
// Description shows only as a hover tooltip on the pedal, not on the face.
var tip = (p.name || p.id) + (desc ? ' — ' + desc : '');
// The action verb tracks what a click actually opens (screen-first,
// then settings) so the a11y label never promises the wrong surface.
var kind = settingsTarget(p).kind;
var action = kind === 'screen' ? ' — open' : (kind === 'settings' ? ' — open settings' : '');
return '<div class="v3-pedal' + (off ? ' v3-pedal-off' : '') + '" data-id="' + esc(p.id) + '" tabindex="0" role="button" ' +
'aria-label="' + esc((p.name || p.id) + action) + '" title="' + esc(tip) + '"' + style + '>' +
'<span class="v3-pedal-glow" aria-hidden="true"></span>' +
(p.bundled ? '<span class="v3-pedal-bundled" title="Ships with FeedBack core">core</span>' : '') +
'<span class="v3-pedal-offbadge" aria-hidden="true">off</span>' +
'<div class="v3-pedal-label">' +
'<img class="v3-pedal-thumb" alt="" loading="lazy" src="' + esc(thumbUrl(p)) + '">' +
'<div class="v3-pedal-name">' + esc(p.name || p.id) + '</div>' +
statusPill(p) +
(failed && p.error ? '<div class="v3-pedal-err">' + esc(p.error) + '</div>' : '') +
'</div>' +
// Footswitch hotspot — clicking toggles the plugin on/off (does NOT
// open settings). Positioned over the photo's stomp button.
'<button class="v3-pedal-foot" data-foot="' + esc(p.id) + '" ' +
'title="Turn this plugin on/off" aria-label="Toggle ' + esc(p.name || p.id) + '"></button>' +
'</div>';
}
function boardHtml(cat, list, collapsed) {
var ec = esc(cat); // category can come from a plugin manifest → escape it
return '<section class="v3-board-section' + (collapsed ? ' collapsed' : '') + '" data-category="' + ec + '">' +
'<button class="v3-board-title" data-toggle="' + ec + '" aria-expanded="' + (!collapsed) + '">' +
'<svg class="v3-board-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">' +
'<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg>' +
'<span>' + esc(BOARD_LABEL[cat] || cat) + '</span>' +
'<span class="v3-board-count">' + list.length + '</span></button>' +
'<div class="v3-pedalboard" data-category="' + ec + '">' +
list.map(pedalHtml).join('') +
'</div></section>';
}
function toast(msg) {
var host = document.getElementById('v3-plugins-toast');
if (!host) {
host = document.createElement('div');
host.id = 'v3-plugins-toast';
host.className = 'fixed bottom-6 left-1/2 -translate-x-1/2 z-[120] bg-fb-card text-fb-text ' +
'border border-fb-border/60 rounded-lg px-4 py-2 text-sm shadow-xl';
document.body.appendChild(host);
}
host.textContent = msg;
host.classList.remove('hidden');
clearTimeout(host._t);
host._t = setTimeout(function () { host.classList.add('hidden'); }, 3000);
}
// Open a plugin's settings panel: switch to the legacy Settings screen (it
// mounts each plugin's settings.html as a <details data-plugin-id> under
// #plugin-settings — app.js), then expand + scroll to it. Retries a few
// frames in case the details haven't hydrated yet.
function openSettingsPanel(id, tries) {
var t = tries == null ? 12 : tries;
// Match by iterating (not an interpolated selector) so an unusual plugin
// id with quotes/brackets can't break querySelector or inject a selector.
var d = null, all = document.querySelectorAll('#plugin-settings details[data-plugin-id]');
for (var i = 0; i < all.length; i++) { if (all[i].getAttribute('data-plugin-id') === id) { d = all[i]; break; } }
if (d) {
d.open = true;
try { d.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) { d.scrollIntoView(); }
return;
}
if (t > 0) requestAnimationFrame(function () { openSettingsPanel(id, t - 1); });
}
function openPluginSettings(p) {
var tgt = settingsTarget(p);
if (tgt.kind === 'settings') {
if (window.showScreen) window.showScreen('settings');
openSettingsPanel(tgt.id);
} else if (tgt.kind === 'screen') {
// Only navigate if the screen is actually mounted — an installing/
// failed plugin has manifest has_screen but no #plugin-<id> div yet.
if (window.showScreen && document.getElementById('plugin-' + tgt.id)) {
window.showScreen('plugin-' + tgt.id);
} else if (p && p.has_settings) {
// Screen declared but not mounted yet — fall back to the
// settings panel rather than stranding the user on a toast.
if (window.showScreen) window.showScreen('settings');
openSettingsPanel(p.id);
} else {
toast('This plugin is still loading — try again in a moment.');
}
} else {
toast('This plugin has no settings or screen to open.');
}
}
// Footswitch enable/disable with "last intent wins": clicks set pedal._want
// (the desired enabled state) + an optimistic class; one request is in flight
// at a time, and if the user's intent changed while it ran we resend, so the
// final state always matches the latest click (no stale-response overwrite).
function flushFoot(pedal, id) {
pedal._pending = true;
var sending = pedal._want;
fetch('/api/plugins/' + encodeURIComponent(id) + '/enabled', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: sending }),
}).then(function (r) { if (!r.ok) throw new Error('http ' + r.status); return r.json(); })
.then(function (res) {
if (pedal._plugin) pedal._plugin.enabled = res.enabled; // last known server state
if (pedal._want !== res.enabled) { flushFoot(pedal, id); return; } // intent changed → resend
pedal.classList.toggle('v3-pedal-off', res.enabled === false);
pedal._pending = false;
})
.catch(function () {
pedal._pending = false;
// revert optimistic UI to the last known-good server state
var known = pedal._plugin ? pedal._plugin.enabled !== false : true;
pedal._want = known;
pedal.classList.toggle('v3-pedal-off', !known);
toast('Could not toggle this plugin.');
});
}
// Position every pedal on a board from saved layout (clamped) or default
// flow, and grow the board to fit. Mutates the DOM.
function layoutBoard(boardEl, cat, layout) {
var boardW = boardEl.clientWidth || boardEl.offsetWidth || 800;
var d = pedalDims(boardW);
var pedals = boardEl.querySelectorAll('.v3-pedal');
var saved = layout && layout[cat];
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) saved = Object.create(null);
var maxBottom = 0;
for (var i = 0; i < pedals.length; i++) {
var el = pedals[i];
var id = el.getAttribute('data-id');
el.style.width = d.w + 'px';
el.style.height = d.h + 'px';
var pos = saved[id] ? clampToBoard(saved[id], boardW, d.w) : defaultSlot(i, boardW);
el.style.left = pos.x + 'px';
el.style.top = pos.y + 'px';
maxBottom = Math.max(maxBottom, pos.y + d.h);
}
boardEl.style.minHeight = (maxBottom + PAD) + 'px';
}
var DRAG_THRESHOLD = 5;
function wirePedalDrag(boardEl, cat) {
var boardW = function () { return boardEl.clientWidth || 800; };
boardEl.querySelectorAll('.v3-pedal').forEach(function (el) {
var startX = 0, startY = 0, origX = 0, origY = 0, dragging = false;
el.addEventListener('pointerdown', function (ev) {
if (ev.button != null && ev.button !== 0) return;
startX = ev.clientX; startY = ev.clientY;
origX = parseFloat(el.style.left) || 0;
origY = parseFloat(el.style.top) || 0;
dragging = false;
// Clear any stale suppression flag from a prior drag that wasn't
// followed by a click (some browsers skip click after capture).
el._dragged = false;
try { el.setPointerCapture(ev.pointerId); } catch (e) { /* */ }
});
el.addEventListener('pointermove', function (ev) {
if (el.hasPointerCapture && !el.hasPointerCapture(ev.pointerId)) return;
var dx = ev.clientX - startX, dy = ev.clientY - startY;
if (!dragging && Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return;
if (!dragging) {
dragging = true;
el.classList.add('v3-pedal-dragging');
if (window.v3PedalCables) window.v3PedalCables.setDragging(true);
}
var bw = boardW(); var d = pedalDims(bw);
var pos = clampToBoard({ x: origX + dx, y: origY + dy }, bw, d.w);
el.style.left = pos.x + 'px';
el.style.top = pos.y + 'px';
// Grow board to fit if dragged below current extent.
var need = pos.y + d.h + PAD;
if (need > (parseFloat(boardEl.style.minHeight) || 0)) boardEl.style.minHeight = need + 'px';
if (window.v3PedalCables) window.v3PedalCables.refresh();
});
function endDrag(ev) {
if (el.hasPointerCapture && ev && ev.pointerId != null) {
try { el.releasePointerCapture(ev.pointerId); } catch (e) { /* */ }
}
if (dragging) {
el.classList.remove('v3-pedal-dragging');
el._dragged = true; // suppress the click that follows
var layout = loadLayout();
var bucket = layout[cat];
if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) {
bucket = layout[cat] = Object.create(null);
}
bucket[el.getAttribute('data-id')] = {
x: parseFloat(el.style.left) || 0,
y: parseFloat(el.style.top) || 0,
};
saveLayout(layout);
if (window.v3PedalCables) window.v3PedalCables.setDragging(false);
}
dragging = false;
}
el.addEventListener('pointerup', endDrag);
el.addEventListener('pointercancel', endDrag);
// Click / keyboard activate → open settings, unless a drag just ran.
el.addEventListener('click', function () {
if (el._dragged) { el._dragged = false; return; }
if (el._plugin) openPluginSettings(el._plugin);
});
el.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
if (el._plugin) openPluginSettings(el._plugin);
}
});
});
}
async function render() {
var root = document.getElementById('v3-plugins');
if (!root) return;
var plugins = [];
try { var r = await fetch('/api/plugins'); if (r.ok) plugins = await r.json(); } catch (e) { /* */ }
if (!Array.isArray(plugins)) plugins = [];
var active = plugins.filter(function (p) { return (p.status || 'ready') === 'ready'; }).length;
var inspectorPresent = plugins.some(function (p) { return p.id === 'capability_inspector'; });
// Assign each plugin its persisted (or freshly-random) pedal skin.
var frames = loadFrames();
plugins.forEach(function (p) { p._frameUrl = frameUrl(frameFor(p.id, frames)); });
saveFrames(frames);
// Group by category. Null-prototype map so a manifest category like
// '__proto__' / 'toString' / 'constructor' resolves to undefined (not an
// inherited member) and can't crash the grouping.
var byCat = Object.create(null);
plugins.forEach(function (p) {
var c = categoryOf(p);
(byCat[c] = byCat[c] || []).push(p);
});
var cats = BOARD_ORDER.filter(function (c) { return byCat[c] && byCat[c].length; });
// Any unexpected category not in BOARD_ORDER → append after.
Object.keys(byCat).forEach(function (c) { if (cats.indexOf(c) === -1) cats.push(c); });
var collapsed = loadCollapsed();
var boardsHtml = cats.map(function (c) { return boardHtml(c, byCat[c], !!collapsed[c]); }).join('');
root.innerHTML =
'<div class="v3-pedalboards-wrap px-6 md:px-8 pb-10">' +
'<div class="flex items-center justify-between gap-3 mb-6 flex-wrap">' +
'<span class="text-lg font-medium text-fb-good">' + active + ' active</span>' +
'<div class="flex items-center gap-2">' +
'<button id="v3-pedal-reset" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-textDim hover:text-fb-text px-3 py-1.5 rounded-md" title="Reset pedal positions and re-roll skins">Reset</button>' +
(inspectorPresent
? '<button id="v3-open-inspector" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-text px-3 py-1.5 rounded-md">Capability Inspector →</button>'
: '') +
'</div></div>' +
(boardsHtml || '<p class="text-fb-textDim text-sm">No plugins installed.</p>') +
'</div>';
// Bind plugin objects to pedal elements + lay them out + wire drag.
var byId = Object.create(null);
plugins.forEach(function (p) { byId[p.id] = p; });
var layout = loadLayout();
root.querySelectorAll('.v3-pedalboard').forEach(function (boardEl) {
var cat = boardEl.getAttribute('data-category');
boardEl.querySelectorAll('.v3-pedal').forEach(function (el) {
el._plugin = byId[el.getAttribute('data-id')];
});
layoutBoard(boardEl, cat, layout);
wirePedalDrag(boardEl, cat);
});
// Thumbnail fallback on load error.
root.querySelectorAll('.v3-pedal-thumb').forEach(function (img) {
img.addEventListener('error', function () {
if (img.src.indexOf(DEFAULT_THUMB) === -1) img.src = DEFAULT_THUMB;
}, { once: true });
});
// Cables overlay (decorative). Re-attach since render() rebuilt the DOM.
// Only run the loop if the plugins screen is actually visible — render()
// also fires at boot while #v3-plugins is hidden, and screen:changed in
// pedal-cables.js handles activation on later navigation.
if (window.v3PedalCables) {
window.v3PedalCables.attach(root);
window.v3PedalCables.markActive(root.classList.contains('active'));
}
// Collapsible boards: toggle on title click, persist per category.
root.querySelectorAll('[data-toggle]').forEach(function (btn) {
btn.addEventListener('click', function () {
var cat = btn.getAttribute('data-toggle');
var sec = btn.closest('.v3-board-section');
if (!sec) return;
var nowCollapsed = !sec.classList.contains('collapsed');
sec.classList.toggle('collapsed', nowCollapsed);
btn.setAttribute('aria-expanded', String(!nowCollapsed));
var c = loadCollapsed();
if (nowCollapsed) c[cat] = true; else delete c[cat];
saveCollapsed(c);
// While collapsed the board is display:none, so its pedals were
// laid out against a 0-width board. Re-flow on expand now that it
// has a real width.
if (!nowCollapsed) {
var board = sec.querySelector('.v3-pedalboard');
if (board) layoutBoard(board, cat, loadLayout());
}
if (window.v3PedalCables) window.v3PedalCables.refresh();
});
});
// Footswitch → enable/disable the plugin (POST /api/plugins/<id>/enabled).
// stopPropagation so it neither starts a drag nor opens settings.
root.querySelectorAll('[data-foot]').forEach(function (btn) {
btn.addEventListener('pointerdown', function (ev) { ev.stopPropagation(); });
btn.addEventListener('click', function (ev) {
ev.stopPropagation();
var id = btn.getAttribute('data-foot');
var pedal = btn.closest('.v3-pedal');
if (!pedal) return;
var desired = pedal.classList.contains('v3-pedal-off'); // off → want enabled
pedal.classList.toggle('v3-pedal-off', !desired); // optimistic
pedal._want = desired;
// A request already running will reconcile to the new _want when
// it returns; otherwise start one.
if (!pedal._pending) flushFoot(pedal, id);
});
});
var reset = root.querySelector('#v3-pedal-reset');
if (reset) reset.addEventListener('click', function () {
// Clear saved positions AND skin assignments so both re-roll.
try { window.localStorage.removeItem(LS_KEY); window.localStorage.removeItem(FRAMES_KEY); } catch (e) { /* */ }
render();
});
var insp = root.querySelector('#v3-open-inspector');
if (insp) insp.addEventListener('click', function () {
if (window.showScreen && document.getElementById('plugin-capability_inspector')) {
window.showScreen('plugin-capability_inspector');
}
});
}
window.v3PluginsPage = {
render: render,
// Pure helpers exposed for unit tests.
_test: {
categoryOf: categoryOf, thumbUrl: thumbUrl, settingsTarget: settingsTarget,
clampToBoard: clampToBoard, defaultSlot: defaultSlot,
loadLayout: loadLayout, saveLayout: saveLayout,
frameFor: frameFor, pickFrame: pickFrame, frameUrl: frameUrl,
loadCollapsed: loadCollapsed, saveCollapsed: saveCollapsed, COLLAPSE_KEY: COLLAPSE_KEY,
DRAG_THRESHOLD: DRAG_THRESHOLD, LS_KEY: LS_KEY, CURATED: CURATED,
BOARD_ORDER: BOARD_ORDER, PEDAL_FRAMES: PEDAL_FRAMES, FRAMES_KEY: FRAMES_KEY,
},
};
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', function (e) { if (e && e.detail && e.detail.id === 'v3-plugins') render(); });
}
// Re-flow the boards when the window resizes (responsive pedal width) — only
// while the Plugins screen is visible, throttled to one frame.
function relayoutAll() {
var root = document.getElementById('v3-plugins');
if (!root || !root.classList.contains('active')) return;
var layout = loadLayout();
root.querySelectorAll('.v3-pedalboard').forEach(function (boardEl) {
layoutBoard(boardEl, boardEl.getAttribute('data-category'), layout);
});
if (window.v3PedalCables) window.v3PedalCables.refresh();
}
var _resizePending = false;
window.addEventListener('resize', function () {
if (_resizePending) return;
_resizePending = true;
requestAnimationFrame(function () { _resizePending = false; relayoutAll(); });
}, { passive: true });
function boot() { render(); }
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();