feat(playground): EOC chain drawer UI with drag-and-drop + bypass toggles (meml-u6x)

Adds eoc-chain-ui.js with NISPS mode selector, draggable module rows, bypass
pill toggles, remove buttons, param count badges, add-module bar, and stub
moduleFactory. Wires into a-immersive.html dock + drawer-stack and a-app.js init.
This commit is contained in:
w1n5t0n 2026-04-03 17:42:59 +01:00
parent 67d2230ba8
commit 46e1e59aaf
4 changed files with 545 additions and 0 deletions

View file

@ -150,6 +150,12 @@
</svg> </svg>
<span class="dock-label">Engine</span> <span class="dock-label">Engine</span>
</button> </button>
<button class="dock-icon" data-drawer="eoc" title="Effects Chain" aria-label="Effects Chain">
<svg viewBox="0 0 16 16" width="20" height="20" fill="currentColor">
<path d="M4 8a4 4 0 0 1 4-4h1a1 1 0 0 0 0-2H8a6 6 0 0 0 0 12h1a1 1 0 0 0 0-2H8a4 4 0 0 1-4-4zm8 0a4 4 0 0 1-4 4H7a1 1 0 0 0 0 2h1a6 6 0 0 0 0-12H7a1 1 0 0 0 0 2h1a4 4 0 0 1 4 4z"/>
</svg>
<span class="dock-label">FX</span>
</button>
<button class="dock-icon" data-drawer="help" title="Help"> <button class="dock-icon" data-drawer="help" title="Help">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="6"/><path d="M6 6a2 2 0 0 1 4 0c0 1.5-2 1.5-2 3"/><circle cx="8" cy="12" r="0.5" fill="currentColor"/> <circle cx="8" cy="8" r="6"/><path d="M6 6a2 2 0 0 1 4 0c0 1.5-2 1.5-2 3"/><circle cx="8" cy="12" r="0.5" fill="currentColor"/>
@ -307,6 +313,17 @@
<div class="engine-params" id="engine-params"></div> <div class="engine-params" id="engine-params"></div>
</div> </div>
</div> </div>
<!-- EOC Effects Chain drawer -->
<div class="drawer hidden" id="drawer-eoc" data-drawer="eoc">
<div class="drawer-header">
<span>Effects Chain</span>
<button class="drawer-close" data-drawer="eoc">&times;</button>
</div>
<div class="drawer-body" id="eoc-drawer-body">
<!-- EOCChainUI renders here -->
</div>
</div>
</div> </div>
<!-- Help modal (unchanged) --> <!-- Help modal (unchanged) -->

View file

