Skip to content

Overhaul SnapLine graph ownership and input routing - #54

Merged
tfukaza merged 10 commits into
mainfrom
snapline-overhaul
Aug 7, 2026
Merged

Overhaul SnapLine graph ownership and input routing#54
tfukaza merged 10 commits into
mainfrom
snapline-overhaul

Conversation

@tfukaza

@tfukaza tfukaza commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Summary

  • make application-owned LineRecord[] the canonical SnapLine topology through an engine-scoped controlled-graph reconciler
  • replace implicit/global graph state with stable mirror identities, explicit connector rules, diagnostics, and atomic connect/disconnect/replace/reconnect proposals
  • separate semantic state from imperative geometry painting, add geometry invalidation observers, and expose explicit resize regions
  • update the Svelte and React adapters, demos, reference docs, and migration guidance for the new contracts
  • refine SnapEngine input ownership, bubbling, pointer capture, and gesture handoff so connector, node, camera, and SnapSort interactions compose correctly

Why

The previous topology and input paths mixed engine-owned runtime state with application-owned document state. That made acceptance and rejection depend on framework timing, allowed ambiguous gesture ownership, and coupled geometry updates to framework renders. This overhaul gives each layer a single ownership contract: applications own topology, SnapLine owns runtime mirrors and geometry, frameworks own structural DOM, and SnapEngine owns input routing.

Impact

This is a pre-1.0 breaking SnapLine API change. Consumers should migrate from EdgeSync and imperative topology methods to ControlledGraph / attachControlledGraph, provide stable connector and line IDs for persistent graphs, use rules for connector policy, and render explicit ResizeRegion components where resizing is supported. The included migration notes and framework reference docs cover the API mapping.

Validation

  • npm run ci
  • npm run test:snapline-ut — 43 passed
  • npm run test:snapline — 14 passed
  • npm run test:snapline-edges — 7 passed
  • npm run test:snapline-group — 7 passed
  • npm run test:snapline-overlay — 4 passed

Repository-wide Svelte checks report existing initial-value warnings but no errors.

tfukaza and others added 10 commits July 27, 2026 21:05
Removes code with no reachable callers anywhere in core, adapters, demos,
website, tests, or docs, and collapses public surfaces that offered two ways
to do one thing (SNAPZEN: "there should only be one way to do something").

Unreachable methods: ConnectorMirror.requestDomGeometrySync,
writeAllLinesNow, findClosestConnector, findClosestConnectorAtPoint,
hoverWhileDragging, startPickUpLine, disconnectFromConnector;
LineMirror.setLineStartAtConnector/setLineEndAtConnector;
NodeMirror.setUpPosition.

Unread state: GraphMirror.reconcilerActive (its comment described an
authority gate that no longer exists), ConnectorMirror.#targetConnector and
its accessors, numIncomingLines/numOutgoingLines, the never-populated
LineChangeRequest.originalEvent, and 4 of 7 ReconciliationError codes that
nothing emits.

Dead accessors and no-op overrides: the `callbacks` setters on
ConnectorMirror and NodeMirror (adapters use updateConfig or in-place
mutation), NodeMirror.writeTransformRecursive and
GroupNodeMirror.writeTransformAndLines (pure super passthroughs — the former
carried a comment describing a re-glue it never performed; the re-glue
actually happens via the sibling scheduleLineWrites in
scheduleTransformAndLines), and RectSelectController.onCollideNode.

Collapses LineMirror's seven positioning methods to three: setLineStart,
setLineEnd, setLinePosition, endWorldX/endWorldY, and the
moveLineToConnectorTransform alias are gone in favour of
setLineStartAnchor/setLineEndAnchor/updateAnchors. cloneAnchor was defined
byte-identically in line.ts and connector.ts; connector.ts now imports it.

Removes the query.ts free functions and the package.json subpath exports map
(both gave every symbol a second import path with no consumers). Adds
GraphQuery.selection() first — getSelectedNodes was the only one of the four
with no query() equivalent, so deleting it outright would have removed the
sole public way to read the selection.

Docs updated for every removed symbol.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Domain entities and the public contract stay directly under src/; the
registry, reconciler, and global.data machinery move to src/internal/.

