memlnaut-nisps/manifold/src/console/ConsoleApp.tsx
monkey-w1n5t0n 19b7f7eee8 feat(manifold): add convertible React front-end + simplify console chrome
Adds the Manifold app (manifold/) — the convertible-mode React front-end on
the real NISPS ML+audio engine, served live at meml.lnfinitemonkeys.org/next/.

Console chrome trimmed per UI pass:
- drop mode label + subtitle from the top-left overlay (keep MEMLNaut wordmark)
- remove the composite split preset/ratio readout (top-centre)
- remove the OUTPUT corner tag above the bars
- remove the A/B compare toggle from the verdict cluster
- remove the follow button + input/noise readout (bottom-left)
- remove AltitudeNav focus switcher (bottom-right)
2026-06-28 03:28:45 +02:00

858 lines
30 KiB
TypeScript
Raw 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.

/**
* ConsoleApp — the reactive spine + layout for the convertible Console, wired to
* the REAL engine.
*
* What changed vs the window-global `ConsoleApp.jsx`:
* - The pseudo-inference (`MF_infer`, sin/cos) is GONE. The `values` every
* consumer reads now come from `engine.getOutputs()`, mapped onto the mode's
* params by `shapeValues` (status/min/max/curve applied here). Pad/joystick
* motion drives `engine.setInput(x,y)`; we subscribe to engine changes via
* `useEngineVersion` and re-derive `values` imperatively on render.
* - Verdicts wire to the engine: commit → feedback.thumbsUp(); perturb →
* feedback.thumbsDown(); reroll → randomise(); each followed by process().
* - The default feedback mode is "Explore and place" → randomise_mlp (set on
* mount; per docs/redesign/rl-feedback-design.md).
* - AltitudeNav switches `focus` via React state (in|split|out|composite), not
* by navigating to separate HTML files.
* - `c15` is labelled "Powerful Synth Engine" (in model.ts) — "C15" never shows.
*
* UI-only state (params status/min/max/curve, snapshots, A/B seed, axes,
* noiseCap, health/rev visuals) is preserved as faithful local React state.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import { useEngine, useEngineVersion } from '../engine';
import { MF_MODES, modeEngineId, seededGradient, shapeValues } from './model';
import type { MFParam } from './model';
import { CompositeStage } from './CompositeStage';
import { SplitStage } from './SplitStage';
import { OutputStage } from './OutputStage';
import { InputMini } from './InputMini';
import { Manifold } from './Manifold';
import { ReadoutStrip } from './ReadoutStrip';
import { VerdictCluster } from './VerdictCluster';
import { Dock } from './Dock';
import type {
Axes,
ConsoleCtx,
DrawerDepth,
DrawerKey,
FeedbackMarker,
FeedbackModeUI,
Focus,
OutputMode,
Pin,
Snapshot,
SoloMode,
} from './types';
import type { BackendId } from '../dock/output-state';
import { buildArmMask } from '../dock/output-state';
import { FeedbackController, type ProtoFeedbackMode } from '../feedback';
import { DEFAULT_OUTPUT_MODE, OUTPUT_MODES, outputModeDescriptor } from './output-mode';
import { useSettings, resolveInputMap } from '../settings/settings-store';
import { useBackendManager } from '../backends';
let SNAP_ID = 0;
/** Small pill-button style for the exploring-scratchpad banner controls. */
function pillBtn(color: string): CSSProperties {
return {
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
padding: '3px 10px',
borderRadius: 'var(--r-pill)',
border: `1px solid ${color}`,
background: 'transparent',
color,
cursor: 'pointer',
};
}
export interface ConsoleAppProps {
focus?: Focus;
}
export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProps) {
const engine = useEngine();
const version = useEngineVersion(engine);
const { settings } = useSettings();
const [focus, setFocus] = useState<Focus>(initialFocus);
const [modeId, setModeId] = useState('paf_synth');
const mode = MF_MODES.find((m) => m.id === modeId) ?? MF_MODES[0];
const [params, setParams] = useState<MFParam[]>(() => mode.params.map((p) => ({ ...p })));
const [pos, setPos] = useState<[number, number]>([0.5, 0.5]);
// A/B seed-snapshot model (kept as visual parity; A holds a remembered weight
// snapshot conceptually — here we mirror the JSX's seed-based preview marker).
const [seed, setSeed] = useState(0.4);
const [axes, setAxes] = useState<Axes>({ boldness: 0.55, memory: 0.4, precision: 0.5 });
const [preset, setPreset] = useState('Sculpt');
const [noiseCap, setNoiseCap] = useState(0.12);
const [examples, setExamples] = useState(0);
const [addingExample, setAddingExample] = useState(false);
const [loss, setLoss] = useState<number[]>([]);
const [busy, setBusy] = useState(false);
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
const [ab, setAB] = useState<'A' | 'B'>('B');
const [, setHoldingA] = useState(false);
const aRef = useRef<{ seed: number } | null>(null);
const [spread, setSpread] = useState(false);
const [tame, setTame] = useState(0.85);
const [health, setHealth] = useState(0.8);
const [rev, setRev] = useState(1);
const [active, setActive] = useState<DrawerKey | null>('learn');
const [depth, setDepth] = useState<DrawerDepth>('peek');
// Learning-behaviour store (dock-spec §1; rl-feedback-design). Default
// feedback mode = "Explore and place"; default solo = "Mask gradients".
const [feedbackMode, setFeedbackModeState] = useState<FeedbackModeUI>('explore-and-place');
const [soloMode, setSoloMode] = useState<SoloMode>('mask-gradients');
const [exploring, setExploring] = useState(false);
const [learningPaused, setLearningPaused] = useState(false);
// Explore-and-place scratchpad session state (workstream B; rl-feedback §2.2).
const [picking, setPicking] = useState(false);
const [anchorCount, setAnchorCount] = useState(0);
const [undoDepth, setUndoDepth] = useState(0);
const [learningRate, setLearningRate] = useState(0.00001);
const [decay, setDecay] = useState(0.97);
const [spreadLevel, setSpreadLevel] = useState(0.6);
// Active output MODE (TOP dock selector) — default Particle System. The dock
// backend + audio backend derive from this.
const [outputMode, setOutputModeState] = useState<OutputMode>(DEFAULT_OUTPUT_MODE);
const outputBackend: BackendId = outputModeDescriptor(outputMode).backend;
// Per-backend transport settings (backends-spec §2.3/§2.4). Persisted via the
// named-preset system; these are the live working values.
const [midiOutputId, setMidiOutputId] = useState<string | null>(null);
const [midiCcCount, setMidiCcCount] = useState(8);
const [oscUrl, setOscUrl] = useState('ws://localhost:8765');
const [oscSendRaw, setOscSendRaw] = useState(false);
// Feedback markers plotted on the 2D map (both polarities; session-scoped).
const [markers, setMarkers] = useState<FeedbackMarker[]>([]);
const [volume, setVolume] = useState(0.8);
const [bpm, setBpm] = useState(120);
const [audioStarted, setAudioStarted] = useState(false);
const [follow, setFollow] = useState(false);
const [split, setSplit] = useState(() => {
const v = parseFloat(localStorage.getItem('mf-composite-split') ?? '');
return Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.5;
});
useEffect(() => {
localStorage.setItem('mf-composite-split', String(split));
}, [split]);
const [stripPinned, setStripPinned] = useState(true);
const [firstSession, setFirstSession] = useState(true);
const [pins, setPins] = useState<Pin[]>([]);
// The framework-neutral learning-engine controller (workstream B). Owns BOTH
// feedback modes' BEHAVIOUR on the existing engine primitives. The dock owns
// the mode/solo SELECTOR UI; this controller implements what those selectors
// mean. One instance per engine, created once the engine resolves.
const controllerRef = useRef<FeedbackController | null>(null);
if (engine && !controllerRef.current) {
controllerRef.current = new FeedbackController(engine, {
seed: 0xfeedbacc,
spread: 0.6,
});
}
// Pull the controller's observable state into React after any action.
const syncController = () => {
const c = controllerRef.current;
if (!c) return;
const s = c.getState();
setExploring(s.exploring);
setLearningPaused(s.exploring); // Mode-2 pauses learning while scratchpad-ing
setPicking(s.picking);
setAnchorCount(s.anchorCount);
setUndoDepth(s.undoDepth);
};
const setFeedbackMode = (m: FeedbackModeUI) => {
setFeedbackModeState(m);
controllerRef.current?.setMode(m as ProtoFeedbackMode);
syncController();
};
// Push the active UI feedback mode to the controller on mount + on change.
useEffect(() => {
controllerRef.current?.setMode(feedbackMode as ProtoFeedbackMode);
syncController();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [engine, feedbackMode]);
// Push the selected solo-mode + the arm mask into the controller whenever they
// change (dock-spec §1.2). The controller RESPECTS the arm mask at the example
// level in BOTH modes and forwards it to engine.feedback.setFocus.
// TODO(rl-feedback-design §3): soloMode (mask-gradients / zero-loss /
// dont-care) selects HOW the mask is applied during training; the C API only
// exposes set_focus today, so the controller approximates it at the example
// level — the true gradient column-freeze (`train_masked`) is the C++ step.
useEffect(() => {
controllerRef.current?.setSoloMode(soloMode);
controllerRef.current?.setArmMask(buildArmMask(params));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [engine, params, soloMode]);
// Keep the audio backend pointed at the current mode (audio itself is gated
// behind a user gesture — see startAudio below).
useEffect(() => {
if (engine) engine.audio.setBackend(modeEngineId(modeId) as Parameters<typeof engine.audio.setBackend>[0]);
}, [engine, modeId]);
// reset transient state on mode switch
useEffect(() => {
setParams(mode.params.map((p) => ({ ...p })));
setPos([0.5, 0.5]);
setExamples(0);
setLoss([]);
setSnapshots([]);
setSeed(0.4);
setFollow(false);
setPins([]);
setMarkers([]);
setActive('learn');
setDepth('peek');
if (engine) engine.setInput(0.5, 0.5);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modeId]);
// Select the active output MODE (TOP dock selector). For the audio (synth)
// mode we keep the audio backend pointed at the current instrument mode (the
// existing effect handles engine.audio.setBackend). The BackendManager (via
// useBackendManager, below) reacts to the derived BackendId: it switches the
// active output backend, gates synth audio (mutes on non-synth modes), and
// drives the real MIDI / OSC transports. Particle reads the spine in its own
// rAF loop; the Editor serial protocol remains its own panel.
const setOutputMode = (m: OutputMode) => setOutputModeState(m);
// Drive a pad/joystick/manifold move through the real engine, then mirror the
// raw position into React state for readouts.
const onMove = (x: number, y: number) => {
engine?.setInput(x, y);
setPos([x, y]);
};
// PICK-LOCATION: when "place" is pending, the next manifold pointer-down picks
// the anchor location → commit the positive anchor there (rl-feedback §2.2 §3).
// The Manifold calls this on pointer-down while `picking` is true; it moves the
// scratchpad input there, captures the output, and stores the anchor.
const onPickLocation = (x: number, y: number) => {
const c = controllerRef.current;
if (!c || !c.isPicking()) return;
c.placeCommit(x, y);
setPos([x, y]);
pushSnap('anchor');
pushMarker([x, y], 'positive');
syncController();
setRev((r) => r + 1);
};
// Output backend transport (backends-spec). The manager consumes the engine
// spine and forwards routed outputs to the active backend; switching Mode
// tears down the old backend, starts the new one, and gates synth audio
// (mute on non-synth modes). MIDI/OSC config + names ride the shared params.
const { status: backendStatus, midiPorts, refreshMidiPorts } = useBackendManager(
engine,
outputBackend,
modeId,
params,
{ outputId: midiOutputId, ccCount: midiCcCount },
{ url: oscUrl, sendRaw: oscSendRaw },
);
// values come from the REAL engine output, shaped per-param. Recomputed when
// the engine version bumps (new inference / weights) or params change.
const values = useMemo(
() => shapeValues(params, engine?.getOutputs() ?? null),
// version drives re-read of the live (reused) output buffer.
// eslint-disable-next-line react-hooks/exhaustive-deps
[params, version, engine],
);
const gradient = useMemo(() => seededGradient(rev), [rev]);
const pushSnap = (tag: string) =>
setSnapshots((s) => [...s, { id: ++SNAP_ID, tag, noise: noiseCap, seed }].slice(-50));
/** Plot a feedback marker at the input location it was given (session-scoped). */
const pushMarker = (at: [number, number], polarity: 'positive' | 'negative') =>
setMarkers((m) => [...m, { x: at[0], y: at[1], polarity }].slice(-200));
// -------------------------------------------------------------------
// Verdict actions — routed per active feedback mode (rl-feedback-design §0).
//
// Explore & place (Mode 2, default): thumbs-DOWN = enter/cancel explore;
// thumbs-UP = place (when exploring) / commit. The scratchpad is never
// trained — it only generates candidate sounds to audition.
// Geometric dislike (Mode 1): thumbs-DOWN = dislike (push away);
// thumbs-UP = like + train.
//
// VerdictCluster reads `feedbackMode` from ctx and relabels itself; the same
// onCommit / onPerturb handlers below dispatch on the active mode.
// -------------------------------------------------------------------
/** thumbs-UP. */
const commit = () => {
const c = controllerRef.current;
setFirstSession(false);
setBusy(true);
if (feedbackMode === 'explore-and-place') {
if (c?.getState().exploring) {
// Place the current candidate → next manifold tap chooses the location.
pushSnap('place');
c.place();
} else {
// Not exploring: a plain positive reinforcement of the current mapping.
pushSnap('commit +');
c?.like(pos, engine?.getOutputs() ?? new Float32Array(0));
pushMarker(pos, 'positive');
}
} else {
// Geometric dislike: thumbs-up = like + train.
pushSnap('like +');
c?.like(pos, engine?.getOutputs() ?? new Float32Array(0));
pushMarker(pos, 'positive');
}
syncController();
setNoiseCap((n) => Math.max(0.02, n * 0.7));
setHealth((h) => Math.min(1, h + 0.08));
setRev((r) => r + 1);
const l = engine?.evalLoss();
setLoss((prev) =>
[...prev, Number.isFinite(l) ? (l as number) : prev.length ? prev[prev.length - 1] : 0.5].slice(-120),
);
setBusy(false);
};
/** thumbs-DOWN. */
const perturb = () => {
const c = controllerRef.current;
setFirstSession(false);
if (feedbackMode === 'explore-and-place') {
// Enter the scratchpad (or, if already exploring, cancel back to the real
// net). NEVER a dislike — Mode 2 is positive-only.
if (c?.getState().exploring) {
pushSnap('cancel explore');
c.cancel();
} else {
pushSnap('explore');
c?.enterExplore();
}
} else {
// Geometric dislike: push the current mapping away from this sound.
pushSnap('dislike ');
c?.dislike(pos, engine?.getOutputs() ?? new Float32Array(0), noiseCap, spread ? 1 : 0.6);
pushMarker(pos, 'negative');
setSeed((s) => s + (Math.random() - 0.5) * (noiseCap * 4 + 0.3));
setNoiseCap((n) => Math.min(0.5, n + 0.06));
setHealth((h) => Math.max(0.1, h - 0.06));
}
syncController();
setRev((r) => r + 1);
};
/** Long-press perturb / explicit re-roll. */
const reroll = () => {
const c = controllerRef.current;
setFirstSession(false);
pushSnap('re-roll');
if (feedbackMode === 'explore-and-place' && c?.getState().exploring) {
// Re-roll the scratchpad net (undoable) without leaving the session.
c.reroll();
} else {
// Outside a scratchpad session a re-roll randomises the real net directly.
engine?.randomise(spread ? 1 : 0.6);
}
syncController();
setSeed(Math.random() * 6);
setNoiseCap(0.4);
setHealth(0.5);
setRev((r) => r + 1);
};
/**
* Undo. While exploring (Mode 2) this pops the scratchpad undo ring (reroll /
* nudge). Otherwise it falls back to the UI snapshot stack (visual A/B seed).
*/
const undo = () => {
const c = controllerRef.current;
if (feedbackMode === 'explore-and-place' && c?.getState().exploring) {
c.undo();
syncController();
setRev((r) => r + 1);
return;
}
setSnapshots((s) => {
if (!s.length) return s;
const last = s[s.length - 1];
setSeed(last.seed);
setNoiseCap(last.noise);
setRev((r) => r + 1);
return s.slice(0, -1);
});
};
// ---- Explore-and-place scratchpad ops surfaced to the dock + cluster ----
const onExplore = () => {
controllerRef.current?.enterExplore();
syncController();
setRev((r) => r + 1);
};
const onScratchReroll = () => {
controllerRef.current?.reroll();
syncController();
setRev((r) => r + 1);
};
const onScratchNudge = () => {
controllerRef.current?.nudge();
syncController();
setRev((r) => r + 1);
};
const onScratchUndo = () => {
controllerRef.current?.undo();
syncController();
setRev((r) => r + 1);
};
const onPlace = () => {
controllerRef.current?.place();
syncController();
};
const onFinalise = () => {
setBusy(true);
controllerRef.current?.finalise();
syncController();
setRev((r) => r + 1);
setBusy(false);
};
const onCancelExplore = () => {
controllerRef.current?.cancel();
syncController();
setRev((r) => r + 1);
};
const train = () => {
setBusy(true);
const l = engine?.train();
engine?.process();
setLoss((p) =>
[...p, Number.isFinite(l) ? (l as number) : p.length ? p[p.length - 1] * 0.82 : 0.5].slice(-120),
);
setBusy(false);
};
const addExample = () => {
if (!addingExample) {
setAddingExample(true);
return;
}
setAddingExample(false);
// Snapshot the current input → current (shaped) output as a training example.
engine?.addExample([pos[0], pos[1]], Array.from(values));
setExamples((e) => e + 1);
pushSnap('example');
train();
};
const setParam = (i: number, patch: Partial<MFParam>) =>
setParams((ps) => ps.map((p, j) => (j === i ? { ...p, ...patch } : p)));
const cycleStatus = (i: number) =>
setParams((ps) =>
ps.map((p, j) =>
j === i
? { ...p, status: ({ off: 'fixed', fixed: 'live', live: 'off' } as const)[p.status] }
: p,
),
);
const toggleAB = () => {
if (ab === 'B') {
aRef.current = { seed };
setAB('A');
} else {
if (aRef.current) setSeed(aRef.current.seed);
setAB('B');
}
};
// keyboard accelerators
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
const map: Record<string, DrawerKey> = {
'1': 'learn',
'2': 'inputs',
'3': 'route',
'4': 'settings',
'5': 'help',
};
if (map[e.key]) {
setActive((a) => (a === map[e.key] ? null : map[e.key]));
setDepth('peek');
} else if (e.key === '\\') setDepth((d) => (d === 'full' ? 'peek' : 'full'));
else if (focus === 'composite' && e.key === '[') {
e.preventDefault();
setSplit((s) => Math.max(0, s - 0.04));
} else if (focus === 'composite' && e.key === ']') {
e.preventDefault();
setSplit((s) => Math.min(1, s + 0.04));
} else if (focus === 'composite' && (e.key === '=' || e.key === '0')) {
e.preventDefault();
setSplit(0.5);
} else if (e.key === ' ' || e.key === 'ArrowUp') {
e.preventDefault();
commit();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
perturb();
} else if (e.key.toLowerCase() === 'z') undo();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
const onToggleAudio = () => {
if (!engine) return;
if (engine.audio.isStarted) {
void engine.audio.stop();
setAudioStarted(false);
} else {
void engine.audio.start().then(() => setAudioStarted(true));
}
};
const ctx: ConsoleCtx = {
modes: MF_MODES,
modeId,
setModeId,
mode,
axes,
setAxis: (k, v) => setAxes((s) => ({ ...s, [k]: v })),
preset,
setPreset,
offsetActive: preset !== 'Sculpt',
datasetCount: examples,
loss,
busy,
addingExample,
onAddExample: addExample,
onTrain: train,
onClear: () => {
engine?.clearExamples();
setExamples(0);
setLoss([]);
setMarkers([]);
},
snapshots,
onJump: (id) => {
const s = snapshots.find((x) => x.id === id);
if (s) {
setSeed(s.seed);
setNoiseCap(s.noise);
setRev((r) => r + 1);
}
},
params,
cycleStatus,
setParam,
outputBackend,
setOutputBackend: (b) => {
// Map a backend id back onto the active Mode (the Mode is the source of
// truth; the Outputs drawer drives it via setOutputMode).
const m = OUTPUT_MODES.find((om) => om.backend === b);
if (m) setOutputMode(m.id);
},
outputMode,
setOutputMode,
// ---- output backend transport ----
backendStatus,
midiPorts,
refreshMidiPorts,
midiOutputId,
setMidiOutputId,
midiCcCount,
setMidiCcCount,
oscUrl,
setOscUrl,
oscSendRaw,
setOscSendRaw,
setParams: (next: MFParam[]) => setParams(next),
markers,
health,
gradient: gradient.norms,
gradientStatus: gradient.status,
weightsRevision: rev,
spread,
setSpread,
tame,
setTame,
noiseCap,
setNoiseCap,
// learning-behaviour
feedbackMode,
setFeedbackMode,
soloMode,
setSoloMode,
exploring,
learningPaused,
armedCount: params.filter((p) => p.armed).length,
clearArmed: () => setParams((ps) => ps.map((p) => (p.armed ? { ...p, armed: false } : p))),
learningRate,
setLearningRate,
decay,
setDecay,
spreadLevel,
setSpreadLevel,
// synth
audioStarted,
onToggleAudio,
volume,
setVolume,
bpm,
setBpm,
// explore-and-place scratchpad session (workstream B)
picking,
anchorCount,
undoDepth,
onExplore,
onScratchReroll,
onScratchNudge,
onPlace,
onScratchUndo,
onFinalise,
onCancelExplore,
};
// Resolve the effective input-map shape from Settings + the mode's declared input.
const inputMapVariant = resolveInputMap(settings.inputMap, mode.input);
const healthColor =
health > 0.66 ? 'rgba(107,194,107,' : health > 0.33 ? 'rgba(245,196,94,' : 'rgba(255,68,102,';
const addPin = (p: [number, number]) =>
setPins((ps) => [...ps, { x: p[0], y: p[1], color: 'rgba(255,106,0,0.16)' }]);
return (
<div
style={{
position: 'absolute',
inset: 0,
background: 'var(--bg)',
overflow: 'hidden',
fontFamily: 'var(--font-mono)',
}}
>
<style>{`@keyframes mfDrawerIn{from{transform:translateX(16px)}to{transform:translateX(0)}}`}</style>
{/* ambient health glow at the screen edge */}
<div
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 25,
boxShadow: `inset 0 0 120px ${healthColor}${0.05 + (1 - health) * 0.12})`,
transition: 'box-shadow var(--dur-slow) var(--ease-console)',
}}
/>
{/* stage = manifold area (left of dock) */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 48, bottom: 0 }}>
{focus === 'in' && (stripPinned || mode.cls !== 'Synth') && (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 30 }}>
<ReadoutStrip
params={params}
values={values}
onChange={setParam}
pinned={stripPinned}
onTogglePin={() => setStripPinned((p) => !p)}
/>
</div>
)}
<div
style={{
position: 'absolute',
top: focus === 'in' && stripPinned ? 76 : 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{focus === 'composite' ? (
<CompositeStage
split={split}
onSplit={setSplit}
mode={mode}
pos={pos}
onMove={onMove}
noiseCap={noiseCap}
pins={pins}
markers={markers}
variant={inputMapVariant}
follow={follow}
onLongPress={addPin}
params={params}
values={values}
onChange={setParam}
/>
) : focus === 'split' ? (
<SplitStage
pos={pos}
onMove={onMove}
noiseCap={noiseCap}
pins={pins}
markers={markers}
variant={inputMapVariant}
follow={follow}
onLongPress={addPin}
params={params}
values={values}
onChange={setParam}
/>
) : focus === 'out' ? (
<>
<OutputStage params={params} values={values} onChange={setParam} />
<InputMini
mode={mode}
pos={pos}
onMove={onMove}
noiseCap={noiseCap}
corner="bottom-left"
variant={inputMapVariant}
/>
</>
) : (
<Manifold
pos={pos}
onMove={onMove}
noiseCap={noiseCap}
pins={pins}
markers={markers}
variant={inputMapVariant}
frozen={false}
follow={follow}
onLongPress={addPin}
picking={picking}
onPickLocation={onPickLocation}
/>
)}
{/* corner overlay */}
<div
style={{
position: 'absolute',
top: 12,
left: 14,
zIndex: 20,
pointerEvents: 'none',
}}
>
<strong style={{ color: 'var(--accent)', fontSize: 'var(--fs-md)' }}>MEMLNaut</strong>
</div>
<VerdictCluster
onPerturb={perturb}
onUndo={undo}
onCommit={commit}
onReroll={reroll}
canUndo={feedbackMode === 'explore-and-place' && exploring ? undoDepth > 0 : snapshots.length > 0}
ab={ab}
onToggleAB={toggleAB}
onHoldA={setHoldingA}
firstSession={firstSession}
feedbackMode={feedbackMode}
exploring={exploring}
picking={picking}
/>
{/* Exploring-scratchpad banner (workstream B; rl-feedback §2.2 §7). */}
{exploring && (
<div
style={{
position: 'absolute',
top: 12,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 30,
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 14px',
background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
border: '1px solid var(--accent-2)',
borderRadius: 'var(--r-pill)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
color: 'var(--accent-2)',
}}
>
<span style={{ fontWeight: 600 }}>
{picking ? 'tap the manifold to place' : 'exploring (scratchpad)'}
</span>
<span style={{ color: 'var(--fg-mute)' }}>
{anchorCount} anchor{anchorCount === 1 ? '' : 's'} placed · undo {undoDepth}
</span>
<button
type="button"
onClick={onScratchNudge}
title="Nudge — small bounded weight perturbation (undoable)"
style={pillBtn('var(--fg-mute)')}
>
nudge
</button>
<button
type="button"
onClick={onScratchReroll}
title="Re-roll the scratchpad net (undoable)"
style={pillBtn('var(--fg-mute)')}
>
re-roll
</button>
<button
type="button"
onClick={onFinalise}
title="Done — restore the real net + warm-start to interpolate all anchors"
style={pillBtn('var(--accent)')}
disabled={anchorCount === 0}
>
done ({anchorCount})
</button>
<button
type="button"
onClick={onCancelExplore}
title="Cancel — discard scratchpad + anchors, restore the real net"
style={pillBtn('var(--danger)')}
>
cancel
</button>
</div>
)}
{/* Global PICK-LOCATION capture overlay: works in any focus (composite/
split stages don't expose picking). The directly-rendered Manifold
(focus==='in') also handles picks + draws the reticle; this overlay
guarantees the place→pick loop is reachable everywhere. */}
{picking && focus !== 'in' && (
<div
onPointerDown={(e) => {
const r = e.currentTarget.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
const y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
onPickLocation(x, y);
}}
style={{
position: 'absolute',
inset: 0,
zIndex: 35,
cursor: 'cell',
background: 'rgba(0,204,255,0.04)',
}}
/>
)}
</div>
</div>
<Dock ctx={ctx} active={active} setActive={setActive} depth={depth} setDepth={setDepth} />
</div>
);
}