// Control Surface UI — DOM for compound axes + settings drawer // Renders 3 compact sliders on the floating bar and a gear-icon settings drawer. // // Usage: // import { initControlSurfaceUI } from './ui/control-surface-ui.js'; // const { surface, destroy } = initControlSurfaceUI(); // // surface is the ControlSurface instance import { ControlSurface, CONTROL_PRESETS, PARAM_DEFAULTS, PARAM_RANGES, LOG_SCALE_PARAMS } from './control-surface.js'; // ---- Parameter metadata for the drawer ---- const SECTIONS = [ { id: 'input', label: 'Input', params: [ { name: 'zoom', label: 'Zoom', log: true }, { name: 'deadzone', label: 'Deadzone' }, { name: 'inputCurve', label: 'Curve' }, { name: 'smoothing', label: 'Smoothing' }, { name: 'momentumZoom', label: 'Momentum', type: 'select', options: ['off', 'gentle', 'strong'] }, { name: 'invertX', label: 'Invert X', type: 'toggle' }, { name: 'invertY', label: 'Invert Y', type: 'toggle' }, ], }, { id: 'training', label: 'Training', params: [ { name: 'learningRate', label: 'Learning Rate', log: true }, { name: 'maxIterations', label: 'Max Iterations' }, { name: 'convergenceThreshold', label: 'Convergence', log: true }, { name: 'rlTrainIntensity', label: 'RL Intensity' }, { name: 'maxExamples', label: 'Max Examples' }, { name: 'exampleDecay', label: 'Example Decay' }, ], }, { id: 'exploration', label: 'Exploration', params: [ { name: 'spread', label: 'Spread' }, { name: 'noiseFloor', label: 'Noise Floor' }, { name: 'noiseCap', label: 'Noise Cap' }, { name: 'noiseGrowth', label: 'Noise Growth' }, { name: 'noiseDecay', label: 'Noise Decay' }, { name: 'weightDecay', label: 'Weight Decay' }, { name: 'noiseDistribution', label: 'Distribution', type: 'select', options: ['gaussian', 'cauchy'] }, { name: 'layerAwareNoise', label: 'Layer-Aware', type: 'toggle' }, { name: 'zoomAwareFeedback', label: 'Zoom-Aware FB', type: 'toggle' }, ], }, { id: 'output', label: 'Output', params: [ { name: 'outputSmoothing', label: 'Smoothing' }, { name: 'outputSlewRate', label: 'Slew Rate' }, { name: 'tame', label: 'Tame' }, { name: 'globalCurve', label: 'Global Curve' }, ], }, ]; // ---- Utility: log-scale slider helpers ---- function toLogSlider(value, min, max) { // Map a log-scale value to 0-1 slider position const logMin = Math.log(min); const logMax = Math.log(max); return (Math.log(Math.max(value, min)) - logMin) / (logMax - logMin); } function fromLogSlider(sliderPos, min, max) { const logMin = Math.log(min); const logMax = Math.log(max); return Math.exp(logMin + sliderPos * (logMax - logMin)); } function formatValue(name, value) { if (typeof value === 'boolean') return value ? 'On' : 'Off'; if (typeof value === 'string') return value; if (typeof value !== 'number') return String(value); // Convergence threshold: scientific notation if (name === 'convergenceThreshold') return value.toExponential(1); // Integers if (name === 'maxExamples' || name === 'maxIterations') return Math.round(value).toString(); // Small decimals if (Math.abs(value) < 0.01 && value !== 0) return value.toExponential(1); if (Math.abs(value) < 1) return value.toFixed(3); return value.toFixed(2); } // ---- Build DOM ---- /** * Initialize the control surface UI. * Inserts compound axis sliders into the floating bar and creates the settings drawer. * Returns { surface, destroy }. */ export function initControlSurfaceUI() { const surface = new ControlSurface(); // Apply default preset surface.applyPreset('default'); // --- Floating bar: compound axis sliders --- const $barContent = document.querySelector('.floating-bar-content') || document.getElementById('floating-bar'); const axisContainer = document.createElement('div'); axisContainer.className = 'cs-axes'; axisContainer.innerHTML = `
`; $barContent.appendChild(axisContainer); // Wire axis sliders const axisSliders = {}; for (const axisEl of axisContainer.querySelectorAll('.cs-axis')) { const axisName = axisEl.dataset.axis; const slider = axisEl.querySelector('.cs-axis-slider'); axisSliders[axisName] = slider; slider.addEventListener('input', () => { const val = parseFloat(slider.value); if (axisName === 'boldness') surface.setBoldness(val); else if (axisName === 'memory') surface.setMemory(val); else if (axisName === 'precision') surface.setPrecision(val); }); // Double-tap to clear all overrides for this axis let lastTap = 0; slider.addEventListener('pointerdown', () => { const now = Date.now(); if (now - lastTap < 350) { surface.clearAllOverrides(); syncDrawerValues(); } lastTap = now; }); } // --- Gear button (settings drawer toggle) --- const gearBtn = document.createElement('button'); gearBtn.className = 'cs-gear-btn'; gearBtn.title = 'Control surface settings'; gearBtn.innerHTML = ` `; // Place gear button near the help button (top-right area) const $helpBtn = document.getElementById('help-btn'); if ($helpBtn && $helpBtn.parentNode) { $helpBtn.parentNode.insertBefore(gearBtn, $helpBtn); } else { document.body.appendChild(gearBtn); } // --- Settings drawer --- const overlay = document.createElement('div'); overlay.className = 'cs-drawer-overlay hidden'; const drawer = document.createElement('div'); drawer.className = 'cs-drawer'; // Build drawer content drawer.innerHTML = buildDrawerHTML(); overlay.appendChild(drawer); document.body.appendChild(overlay); // --- Wire drawer interactions --- let drawerOpen = false; function openDrawer() { drawerOpen = true; overlay.classList.remove('hidden'); requestAnimationFrame(() => { overlay.classList.add('open'); drawer.classList.add('open'); }); syncDrawerValues(); } function closeDrawer() { drawerOpen = false; overlay.classList.remove('open'); drawer.classList.remove('open'); setTimeout(() => { if (!drawerOpen) overlay.classList.add('hidden'); }, 220); } gearBtn.addEventListener('click', () => { if (drawerOpen) closeDrawer(); else openDrawer(); }); // Close on overlay click (outside drawer) overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDrawer(); }); // Close button inside drawer const closeBtn = drawer.querySelector('.cs-drawer-close'); if (closeBtn) closeBtn.addEventListener('click', closeDrawer); // Preset dropdown const presetSelect = drawer.querySelector('#cs-preset-select'); if (presetSelect) { presetSelect.addEventListener('change', () => { const id = presetSelect.value; if (id && CONTROL_PRESETS[id]) { surface.applyPreset(id); // Sync axis sliders axisSliders.boldness.value = surface.getBoldness(); axisSliders.memory.value = surface.getMemory(); axisSliders.precision.value = surface.getPrecision(); syncDrawerValues(); } }); } // Reset button const resetBtn = drawer.querySelector('#cs-reset-preset'); if (resetBtn) { resetBtn.addEventListener('click', () => { const id = presetSelect?.value || 'default'; surface.applyPreset(id); axisSliders.boldness.value = surface.getBoldness(); axisSliders.memory.value = surface.getMemory(); axisSliders.precision.value = surface.getPrecision(); syncDrawerValues(); }); } // Section expand/collapse for (const header of drawer.querySelectorAll('.cs-section-header')) { header.addEventListener('click', () => { const section = header.parentElement; section.classList.toggle('collapsed'); }); } // Wire individual parameter controls wireParamControls(drawer, surface, () => syncDrawerValues()); // --- Sync drawer values from surface state --- function syncDrawerValues() { const params = surface.getParams(); for (const section of SECTIONS) { for (const p of section.params) { const val = params[p.name]; const el = drawer.querySelector(`[data-param="${p.name}"]`); if (!el) continue; const range = PARAM_RANGES[p.name]; if (p.type === 'toggle') { const checkbox = el.querySelector('input[type="checkbox"]'); if (checkbox) checkbox.checked = !!val; } else if (p.type === 'select') { const select = el.querySelector('select'); if (select) select.value = val; } else if (range) { const slider = el.querySelector('input[type="range"]'); const valueSpan = el.querySelector('.cs-param-value'); if (slider) { if (p.log || LOG_SCALE_PARAMS.has(p.name)) { slider.value = toLogSlider(val, range[0], range[1]); } else { slider.value = val; } } if (valueSpan) valueSpan.textContent = formatValue(p.name, val); } // Show override indicator const overrideIndicator = el.querySelector('.cs-override-dot'); if (overrideIndicator) { overrideIndicator.classList.toggle('active', surface.hasOverride(p.name)); } } } } // Listen for external changes to the surface (e.g. from app code) surface.onChange(() => { if (drawerOpen) syncDrawerValues(); }); // --- Inject styles --- injectStyles(); // --- Cleanup function --- function destroy() { axisContainer.remove(); gearBtn.remove(); overlay.remove(); const styleEl = document.getElementById('cs-styles'); if (styleEl) styleEl.remove(); } return { surface, destroy }; } // ---- Build drawer HTML ---- function buildDrawerHTML() { let html = `
Control Surface
`; for (const section of SECTIONS) { html += `
${section.label}
`; for (const p of section.params) { html += buildParamRow(p); } html += `
`; } html += `
`; return html; } function buildParamRow(p) { const range = PARAM_RANGES[p.name]; if (p.type === 'toggle') { return `
`; } if (p.type === 'select') { const options = p.options || []; return `
`; } // Numeric slider const isLog = p.log || LOG_SCALE_PARAMS.has(p.name); const min = range ? range[0] : 0; const max = range ? range[1] : 1; const step = isLog ? 0.001 : (range ? (range[2] || 0.01) : 0.01); const sliderMin = isLog ? 0 : min; const sliderMax = isLog ? 1 : max; const defaultVal = PARAM_DEFAULTS[p.name] ?? 0.5; const sliderDefault = isLog ? toLogSlider(defaultVal, min, max) : defaultVal; return `
${formatValue(p.name, defaultVal)}
`; } // ---- Wire parameter controls ---- function wireParamControls(drawer, surface, syncFn) { for (const section of SECTIONS) { for (const p of section.params) { const el = drawer.querySelector(`[data-param="${p.name}"]`); if (!el) continue; const range = PARAM_RANGES[p.name]; if (p.type === 'toggle') { const checkbox = el.querySelector('input[type="checkbox"]'); if (checkbox) { checkbox.addEventListener('change', () => { surface.setOverride(p.name, checkbox.checked); syncFn(); }); } } else if (p.type === 'select') { const select = el.querySelector('select'); if (select) { select.addEventListener('change', () => { surface.setOverride(p.name, select.value); syncFn(); }); } } else if (range) { const slider = el.querySelector('input[type="range"]'); const valueSpan = el.querySelector('.cs-param-value'); const isLog = p.log || LOG_SCALE_PARAMS.has(p.name); if (slider) { slider.addEventListener('input', () => { let val; if (isLog) { val = fromLogSlider(parseFloat(slider.value), range[0], range[1]); } else { val = parseFloat(slider.value); } surface.setOverride(p.name, val); if (valueSpan) valueSpan.textContent = formatValue(p.name, val); // Update override dot const dot = el.querySelector('.cs-override-dot'); if (dot) dot.classList.add('active'); }); // Double-tap slider to clear override for this param let lastTap = 0; slider.addEventListener('pointerdown', () => { const now = Date.now(); if (now - lastTap < 350) { surface.clearOverride(p.name); syncFn(); } lastTap = now; }); } } } } } // ---- CSS injection ---- function injectStyles() { if (document.getElementById('cs-styles')) return; const style = document.createElement('style'); style.id = 'cs-styles'; style.textContent = ` /* ---- Control Surface: Compound Axes (floating bar) ---- */ .cs-axes { display: flex; align-items: center; gap: 6px; margin: 0 4px; } .cs-axis { display: flex; align-items: center; gap: 3px; } .cs-axis-label { font-size: 9px; color: rgba(255, 255, 255, 0.45); text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; cursor: default; user-select: none; } .cs-axis-slider { width: 48px; height: 3px; -webkit-appearance: none; appearance: none; background: rgba(255, 255, 255, 0.12); border-radius: 2px; outline: none; cursor: pointer; } .cs-axis-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #ff6a00); cursor: pointer; border: none; } .cs-axis-slider::-moz-range-thumb { width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #ff6a00); border: none; cursor: pointer; } /* ---- Gear button ---- */ .cs-gear-btn { position: fixed; bottom: 96px; right: 50px; z-index: 45; width: 32px; height: 32px; border-radius: 50%; border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.08)); background: var(--glass-bg, rgba(13, 13, 13, 0.65)); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); color: var(--text-dim, #888); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; padding: 0; } .cs-gear-btn:hover, .cs-gear-btn:active { border-color: var(--accent, #ff6a00); color: var(--accent, #ff6a00); } /* ---- Drawer overlay ---- */ .cs-drawer-overlay { position: fixed; inset: 0; z-index: 90; background: rgba(0, 0, 0, 0); transition: background 0.2s ease; pointer-events: auto; } .cs-drawer-overlay.hidden { display: none; } .cs-drawer-overlay.open { background: rgba(0, 0, 0, 0.4); } /* ---- Drawer panel ---- */ .cs-drawer { position: fixed; top: 0; right: -320px; width: 300px; height: 100%; z-index: 91; background: rgba(0, 0, 0, 0.88); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-left: 1px solid rgba(255, 255, 255, 0.08); overflow-y: auto; -webkit-overflow-scrolling: touch; transition: right 0.2s ease; font-family: 'JetBrains Mono', 'SF Mono', monospace; } .cs-drawer.open { right: 0; } /* ---- Drawer header ---- */ .cs-drawer-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px 10px; border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .cs-drawer-title { font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.8); text-transform: uppercase; letter-spacing: 0.8px; } .cs-drawer-close { width: 28px; height: 28px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.5); font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; padding: 0; } .cs-drawer-close:hover { background: rgba(255, 255, 255, 0.1); color: white; } /* ---- Drawer body ---- */ .cs-drawer-body { padding: 0 0 24px; } /* ---- Preset row ---- */ .cs-preset-row { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid rgba(255, 255, 255, 0.04); } .cs-preset-label { font-size: 10px; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap; } .cs-preset-select { flex: 1; height: 28px; padding: 0 8px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.04); color: var(--text, #e0e0e0); font-family: inherit; font-size: 11px; cursor: pointer; appearance: none; -webkit-appearance: none; } .cs-preset-select option { background: #1a1a1a; color: var(--text, #e0e0e0); } .cs-reset-btn { height: 28px; padding: 0 10px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.5); font-family: inherit; font-size: 10px; cursor: pointer; transition: all 0.15s; white-space: nowrap; } .cs-reset-btn:hover { background: rgba(255, 255, 255, 0.1); color: white; } /* ---- Sections ---- */ .cs-section { border-bottom: 1px solid rgba(255, 255, 255, 0.04); } .cs-section-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; cursor: pointer; user-select: none; transition: background 0.15s; } .cs-section-header:hover { background: rgba(255, 255, 255, 0.03); } .cs-section-header span:first-child { font-size: 10px; font-weight: 500; color: rgba(255, 255, 255, 0.5); text-transform: uppercase; letter-spacing: 1px; } .cs-section-chevron { font-size: 8px; color: rgba(255, 255, 255, 0.3); transition: transform 0.2s; } .cs-section.collapsed .cs-section-chevron { transform: rotate(-90deg); } .cs-section-body { padding: 0 16px 8px; overflow: hidden; transition: max-height 0.2s ease, padding 0.2s ease; max-height: 600px; } .cs-section.collapsed .cs-section-body { max-height: 0; padding-top: 0; padding-bottom: 0; } /* ---- Parameter rows ---- */ .cs-param-row { margin-bottom: 8px; } .cs-param-label { display: flex; align-items: center; gap: 4px; font-size: 10px; color: rgba(255, 255, 255, 0.45); margin-bottom: 3px; cursor: default; } /* Override dot: shows when param has a manual offset */ .cs-override-dot { width: 5px; height: 5px; border-radius: 50%; background: transparent; flex-shrink: 0; transition: background 0.15s; } .cs-override-dot.active { background: var(--accent, #ff6a00); } /* ---- Slider row ---- */ .cs-slider-row { display: flex; align-items: center; gap: 6px; } .cs-slider { flex: 1; height: 3px; -webkit-appearance: none; appearance: none; background: rgba(255, 255, 255, 0.1); border-radius: 2px; outline: none; cursor: pointer; } .cs-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #ff6a00); cursor: pointer; border: none; } .cs-slider::-moz-range-thumb { width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #ff6a00); border: none; cursor: pointer; } .cs-param-value { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: rgba(255, 255, 255, 0.5); min-width: 36px; text-align: right; white-space: nowrap; } /* ---- Toggle (checkbox) ---- */ .cs-toggle { accent-color: var(--accent, #ff6a00); cursor: pointer; width: 14px; height: 14px; } /* ---- Select ---- */ .cs-select { height: 24px; padding: 0 8px; border-radius: 4px; border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.04); color: var(--text, #e0e0e0); font-family: inherit; font-size: 10px; cursor: pointer; appearance: none; -webkit-appearance: none; } .cs-select option { background: #1a1a1a; color: var(--text, #e0e0e0); } /* ---- Responsive: shrink axes on narrow screens ---- */ @media (max-width: 600px) { .cs-axes { gap: 4px; } .cs-axis-slider { width: 36px; } .cs-axis-label { font-size: 8px; } .cs-drawer { width: 280px; right: -300px; } } `; document.head.appendChild(style); }