@ -2021,3 +2021,201 @@ html, body {
min-width: 20px; min-width: 20px;
text-align: center; text-align: center;
} }
/* ---- EOC Chain UI ---- */
.eoc-nisps-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.eoc-section-label {
font-size: 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 36px;
}
.eoc-nisps-toggle {
flex: 1;
}
.eoc-module-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.eoc-empty-hint {
font-size: 10px;
color: var(--text-dim);
text-align: center;
padding: 10px 0;
opacity: 0.6;
}
.eoc-module-row {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 6px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
cursor: default;
transition: background 0.15s;
}
.eoc-module-row:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
.eoc-module-row.dragging {
opacity: 0.4;
cursor: grabbing;
}
.eoc-drag-handle {
cursor: grab;
touch-action: none;
color: var(--text-dim);
font-size: 14px;
line-height: 1;
padding: 0 2px;
flex-shrink: 0;
user-select: none;
}
.eoc-drag-handle:active {
cursor: grabbing;
}
.eoc-module-name {
flex: 1;
font-size: 11px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.eoc-param-badge {
font-size: 9px;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 1px 6px;
white-space: nowrap;
flex-shrink: 0;
}
/* Bypass pill toggle */
.eoc-bypass-toggle {
position: relative;
display: inline-block;
width: 30px;
height: 16px;
flex-shrink: 0;
cursor: pointer;
}
.eoc-bypass-toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.eoc-bypass-slider {
position: absolute;
inset: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: background 0.2s, border-color 0.2s;
}
.eoc-bypass-slider::after {
content: '';
position: absolute;
left: 2px;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-dim);
transition: transform 0.2s, background 0.2s;
}
.eoc-bypass-toggle input:checked + .eoc-bypass-slider {
background: rgba(255, 106, 0, 0.25);
border-color: rgba(255, 106, 0, 0.4);
}
.eoc-bypass-toggle input:checked + .eoc-bypass-slider::after {
transform: translateY(-50%) translateX(14px);
background: var(--accent);
}
.eoc-remove-btn {
background: none;
border: 1px solid transparent;
color: var(--text-dim);
font-size: 15px;
line-height: 1;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
cursor: pointer;
padding: 0;
flex-shrink: 0;
transition: all 0.15s;
}
.eoc-remove-btn:hover {
color: var(--danger);
border-color: rgba(255, 68, 102, 0.3);
background: rgba(255, 68, 102, 0.08);
}
/* Drop indicator line between rows during drag */
.eoc-drop-indicator {
height: 2px;
border-radius: 1px;
background: var(--accent);
margin: 2px 0;
pointer-events: none;
opacity: 0.8;
}
/* Add module bar */
.eoc-add-bar {
display: flex;
flex-wrap: wrap;
gap: 5px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.eoc-add-hint {
font-size: 10px;
color: var(--text-dim);
opacity: 0.5;
padding: 4px 0;
}
.eoc-add-btn {
font-size: 10px;
padding: 3px 8px;
}

View file

@ -15,6 +15,8 @@ import { GamepadInput } from './ui/gamepad.js';
import { HandTracker } from './ui/hand-tracker.js'; import { HandTracker } from './ui/hand-tracker.js';
import { createDevPanel } from './ui/dev-panel.js'; import { createDevPanel } from './ui/dev-panel.js';
import { SYNTH_PRESETS, PRESET_TIERS } from './synth/presets.js'; import { SYNTH_PRESETS, PRESET_TIERS } from './synth/presets.js';
import { EOCChain } from './eoc/index.js';
import { EOCChainUI, moduleFactory } from './ui/eoc-chain-ui.js';
// ---- Constants ---- // ---- Constants ----
const N_JOY_INPUTS = 2; const N_JOY_INPUTS = 2;
@ -898,6 +900,13 @@ async function init() {
// Auto-save every 10 seconds // Auto-save every 10 seconds
setInterval(saveState, 10000); setInterval(saveState, 10000);
// EOC Effects Chain — initialise chain and wire drawer UI
const eocChain = new EOCChain();
const eocDrawerBody = document.getElementById('eoc-drawer-body');
if (eocDrawerBody) {
EOCChainUI.init(eocChain, eocDrawerBody);
}
// Debug probe — exposed on window when ?debug=1 is in the URL. // Debug probe — exposed on window when ?debug=1 is in the URL.
// Used by Playwright e2e tests. Zero footprint in production. // Used by Playwright e2e tests. Zero footprint in production.
if (new URLSearchParams(window.location.search).has('debug')) { if (new URLSearchParams(window.location.search).has('debug')) {
@ -946,6 +955,7 @@ async function init() {
evalLoss: () => iml.evalLoss(), evalLoss: () => iml.evalLoss(),
inferBatch: (points) => iml.inferBatch(points), inferBatch: (points) => iml.inferBatch(points),
getLayerStats:() => iml.getLayerStats(), getLayerStats:() => iml.getLayerStats(),
eocChain,
}; };
} }

View file

@ -0,0 +1,320 @@
// eoc-chain-ui.js — Drawer UI for the End-of-Chain effects rack.
//
// Usage:
// import { EOCChainUI, moduleFactory } from './ui/eoc-chain-ui.js';
// EOCChainUI.init(chain, document.getElementById('eoc-drawer-body'));
//
// The drawer renders:
// 1. NISPS mode selector (Bypass / Shared / Linked / Independent)
// 2. Ordered, draggable list of active modules (bypass toggle, remove, param count)
// 3. Add-module bar (one button per available but absent module)
//
// Reacts to 'eoc:change' events on window to stay in sync with external mutations.
import { EOCModule } from '../eoc/eoc-module.js';
// ---------------------------------------------------------------------------
// Stub module factory
// ---------------------------------------------------------------------------
const STUB_DEFS = {
saturation: { displayName: 'Saturation', paramMeta: [] },
eq: { displayName: 'EQ', paramMeta: [] },
compressor: { displayName: 'Compressor', paramMeta: [] },
reverb: { displayName: 'Reverb', paramMeta: [] },
delay: { displayName: 'Delay', paramMeta: [] },
master: { displayName: 'Master Bus', paramMeta: [] },
};
// Canonical slot order (matches EOCChain's DEFAULT_ORDER)
const MODULE_ORDER = ['saturation', 'eq', 'compressor', 'reverb', 'delay', 'master'];
/**
* Build a minimal EOCModule stub for the given id.
* Exported so a-app.js can replace it with real implementations later.
*
* @param {string} id one of saturation | eq | compressor | reverb | delay | master
* @returns {EOCModule}
*/
export function moduleFactory(id) {
const def = STUB_DEFS[id];
if (!def) throw new Error(`moduleFactory: unknown module id '${id}'`);
// Build stub class inline, scoped to the specific id/def
class StubModule extends EOCModule {
get id() { return id; }
get displayName() { return def.displayName; }
get paramMeta() { return def.paramMeta; }
async init(audioCtx) {
// Minimal super call to satisfy the base class contract without
// requiring a real AudioContext in stub/test scenarios
if (audioCtx && typeof audioCtx.createGain === 'function') {
await super.init(audioCtx);
this._finishInit();
} else {
this._initialized = true;
}
}
}
return new StubModule();
}
// ---------------------------------------------------------------------------
// EOCChainUI
// ---------------------------------------------------------------------------
export const EOCChainUI = {
/**
* @type {import('../eoc/eoc-chain.js').EOCChain|null}
*/
_chain: null,
/** @type {HTMLElement|null} */
_container: null,
/** Dragged module id during HTML5 DnD */
_dragId: null,
/**
* Initialise the UI.
*
* @param {import('../eoc/eoc-chain.js').EOCChain} chain
* @param {HTMLElement} containerEl
*/
init(chain, containerEl) {
this._chain = chain;
this._container = containerEl;
// Listen for external chain changes
window.addEventListener('eoc:change', () => this._render());
this._render();
},
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
_render() {
if (!this._chain || !this._container) return;
const c = this._container;
c.innerHTML = '';
c.appendChild(this._buildNispsBar());
c.appendChild(this._buildModuleList());
c.appendChild(this._buildAddBar());
},
// NISPS mode selector bar
_buildNispsBar() {
const modes = [
{ id: 'bypass', label: 'Bypass' },
{ id: 'shared', label: 'Shared' },
{ id: 'linked', label: 'Linked' },
{ id: 'independent', label: 'Indep' },
];
const bar = document.createElement('div');
bar.className = 'eoc-nisps-bar';
const label = document.createElement('span');
label.className = 'eoc-section-label';
label.textContent = 'NISPS';
bar.appendChild(label);
const toggle = document.createElement('div');
toggle.className = 'pill-toggle pill-toggle-sm eoc-nisps-toggle';
modes.forEach(({ id, label: lbl }) => {
const btn = document.createElement('button');
btn.className = 'pill-opt' + (this._chain.nispsMode === id ? ' active' : '');
btn.textContent = lbl;
btn.addEventListener('click', () => {
this._chain.nispsMode = id;
// re-render is triggered by eoc:change event from chain
});
toggle.appendChild(btn);
});
bar.appendChild(toggle);
return bar;
},
// Ordered list of module rows
_buildModuleList() {
const list = document.createElement('div');
list.className = 'eoc-module-list';
const modules = this._chain.modules;
if (modules.length === 0) {
const empty = document.createElement('div');
empty.className = 'eoc-empty-hint';
empty.textContent = 'No modules — add one below';
list.appendChild(empty);
return list;
}
modules.forEach((mod, idx) => {
const row = this._buildModuleRow(mod, idx, modules.length, list);
list.appendChild(row);
});
return list;
},
_buildModuleRow(mod, idx, totalCount, listEl) {
const row = document.createElement('div');
row.className = 'eoc-module-row';
row.dataset.id = mod.id;
row.draggable = true;
// --- Drag handle ---
const handle = document.createElement('span');
handle.className = 'eoc-drag-handle';
handle.title = 'Drag to reorder';
handle.textContent = '⠿';
row.appendChild(handle);
// --- Module name ---
const name = document.createElement('span');
name.className = 'eoc-module-name';
name.textContent = mod.displayName;
row.appendChild(name);
// --- Param count badge ---
const badge = document.createElement('span');
badge.className = 'eoc-param-badge';
badge.textContent = mod.paramCount === 1
? '1 param'
: `${mod.paramCount} params`;
row.appendChild(badge);
// --- Bypass toggle ---
const toggleLabel = document.createElement('label');
toggleLabel.className = 'eoc-bypass-toggle';
toggleLabel.title = mod.enabled ? 'Active — click to bypass' : 'Bypassed — click to enable';
const toggleInput = document.createElement('input');
toggleInput.type = 'checkbox';
toggleInput.checked = mod.enabled;
toggleInput.addEventListener('change', () => {
mod.enabled = toggleInput.checked;
// _applyBypass wires audio graph internally; dispatch change so UI refreshes
window.dispatchEvent(new CustomEvent('eoc:change', {
detail: { reason: 'bypass-toggled', chain: this._chain }
}));
});
const toggleSlider = document.createElement('span');
toggleSlider.className = 'eoc-bypass-slider';
toggleLabel.appendChild(toggleInput);
toggleLabel.appendChild(toggleSlider);
row.appendChild(toggleLabel);
// --- Remove button ---
const removeBtn = document.createElement('button');
removeBtn.className = 'eoc-remove-btn';
removeBtn.title = `Remove ${mod.displayName}`;
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => {
this._chain.removeModule(mod.id);
});
row.appendChild(removeBtn);
// --- HTML5 Drag-and-drop ---
row.addEventListener('dragstart', (e) => {
this._dragId = mod.id;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', mod.id);
});
row.addEventListener('dragend', () => {
this._dragId = null;
row.classList.remove('dragging');
// Clean up all drop indicators
listEl.querySelectorAll('.eoc-drop-indicator').forEach(el => el.remove());
});
row.addEventListener('dragover', (e) => {
if (!this._dragId || this._dragId === mod.id) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Show drop-indicator above or below based on cursor position
const rect = row.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const insertBefore = e.clientY < midY;
// Remove all existing indicators then place one
listEl.querySelectorAll('.eoc-drop-indicator').forEach(el => el.remove());
const indicator = document.createElement('div');
indicator.className = 'eoc-drop-indicator';
if (insertBefore) {
listEl.insertBefore(indicator, row);
} else {
row.insertAdjacentElement('afterend', indicator);
}
});
row.addEventListener('drop', (e) => {
if (!this._dragId || this._dragId === mod.id) return;
e.preventDefault();
// Determine new position
const rect = row.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const insertBefore = e.clientY < midY;
const allRows = [...listEl.querySelectorAll('.eoc-module-row')];
const targetIdx = allRows.findIndex(r => r.dataset.id === mod.id);
// After moving the dragged element out, the target index needs adjustment
const dragIdx = allRows.findIndex(r => r.dataset.id === this._dragId);
let newPos = insertBefore ? targetIdx : targetIdx + 1;
if (dragIdx < targetIdx) newPos -= 1; // account for removal shifting indices
this._chain.moveModule(this._dragId, Math.max(0, newPos));
// eoc:change will re-render
});
return row;
},
// Add-module bar
_buildAddBar() {
const bar = document.createElement('div');
bar.className = 'eoc-add-bar';
const activeIds = new Set(this._chain.modules.map(m => m.id));
const available = MODULE_ORDER.filter(id => !activeIds.has(id));
if (available.length === 0) {
const hint = document.createElement('span');
hint.className = 'eoc-add-hint';
hint.textContent = 'All modules added';
bar.appendChild(hint);
return bar;
}
available.forEach(id => {
const def = STUB_DEFS[id];
if (!def) return;
const btn = document.createElement('button');
btn.className = 'action-btn eoc-add-btn';
btn.textContent = `+ ${def.displayName}`;
btn.title = `Add ${def.displayName} to chain`;
btn.addEventListener('click', () => {
this._chain.addModule(moduleFactory(id));
// eoc:change re-renders
});
bar.appendChild(btn);
});
return bar;
},
};