/** * Console — the FIVE real dock drawers (operator dock restructure). Each renderer * takes (ctx, depth); what shows is gated by depth (peek | expand | full): * * learn — Learning : feedback-mode selector, solo/arm chooser, live params * inputs — Inputs : input source * route — Outputs : per-output control matrix for the ACTIVE mode/backend, * a Mode-specific config section, and (Editor mode) the * MEMLNaut serial panel. The old separate "Synth" and * "Particle/Visual" drawers are REMOVED — their config now * lives here under the active Mode (TOP dock selector). * settings — Settings : icon style + input-map shape (settings-store) * help — Help : keymap + the loop explanation * * The TOP dock selector ("Mode") chooses the active OUTPUT backend/target; this * drawer renders whatever that backend needs. * * Engine wiring: the feedback-mode pill → controller.setMode; the arm flags → * engine.feedback.setFocus; per-output rows write the shared MFParam store. * Where engine support does not yet exist the UI + state are wired and a TODO * references the relevant spec — no faked engine behaviour. */ import type { ReactNode } from 'react'; import { Badge, Button, PillToggle, Slider, Switch } from '../primitives'; import type { ConsoleCtx, DrawerDepth, DrawerKey, FeedbackModeUI, SoloMode } from './types'; import { OutputControlRow } from '../dock/OutputControlRow'; import { BackendAdvanced } from '../dock/BackendAdvanced'; import { OutputsBackendConfig, BackendStatusChip } from '../dock/OutputsBackendConfig'; import { BACKENDS } from '../dock/output-state'; import { shapeValues } from './model'; import { outputModeDescriptor } from './output-mode'; import { useSettings, unfocusedIconCss } from '../settings/settings-store'; import type { UnfocusedIconColour, InputMapMode } from '../settings/settings-store'; import { EditorPanel } from '../serial/EditorPanel'; import { LearningIcon, InputsIcon, OutputsIcon, SettingsIcon, HelpIcon, } from './icons'; function Chip({ children, tone }: { children: ReactNode; tone?: string }) { return ( {children} ); } function SectionLabel({ children }: { children: ReactNode }) { return (
{children}
); } /** A 2+ segment selector pill that drives a typed value. */ function Segmented({ value, onChange, options, }: { value: T; onChange: (v: T) => void; options: { value: T; label: string }[]; }) { return (
{options.map((o) => { const on = value === o.value; return ( ); })}
); } // =========================================================================== // 1. LEARNING-BEHAVIOUR (dock-spec §1; rl-feedback-design) // =========================================================================== const FEEDBACK_OPTS: { value: FeedbackModeUI; label: string }[] = [ { value: 'geometric-dislike', label: 'Push away' }, { value: 'explore-and-place', label: 'Explore & place' }, ]; const FEEDBACK_DESC: Record = { 'geometric-dislike': 'Down carves the current sound away from what you like — directed repulsion (Mode 1).', 'explore-and-place': 'Down re-rolls the whole net into a scratchpad you audition; + places a liked sound (Mode 2).', }; const SOLO_OPTS: { value: SoloMode; label: string }[] = [ { value: 'mask-gradients', label: 'Mask gradients' }, { value: 'zero-loss', label: 'Zero loss' }, { value: 'dont-care', label: "Don't-care mask" }, ]; const SOLO_DESC: Record = { 'mask-gradients': 'Column-freeze (default) — only the armed output moves; the rest stay bit-identical.', 'zero-loss': 'Expressive, but armed and unarmed outputs share hidden weights, so others can drift.', 'dont-care': 'Each example stores a per-output mask so stale labels never pull unarmed outputs.', }; function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { return ( <>
{FEEDBACK_OPTS.find((o) => o.value === ctx.feedbackMode)?.label} arm: {ctx.armedCount ? `${ctx.armedCount} output${ctx.armedCount > 1 ? 's' : ''}` : 'all'} {ctx.exploring && exploring…} {ctx.learningPaused && learning paused}
Down action · feedback mode {depth !== 'peek' && (

{FEEDBACK_DESC[ctx.feedbackMode]}

)} {depth !== 'peek' && ( <> Solo / arm scope
{ctx.armedCount ? `${ctx.armedCount} armed — arm with the S button on each output row` : 'every live output learns'}
Solo behaviour

{SOLO_DESC[ctx.soloMode]} Solo only freezes the rest as far as a shared network allows.

Live training params v.toExponential(1)} /> )} {depth === 'full' && ( <> Feedback lab

State machine: idle → exploring → commit / cancel. While Explore & place is exploring, training is paused and the joystick auditions a random scratchpad net; + commits a placed anchor and restores the real net.

{/* TODO(dock-spec §1.3): real LossPlot / WeightHealth / LayerStats / GradientFlow need nisps_ml_loss_history plumbed through the C API. Diagnostics suite deferred. */}

Loss plot · weight-health · layer-stats · gradient-flow land here once the loss-history C API is plumbed (dock-spec §1.3).

)} ); } // =========================================================================== // 2. INPUTS (workstream F; inputs-spec — modular input layer) // =========================================================================== const STATUS_TONE: Record = { ready: 'var(--accent)', connecting: 'var(--warn)', unavailable: 'var(--fg-dim)', error: 'var(--danger)', idle: 'var(--fg-dim)', }; function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { const inp = ctx.inputs; const enabledCount = inp.sources.filter((s) => s.enabled).length; const reshaping = inp.axisCount > inp.engineInputSize; return ( <>
{enabledCount === 0 ? 'no source' : `${enabledCount} source${enabledCount > 1 ? 's' : ''}`} {inp.axisCount} axes engine: {inp.engineInputSize}-in {reshaping && blended → {inp.engineInputSize}}
Sources
{inp.sources.map((s) => (
{s.label} {s.enabled ? ` · ${s.axisCount} ax` : ''} {depth !== 'peek' && ( {s.status.message} )}
inp.setEnabled(s.kind, v)} label="" />
))}
{depth !== 'peek' && ( <> {/* ---- Gamepad config ---- */} {inp.sources.find((s) => s.kind === 'gamepad')?.enabled && ( <> Gamepad · sticks )} {/* ---- MIDI learn-map ---- */} {inp.sources.find((s) => s.kind === 'midi')?.enabled && ( <> MIDI · learn-map
{inp.midiBindings.length > 0 && ( )}
{inp.midiBindings.length === 0 ? (

No axes learned yet — arm Learn, then wiggle a knob or hit a pad. CCs map to a continuous axis; notes map to a gate (1 while held).

) : (
{inp.midiBindings.map((b, i) => (
{b.label} · {b.value.toFixed(2)}
))}
)} {inp.midiInputs.length > 0 && (

Listening on: {inp.midiInputs.map((p) => p.name).join(', ')}

)} )} {/* ---- Channel layout ---- */} Channel layout {inp.channelLayout.length === 0 ? (

No active axes. Enable a source above.

) : (
{inp.channelLayout.map((c, i) => ( {i}: {c.source}·{c.label} ))}
)}

The active sources concatenate into one input vector at the head of the spine. {reshaping ? ` The browser WASM is a fixed ${inp.engineInputSize}-input head (MLP<2,…>), so the ${inp.axisCount} axes are blended down to ${inp.engineInputSize} (even→X / odd→Y mean).` : ''}{' '} {/* TODO(workstream F, docs/redesign/inputs-spec.md — "multiple WASM modules + warm-start"): give every axis its own genuine input dimension by (re)loading a WASM module whose MLP arity matches axisCount and warm-starting from the prior net. Deferred — the reduction lives in InputLayer.compose(). */} True per-axis dimensions land with the multi-WASM reshape (inputs-spec).

)} ); } // =========================================================================== // 3. OUTPUTS / ROUTING (dock-spec §3, §4) // =========================================================================== const VISUAL_NAMES = [ 'Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn', 'Advection', 'Inertia', 'Drag', 'Repulse', 'RepCnt', 'RepRate', ]; /** * Per-Mode config section shown ABOVE the per-output rows. The synth Mode shows * transport + tempo; the particle Mode names the outputs; MIDI/OSC/Editor show * their own affordances. Replaces the removed Synth + Visual drawers. */ function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) { switch (ctx.outputMode) { case 'synth': return ( <> Transport
audio starts on the play gesture
{depth !== 'peek' && ( <> Tempo

The active engine follows the selected mode ({ctx.mode.label}). {/* TODO(dock-spec §5): arpeggiator + tiered synth presets + the 18-section group-override matrix are workstream E. */}

)} ); case 'editor': return ( <> MEMLNaut · USB serial ); case 'particles': return depth !== 'peek' ? (

Flow-field visualiser driven by the first {Math.min(20, ctx.params.length)} outputs (no audio). {/* TODO(backends-spec §4): port FlowFieldVisualizer + visual preset chips. */}

) : null; case 'midi': // The full MIDI config (port picker, CC count, per-output CC/channel/name) // + preset bar render via OutputsBackendConfig in RoutingDrawer below. return depth !== 'peek' ? (

