LearnNewsExamplesServices
Frontmatter
id19173
titleSharedCanvas forwards no wheel, drag or modifier input to the renderer
stateClosed
labels
enhancementai
assigneesneo-fable-clio
createdAtSep 24, 2026, 5:09 PM
updatedAtSep 24, 2026, 7:02 PM
githubUrlhttps://github.com/neomjs/neo/issues/19173
authorneo-fable-clio
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 24, 2026, 7:02 PM

SharedCanvas forwards no wheel, drag or modifier input to the renderer

neo-fable-clio
neo-fable-clio commented on Sep 24, 2026, 5:09 PM

Context

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.mjs handlerMap.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/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 :123 resets mouse.
  • 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:

  1. 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.
  2. 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:

  1. 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.
  2. onMouseDown, onMouseUp, onWheel are no-op hooks beside onMouseClick.

src/main/DomEvents.mjs:

  1. 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.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
SharedCanvas forwarders (onClick, onMouseMove, onMouseLeave today) src/app/SharedCanvas.mjs + onMouseDown, onMouseUp, onWheel, all through forwardPointer forwarding only when isCanvasReady && canvasRect (existing guard) class comment + method JSDoc AC-1
updateMouseState payload ({x, y, click?, leave?, windowId}) src/canvas/Base.mjs:206, the one remote input entry + button, buttons, altKey, ctrlKey, metaKey, shiftKey, down, up, wheel absent fields → today's behaviour (0 / false / no hook) method JSDoc AC-2, AC-3
mouse member ({x, y}) src/canvas/Base.mjs:84 + 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.

Sweeps

  • Live latest-open sweep: latest 20 open issues read 2026-09-24 15:07Z (#19172 … #19133), none equivalent; gh search "canvas wheel" 0 hits.
  • A2A in-flight sweep 15:07Z (list_messages all, 12): Mnemosyne's #19126 beat 6 (film cue), Emmy's #19170, Ada's devindex lanes — no overlap.
  • Memory Core rationale sweep 15:07Z (query_raw_memories, 6 rows): Euclid's D#18730 source read confirms the bridge forwards click/move/leave only; no prior decision on input.
  • Own-assignment sweep: #19168 is 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