Three modules mixed public API with internals, so a straight directory move
would have pushed public API into internal/. They are split on that seam
first:

  types.ts            NEW — pure types, zero imports: NodeId/ConnectorId/
                      LineId, ReconciliationError, GraphBatch, GeometryWriter
                      (absorbing the 2-line geometry.ts), and the whole
                      controlled-graph contract (LineRecord, LineChangeRequest,
                      ProposedLine, LineEndpointUpdate, CanonicalGraphSnapshot,
                      ControlledGraphCallbacks/Handle).
  controlled-graph.ts NEW — attachControlledGraph, the package's headline
                      entry point, previously declared next to an internal
                      global.data accessor bag.
  internal/graph-registry.ts   the registry class + mintDomainId +
                               GraphReconcilerLike (was graph-mirror.ts)
  internal/line-reconciler.ts  the LineReconciler algorithm only
  internal/shared-data.ts      SnapLineSharedData, SourceSurfaceOwner,
                               snapData, getResizeHandles, getSourceSurfaces,
                               getGraphRegistry (was snapline-globals.ts)

Renames that ride along, both fixing names that described the wrong thing:
GraphMirror -> GraphRegistry (it is a registry *of* mirrors, and the suffix
collided with the NodeMirror/ConnectorMirror/LineMirror entity convention —
AGENTS.md already called it "the per-engine registry"), and its accessor
getGraphMirror -> getGraphRegistry. snapline-globals.ts was the only file
named after a storage location rather than a concept.

getGraphRegistry is now exported from index.ts. Four unit specs previously
deep-imported it by relative path while AGENTS.md advertised it as a package
export; they now import from the package root, so the move is invisible to
them.

Also updates the two engine-core comments in src/input.ts that tell
contributors to keep the resizeHandles/sourceSurfaces structural types in
sync with a file that no longer exists under that name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a multicast observation channel so a library outside SnapLine can keep
something (a text overlay, a badge) glued to a line or node while it moves.
This was previously impossible: LineMirror.#geometryWriter is a single field
already owned by <Line>, so a consumer calling bindGeometryWriter would not
add a watcher but silently replace the renderer; NodeMirror had no
subscription channel at all, only a single-slot #callbacks object the
adapters take over wholesale on mount.

LineMirror.onGeometryInvalidated / NodeMirror.onGeometryInvalidated are
Set-backed registrars returning an unsubscribe, matching the existing
onStateChange shape.

The signal is an INVALIDATION, not a paint hook. It fires synchronously
during input dispatch, before any frame task is queued, and carries no
geometry. Invoking it from inside writeTransform() would have silently
decided the subscriber's write phase for them — WRITE_2, with no way to opt
into a read stage or a later write stage, and nothing in the signature
saying so. Instead the subscriber schedules its own task with the existing
schedule(cb, { stage, queueId }) primitive and reads geometrySnapshot()
there. Per SNAPZEN: expose the primitives, no magic abstraction.

Consequences the docs have to state, and the unit spec pins: a line paints
at WRITE_2 with updateAnchors() inside that same task, so an overlay wanting
this frame's position schedules at WRITE_3. There is no priming call —
nothing is invalidated at subscribe time, so read geometrySnapshot()
directly for the initial position. Observer throws are isolated per
subscriber so third-party code cannot blank the graph.

Fire sites are consolidated rather than sprinkled. Line writes now funnel
through LineMirror.invalidateGeometry() (deferred) and
invalidateGeometryNow() (synchronous), each notifying before it queues;
connector/node schedulers became loops over those. On the node side the two
scheduling entry points cover it, and because scheduleTransformAndLines
already walks #transformNodeTree(), multi-select peers and group-carried
members each get their own signal — three cases NodeCallbacks.onDrag misses,
along with camera edge-pan.

Adds NodeMirror.geometrySnapshot() sourced from #authoredWidth/#authoredHeight
rather than leaving consumers to reach for the public hitBox, which is DOM
truth a ResizeObserver-scheduled READ_1 overwrites with the previously
rendered box — reading it mid-gesture pairs last frame's height with this
frame's y.

Renames NodeCallbacks.onGeometryChanged -> onGeometryCommit. It is
gesture-end only, and sitting beside a per-frame onGeometryInvalidated the
old name was a trap; the pair now reads unambiguously.

Adds tests/ut/snapline-observers.spec.ts and a test:snapline-ut script (the
SnapLine unit specs previously had no npm entry point).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
onLineChangeRequest now RETURNS the line list that should be canonical, and
the bridge hands it straight to setCanonicalGraph. The `lines` prop is gone
from both adapters, along with both queueMicrotask pushes and
GraphRegistry.pendingGestureRequest.