Each output sends a real Web MIDI CC. Pick a port and set CC# / channel per output.

) : null; case 'osc': return depth !== 'peek' ? (

Each output sends to an OSC path with a physical range, over the WebSocket bridge.

) : null; default: return null; } } function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { const values = shapeValues(ctx.params, null); // bar uses shaped held/live value snapshot const counts = ctx.params.reduce>((a, p) => { a[p.status] = (a[p.status] || 0) + 1; return a; }, {}); const mutedN = ctx.params.filter((p) => p.muted).length; const modeDesc = outputModeDescriptor(ctx.outputMode); const backend = BACKENDS.find((b) => b.id === modeDesc.backend) ?? BACKENDS[0]; // The particle Mode names its outputs; otherwise use the param names. const nameFor = (idx: number, fallback: string) => ctx.outputMode === 'particles' ? VISUAL_NAMES[idx] ?? fallback : fallback; if (depth === 'full') { return ( <>
{modeDesc.label}
{ModeConfig(ctx, depth)} {/* Specialised, editable per-backend config (MIDI/OSC) + named-preset bar. */} Advanced · {modeDesc.label} ); } const rows = depth === 'peek' ? ctx.params.slice(0, 6) : ctx.params; return ( <>
{modeDesc.label} live {counts.live || 0} fixed {counts.fixed || 0} off {counts.off || 0} muted {mutedN}
{ModeConfig(ctx, depth)} {/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden at peek. */} {depth !== 'peek' && } Outputs · M mute · S arm · off/fixed/live
{rows.map((p) => { const i = ctx.params.indexOf(p); const labelled = { ...p, name: nameFor(i, p.name) }; return ( ctx.setParam(i, patch)} showCurve={depth !== 'peek'} /> ); })}
{depth === 'peek' && ctx.params.length > 6 && ( +{ctx.params.length - 6} more — expand to edit )} ); } // =========================================================================== // 4. SETTINGS (operator dock restructure — settings-store) // =========================================================================== const ICON_COLOUR_OPTS: { value: UnfocusedIconColour; label: string }[] = [ { value: 'off-white', label: 'Off-white' }, { value: 'white', label: 'White' }, { value: 'orange', label: 'Orange' }, ]; const INPUT_MAP_OPTS: { value: InputMapMode; label: string }[] = [ { value: 'follow-mode', label: 'Follow mode' }, { value: 'rectangular', label: 'Rectangular' }, { value: 'circular', label: 'Circular' }, ]; function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) { const { settings, set } = useSettings(); return ( <> Icons set('monochromeIcons', v)} label="Monochrome icons" /> {depth !== 'peek' && ( <>
Unfocused icon colour
set('unfocusedIconColour', v as UnfocusedIconColour)} options={ICON_COLOUR_OPTS} />

