diff --git a/MAP.md b/MAP.md index c58cad0..1743714 100644 --- a/MAP.md +++ b/MAP.md @@ -47,7 +47,8 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set. - `manifold/src/primitives/` — the 7 design primitives as typed React (Badge, Button, PillToggle, Slider, Switch, VirtualJoystick, XYPad). Five unused ones were deleted in the 2026-07 sweep (L22). - `manifold/src/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas, - rect↔circular + feedback markers; ParticleStage has interactive cursor-labelled heatmap sliders), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers` + rect↔circular + feedback markers; ParticleStage has interactive cursor-labelled heatmap sliders, an adjustable + joystick, and double-click whole-screen follow-mouse input), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers` (Learning/Inputs/Outputs/Settings/Help; Learning includes the live model-architecture inspector and expanded Outputs owns the remaining scroll height), `TrainingHealth` (real per-iteration loss curve from `nisps_ml_loss_history` + per-layer weight health from `nisps_ml_get_layer_stats`; rendered only at @@ -60,7 +61,7 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set. Switching mode reshapes the WASM net to the mode's `ml` dims (ConsoleApp P5.3; boot mode paf_synth → 4→[10,10,14]→33). - `manifold/src/dock/` — `OutputControlRow` (editable names, corner delete, cycling off/fixed/live status, mute + solo/arm + min/max/curve, plus MIDI card fields), `output-state.ts`, - `OutputsBackendConfig.tsx` (per-backend specialised Outputs panel — the sole per-backend editor; the centered add-card control lives below the rows). + `OutputsBackendConfig.tsx` (per-backend specialised Outputs panel — the sole per-backend editor; centered prepend/append add-card controls live above and below the rows). - `manifold/src/backends/` — `OutputBackend` adapter + `BackendManager` (spine consumer); `midi-backend.ts` (WebMIDI), `osc-backend.ts`+`osc-client.ts` (OSC-over-WS), `vcv-backend.ts` (VCV-over-WS), `cv-backend.ts` (`UseqCvBackend` — uSEQ CV/gate over USB Web Serial, backend id `cvgate`) + `useq-protocol.ts` (v2 wire diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md index c6c03ef..c8a684f 100644 --- a/manifold/ONBOARDING.md +++ b/manifold/ONBOARDING.md @@ -109,7 +109,7 @@ Manifold ships a single "composite" altitude. Selection is now a plain three-way |---|---|---|---| | CompositeStage | `CompositeStage.tsx` | **default / hero** | Draggable split-ratio; magnet-snaps to 0.14/0.33/0.5/0.66/0.86; collapses a side to a corner minimap at extremes. | | SandwichStage | `SandwichStage.tsx` | `sandwich===true` (wins over the others) | Three-pane layout: `Manifold` input surface left, 3D parameter-landscape centre (input → MLP heatmap grid → outputs, drag to orbit), compact `OutputStage` right. | -| ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400-particle Canvas2D port) + interactive output heatmap sliders with cursor tooltips + corner joystick. | +| ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400-particle Canvas2D port) + interactive output heatmap sliders with cursor tooltips + a larger, explicitly adjustable/repositionable joystick; double-click anywhere in the stage enters whole-screen follow-mouse mode. | `Manifold.tsx` and `OutputStage.tsx` are no longer top-level stages — they are panes composed by CompositeStage/SandwichStage. `Manifold.tsx` is the full-bleed 2D input surface (canvas trail + pins @@ -157,8 +157,9 @@ for the narrow pane. surface there rather than inventing one. The panel renders "no training run yet" when the core has no history; it never synthesises a curve. - **inputs** — enable/configure input sources (XY pad / MIDI / gamepad). - - **route** (label "Outputs") — per-output control matrix + per-backend config; at expanded depth - the output rows fill the remaining drawer height and scroll independently. + - **route** (label "Outputs") — per-output control matrix + per-backend config; centered prepend/append `+` + controls are available in both condensed and expanded views, and at expanded depth the output rows fill the + remaining drawer height and scroll independently. The orange drawer tab uses `<` to expand and `>` to condense. - **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius. - **help** — keymap pills + loop explanation. - `src/dock/` holds the output-routing internals used by the `route` drawer: @@ -168,7 +169,8 @@ for the narrow pane. - `OutputControlRow.tsx` — one output card: editable name · M(mute) · S(solo/arm) · cycling off/fixed/live status · dual-range · live value; the curve pad is a right-hand column and MIDI mode adds CC#/channel fields. The delete `×` sits on the upper-right corner. **Writes eagerly to the shared - `MFParam` store via `onChange`.** The centered `+` control follows the last visible card. + `MFParam` store via `onChange`.** The Outputs drawer has centered `+` controls that prepend before the first + visible card or append after the last. - `OutputsBackendConfig.tsx` — preset bar (save/restore/rename/delete) + transport/device config (MIDI port/templates, OSC path/range, VCV polarity); MIDI per-output fields live on `OutputControlRow`. diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx index fab81c7..e49d0ef 100644 --- a/manifold/src/console/ConsoleApp.tsx +++ b/manifold/src/console/ConsoleApp.tsx @@ -737,13 +737,11 @@ export function ConsoleApp() { setOutputCounts((counts) => ({ ...counts, [outputMode]: activeCards.length })); }; - const addOutput = () => { + const addOutput = (placement: 'prepend' | 'append' = 'append') => { const active = params.slice(0, displayOutputCount); const next = params[displayOutputCount] ?? createOutputParam(params.length); - const spares = params - .slice(displayOutputCount + (params[displayOutputCount] ? 1 : 0)) - .filter((param) => param.id !== next.id); - applyOutputCards([...active, next], spares); + const spares = params.filter((param) => param.id !== next.id && !active.some((p) => p.id === param.id)); + applyOutputCards(placement === 'prepend' ? [next, ...active] : [...active, next], spares); }; const deleteOutput = (index: number) => { diff --git a/manifold/src/console/Dock.tsx b/manifold/src/console/Dock.tsx index cbad630..720cb8f 100644 --- a/manifold/src/console/Dock.tsx +++ b/manifold/src/console/Dock.tsx @@ -31,7 +31,6 @@ import { EditorIcon, SandwichIcon, CloseIcon, - ExpandIcon, GLYPH_FALLBACK, } from './icons'; import type { IconProps } from './icons'; @@ -309,14 +308,14 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan top: '50%', transform: 'translateY(-50%)', width: 22, - height: 56, + height: 40, borderRadius: 'var(--r-2) 0 0 var(--r-2)', border: '1px solid var(--glass-line)', borderRight: 'none', background: 'var(--glass)', backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)', - color: 'var(--fg-mute)', + color: 'var(--accent)', cursor: 'pointer', display: 'flex', alignItems: 'center', @@ -324,7 +323,9 @@ export function Dock({ ctx, active, setActive, depth, setDepth, sandwich, setSan zIndex: 1, }} > - + {/* Inner scroll wrapper — holds header + content, scrolls independently. */}
( + + ); return ( <>
@@ -774,6 +798,7 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { overflow: 'auto', }} > + {addButton('prepend')} {rows.map((p) => { const i = ctx.params.indexOf(p); const labelled = { ...p, name: nameFor(p) }; @@ -789,28 +814,7 @@ function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) { /> ); })} - + {addButton('append')}
{!expanded && activeParams.length > 6 && ( +{activeParams.length - 6} more — expand to edit diff --git a/manifold/src/console/ParticleStage.tsx b/manifold/src/console/ParticleStage.tsx index 9ad4271..27f3e50 100644 --- a/manifold/src/console/ParticleStage.tsx +++ b/manifold/src/console/ParticleStage.tsx @@ -9,8 +9,9 @@ * value — the same `.heatmap-strip` the a-immersive app shows above the * flow field (NOT the Boldness/Memory/Precision macro axes, which the * deployed a-immersive never used); - * • a small circular pad in the bottom-left corner that drives the 2D input - * (engine.setInput) — the "joystick" of the immersive app. + * • a large circular pad that drives the 2D input (engine.setInput) — the + * "joystick" of the immersive app; its explicit edit handles reposition + * and resize it without making normal input gestures destructive. * * The canvas animates on its own rAF clock so particles keep flowing between * inferences; only the *field* parameters change when the MLP outputs do. The @@ -18,7 +19,7 @@ * churn React state every frame. */ import { useEffect, useRef, useState } from 'react'; -import type { PointerEvent as ReactPointerEvent } from 'react'; +import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from 'react'; import { useEngine } from '../engine'; import { VirtualJoystick } from '../primitives/VirtualJoystick'; import type { MFParam } from './model'; @@ -49,6 +50,22 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl const [hover, setHover] = useState(null); const [tooltipPos, setTooltipPos] = useState({ x: 12, y: 28 }); const [clampMarker, setClampMarker] = useState<{ i: number; value: number } | null>(null); + const [followMouse, setFollowMouse] = useState(false); + const onMoveRef = useRef(onMove); + const [padSize, setPadSize] = useState(200); + const [padPosition, setPadPosition] = useState(() => ({ + left: 24, + top: typeof window === 'undefined' ? 24 : Math.max(24, window.innerHeight - 224), + })); + const [padEdit, setPadEdit] = useState(false); + const padGesture = useRef<{ + kind: 'move' | 'resize'; + startX: number; + startY: number; + left: number; + top: number; + size: number; + } | null>(null); const clampTimer = useRef | null>(null); const drag = useRef<{ i: number; moved: boolean; startX: number; el: HTMLDivElement | null }>({ i: -1, @@ -57,6 +74,49 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl el: null, }); valuesRef.current = values; + onMoveRef.current = onMove; + + const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); + + const beginPadGesture = (e: ReactPointerEvent, kind: 'move' | 'resize') => { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.setPointerCapture?.(e.pointerId); + padGesture.current = { + kind, + startX: e.clientX, + startY: e.clientY, + left: padPosition.left, + top: padPosition.top, + size: padSize, + }; + }; + + const movePadGesture = (e: ReactPointerEvent) => { + const gesture = padGesture.current; + if (!gesture) return; + e.preventDefault(); + e.stopPropagation(); + if (gesture.kind === 'move') { + setPadPosition({ + left: clamp(gesture.left + e.clientX - gesture.startX, 8, Math.max(8, window.innerWidth - padSize - 56)), + top: clamp(gesture.top + e.clientY - gesture.startY, 8, Math.max(8, window.innerHeight - padSize - 8)), + }); + } else { + const maxSize = Math.max(140, Math.min(window.innerWidth - gesture.left - 56, window.innerHeight - gesture.top - 8, 360)); + setPadSize(clamp(gesture.size + e.clientX - gesture.startX, 140, maxSize)); + } + }; + + const endPadGesture = (e: ReactPointerEvent) => { + e.stopPropagation(); + padGesture.current = null; + }; + + const toggleFollowMouse = (e?: ReactMouseEvent) => { + e?.preventDefault(); + setFollowMouse((active) => !active); + }; const updateHover = (i: number, e: ReactPointerEvent) => { hoverRef.current = i; @@ -168,8 +228,35 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl }; }, []); + useEffect(() => { + if (!followMouse) return; + const onWinMove = (e: PointerEvent) => { + const w = window.innerWidth || 1; + const h = window.innerHeight || 1; + onMoveRef.current(clamp(e.clientX / w, 0, 1), clamp(1 - e.clientY / h, 0, 1)); + }; + const onWinKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setFollowMouse(false); + }; + window.addEventListener('pointermove', onWinMove); + window.addEventListener('keydown', onWinKey); + return () => { + window.removeEventListener('pointermove', onWinMove); + window.removeEventListener('keydown', onWinKey); + }; + }, [followMouse]); + return ( -
+
{/* Main view — the flow-field particle system */} @@ -285,9 +372,136 @@ export function ParticleStage({ pos, onMove, params, values, onChange }: Particl }} /> - {/* Bottom-left circular pad — drives the 2D input */} -
- + {followMouse && ( +
+ FOLLOW MOUSE · ESC OR DOUBLE-CLICK TO EXIT +
+ )} + + {/* Adjustable circular pad — explicit edit handles keep repositioning/resizing deliberate. */} +
+ + + {padEdit && ( + <> + + + + )}
); diff --git a/manifold/src/console/types.ts b/manifold/src/console/types.ts index 0934ae6..5c4fa79 100644 --- a/manifold/src/console/types.ts +++ b/manifold/src/console/types.ts @@ -76,7 +76,7 @@ export interface ConsoleCtx { params: MFParam[]; /** Patch one output row in the shared store (drives stage + dock in sync). */ setParam: (i: number, patch: Partial) => void; - addOutput: () => void; + addOutput: (placement?: 'prepend' | 'append') => void; deleteOutput: (i: number) => void; /** Active backend outputs currently presented by the stage + routing rows. */ displayOutputCount: number;