/** * ShapeSeq Chain Builder UI — vertical stack of primitive cards * * Pedalboard-style chain editor: expandable cards with param sliders, * drag-to-reorder, delete, add-primitive palette, generator combine toggle. * Mobile-first, dark glass theme matching the immersive playground. * * All styles are inline (no external CSS dependency). * * @module shapeseq/chain-ui */ import { PRIMITIVE_REGISTRY } from './primitives.js'; // ── Category colors ────────────────────────────────────────────────── const CATEGORY_COLORS = { generator: '#ff6a00', processor: '#00ccff', timing: '#ffcc00', converter: '#88ff00', }; // ── Inject styles once ─────────────────────────────────────────────── let stylesInjected = false; function injectStyles() { if (stylesInjected) return; stylesInjected = true; const css = ` /* ── Chain Builder container ─────────────────────────────────── */ .chain-builder { --cb-glass-bg: rgba(13, 13, 13, 0.72); --cb-glass-border: rgba(255, 255, 255, 0.08); --cb-glass-blur: 16px; --cb-text: #e0e0e0; --cb-text-dim: #888; --cb-radius: 12px; --cb-radius-sm: 8px; --cb-accent: #ff6a00; display: flex; flex-direction: column; gap: 6px; padding: 8px; overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 13px; color: var(--cb-text); } /* ── Primitive card ──────────────────────────────────────────── */ .chain-card { position: relative; background: var(--cb-glass-bg); backdrop-filter: blur(var(--cb-glass-blur)); -webkit-backdrop-filter: blur(var(--cb-glass-blur)); border: 1px solid var(--cb-glass-border); border-radius: var(--cb-radius); overflow: hidden; transition: box-shadow 0.2s, transform 0.15s; } .chain-card.dragging { opacity: 0.7; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); transform: scale(1.03); z-index: 100; } .chain-card-left-border { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; border-radius: var(--cb-radius) 0 0 var(--cb-radius); } /* ── Card header ─────────────────────────────────────────────── */ .chain-card-header { display: flex; align-items: center; gap: 8px; padding: 10px 10px 10px 14px; cursor: pointer; user-select: none; -webkit-user-select: none; } .chain-card-drag { display: flex; flex-direction: column; gap: 2px; cursor: grab; touch-action: none; padding: 4px 2px; opacity: 0.4; transition: opacity 0.15s; } .chain-card-drag:hover, .chain-card-drag:active { opacity: 0.8; } .chain-card-drag span { display: block; width: 14px; height: 2px; background: var(--cb-text); border-radius: 1px; } .chain-card-name { flex: 1; font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .chain-card-badge { font-size: 9px; text-transform: uppercase; letter-spacing: 0.5px; padding: 2px 6px; border-radius: 4px; background: rgba(255, 255, 255, 0.06); color: var(--cb-text-dim); white-space: nowrap; } .chain-card-summary { font-size: 11px; color: var(--cb-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 140px; } .chain-card-delete { background: none; border: none; color: var(--cb-text-dim); font-size: 16px; padding: 4px 6px; cursor: pointer; border-radius: 4px; transition: color 0.15s, background 0.15s; line-height: 1; } .chain-card-delete:hover, .chain-card-delete:active { color: #ff4466; background: rgba(255, 68, 102, 0.12); } .chain-card-expand-icon { font-size: 10px; color: var(--cb-text-dim); transition: transform 0.2s; } .chain-card.expanded .chain-card-expand-icon { transform: rotate(90deg); } /* ── Card body (params) ──────────────────────────────────────── */ .chain-card-body { max-height: 0; overflow: hidden; transition: max-height 0.25s ease; } .chain-card.expanded .chain-card-body { max-height: 600px; } .chain-card-params { display: flex; flex-direction: column; gap: 6px; padding: 4px 14px 12px 14px; } /* ── Param slider row ────────────────────────────────────────── */ .chain-param-row { display: flex; align-items: center; gap: 8px; } .chain-param-label { font-size: 11px; color: var(--cb-text-dim); min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .chain-param-slider { flex: 1; -webkit-appearance: none; appearance: none; height: 6px; border-radius: 3px; background: rgba(255, 255, 255, 0.08); outline: none; cursor: pointer; } .chain-param-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--cb-text); border: 2px solid rgba(0, 0, 0, 0.3); cursor: pointer; } .chain-param-slider::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--cb-text); border: 2px solid rgba(0, 0, 0, 0.3); cursor: pointer; } .chain-param-value { font-size: 11px; color: var(--cb-text-dim); min-width: 32px; text-align: right; font-variant-numeric: tabular-nums; } /* ── NISPS live indicator ────────────────────────────────────── */ .chain-param-row.nisps-live .chain-param-slider { box-shadow: 0 0 0 2px rgba(0, 140, 255, 0.5); animation: nisps-pulse 1.5s ease-in-out infinite; } @keyframes nisps-pulse { 0%, 100% { box-shadow: 0 0 0 2px rgba(0, 140, 255, 0.3); } 50% { box-shadow: 0 0 0 3px rgba(0, 140, 255, 0.7); } } /* ── Add button ──────────────────────────────────────────────── */ .chain-add-btn { display: flex; align-items: center; justify-content: center; gap: 6px; padding: 12px; border: 2px dashed rgba(255, 255, 255, 0.12); border-radius: var(--cb-radius); background: transparent; color: var(--cb-text-dim); font-family: inherit; font-size: 13px; cursor: pointer; transition: border-color 0.15s, color 0.15s; } .chain-add-btn:hover, .chain-add-btn:active { border-color: var(--cb-accent); color: var(--cb-accent); } /* ── Generator combine mode toggle ───────────────────────────── */ .chain-combine-toggle { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 0 4px; } .chain-combine-label { font-size: 11px; color: var(--cb-text-dim); } .chain-combine-btn { font-family: inherit; font-size: 12px; font-weight: 600; padding: 4px 14px; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 6px; background: rgba(255, 255, 255, 0.04); color: var(--cb-text); cursor: pointer; transition: border-color 0.15s, background 0.15s; min-width: 60px; text-align: center; } .chain-combine-btn:hover, .chain-combine-btn:active { border-color: var(--cb-accent); background: rgba(255, 106, 0, 0.1); } /* ── Palette modal ───────────────────────────────────────────── */ .chain-palette-overlay { position: fixed; inset: 0; z-index: 9999; display: flex; align-items: flex-end; justify-content: center; background: rgba(0, 0, 0, 0.6); animation: palette-fade-in 0.15s ease; } @keyframes palette-fade-in { from { opacity: 0; } to { opacity: 1; } } .chain-palette { width: 100%; max-width: 420px; max-height: 80vh; overflow-y: auto; background: rgba(20, 20, 20, 0.95); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px 16px 0 0; padding: 16px 12px 24px; animation: palette-slide-up 0.2s ease; } @keyframes palette-slide-up { from { transform: translateY(40px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .chain-palette-title { font-size: 14px; font-weight: 600; color: var(--cb-text, #e0e0e0); text-align: center; margin-bottom: 12px; } .chain-palette-item { display: flex; align-items: center; gap: 10px; padding: 12px; border-radius: 10px; cursor: pointer; transition: background 0.12s; border: none; background: none; width: 100%; text-align: left; font-family: inherit; color: #e0e0e0; } .chain-palette-item:hover, .chain-palette-item:active { background: rgba(255, 255, 255, 0.06); } .chain-palette-item-color { width: 4px; height: 36px; border-radius: 2px; flex-shrink: 0; } .chain-palette-item-info { flex: 1; min-width: 0; } .chain-palette-item-name { font-size: 13px; font-weight: 600; } .chain-palette-item-meta { font-size: 11px; color: #888; margin-top: 2px; } /* ── Drop indicator ──────────────────────────────────────────── */ .chain-drop-indicator { height: 3px; background: var(--cb-accent, #ff6a00); border-radius: 2px; margin: -2px 8px; opacity: 0; transition: opacity 0.1s; } .chain-drop-indicator.active { opacity: 1; } `; const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } // ── ChainBuilderUI ─────────────────────────────────────────────────── export class ChainBuilderUI { /** * @param {{ container: HTMLElement, chain: import('./chain.js').Chain, eventBus: { emit: Function, on: Function } }} opts */ constructor({ container, chain, eventBus }) { injectStyles(); /** @type {HTMLElement} */ this._container = container; /** @type {import('./chain.js').Chain} */ this._chain = chain; /** @type {{ emit: Function, on: Function }} */ this._bus = eventBus; /** @private @type {Set} flat param indices controlled by NISPS */ this._liveParams = new Set(); /** @private @type {Function|null} */ this._paramChangeCb = null; /** @private @type {Set} indices of expanded cards */ this._expandedCards = new Set(); /** @private @type {Float32Array|null} current param values for display */ this._currentParams = null; // ── Drag state ── /** @private */ this._dragIndex = -1; /** @private */ this._dragEl = null; /** @private */ this._dragStartY = 0; /** @private */ this._dragOffsetY = 0; /** @private @type {HTMLElement|null} */ this._cardList = null; // ── Palette overlay ref ── /** @private @type {HTMLElement|null} */ this._paletteOverlay = null; // ── Bound handlers for cleanup ── this._onPointerMove = this._handlePointerMove.bind(this); this._onPointerUp = this._handlePointerUp.bind(this); this.render(); } // ── Public API ───────────────────────────────────────────────────── /** * Rebuild the entire UI from current chain state. */ render() { this._container.innerHTML = ''; this._container.classList.add('chain-builder'); const primitives = this._chain.getPrimitives(); // Card list wrapper const cardList = document.createElement('div'); cardList.style.display = 'flex'; cardList.style.flexDirection = 'column'; cardList.style.gap = '6px'; this._cardList = cardList; // Compute flat param offset for each primitive let flatOffset = 0; for (let i = 0; i < primitives.length; i++) { const prim = primitives[i]; const card = this._createCard(prim, i, flatOffset); cardList.appendChild(card); flatOffset += prim.paramCount; } this._container.appendChild(cardList); // Add button const addBtn = document.createElement('button'); addBtn.className = 'chain-add-btn'; addBtn.innerHTML = '+ Add Primitive'; addBtn.addEventListener('click', () => this._openPalette()); this._container.appendChild(addBtn); // Generator combine mode toggle const toggle = this._createCombineToggle(); this._container.appendChild(toggle); } /** * Update which flat param indices are NISPS-controlled (pulsing indicator). * @param {Array} paramIndices */ setLiveParams(paramIndices) { this._liveParams = new Set(paramIndices); // Update existing rows without full re-render const rows = this._container.querySelectorAll('.chain-param-row'); rows.forEach((row) => { const idx = parseInt(row.dataset.flatIndex, 10); if (isNaN(idx)) return; if (this._liveParams.has(idx)) { row.classList.add('nisps-live'); } else { row.classList.remove('nisps-live'); } }); } /** * Register a callback for manual slider changes. * @param {(flatParamIndex: number, value: number) => void} callback */ onParamChange(callback) { this._paramChangeCb = callback; } /** * Update displayed param values without re-rendering. * @param {Float32Array|Array} params - flat param array */ updateParamValues(params) { this._currentParams = params; const sliders = this._container.querySelectorAll('.chain-param-slider'); sliders.forEach((slider) => { const idx = parseInt(slider.dataset.flatIndex, 10); if (!isNaN(idx) && idx < params.length) { slider.value = params[idx]; // Update adjacent value display const valEl = slider.parentElement && slider.parentElement.querySelector('.chain-param-value'); if (valEl) { valEl.textContent = params[idx].toFixed(2); } } }); } /** * Cleanup all DOM and listeners. */ destroy() { this._closePalette(); document.removeEventListener('pointermove', this._onPointerMove); document.removeEventListener('pointerup', this._onPointerUp); this._container.innerHTML = ''; this._container.classList.remove('chain-builder'); this._paramChangeCb = null; } // ── Card creation ────────────────────────────────────────────────── /** * @private * @param {import('./primitive.js').Primitive} prim * @param {number} chainIndex * @param {number} flatOffset - flat param offset for this primitive * @returns {HTMLElement} */ _createCard(prim, chainIndex, flatOffset) { const card = document.createElement('div'); card.className = 'chain-card'; card.dataset.chainIndex = chainIndex; if (this._expandedCards.has(chainIndex)) { card.classList.add('expanded'); } const catColor = CATEGORY_COLORS[prim.category] || '#888'; // Left color border const leftBorder = document.createElement('div'); leftBorder.className = 'chain-card-left-border'; leftBorder.style.background = catColor; card.appendChild(leftBorder); // Header const header = document.createElement('div'); header.className = 'chain-card-header'; // Drag handle const drag = document.createElement('div'); drag.className = 'chain-card-drag'; drag.innerHTML = ''; drag.addEventListener('pointerdown', (e) => this._handleDragStart(e, chainIndex, card)); header.appendChild(drag); // Name const name = document.createElement('span'); name.className = 'chain-card-name'; name.textContent = prim.name; header.appendChild(name); // Category badge const badge = document.createElement('span'); badge.className = 'chain-card-badge'; badge.textContent = prim.category; badge.style.color = catColor; header.appendChild(badge); // Summary (collapsed key params) const summary = document.createElement('span'); summary.className = 'chain-card-summary'; summary.textContent = this._buildSummary(prim, flatOffset); header.appendChild(summary); // Expand chevron const chevron = document.createElement('span'); chevron.className = 'chain-card-expand-icon'; chevron.textContent = '\u25B6'; // right-pointing triangle header.appendChild(chevron); // Delete button const del = document.createElement('button'); del.className = 'chain-card-delete'; del.textContent = '\u00D7'; // multiplication sign as X del.title = 'Remove'; del.addEventListener('click', (e) => { e.stopPropagation(); this._removePrimitive(chainIndex); }); header.appendChild(del); // Toggle expand on header click (but not on drag handle or delete) header.addEventListener('click', (e) => { if (e.target.closest('.chain-card-drag') || e.target.closest('.chain-card-delete')) return; this._toggleExpand(chainIndex, card); }); card.appendChild(header); // Body (params) const body = document.createElement('div'); body.className = 'chain-card-body'; const paramsDiv = document.createElement('div'); paramsDiv.className = 'chain-card-params'; for (let pi = 0; pi < prim.paramSchema.length; pi++) { const schema = prim.paramSchema[pi]; const flatIdx = flatOffset + pi; const row = this._createParamRow(schema, flatIdx, prim); paramsDiv.appendChild(row); } body.appendChild(paramsDiv); card.appendChild(body); return card; } /** * @private */ _buildSummary(prim, flatOffset) { const schema = prim.paramSchema; const maxShow = Math.min(3, schema.length); const parts = []; for (let i = 0; i < maxShow; i++) { const val = this._currentParams ? this._currentParams[flatOffset + i] : schema[i].default; parts.push(schema[i].name + ':' + (val !== undefined ? val.toFixed(2) : schema[i].default.toFixed(2))); } return parts.join(' '); } /** * @private */ _createParamRow(schema, flatIdx, prim) { const row = document.createElement('div'); row.className = 'chain-param-row'; row.dataset.flatIndex = flatIdx; if (this._liveParams.has(flatIdx)) { row.classList.add('nisps-live'); } const label = document.createElement('span'); label.className = 'chain-param-label'; label.textContent = schema.name; row.appendChild(label); const slider = document.createElement('input'); slider.type = 'range'; slider.className = 'chain-param-slider'; slider.min = '0'; slider.max = '1'; slider.step = '0.001'; slider.dataset.flatIndex = flatIdx; const currentVal = this._currentParams ? this._currentParams[flatIdx] : schema.default; slider.value = currentVal !== undefined ? currentVal : schema.default; const valDisplay = document.createElement('span'); valDisplay.className = 'chain-param-value'; valDisplay.textContent = parseFloat(slider.value).toFixed(2); slider.addEventListener('input', () => { const v = parseFloat(slider.value); valDisplay.textContent = v.toFixed(2); if (this._paramChangeCb) { this._paramChangeCb(flatIdx, v); } }); row.appendChild(slider); row.appendChild(valDisplay); return row; } // ── Expand / collapse ────────────────────────────────────────────── /** * @private */ _toggleExpand(chainIndex, cardEl) { if (this._expandedCards.has(chainIndex)) { this._expandedCards.delete(chainIndex); cardEl.classList.remove('expanded'); } else { this._expandedCards.add(chainIndex); cardEl.classList.add('expanded'); } } // ── Add / remove primitives ──────────────────────────────────────── /** * @private */ _removePrimitive(chainIndex) { this._chain.removePrimitive(chainIndex); // Adjust expanded set const newExpanded = new Set(); for (const idx of this._expandedCards) { if (idx < chainIndex) newExpanded.add(idx); else if (idx > chainIndex) newExpanded.add(idx - 1); // idx === chainIndex is removed } this._expandedCards = newExpanded; this.render(); this._bus.emit('ui.chainEdit', { action: 'remove', index: chainIndex }); } /** * @private */ _addPrimitive(registryKey) { const Ctor = PRIMITIVE_REGISTRY[registryKey]; if (!Ctor) return; const instance = new Ctor(); this._chain.addPrimitive(instance); this.render(); this._bus.emit('ui.chainEdit', { action: 'add', name: registryKey }); } // ── Palette ──────────────────────────────────────────────────────── /** * @private */ _openPalette() { if (this._paletteOverlay) return; const overlay = document.createElement('div'); overlay.className = 'chain-palette-overlay'; const palette = document.createElement('div'); palette.className = 'chain-palette'; const title = document.createElement('div'); title.className = 'chain-palette-title'; title.textContent = 'Add Primitive'; palette.appendChild(title); const keys = Object.keys(PRIMITIVE_REGISTRY); for (let i = 0; i < keys.length; i++) { const key = keys[i]; const CtorRef = PRIMITIVE_REGISTRY[key]; // Instantiate temporarily to read metadata const temp = new CtorRef(); const item = document.createElement('button'); item.className = 'chain-palette-item'; const colorBar = document.createElement('div'); colorBar.className = 'chain-palette-item-color'; colorBar.style.background = CATEGORY_COLORS[temp.category] || '#888'; item.appendChild(colorBar); const info = document.createElement('div'); info.className = 'chain-palette-item-info'; const nameEl = document.createElement('div'); nameEl.className = 'chain-palette-item-name'; nameEl.textContent = temp.name; info.appendChild(nameEl); const meta = document.createElement('div'); meta.className = 'chain-palette-item-meta'; meta.textContent = temp.category + ' \u00B7 ' + temp.paramCount + ' params'; info.appendChild(meta); item.appendChild(info); item.addEventListener('click', () => { this._closePalette(); this._addPrimitive(key); }); palette.appendChild(item); } overlay.appendChild(palette); // Close on overlay click (outside palette) overlay.addEventListener('click', (e) => { if (e.target === overlay) { this._closePalette(); } }); document.body.appendChild(overlay); this._paletteOverlay = overlay; } /** * @private */ _closePalette() { if (this._paletteOverlay) { this._paletteOverlay.remove(); this._paletteOverlay = null; } } // ── Generator combine mode toggle ────────────────────────────────── /** * @private */ _createCombineToggle() { const wrapper = document.createElement('div'); wrapper.className = 'chain-combine-toggle'; const label = document.createElement('span'); label.className = 'chain-combine-label'; label.textContent = 'Gen combine:'; wrapper.appendChild(label); const btn = document.createElement('button'); btn.className = 'chain-combine-btn'; btn.textContent = this._chain.generatorCombineMode === 'additive' ? 'Add' : 'Mul'; btn.addEventListener('click', () => { if (this._chain.generatorCombineMode === 'additive') { this._chain.generatorCombineMode = 'multiplicative'; btn.textContent = 'Mul'; } else { this._chain.generatorCombineMode = 'additive'; btn.textContent = 'Add'; } this._bus.emit('ui.chainEdit', { action: 'combineMode', mode: this._chain.generatorCombineMode }); }); wrapper.appendChild(btn); return wrapper; } // ── Drag-to-reorder (pointer events) ─────────────────────────────── /** * @private */ _handleDragStart(e, chainIndex, cardEl) { e.preventDefault(); e.stopPropagation(); this._dragIndex = chainIndex; this._dragEl = cardEl; this._dragStartY = e.clientY; this._dragOffsetY = 0; cardEl.classList.add('dragging'); cardEl.setPointerCapture(e.pointerId); document.addEventListener('pointermove', this._onPointerMove); document.addEventListener('pointerup', this._onPointerUp); } /** * @private */ _handlePointerMove(e) { if (this._dragIndex < 0 || !this._dragEl || !this._cardList) return; const dy = e.clientY - this._dragStartY; this._dragOffsetY = dy; this._dragEl.style.transform = 'translateY(' + dy + 'px) scale(1.03)'; // Determine drop target by checking which card we're over const cards = this._cardList.querySelectorAll('.chain-card'); const dragRect = this._dragEl.getBoundingClientRect(); const dragCenter = dragRect.top + dragRect.height / 2; let targetIndex = this._dragIndex; for (let i = 0; i < cards.length; i++) { if (i === this._dragIndex) continue; const rect = cards[i].getBoundingClientRect(); const center = rect.top + rect.height / 2; if (this._dragIndex < i && dragCenter > center) { targetIndex = i; } else if (this._dragIndex > i && dragCenter < center) { targetIndex = i; } } this._dragTargetIndex = targetIndex; } /** * @private */ _handlePointerUp(e) { document.removeEventListener('pointermove', this._onPointerMove); document.removeEventListener('pointerup', this._onPointerUp); if (this._dragEl) { this._dragEl.classList.remove('dragging'); this._dragEl.style.transform = ''; } const from = this._dragIndex; const to = this._dragTargetIndex !== undefined ? this._dragTargetIndex : from; this._dragIndex = -1; this._dragEl = null; this._dragTargetIndex = undefined; if (from !== to && from >= 0) { // Adjust expanded set for the move const newExpanded = new Set(); for (const idx of this._expandedCards) { if (idx === from) { newExpanded.add(to); } else { let adjusted = idx; if (from < to) { if (idx > from && idx <= to) adjusted = idx - 1; } else { if (idx >= to && idx < from) adjusted = idx + 1; } newExpanded.add(adjusted); } } this._expandedCards = newExpanded; this._chain.movePrimitive(from, to); this.render(); this._bus.emit('ui.chainEdit', { action: 'reorder', from: from, to: to }); } } }