Why the microtask existed, and why returning removes the need for it: a
dropped line is staged — visually targeted, no topology commitment — so its
only exit is the next reconcile pass, which either settles it or discards it
in the end-of-pass sweep. The reconciler has no pull channel. If the app
rejected by doing nothing, no prop changed, no effect fired, no push
happened, and the staged line hung forever; the unconditional post-request
push closed that hole. Making the handler return the list closes it
structurally instead, and takes three fragilities with it: the React
adapter's correctness rested on an inference about React internals (that the
handler's setState flushes and re-renders the props ref before microtasks
drain — true for React 18 discrete events, not a contract); an ordering
hazard where the decisive pass could be queued ahead of the adapter's push;
and pendingGestureRequest, which gated nothing and only warned about a
stalled push whose failure mode no longer exists.

Rejection is now `return lines` — strictly better than the old bare
`return`, because "I reject" and "I forgot" stop being the same code. The
return type is non-optional so the second one is a type error.

Adds applyLineChange(lines, request) to core, replacing a reducer that was
byte-identical across nine sites (three doc variants, migration notes, the
website demo, two Svelte demos, two React demos).

Both adapters gain the imperative handle the protocol always needed for
changes with no originating request — hydration/load, undo/redo,
collaboration. Svelte exports setLines/flush (it exported nothing before);
React is now forwardRef<ControlledGraphHandle> instead of a plain function
returning null, which also makes AGENTS.md's claim about forwarded refs true.

Consumers migrated. Note the React idiom the synchronous return forces: the
document lives in a ref, because a functional setState has not produced the
next list by the time the handler must return it. Svelte demos move to
$state.raw, since applyLineChange replaces rather than mutates.

The demos' addDocLine stays app code rather than becoming a core helper: its
"replace any record sharing toConnectorId" step is single-capacity policy the
application owns, not a request being applied.

Test harness now stands in for a document: it tracks the records, defaults to
rejection (return unchanged), and offers respondWith for accept/normalize.
All 33 unit and 58 e2e tests pass with no assertion changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two hooks that together close the gap between a line's preview and its
settled form.

resolveNewLine seeds application data onto a line the moment a drag creates
it. Until now a settled line could be styled from LineRecord.payload, but a
line being *dragged* had none — setPayload only runs when the reconciler
settles or creates from a record, so the only thing a preview renderer could
reach was line.start.metadata: undocumented, undiscoverable, and
connector-level rather than per-gesture.

The seam is the else-branch of #onDragStart, which runs only for a genuinely
new line, so a seed placed there structurally cannot clobber payload the
application already owns on a reconnect — covered by a test that reconnects a
settled line and asserts the resolver did not fire again. Because the payload
already rides into request.add at drop, this one hook closes three things at
once: preview styling, complete minted records for a custom record type, and
preview→settled continuity (the app keeps what it was handed instead of
deriving something different at commit).

It lives on the node — one place for all its ports, with the connector on the
event so it can branch — and a connector may override it, matching how
adapter and consumer callbacks already compose. Named for *when* it fires:
"New" is load-bearing, since the seam never runs for a reconnect.

resolveLineComponent picks a renderer per line, so a data edge and a control
edge leaving the same node can look different — previously impossible, since
the renderer was one prop on <Node>. Two levels, not four: the per-line
resolver is the primitive and can express any coarser tier, so a
connector-level prop would be a second way to do the same thing.

Resolved at RENDER time, not at line creation. Hydration never runs the
creation callback — a reloaded graph builds its lines through the reconciler
— so resolving from the line is what makes a line you just drew and the same
line after a refresh render identically. That is also why the discriminator
must be serializable: store `kind: "data"` in the payload and map it to a
component in the resolver; a component reference in a record hydrates as an
empty object after one save/reload round-trip.

Also introduces ResolvedNodeConfig so resolveNewLine stays optional rather
than being forced into DEFAULT_NODE_CONFIG: it seeds application data, and
per SNAPZEN there is no sensible default for that — its absence is the
meaningful state.

Note check:adapters, not typecheck, is what catches type errors in
assets/snapline — the root tsconfig only includes src and demo. Core is
checked transitively through the Svelte adapter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#endControlledDrop took the same branch for "dropped where no connector was
resolved" and "dropped on a connector that declined", so both reached
consumers as a bare onDragEnd({ connected: false }). They are different
answers: only the first means the user gestured toward a node that does not
exist yet, which is the prerequisite for letting SnapLine request node
creation.

Adds DragEndOutcome ("connected" | "refused" | "empty-space" | "cancelled")
to the onDragEnd payload. This is useful on its own — a consumer can now tell
a rejected connection from a miss without re-running hit resolution — and it
is the branch a node-change channel will hang off.

Also aligns the console warning with the component name. It said "no graph
owner attached" while the thing to mount is called <ControlledGraph>, which
was the concrete naming bug behind the (now declined) rename question.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nnel

