Neo.app.SharedCanvas bridges the DOM to a canvas-worker renderer. Its interaction bridge forwards three facts: mousemove (onMouseMove, src/app/SharedCanvas.mjs:234 at 2d993a26cb), click (onClick, :198) and mouseleave (onMouseLeave, :225), each as renderer.updateMouseState({x, y, click?, leave?, windowId}) with x/y relative to the cached canvasRect. The host owns no DOM listeners itself; its consumers subscribe and delegate (apps/portal/view/services/Container.mjs:40 subscribes mousemove / mouseleave on the container and calls the canvas item's forwarders). Neo.canvas.Base#updateMouseState (src/canvas/Base.mjs:206) stores mouse.x/y and calls onMouseClick. Nothing else reaches the worker: no wheel, no button state, no modifier keys.
Operator direction (2026-09-24, D#19151): the graph observatory "would need zoom and rotate capabilities". An orbit camera is a drag (button down → move deltas → up) and zoom is the wheel (ctrl+wheel is the trackpad pinch on macOS); neither can be built on the host today, which is why the D#19151 witness rotates its scene on a timer. Any interactive renderer has the same gap (a scrubbable timeline, a pannable services map).
The complementary bridge stays as it is: Neo.app.header.Canvas measures sibling DOM rects and pushes them plus hoverId / activeId to its renderer (the canvas flows around real buttons), and the timeline host anchors nodes to avatar -target elements. This ticket adds the pointer-on-canvas half; DOM-anchored geometry and state bridging remain the consumer's pattern.
The Problem
No forwarder exists for mousedown, mouseup or wheel, although the main thread already delivers them (src/main/DomEvents.mjs: mousedown :16, mouseup :19, wheel :21, with altKey, button, buttons, ctrlKey, metaKey, shiftKey on every mouse event :329–335 and deltaX/deltaY/deltaZ on wheel :844–851).
The main thread forwards wheel only for a fixed class list (globalWheelTargets, src/main/DomEvents.mjs:35); a local wheel listener on any other node (wheel: {local: true}, the Helix precedent src/component/Helix.mjs:293) routes through the same onWheel (src/manager/DomEvent.mjshandlerMap.wheel) and is dropped when its node carries none of the listed classes. A canvas node carries none.
The renderer receives absolute positions only, so a drag needs deltas the base does not compute; every subclass would keep its own last-position state.
updateMouseState is the one remote input entry (remote.app, src/canvas/Base.mjs:41). New events must widen the payload, not add remote methods per event.
The Architectural Reality
src/app/SharedCanvas.mjs (App Worker): onClick :198, onMouseLeave :225, onMouseMove :234, canvasRect:55, updateSize :272; consumers subscribe DOM listeners and delegate.
src/main/DomEvents.mjs (main thread): onWheel :822 resolves its target through testPathInclusion(event, globalWheelTargets) and sends nothing without a match; a local listener fires with the node as event.currentTarget (:859 already distinguishes it from the body listener).
In-tree consumers of the input surface: apps/portal/canvas/HomeCanvas.mjs:738 and src/canvas/Header.mjs:961 override onMouseClick; the other renderers read mouse.x/y in their loops. All keep working: the new fields are additive and click keeps its shape. The four components with local wheel listeners (DateSelector, Gallery, Helix, Circle) are in the global list and match it first.
Window groups (#19125): windowId stays on every payload.
The Fix
src/app/SharedCanvas.mjs:
One protected forwardPointer(data, extra) builds the report — windowId, canvas-relative x/y, then button, buttons and the four modifiers when the event carries them — under the existing isCanvasReady && canvasRect guard.
onClick ({click: true}), onMouseMove (no extra), new onMouseDown ({down: true}), onMouseUp ({up: true}) and onWheel ({wheel: {deltaMode, deltaX, deltaY, deltaZ}}) call it. The class comment names the set and the subscription pattern (wheel: {fn, local: true, passive: false}).
src/canvas/Base.mjs:
mouse gains dx, dy, buttons and the four modifiers (an idleMouseState() helper is the one definition of the idle shape, used by the field, clearGraph and a leave). updateMouseState computes dx/dy against the previous on-canvas position, stores the facts the report carries, and dispatches down → onMouseDown, up → onMouseUp, wheel → onWheel, click → onMouseClick; a leave resets to idle and reaches no hook.
onMouseDown, onMouseUp, onWheel are no-op hooks beside onMouseClick.
src/main/DomEvents.mjs:
onWheel falls back to the local target (getLocalWheelTarget: {cls: null, node: event.currentTarget} when the listener is not the body's), so a node a component listens on locally receives deltas; the global list, its buffer and its keep-event behaviour are untouched.
Gestures (orbit, pan, pinch-zoom) stay in subclasses: the base forwards facts and deltas and owns no gesture state machine.
+ dx, dy, buttons, the four modifiers; idle on leave and clearGraph
idleMouseState()
member JSDoc
AC-2
onMouseDown, onMouseUp, onWheel (new hooks)
beside onMouseClick :156
no-op in the base, called with the forwarded payload
n/a
JSDoc
AC-3
DomEvents#onWheel target resolution (:822)
src/main/DomEvents.mjs
global list first, then the local listener's node
no local listener and no listed class → nothing sent, as today
method JSDoc
AC-4 (the observatory's wheel), unit arm if the main-thread harness admits one
Decision Record impact
none. D#19151 is the witness, not the authority: the change matches no ideation-sandbox-workflow.md §5.2 high-blast trigger (three engine files, additive, single-PR).
Acceptance Criteria
AC-1 — SharedCanvas forwards mousedown, mouseup and wheel (deltas and modifiers) and adds buttons + modifiers to mousemove, all through updateMouseState, x/y relative to canvasRect, windowId on every payload; unit spec on the host with a recording renderer stub.
AC-2 — Neo.canvas.Base#updateMouseState stores buttons and the modifiers, computes dx/dy per move, resets to idle on leave, keeps click → onMouseClick; clearGraph resets the shape; unit spec.
AC-3 — down, up and wheel payloads reach onMouseDown, onMouseUp, onWheel; a subclass without overrides is unaffected (Header and the Portal canvases are not in the diff); unit spec, red-first per arm.
AC-4 (outside CI, L3) — the D#19151 observatory witness orbits on drag and zooms on wheel through the base's hooks, its container subscribing wheel locally; receipt on D#19151.
Out of Scope
Touch and pointer events, pinch gestures, pointer capture for drags that leave the canvas — a v2 when a consumer needs them.
Keyboard input.
Gesture helpers (orbit, pan) in the base.
Changing the global wheel target list or its throttle buffer.
Gold Standards Rejected
One remote method per event. Multiplies the remote surface and the window-group routing; one payload entry keeps updateMouseState the single input door.
A gesture recognizer in the base. Every renderer maps input differently (orbit vs pan vs scrub); the base forwards facts and deltas.
Adding a canvas class to globalWheelTargets. Every canvas would then post every wheel tick to the app worker, subscribed or not — the cost the list exists to avoid (wheel fires very often, and most of the time the user just wants to scroll); a local listener is opt-in per node.
Registering global wheel targets at runtime (a registerWheelTargets remote beside registerPreventDefaultTargets, operator steer 2026-09-24). The right door if a global target is ever wanted; a canvas wants per-node opt-in, which the local listener already is.
Pointer Events instead of mouse events. The main thread's DomEvents ships mouse and wheel today; pointer/touch is a v2 with its own main-thread work.
Related
D#19151 — H3 observatory, the witness; the operator's zoom + rotate direction.
#19168 / PR #19169 — the context-type seam, the sibling engine leaf.
#19125 — one canvas worker per window group (windowId on every payload).
#19163 / PR #19165 — loader roots for engine renderers.
Context
Neo.app.SharedCanvasbridges the DOM to a canvas-worker renderer. Its interaction bridge forwards three facts:mousemove(onMouseMove,src/app/SharedCanvas.mjs:234at2d993a26cb),click(onClick,:198) andmouseleave(onMouseLeave,:225), each asrenderer.updateMouseState({x, y, click?, leave?, windowId})withx/yrelative to the cachedcanvasRect. The host owns no DOM listeners itself; its consumers subscribe and delegate (apps/portal/view/services/Container.mjs:40subscribesmousemove/mouseleaveon the container and calls the canvas item's forwarders).Neo.canvas.Base#updateMouseState(src/canvas/Base.mjs:206) storesmouse.x/yand callsonMouseClick. Nothing else reaches the worker: no wheel, no button state, no modifier keys.Operator direction (2026-09-24, D#19151): the graph observatory "would need zoom and rotate capabilities". An orbit camera is a drag (button down → move deltas → up) and zoom is the wheel (ctrl+wheel is the trackpad pinch on macOS); neither can be built on the host today, which is why the D#19151 witness rotates its scene on a timer. Any interactive renderer has the same gap (a scrubbable timeline, a pannable services map).
The complementary bridge stays as it is:
Neo.app.header.Canvasmeasures sibling DOM rects and pushes them plushoverId/activeIdto its renderer (the canvas flows around real buttons), and the timeline host anchors nodes to avatar-targetelements. This ticket adds the pointer-on-canvas half; DOM-anchored geometry and state bridging remain the consumer's pattern.The Problem
mousedown,mouseuporwheel, although the main thread already delivers them (src/main/DomEvents.mjs:mousedown :16,mouseup :19,wheel :21, withaltKey, button, buttons, ctrlKey, metaKey, shiftKeyon every mouse event:329–335anddeltaX/deltaY/deltaZon wheel:844–851).wheelonly for a fixed class list (globalWheelTargets,src/main/DomEvents.mjs:35); a localwheellistener on any other node (wheel: {local: true}, the Helix precedentsrc/component/Helix.mjs:293) routes through the sameonWheel(src/manager/DomEvent.mjshandlerMap.wheel) and is dropped when its node carries none of the listed classes. A canvas node carries none.updateMouseStateis the one remote input entry (remote.app,src/canvas/Base.mjs:41). New events must widen the payload, not add remote methods per event.The Architectural Reality
src/app/SharedCanvas.mjs(App Worker):onClick :198,onMouseLeave :225,onMouseMove :234,canvasRect:55,updateSize :272; consumers subscribe DOM listeners and delegate.src/canvas/Base.mjs(Canvas Worker):mouse = {x: -1000, y: -1000}:84,onMouseClick(data) {}:156,updateMouseState :206–220(leave → off-screen; x/y; click →onMouseClick),clearGraph :123resetsmouse.src/main/DomEvents.mjs(main thread):onWheel :822resolves its target throughtestPathInclusion(event, globalWheelTargets)and sends nothing without a match; a local listener fires with the node asevent.currentTarget(:859already distinguishes it from the body listener).apps/portal/canvas/HomeCanvas.mjs:738andsrc/canvas/Header.mjs:961overrideonMouseClick; the other renderers readmouse.x/yin their loops. All keep working: the new fields are additive andclickkeeps its shape. The four components with local wheel listeners (DateSelector,Gallery,Helix,Circle) are in the global list and match it first.windowIdstays on every payload.The Fix
src/app/SharedCanvas.mjs:forwardPointer(data, extra)builds the report —windowId, canvas-relativex/y, thenbutton,buttonsand the four modifiers when the event carries them — under the existingisCanvasReady && canvasRectguard.onClick({click: true}),onMouseMove(no extra), newonMouseDown({down: true}),onMouseUp({up: true}) andonWheel({wheel: {deltaMode, deltaX, deltaY, deltaZ}}) call it. The class comment names the set and the subscription pattern (wheel: {fn, local: true, passive: false}).src/canvas/Base.mjs:mousegainsdx,dy,buttonsand the four modifiers (anidleMouseState()helper is the one definition of the idle shape, used by the field,clearGraphand a leave).updateMouseStatecomputesdx/dyagainst the previous on-canvas position, stores the facts the report carries, and dispatchesdown→onMouseDown,up→onMouseUp,wheel→onWheel,click→onMouseClick; a leave resets to idle and reaches no hook.onMouseDown,onMouseUp,onWheelare no-op hooks besideonMouseClick.src/main/DomEvents.mjs:onWheelfalls back to the local target (getLocalWheelTarget:{cls: null, node: event.currentTarget}when the listener is not the body's), so a node a component listens on locally receives deltas; the global list, its buffer and its keep-event behaviour are untouched.Gestures (orbit, pan, pinch-zoom) stay in subclasses: the base forwards facts and deltas and owns no gesture state machine.
Contract Ledger
SharedCanvasforwarders (onClick,onMouseMove,onMouseLeavetoday)src/app/SharedCanvas.mjsonMouseDown,onMouseUp,onWheel, all throughforwardPointerisCanvasReady && canvasRect(existing guard)updateMouseStatepayload ({x, y, click?, leave?, windowId})src/canvas/Base.mjs:206, the one remote input entrybutton,buttons,altKey,ctrlKey,metaKey,shiftKey,down,up,wheelmousemember ({x, y})src/canvas/Base.mjs:84dx,dy,buttons, the four modifiers; idle onleaveandclearGraphidleMouseState()onMouseDown,onMouseUp,onWheel(new hooks)onMouseClick :156DomEvents#onWheeltarget resolution (:822)src/main/DomEvents.mjsDecision Record impact
none. D#19151 is the witness, not the authority: the change matches noideation-sandbox-workflow.md §5.2high-blast trigger (three engine files, additive, single-PR).Acceptance Criteria
SharedCanvasforwardsmousedown,mouseupandwheel(deltas and modifiers) and addsbuttons+ modifiers tomousemove, all throughupdateMouseState,x/yrelative tocanvasRect,windowIdon every payload; unit spec on the host with a recording renderer stub.Neo.canvas.Base#updateMouseStatestoresbuttonsand the modifiers, computesdx/dyper move, resets to idle onleave, keepsclick→onMouseClick;clearGraphresets the shape; unit spec.down,upandwheelpayloads reachonMouseDown,onMouseUp,onWheel; a subclass without overrides is unaffected (Header and the Portal canvases are not in the diff); unit spec, red-first per arm.wheellocally; receipt on D#19151.Out of Scope
Gold Standards Rejected
updateMouseStatethe single input door.globalWheelTargets. Every canvas would then post every wheel tick to the app worker, subscribed or not — the cost the list exists to avoid (wheel fires very often, and most of the time the user just wants to scroll); a local listener is opt-in per node.registerWheelTargetsremote besideregisterPreventDefaultTargets, operator steer 2026-09-24). The right door if a global target is ever wanted; a canvas wants per-node opt-in, which the local listener already is.DomEventsships mouse and wheel today; pointer/touch is a v2 with its own main-thread work.Related
#19168/ PR#19169— the context-type seam, the sibling engine leaf.#19125— one canvas worker per window group (windowIdon every payload).#19163/ PR#19165— loader roots for engine renderers.Sweeps
#19172…#19133), none equivalent;gh search"canvas wheel" 0 hits.list_messagesall, 12): Mnemosyne's#19126beat 6 (film cue), Emmy's#19170, Ada's devindex lanes — no overlap.query_raw_memories, 6 rows): Euclid's D#18730 source read confirms the bridge forwards click/move/leave only; no prior decision on input.#19168is the sibling seam; none equivalent.Origin Session ID: 913e5802-0d84-43f2-ac18-5b826dd4e913 Retrieval Hint: "SharedCanvas wheel mousedown updateMouseState dx dy onWheel getLocalWheelTarget"
📜 Clio · @neo-fable-clio · Claude Fable 5.1 · Claude Code · session 913e5802-0d84-43f2-ac18-5b826dd4e913