Focused / active icons are always accent orange. This sets the resting colour of unfocused icons (preview below).

)} Input map set('inputMap', v as InputMapMode)} options={INPUT_MAP_OPTS} /> {depth !== 'peek' && (

The 2D input surface: a rectangular XY map or a circular joystick-style disc. "Follow mode" uses the active mode's declared input (joystick → circular, else rectangular).

)} Chrome set('cornerRadius', Math.round(v))} /> {depth !== 'peek' && (

Roundness of buttons, control rows, dock icons and panels. Pills and the circular verdict buttons are intentionally exempt. Default 2px.

)} ); } // =========================================================================== // 5. HELP // =========================================================================== const KEYS: [string, string][] = [ ['1–5', 'open drawers'], ['\\', 'full depth'], ['space / ↑', 'commit +'], ['↓', 'perturb / down −'], ['z', 'undo'], ['[ ] =', 'split (composite)'], ]; function HelpDrawer() { return ( <> Keyboard
{KEYS.map(([k, v]) => (
{k} {v}
))}
The loop

Drag the manifold to explore. Hear something good → + to keep it. Wrong → − to push away or re-roll (set the behaviour in the Learning drawer). Went too far → undo. The dock reveals exactly as much machinery as you reach for.

Learn more ▶ Watch the explainers + interactive demos ); } export interface DrawerSection { /** Monochrome inline-SVG icon (currentColor-driven by the dock button). */ icon: ReactNode; /** Prior colour-emoji glyph, used when monochrome icons are OFF. */ glyph: string; label: string; render: (ctx: ConsoleCtx, depth: DrawerDepth) => ReactNode; } export const DRAWERS: Record = { learn: { icon: , glyph: '🧠', label: 'Learning', render: LearningDrawer }, inputs: { icon: , glyph: '🎚', label: 'Inputs', render: InputsDrawer }, route: { icon: , glyph: '🔀', label: 'Outputs', render: RoutingDrawer }, settings: { icon: , glyph: '⚙', label: 'Settings', render: (c, d) => }, help: { icon: , glyph: '?', label: 'Help', render: HelpDrawer }, };