Closes the last open item of the controlled-graph pass. There is no
onNodeChangeRequest. The application mounts the node AND pushes the line
record itself, using onDragEnd's new outcome plus the ControlledGraph handle.

The symmetric channel was appealing but could not be made honest. A node
request has to answer "which connector does the new line attach to?", and the
application cannot answer truthfully at return time: Svelte and React both
mount asynchronously, so the connector does not exist yet. Every shape that
bridged the gap either accepted a one-frame flicker (return the id, let the
record go latent, let the end-of-pass sweep discard the preview meanwhile) or
added a "pending" grace state to the reconciler with its own cancel/timeout
rule. Both invent machinery to hide the fact that the application, not
SnapLine, decides when a node exists. SNAPZEN stanza 1 settles it: expose the
primitives, let the developer combine them.

What was genuinely missing was the information, not the channel — and that
shipped in the previous commit as DragEndOutcome. The record naming an
unmounted connector stays latent and converges when registration schedules
the next pass, which is already documented reconciler behavior; no new state.

Covered by a unit test that drives the whole flow: an empty-space drop, a
refusal, then mounting the node and watching the latent record converge. That
test also pins a distinction worth knowing — a role-ineligible connector
(maxIncoming: 0) is filtered out during candidate resolution, so dropping on
one reports "empty-space", not "refused". A refusal is a connector that
resolves as a candidate and then declines at drop, which is exactly what
isValidConnection's phase parameter is for.

TODO.md records the decision and unblocks both dependent features:
spawn-on-empty-drop now needs only a demo, and node-dropped-on-line needs a
hit test rather than a request channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the written contracts in line with what shipped, and proves the
observation channel works in a browser rather than only in unit tests.

AGENTS.md: codifies SNAPZEN as a "Design rules" section, so a reviewer has a
written basis for rejecting a convenience API that duplicates a primitive
rather than merely debating it — with the three rules annotated by what they
have already decided in this arc. Rewrites the callback-conventions section
around the distinction that actually exists (notification vs registrar vs
value-returning policy, and why onLineChangeRequest is the one deliberate
exception). Adds the observation-vs-painting rule to the DOM ownership
contract. Replaces the Node.svelte props block, which listed 3 of ~20.

ownership-specification.md: adds A7 (a change request carries its own answer
— no dependence on framework flush timing, exactly one decisive pass per
request, synchronous handler, imperative handle for non-request pushes) and
A8 (observation is not painting — single-owner writer, multicast pre-queue
signal carrying no geometry, core picks no write phase, throws isolated, no
adapter prop). Four new conformance rows.

Reference pages: all three line.mdx now document onGeometryInvalidated with
the stage-ordering table (a line paints at WRITE_2, so an overlay wanting this
frame's position schedules WRITE_3), onStateChange, and the standing question
of where a line's DOM lives — SnapLine never holds it.

New tests/e2e/snapline-overlay.spec.ts drives a marker glued to a line's
midpoint by an overlay component that did not create the line and does not
paint it. Two findings from making it honest:

- The first version's measurement was wrong, not the feature: it compared a
  container-local midpoint against a screen-space box, giving a CONSTANT 95px
  drift across every frame of a drag — which was itself the tell that tracking
  worked. It now reads the painted path's bounding box, whose centre is
  exactly the midpoint because the default cubic is point-symmetric about it.
- The resize case originally passed with the channel removed, so it was
  proving nothing: EdgeNode was not resizable, and a bottom-right resize would
  not move a left-edge connector anyway. Node B is now resizable and the test
  grabs the TOP-LEFT handle, so the anchored edge — and the line endpoint —
  actually travels. Verified by sabotage: with the subscription removed both
  gesture tests go red, while the two mount-time tests correctly still pass.

Also extends lint from `src` to `src assets/snapline` (it had never covered
the adapters) and fixes the 7 prefer-const errors that surfaced. TODO.md
records the remaining gate gaps: tsc still excludes assets/**, asset-base has
6 lint errors blocking a repo-wide sweep, and tests/ut/input.spec.ts has been
broken on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add native pointer capture handoff and resilient gesture finalization, migrate SnapLine connectors to DOM-owned input with collision point queries, and update SnapSort integration, adapters, documentation, and regression coverage.
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
snap-engine-js Ready Ready Preview Aug 7, 2026 5:24am

@tfukaza
tfukaza marked this pull request as ready for review August 7, 2026 05:28
@tfukaza
tfukaza merged commit e068167 into main Aug 7, 2026
3 checks passed
@tfukaza
tfukaza deleted the snapline-overhaul branch August 7, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant