ui: NodeGraph improvements

- Can insert node anywhere in a stack of nodes.
- Ghost node showing where the node will land if dropped.
- Snap to grid.
- Edge pan while dragging a node.
- Complete refactor of NodeGraph.
- Ports are addressed using unique IDs.
- Remove labels - just use styled nodes. This solves a whole host of
  inconsistencies around how lables are handled vs nodes.
- Remove hotkeys - wrap NodeGraph in HotkeyContext instead.

A few tweaks

Change-Id: I8f421b29dbcbac2837bf4cd7fb7385954645532a
diff --git a/ui/src/assets/widgets/nodegraph.scss b/ui/src/assets/widgets/nodegraph.scss
index 5418079..6ace75d 100644
--- a/ui/src/assets/widgets/nodegraph.scss
+++ b/ui/src/assets/widgets/nodegraph.scss
@@ -14,88 +14,108 @@
 
 @import "../theme";
 
-.pf-canvas {
-  isolation: isolate; // Don't allow z-index to escape container.
-  overflow: hidden; // Clip overflowing content.
-  position: relative; // Place absolute children relative to this container.
-  height: 450px; // Give the canvas some intrinsic height by default.
+.pf-ng {
+  --bg-size: 12px;
+  --bg-pos-x: 0px;
+  --bg-pos-y: 0px;
 
-  touch-action: none; // Disable default touch actions for panning.
-  user-select: none; // Disable text selection during dragging.
-
-  font-family: var(--pf-font-compact); // Consistent font.
-  cursor: grab; // Indicate panning by default.
-
-  // Dot pattern background.
-  background-color: color-mix(
-    in srgb,
-    var(--pf-color-background) 90%,
-    var(--pf-color-border-secondary) 10%
-  );
+  position: relative;
+  overflow: hidden;
+  width: 100%;
+  height: 100%;
+  touch-action: none;
+  user-select: none;
+  cursor: grab;
   background-image: radial-gradient(
     circle,
     color-mix(in srgb, var(--pf-color-border-secondary) 85%, black 15%) 1px,
-    transparent 0px
+    transparent 0
   );
-  background-size: 20px 20px;
+  background-size: var(--bg-size) var(--bg-size);
+  background-position: var(--bg-pos-x) var(--bg-pos-y);
 
-  &--fill-height {
-    height: 100%; // Fill parent height when modifier class applied.
+  &:active {
+    cursor: grabbing;
   }
 }
 
-.pf-canvas-content {
+.pf-ng__workspace {
   position: absolute;
   top: 0;
   left: 0;
-  width: 100%;
-  height: 100%;
-  pointer-events: none;
-  overflow: visible;
-
-  svg {
-    isolation: isolate;
-    position: absolute;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
-    pointer-events: none;
-    overflow: visible;
-  }
+  transform-origin: 0 0;
+  z-index: 1;
 }
 
-.pf-nodegraph-controls {
+.pf-ng__toolbar {
   position: absolute;
-  top: 16px;
-  right: 16px;
-  z-index: 3;
-  pointer-events: auto;
+  top: 6px;
+  right: 6px;
   display: flex;
-  gap: 8px;
+  gap: 4px;
+  pointer-events: auto;
+  z-index: 2;
 }
 
-.pf-node {
+.pf-ng__node {
+  display: flex;
+  flex-direction: column;
+  cursor: move; // The whole node and all children are draggable
+}
+
+.pf-ng__card {
   position: relative;
   background: var(--pf-color-background-secondary);
   border: 2px solid var(--pf-color-border);
-  border-radius: 8px;
-  min-width: 180px;
-  cursor: move;
-  pointer-events: auto;
-  width: 100%;
+  min-width: 140px;
+  background: var(--pf-color-background-secondary);
+  border-radius: 6px;
 
-  // Hide elements with this class, show on node hover
-  .pf-show-on-hover {
-    visibility: hidden;
+  &:not(:only-child) {
+    border-bottom-left-radius: 0;
+    border-bottom-right-radius: 0;
   }
 
-  &:hover .pf-show-on-hover {
-    visibility: visible;
+  &--selected {
+    border-color: var(--pf-color-accent);
+  }
+
+  .pf-ng__card-header {
+    border-radius: 4px 4px 0 0;
+    background: color-mix(
+      in srgb,
+      hsl(var(--pf-ng-hue), 60%, 50%) 50%,
+      var(--pf-color-background)
+    );
+    padding: 6px 6px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 6px;
+  }
+
+  .pf-ng__card-body {
+    padding: 8px;
+    min-width: max-content;
   }
 }
 
-.pf-node--has-accent-bar::before {
+.pf-ng__node {
+  .pf-ng__node {
+    .pf-ng__card {
+      border-top-left-radius: 0;
+      border-top-right-radius: 0;
+      border-top-width: 0;
+
+      .pf-ng__card-header {
+        border-radius: 0;
+      }
+    }
+  }
+}
+
+// Accent bar (left-side color strip)
+.pf-ng__card--accent::before {
   content: "";
   position: absolute;
   top: 0;
@@ -105,129 +125,28 @@
   background-color: var(
     --pf-color-border
   ); /* Fallback color when hue not set */
-  border-top-left-radius: 6px;
-  border-bottom-left-radius: 6px;
+  border-top-left-radius: 4px;
+  border-bottom-left-radius: 4px;
 }
 
-/* Override with hue-based color when --pf-node-hue is set via inline styles */
-.pf-node[style*="--pf-node-hue"].pf-node--has-accent-bar::before {
+.pf-ng__card[style*="--pf-ng-hue"].pf-ng__card--accent::before {
   background: color-mix(
     in srgb,
-    hsl(var(--pf-node-hue), 60%, 50%) 50%,
+    hsl(var(--pf-ng-hue), 60%, 50%) 50%,
     var(--pf-color-background)
   );
 }
 
-.pf-node-body {
-  padding-block: 12px;
-}
-
-// Wrapper for dock chains - uses flexbox to make children same width
-.pf-node-wrapper {
-  isolation: isolate; // Don't allow z-index to escape container.
-  position: absolute;
-  display: flex;
-  flex-direction: column;
-  pointer-events: auto;
-  box-shadow: 0 4px 12px var(--pf-color-box-shadow);
-  border-radius: 8px;
-  width: max-content;
-  transition:
-    left 0.1s ease-out,
-    top 0.1s ease-out;
-
-  &--dragging {
-    z-index: 1; // Ensure dragging entire chain appears above other nodes.
-
-    // Don't animate transitions while dragging to avoid laggy movement
-    transition: none;
-
-    // TODO Might want to add some visual indication of dragging entire chain
-    // using box shadow, and maybe make slightly transparent.
-    // opacity: 0.8;
-    // box-shadow: 6px 6px 24px var(--pf-color-box-shadow);
-  }
-}
-
-.pf-node.pf-selected {
-  border-color: var(--pf-color-accent);
-  box-shadow: 0 0 0 3px
-    color-mix(in srgb, var(--pf-color-accent) 50%, transparent);
-  z-index: 1; // Make sure box shadow appears above other nodes
-}
-
-// Docked child: flush top edge with no rounded corners or border
-.pf-node.pf-docked-child {
-  border-top-left-radius: 0;
-  border-top-right-radius: 0;
-  margin-top: -2px; // Overlap border with parent
-}
-
-// Parent with docked child: flush bottom edge with no rounded corners
-.pf-node.pf-has-docked-child {
-  border-bottom-left-radius: 0;
-  border-bottom-right-radius: 0;
-}
-
-// Dock target highlight with pulsing animation
-.pf-node.pf-dock-target {
-  box-shadow: 0 4px 0 3px var(--pf-color-accent);
-  animation: pulse-dock 0.6s ease-in-out infinite;
-}
-
-@keyframes pulse-dock {
-  0%,
-  100% {
-    box-shadow: 0 4px 0 3px var(--pf-color-accent);
-  }
-  50% {
-    box-shadow: 0 6px 0 5px var(--pf-color-accent);
-    opacity: 0.95;
-  }
-}
-
-.pf-node-header {
-  /* Default background when hue is not set */
-  background: var(--pf-color-background);
-  padding: 6px 6px;
-  border-radius: 6px 6px 0 0;
-  display: flex;
-  border-bottom: 1px solid var(--pf-color-border);
-  justify-content: space-between;
-  align-items: center;
-  gap: 6px;
-}
-
-/* Override with hue-based color when --pf-node-hue is set via inline styles */
-.pf-node[style*="--pf-node-hue"] .pf-node-header {
-  background: color-mix(
-    in srgb,
-    hsl(var(--pf-node-hue), 60%, 50%) 50%,
-    var(--pf-color-background)
-  );
-}
-
-.pf-node--has-accent-bar .pf-node-header {
-  padding-left: 22px;
-}
-
-.pf-docked-child .pf-node-header {
-  border-top-left-radius: 0;
-  border-top-right-radius: 0;
-}
-
-.pf-has-docked-child .pf-node-header {
+// When the card has a docked node below it, flatten the accent bar's bottom-left corner.
+.pf-ng__card--accent:not(:only-child)::before {
   border-bottom-left-radius: 0;
 }
 
-.pf-docked-child::before {
+// When the card is itself docked (nested node), flatten the accent bar's top-left corner.
+.pf-ng__node .pf-ng__node .pf-ng__card--accent::before {
   border-top-left-radius: 0;
 }
 
-.pf-has-docked-child::before {
-  border-bottom-left-radius: 0;
-}
-
 .pf-node-title {
   flex: 1;
 }
@@ -236,45 +155,32 @@
   align-self: center;
 }
 
-.pf-node-context-menu {
-  position: absolute;
-  top: 6px;
-  right: 6px;
-}
-
-.pf-node-content {
-  padding-inline: 12px;
-}
-
-.pf-node--has-accent-bar .pf-node-content {
+.pf-ng__card--accent .pf-ng__port,
+.pf-ng__card--accent .pf-ng__card-body {
   padding-left: 22px;
 }
 
-.pf-port-row {
+.pf-ng__port {
   position: relative;
   margin: 8px 0;
   color: var(--pf-color-text-muted);
   font-size: 13px;
   padding: 4px 16px;
 
-  .pf-port {
+  .pf-ng__port-dot {
     transform: translateY(-50%);
   }
 }
 
-.pf-node--has-accent-bar .pf-port-row {
-  padding-left: 22px;
-}
-
-.pf-port-input {
+.pf-ng__port--west {
   text-align: left;
 }
 
-.pf-port-output {
+.pf-ng__port--east {
   text-align: right;
 }
 
-.pf-port {
+.pf-ng__port-dot {
   width: 16px;
   height: 16px;
   border-radius: 50%;
@@ -283,254 +189,71 @@
   cursor: crosshair;
   position: absolute;
   top: 50%;
-
-  &--with-context-menu {
-    cursor: pointer;
-    &::after {
-      // Render a little plus button to indicate context menu availability
-      @include material-icon("add");
-      position: absolute;
-      font-size: 15px;
-      top: 50%;
-      left: 50%;
-      transform: translate(-50%, -50%);
-    }
-  }
-
-  &.pf-connected {
-    background: var(--pf-color-accent);
-    border-color: var(--pf-color-accent);
-    color: var(--pf-color-text-on-accent);
-  }
 }
 
 .pf-output {
   background: var(--pf-color-border);
-  z-index: 2; // Make outputs appear above inputs when docked.
 }
 
 .pf-output:hover {
-  background: var(--pf-color-accent);
-  border-color: var(--pf-color-accent);
-  color: var(--pf-color-text-on-accent);
+  background: var(--pf-color-primary);
+  border-color: var(--pf-color-primary);
+  color: var(--pf-color-text-on-primary);
 }
 
-.pf-port.pf-input {
-  left: -9px;
+.pf-ng__port-dot.pf-input {
+  left: -8px;
 }
 
-.pf-port.pf-port-top {
+.pf-ng__port-dot.pf-port-north {
   top: -1px;
+  right: unset;
   left: 50%;
   transform: translate(-50%, -50%);
 }
 
-.pf-port.pf-port-bottom {
+.pf-ng__port-dot.pf-port-south {
   top: unset;
   bottom: -1px;
+  right: unset;
   left: 50%;
   transform: translate(-50%, 50%);
 }
 
-.pf-port.pf-output {
-  right: -9px;
+.pf-ng__port-dot.pf-output {
+  right: -8px;
 }
 
-.pf-connection {
-  stroke: var(--pf-color-accent);
-  color: var(--pf-color-accent);
-  stroke-width: 2;
-  fill: none;
-  transition:
-    stroke-width 0.2s,
-    stroke 0.2s;
-  pointer-events: auto;
-  cursor: pointer;
+// Connected input ports show a delete cursor on hover.
+.pf-ng__port-dot.pf-input.pf-port--connected {
+  border-color: var(--pf-color-primary);
+  background: var(--pf-color-primary);
 }
 
-.pf-connection-group:hover .pf-connection {
+// Connected output ports are colored in primary blue.
+.pf-ng__port-dot.pf-output.pf-port--connected {
+  border-color: var(--pf-color-primary);
+  background: var(--pf-color-primary);
+}
+
+// Snap target highlight when a wire drag is close enough to connect.
+.pf-ng__port-dot.pf-input.pf-wire-snap {
+  border-color: var(--pf-color-primary);
+  background: var(--pf-color-primary);
+}
+
+// Connection wires turn red on hover to indicate they can be clicked to delete.
+.pf-connection-group:hover .pf-connection-path {
   stroke: var(--pf-color-danger);
-  filter: drop-shadow(
-    0 0 6px color-mix(in srgb, var(--pf-color-danger) 80%, transparent)
-  );
 }
 
-.pf-temp-connection {
-  stroke: var(--pf-color-text-muted);
-  stroke-width: 2;
-  fill: none;
-  stroke-dasharray: 5, 5;
-}
-
-.pf-connecting {
-  cursor: crosshair;
-
-  .pf-input:hover {
-    background: var(--pf-color-accent);
-    border-color: var(--pf-color-accent);
-    cursor: copy;
-  }
-  .pf-output:hover {
-    background: var(--pf-color-border);
-    border-color: var(--pf-color-border-secondary);
-    cursor: not-allowed;
-  }
-}
-
-.pf-output.pf-active,
-.pf-output.pf-active:hover {
-  background: var(--pf-color-accent);
-  border-color: var(--pf-color-accent);
-  color: var(--pf-color-text-on-accent);
-  cursor: crosshair;
-}
-
-.pf-panning {
-  cursor: grabbing;
-}
-
-.pf-selection-rect {
-  position: absolute;
-  border: 2px solid var(--pf-color-accent);
-  background: color-mix(in srgb, var(--pf-color-accent) 15%, transparent);
-  pointer-events: none;
-  z-index: 2; // Render selection rectangle above nodes.
-}
-
-.pf-node.pf-invalid {
-  border-color: var(--pf-color-danger);
+// Highlight input ports while the user is dragging a wire from an output port.
+.pf-ng--wire-dragging .pf-ng__port-dot.pf-input {
+  border-color: var(--pf-color-primary);
   background: color-mix(
     in srgb,
-    var(--pf-color-danger) 8%,
+    var(--pf-color-primary) 20%,
     var(--pf-color-background-secondary)
   );
-
-  .pf-node-header {
-    background: color-mix(
-      in srgb,
-      var(--pf-color-danger) 15%,
-      var(--pf-color-background)
-    );
-    border-bottom-color: var(--pf-color-danger);
-  }
-}
-
-// Invalid nodes override hue-based styling
-.pf-node.pf-invalid[style*="--pf-node-hue"] .pf-node-header {
-  background: color-mix(
-    in srgb,
-    var(--pf-color-danger) 15%,
-    var(--pf-color-background)
-  );
-}
-
-.pf-node.pf-invalid.pf-node--has-accent-bar::before {
-  background-color: var(--pf-color-danger);
-}
-
-// Label styles
-.pf-label {
-  position: absolute;
-  background: color-mix(
-    in srgb,
-    var(--pf-color-background-secondary) 85%,
-    var(--pf-color-accent) 15%
-  );
-  border: 2px solid var(--pf-color-border);
-  cursor: move;
-  pointer-events: auto;
-  box-sizing: border-box;
-
-  &.pf-dragging {
-    opacity: 0.7;
-    cursor: grabbing;
-  }
-
-  &.pf-selected {
-    border-color: var(--pf-color-accent);
-    box-shadow: 0 0 0 3px
-      color-mix(in srgb, var(--pf-color-accent) 50%, transparent);
-  }
-
-  .pf-label-resize-handle {
-    position: absolute;
-    right: -5px;
-    top: 50%;
-    transform: translateY(-50%);
-    width: 10px;
-    height: 20px;
-    background: var(--pf-color-accent);
-    border-radius: 3px;
-    cursor: ew-resize;
-    opacity: 0;
-    transition: opacity 0.2s;
-
-    &:hover {
-      opacity: 1;
-    }
-  }
-
-  &:hover .pf-label-resize-handle {
-    opacity: 0.6;
-  }
-
-  &.pf-selected .pf-label-resize-handle {
-    opacity: 1;
-  }
-
-  .pf-label-delete-button {
-    position: absolute;
-    top: -10px;
-    right: -10px;
-    width: 20px;
-    height: 20px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    background: var(--pf-color-danger);
-    color: white;
-    border-radius: 3px;
-    cursor: pointer;
-    opacity: 0;
-    transition: opacity 0.2s;
-
-    &:hover {
-      opacity: 1;
-      background: color-mix(in srgb, var(--pf-color-danger) 85%, black 15%);
-    }
-
-    .pf-icon {
-      font-size: var(--pf-font-size-s);
-    }
-  }
-
-  &:hover .pf-label-delete-button,
-  &.pf-selected .pf-label-delete-button {
-    opacity: 1;
-  }
-}
-
-.pf-label-content {
-  width: 100%;
-  max-height: 400px;
-  overflow-y: auto;
-  box-sizing: border-box;
-
-  > .pf-simple-label-text {
-    padding: 12px;
-    font-family: var(--pf-font-compact);
-    font-size: var(--pf-font-size-m);
-    line-height: 1.4;
-    color: var(--pf-color-text);
-  }
-
-  > .pf-simple-label-button {
-    padding: 8px;
-  }
-
-  > .pf-label-placeholder {
-    padding: 8px;
-    color: var(--pf-color-text-secondary);
-    font-style: italic;
-  }
+  cursor: crosshair;
 }
diff --git a/ui/src/base/dom_utils.ts b/ui/src/base/dom_utils.ts
index f418b1f..f26c110 100644
--- a/ui/src/base/dom_utils.ts
+++ b/ui/src/base/dom_utils.ts
@@ -137,3 +137,99 @@
     },
   };
 }
+
+export interface DragEvent {
+  // Movement delta since the previous event.
+  delta: {readonly x: number; readonly y: number};
+  // Absolute pointer position in client coordinates.
+  client: {readonly x: number; readonly y: number};
+  // Pointer position at the very start of the drag in client coordinates.
+  startClient: {readonly x: number; readonly y: number};
+}
+
+// Waits for a drag gesture to begin (pointer moved beyond `deadzone` px).
+// Returns an async iterable of DragEvents, or undefined if the pointer was
+// released before the deadzone was crossed (i.e. it was a click). The first yielded
+// event includes accumulated movement from the deadzone phase so callers never
+// lose movement that occurred before the drag was confirmed.
+export async function captureDrag(attrs: {
+  el: HTMLElement;
+  e: PointerEvent;
+  deadzone?: number;
+}): Promise<AsyncIterable<DragEvent> | undefined> {
+  const {el, e, deadzone = 0} = attrs;
+  const pointerId = e.pointerId;
+
+  el.setPointerCapture(pointerId);
+
+  let resolveNext: ((e: PointerEvent | undefined) => void) | undefined;
+
+  const onMove = (e: PointerEvent) => {
+    if (e.pointerId !== pointerId) return;
+    resolveNext?.(e);
+    resolveNext = undefined;
+  };
+  const onDone = (e: PointerEvent) => {
+    if (e.pointerId !== pointerId) return;
+    resolveNext?.(undefined);
+    resolveNext = undefined;
+  };
+
+  el.addEventListener('pointermove', onMove);
+  el.addEventListener('pointerup', onDone);
+  el.addEventListener('pointercancel', onDone);
+
+  const cleanup = () => {
+    el.removeEventListener('pointermove', onMove);
+    el.removeEventListener('pointerup', onDone);
+    el.removeEventListener('pointercancel', onDone);
+  };
+
+  const next = () =>
+    new Promise<PointerEvent | undefined>((r) => {
+      resolveNext = r;
+    });
+
+  // Phase 1: wait for deadzone to be crossed, or pointerup (→ click).
+  // Accumulate movement so the first yield includes the full delta.
+  let accum = new Vector2D({x: 0, y: 0});
+  const start = new Vector2D({x: e.clientX, y: e.clientY});
+  let firstEvent: PointerEvent = e;
+  while (deadzone > 0) {
+    const ev = await next();
+    if (ev === undefined) {
+      cleanup();
+      return undefined;
+    }
+    firstEvent = ev;
+    accum = accum.add({x: ev.movementX, y: ev.movementY});
+    if (start.sub({x: ev.clientX, y: ev.clientY}).magnitude >= deadzone) break;
+  }
+
+  const startClient = {x: e.clientX, y: e.clientY};
+
+  // Phase 2: drag confirmed — stream events to the caller, leading with the
+  // accumulated deadzone movement as the first yield.
+  return (async function* () {
+    try {
+      if (deadzone > 0) {
+        yield {
+          delta: accum,
+          client: {x: firstEvent.clientX, y: firstEvent.clientY},
+          startClient,
+        };
+      }
+      while (true) {
+        const ev = await next();
+        if (ev === undefined) return;
+        yield {
+          delta: {x: ev.movementX, y: ev.movementY},
+          client: {x: ev.clientX, y: ev.clientY},
+          startClient,
+        };
+      }
+    } finally {
+      cleanup();
+    }
+  })();
+}
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/graph.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/graph.ts
index 625f475..a61c208 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/graph.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/graph.ts
@@ -44,16 +44,25 @@
   MenuTitle,
   PopupMenu,
 } from '../../../../widgets/menu';
+import {Point2D} from '../../../../base/geom';
 import {
-  Connection,
-  Label,
-  Node,
+  NodeGraphConnection,
+  NodeGraphDockedNode,
+  NodeGraphNode,
   NodeGraph,
-  NodeGraphApi,
+  NodeGraphAPI,
   NodeGraphAttrs,
-  NodePort,
+  NodeGraphPort,
 } from '../../../../widgets/nodegraph';
-import {createEditableTextLabels} from './text_label';
+
+// Local label type (not part of NodeGraph API — rendered as plain nodes).
+interface Label {
+  id: string;
+  x: number;
+  y: number;
+  width: number;
+  content?: m.Children;
+}
 import {QueryNode, singleNodeOperation, NodeType} from '../../query_node';
 import {NodeBox} from './node_box';
 import {buildMenuItems} from './menu_utils';
@@ -85,6 +94,28 @@
 };
 
 // ========================================
+// PORT ID HELPERS
+// ========================================
+
+// Port IDs use '|' as separator (safe: UUIDs only contain hex chars and '-').
+const getOutputPortId = (nodeId: string) => `${nodeId}|out`;
+const getTopPortId = (nodeId: string) => `${nodeId}|in-top`;
+const getLeftPortId = (nodeId: string, i: number) => `${nodeId}|in-left-${i}`;
+
+function parseToPort(
+  toPort: string,
+): {nodeId: string; isTop: boolean; leftIndex?: number} | undefined {
+  const sep = toPort.indexOf('|');
+  if (sep === -1) return undefined;
+  const nodeId = toPort.slice(0, sep);
+  const suffix = toPort.slice(sep + 1);
+  if (suffix === 'in-top') return {nodeId, isTop: true};
+  const m2 = suffix.match(/^in-left-(\d+)$/);
+  if (m2) return {nodeId, isTop: false, leftIndex: parseInt(m2[1])};
+  return undefined;
+}
+
+// ========================================
 // TYPE GUARDS
 // ========================================
 
@@ -159,28 +190,31 @@
     : Math.min(currentConnections + 1, max);
 }
 
-function getInputLabels(node: QueryNode): NodePort[] {
+function getInputPorts(node: QueryNode): NodeGraphPort[] {
+  const nodeId = node.nodeId;
+
   // Single-input operation nodes always have a top port (even when disconnected)
   if (singleNodeOperation(node.type)) {
-    const labels: NodePort[] = [];
-    labels.push({content: 'Input', direction: 'top'});
+    const ports: NodeGraphPort[] = [];
+    ports.push({id: getTopPortId(nodeId), label: 'Input', direction: 'north'});
 
     // Check if node also has secondaryInputs (like AddColumnsNode or FilterDuring)
     if (node.secondaryInputs) {
-      // Show side ports using the node's custom port names
       const portNames = node.secondaryInputs.portNames;
       const currentConnections = node.secondaryInputs.connections.size ?? 0;
       const numPorts = calculateNumPorts(
         currentConnections,
         node.secondaryInputs.max,
       );
-
       for (let i = 0; i < numPorts; i++) {
-        const portName = getPortName(portNames, i);
-        labels.push({content: portName, direction: 'left'});
+        ports.push({
+          id: getLeftPortId(nodeId, i),
+          label: getPortName(portNames, i),
+          direction: 'west',
+        });
       }
     }
-    return labels;
+    return ports;
   }
 
   // Multi-source nodes (IntervalIntersect, Join, Union) - no primaryInput
@@ -191,13 +225,15 @@
       currentConnections,
       node.secondaryInputs.max,
     );
-    const labels: NodePort[] = [];
-
+    const ports: NodeGraphPort[] = [];
     for (let i = 0; i < numPorts; i++) {
-      const portName = getPortName(portNames, i);
-      labels.push({content: portName, direction: 'left'});
+      ports.push({
+        id: getLeftPortId(nodeId, i),
+        label: getPortName(portNames, i),
+        direction: 'west',
+      });
     }
-    return labels;
+    return ports;
   }
 
   // Source nodes have no inputs
@@ -293,7 +329,7 @@
 function ensureNodeLayouts(
   roots: QueryNode[],
   attrs: GraphAttrs,
-  nodeGraphApi: NodeGraphApi | null,
+  nodeGraphApi: NodeGraphAPI | null,
 ): void {
   if (!nodeGraphApi) return;
 
@@ -306,10 +342,16 @@
       if (!lastPlacement) {
         // First node - use API placement
         const canDockTop = shouldShowTopPort(qnode);
-        const nodeTemplate: Omit<Node, 'x' | 'y'> = {
+        const nodeTemplate: NodeGraphDockedNode = {
           id: qnode.nodeId,
-          inputs: getInputLabels(qnode),
-          outputs: [{content: 'Output', direction: 'bottom'}],
+          inputs: getInputPorts(qnode),
+          outputs: [
+            {
+              id: getOutputPortId(qnode.nodeId),
+              label: 'Output',
+              direction: 'south',
+            },
+          ],
           canDockBottom: true,
           canDockTop,
           hue: getNodeHue(qnode),
@@ -342,7 +384,7 @@
 function getNextDockedNode(
   qnode: QueryNode,
   attrs: GraphAttrs,
-): Omit<Node, 'x' | 'y'> | undefined {
+): NodeGraphDockedNode | undefined {
   if (
     qnode.nextNodes.length === 1 &&
     qnode.nextNodes[0] !== undefined &&
@@ -384,30 +426,39 @@
 function createNodeConfig(
   qnode: QueryNode,
   attrs: GraphAttrs,
-): Omit<Node, 'x' | 'y'> {
+): NodeGraphDockedNode {
   const canDockTop = shouldShowTopPort(qnode);
   const addMenuItems = buildAddMenuItems(qnode, attrs.onAddOperationNode);
-  const outputs: NodePort[] =
+  const outputs: NodeGraphPort[] =
     addMenuItems.length > 0
       ? [
           {
-            content: 'Output',
-            direction: 'bottom',
+            id: getOutputPortId(qnode.nodeId),
+            label: 'Output',
+            direction: 'south',
             contextMenuItems: addMenuItems,
           },
         ]
       : nodeRegistry.getAllowedChildrenFor(qnode.type).length > 0
-        ? [{content: 'Output', direction: 'bottom'}]
+        ? [
+            {
+              id: getOutputPortId(qnode.nodeId),
+              label: 'Output',
+              direction: 'south',
+            },
+          ]
         : [];
 
   const isGroup = qnode.type === NodeType.kGroup;
+  const hasAllowedChildren =
+    nodeRegistry.getAllowedChildrenFor(qnode.type).length > 0;
   return {
     id: qnode.nodeId,
-    inputs: getInputLabels(qnode),
+    inputs: getInputPorts(qnode),
     outputs,
-    canDockBottom: !isGroup,
+    canDockBottom: !isGroup && hasAllowedChildren,
     canDockTop,
-    hue: isGroup ? undefined : getNodeHue(qnode),
+    hue: isGroup ? 0 : getNodeHue(qnode),
     accentBar: !isGroup,
     className: classNames(isGroup && 'pf-node--group'),
     contextMenuItems: buildNodeContextMenuItems(qnode, attrs),
@@ -416,14 +467,13 @@
       onAddOperationNode: attrs.onAddOperationNode,
     }),
     next: getNextDockedNode(qnode, attrs),
-    invalid: !qnode.validate(),
   };
 }
 
 function renderChildNode(
   qnode: QueryNode,
   attrs: GraphAttrs,
-): Omit<Node, 'x' | 'y'> {
+): NodeGraphDockedNode {
   return createNodeConfig(qnode, attrs);
 }
 
@@ -431,11 +481,10 @@
   qnode: QueryNode,
   layout: Position,
   attrs: GraphAttrs,
-): Node {
+): NodeGraphNode {
   return {
     ...createNodeConfig(qnode, attrs),
-    x: layout.x,
-    y: layout.y,
+    pos: layout,
   };
 }
 
@@ -443,8 +492,8 @@
 function renderNodes(
   rootNodes: QueryNode[],
   attrs: GraphAttrs,
-  nodeGraphApi: NodeGraphApi | null,
-): Node[] {
+  nodeGraphApi: NodeGraphAPI | null,
+): NodeGraphNode[] {
   const allNodes = getAllNodes(rootNodes, {traverseGroups: false});
   const roots = getRootNodes(allNodes, attrs.nodeLayouts);
 
@@ -459,35 +508,19 @@
       }
       return renderNodeChain(qnode, layout, attrs);
     })
-    .filter((n): n is Node => n !== null);
+    .filter((n): n is NodeGraphNode => n !== null);
 }
 
 // ========================================
 // CONNECTION HANDLING
 // ========================================
 
-// Single-input nodes use port 0 for primaryInput, multi-source nodes don't have primaryInput
-function hasPrimaryInputPort(node: QueryNode): boolean {
-  return singleNodeOperation(node.type);
-}
-
-// Convert visual port to secondary input index (undefined means primary input)
-function toSecondaryIndex(
-  node: QueryNode,
-  visualPort: number,
-): number | undefined {
-  if (hasPrimaryInputPort(node)) {
-    return visualPort === 0 ? undefined : visualPort - 1;
-  }
-  return visualPort;
-}
-
 // Builds visual connections between nodes (skips docked chains since they use 'next' property)
 function buildConnections(
   rootNodes: QueryNode[],
   nodeLayouts: LayoutMap,
-): Connection[] {
-  const connections: Connection[] = [];
+): NodeGraphConnection[] {
+  const connections: NodeGraphConnection[] = [];
   const allNodes = getAllNodes(rootNodes, {traverseGroups: false});
 
   for (const qnode of allNodes) {
@@ -506,34 +539,24 @@
         continue;
       }
 
-      // Check if this parent is connected to multiple ports on the child
-      // (e.g., Union node with same parent connected to Input 0 and Input 1)
-      const connectedPorts: number[] = [];
+      const fromPort = getOutputPortId(qnode.nodeId);
 
       // Check primary input
       if (child.primaryInput === qnode) {
-        connectedPorts.push(0);
+        connections.push({fromPort, toPort: getTopPortId(child.nodeId)});
       }
 
       // Check secondary inputs
       if (child.secondaryInputs) {
-        const offset = hasPrimaryInputPort(child) ? 1 : 0;
         for (const [index, node] of child.secondaryInputs.connections) {
           if (node === qnode) {
-            connectedPorts.push(index + offset);
+            connections.push({
+              fromPort,
+              toPort: getLeftPortId(child.nodeId, index),
+            });
           }
         }
       }
-
-      // Create a separate connection for each port
-      for (const toPort of connectedPorts) {
-        connections.push({
-          fromNode: qnode.nodeId,
-          fromPort: 0,
-          toNode: child.nodeId,
-          toPort: toPort,
-        });
-      }
     }
   }
 
@@ -541,14 +564,19 @@
 }
 
 // Handles creating a new connection between nodes (updates both forward and backward links)
-function handleConnect(conn: Connection, rootNodes: QueryNode[]): void {
-  const fromNode = findQueryNode(conn.fromNode, rootNodes);
-  const toNode = findQueryNode(conn.toNode, rootNodes);
+function handleConnect(
+  conn: NodeGraphConnection,
+  rootNodes: QueryNode[],
+): void {
+  const parsed = parseToPort(conn.toPort);
+  if (!parsed) return;
+
+  const fromNodeId = conn.fromPort.slice(0, conn.fromPort.lastIndexOf('|'));
+  const fromNode = findQueryNode(fromNodeId, rootNodes);
+  const toNode = findQueryNode(parsed.nodeId, rootNodes);
 
   if (!fromNode || !toNode) {
-    console.warn(
-      `Cannot create connection: node not found (from: ${conn.fromNode}, to: ${conn.toNode})`,
-    );
+    console.warn(`Cannot create connection: node not found`);
     return;
   }
 
@@ -557,13 +585,12 @@
   }
 
   if (wouldCreateCycle(fromNode, toNode)) {
-    console.warn(
-      `Cannot create connection: would create a cycle (from: ${conn.fromNode}, to: ${conn.toNode})`,
-    );
+    console.warn(`Cannot create connection: would create a cycle`);
     return;
   }
 
-  const secondaryIndex = toSecondaryIndex(toNode, conn.toPort);
+  // isTop → primary input (secondaryIndex = undefined), otherwise left port index
+  const secondaryIndex = parsed.isTop ? undefined : parsed.leftIndex;
   addConnection(fromNode, toNode, secondaryIndex);
 
   m.redraw();
@@ -571,7 +598,7 @@
 
 // Handles removing a connection (cleans up both forward and backward links)
 function handleConnectionRemove(
-  conn: Connection,
+  conn: NodeGraphConnection,
   rootNodes: QueryNode[],
   onConnectionRemove: (
     fromNode: QueryNode,
@@ -579,34 +606,22 @@
     isSecondaryInput: boolean,
   ) => void,
 ): void {
-  const fromNode = findQueryNode(conn.fromNode, rootNodes);
-  const toNode = findQueryNode(conn.toNode, rootNodes);
+  const parsed = parseToPort(conn.toPort);
+  if (!parsed) return;
+
+  const fromNodeId = conn.fromPort.slice(0, conn.fromPort.lastIndexOf('|'));
+  const fromNode = findQueryNode(fromNodeId, rootNodes);
+  const toNode = findQueryNode(parsed.nodeId, rootNodes);
 
   if (!fromNode || !toNode) {
-    console.warn(
-      `Cannot remove connection: node not found (from: ${conn.fromNode}, to: ${conn.toNode})`,
-    );
+    console.warn(`Cannot remove connection: node not found`);
     return;
   }
 
-  // Check BEFORE removal if this is a secondary input connection
-  let isSecondaryInput = false;
-  if (toNode.secondaryInputs?.connections) {
-    for (const node of toNode.secondaryInputs.connections.values()) {
-      if (node === fromNode) {
-        isSecondaryInput = true;
-        break;
-      }
-    }
-  }
+  const isSecondaryInput = !parsed.isTop;
+  const secondaryIndex = parsed.isTop ? undefined : parsed.leftIndex;
 
-  // Convert visual port to secondary index for removal
-  const secondaryIndex = toSecondaryIndex(toNode, conn.toPort);
-
-  // Use the helper function to cleanly remove the connection
   removeConnection(fromNode, toNode, secondaryIndex);
-
-  // Call the parent callback for any additional cleanup (e.g., state management)
   onConnectionRemove(fromNode, toNode, isSecondaryInput);
 }
 
@@ -633,8 +648,7 @@
 // ========================================
 
 export class Graph implements m.ClassComponent<GraphAttrs> {
-  private nodeGraphApi: NodeGraphApi | null = null;
-  private hasPerformedInitialLayout: boolean = false;
+  private nodeGraphApi: NodeGraphAPI | null = null;
   private hasPerformedInitialRecenter: boolean = false;
   private recenterRequired: boolean = false;
   // True while a recenter is pending. The graph is hidden (visibility:hidden)
@@ -804,7 +818,7 @@
         title: 'Center Graph',
         onclick: () => {
           if (this.nodeGraphApi) {
-            this.nodeGraphApi.recenter();
+            this.nodeGraphApi.autofit();
           }
         },
       }),
@@ -824,7 +838,17 @@
   view({attrs}: m.CVnode<GraphAttrs>) {
     const {rootNodes} = attrs;
 
-    const nodes = renderNodes(rootNodes, attrs, this.nodeGraphApi);
+    const queryNodes = renderNodes(rootNodes, attrs, this.nodeGraphApi);
+    const labelNodes: NodeGraphNode[] = this.labels.map((label) => ({
+      id: label.id,
+      pos: {x: label.x, y: label.y},
+      hue: 0,
+      content: label.content,
+      className: 'pf-ngd__label',
+      canDockTop: false,
+      canDockBottom: false,
+    }));
+    const nodes = [...queryNodes, ...labelNodes];
     const connections = buildConnections(rootNodes, attrs.nodeLayouts);
 
     // Detect if loadGeneration has changed (indicates a load operation occurred)
@@ -846,21 +870,7 @@
       }
     }
 
-    // Perform auto-layout if nodeLayouts is empty and API is available
     if (
-      !this.hasPerformedInitialLayout &&
-      this.nodeGraphApi &&
-      attrs.nodeLayouts.size === 0 &&
-      nodes.length > 0
-    ) {
-      this.hasPerformedInitialLayout = true;
-      this.hasPerformedInitialRecenter = true;
-      // Call autoLayout to arrange nodes hierarchically
-      // autoLayout will call onNodeMove for each node it repositions
-      this.nodeGraphApi.autoLayout();
-      // Recenter will happen in the onReady callback after the next render
-      this.recenterRequired = true;
-    } else if (
       !this.hasPerformedInitialRecenter &&
       this.nodeGraphApi &&
       nodes.length > 0
@@ -893,12 +903,12 @@
           const ZOOM_STEP = 0.1;
           if (e.shiftKey && this.nodeGraphApi !== null) {
             if (e.code === 'KeyW') {
-              this.nodeGraphApi.zoomBy(ZOOM_STEP);
+              this.nodeGraphApi.zoom(ZOOM_STEP);
               e.preventDefault();
               return;
             }
             if (e.code === 'KeyS') {
-              this.nodeGraphApi.zoomBy(-ZOOM_STEP);
+              this.nodeGraphApi.zoom(-ZOOM_STEP);
               e.preventDefault();
               return;
             }
@@ -914,7 +924,7 @@
           };
           const pan = panMap[e.code];
           if (pan !== undefined && this.nodeGraphApi !== null) {
-            this.nodeGraphApi.panBy(pan[0], pan[1]);
+            this.nodeGraphApi.pan(pan[0], pan[1]);
             e.preventDefault();
           }
         },
@@ -926,10 +936,7 @@
           selectedNodeIds: attrs.selectedNodes,
           hideControls: true,
           fillHeight: true,
-          // Hide the graph while a recenter is pending to avoid a flash of
-          // un-centered content.
-          style: this.pendingRecenter ? {visibility: 'hidden'} : undefined,
-          onReady: (api: NodeGraphApi) => {
+          onReady: (api: NodeGraphAPI) => {
             this.nodeGraphApi = api;
 
             if (this.recenterRequired) {
@@ -944,64 +951,57 @@
               }
 
               this.recenterRequired = false;
-              api.recenter();
+              api.autofit();
               if (this.pendingRecenter) {
                 this.pendingRecenter = false;
                 m.redraw();
               }
             }
           },
-          multiselect: true,
-          onNodeSelect: (nodeId: string) => {
-            const qnode = findQueryNode(nodeId, rootNodes);
-            if (qnode) {
-              attrs.onNodeSelected(qnode);
+          onSelect: (nodeIds: string[]) => {
+            if (nodeIds.length === 0) {
+              attrs.onDeselect();
+            } else {
+              const qnode = findQueryNode(nodeIds[0], rootNodes);
+              if (qnode) attrs.onNodeSelected(qnode);
             }
           },
-          onNodeAddToSelection: (nodeId: string) => {
+          onSelectionAdd: (nodeId: string) => {
             const qnode = findQueryNode(nodeId, rootNodes);
             if (qnode) {
               attrs.onNodeAddToSelection(qnode);
             }
           },
-          onNodeRemoveFromSelection: (nodeId: string) => {
+          onSelectionRemove: (nodeId: string) => {
             attrs.onNodeRemoveFromSelection(nodeId);
           },
           onSelectionClear: () => {
             attrs.onDeselect();
           },
-          onNodeMove: (nodeId: string, x: number, y: number) => {
-            attrs.onNodeLayoutChange(nodeId, {x, y});
+          onNodeMove: (nodeId: string, pos: Point2D) => {
+            // Labels are stored in component state, not in the layout map
+            const label = this.labels.find((l) => l.id === nodeId);
+            if (label) {
+              label.x = pos.x;
+              label.y = pos.y;
+              this.notifyLabelsChanged(attrs);
+              return;
+            }
+            attrs.onNodeLayoutChange(nodeId, pos);
           },
-          onConnect: (conn: Connection) => {
+          onConnect: (conn: NodeGraphConnection) => {
             handleConnect(conn, rootNodes);
           },
-          onConnectionRemove: (index: number) => {
+          onDisconnect: (index: number) => {
             handleConnectionRemove(
               connections[index],
               rootNodes,
               attrs.onConnectionRemove,
             );
           },
-          onNodeRemove: (nodeId: string) => {
-            const qnode = findQueryNode(nodeId, rootNodes);
-            if (qnode) {
-              attrs.onDeleteNode(qnode);
-            }
-          },
-          onUndock: (
-            _parentId: string,
-            nodeId: string,
-            x: number,
-            y: number,
-          ) => {
-            // Store the new position in the layout map so node becomes independent
-            attrs.onNodeLayoutChange(nodeId, {x, y});
-            m.redraw();
-          },
-          onDock: (targetId: string, childNode: Omit<Node, 'x' | 'y'>) => {
+          onNodeDock: (nodeId: string, targetId: string) => {
             const parentNode = findQueryNode(targetId, rootNodes);
-            const childQueryNode = findQueryNode(childNode.id, rootNodes);
+            const childQueryNode = findQueryNode(nodeId, rootNodes);
 
             if (!parentNode || !childQueryNode) {
               console.warn('Cannot dock: parent or child node not found');
@@ -1046,41 +1046,10 @@
             }
 
             // Dock the child
-            attrs.nodeLayouts.delete(childNode.id);
+            attrs.nodeLayouts.delete(nodeId);
             addConnection(parentNode, childQueryNode);
             m.redraw();
           },
-          contextMenuOnHover: true,
-          labels: createEditableTextLabels(
-            this.labels,
-            this.labelTexts,
-            this.editingLabels,
-            () => this.notifyLabelsChanged(attrs),
-          ),
-          onLabelMove: (labelId: string, x: number, y: number) => {
-            const label = this.labels.find((l) => l.id === labelId);
-            if (label) {
-              label.x = x;
-              label.y = y;
-              this.notifyLabelsChanged(attrs);
-            }
-          },
-          onLabelResize: (labelId: string, width: number) => {
-            const label = this.labels.find((l) => l.id === labelId);
-            if (label) {
-              label.width = width;
-              this.notifyLabelsChanged(attrs);
-            }
-          },
-          onLabelRemove: (labelId: string) => {
-            const labelIndex = this.labels.findIndex((l) => l.id === labelId);
-            if (labelIndex !== -1) {
-              this.labels.splice(labelIndex, 1);
-            }
-            this.labelTexts.delete(labelId);
-            this.notifyLabelsChanged(attrs);
-            m.redraw();
-          },
         } satisfies NodeGraphAttrs),
         this.renderControls(attrs),
       ],
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/node_config.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/node_config.ts
index d76ea04..928ae8f 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/node_config.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/node_config.ts
@@ -12,7 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {Node, NodePort} from '../../../../widgets/nodegraph';
+import {
+  NodeGraphDockedNode,
+  NodeGraphPort,
+} from '../../../../widgets/nodegraph';
 import {QueryNode, NodeType, singleNodeOperation} from '../../query_node';
 
 function getPortName(
@@ -70,16 +73,19 @@
   }
 }
 
+// Port ID helpers for read-only preview nodes.
+// The '|' separator is safe since node IDs are UUIDs (hex and '-' only).
+export const getReadOnlyOutputPortId = (nodeId: string) => `${nodeId}|out`;
+export const getReadOnlyTopPortId = (nodeId: string) => `${nodeId}|in-top`;
+export const getReadOnlyLeftPortId = (nodeId: string, i: number) =>
+  `${nodeId}|in-left-${i}`;
+
 /**
- * Set of "nodeId:portIndex" strings identifying ports that have connections.
+ * Set of port ID strings identifying ports that have connections.
  * Used to hide unused ports in read-only previews.
  */
 export type ConnectedPorts = ReadonlySet<string>;
 
-function portKey(nodeId: string, portIndex: number): string {
-  return `${nodeId}:${portIndex}`;
-}
-
 /**
  * Builds a read-only Node config for preview purposes (e.g. group node
  * inner graph). Only depends on QueryNode and nodegraph — no graph_utils
@@ -90,45 +96,37 @@
   innerSet: ReadonlySet<string>,
   connectedInputs?: ConnectedPorts,
   connectedOutputs?: ConnectedPorts,
-): Omit<Node, 'x' | 'y'> {
+): NodeGraphDockedNode {
   const isSingle = singleNodeOperation(qnode.type);
-  const inputs: NodePort[] = [];
-  let portIdx = 0;
+  const inputs: NodeGraphPort[] = [];
 
   if (isSingle) {
-    // Top port (primary input) — only show if connected or no filter provided.
-    if (
-      connectedInputs === undefined ||
-      connectedInputs.has(portKey(qnode.nodeId, portIdx))
-    ) {
-      inputs.push({direction: 'top'});
+    // North port (primary input) — only show if connected or no filter provided.
+    const portId = getReadOnlyTopPortId(qnode.nodeId);
+    if (connectedInputs === undefined || connectedInputs.has(portId)) {
+      inputs.push({id: portId, direction: 'north'});
     }
-    portIdx++;
   }
   if (qnode.secondaryInputs) {
     const portNames = qnode.secondaryInputs.portNames;
     let secIdx = 0;
     for (const [,] of qnode.secondaryInputs.connections) {
-      if (
-        connectedInputs === undefined ||
-        connectedInputs.has(portKey(qnode.nodeId, portIdx))
-      ) {
+      const portId = getReadOnlyLeftPortId(qnode.nodeId, secIdx);
+      if (connectedInputs === undefined || connectedInputs.has(portId)) {
         inputs.push({
-          content: getPortName(portNames, secIdx),
-          direction: 'left',
+          id: portId,
+          label: getPortName(portNames, secIdx),
+          direction: 'west',
         });
       }
-      portIdx++;
       secIdx++;
     }
   }
 
-  const outputs: NodePort[] = [];
-  if (
-    connectedOutputs === undefined ||
-    connectedOutputs.has(portKey(qnode.nodeId, 0))
-  ) {
-    outputs.push({direction: 'bottom'});
+  const outputs: NodeGraphPort[] = [];
+  const outPortId = getReadOnlyOutputPortId(qnode.nodeId);
+  if (connectedOutputs === undefined || connectedOutputs.has(outPortId)) {
+    outputs.push({id: outPortId, direction: 'south'});
   }
 
   // Find docked child — the nodegraph widget can route connection lines
@@ -137,7 +135,7 @@
   // multiple children (even if only one is in innerSet), we skip docking to
   // avoid ambiguity — the outer graph's docking logic handles multi-child
   // cases differently via layout positions.
-  let next: Omit<Node, 'x' | 'y'> | undefined;
+  let next: NodeGraphDockedNode | undefined;
   if (
     qnode.nextNodes.length === 1 &&
     singleNodeOperation(qnode.nextNodes[0].type) &&
@@ -154,7 +152,7 @@
 
   return {
     id: qnode.nodeId,
-    titleBar: undefined,
+    headerBar: undefined,
     inputs,
     outputs,
     canDockTop: isSingle,
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/text_label.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/text_label.ts
index 14cf4f4..76126b5 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/text_label.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/graph/text_label.ts
@@ -13,7 +13,15 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {Label} from '../../../../widgets/nodegraph';
+
+// Local label type matching the Label interface used in graph.ts.
+interface Label {
+  id: string;
+  x: number;
+  y: number;
+  width: number;
+  content?: m.Children;
+}
 
 // Helper function to auto-resize textarea to fit content
 function autoResizeTextarea(textarea: HTMLTextAreaElement) {
diff --git a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/group_node/inner_graph_preview.ts b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/group_node/inner_graph_preview.ts
index eb34780..8a5feda 100644
--- a/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/group_node/inner_graph_preview.ts
+++ b/ui/src/plugins/dev.perfetto.DataExplorer/query_builder/nodes/group_node/inner_graph_preview.ts
@@ -16,11 +16,16 @@
 import {singleNodeOperation} from '../../../query_node';
 import {
   NodeGraph,
-  Node as GraphNode,
-  Connection,
-  NodeGraphApi,
+  NodeGraphNode as GraphNode,
+  NodeGraphConnection,
+  NodeGraphAPI,
 } from '../../../../../widgets/nodegraph';
-import {buildReadOnlyNodeConfig} from '../../graph/node_config';
+import {
+  buildReadOnlyNodeConfig,
+  getReadOnlyOutputPortId,
+  getReadOnlyTopPortId,
+  getReadOnlyLeftPortId,
+} from '../../graph/node_config';
 import type {GroupNode} from '.';
 
 interface InnerGraphPreviewAttrs {
@@ -50,7 +55,7 @@
     }
 
     const innerSet = new Set(groupNode.innerNodes.map((n) => n.nodeId));
-    const connections: Connection[] = [];
+    const connections: NodeGraphConnection[] = [];
 
     // Collect docked node IDs (rendered via `next`, not as top-level).
     const dockedIds = new Set<string>();
@@ -75,13 +80,18 @@
       inputNodeIds.set(conn.groupPort, inputId);
 
       const offset = singleNodeOperation(conn.innerTargetNode.type) ? 1 : 0;
-      const toPort =
+      const toPortIndex =
         conn.innerTargetPort !== undefined ? conn.innerTargetPort + offset : 0;
+      const toPortId =
+        toPortIndex === 0
+          ? getReadOnlyTopPortId(conn.innerTargetNode.nodeId)
+          : getReadOnlyLeftPortId(
+              conn.innerTargetNode.nodeId,
+              toPortIndex - offset,
+            );
       connections.push({
-        fromNode: inputId,
-        fromPort: 0,
-        toNode: conn.innerTargetNode.nodeId,
-        toPort,
+        fromPort: getReadOnlyOutputPortId(inputId),
+        toPort: toPortId,
       });
     }
 
@@ -94,22 +104,17 @@
         !dockedIds.has(n.nodeId)
       ) {
         connections.push({
-          fromNode: n.primaryInput.nodeId,
-          fromPort: 0,
-          toNode: n.nodeId,
-          toPort: 0,
+          fromPort: getReadOnlyOutputPortId(n.primaryInput.nodeId),
+          toPort: getReadOnlyTopPortId(n.nodeId),
         });
       }
 
       if (n.secondaryInputs) {
-        const offset = singleNodeOperation(n.type) ? 1 : 0;
         for (const [port, src] of n.secondaryInputs.connections) {
           if (src !== undefined && innerSet.has(src.nodeId)) {
             connections.push({
-              fromNode: src.nodeId,
-              fromPort: 0,
-              toNode: n.nodeId,
-              toPort: port + offset,
+              fromPort: getReadOnlyOutputPortId(src.nodeId),
+              toPort: getReadOnlyLeftPortId(n.nodeId, port),
             });
           }
         }
@@ -120,8 +125,8 @@
     const connectedInputs = new Set<string>();
     const connectedOutputs = new Set<string>();
     for (const c of connections) {
-      connectedInputs.add(`${c.toNode}:${c.toPort}`);
-      connectedOutputs.add(`${c.fromNode}:${c.fromPort}`);
+      connectedInputs.add(c.toPort);
+      connectedOutputs.add(c.fromPort);
     }
 
     // Docked nodes' primary input (port 0) is rendered via `next`, not as
@@ -129,7 +134,7 @@
     // filtered out — otherwise secondary input port indices shift and
     // connections target the wrong port.
     for (const dockedId of dockedIds) {
-      connectedInputs.add(`${dockedId}:0`);
+      connectedInputs.add(getReadOnlyTopPortId(dockedId));
     }
 
     // Build nodes with only connected ports.
@@ -145,8 +150,7 @@
           connectedInputs,
           connectedOutputs,
         ),
-        x: i * nodeSpacingX,
-        y: 0,
+        pos: {x: i * nodeSpacingX, y: 0},
       });
     }
 
@@ -161,10 +165,10 @@
       if (inputId === undefined) continue;
       nodes.push({
         id: inputId,
-        x: i * nodeSpacingX,
-        y: -80,
-        titleBar: {title: `Input ${conn.groupPort + 1}`},
-        outputs: [{direction: 'bottom'}],
+        pos: {x: i * nodeSpacingX, y: -80},
+        hue: 0,
+        headerBar: {title: `Input ${conn.groupPort + 1}`},
+        outputs: [{id: getReadOnlyOutputPortId(inputId), direction: 'south'}],
         className: isConnected ? undefined : 'pf-node--disconnected',
       });
     }
@@ -182,10 +186,10 @@
           connections,
           hideControls: true,
           fillHeight: true,
-          onReady: (api: NodeGraphApi) => {
+          onReady: (api: NodeGraphAPI) => {
             if (!this.recentered) {
               this.recentered = true;
-              requestAnimationFrame(() => api.recenter());
+              requestAnimationFrame(() => api.autofit());
             }
           },
         }),
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.scss b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.scss
new file mode 100644
index 0000000..62a11bb
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.scss
@@ -0,0 +1,25 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+.pf-ng__card.pf-ngd__label {
+  // border-radius: 0;
+
+  .pf-ng__card-body {
+    // padding: 0;
+
+    textarea {
+      color: var(--pf-color-text);
+    }
+  }
+}
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts
index 86ab290..7e9f207 100644
--- a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts
+++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts
@@ -12,25 +12,28 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import m from 'mithril';
 import {produce} from 'immer';
-import {uuidv4} from '../../../base/uuid';
+import m from 'mithril';
+import {Point2D} from '../../../base/geom';
+import {shortUuid} from '../../../base/uuid';
 import {Button, ButtonGroup, ButtonVariant} from '../../../widgets/button';
 import {Checkbox} from '../../../widgets/checkbox';
 import {MenuItem, PopupMenu} from '../../../widgets/menu';
 import {
-  Connection,
-  Label,
-  Node,
+  NodeGraphConnection,
+  NodeGraphNode,
   NodeGraph,
-  NodeGraphApi,
+  NodeGraphAPI,
   NodeGraphAttrs,
-  NodePort,
+  NodeGraphPort,
 } from '../../../widgets/nodegraph';
 import {Combobox} from '../../../widgets/combobox';
 import {Select} from '../../../widgets/select';
 import {TextInput} from '../../../widgets/text_input';
 import {renderDocSection, renderWidgetShowcase} from '../widgets_page_utils';
+import {maybeUndefined} from '../../../base/utils';
+import {assertIsInstance} from '../../../base/assert';
+import {MithrilEvent} from '../../../base/mithril_utils';
 
 const MAX_HISTORY_DEPTH = 500;
 
@@ -39,7 +42,10 @@
   readonly id: string;
   x: number;
   y: number;
-  nextId?: string;
+  next?: NodeData;
+  // Ordered list of source node IDs feeding into each manifest input slot.
+  // undefined means the slot is unconnected.
+  inputNodeIds?: (string | undefined)[];
 }
 
 // Individual node type interfaces
@@ -75,407 +81,447 @@
   readonly unionType: 'UNION' | 'UNION ALL';
 }
 
-interface ResultNodeData extends BaseNodeData {
-  readonly type: 'result';
-}
-
-// Discriminated union of all node types
+// Discriminated union of all pipeline node types
 type NodeData =
   | TableNodeData
   | SelectNodeData
   | FilterNodeData
   | SortNodeData
   | JoinNodeData
-  | UnionNodeData
-  | ResultNodeData;
+  | UnionNodeData;
+
+// Labels are a separate entity: free-floating annotations, no ports, no docking
+interface LabelData {
+  readonly id: string;
+  x: number;
+  y: number;
+  text: string;
+}
 
 // Store interface (only data that should be in undo/redo history)
 interface NodeGraphStore {
-  readonly nodes: Map<string, NodeData>;
-  readonly connections: Connection[];
-  readonly labels: Label[];
-  readonly invalidNodes: Set<string>; // Track which nodes are marked as invalid
+  readonly nodes: NodeData[]; // only root nodes; docked nodes are nested via .next
+  readonly labels: LabelData[];
 }
 
-// Node metadata configuration
-interface NodeConfig {
-  readonly inputs?: ReadonlyArray<NodePort>;
-  readonly outputs?: ReadonlyArray<NodePort>;
-  readonly canDockTop?: boolean;
-  readonly canDockBottom?: boolean;
-  readonly hue: number;
+// Single source of truth per node type: metadata, factory, and renderer.
+// C is the specific NodeData subtype (e.g. TableNodeData).
+interface NodeTypeManifest<C extends NodeData> {
+  readonly title: string;
   readonly icon: string;
+  readonly hue: number;
+  readonly inputs?: ReadonlyArray<{
+    readonly id: string;
+    readonly label: string;
+    readonly direction?: NodeGraphPort['direction'];
+  }>;
+  create(id: string, x: number, y: number): C;
+  render(node: C, update: (updates: Partial<C>) => void): m.Children;
 }
 
-const NODE_CONFIGS: Record<NodeData['type'], NodeConfig> = {
+interface LabelContentAttrs {
+  readonly text: string;
+  readonly update: (text: string) => void;
+}
+
+function LabelContent(): m.Component<LabelContentAttrs> {
+  let editing = false;
+  let lastPointerDownTime = 0;
+  let labelElement!: HTMLTextAreaElement;
+
+  return {
+    view({attrs}: m.Vnode<LabelContentAttrs>) {
+      const {text, update} = attrs;
+      return m(
+        '',
+        {
+          // Use pointerdown-based double-click detection instead of ondblclick.
+          // captureDrag calls setPointerCapture on the first click, which can
+          // redirect the second click's events in some browsers, preventing
+          // the native dblclick event from firing on the original element.
+          onpointerdown: (e: PointerEvent) => {
+            if (editing) {
+              e.stopPropagation();
+              return;
+            }
+            const now = Date.now();
+            if (now - lastPointerDownTime < 400) {
+              editing = true;
+              e.stopPropagation();
+              m.redraw();
+              // Focus after redraw so the textarea is no longer readonly.
+              requestAnimationFrame(() => labelElement.focus());
+            }
+            lastPointerDownTime = now;
+          },
+        },
+        m('textarea', {
+          readonly: !editing,
+          style: {
+            resize: editing ? 'both' : 'none',
+            width: '160px',
+            height: '60px',
+            border: 'none',
+            borderRadius: '4px',
+            background: editing ? 'var(--pf-color-background)' : 'transparent',
+            font: 'inherit',
+            cursor: editing ? 'text' : 'inherit',
+            pointerEvents: editing ? 'auto' : 'none',
+            outline: editing ? '2px solid var(--pf-color-primary)' : 'none',
+            padding: editing ? '4px' : '0',
+          },
+          value: text,
+          onchange: (e: Event) =>
+            update((e.target as HTMLTextAreaElement).value),
+          onkeydown: (e: MithrilEvent<KeyboardEvent>) => {
+            e.redraw = false;
+
+            if (editing) {
+              e.stopPropagation();
+              if (e.key === 'Escape') {
+                editing = false;
+                e.redraw = true;
+              }
+            }
+          },
+          onpointerdown: (e: PointerEvent) => {
+            if (editing) e.stopPropagation();
+          },
+          onblur: (e: FocusEvent) => {
+            console.log('Label blur', e);
+            editing = false;
+            const ta = e.target as HTMLTextAreaElement;
+            ta.setSelectionRange(0, 0);
+          },
+        }),
+      );
+    },
+    oncreate({dom}: m.VnodeDOM<LabelContentAttrs>) {
+      labelElement = assertIsInstance(
+        dom.querySelector('textarea'),
+        HTMLTextAreaElement,
+      );
+    },
+  };
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const NODE_CONFIGS: {[K in NodeData['type']]: NodeTypeManifest<any>} = {
   table: {
-    canDockBottom: true,
-    hue: 200,
+    title: 'Table',
     icon: 'table_chart',
+    hue: 200,
+    create: (id, x, y): TableNodeData => ({
+      type: 'table',
+      id,
+      x,
+      y,
+      table: 'slice',
+    }),
+    render: (node: TableNodeData, update) =>
+      m(
+        Select,
+        {
+          value: node.table,
+          onchange: (e: Event) =>
+            update({table: (e.target as HTMLSelectElement).value}),
+        },
+        [
+          m('option', {value: 'slice'}, 'slice'),
+          m('option', {value: 'sched'}, 'sched'),
+          m('option', {value: 'thread'}, 'thread'),
+          m('option', {value: 'process'}, 'process'),
+        ],
+      ),
   },
+
   select: {
-    outputs: [{content: 'Output', direction: 'right'}],
-    canDockTop: true,
-    hue: 100,
+    title: 'Select',
     icon: 'checklist',
+    hue: 100,
+    inputs: [{label: 'Input', id: 'input', direction: 'north' as const}],
+    create: (id, x, y): SelectNodeData => ({
+      type: 'select',
+      id,
+      x,
+      y,
+      columns: {
+        id: true,
+        name: true,
+        cpu: false,
+        duration: false,
+        timestamp: false,
+      },
+    }),
+    render: (node: SelectNodeData, update) =>
+      m(
+        '',
+        {style: {display: 'flex', flexDirection: 'column', gap: '4px'}},
+        Object.entries(node.columns).map(([col, checked]) =>
+          m(Checkbox, {
+            label: col,
+            checked,
+            onchange: () =>
+              update({columns: {...node.columns, [col]: !checked}}),
+          }),
+        ),
+      ),
   },
+
   filter: {
-    canDockTop: true,
-    canDockBottom: true,
-    hue: 50,
+    title: 'Filter',
     icon: 'filter_alt',
+    hue: 50,
+    inputs: [{label: 'Input', id: 'input', direction: 'north' as const}],
+    create: (id, x, y): FilterNodeData => ({
+      type: 'filter',
+      id,
+      x,
+      y,
+      filterExpression: '',
+    }),
+    render: (node: FilterNodeData, update) =>
+      m(TextInput, {
+        placeholder: 'Filter expression...',
+        value: node.filterExpression,
+        oninput: (e: InputEvent) =>
+          update({filterExpression: (e.target as HTMLInputElement).value}),
+      }),
   },
+
   sort: {
-    canDockTop: true,
-    canDockBottom: true,
-    hue: 150,
+    title: 'Sort',
     icon: 'sort',
+    hue: 150,
+    inputs: [{label: 'Input', id: 'input', direction: 'north' as const}],
+    create: (id, x, y): SortNodeData => ({
+      type: 'sort',
+      id,
+      x,
+      y,
+      sortColumn: '',
+      sortOrder: 'ASC',
+    }),
+    render: (node: SortNodeData, update) =>
+      m('', {style: {display: 'flex', flexDirection: 'column', gap: '4px'}}, [
+        m(TextInput, {
+          placeholder: 'Sort column...',
+          value: node.sortColumn,
+          oninput: (e: InputEvent) =>
+            update({sortColumn: (e.target as HTMLInputElement).value}),
+        }),
+        m(
+          Select,
+          {
+            value: node.sortOrder,
+            onchange: (e: Event) =>
+              update({
+                sortOrder: (e.target as HTMLSelectElement).value as
+                  | 'ASC'
+                  | 'DESC',
+              }),
+          },
+          [
+            m('option', {value: 'ASC'}, 'ASC'),
+            m('option', {value: 'DESC'}, 'DESC'),
+          ],
+        ),
+      ]),
   },
+
   join: {
-    inputs: [{content: 'Right', direction: 'left'}],
-    canDockTop: true,
-    canDockBottom: true,
-    hue: 300,
+    title: 'Join',
     icon: 'join',
-  },
-  union: {
+    hue: 300,
     inputs: [
-      {content: 'Input 1', direction: 'left'},
-      {content: 'Input 2', direction: 'left'},
+      {label: 'Left', id: 'left', direction: 'north' as const},
+      {label: 'Right', id: 'right', direction: 'west' as const},
     ],
-    canDockBottom: true,
-    hue: 240,
-    icon: 'merge',
+    create: (id, x, y): JoinNodeData => ({
+      type: 'join',
+      id,
+      x,
+      y,
+      joinType: 'INNER',
+      joinOn: '',
+    }),
+    render: (node: JoinNodeData, update) =>
+      m('', {style: {display: 'flex', flexDirection: 'column', gap: '4px'}}, [
+        m(
+          Select,
+          {
+            value: node.joinType,
+            onchange: (e: Event) =>
+              update({
+                joinType: (e.target as HTMLSelectElement)
+                  .value as JoinNodeData['joinType'],
+              }),
+          },
+          [
+            m('option', {value: 'INNER'}, 'INNER'),
+            m('option', {value: 'LEFT'}, 'LEFT'),
+            m('option', {value: 'RIGHT'}, 'RIGHT'),
+            m('option', {value: 'FULL'}, 'FULL'),
+          ],
+        ),
+        m(TextInput, {
+          placeholder: 'ON condition...',
+          value: node.joinOn,
+          oninput: (e: InputEvent) =>
+            update({joinOn: (e.target as HTMLInputElement).value}),
+        }),
+      ]),
   },
-  result: {
-    canDockTop: true,
-    hue: 0,
-    icon: 'output',
+
+  union: {
+    title: 'Union',
+    icon: 'merge',
+    hue: 240,
+    inputs: [
+      {label: 'Input 1', id: 'input-1', direction: 'north' as const},
+      {label: 'Input 2', id: 'input-2', direction: 'west' as const},
+    ],
+    create: (id, x, y): UnionNodeData => ({
+      type: 'union',
+      id,
+      x,
+      y,
+      unionType: 'UNION ALL',
+    }),
+    render: (node: UnionNodeData, update) =>
+      m(
+        Select,
+        {
+          value: node.unionType,
+          onchange: (e: Event) =>
+            update({
+              unionType: (e.target as HTMLSelectElement)
+                .value as UnionNodeData['unionType'],
+            }),
+        },
+        [
+          m('option', {value: 'UNION'}, 'UNION'),
+          m('option', {value: 'UNION ALL'}, 'UNION ALL'),
+        ],
+      ),
   },
 };
 
-// Factory functions for creating node data
-function createTableNode(id: string, x: number, y: number): TableNodeData {
-  return {
-    type: 'table',
-    id,
-    x,
-    y,
-    table: 'slice',
-  };
+// Assign stable per-node port IDs to a list of port templates.
+// Input port i on node `nodeId` gets id `${nodeId}-in-${i}`.
+function makeInputPorts(
+  nodeId: string,
+  templates:
+    | ReadonlyArray<{
+        id: string;
+        label: string;
+        direction?: NodeGraphPort['direction'];
+      }>
+    | undefined,
+): NodeGraphPort[] | undefined {
+  if (!templates) return undefined;
+  return templates.map((t) => ({
+    direction: t.direction ?? 'west',
+    id: `${nodeId}-in-${t.id}`,
+    label: t.label,
+  }));
 }
 
-function createSelectNode(id: string, x: number, y: number): SelectNodeData {
-  return {
-    type: 'select',
-    id,
-    x,
-    y,
-    columns: {
-      id: true,
-      name: true,
-      cpu: false,
-      duration: false,
-      timestamp: false,
-    },
-  };
-}
-
-function createFilterNode(id: string, x: number, y: number): FilterNodeData {
-  return {
-    type: 'filter',
-    id,
-    x,
-    y,
-    filterExpression: '',
-  };
-}
-
-function createSortNode(id: string, x: number, y: number): SortNodeData {
-  return {
-    type: 'sort',
-    id,
-    x,
-    y,
-    sortColumn: '',
-    sortOrder: 'ASC',
-  };
-}
-
-function createJoinNode(id: string, x: number, y: number): JoinNodeData {
-  return {
-    type: 'join',
-    id,
-    x,
-    y,
-    joinType: 'INNER',
-    joinOn: '',
-  };
-}
-
-function createUnionNode(id: string, x: number, y: number): UnionNodeData {
-  return {
-    type: 'union',
-    id,
-    x,
-    y,
-    unionType: 'UNION ALL',
-  };
-}
-
-function createResultNode(id: string, x: number, y: number): ResultNodeData {
-  return {
-    type: 'result',
-    id,
-    x,
-    y,
-  };
-}
-
-// Pure render functions for each node type
-const TABLE_SUGGESTIONS = [
-  'slice',
-  'sched_slice',
-  'thread_state',
-  'thread',
-  'process',
-  'counter',
-  'android_logs',
-  'cpu_counter_track',
-  'gpu_slice',
-  'gpu_track',
-  'gpu_counter_track',
-  'heap_graph_object',
-  'heap_profile_allocation',
-  'perf_sample',
-  'stack_profile_frame',
-  'stack_profile_callsite',
-  'ftrace_event',
-  'flow',
-  'args',
-  'raw',
-  'metadata',
-  'trace_stats',
-];
-
-function renderTableNode(
-  node: TableNodeData,
-  updateNode: (updates: Partial<Omit<TableNodeData, 'type' | 'id'>>) => void,
-): m.Children {
-  return m(Combobox, {
-    value: node.table,
-    suggestions: TABLE_SUGGESTIONS,
-    placeholder: 'Table name...',
-    icon: 'table_chart',
-    onChange: (value: string) => updateNode({table: value}),
-  });
-}
-
-function renderSelectNode(
-  node: SelectNodeData,
-  updateNode: (updates: Partial<Omit<SelectNodeData, 'type' | 'id'>>) => void,
-): m.Children {
-  return m(
-    '',
-    {style: {display: 'flex', flexDirection: 'column', gap: '4px'}},
-    Object.entries(node.columns).map(([col, checked]) =>
-      m(Checkbox, {
-        label: col,
-        checked,
-        onchange: () => {
-          updateNode({
-            columns: {
-              ...node.columns,
-              [col]: !checked,
-            },
-          });
-        },
-      }),
-    ),
-  );
-}
-
-function renderFilterNode(
-  node: FilterNodeData,
-  updateNode: (updates: Partial<Omit<FilterNodeData, 'type' | 'id'>>) => void,
-): m.Children {
-  return m(TextInput, {
-    placeholder: 'Filter expression...',
-    value: node.filterExpression,
-    oninput: (e: InputEvent) => {
-      const target = e.target as HTMLInputElement;
-      updateNode({filterExpression: target.value});
-    },
-  });
-}
-
-function renderSortNode(
-  node: SortNodeData,
-  updateNode: (updates: Partial<Omit<SortNodeData, 'type' | 'id'>>) => void,
-): m.Children {
-  return m(
-    '',
-    {style: {display: 'flex', flexDirection: 'column', gap: '4px'}},
-    [
-      m(TextInput, {
-        placeholder: 'Sort column...',
-        value: node.sortColumn,
-        oninput: (e: InputEvent) => {
-          const target = e.target as HTMLInputElement;
-          updateNode({sortColumn: target.value});
-        },
-      }),
-      m(
-        Select,
-        {
-          value: node.sortOrder,
-          onchange: (e: Event) => {
-            updateNode({
-              sortOrder: (e.target as HTMLSelectElement).value as
-                | 'ASC'
-                | 'DESC',
-            });
-          },
-        },
-        [
-          m('option', {value: 'ASC'}, 'ASC'),
-          m('option', {value: 'DESC'}, 'DESC'),
-        ],
-      ),
-    ],
-  );
-}
-
-function renderJoinNode(
-  node: JoinNodeData,
-  updateNode: (updates: Partial<Omit<JoinNodeData, 'type' | 'id'>>) => void,
-): m.Children {
-  return m(
-    '',
-    {style: {display: 'flex', flexDirection: 'column', gap: '4px'}},
-    [
-      m(
-        Select,
-        {
-          value: node.joinType,
-          onchange: (e: Event) => {
-            updateNode({
-              joinType: (e.target as HTMLSelectElement)
-                .value as JoinNodeData['joinType'],
-            });
-          },
-        },
-        [
-          m('option', {value: 'INNER'}, 'INNER'),
-          m('option', {value: 'LEFT'}, 'LEFT'),
-          m('option', {value: 'RIGHT'}, 'RIGHT'),
-          m('option', {value: 'FULL'}, 'FULL'),
-        ],
-      ),
-      m(TextInput, {
-        placeholder: 'ON condition...',
-        value: node.joinOn,
-        oninput: (e: InputEvent) => {
-          const target = e.target as HTMLInputElement;
-          updateNode({joinOn: target.value});
-        },
-      }),
-    ],
-  );
-}
-
-function renderUnionNode(
-  node: UnionNodeData,
-  updateNode: (updates: Partial<Omit<UnionNodeData, 'type' | 'id'>>) => void,
-): m.Children {
-  return m(
-    Select,
-    {
-      value: node.unionType,
-      onchange: (e: Event) => {
-        updateNode({
-          unionType: (e.target as HTMLSelectElement)
-            .value as UnionNodeData['unionType'],
-        });
-      },
-    },
-    [
-      m('option', {value: 'UNION'}, 'UNION'),
-      m('option', {value: 'UNION ALL'}, 'UNION ALL'),
-    ],
-  );
-}
-
-function renderResultNode(): m.Children {
-  return 'Result';
-}
-
-// Master renderer with type narrowing
-function renderNodeContent(
-  node: NodeData,
-  updateNode: (updates: Partial<Omit<NodeData, 'id'>>) => void,
-): m.Children {
-  switch (node.type) {
-    case 'table':
-      return renderTableNode(node, updateNode);
-    case 'select':
-      return renderSelectNode(node, updateNode);
-    case 'filter':
-      return renderFilterNode(node, updateNode);
-    case 'sort':
-      return renderSortNode(node, updateNode);
-    case 'join':
-      return renderJoinNode(node, updateNode);
-    case 'union':
-      return renderUnionNode(node, updateNode);
-    case 'result':
-      return renderResultNode();
-  }
+function createLabel(id: string, x: number, y: number): LabelData {
+  return {id, x, y, text: 'Label'};
 }
 
 interface NodeGraphDemoAttrs {
-  readonly multiselect?: boolean;
   readonly titleBars?: boolean;
   readonly headerIcons?: boolean;
   readonly accentBars?: boolean;
-  readonly colors?: boolean;
   readonly contextMenus?: boolean;
-  readonly contextMenuOnHover?: boolean;
 }
 
 export function NodeGraphDemo(): m.Component<NodeGraphDemoAttrs> {
-  let graphApi: NodeGraphApi | undefined;
+  let graphApi!: NodeGraphAPI;
+  let nodeMenuSearch = '';
 
-  // Initialize store with a single table node
-  const initialId = uuidv4();
+  // Pipeline 1:  slice ──► filter ──┐
+  //                                   ├──► join ──► sort ──► select
+  //              sched ──────────────┘
+  const sliceId = shortUuid();
+  const schedId = shortUuid();
+  const filterId = shortUuid();
+  const joinId = shortUuid();
+  const sortId = shortUuid();
+  const selectId = shortUuid();
+  const label1Id = shortUuid();
+
+  // Pipeline 2:  thread  ──┐
+  //                         ├──► union ──► sort2
+  //              process ──┘
+  const threadId = shortUuid();
+  const processId = shortUuid();
+  const unionId = shortUuid();
+  const sort2Id = shortUuid();
+  const label2Id = shortUuid();
+
   let store: NodeGraphStore = {
-    nodes: new Map([[initialId, createTableNode(initialId, 150, 100)]]),
-    connections: [],
-    labels: [
+    nodes: [
+      // Pipeline 1 — slice+filter are stacked (docked)
       {
-        id: uuidv4(),
-        x: 400,
-        y: 100,
-        width: 180,
-        content: m('.pf-simple-label-text', 'Simple text label'),
+        ...NODE_CONFIGS.table.create(sliceId, 50, 80),
+        next: {
+          ...NODE_CONFIGS.filter.create(filterId, 0, 0),
+          filterExpression: 'dur > 1000',
+        },
+      },
+      {...NODE_CONFIGS.table.create(schedId, 50, 300), table: 'sched'},
+      {
+        ...NODE_CONFIGS.join.create(joinId, 570, 180),
+        joinType: 'INNER',
+        joinOn: 'utid',
+        inputNodeIds: [filterId, schedId], // left=filter, right=sched
       },
       {
-        id: uuidv4(),
-        x: 400,
-        y: 200,
-        width: 180,
-        content: m(
-          '.pf-simple-label-button',
-          m(Button, {
-            label: 'Click me!',
-            onclick: () => {
-              console.log('Label button clicked!');
-            },
-          }),
-        ),
+        ...NODE_CONFIGS.sort.create(sortId, 830, 180),
+        sortColumn: 'dur',
+        sortOrder: 'DESC',
+        inputNodeIds: [joinId],
+      },
+      {
+        ...NODE_CONFIGS.select.create(selectId, 1090, 180),
+        columns: {
+          id: true,
+          name: true,
+          dur: true,
+          cpu: false,
+          timestamp: false,
+        },
+        inputNodeIds: [sortId],
+      },
+
+      // Pipeline 2
+      {...NODE_CONFIGS.table.create(threadId, 50, 700), table: 'thread'},
+      {...NODE_CONFIGS.table.create(processId, 50, 900), table: 'process'},
+      {
+        ...NODE_CONFIGS.union.create(unionId, 310, 790),
+        unionType: 'UNION ALL',
+        inputNodeIds: [threadId, processId],
+      },
+      {
+        ...NODE_CONFIGS.sort.create(sort2Id, 570, 790),
+        sortColumn: 'name',
+        sortOrder: 'ASC',
+        inputNodeIds: [unionId],
       },
     ],
-    invalidNodes: new Set<string>(),
+    labels: [
+      {
+        ...createLabel(label1Id, 50, 490),
+        text: 'Slice ⨝ Sched pipeline\nFilters short slices,\njoins on thread, sorts by duration.',
+      },
+      {
+        ...createLabel(label2Id, 50, 1010),
+        text: 'Thread ∪ Process\nAll named entities,\nsorted alphabetically.',
+      },
+    ],
   };
 
   // History management
@@ -485,35 +531,79 @@
   // Selection state (separate from undo/redo history)
   const selectedNodeIds = new Set<string>();
 
-  // Helper to find the parent node (node that has this node as nextId)
-  function findDockedParent(
-    nodes: Map<string, NodeData>,
-    nodeId: string,
-  ): NodeData | undefined {
-    for (const node of nodes.values()) {
-      if (node.nextId === nodeId) {
-        return node;
+  // Helper to find a node by id, searching recursively through linked chains
+  function findNodeById(nodes: NodeData[], id: string): NodeData | undefined {
+    for (const root of nodes) {
+      let cur: NodeData | undefined = root;
+      while (cur) {
+        if (cur.id === id) return cur;
+        cur = cur.next;
       }
     }
     return undefined;
   }
 
-  // Helper to find input nodes via connections
-  function findConnectedInputs(
-    nodes: Map<string, NodeData>,
-    connections: Connection[],
-    nodeId: string,
-  ): Map<number, NodeData> {
-    const inputs = new Map<number, NodeData>();
-    for (const conn of connections) {
-      if (conn.toNode === nodeId) {
-        const inputNode = nodes.get(conn.fromNode);
-        if (inputNode) {
-          inputs.set(conn.toPort, inputNode);
-        }
+  // Helper to find the parent node (node that has this node as .next)
+  function findParent(
+    nodes: NodeData[],
+    targetId: string,
+  ): NodeData | undefined {
+    for (const root of nodes) {
+      let cur: NodeData | undefined = root;
+      while (cur) {
+        if (cur.next?.id === targetId) return cur;
+        cur = cur.next;
       }
     }
-    return inputs;
+    return undefined;
+  }
+
+  // Flatten all nodes (root + docked chains) into a single array.
+  function flattenNodes(nodes: NodeData[]): NodeData[] {
+    const result: NodeData[] = [];
+    for (const root of nodes) {
+      let cur: NodeData | undefined = root;
+      while (cur) {
+        result.push(cur);
+        cur = cur.next;
+      }
+    }
+    return result;
+  }
+
+  // Derive Connection objects from nodes' inputNodeIds.
+  function deriveConnections(nodes: NodeData[]): NodeGraphConnection[] {
+    const connections: NodeGraphConnection[] = [];
+    for (const node of flattenNodes(nodes)) {
+      if (!node.inputNodeIds) continue;
+      const manifest = NODE_CONFIGS[node.type];
+      node.inputNodeIds.forEach((sourceId, slotIndex) => {
+        if (!sourceId) return;
+        const inputDef = manifest.inputs?.[slotIndex];
+        if (!inputDef) return;
+        connections.push({
+          fromPort: `${sourceId}-out`,
+          toPort: `${node.id}-in-${inputDef.id}`,
+        });
+      });
+    }
+    return connections;
+  }
+
+  // Find which node and input slot a toPort string refers to.
+  function findPortSlot(
+    nodes: NodeData[],
+    toPort: string,
+  ): {node: NodeData; slotIndex: number} | undefined {
+    for (const node of flattenNodes(nodes)) {
+      const manifest = NODE_CONFIGS[node.type];
+      const slotIndex =
+        manifest.inputs?.findIndex(
+          (input) => `${node.id}-in-${input.id}` === toPort,
+        ) ?? -1;
+      if (slotIndex !== -1) return {node, slotIndex};
+    }
+    return undefined;
   }
 
   // Update store with history
@@ -567,35 +657,59 @@
     updates: Partial<Omit<NodeData, 'id'>>,
   ) => {
     updateStore((draft) => {
-      const node = draft.nodes.get(nodeId);
+      const node = findNodeById(draft.nodes, nodeId);
       if (node) {
         Object.assign(node, updates);
       }
     });
   };
 
+  const updateLabel = (
+    labelId: string,
+    updates: Partial<Omit<LabelData, 'id'>>,
+  ) => {
+    updateStore((draft) => {
+      const label = draft.labels.find((l) => l.id === labelId);
+      if (label) Object.assign(label, updates);
+    });
+  };
+
   const removeNode = (nodeId: string) => {
     updateStore((draft) => {
-      const nodeToDelete = draft.nodes.get(nodeId);
+      // Check if it's a label first
+      const labelIdx = draft.labels.findIndex((l) => l.id === nodeId);
+      if (labelIdx !== -1) {
+        draft.labels.splice(labelIdx, 1);
+        selectedNodeIds.delete(nodeId);
+        return;
+      }
+
+      const nodeToDelete = findNodeById(draft.nodes, nodeId);
       if (!nodeToDelete) return;
 
-      // Dock any child node to its parent
-      for (const parent of draft.nodes.values()) {
-        if (parent.nextId === nodeId) {
-          parent.nextId = nodeToDelete.nextId;
+      // Clear any inputNodeIds referencing this node from other nodes.
+      for (const n of flattenNodes(draft.nodes)) {
+        if (!n.inputNodeIds) continue;
+        for (let i = 0; i < n.inputNodeIds.length; i++) {
+          if (n.inputNodeIds[i] === nodeId) n.inputNodeIds[i] = undefined;
         }
       }
 
-      // Remove any connections to/from this node
-      for (let i = draft.connections.length - 1; i >= 0; i--) {
-        const c = draft.connections[i];
-        if (c.fromNode === nodeId || c.toNode === nodeId) {
-          draft.connections.splice(i, 1);
+      const parent = findParent(draft.nodes, nodeId);
+      if (parent) {
+        // Splice out from chain, promoting its child
+        parent.next = nodeToDelete.next;
+      } else {
+        // Root node: remove from array, optionally promoting child to root
+        const idx = draft.nodes.findIndex((n) => n.id === nodeId);
+        if (idx !== -1) {
+          if (nodeToDelete.next) {
+            draft.nodes.splice(idx, 1, nodeToDelete.next as NodeData);
+          } else {
+            draft.nodes.splice(idx, 1);
+          }
         }
       }
-
-      // Finally remove the node
-      draft.nodes.delete(nodeId);
     });
 
     // Clear from selection (outside of store update)
@@ -604,554 +718,254 @@
     console.log(`removeNode: ${nodeId}`);
   };
 
-  const removeLabel = (labelId: string) => {
-    updateStore((draft) => {
-      const labelIndex = draft.labels.findIndex((l) => l.id === labelId);
-      if (labelIndex !== -1) {
-        draft.labels.splice(labelIndex, 1);
-      }
-    });
-
-    // Clear from selection (outside of store update)
-    selectedNodeIds.delete(labelId);
-
-    console.log(`removeLabel: ${labelId}`);
-  };
-
   // Stress test function
   const runStressTest = () => {
     updateStore((draft) => {
       // Clear existing state
-      draft.nodes.clear();
-      draft.connections.length = 0;
-      draft.invalidNodes.clear();
-
-      // Node factory options
-      const nodeFactories = [
-        createTableNode,
-        createSelectNode,
-        createFilterNode,
-        createSortNode,
-        createJoinNode,
-        createUnionNode,
-        createResultNode,
-      ];
+      draft.nodes.length = 0;
+      draft.labels.length = 0;
 
       // Create 100 random nodes
+      const nodeTypes = Object.keys(NODE_CONFIGS) as NodeData['type'][];
       const nodeIds: string[] = [];
       for (let i = 0; i < 100; i++) {
-        const id = uuidv4();
-        const factory =
-          nodeFactories[Math.floor(Math.random() * nodeFactories.length)];
+        const id = shortUuid();
+        const type = nodeTypes[Math.floor(Math.random() * nodeTypes.length)];
         const x = Math.random() * 2000;
         const y = Math.random() * 2000;
-        const newNode = factory(id, x, y);
-
-        draft.nodes.set(id, newNode);
+        draft.nodes.push(NODE_CONFIGS[type].create(id, x, y));
         nodeIds.push(id);
       }
 
-      // Create some stacked (docked) nodes
-      // Aim for ~20 stacks (20% of nodes)
-      const usedInStacks = new Set<string>();
-      let stacksCreated = 0;
-      for (let i = 0; i < 20; i++) {
-        // Find a parent node that can dock (has canDockBottom)
-        const availableParents = nodeIds.filter((id) => {
-          const node = draft.nodes.get(id)!;
-          const config = NODE_CONFIGS[node.type];
-          return config.canDockBottom && !node.nextId && !usedInStacks.has(id);
-        });
+      // Wire ~150 random connections via inputNodeIds.
+      for (let i = 0; i < 150; i++) {
+        const fromId = nodeIds[Math.floor(Math.random() * nodeIds.length)];
+        const toId = nodeIds[Math.floor(Math.random() * nodeIds.length)];
+        if (fromId === toId) continue;
 
-        if (availableParents.length === 0) break;
+        const toNode = findNodeById(draft.nodes, toId);
+        if (!toNode) continue;
 
-        const parentId =
-          availableParents[Math.floor(Math.random() * availableParents.length)];
-        const parent = draft.nodes.get(parentId)!;
+        const numInputs = NODE_CONFIGS[toNode.type].inputs?.length ?? 0;
+        if (numInputs === 0) continue;
 
-        // Find a child node that can dock (has canDockTop)
-        const availableChildren = nodeIds.filter((id) => {
-          const node = draft.nodes.get(id)!;
-          const config = NODE_CONFIGS[node.type];
-          return config.canDockTop && id !== parentId && !usedInStacks.has(id);
-        });
-
-        if (availableChildren.length === 0) continue;
-
-        const childId =
-          availableChildren[
-            Math.floor(Math.random() * availableChildren.length)
-          ];
-
-        // Stack the child under the parent
-        parent.nextId = childId;
-        usedInStacks.add(parentId);
-        usedInStacks.add(childId);
-        stacksCreated++;
+        const slotIndex = Math.floor(Math.random() * numInputs);
+        if (!toNode.inputNodeIds) toNode.inputNodeIds = [];
+        toNode.inputNodeIds[slotIndex] = fromId;
       }
 
-      // Create random connections between nodes
-      // Aim for ~150 connections (1.5 per node on average)
-      const numConnections = 150;
-      for (let i = 0; i < numConnections; i++) {
-        // Pick random nodes
-        const fromNodeId = nodeIds[Math.floor(Math.random() * nodeIds.length)];
-        const toNodeId = nodeIds[Math.floor(Math.random() * nodeIds.length)];
-
-        if (fromNodeId === toNodeId) continue;
-
-        const fromNode = draft.nodes.get(fromNodeId)!;
-        const toNode = draft.nodes.get(toNodeId)!;
-
-        // Check if nodes have compatible ports
-        const fromConfig = NODE_CONFIGS[fromNode.type];
-        const toConfig = NODE_CONFIGS[toNode.type];
-        const numOutputs = fromConfig.outputs?.length ?? 0;
-        const numInputs = toConfig.inputs?.length ?? 0;
-
-        if (numOutputs === 0 || numInputs === 0) continue;
-
-        // Random output and input ports
-        const fromPort = Math.floor(Math.random() * numOutputs);
-        const toPort = Math.floor(Math.random() * numInputs);
-
-        // Check if this connection already exists
-        const exists = draft.connections.some(
-          (c) =>
-            c.fromNode === fromNodeId &&
-            c.toNode === toNodeId &&
-            c.fromPort === fromPort &&
-            c.toPort === toPort,
-        );
-
-        if (!exists) {
-          draft.connections.push({
-            fromNode: fromNodeId,
-            fromPort,
-            toNode: toNodeId,
-            toPort,
-          });
-        }
-      }
-
-      console.log(
-        `Stress test: Created ${draft.nodes.size} nodes, ${draft.connections.length} connections, and ${stacksCreated} stacks`,
-      );
+      console.log(`Stress test: Created ${draft.nodes.length} nodes`);
     });
 
     // Clear selection after stress test
     selectedNodeIds.clear();
   };
 
-  // Build SQL query from a node by traversing upwards
-  function buildSqlFromNode(
-    nodes: Map<string, NodeData>,
-    connections: Connection[],
-    nodeId: string,
-    visited: Set<string> = new Set(),
-  ): string {
-    // Cycle detection: if we've already visited this node in the current path, return empty
-    if (visited.has(nodeId)) {
-      console.warn(`Cycle detected at node ${nodeId}`);
-      return '';
-    }
-
-    const node = nodes.get(nodeId);
-    if (!node) return '';
-
-    // Add current node to visited set for this traversal path
-    const newVisited = new Set(visited);
-    newVisited.add(nodeId);
-
-    // First check for docked parent
-    const dockedParent = findDockedParent(nodes, nodeId);
-    const connectedInputs = findConnectedInputs(nodes, connections, nodeId);
-
-    switch (node.type) {
-      case 'table': {
-        return node.table || 'unknown_table';
-      }
-
-      case 'select': {
-        const selectedCols = Object.entries(node.columns)
-          .filter(([_, checked]) => checked)
-          .map(([col]) => col);
-        const colList = selectedCols.length > 0 ? selectedCols.join(', ') : '*';
-
-        const inputSql = dockedParent
-          ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited)
-          : connectedInputs.get(0)
-            ? buildSqlFromNode(
-                nodes,
-                connections,
-                connectedInputs.get(0)!.id,
-                newVisited,
-              )
-            : '';
-
-        if (!inputSql) return `SELECT ${colList}`;
-        return `SELECT ${colList} FROM (${inputSql})`;
-      }
-
-      case 'filter': {
-        const filterExpr = node.filterExpression || '';
-
-        const inputSql = dockedParent
-          ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited)
-          : connectedInputs.get(0)
-            ? buildSqlFromNode(
-                nodes,
-                connections,
-                connectedInputs.get(0)!.id,
-                newVisited,
-              )
-            : '';
-
-        if (!inputSql) return '';
-        if (!filterExpr) return inputSql;
-        return `SELECT * FROM (${inputSql}) WHERE ${filterExpr}`;
-      }
-
-      case 'sort': {
-        const sortColumn = node.sortColumn || '';
-        const sortOrder = node.sortOrder || 'ASC';
-
-        const inputSql = dockedParent
-          ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited)
-          : connectedInputs.get(0)
-            ? buildSqlFromNode(
-                nodes,
-                connections,
-                connectedInputs.get(0)!.id,
-                newVisited,
-              )
-            : '';
-
-        if (!inputSql) return '';
-        if (!sortColumn) return inputSql;
-        return `SELECT * FROM (${inputSql}) ORDER BY ${sortColumn} ${sortOrder}`;
-      }
-
-      case 'join': {
-        const joinType = node.joinType || 'INNER';
-        const joinOn = node.joinOn || 'true';
-
-        // Join needs two inputs: one docked and one from left connection
-        const leftInput = dockedParent
-          ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited)
-          : '';
-
-        const rightInput = connectedInputs.get(0)
-          ? buildSqlFromNode(
-              nodes,
-              connections,
-              connectedInputs.get(0)!.id,
-              newVisited,
-            )
-          : '';
-
-        if (!leftInput || !rightInput) return leftInput || rightInput || '';
-        return `SELECT * FROM (${leftInput}) ${joinType} JOIN (${rightInput}) ON ${joinOn}`;
-      }
-
-      case 'union': {
-        const unionType = node.unionType || '';
-
-        const inputs: string[] = [];
-
-        // Collect all inputs from left connections (no docked parent for union)
-        if (dockedParent) {
-          inputs.push(
-            buildSqlFromNode(nodes, connections, dockedParent.id, newVisited),
-          );
-        }
-        for (const [_, inputNode] of connectedInputs) {
-          inputs.push(
-            buildSqlFromNode(nodes, connections, inputNode.id, newVisited),
-          );
-        }
-
-        const validInputs = inputs.filter((sql) => sql);
-        if (validInputs.length === 0) return '';
-        if (validInputs.length === 1) return validInputs[0];
-        return validInputs.map((sql) => `(${sql})`).join(` ${unionType} `);
-      }
-
-      case 'result': {
-        const inputSql = dockedParent
-          ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited)
-          : connectedInputs.get(0)
-            ? buildSqlFromNode(
-                nodes,
-                connections,
-                connectedInputs.get(0)!.id,
-                newVisited,
-              )
-            : '';
-        return inputSql;
-      }
-    }
-  }
-
   function renderNodeContextMenu(node: NodeData) {
-    const isInvalid = store.invalidNodes.has(node.id);
     return [
       m(MenuItem, {
-        label: isInvalid ? 'Mark as Valid' : 'Mark as Invalid',
-        icon: isInvalid ? 'check_circle' : 'error',
-        onclick: () => {
-          updateStore((draft) => {
-            if (draft.invalidNodes.has(node.id)) {
-              draft.invalidNodes.delete(node.id);
-            } else {
-              draft.invalidNodes.add(node.id);
-            }
-          });
-          console.log(
-            `Context Menu: Toggle invalid state for ${node.id}: ${!isInvalid}`,
-          );
-        },
-      }),
-      m(MenuItem, {
         label: 'Delete',
         icon: 'delete',
         onclick: () => {
           removeNode(node.id);
-          console.log(`Context Menu: onNodeRemove: ${node.id}`);
         },
       }),
     ];
   }
 
-  // Find root nodes (not referenced by any other node's nextId)
-  function getRootNodeIds(nodes: Map<string, NodeData>): string[] {
-    const referenced = new Set<string>();
-    for (const node of nodes.values()) {
-      if (node.nextId) referenced.add(node.nextId);
-    }
-    return Array.from(nodes.keys()).filter((id) => !referenced.has(id));
-  }
-
   return {
     view: ({attrs}: m.Vnode<NodeGraphDemoAttrs>) => {
-      // Log the SQL queries for all result nodes
-      const queries = [];
-      for (const node of store.nodes.values()) {
-        if (node.type === 'result') {
-          const sql = buildSqlFromNode(store.nodes, store.connections, node.id);
-          queries.push(sql);
-        }
-      }
-      if (queries.length > 0) {
-        console.log('Generated SQL queries for result nodes:', queries);
-      }
-
+      // Produces the "add downstream node" context menu for output ports.
+      // Only pipeline nodes (not table) can be appended downstream.
       function renderAddNodeMenu(toNode: string) {
+        const appendableTypes: NodeData['type'][] = [
+          'select',
+          'filter',
+          'sort',
+          'join',
+          'union',
+        ];
         return [
-          m(MenuItem, {
-            label: 'Select',
-            icon: 'checklist',
-            onclick: () => addNode(createSelectNode, toNode),
-            style: {
-              borderLeft: `4px solid hsl(${NODE_CONFIGS.select.hue}, 60%, 50%)`,
-            },
+          ...appendableTypes.map((type) => {
+            const manifest = NODE_CONFIGS[type];
+            return m(MenuItem, {
+              label: manifest.title,
+              icon: manifest.icon,
+              onclick: () => addNode(type, toNode),
+              style: {borderLeft: `4px solid hsl(${manifest.hue}, 60%, 50%)`},
+            });
           }),
           m(MenuItem, {
-            label: 'Filter',
-            icon: 'filter_alt',
-            onclick: () => addNode(createFilterNode, toNode),
-            style: {
-              borderLeft: `4px solid hsl(${NODE_CONFIGS.filter.hue}, 60%, 50%)`,
-            },
-          }),
-          m(MenuItem, {
-            label: 'Sort',
-            icon: 'sort',
-            onclick: () => addNode(createSortNode, toNode),
-            style: {
-              borderLeft: `4px solid hsl(${NODE_CONFIGS.sort.hue}, 60%, 50%)`,
-            },
-          }),
-          m(MenuItem, {
-            label: 'Join',
-            icon: 'join',
-            onclick: () => addNode(createJoinNode, toNode),
-            style: {
-              borderLeft: `4px solid hsl(${NODE_CONFIGS.join.hue}, 60%, 50%)`,
-            },
-          }),
-          m(MenuItem, {
-            label: 'Union',
-            icon: 'merge',
-            onclick: () => addNode(createUnionNode, toNode),
-            style: {
-              borderLeft: `4px solid hsl(${NODE_CONFIGS.union.hue}, 60%, 50%)`,
-            },
-          }),
-          m(MenuItem, {
-            label: 'Result',
-            icon: 'output',
-            onclick: () => addNode(createResultNode, toNode),
-            style: {
-              borderLeft: `4px solid hsl(${NODE_CONFIGS.result.hue}, 60%, 50%)`,
-            },
+            label: 'Label',
+            icon: 'label',
+            onclick: () => addLabel(),
           }),
         ];
       }
 
-      const addNode = (
-        factory: (id: string, x: number, y: number) => NodeData,
-        toNodeId?: string,
-      ) => {
-        const id = uuidv4();
+      const addNode = (type: NodeData['type'], toNodeId?: string) => {
+        const manifest = NODE_CONFIGS[type];
+        const id = shortUuid();
 
         let x: number;
         let y: number;
 
-        // Use API to find optimal placement if available
-        if (graphApi && !toNodeId) {
-          const tempNode = factory(id, 0, 0);
-          const config = NODE_CONFIGS[tempNode.type];
-          const placement = graphApi.findPlacementForNode({
-            id,
-            inputs: config.inputs,
-            outputs: config.outputs?.map((out) => {
-              return {...out, contextMenuItems: renderAddNodeMenu(tempNode.id)};
-            }),
-            content: renderNodeContent(tempNode, () => {}),
-            canDockBottom: config.canDockBottom,
-            canDockTop: config.canDockTop,
-            accentBar: attrs.accentBars,
-            titleBar: attrs.titleBars
-              ? {
-                  title: tempNode.type.toUpperCase(),
-                  icon: attrs.headerIcons ? config.icon : undefined,
-                }
-              : undefined,
-            hue: attrs.colors ? config.hue : undefined,
-            contextMenuItems: attrs.contextMenus
-              ? renderNodeContextMenu(tempNode)
-              : undefined,
-          });
-          x = placement.x;
-          y = placement.y;
-        } else {
-          // Fallback to random position
-          x = 100 + Math.random() * 200;
-          y = 50 + Math.random() * 200;
-        }
+        const tempNode = manifest.create(id, 0, 0);
+        const placement = graphApi.findPlacementForNode({
+          id,
+          inputs: makeInputPorts(id, manifest.inputs),
+          outputs: [
+            {
+              id: `${id}-out`,
+              label: 'Output',
+              direction: 'south',
+              contextMenuItems: renderAddNodeMenu(id),
+            },
+          ],
+          content: manifest.render(tempNode, () => {}),
+          accentBar: attrs.accentBars,
+          headerBar: attrs.titleBars
+            ? {
+                title: manifest.title,
+                icon: attrs.headerIcons ? manifest.icon : undefined,
+              }
+            : undefined,
+          hue: manifest.hue,
+          contextMenuItems: attrs.contextMenus
+            ? renderNodeContextMenu(tempNode)
+            : undefined,
+        });
+        x = placement.x;
+        y = placement.y;
 
-        const newNode = factory(id, x, y);
-
+        const newNode = manifest.create(id, x, y);
         updateStore((draft) => {
-          draft.nodes.set(newNode.id, newNode);
-
           if (toNodeId) {
-            const parentNode = draft.nodes.get(toNodeId);
+            const parentNode = findNodeById(draft.nodes, toNodeId);
             if (parentNode) {
-              newNode.nextId = parentNode.nextId;
-              parentNode.nextId = id;
+              // Insert newNode between parent and its current next
+              const existingNext = parentNode.next;
+              parentNode.next = newNode;
+              newNode.next = existingNext;
+            } else {
+              draft.nodes.push(newNode);
             }
 
-            // Find any connection connected to the bottom port of this node
-            const bottomConnectionIdx = draft.connections.findIndex(
-              (c) => c.fromNode === toNodeId && c.fromPort === 0,
-            );
-            if (bottomConnectionIdx > -1) {
-              draft.connections[bottomConnectionIdx] = {
-                ...draft.connections[bottomConnectionIdx],
-                fromNode: id,
-                fromPort: 0,
-              };
+            // Re-route any node whose first input was toNodeId so it now
+            // comes from the new node's output.
+            for (const n of flattenNodes(draft.nodes)) {
+              if (!n.inputNodeIds) continue;
+              for (let i = 0; i < n.inputNodeIds.length; i++) {
+                if (n.inputNodeIds[i] === toNodeId) {
+                  n.inputNodeIds[i] = id;
+                }
+              }
             }
+          } else {
+            draft.nodes.push(newNode);
           }
         });
       };
 
-      // Render a model node and its chain
-      function renderNodeChain(nodeData: NodeData): Node {
-        const hasNext = nodeData.nextId !== undefined;
-        const nextModel = hasNext
-          ? store.nodes.get(nodeData.nextId!)
-          : undefined;
+      const addLabel = () => {
+        const id = shortUuid();
+        const pos = graphApi.findPlacementForNode({
+          id,
+          hue: 0,
+          canDockTop: false,
+          canDockBottom: false,
+        });
+        updateStore((draft) => {
+          draft.labels.push(createLabel(id, pos.x, pos.y));
+        });
+      };
 
-        const config = NODE_CONFIGS[nodeData.type];
-
+      // Shared helper: builds the common Node fields from a NodeData + manifest.
+      // originalManifest is the unsliced manifest used for canDockTop; it
+      // defaults to manifest when not supplied (i.e. for root nodes).
+      function buildNodeFields(
+        nodeData: NodeData,
+        manifest: NodeTypeManifest<NodeData>,
+        originalManifest?: NodeTypeManifest<NodeData>,
+      ) {
+        const orig = originalManifest ?? manifest;
         return {
-          id: nodeData.id,
-          x: nodeData.x,
-          y: nodeData.y,
-          inputs: config.inputs,
-          outputs: config.outputs?.map((out) => {
-            return {...out, contextMenuItems: renderAddNodeMenu(nodeData.id)};
-          }),
-          content: renderNodeContent(nodeData, (updates) =>
+          inputs: makeInputPorts(nodeData.id, manifest.inputs),
+          outputs: nodeData.next
+            ? []
+            : [
+                {
+                  id: `${nodeData.id}-out`,
+                  label: 'Output',
+                  direction: 'south' as const,
+                  contextMenuItems: renderAddNodeMenu(nodeData.id),
+                },
+              ],
+          content: manifest.render(nodeData, (updates) =>
             updateNode(nodeData.id, updates),
           ),
-          canDockBottom: config.canDockBottom,
-          canDockTop: config.canDockTop,
-          next: nextModel ? renderChildNode(nextModel) : undefined,
           accentBar: attrs.accentBars,
-          titleBar: attrs.titleBars
+          headerBar: attrs.titleBars
             ? {
-                title: nodeData.type.toUpperCase(),
-                icon: attrs.headerIcons ? config.icon : undefined,
+                title: manifest.title,
+                icon: attrs.headerIcons ? manifest.icon : undefined,
               }
             : undefined,
-          hue: attrs.colors ? config.hue : undefined,
+          hue: manifest.hue,
           contextMenuItems: attrs.contextMenus
             ? renderNodeContextMenu(nodeData)
             : undefined,
-          invalid: store.invalidNodes.has(nodeData.id),
+          canDockTop: (orig.inputs?.length ?? 0) > 0,
+          canDockBottom: true,
         };
       }
 
-      // Render child node (keep all ports visible)
-      function renderChildNode(nodeData: NodeData): Omit<Node, 'x' | 'y'> {
-        const hasNext = nodeData.nextId !== undefined;
-        const nextModel = hasNext
-          ? store.nodes.get(nodeData.nextId!)
-          : undefined;
-
-        const config = NODE_CONFIGS[nodeData.type];
-
+      // Render a model node and its chain
+      function renderNodeChain(nodeData: NodeData): NodeGraphNode {
+        const manifest = NODE_CONFIGS[nodeData.type];
         return {
           id: nodeData.id,
-          inputs: config.inputs,
-          outputs: config.outputs?.map((out) => {
-            return {...out, contextMenuItems: renderAddNodeMenu(nodeData.id)};
+          pos: {x: nodeData.x, y: nodeData.y},
+          ...buildNodeFields(nodeData, manifest),
+          next: nodeData.next ? renderChildNode(nodeData.next) : undefined,
+        };
+      }
+
+      // Render child node: hide input[0] (implicitly fed by the parent above).
+      function renderChildNode(nodeData: NodeData): Omit<NodeGraphNode, 'pos'> {
+        const manifest = NODE_CONFIGS[nodeData.type];
+        const manifestWithoutFirstInput = {
+          ...manifest,
+          inputs: manifest.inputs?.slice(1),
+        };
+        return {
+          id: nodeData.id,
+          ...buildNodeFields(nodeData, manifestWithoutFirstInput, manifest),
+          next: nodeData.next ? renderChildNode(nodeData.next) : undefined,
+        };
+      }
+
+      function renderLabelNode(label: LabelData): NodeGraphNode {
+        return {
+          id: label.id,
+          pos: {x: label.x, y: label.y},
+          hue: 0,
+          content: m(LabelContent, {
+            text: label.text,
+            update: (text) => updateLabel(label.id, {text}),
           }),
-          content: renderNodeContent(nodeData, (updates) =>
-            updateNode(nodeData.id, updates),
-          ),
-          canDockBottom: config.canDockBottom,
-          canDockTop: config.canDockTop,
-          next: nextModel ? renderChildNode(nextModel) : undefined,
-          accentBar: attrs.accentBars,
-          titleBar: attrs.titleBars
-            ? {
-                title: nodeData.type.toUpperCase(),
-                icon: attrs.headerIcons ? config.icon : undefined,
-              }
-            : undefined,
-          hue: attrs.colors ? config.hue : undefined,
-          contextMenuItems: attrs.contextMenus
-            ? renderNodeContextMenu(nodeData)
-            : undefined,
-          invalid: store.invalidNodes.has(nodeData.id),
+          canDockTop: false,
+          canDockBottom: false,
+          className: 'pf-ngd__label',
         };
       }
 
       // Render model state into NodeGraph nodes
-      function renderNodes(): Node[] {
-        const rootIds = getRootNodeIds(store.nodes);
-        return rootIds
-          .map((id) => {
-            const model = store.nodes.get(id);
-            if (!model) return null;
-            return renderNodeChain(model);
-          })
-          .filter((n): n is Node => n !== null);
+      function renderNodes(): NodeGraphNode[] {
+        return [
+          ...store.nodes.map(renderNodeChain),
+          ...store.labels.map(renderLabelNode),
+        ];
       }
 
       const nodeGraphAttrs: NodeGraphAttrs = {
@@ -1165,64 +979,67 @@
                 icon: 'add',
                 variant: ButtonVariant.Filled,
               }),
+              onChange: (open) => {
+                if (!open) {
+                  nodeMenuSearch = '';
+                }
+              },
             },
             [
-              m(MenuItem, {
-                label: 'Table',
-                icon: 'table_chart',
-                onclick: () => addNode(createTableNode),
+              m('input', {
+                type: 'text',
+                placeholder: 'Search...',
+                value: nodeMenuSearch,
                 style: {
-                  borderLeft: `4px solid hsl(${NODE_CONFIGS.table.hue}, 60%, 50%)`,
+                  display: 'block',
+                  width: '100%',
+                  boxSizing: 'border-box',
+                  padding: '6px 8px',
+                  border: 'none',
+                  borderBottom: '1px solid var(--pf-color-border)',
+                  background: 'var(--pf-color-background)',
+                  color: 'var(--pf-color-text)',
+                  fontSize: '13px',
+                  outline: 'none',
+                },
+                oncreate: ({dom}: {dom: Element}) => {
+                  (dom as HTMLInputElement).focus();
+                },
+                oninput: (e: InputEvent) => {
+                  nodeMenuSearch = (e.target as HTMLInputElement).value;
+                  m.redraw();
+                },
+                onkeydown: (e: KeyboardEvent) => {
+                  e.stopPropagation();
                 },
               }),
-              m(MenuItem, {
-                label: 'Select',
-                icon: 'checklist',
-                onclick: () => addNode(createSelectNode),
-                style: {
-                  borderLeft: `4px solid hsl(${NODE_CONFIGS.select.hue}, 60%, 50%)`,
-                },
-              }),
-              m(MenuItem, {
-                label: 'Filter',
-                icon: 'filter_alt',
-                onclick: () => addNode(createFilterNode),
-                style: {
-                  borderLeft: `4px solid hsl(${NODE_CONFIGS.filter.hue}, 60%, 50%)`,
-                },
-              }),
-              m(MenuItem, {
-                label: 'Sort',
-                icon: 'sort',
-                onclick: () => addNode(createSortNode),
-                style: {
-                  borderLeft: `4px solid hsl(${NODE_CONFIGS.sort.hue}, 60%, 50%)`,
-                },
-              }),
-              m(MenuItem, {
-                label: 'Join',
-                icon: 'join',
-                onclick: () => addNode(createJoinNode),
-                style: {
-                  borderLeft: `4px solid hsl(${NODE_CONFIGS.join.hue}, 60%, 50%)`,
-                },
-              }),
-              m(MenuItem, {
-                label: 'Union',
-                icon: 'merge',
-                onclick: () => addNode(createUnionNode),
-                style: {
-                  borderLeft: `4px solid hsl(${NODE_CONFIGS.union.hue}, 60%, 50%)`,
-                },
-              }),
-              m(MenuItem, {
-                label: 'Result',
-                icon: 'output',
-                onclick: () => addNode(createResultNode),
-                style: {
-                  borderLeft: `4px solid hsl(${NODE_CONFIGS.result.hue}, 60%, 50%)`,
-                },
-              }),
+              ...(Object.keys(NODE_CONFIGS) as NodeData['type'][])
+                .filter((type) => {
+                  const manifest = NODE_CONFIGS[type];
+                  return manifest.title
+                    .toLowerCase()
+                    .includes(nodeMenuSearch.toLowerCase());
+                })
+                .map((type) => {
+                  const manifest = NODE_CONFIGS[type];
+                  return m(MenuItem, {
+                    label: manifest.title,
+                    icon: manifest.icon,
+                    onclick: () => addNode(type),
+                    style: {
+                      borderLeft: `4px solid hsl(${manifest.hue}, 60%, 50%)`,
+                    },
+                  });
+                }),
+              ...('label'.includes(nodeMenuSearch.toLowerCase())
+                ? [
+                    m(MenuItem, {
+                      label: 'Label',
+                      icon: 'label',
+                      onclick: () => addLabel(),
+                    }),
+                  ]
+                : []),
             ],
           ),
           m(
@@ -1257,129 +1074,150 @@
               variant: ButtonVariant.Filled,
               onclick: () => {
                 updateStore((draft) => {
-                  draft.nodes.clear();
-                  draft.connections.length = 0;
+                  draft.nodes.length = 0;
+                  draft.labels.length = 0;
                 });
                 selectedNodeIds.clear();
               },
             }),
           ),
         ],
+        style: {height: '500px'},
         nodes: renderNodes(),
-        connections: store.connections,
+        connections: deriveConnections(store.nodes),
         selectedNodeIds: selectedNodeIds,
-        multiselect: attrs.multiselect,
-        contextMenuOnHover: attrs.contextMenuOnHover,
-        onReady: (api: NodeGraphApi) => {
+        onReady: (api: NodeGraphAPI) => {
+          console.log('onReady');
           graphApi = api;
         },
-        onNodeMove: (nodeId: string, x: number, y: number) => {
-          // Update position in store with history entry when node is dropped
-          updateNode(nodeId, {x, y});
-          console.log(`onNodeMove: ${nodeId} to (${x}, ${y})`);
-        },
-        onConnect: (conn: Connection) => {
-          console.log('onConnect:', conn);
-          updateStore((draft) => {
-            draft.connections.push(conn);
-          });
-        },
-        onConnectionRemove: (index: number) => {
-          console.log('onConnectionRemove:', index);
-          updateStore((draft) => {
-            draft.connections.splice(index, 1);
-          });
-        },
-        onNodeRemove: (nodeId: string) => {
-          removeNode(nodeId);
-          console.log(`onNodeRemove: ${nodeId}`);
-        },
-        onNodeSelect: (nodeId: string) => {
-          selectedNodeIds.clear();
-          selectedNodeIds.add(nodeId);
-          m.redraw();
-          console.log(`onNodeSelect: ${nodeId}`);
-        },
-        onNodeAddToSelection: (nodeId: string) => {
-          selectedNodeIds.add(nodeId);
-          m.redraw();
+        onNodeMove: (nodeId: string, pos: Point2D) => {
           console.log(
-            `onNodeAddToSelection: ${nodeId} (total: ${selectedNodeIds.size})`,
+            `onNodeMove: ${nodeId} to (${pos.x.toFixed(1)}, ${pos.y.toFixed(1)})`,
           );
-        },
-        onNodeRemoveFromSelection: (nodeId: string) => {
-          selectedNodeIds.delete(nodeId);
-          m.redraw();
-          console.log(
-            `onNodeRemoveFromSelection: ${nodeId} (total: ${selectedNodeIds.size})`,
-          );
-        },
-        onSelectionClear: () => {
-          selectedNodeIds.clear();
-          m.redraw();
-          console.log(`onSelectionClear`);
-        },
-        onDock: (targetId: string, childNode: Omit<Node, 'x' | 'y'>) => {
           updateStore((draft) => {
-            const target = draft.nodes.get(targetId);
-            const child = draft.nodes.get(childNode.id);
-
-            if (target && child) {
-              target.nextId = child.id;
-              console.log(`onDock: ${child.id} to ${targetId}`);
+            const label = draft.labels.find((l) => l.id === nodeId);
+            if (label) {
+              label.x = pos.x;
+              label.y = pos.y;
+              return;
             }
-
-            // If a connection already exists between these nodes, remove it
-            for (let i = draft.connections.length - 1; i >= 0; i--) {
-              const conn = draft.connections[i];
-              if (
-                (conn.fromNode === targetId && conn.fromPort === 0) ||
-                (conn.toNode === child?.id && conn.toPort === 0)
-              ) {
-                draft.connections.splice(i, 1);
+            const parent = findParent(draft.nodes, nodeId);
+            if (parent) {
+              // Undock: detach from parent chain, add as new root
+              const child = parent.next!;
+              parent.next = undefined;
+              child.x = pos.x;
+              child.y = pos.y;
+              draft.nodes.push(child as NodeData);
+            } else {
+              const node = findNodeById(draft.nodes, nodeId);
+              if (node) {
+                node.x = pos.x;
+                node.y = pos.y;
               }
             }
           });
         },
-        onUndock: (parentId: string, nodeId: string, x: number, y: number) => {
+        onConnect: (conn: NodeGraphConnection) => {
+          console.log(`onConnect: ${conn.fromPort} -> ${conn.toPort}`);
           updateStore((draft) => {
-            const parent = draft.nodes.get(parentId);
-            const child = draft.nodes.get(nodeId);
-
-            if (parent && child) {
-              child.x = x;
-              child.y = y;
-              parent.nextId = undefined;
-
-              console.log(
-                `onUndock: ${nodeId} from ${parentId} at (${x}, ${y})`,
+            const slot = findPortSlot(draft.nodes, conn.toPort);
+            if (!slot) return;
+            const sourceId = conn.fromPort.replace(/-out$/, '');
+            if (!slot.node.inputNodeIds) slot.node.inputNodeIds = [];
+            slot.node.inputNodeIds[slot.slotIndex] = sourceId;
+          });
+        },
+        onDisconnect: (index: number) => {
+          console.log(`onDisconnect: index ${index}`);
+          updateStore((draft) => {
+            const conn = maybeUndefined(deriveConnections(draft.nodes)[index]);
+            if (conn === undefined) return;
+            const slot = findPortSlot(draft.nodes, conn.toPort);
+            if (!slot?.node.inputNodeIds) return;
+            slot.node.inputNodeIds[slot.slotIndex] = undefined;
+          });
+        },
+        onSelect: (nodeIds: string[]) => {
+          console.log(`onSelect: [${nodeIds.join(', ')}]`);
+          selectedNodeIds.clear();
+          nodeIds.forEach((nodeId) => selectedNodeIds.add(nodeId));
+          m.redraw();
+        },
+        onSelectionAdd: (nodeId: string) => {
+          console.log(`onSelectionAdd: ${nodeId}`);
+          selectedNodeIds.add(nodeId);
+          m.redraw();
+        },
+        onSelectionRemove: (nodeId: string) => {
+          console.log(`onSelectionRemove: ${nodeId}`);
+          selectedNodeIds.delete(nodeId);
+          m.redraw();
+        },
+        onSelectionClear: () => {
+          console.log('onSelectionClear');
+          selectedNodeIds.clear();
+          m.redraw();
+        },
+        onNodeDock: (nodeId, targetId) => {
+          console.log(`onDock: ${nodeId} -> ${targetId}`);
+          updateStore((draft) => {
+            const target = findNodeById(draft.nodes, targetId);
+            const child = findNodeById(draft.nodes, nodeId);
+            if (target && child) {
+              // Remove child from root array if it's there
+              const childRootIdx = draft.nodes.findIndex(
+                (n) => n.id === child.id,
               );
+              if (childRootIdx !== -1) {
+                draft.nodes.splice(childRootIdx, 1);
+              }
+
+              // If the child already exists somewhere else in the graph, remove it
+              const parent = findParent(draft.nodes, child.id);
+              if (parent) {
+                // Undock: detach from parent chain
+                parent.next = undefined;
+              }
+
+              // If the target node already has children, insert the new child
+              // in between the target and its existing children
+              if (target.next) {
+                // Find the end of the chain of child nodes - this is where
+                // we'll attach the target's existing children
+                let nextChild = child;
+                while (nextChild.next) {
+                  nextChild = nextChild.next;
+                }
+                nextChild.next = target.next;
+              }
+              target.next = child;
+            }
+            // Clear inputNodeIds that are no longer valid after docking:
+            // - child's slot 0 (implicitly fed by the docked parent)
+            // - any node whose input was targetId (target's output is now hidden)
+            if (child) {
+              if (child.inputNodeIds) child.inputNodeIds[0] = undefined;
+              for (const n of flattenNodes(draft.nodes)) {
+                if (!n.inputNodeIds) continue;
+                for (let i = 0; i < n.inputNodeIds.length; i++) {
+                  if (n.inputNodeIds[i] === targetId) {
+                    n.inputNodeIds[i] = undefined;
+                  }
+                }
+              }
             }
           });
         },
-        labels: store.labels,
-        onLabelMove: (labelId: string, x: number, y: number) => {
-          updateStore((draft) => {
-            const label = draft.labels.find((l) => l.id === labelId);
-            if (label) {
-              label.x = x;
-              label.y = y;
-              console.log(`onLabelMove: ${labelId} to (${x}, ${y})`);
-            }
-          });
+        onNodeRemove: (nodeIds: string[]) => {
+          for (const id of nodeIds) {
+            removeNode(id);
+          }
         },
-        onLabelResize: (labelId: string, width: number) => {
-          updateStore((draft) => {
-            const label = draft.labels.find((l) => l.id === labelId);
-            if (label) {
-              label.width = width;
-              console.log(`onLabelResize: ${labelId} to width ${width}`);
-            }
-          });
-        },
-        onLabelRemove: (labelId: string) => {
-          removeLabel(labelId);
-          console.log(`onLabelRemove: ${labelId}`);
+        onViewportMove: ({offset, zoom}) => {
+          console.log(
+            `onViewportMove: (${offset.x.toFixed(1)}, ${offset.y.toFixed(1)}) zoom=${zoom.toFixed(2)}`,
+          );
         },
       };
 
@@ -1402,13 +1240,10 @@
       noPadding: true,
       renderWidget: (opts) => m(NodeGraphDemo, opts),
       initialOpts: {
-        multiselect: true,
         accentBars: false,
         titleBars: true,
         headerIcons: true,
-        colors: true,
         contextMenus: true,
-        contextMenuOnHover: false,
       },
     }),
 
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/styles.scss b/ui/src/plugins/dev.perfetto.WidgetsPage/styles.scss
index c039f49..5d74272 100644
--- a/ui/src/plugins/dev.perfetto.WidgetsPage/styles.scss
+++ b/ui/src/plugins/dev.perfetto.WidgetsPage/styles.scss
@@ -12,6 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+@import "demos/nodegraph_demo";
+
 .pf-widgets-page {
   display: flex;
   height: 100%;
diff --git a/ui/src/widgets/nodegraph.ts b/ui/src/widgets/nodegraph.ts
deleted file mode 100644
index f4099d9..0000000
--- a/ui/src/widgets/nodegraph.ts
+++ /dev/null
@@ -1,2545 +0,0 @@
-// Copyright (C) 2025 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-/**
- * A component for displaying and interacting with a node-based graph.
- *
- * Features:
- * - Draggable, selectable, and removable nodes.
- * - Pannable and zoomable canvas.
- * - Connectable ports to create links between nodes.
- * - Docking nodes to each other to form chains.
- * - Customizable node content and appearance.
- * - Auto-layout and fit-to-screen functionality.
- *
- * Minimal example:
- *
- * ```typescript
- * const nodes: Node[] = [
- *   {id: 'node1', x: 50, y: 50, outputs: [{direction: 'right'}]},
- *   {id: 'node2', x: 250, y: 50, inputs: [{direction: 'left'}]},
- * ];
- *
- * const connections: Connection[] = [
- *   {fromNode: 'node1', fromPort: 0, toNode: 'node2', toPort: 0},
- * ];
- *
- * m(NodeGraph, {
- *   nodes,
- *   connections,
- *   onConnect: (newConnection) => {
- *     // Handle new connection
- *   },
- *   onNodeMove: (nodeId, x, y) => {
- *     // Handle node position change (called when node is dropped)
- *   },
- * });
- * ```
- */
-import m from 'mithril';
-import {Button, ButtonGroup, ButtonVariant} from './button';
-import {Icon} from './icon';
-import {PopupMenu} from './menu';
-import {classNames} from '../base/classnames';
-import {Icons} from '../base/semantic_icons';
-import {assertExists} from '../base/assert';
-import {shortUuid} from '../base/uuid';
-
-// Default height estimate for labels (used for box selection calculations)
-const DEFAULT_LABEL_MIN_HEIGHT = 30;
-
-// Typical height estimate for labels with content (used for autofit calculations)
-// Labels can vary in height based on content, but this provides a reasonable
-// estimate for bounding box calculations when actual DOM measurements aren't available
-const TYPICAL_LABEL_HEIGHT = 100;
-
-interface Position {
-  x: number;
-  y: number;
-  transformedX?: number;
-  transformedY?: number;
-}
-
-export interface Connection {
-  readonly fromNode: string;
-  readonly fromPort: number;
-  readonly toNode: string;
-  readonly toPort: number;
-}
-
-export interface NodeTitleBar {
-  readonly title: m.Children;
-  readonly icon?: string;
-}
-
-export interface NodePort {
-  readonly content?: m.Children;
-  readonly direction: 'top' | 'left' | 'right' | 'bottom';
-  readonly contextMenuItems?: m.Children;
-}
-
-export type DockedNode = Omit<Node, 'x' | 'y'>;
-
-export interface Node {
-  readonly id: string;
-  readonly x: number;
-  readonly y: number;
-  readonly hue?: number; // Color of the title / accent bar (0-360)
-  readonly accentBar?: boolean; // Optional strip of accent color on the left side (doesn't work well with titleBar)
-  readonly titleBar?: NodeTitleBar; // Optional title bar (doesn't work well with accentBar or docking)
-  readonly inputs?: ReadonlyArray<NodePort>;
-  readonly outputs?: ReadonlyArray<NodePort>;
-  readonly content?: m.Children; // Optional custom content to render in node body
-  readonly next?: DockedNode; // Next node in chain
-  readonly canDockTop?: boolean;
-  readonly canDockBottom?: boolean;
-  readonly contextMenuItems?: m.Children;
-  readonly invalid?: boolean; // Whether this node is in an invalid state
-  readonly className?: string; // Extra CSS class(es) on the .pf-node element
-}
-
-export interface Label {
-  readonly id: string;
-  x: number;
-  y: number;
-  width: number; // Width of the label box (user can resize)
-  content?: m.Children; // Content to render inside the label (optional, defaults to empty)
-  selectable?: boolean; // Whether clicking the label selects it (default: false, only shift+click works)
-}
-
-interface ConnectingState {
-  nodeId: string;
-  portIndex: number;
-  type: 'input' | 'output';
-  portType: 'top' | 'bottom' | 'left' | 'right';
-  x: number;
-  y: number;
-  transformedX: number;
-  transformedY: number;
-}
-
-interface UndockCandidate {
-  nodeId: string;
-  parentId: string;
-  startX: number;
-  startY: number;
-  renderY: number;
-}
-
-interface UndockedNode {
-  nodeId: string;
-  parentId: string;
-}
-
-interface SelectionRect {
-  startX: number;
-  startY: number;
-  currentX: number;
-  currentY: number;
-}
-
-interface CanvasState {
-  draggedNode: string | null;
-  dragOffset: Position;
-  connecting: ConnectingState | null;
-  mousePos: Position;
-  selectedNodes: ReadonlySet<string>;
-  panOffset: Position;
-  isPanning: boolean;
-  panStart: Position;
-  zoom: number;
-  dockTarget: string | null; // Node being targeted for docking
-  isDockZone: boolean; // Whether we're in valid dock position
-  undockCandidate: UndockCandidate | null; // Tracks potential undock before threshold
-  undockedNode: UndockedNode | null; // Node that was undocked (set when threshold exceeded)
-  hoveredPort: {
-    nodeId: string;
-    portIndex: number;
-    type: 'input' | 'output';
-  } | null;
-  selectionRect: SelectionRect | null; // Box selection state
-  canvasMouseDownPos: Position;
-  tempNodePositions: Map<string, Position>; // Temporary positions during drag
-  tempLabelPositions: Map<string, Position>; // Temporary label positions during drag
-  tempLabelWidths: Map<string, number>; // Temporary label widths during resize
-  draggedLabel: string | null; // ID of label being dragged
-  labelDragStartPos: Position | null; // Position where label drag started
-  resizingLabel: string | null; // ID of label being resized
-  resizeStartWidth: number; // Width when resize started
-  resizeStartX: number; // Mouse X position when resize started
-}
-
-export interface NodeGraphApi {
-  autoLayout: () => void;
-  recenter: () => void;
-  findPlacementForNode: (node: Omit<Node, 'x' | 'y'>) => Position;
-  panBy: (dx: number, dy: number) => void;
-  /**
-   * Zooms the canvas by the given delta factor.
-   * @param deltaZoom - The zoom delta (e.g., 0.1 for 10% zoom in, -0.1 for 10% zoom out)
-   * @param centerX - X coordinate to zoom around (in viewport space). Defaults to canvas center.
-   * @param centerY - Y coordinate to zoom around (in viewport space). Defaults to canvas center.
-   */
-  zoomBy: (deltaZoom: number, centerX?: number, centerY?: number) => void;
-  /**
-   * Reset the canvas zoom level to the default (1.0) retaining the current
-   * center point.
-   */
-  resetZoom: () => void;
-}
-
-export interface NodeGraphAttrs {
-  readonly nodes: ReadonlyArray<Node>;
-  readonly connections: ReadonlyArray<Connection>;
-  readonly labels?: ReadonlyArray<Label>;
-  readonly onConnect?: (connection: Connection) => void;
-  readonly onNodeMove?: (nodeId: string, x: number, y: number) => void;
-  readonly onConnectionRemove?: (index: number) => void;
-  readonly onReady?: (api: NodeGraphApi) => void;
-  // Selection state and callbacks apply to both nodes and labels.
-  // selectedNodeIds should contain IDs of both selected nodes and labels.
-  readonly selectedNodeIds?: ReadonlySet<string>;
-  // Called when a node or label is selected (replacing current selection).
-  readonly onNodeSelect?: (nodeId: string) => void;
-  // Called when a node or label is added to the current selection (multiselect).
-  readonly onNodeAddToSelection?: (nodeId: string) => void;
-  // Called when a node or label is removed from the current selection.
-  readonly onNodeRemoveFromSelection?: (nodeId: string) => void;
-  readonly onSelectionClear?: () => void;
-  readonly onDock?: (
-    parentId: string,
-    childNode: Omit<Node, 'x' | 'y'>,
-  ) => void;
-  readonly onUndock?: (
-    parentId: string,
-    nodeId: string,
-    x: number,
-    y: number,
-  ) => void;
-  readonly onNodeRemove?: (nodeId: string) => void;
-  readonly onLabelMove?: (labelId: string, x: number, y: number) => void;
-  readonly onLabelResize?: (labelId: string, width: number) => void;
-  readonly onLabelRemove?: (labelId: string) => void;
-  readonly hideControls?: boolean;
-  readonly multiselect?: boolean; // Enable multi-node selection (default: true)
-  readonly contextMenuOnHover?: boolean; // Show context menu on hover (default: false)
-  readonly fillHeight?: boolean;
-  readonly toolbarItems?: m.Children;
-  readonly style?: Partial<CSSStyleDeclaration>;
-}
-
-const UNDOCK_THRESHOLD = 5; // Pixels to drag before undocking
-
-function isPortConnected(
-  nodeId: string,
-  portType: 'input' | 'output',
-  portIndex: number,
-  connections: ReadonlyArray<Connection>,
-): boolean {
-  return connections.some((conn) => {
-    if (portType === 'input') {
-      return conn.toNode === nodeId && conn.toPort === portIndex;
-    } else {
-      return conn.fromNode === nodeId && conn.fromPort === portIndex;
-    }
-  });
-}
-
-// Get the entire chain starting from a root node
-function getChain(rootNode: Node): Array<Node | Omit<Node, 'x' | 'y'>> {
-  const chain: Array<Node | Omit<Node, 'x' | 'y'>> = [rootNode];
-  let current = rootNode.next;
-
-  while (current) {
-    chain.push(current);
-    current = current.next;
-  }
-
-  return chain;
-}
-
-function createCurve(
-  x1: number,
-  y1: number,
-  x2: number,
-  y2: number,
-  fromPortType?: 'top' | 'bottom' | 'left' | 'right',
-  toPortType?: 'top' | 'bottom' | 'left' | 'right',
-  shortenEnd = 0,
-): string {
-  const dx = x2 - x1;
-  const dy = y2 - y1;
-  const distance = Math.sqrt(dx * dx + dy * dy);
-
-  let cx1: number;
-  let cy1: number;
-  let cx2: number;
-  let cy2: number;
-
-  if (shortenEnd > 0) {
-    if (toPortType === 'bottom') {
-      y2 += shortenEnd;
-    } else if (toPortType === 'top') {
-      y2 -= shortenEnd;
-    } else if (toPortType === 'left') {
-      x2 -= shortenEnd;
-    } else if (toPortType === 'right') {
-      x2 += shortenEnd;
-    }
-  }
-
-  // For top/bottom ports, control points extend vertically
-  // For left/right ports, control points extend horizontally
-  if (fromPortType === 'bottom' || fromPortType === 'top') {
-    // First control point extends vertically
-    const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5);
-    cx1 = x1;
-    cy1 = fromPortType === 'bottom' ? y1 + verticalOffset : y1 - verticalOffset;
-  } else {
-    // First control point extends horizontally for left/right ports
-    const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5);
-    cx1 = x1 + horizontalOffset;
-    cy1 = y1; // Keep Y constant for horizontal extension
-  }
-
-  if (toPortType === 'bottom' || toPortType === 'top') {
-    // Second control point extends vertically
-    const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5);
-    cx2 = x2;
-    cy2 = toPortType === 'bottom' ? y2 + verticalOffset : y2 - verticalOffset;
-  } else {
-    // Second control point extends horizontally for left/right ports
-    const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5);
-    cx2 = x2 - horizontalOffset;
-    cy2 = y2; // Keep Y constant for horizontal extension
-  }
-
-  // if (shortenEnd > 0) {
-  //   const tangentX = x2 - cx2;
-  //   const tangentY = y2 - cy2;
-  //   const tangentLength = Math.sqrt(tangentX * tangentX + tangentY * tangentY);
-  //   if (tangentLength > shortenEnd) {
-  //     const unitTangentX = tangentX / tangentLength;
-  //     const unitTangentY = tangentY / tangentLength;
-  //     x2 -= unitTangentX * shortenEnd;
-  //     y2 -= unitTangentY * shortenEnd;
-  //   }
-  // }
-
-  return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
-}
-
-export function NodeGraph(): m.Component<NodeGraphAttrs> {
-  const canvasState: CanvasState = {
-    draggedNode: null,
-    dragOffset: {x: 0, y: 0},
-    connecting: null,
-    mousePos: {x: 0, y: 0},
-    selectedNodes: new Set<string>(),
-    panOffset: {x: 0, y: 0},
-    isPanning: false,
-    panStart: {x: 0, y: 0},
-    zoom: 1.0,
-    dockTarget: null,
-    isDockZone: false,
-    undockCandidate: null,
-    undockedNode: null,
-    hoveredPort: null,
-    selectionRect: null,
-    canvasMouseDownPos: {x: 0, y: 0},
-    tempNodePositions: new Map<string, Position>(),
-    tempLabelPositions: new Map<string, Position>(),
-    tempLabelWidths: new Map<string, number>(),
-    draggedLabel: null,
-    labelDragStartPos: null,
-    resizingLabel: null,
-    resizeStartWidth: 0,
-    resizeStartX: 0,
-  };
-
-  // Track drag state for batching updates
-  let dragStartPosition: {nodeId: string; x: number; y: number} | null = null;
-  let currentDragPosition: {x: number; y: number} | null = null;
-
-  let latestVnode: m.Vnode<NodeGraphAttrs> | null = null;
-  let canvasElement: HTMLElement | null = null;
-
-  // Unique instance ID for SVG marker references. Multiple NodeGraph instances
-  // (e.g. in different tabs) each create <marker id="..."> elements. Without
-  // unique IDs, url(#arrowhead) resolves to the first matching marker in
-  // document order, which may be inside a hidden tab (display:none).
-  const instanceId = shortUuid();
-
-  // Shared pan function used by both internal handlers and external API
-  const panBy = (dx: number, dy: number) => {
-    canvasState.panOffset.x += dx;
-    canvasState.panOffset.y += dy;
-  };
-
-  // Shared zoom function used by both internal handlers and external API
-  // Zooms around the center of the canvas (or specified point)
-  const zoomBy = (deltaZoom: number, centerX?: number, centerY?: number) => {
-    if (!canvasElement) return;
-    const canvas = canvasElement;
-    const canvasRect = canvas.getBoundingClientRect();
-
-    // Use center of canvas if no center point specified
-    const zoomX = centerX ?? canvasRect.width / 2;
-    const zoomY = centerY ?? canvasRect.height / 2;
-
-    const newZoom = Math.max(
-      0.1,
-      Math.min(5.0, canvasState.zoom * (1 + deltaZoom)),
-    );
-
-    // Calculate the point in canvas space (before zoom)
-    const canvasX = (zoomX - canvasState.panOffset.x) / canvasState.zoom;
-    const canvasY = (zoomY - canvasState.panOffset.y) / canvasState.zoom;
-
-    // Update zoom
-    canvasState.zoom = newZoom;
-
-    // Adjust pan to keep the same point under the zoom center
-    canvasState.panOffset = {
-      x: zoomX - canvasX * newZoom,
-      y: zoomY - canvasY * newZoom,
-    };
-  };
-
-  // API functions that are exposed to parent components via onReady callback
-  // These are initialized in oncreate and can be used in subsequent lifecycle hooks
-  let autoLayoutApi: (() => void) | null = null;
-  let recenterApi: (() => void) | null = null;
-  let resetZoom: (() => void) | null = null;
-  let findPlacementForNodeApi:
-    | ((newNode: Omit<Node, 'x' | 'y'>) => Position)
-    | null = null;
-
-  const handleMouseMove = (e: PointerEvent) => {
-    m.redraw();
-    if (!latestVnode || !canvasElement) return;
-    const vnode = latestVnode;
-    const canvas = canvasElement;
-    const canvasRect = canvas.getBoundingClientRect();
-
-    // Store both screen and transformed coordinates
-    canvasState.mousePos = {
-      x: e.clientX,
-      y: e.clientY,
-      transformedX:
-        (e.clientX - canvasRect.left - canvasState.panOffset.x) /
-        canvasState.zoom,
-      transformedY:
-        (e.clientY - canvasRect.top - canvasState.panOffset.y) /
-        canvasState.zoom,
-    };
-
-    // Track hovered port (useful for connection snapping and visual feedback)
-    const portElement = (e.target as HTMLElement).closest('.pf-port.pf-input');
-    if (portElement) {
-      const nodeElement = portElement.closest(
-        '[data-node]',
-      ) as HTMLElement | null;
-      const portId =
-        portElement.getAttribute('data-port') ||
-        portElement.parentElement?.getAttribute('data-port');
-
-      if (nodeElement && portId) {
-        const nodeId = nodeElement.dataset.node!;
-        const [type, portIndexStr] = portId.split('-');
-        if (type === 'input') {
-          const portIndex = parseInt(portIndexStr, 10);
-          canvasState.hoveredPort = {nodeId, portIndex, type: 'input'};
-        } else {
-          canvasState.hoveredPort = null;
-        }
-      } else {
-        canvasState.hoveredPort = null;
-      }
-    } else {
-      canvasState.hoveredPort = null;
-    }
-
-    if (canvasState.selectionRect) {
-      // Update selection rectangle
-      canvasState.selectionRect.currentX =
-        canvasState.mousePos.transformedX ?? 0;
-      canvasState.selectionRect.currentY =
-        canvasState.mousePos.transformedY ?? 0;
-      m.redraw();
-    } else if (canvasState.draggedLabel !== null) {
-      // Handle label dragging - store temp position, don't call callback yet
-      const newX =
-        (canvasState.mousePos.transformedX ?? 0) - canvasState.dragOffset.x;
-      const newY =
-        (canvasState.mousePos.transformedY ?? 0) - canvasState.dragOffset.y;
-
-      // Store temporary position during drag
-      canvasState.tempLabelPositions.set(canvasState.draggedLabel, {
-        x: newX,
-        y: newY,
-      });
-      m.redraw();
-    } else if (canvasState.resizingLabel !== null) {
-      // Handle label resizing - store temp width, don't call callback yet
-      const currentX = canvasState.mousePos.transformedX ?? 0;
-      const deltaX = currentX - canvasState.resizeStartX;
-      const newWidth = Math.max(100, canvasState.resizeStartWidth + deltaX);
-
-      // Store temporary width during resize
-      canvasState.tempLabelWidths.set(canvasState.resizingLabel, newWidth);
-      m.redraw();
-    } else if (canvasState.isPanning) {
-      // Pan the canvas
-      const dx = e.clientX - canvasState.panStart.x;
-      const dy = e.clientY - canvasState.panStart.y;
-      panBy(dx, dy);
-      canvasState.panStart = {x: e.clientX, y: e.clientY};
-      m.redraw();
-    } else if (canvasState.undockCandidate !== null) {
-      // Check if we've exceeded the undock threshold
-      const dx = e.clientX - canvasState.undockCandidate.startX;
-      const dy = e.clientY - canvasState.undockCandidate.startY;
-      const distance = Math.sqrt(dx * dx + dy * dy);
-
-      if (distance > UNDOCK_THRESHOLD) {
-        // Exceeded threshold - call onUndock immediately so node becomes independent
-        const {onUndock} = vnode.attrs;
-        const tempX =
-          (canvasState.undockCandidate.startX -
-            canvasRect.left -
-            canvasState.panOffset.x) /
-            canvasState.zoom -
-          canvasState.dragOffset.x / canvasState.zoom;
-        const tempY = canvasState.undockCandidate.renderY;
-
-        // Store temp position for this node
-        canvasState.tempNodePositions.set(canvasState.undockCandidate.nodeId, {
-          x: tempX,
-          y: tempY,
-        });
-
-        // Immediately call onUndock so the node becomes independent
-        if (onUndock) {
-          onUndock(
-            canvasState.undockCandidate.parentId,
-            canvasState.undockCandidate.nodeId,
-            tempX,
-            tempY,
-          );
-        }
-
-        // Mark as undocked so we track it as a regular drag now
-        canvasState.undockedNode = {
-          nodeId: canvasState.undockCandidate.nodeId,
-          parentId: canvasState.undockCandidate.parentId,
-        };
-
-        canvasState.undockCandidate = null;
-        m.redraw(); // Force update so nodes array regenerates
-      }
-    } else if (canvasState.draggedNode !== null) {
-      // Calculate new position relative to canvas container (accounting for pan and zoom)
-      const newX =
-        (e.clientX - canvasRect.left - canvasState.panOffset.x) /
-          canvasState.zoom -
-        canvasState.dragOffset.x / canvasState.zoom;
-      const newY =
-        (e.clientY - canvasRect.top - canvasState.panOffset.y) /
-          canvasState.zoom -
-        canvasState.dragOffset.y / canvasState.zoom;
-
-      // Store current position internally
-      currentDragPosition = {x: newX, y: newY};
-      canvasState.tempNodePositions.set(canvasState.draggedNode, {
-        x: newX,
-        y: newY,
-      });
-
-      // Check if we're in a dock zone (exclude the parent we just undocked from)
-      const {nodes} = vnode.attrs;
-      const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
-      if (draggedNode) {
-        const dockInfo = findDockTarget(draggedNode, newX, newY, nodes);
-        canvasState.dockTarget = dockInfo.targetNodeId;
-        canvasState.isDockZone = dockInfo.isValidZone;
-      }
-      m.redraw();
-    }
-  };
-
-  const handleMouseUp = () => {
-    if (!latestVnode) return;
-    const vnode = latestVnode;
-
-    // Handle box selection completion
-    if (canvasState.selectionRect) {
-      const {nodes = [], labels = []} = vnode.attrs;
-      const rect = canvasState.selectionRect;
-      const minX = Math.min(rect.startX, rect.currentX);
-      const maxX = Math.max(rect.startX, rect.currentX);
-      const minY = Math.min(rect.startY, rect.currentY);
-      const maxY = Math.max(rect.startY, rect.currentY);
-
-      // Helper to check if a node at given position overlaps with selection rectangle
-      const nodeOverlapsRect = (
-        nodeX: number,
-        nodeY: number,
-        nodeId: string,
-      ): boolean => {
-        const dims = getNodeDimensions(nodeId);
-        const nodeRight = nodeX + dims.width;
-        const nodeBottom = nodeY + dims.height;
-
-        return (
-          nodeX < maxX && nodeRight > minX && nodeY < maxY && nodeBottom > minY
-        );
-      };
-
-      // Helper to check if a label overlaps with selection rectangle
-      const labelOverlapsRect = (label: Label): boolean => {
-        const labelRight = label.x + label.width;
-        const labelBottom = label.y + DEFAULT_LABEL_MIN_HEIGHT;
-
-        return (
-          label.x < maxX &&
-          labelRight > minX &&
-          label.y < maxY &&
-          labelBottom > minY
-        );
-      };
-
-      // Find all nodes (including chained/docked nodes) that intersect with the selection rectangle
-      const selectedInRect: string[] = [];
-      nodes.forEach((node) => {
-        // Check root node
-        if (nodeOverlapsRect(node.x, node.y, node.id)) {
-          selectedInRect.push(node.id);
-        }
-
-        // Check all chained nodes
-        const chain = getChain(node);
-        let currentY = node.y;
-        chain.slice(1).forEach((chainNode) => {
-          // For chained nodes, calculate their Y position
-          const previousNodeId = chain[chain.indexOf(chainNode) - 1].id;
-          currentY += getNodeDimensions(previousNodeId).height;
-
-          if (nodeOverlapsRect(node.x, currentY, chainNode.id)) {
-            selectedInRect.push(chainNode.id);
-          }
-        });
-      });
-
-      // Find all labels that intersect with the selection rectangle
-      labels.forEach((label) => {
-        if (labelOverlapsRect(label)) {
-          selectedInRect.push(label.id);
-        }
-      });
-
-      // Add all selected nodes and labels to selection
-      const {onNodeAddToSelection} = vnode.attrs;
-      selectedInRect.forEach((id) => {
-        if (!canvasState.selectedNodes.has(id)) {
-          if (onNodeAddToSelection !== undefined) {
-            onNodeAddToSelection(id);
-          }
-        }
-      });
-
-      canvasState.selectionRect = null;
-      m.redraw();
-      return;
-    }
-
-    // Handle docking if in dock zone
-    if (
-      canvasState.draggedNode &&
-      canvasState.isDockZone &&
-      canvasState.dockTarget
-    ) {
-      const {nodes = [], onDock} = vnode.attrs;
-      const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
-      if (onDock && draggedNode) {
-        // Create child node without x/y coordinates
-        // eslint-disable-next-line @typescript-eslint/no-unused-vars
-        const {x, y, ...childNode} = draggedNode;
-        onDock(canvasState.dockTarget, childNode);
-      }
-    }
-
-    // Check for collision and finalize drag (only for non-docked/undocked nodes)
-    if (canvasState.draggedNode !== null && !canvasState.isDockZone) {
-      const {nodes = [], onNodeMove} = vnode.attrs;
-      const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
-
-      // Only do overlap checking if NOT being docked
-      if (draggedNode) {
-        // Get actual node dimensions from DOM
-        const dims = getNodeDimensions(draggedNode.id);
-
-        // Calculate total height of the dragged node's chain
-        const chain = getChain(draggedNode);
-        let chainHeight = 0;
-        chain.forEach((chainNode) => {
-          chainHeight += getNodeDimensions(chainNode.id).height;
-        });
-
-        // Check if node (and its entire chain) overlaps with any other nodes
-        if (
-          currentDragPosition &&
-          checkNodeOverlap(
-            currentDragPosition.x,
-            currentDragPosition.y,
-            draggedNode.id,
-            nodes,
-            dims.width,
-            chainHeight,
-          )
-        ) {
-          // Find nearest non-overlapping position
-          const newPos = findNearestNonOverlappingPosition(
-            currentDragPosition.x,
-            currentDragPosition.y,
-            draggedNode.id,
-            nodes,
-            dims.width,
-            chainHeight,
-          );
-          // Update to the non-overlapping position
-          currentDragPosition = newPos;
-          canvasState.tempNodePositions.set(draggedNode.id, newPos);
-        }
-      }
-
-      // Call onNodeMove with final position if it changed
-      // For undocked nodes, this provides the final position after dragging
-      // For regular nodes, this is the only position update
-      if (onNodeMove !== undefined && currentDragPosition !== null) {
-        const startX = dragStartPosition?.x ?? 0;
-        const startY = dragStartPosition?.y ?? 0;
-        const moved =
-          Math.abs(currentDragPosition.x - startX) > 0.5 ||
-          Math.abs(currentDragPosition.y - startY) > 0.5;
-        if (moved || canvasState.undockedNode !== null) {
-          onNodeMove(
-            canvasState.draggedNode,
-            currentDragPosition.x,
-            currentDragPosition.y,
-          );
-        }
-      }
-    }
-
-    // Handle label callbacks with final values
-    const {onLabelMove, onLabelResize} = vnode.attrs;
-
-    if (canvasState.draggedLabel !== null) {
-      const finalPos = canvasState.tempLabelPositions.get(
-        canvasState.draggedLabel,
-      );
-      if (finalPos && onLabelMove) {
-        onLabelMove(canvasState.draggedLabel, finalPos.x, finalPos.y);
-      }
-    }
-
-    if (canvasState.resizingLabel !== null) {
-      const finalWidth = canvasState.tempLabelWidths.get(
-        canvasState.resizingLabel,
-      );
-      if (finalWidth !== undefined && onLabelResize) {
-        onLabelResize(canvasState.resizingLabel, finalWidth);
-      }
-    }
-
-    // Cleanup label state
-    canvasState.draggedLabel = null;
-    canvasState.labelDragStartPos = null;
-    canvasState.resizingLabel = null;
-    canvasState.tempLabelPositions.clear();
-    canvasState.tempLabelWidths.clear();
-
-    canvasState.draggedNode = null;
-    dragStartPosition = null;
-    currentDragPosition = null;
-    canvasState.connecting = null;
-    canvasState.hoveredPort = null;
-    canvasState.isPanning = false;
-    canvasState.dockTarget = null;
-    canvasState.isDockZone = false;
-    canvasState.undockCandidate = null;
-    canvasState.undockedNode = null;
-    canvasState.tempNodePositions.clear();
-    m.redraw();
-  };
-
-  // Helper to determine port type based on port index
-  function getPortType(
-    nodeId: string,
-    portType: 'input' | 'output',
-    portIndex: number,
-    nodes: ReadonlyArray<Node>,
-  ): 'top' | 'bottom' | 'left' | 'right' {
-    // Search in main nodes array
-    let node: Node | Omit<Node, 'x' | 'y'> | undefined = nodes.find(
-      (n) => n.id === nodeId,
-    );
-
-    // If not found, search in the next chains of all nodes
-    if (!node) {
-      for (const rootNode of nodes) {
-        let current = rootNode.next;
-        while (current) {
-          if (current.id === nodeId) {
-            node = current;
-            break;
-          }
-          current = current.next;
-        }
-        if (node) break;
-      }
-    }
-
-    if (!node) return portType === 'input' ? 'left' : 'right';
-
-    // Get the port from the node
-    const ports = portType === 'input' ? node.inputs : node.outputs;
-    if (!ports || portIndex >= ports.length) {
-      return portType === 'input' ? 'left' : 'right';
-    }
-
-    return ports[portIndex].direction;
-  }
-
-  function renderConnections(
-    svg: SVGElement,
-    connections: ReadonlyArray<Connection>,
-    nodes: ReadonlyArray<Node>,
-    onConnectionRemove?: (index: number) => void,
-  ) {
-    const shortenLength = 16;
-    const arrowheadLength = 4;
-
-    // Cache all port positions at once for performance
-    const portPositionCache = new Map<string, Position>();
-
-    // Query ports within this NodeGraph instance only (not globally).
-    // Using document.querySelectorAll would pick up ports from other
-    // NodeGraph instances (e.g. hidden tabs), causing incorrect positions.
-    const container = assertExists(canvasElement);
-    const allPorts = container.querySelectorAll('.pf-port[data-port]');
-    allPorts.forEach((portElement) => {
-      const portId = portElement.getAttribute('data-port');
-      if (!portId) return;
-
-      const nodeElement = portElement.closest(
-        '[data-node]',
-      ) as HTMLElement | null;
-      if (!nodeElement) return;
-
-      const nodeId = nodeElement.dataset.node;
-      if (!nodeId) return;
-
-      const [portType, portIndexStr] = portId.split('-');
-      const cacheKey = `${nodeId}-${portType}-${portIndexStr}`;
-
-      // Calculate position
-      const chainContainer = nodeElement.closest(
-        '.pf-node-wrapper',
-      ) as HTMLElement | null;
-
-      let nodeLeft: number;
-      let nodeTop: number;
-
-      if (chainContainer) {
-        // Node is in a dock chain - use container's position
-        nodeLeft = parseFloat(chainContainer.style.left) || 0;
-        nodeTop = parseFloat(chainContainer.style.top) || 0;
-
-        // Add offset of node within the chain
-        const chainRect = chainContainer.getBoundingClientRect();
-        const nodeRect = nodeElement.getBoundingClientRect();
-        const offsetY = (nodeRect.top - chainRect.top) / canvasState.zoom;
-
-        nodeTop += offsetY;
-      } else {
-        // Standalone node - use its position directly
-        nodeLeft = parseFloat(nodeElement.style.left) || 0;
-        nodeTop = parseFloat(nodeElement.style.top) || 0;
-      }
-
-      // Get port's position relative to the node
-      const portRect = portElement.getBoundingClientRect();
-      const nodeRect = nodeElement.getBoundingClientRect();
-
-      // Calculate offset in screen space, then divide by zoom to get canvas content space
-      const portX =
-        (portRect.left - nodeRect.left + portRect.width / 2) / canvasState.zoom;
-      const portY =
-        (portRect.top - nodeRect.top + portRect.height / 2) / canvasState.zoom;
-
-      portPositionCache.set(cacheKey, {
-        x: nodeLeft + portX,
-        y: nodeTop + portY,
-      });
-    });
-
-    // Helper function to get port position from cache or fallback to direct lookup
-    const getPortPos = (
-      nodeId: string,
-      portType: 'input' | 'output',
-      portIndex: number,
-    ): Position => {
-      const cacheKey = `${nodeId}-${portType}-${portIndex}`;
-      return (
-        portPositionCache.get(cacheKey) ||
-        getPortPosition(nodeId, portType, portIndex)
-      );
-    };
-
-    // Build arrowhead markers using mithril
-    const arrowheadMarker = (id: string) =>
-      m(
-        'marker',
-        {
-          id,
-          viewBox: `0 0 ${arrowheadLength} 10`,
-          refX: '0',
-          refY: '5',
-          markerWidth: `${arrowheadLength}`,
-          markerHeight: '10',
-          orient: 'auto',
-        },
-        m('polygon', {
-          points: `0 2.5, ${arrowheadLength} 5, 0 7.5`,
-          fill: 'context-stroke',
-        }),
-      );
-
-    // Build connection paths using mithril
-    // Each connection is rendered as two paths: a wider invisible hitbox and the visible line
-    const connectionPaths = connections
-      .map((conn, idx) => {
-        const from = getPortPos(conn.fromNode, 'output', conn.fromPort);
-        const to = getPortPos(conn.toNode, 'input', conn.toPort);
-
-        // Validate that both ports exist (return {x: 0, y: 0} if not found)
-        const fromValid = from.x !== 0 || from.y !== 0;
-        const toValid = to.x !== 0 || to.y !== 0;
-
-        if (!fromValid || !toValid) {
-          console.warn(
-            `Invalid connection: ${conn.fromNode}:${conn.fromPort} -> ${conn.toNode}:${conn.toPort}`,
-            !fromValid ? `(source port not found)` : `(target port not found)`,
-          );
-          return null;
-        }
-
-        const fromPortType = getPortType(
-          conn.fromNode,
-          'output',
-          conn.fromPort,
-          nodes,
-        );
-        const toPortType = getPortType(
-          conn.toNode,
-          'input',
-          conn.toPort,
-          nodes,
-        );
-
-        const pathData = createCurve(
-          from.x,
-          from.y,
-          to.x,
-          to.y,
-          fromPortType,
-          toPortType,
-          shortenLength,
-        );
-
-        const handlePointerDown = (e: PointerEvent) => {
-          e.stopPropagation();
-          e.preventDefault();
-        };
-
-        const handleClick = (e: Event) => {
-          e.stopPropagation();
-          if (onConnectionRemove !== undefined) {
-            onConnectionRemove(idx);
-          }
-        };
-
-        // Return a group with both the hitbox and visible path
-        return m('g', {key: `conn-${idx}`, class: 'pf-connection-group'}, [
-          // Invisible wider hitbox path
-          m('path', {
-            d: pathData,
-            class: 'pf-connection-hitbox',
-            style: {
-              stroke: 'transparent',
-              strokeWidth: '20',
-              fill: 'none',
-              pointerEvents: 'stroke',
-              cursor: 'pointer',
-            },
-            onpointerdown: handlePointerDown,
-            onclick: handleClick,
-          }),
-          // Visible connection path
-          m('path', {
-            'd': pathData,
-            'class': 'pf-connection',
-            'marker-end': `url(#arrowhead-${instanceId})`,
-            'style': {
-              pointerEvents: 'none',
-            },
-            'onpointerdown': handlePointerDown,
-            'onclick': handleClick,
-          }),
-        ]);
-      })
-      .filter((path) => path !== null);
-
-    // Build temp connection if connecting
-    let tempConnectionPath = null;
-    if (canvasState.connecting) {
-      const fromX = canvasState.connecting.transformedX;
-      const fromY = canvasState.connecting.transformedY;
-      let toX = canvasState.mousePos.transformedX ?? 0;
-      let toY = canvasState.mousePos.transformedY ?? 0;
-
-      const fromPortType = canvasState.connecting.portType;
-      let toPortType: 'top' | 'left' | 'right' | 'bottom' =
-        fromPortType === 'top' || fromPortType === 'bottom' ? 'top' : 'left';
-
-      if (
-        canvasState.hoveredPort &&
-        canvasState.connecting.type === 'output' &&
-        canvasState.hoveredPort.type === 'input'
-      ) {
-        const {nodeId, portIndex, type} = canvasState.hoveredPort;
-        const hoverPos = getPortPos(nodeId, type, portIndex);
-        if (hoverPos.x !== 0 || hoverPos.y !== 0) {
-          toX = hoverPos.x;
-          toY = hoverPos.y;
-          toPortType = getPortType(nodeId, type, portIndex, nodes);
-        }
-      }
-
-      tempConnectionPath = m('path', {
-        'class': 'pf-temp-connection',
-        'd': createCurve(
-          fromX,
-          fromY,
-          toX,
-          toY,
-          fromPortType,
-          toPortType,
-          shortenLength,
-        ),
-        'marker-end': `url(#arrowhead-${instanceId})`,
-      });
-    }
-
-    // Render everything using mithril's render function.
-    // Use instance-unique marker ID to avoid conflicts when multiple
-    // NodeGraph instances exist in the document (e.g. tabs).
-    const markerId = `arrowhead-${instanceId}`;
-    m.render(svg, [
-      m('defs', [arrowheadMarker(markerId)]),
-      m('g', connectionPaths),
-      tempConnectionPath,
-    ]);
-  }
-
-  function getPortPosition(
-    nodeId: string,
-    portType: 'input' | 'output',
-    portIndex: number,
-  ): Position {
-    // For port index 0 (top/bottom), data-port is on .pf-port itself
-    // For port index 1+ (left/right), data-port is on .pf-port-row wrapper
-    const selector =
-      portIndex === 0
-        ? `[data-node="${nodeId}"] .pf-port[data-port="${portType}-${portIndex}"]`
-        : `[data-node="${nodeId}"] [data-port="${portType}-${portIndex}"] .pf-port`;
-
-    // Scope to this NodeGraph instance to avoid matching elements from other
-    // instances (e.g. hidden tabs with the same node IDs).
-    const scope = assertExists(canvasElement);
-    const portElement = scope.querySelector(selector);
-
-    if (portElement) {
-      const nodeElement = portElement.closest('.pf-node') as HTMLElement | null;
-      if (nodeElement !== null) {
-        // Check if node is in a dock chain (flexbox positioning)
-        const chainContainer = nodeElement.closest(
-          '.pf-node-wrapper',
-        ) as HTMLElement | null;
-
-        let nodeLeft: number;
-        let nodeTop: number;
-
-        if (chainContainer) {
-          // Node is in a dock chain - use container's position
-          nodeLeft = parseFloat(chainContainer.style.left) || 0;
-          nodeTop = parseFloat(chainContainer.style.top) || 0;
-
-          // Add offset of node within the chain
-          const chainRect = chainContainer.getBoundingClientRect();
-          const nodeRect = nodeElement.getBoundingClientRect();
-          const offsetY = (nodeRect.top - chainRect.top) / canvasState.zoom;
-
-          nodeTop += offsetY;
-        } else {
-          // Standalone node - use its position directly
-          nodeLeft = parseFloat(nodeElement.style.left) || 0;
-          nodeTop = parseFloat(nodeElement.style.top) || 0;
-        }
-
-        // Get port's position relative to the node
-        const portRect = portElement.getBoundingClientRect();
-        const nodeRect = nodeElement.getBoundingClientRect();
-
-        // Calculate offset in screen space, then divide by zoom to get canvas content space
-        const portX =
-          (portRect.left - nodeRect.left + portRect.width / 2) /
-          canvasState.zoom;
-        const portY =
-          (portRect.top - nodeRect.top + portRect.height / 2) /
-          canvasState.zoom;
-
-        return {
-          x: nodeLeft + portX,
-          y: nodeTop + portY,
-        };
-      }
-    }
-
-    return {x: 0, y: 0};
-  }
-
-  // Find if dragged node is in dock zone of any node
-  function findDockTarget(
-    draggedNode: Node,
-    draggedX: number,
-    draggedY: number,
-    nodes: ReadonlyArray<Node>,
-  ): {targetNodeId: string | null; isValidZone: boolean} {
-    const DOCK_DISTANCE = 30;
-    const HORIZONTAL_TOLERANCE = 100;
-
-    // Check if dragged node can be docked at the top
-    if (!draggedNode.canDockTop) {
-      return {targetNodeId: null, isValidZone: false};
-    }
-
-    const draggedPos = {x: draggedX, y: draggedY};
-
-    for (const node of nodes) {
-      if (node.id === draggedNode.id) continue;
-
-      // Find the last node in this chain
-      let lastInChain: Node | Omit<Node, 'x' | 'y'> = node;
-      while (lastInChain.next) {
-        lastInChain = lastInChain.next;
-      }
-
-      // Check if last node in chain allows docking below it
-      if (!lastInChain.canDockBottom) {
-        continue; // Skip this node as a dock target
-      }
-
-      const nodePos = {x: node.x, y: node.y};
-      const lastDims = getNodeDimensions(lastInChain.id);
-
-      // Calculate position of last node in chain
-      let chainHeight = 0;
-      let current: Node | Omit<Node, 'x' | 'y'> = node;
-      while (current !== lastInChain) {
-        chainHeight += getNodeDimensions(current.id).height;
-        current = current.next!;
-      }
-
-      const nodeBottom = nodePos.y + chainHeight + lastDims.height;
-
-      const verticalDist = draggedPos.y - nodeBottom;
-      const isBelow = verticalDist >= -10 && verticalDist <= DOCK_DISTANCE;
-
-      const draggedDims = getNodeDimensions(draggedNode.id);
-      const nodeDims = getNodeDimensions(node.id);
-      const horizontalDist = Math.abs(
-        nodePos.x + nodeDims.width / 2 - (draggedPos.x + draggedDims.width / 2),
-      );
-      const isAligned = horizontalDist <= HORIZONTAL_TOLERANCE;
-
-      if (isBelow && isAligned) {
-        // Return the ID of the LAST node in the chain
-        return {targetNodeId: lastInChain.id, isValidZone: true};
-      }
-    }
-
-    return {targetNodeId: null, isValidZone: false};
-  }
-
-  function getNodeDimensions(nodeId: string): {width: number; height: number} {
-    const scope = assertExists(canvasElement);
-    const nodeElement = scope.querySelector(`[data-node="${nodeId}"]`);
-    if (nodeElement) {
-      const rect = nodeElement.getBoundingClientRect();
-      // Divide by zoom to get canvas content space dimensions
-      return {
-        width: rect.width / canvasState.zoom,
-        height: rect.height / canvasState.zoom,
-      };
-    }
-    // Fallback if DOM element not found
-    return {width: 180, height: 100};
-  }
-
-  function checkNodeOverlap(
-    x: number,
-    y: number,
-    nodeId: string,
-    nodes: ReadonlyArray<Node>,
-    nodeWidth: number,
-    nodeHeight: number,
-  ): boolean {
-    const padding = 10;
-
-    for (const node of nodes) {
-      if (node.id === nodeId) continue; // Don't check against self
-
-      // Get dimensions of the node we're checking against
-      const otherDims = getNodeDimensions(node.id);
-
-      // Calculate total height of the other node's chain
-      const chain = getChain(node);
-      let otherChainHeight = 0;
-      chain.forEach((chainNode) => {
-        otherChainHeight += getNodeDimensions(chainNode.id).height;
-      });
-
-      const overlaps = !(
-        x + nodeWidth + padding < node.x ||
-        x > node.x + otherDims.width + padding ||
-        y + nodeHeight + padding < node.y ||
-        y > node.y + otherChainHeight + padding
-      );
-
-      if (overlaps) return true;
-    }
-    return false;
-  }
-
-  function findNearestNonOverlappingPosition(
-    startX: number,
-    startY: number,
-    nodeId: string,
-    nodes: ReadonlyArray<Node>,
-    nodeWidth: number,
-    nodeHeight: number,
-  ): Position {
-    // If no overlap at current position, return it
-    if (
-      !checkNodeOverlap(startX, startY, nodeId, nodes, nodeWidth, nodeHeight)
-    ) {
-      return {x: startX, y: startY};
-    }
-
-    // Search in a spiral pattern for a non-overlapping position
-    const step = 20; // Step size for searching
-    const maxRadius = 500; // Maximum search radius
-
-    for (let radius = step; radius <= maxRadius; radius += step) {
-      // Try positions in a circle around the original position
-      const numSteps = Math.ceil((2 * Math.PI * radius) / step);
-
-      for (let i = 0; i < numSteps; i++) {
-        const angle = (2 * Math.PI * i) / numSteps;
-        const x = Math.round(startX + radius * Math.cos(angle));
-        const y = Math.round(startY + radius * Math.sin(angle));
-
-        if (!checkNodeOverlap(x, y, nodeId, nodes, nodeWidth, nodeHeight)) {
-          return {x, y};
-        }
-      }
-    }
-
-    // Fallback: return original position if no free space found
-    return {x: startX, y: startY};
-  }
-
-  function getNodesBoundingBox(
-    nodes: ReadonlyArray<Node>,
-    includeChains: boolean,
-  ): {minX: number; minY: number; maxX: number; maxY: number} {
-    if (nodes.length === 0) {
-      return {minX: 0, minY: 0, maxX: 0, maxY: 0};
-    }
-
-    let minX = Infinity;
-    let minY = Infinity;
-    let maxX = -Infinity;
-    let maxY = -Infinity;
-
-    nodes.forEach((node) => {
-      const dims = getNodeDimensions(node.id);
-      minX = Math.min(minX, node.x);
-      minY = Math.min(minY, node.y);
-      maxX = Math.max(maxX, node.x + dims.width);
-
-      if (includeChains) {
-        const chain = getChain(node);
-        let chainHeight = 0;
-        chain.forEach((chainNode) => {
-          const chainDims = getNodeDimensions(chainNode.id);
-          chainHeight += chainDims.height;
-        });
-        maxY = Math.max(maxY, node.y + chainHeight);
-      } else {
-        maxY = Math.max(maxY, node.y + dims.height);
-      }
-    });
-
-    return {minX, minY, maxX, maxY};
-  }
-
-  // Helper to perform auto-layout
-  function autoLayoutGraph(
-    nodes: ReadonlyArray<Node>,
-    connections: ReadonlyArray<Connection>,
-    onNodeMove: ((nodeId: string, x: number, y: number) => void) | undefined,
-  ) {
-    // Build a map from any node ID (including nodes in chains) to its root node ID
-    const nodeIdToRootId = new Map<string, string>();
-    nodes.forEach((node) => {
-      nodeIdToRootId.set(node.id, node.id);
-      const chain = getChain(node);
-      chain.slice(1).forEach((chainNode) => {
-        nodeIdToRootId.set(chainNode.id, node.id);
-      });
-    });
-
-    // Find root nodes (nodes with no incoming connections)
-    // Count connections to any node in a chain as connections to the root
-    const incomingCounts = new Map<string, number>();
-    nodes.forEach((node) => incomingCounts.set(node.id, 0));
-    connections.forEach((conn) => {
-      const rootId = nodeIdToRootId.get(conn.toNode) ?? conn.toNode;
-      const currentCount = incomingCounts.get(rootId) ?? 0;
-      incomingCounts.set(rootId, currentCount + 1);
-    });
-
-    const rootNodes = nodes.filter((node) => incomingCounts.get(node.id) === 0);
-    const visited = new Set<string>();
-    const layers: string[][] = [];
-
-    // BFS to assign nodes to layers
-    const queue: Array<{id: string; layer: number}> = rootNodes.map((n) => ({
-      id: n.id,
-      layer: 0,
-    }));
-
-    while (queue.length > 0) {
-      const {id, layer} = queue.shift()!;
-      if (visited.has(id)) continue;
-      visited.add(id);
-
-      if (layers[layer] === undefined) layers[layer] = [];
-      layers[layer].push(id);
-
-      // Add connected nodes to next layer
-      // If connection goes to a node in a chain, add the root node
-      connections
-        .filter((conn) => {
-          // Check if this node or any node in its chain is the source
-          const node = nodes.find((n) => n.id === id);
-          if (!node) return false;
-          const chain = getChain(node);
-          return chain.some((chainNode) => chainNode.id === conn.fromNode);
-        })
-        .forEach((conn) => {
-          const rootId = nodeIdToRootId.get(conn.toNode) ?? conn.toNode;
-          if (!visited.has(rootId)) {
-            queue.push({id: rootId, layer: layer + 1});
-          }
-        });
-    }
-
-    // Position nodes using actual DOM dimensions
-    const layerSpacing = 50; // Horizontal spacing between layers
-    let currentX = 50; // Start position
-
-    layers.forEach((layer) => {
-      // Find the widest node in this layer (considering entire chains)
-      let maxWidth = 0;
-      layer.forEach((nodeId) => {
-        const node = nodes.find((n) => n.id === nodeId);
-        if (node) {
-          // Check width of all nodes in the chain
-          const chain = getChain(node);
-          chain.forEach((chainNode) => {
-            const chainDims = getNodeDimensions(chainNode.id);
-            maxWidth = Math.max(maxWidth, chainDims.width);
-          });
-        }
-      });
-
-      // Position each node in this layer
-      let currentY = 50;
-      layer.forEach((nodeId) => {
-        const node = nodes.find((n) => n.id === nodeId);
-        if (node && onNodeMove) {
-          onNodeMove(node.id, currentX, currentY);
-
-          // Calculate height of entire chain
-          const chain = getChain(node);
-          let chainHeight = 0;
-          chain.forEach((chainNode) => {
-            const dims = getNodeDimensions(chainNode.id);
-            chainHeight += dims.height;
-          });
-
-          currentY += chainHeight + 30;
-        }
-      });
-
-      // Move to next layer
-      currentX += maxWidth + layerSpacing;
-    });
-
-    m.redraw();
-  }
-
-  function autofit(
-    nodes: ReadonlyArray<Node>,
-    labels: ReadonlyArray<Label>,
-    canvas: HTMLElement,
-  ) {
-    if (nodes.length === 0 && labels.length === 0) return;
-
-    // Initialize bounding box
-    // If we have nodes, start with their bounding box
-    // If we only have labels, initialize with Infinity values that will be replaced
-    let minX = Infinity;
-    let minY = Infinity;
-    let maxX = -Infinity;
-    let maxY = -Infinity;
-
-    if (nodes.length > 0) {
-      const nodesBBox = getNodesBoundingBox(nodes, true);
-      minX = nodesBBox.minX;
-      minY = nodesBBox.minY;
-      maxX = nodesBBox.maxX;
-      maxY = nodesBBox.maxY;
-    }
-
-    // Include labels in bounding box calculation
-    labels.forEach((label) => {
-      minX = Math.min(minX, label.x);
-      minY = Math.min(minY, label.y);
-      maxX = Math.max(maxX, label.x + label.width);
-      maxY = Math.max(maxY, label.y + TYPICAL_LABEL_HEIGHT);
-    });
-
-    // Calculate bounding box dimensions
-    const boundingWidth = maxX - minX;
-    const boundingHeight = maxY - minY;
-
-    // Get canvas dimensions
-    const canvasRect = canvas.getBoundingClientRect();
-
-    // Calculate zoom to fit with buffer (10% padding)
-    const bufferFactor = 0.9; // Use 90% of viewport to leave 10% buffer
-    const zoomX = (canvasRect.width * bufferFactor) / boundingWidth;
-    const zoomY = (canvasRect.height * bufferFactor) / boundingHeight;
-    const newZoom = Math.max(0.1, Math.min(1.0, Math.min(zoomX, zoomY)));
-
-    // Calculate the scaled bounding box dimensions
-    const scaledWidth = boundingWidth * newZoom;
-    const scaledHeight = boundingHeight * newZoom;
-
-    // Calculate pan offset to center the bounding box with equal padding on all sides
-    const paddingX = (canvasRect.width - scaledWidth) / 2;
-    const paddingY = (canvasRect.height - scaledHeight) / 2;
-
-    canvasState.zoom = newZoom;
-    canvasState.panOffset = {
-      x: paddingX - minX * newZoom,
-      y: paddingY - minY * newZoom,
-    };
-
-    m.redraw();
-  }
-
-  const handleWheel = (e: WheelEvent) => {
-    if (!canvasElement) return;
-    e.preventDefault();
-
-    // Zoom with Ctrl+wheel, pan without Ctrl
-    if (e.ctrlKey || e.metaKey) {
-      // Zoom around mouse position
-      const canvas = canvasElement;
-      const canvasRect = canvas.getBoundingClientRect();
-      const mouseX = e.clientX - canvasRect.left;
-      const mouseY = e.clientY - canvasRect.top;
-
-      // Calculate zoom delta (negative deltaY = zoom in)
-      const zoomDelta = -e.deltaY * 0.003;
-      const newZoom = Math.max(
-        0.1,
-        Math.min(5.0, canvasState.zoom * (1 + zoomDelta)),
-      );
-
-      // Calculate the point in canvas space (before zoom)
-      const canvasX = (mouseX - canvasState.panOffset.x) / canvasState.zoom;
-      const canvasY = (mouseY - canvasState.panOffset.y) / canvasState.zoom;
-
-      // Update zoom
-      canvasState.zoom = newZoom;
-
-      // Adjust pan to keep the same point under the mouse
-      canvasState.panOffset = {
-        x: mouseX - canvasX * newZoom,
-        y: mouseY - canvasY * newZoom,
-      };
-    } else {
-      // Pan the canvas based on wheel delta
-      panBy(-e.deltaX, -e.deltaY);
-    }
-
-    m.redraw();
-  };
-
-  // Helper function to render a single node
-  function renderNode(
-    node: Node | Omit<Node, 'x' | 'y'>,
-    vnode: m.Vnode<NodeGraphAttrs>,
-    options: {
-      isDockedChild: boolean;
-      hasDockedChild: boolean;
-      isDockTarget: boolean;
-      rootNode?: Node;
-      multiselect: boolean;
-      contextMenuOnHover: boolean;
-    },
-  ): m.Vnode {
-    const {
-      id,
-      inputs = [],
-      outputs = [],
-      titleBar,
-      content,
-      hue,
-      accentBar,
-      contextMenuItems,
-      invalid,
-      className: nodeClassName,
-    } = node;
-    const {
-      isDockedChild,
-      hasDockedChild,
-      isDockTarget,
-      rootNode,
-      multiselect,
-      contextMenuOnHover,
-    } = options;
-    const {connections = [], onConnect, nodes = []} = vnode.attrs;
-
-    // Separate ports by direction
-    const topInputs = inputs.filter((p) => p.direction === 'top');
-    const leftInputs = inputs.filter((p) => p.direction === 'left');
-    const bottomOutputs = outputs.filter((p) => p.direction === 'bottom');
-    const rightOutputs = outputs.filter((p) => p.direction === 'right');
-
-    const classes = classNames(
-      canvasState.selectedNodes.has(id) && 'pf-selected',
-      isDockedChild && 'pf-docked-child',
-      hasDockedChild && 'pf-has-docked-child',
-      isDockTarget && 'pf-dock-target',
-      accentBar && 'pf-node--has-accent-bar',
-      invalid && 'pf-invalid',
-      nodeClassName,
-    );
-
-    // Helper to render a port
-    const renderPort = (
-      port: NodePort,
-      portIndex: number,
-      portType: 'input' | 'output',
-      forceConnected?: boolean,
-    ) => {
-      const portId = `${portType}-${portIndex}`;
-      const cssClass = classNames(
-        portType === 'input' ? 'pf-input' : 'pf-output',
-        `pf-port-${port.direction}`,
-        (forceConnected ||
-          isPortConnected(id, portType, portIndex, connections)) &&
-          'pf-connected',
-        canvasState.connecting &&
-          canvasState.connecting.nodeId === id &&
-          canvasState.connecting.portIndex === portIndex &&
-          canvasState.connecting.type === portType &&
-          'pf-active',
-        port.contextMenuItems !== undefined && 'pf-port--with-context-menu',
-      );
-
-      const portElement = m('.pf-port', {
-        'data-port': portId,
-        'className': cssClass,
-        'onpointerdown': (e: PointerEvent) => {
-          e.stopPropagation();
-          if (portType === 'input') {
-            // Input port - check for existing connection
-            const existingConnIdx = connections.findIndex(
-              (conn) => conn.toNode === id && conn.toPort === portIndex,
-            );
-            if (existingConnIdx !== -1) {
-              const existingConn = connections[existingConnIdx];
-              const {onConnectionRemove} = vnode.attrs;
-              if (onConnectionRemove !== undefined) {
-                onConnectionRemove(existingConnIdx);
-              }
-              const outputPos = getPortPosition(
-                existingConn.fromNode,
-                'output',
-                existingConn.fromPort,
-              );
-              canvasState.connecting = {
-                nodeId: existingConn.fromNode,
-                portIndex: existingConn.fromPort,
-                type: 'output',
-                portType: getPortType(
-                  existingConn.fromNode,
-                  'output',
-                  existingConn.fromPort,
-                  nodes,
-                ),
-                x: 0,
-                y: 0,
-                transformedX: outputPos.x,
-                transformedY: outputPos.y,
-              };
-              m.redraw();
-            }
-          } else {
-            // Output port - start connection
-            const portPos = getPortPosition(id, portType, portIndex);
-            canvasState.connecting = {
-              nodeId: id,
-              portIndex,
-              type: portType,
-              portType: port.direction,
-              x: 0,
-              y: 0,
-              transformedX: portPos.x,
-              transformedY: portPos.y,
-            };
-          }
-        },
-        'onpointerup': (e: PointerEvent) => {
-          e.stopPropagation();
-          if (portType === 'input') {
-            if (
-              canvasState.connecting &&
-              canvasState.connecting.type === 'output'
-            ) {
-              // Input port receiving connection
-              const existingConnIdx = connections.findIndex(
-                (conn) => conn.toNode === id && conn.toPort === portIndex,
-              );
-              if (existingConnIdx !== -1) {
-                const {onConnectionRemove} = vnode.attrs;
-                if (onConnectionRemove !== undefined) {
-                  onConnectionRemove(existingConnIdx);
-                }
-              }
-              const connection = {
-                fromNode: canvasState.connecting.nodeId,
-                fromPort: canvasState.connecting.portIndex,
-                toNode: id,
-                toPort: portIndex,
-              };
-              if (onConnect !== undefined) {
-                onConnect(connection);
-              }
-              canvasState.connecting = null;
-            }
-          } else if (portType === 'output') {
-            // Clear connecting state if releasing on output port without completing connection
-            canvasState.connecting = null;
-          }
-        },
-      });
-
-      // Wrap with PopupMenu if contextMenuItems exist
-      if (port.contextMenuItems !== undefined) {
-        return m(PopupMenu, {trigger: portElement}, port.contextMenuItems);
-      }
-      return portElement;
-    };
-
-    const style = hue !== undefined ? {'--pf-node-hue': `${hue}`} : undefined;
-
-    return m(
-      '.pf-node',
-      {
-        'key': id,
-        'data-node': id,
-        'class': classes,
-        'style': {
-          ...style,
-        },
-        'onpointerdown': (e: PointerEvent) => {
-          if ((e.target as HTMLElement).closest('.pf-port')) {
-            return;
-          }
-          e.stopPropagation();
-
-          // Handle multi-selection with Shift or Cmd/Ctrl (only if multiselect is enabled)
-          if (multiselect && (e.shiftKey || e.metaKey || e.ctrlKey)) {
-            // Toggle selection
-            if (canvasState.selectedNodes.has(id)) {
-              const {onNodeRemoveFromSelection} = vnode.attrs;
-              if (onNodeRemoveFromSelection !== undefined) {
-                onNodeRemoveFromSelection(id);
-              }
-            } else {
-              const {onNodeAddToSelection} = vnode.attrs;
-              if (onNodeAddToSelection !== undefined) {
-                onNodeAddToSelection(id);
-              }
-            }
-
-            // Focus the canvas element to ensure keyboard events (like Delete) are captured
-            if (canvasElement) {
-              canvasElement.focus();
-            }
-
-            return;
-          }
-
-          const {onNodeSelect, selectedNodeIds} = vnode.attrs;
-
-          // When multiple nodes are selected, disable all dragging.
-          // Only allow selection change and focus.
-          if (selectedNodeIds !== undefined && selectedNodeIds.size > 1) {
-            // If clicking an unselected node, select just that node
-            if (!selectedNodeIds.has(id) && onNodeSelect !== undefined) {
-              onNodeSelect(id);
-            }
-            // Focus the canvas for keyboard events (Delete, etc.)
-            if (canvasElement) {
-              canvasElement.focus();
-            }
-            return;
-          }
-
-          // Check if this is a chained node (not root)
-          if (isDockedChild && rootNode) {
-            // Don't undock immediately - wait for drag threshold
-            // Calculate current render position
-            let yOffset = rootNode.y;
-            const chainArr = getChain(rootNode);
-            for (const cn of chainArr) {
-              if (cn.id === id) break;
-              yOffset += getNodeDimensions(cn.id).height;
-            }
-
-            // Find parent node in chain
-            let parentId = rootNode.id;
-            let curr = rootNode.next;
-            while (curr && curr.id !== id) {
-              parentId = curr.id;
-              curr = curr.next;
-            }
-
-            // Store undock candidate - will undock if dragged beyond threshold
-            canvasState.undockCandidate = {
-              nodeId: id,
-              parentId: parentId,
-              startX: e.clientX,
-              startY: e.clientY,
-              renderY: yOffset,
-            };
-          }
-
-          canvasState.draggedNode = id;
-
-          // Store initial drag position for batching
-          // Check if node has x,y properties (root nodes) vs docked children (no x,y)
-          if ('x' in node && 'y' in node) {
-            dragStartPosition = {nodeId: id, x: node.x, y: node.y};
-            currentDragPosition = {x: node.x, y: node.y};
-          }
-
-          // Only change selection if the clicked node is not already selected
-          // This prevents unnecessary selection changes when starting to drag
-          if (!selectedNodeIds?.has(id) && onNodeSelect !== undefined) {
-            onNodeSelect(id);
-          }
-
-          // Focus the canvas element to ensure keyboard events (like Delete) are captured
-          if (canvasElement) {
-            canvasElement.focus();
-          }
-
-          const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
-          canvasState.dragOffset = {
-            x: e.clientX - rect.left,
-            y: e.clientY - rect.top,
-          };
-        },
-      },
-      [
-        // Render node title if it exists
-        titleBar !== undefined &&
-          m('.pf-node-header', [
-            titleBar.icon !== undefined &&
-              m(Icon, {icon: titleBar.icon, className: 'pf-node-title-icon'}),
-            m('.pf-node-title', titleBar.title),
-            contextMenuItems !== undefined &&
-              m(
-                PopupMenu,
-                {
-                  trigger: m(Button, {
-                    rounded: true,
-                    icon: Icons.ContextMenuAlt,
-                    className: contextMenuOnHover ? 'pf-show-on-hover' : '',
-                  }),
-                },
-                contextMenuItems,
-              ),
-          ]),
-
-        // Context menu button for nodes without titlebar
-        titleBar === undefined &&
-          contextMenuItems !== undefined &&
-          m(
-            '.pf-node-context-menu',
-            {className: contextMenuOnHover ? 'pf-show-on-hover' : ''},
-            m(
-              PopupMenu,
-              {
-                trigger: m(Button, {
-                  rounded: true,
-                  icon: Icons.ContextMenuAlt,
-                }),
-              },
-              contextMenuItems,
-            ),
-          ),
-
-        // Top input ports (if not docked child)
-        topInputs.map((port) => {
-          const portIndex = inputs.indexOf(port);
-          return renderPort(port, portIndex, 'input');
-        }),
-
-        m('.pf-node-body', [
-          content !== undefined &&
-            m(
-              '.pf-node-content',
-              {
-                onkeydown: (e: KeyboardEvent) => {
-                  e.stopPropagation();
-                },
-              },
-              content,
-            ),
-
-          // Left input ports
-          leftInputs.map((port) => {
-            const portIndex = inputs.indexOf(port);
-            return m(
-              '.pf-port-row.pf-port-input',
-              {
-                'data-port': `input-${portIndex}`,
-              },
-              [renderPort(port, portIndex, 'input'), port.content],
-            );
-          }),
-
-          // Right output ports
-          rightOutputs.map((port) => {
-            const portIndex = outputs.indexOf(port);
-            return m(
-              '.pf-port-row.pf-port-output',
-              {
-                'data-port': `output-${portIndex}`,
-              },
-              [port.content, renderPort(port, portIndex, 'output')],
-            );
-          }),
-        ]),
-
-        // Bottom output ports (if no docked child below)
-        bottomOutputs.map((port) => {
-          const portIndex = outputs.indexOf(port);
-          return renderPort(port, portIndex, 'output');
-        }),
-      ],
-    );
-  }
-
-  function renderLabel(label: Label, vnode: m.Vnode<NodeGraphAttrs>): m.Vnode {
-    const {id, x, y, width, content, selectable = false} = label;
-    const isDragging = canvasState.draggedLabel === id;
-    const isSelected = canvasState.selectedNodes.has(id);
-
-    // Use temporary position/width during drag if available
-    const tempPos = canvasState.tempLabelPositions.get(id);
-    const tempWidth = canvasState.tempLabelWidths.get(id);
-    const renderX = tempPos?.x ?? x;
-    const renderY = tempPos?.y ?? y;
-    const renderWidth = tempWidth ?? width;
-
-    return m(
-      '.pf-label',
-      {
-        'key': `label-${id}`,
-        'data-label': id,
-        'className': classNames(
-          isDragging && 'pf-dragging',
-          isSelected && 'pf-selected',
-        ),
-        'style': {
-          left: `${renderX}px`,
-          top: `${renderY}px`,
-          width: `${renderWidth}px`,
-        },
-        'onpointerdown': (e: PointerEvent) => {
-          const target = e.target as HTMLElement;
-
-          // Check if clicking on the resize handle or delete button
-          if (
-            target.closest('.pf-label-resize-handle') ||
-            target.closest('.pf-label-delete-button')
-          ) {
-            e.stopPropagation();
-            return;
-          }
-
-          // Check if clicking on a textarea that is being edited (not readonly)
-          // Allow normal text selection behavior in edit mode
-          if (target instanceof HTMLTextAreaElement && !target.readOnly) {
-            // Don't start dragging, allow text selection
-            return;
-          }
-
-          const {multiselect = true} = vnode.attrs;
-
-          // Handle multi-selection with Shift or Cmd/Ctrl (only if multiselect is enabled)
-          if (multiselect && (e.shiftKey || e.metaKey || e.ctrlKey)) {
-            // Toggle selection
-            if (isSelected) {
-              const {onNodeRemoveFromSelection} = vnode.attrs;
-              if (onNodeRemoveFromSelection !== undefined) {
-                onNodeRemoveFromSelection(id);
-              }
-            } else {
-              const {onNodeAddToSelection} = vnode.attrs;
-              if (onNodeAddToSelection !== undefined) {
-                onNodeAddToSelection(id);
-              }
-            }
-
-            // Focus the canvas element to ensure keyboard events (like Delete) are captured
-            if (canvasElement) {
-              canvasElement.focus();
-            }
-
-            e.stopPropagation();
-            return;
-          }
-
-          // Start dragging the label
-          canvasState.draggedLabel = id;
-          canvasState.dragOffset = {
-            x: (canvasState.mousePos.transformedX ?? 0) - x,
-            y: (canvasState.mousePos.transformedY ?? 0) - y,
-          };
-
-          // Select the label if selectable (replace current selection)
-          if (selectable) {
-            const {onNodeSelect} = vnode.attrs;
-            if (onNodeSelect !== undefined) {
-              onNodeSelect(id);
-            }
-          }
-
-          // Focus the canvas element to ensure keyboard events (like Delete) are captured
-          if (canvasElement) {
-            canvasElement.focus();
-          }
-
-          e.stopPropagation();
-        },
-      },
-      [
-        // Render the content (or placeholder if not provided)
-        m(
-          '.pf-label-content',
-          content ?? m('.pf-label-placeholder', 'Empty label'),
-        ),
-        // Resize handle (always rendered)
-        m('.pf-label-resize-handle', {
-          onpointerdown: (e: PointerEvent) => {
-            // Start resizing
-            canvasState.resizingLabel = id;
-            canvasState.resizeStartWidth = width;
-            canvasState.resizeStartX = canvasState.mousePos.transformedX ?? 0;
-            e.stopPropagation();
-          },
-        }),
-        // Delete button (always rendered, visible on hover/selection)
-        m(
-          '.pf-label-delete-button',
-          {
-            onclick: (e: PointerEvent) => {
-              const {onLabelRemove} = vnode.attrs;
-              if (onLabelRemove !== undefined) {
-                onLabelRemove(id);
-              }
-              e.stopPropagation();
-            },
-          },
-          m(Icon, {icon: 'close'}),
-        ),
-      ],
-    );
-  }
-
-  return {
-    oncreate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
-      latestVnode = vnode;
-      canvasElement = vnode.dom as HTMLElement;
-      document.addEventListener('pointermove', handleMouseMove);
-      document.addEventListener('pointerup', handleMouseUp);
-      canvasElement.addEventListener('wheel', handleWheel, {passive: false});
-
-      const {
-        connections = [],
-        nodes = [],
-        onConnectionRemove,
-        onReady,
-      } = vnode.attrs;
-
-      // Render connections after DOM is ready
-      const svg = vnode.dom.querySelector('svg');
-      if (svg) {
-        renderConnections(
-          svg as SVGElement,
-          connections,
-          nodes,
-          onConnectionRemove,
-        );
-      }
-
-      // Create auto-layout function that uses actual DOM dimensions
-      autoLayoutApi = () => {
-        const {nodes = [], connections = [], onNodeMove} = vnode.attrs;
-        autoLayoutGraph(nodes, connections, onNodeMove);
-      };
-
-      // Create recenter function that brings all nodes into view
-      recenterApi = () => {
-        if (latestVnode === null || canvasElement === null) {
-          return;
-        }
-        const {nodes = [], labels = []} = latestVnode.attrs;
-        const canvas = canvasElement;
-        autofit(nodes, labels, canvas);
-      };
-
-      // Find a non-overlapping position for a new node
-      findPlacementForNodeApi = (newNode: Omit<Node, 'x' | 'y'>): Position => {
-        if (latestVnode === null || canvasElement === null) {
-          return {x: 0, y: 0};
-        }
-
-        const {nodes = []} = latestVnode.attrs;
-        const canvas = canvasElement;
-
-        // Default starting position (center of viewport in canvas space)
-        const canvasRect = canvas.getBoundingClientRect();
-        const centerX =
-          (canvasRect.width / 2 - canvasState.panOffset.x) / canvasState.zoom;
-        const centerY =
-          (canvasRect.height / 2 - canvasState.panOffset.y) / canvasState.zoom;
-
-        // Create a temporary node with coordinates to render and measure
-        const tempNode: Node = {
-          ...newNode,
-          x: centerX,
-          y: centerY,
-        };
-
-        // Create temporary DOM element to measure size
-        const tempContainer = document.createElement('div');
-        tempContainer.style.position = 'absolute';
-        tempContainer.style.left = '-9999px';
-        tempContainer.style.visibility = 'hidden';
-        canvas.appendChild(tempContainer);
-
-        // Render the node into the temporary container with animation disabled
-        m.render(
-          tempContainer,
-          m(
-            '.pf-node-wrapper',
-            m(
-              '.pf-node',
-              {
-                'data-node': tempNode.id,
-                'style': {
-                  ...(tempNode.hue !== undefined
-                    ? {'--pf-node-hue': `${tempNode.hue}`}
-                    : {}),
-                },
-              },
-              [
-                tempNode.titleBar &&
-                  m('.pf-node-header', [
-                    m('.pf-node-title', tempNode.titleBar.title),
-                  ]),
-                m('.pf-node-body', [
-                  tempNode.content !== undefined &&
-                    m('.pf-node-content', tempNode.content),
-                  tempNode.inputs
-                    ?.filter((p) => p.direction === 'left')
-                    .map((port) =>
-                      m('.pf-port-row.pf-port-input', [
-                        m('.pf-port'),
-                        port.content,
-                      ]),
-                    ),
-                  tempNode.outputs
-                    ?.filter((p) => p.direction === 'right')
-                    .map((port) =>
-                      m('.pf-port-row.pf-port-output', [
-                        port.content,
-                        m('.pf-port'),
-                      ]),
-                    ),
-                ]),
-              ],
-            ),
-          ),
-        );
-
-        // Get dimensions from the rendered element
-        const dims = getNodeDimensions(tempNode.id);
-
-        // Calculate chain height
-        const chain = getChain(tempNode);
-        let chainHeight = 0;
-        chain.forEach((chainNode) => {
-          const chainDims = getNodeDimensions(chainNode.id);
-          chainHeight += chainDims.height;
-        });
-
-        // Clean up temporary element
-        canvas.removeChild(tempContainer);
-
-        // Find non-overlapping position starting from center
-        const finalPos = findNearestNonOverlappingPosition(
-          centerX - dims.width / 2,
-          centerY - dims.height / 2,
-          tempNode.id,
-          nodes,
-          dims.width,
-          chainHeight,
-        );
-
-        return finalPos;
-      };
-
-      // Reset zoom to 100% (1.0x) around canvas center
-      resetZoom = () => {
-        if (!canvasElement) return;
-
-        const canvas = canvasElement;
-        const canvasRect = canvas.getBoundingClientRect();
-        const centerX = canvasRect.width / 2;
-        const centerY = canvasRect.height / 2;
-
-        // Calculate the point in canvas space (before zoom)
-        const canvasX = (centerX - canvasState.panOffset.x) / canvasState.zoom;
-        const canvasY = (centerY - canvasState.panOffset.y) / canvasState.zoom;
-
-        // Reset zoom to 1.0
-        canvasState.zoom = 1.0;
-
-        // Adjust pan to keep the same point under the center
-        canvasState.panOffset = {
-          x: centerX - canvasX,
-          y: centerY - canvasY,
-        };
-
-        m.redraw();
-      };
-
-      // Provide API to parent
-      if (
-        onReady !== undefined &&
-        autoLayoutApi !== null &&
-        recenterApi !== null &&
-        findPlacementForNodeApi !== null
-      ) {
-        onReady({
-          autoLayout: autoLayoutApi,
-          recenter: recenterApi,
-          findPlacementForNode: findPlacementForNodeApi,
-          panBy,
-          zoomBy,
-          resetZoom,
-        });
-      }
-    },
-
-    onupdate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
-      latestVnode = vnode;
-      const {
-        connections = [],
-        nodes = [],
-        onConnectionRemove,
-        onReady,
-      } = vnode.attrs;
-
-      // Re-render connections when component updates
-      const svg = vnode.dom.querySelector('svg');
-      if (svg) {
-        renderConnections(
-          svg as SVGElement,
-          connections,
-          nodes,
-          onConnectionRemove,
-        );
-      }
-
-      // Call onReady after every render cycle so parent can perform
-      // post-render actions like recentering
-      if (
-        onReady !== undefined &&
-        autoLayoutApi !== null &&
-        recenterApi !== null &&
-        findPlacementForNodeApi !== null &&
-        resetZoom !== null
-      ) {
-        onReady({
-          autoLayout: autoLayoutApi,
-          recenter: recenterApi,
-          findPlacementForNode: findPlacementForNodeApi,
-          panBy,
-          zoomBy,
-          resetZoom,
-        });
-      }
-    },
-
-    onremove: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
-      document.removeEventListener('pointermove', handleMouseMove);
-      document.removeEventListener('pointerup', handleMouseUp);
-      (vnode.dom as HTMLElement).removeEventListener('wheel', handleWheel);
-    },
-
-    view: (vnode: m.Vnode<NodeGraphAttrs>) => {
-      latestVnode = vnode;
-      const {
-        nodes,
-        selectedNodeIds = new Set<string>(),
-        multiselect = true,
-        contextMenuOnHover = false,
-        fillHeight,
-      } = vnode.attrs;
-
-      // Sync internal state with prop
-      canvasState.selectedNodes = selectedNodeIds;
-
-      const className = classNames(
-        fillHeight && 'pf-canvas--fill-height',
-        canvasState.connecting && 'pf-connecting',
-        canvasState.connecting &&
-          `connecting-from-${canvasState.connecting.type}`,
-        canvasState.isPanning && 'pf-panning',
-      );
-
-      return m(
-        '.pf-canvas',
-        {
-          className,
-          tabindex: 0, // Make div focusable to capture keyboard events
-          oncontextmenu: (e: Event) => {
-            e.preventDefault(); // Disable default context menu
-          },
-          onpointerdown: (e: PointerEvent) => {
-            const target = e.target as HTMLElement;
-            if (
-              target.classList.contains('pf-canvas') ||
-              target.tagName === 'svg'
-            ) {
-              // Start box selection with Shift (only if multiselect is enabled)
-              if (multiselect && e.shiftKey) {
-                const transformedX = canvasState.mousePos.transformedX ?? 0;
-                const transformedY = canvasState.mousePos.transformedY ?? 0;
-                canvasState.selectionRect = {
-                  startX: transformedX,
-                  startY: transformedY,
-                  currentX: transformedX,
-                  currentY: transformedY,
-                };
-                return;
-              }
-
-              // Start panning and store position to detect click vs drag
-              canvasState.isPanning = true;
-              canvasState.panStart = {x: e.clientX, y: e.clientY};
-              canvasState.canvasMouseDownPos = {x: e.clientX, y: e.clientY};
-            }
-          },
-          onclick: (e: PointerEvent) => {
-            const target = e.target as HTMLElement;
-            // Clear selection on canvas click (only if mouse didn't move significantly)
-            if (
-              target.classList.contains('pf-canvas') ||
-              target.tagName === 'svg'
-            ) {
-              const dx = Math.abs(e.clientX - canvasState.canvasMouseDownPos.x);
-              const dy = Math.abs(e.clientY - canvasState.canvasMouseDownPos.y);
-              const threshold = 3; // Pixels of movement tolerance
-
-              // Only clear if it was a click (not a drag)
-              if (dx <= threshold && dy <= threshold) {
-                const {onSelectionClear} = vnode.attrs;
-                if (onSelectionClear !== undefined) {
-                  onSelectionClear();
-                }
-              }
-            }
-          },
-          onkeydown: (e: KeyboardEvent) => {
-            if (e.key === 'Escape') {
-              // Deselect all nodes and labels
-              const hasSelection = canvasState.selectedNodes.size > 0;
-              if (hasSelection) {
-                const {onSelectionClear} = vnode.attrs;
-                if (onSelectionClear !== undefined) {
-                  onSelectionClear();
-                }
-              }
-            } else if (e.key === 'Delete' || e.key === 'Backspace') {
-              const {onNodeRemove, onLabelRemove, labels = []} = vnode.attrs;
-
-              if (canvasState.selectedNodes.size > 0) {
-                // Flatten all nodes including docked nodes (via 'next' property)
-                const allNodeIds = new Set<string>();
-                const queue: Array<Node | DockedNode> = [...nodes];
-                while (queue.length > 0) {
-                  const node = queue.shift();
-                  if (node) {
-                    allNodeIds.add(node.id);
-                    // Traverse docked children via 'next' property
-                    if (node.next) {
-                      queue.push(node.next);
-                    }
-                  }
-                }
-
-                const labelIds = new Set(labels.map((l) => l.id));
-
-                // Delete selected nodes and labels
-                canvasState.selectedNodes.forEach((id) => {
-                  if (allNodeIds.has(id) && onNodeRemove !== undefined) {
-                    onNodeRemove(id);
-                  } else if (labelIds.has(id) && onLabelRemove !== undefined) {
-                    onLabelRemove(id);
-                  }
-                });
-              }
-            }
-          },
-          style: {
-            backgroundSize: (() => {
-              const minPixelSpacing = 10;
-              let gridSize = 20;
-              while (gridSize * canvasState.zoom < minPixelSpacing) {
-                gridSize *= 2;
-              }
-              const size = gridSize * canvasState.zoom;
-              return `${size}px ${size}px`;
-            })(),
-            backgroundPosition: (() => {
-              const minPixelSpacing = 10;
-              let gridSize = 20;
-              while (gridSize * canvasState.zoom < minPixelSpacing) {
-                gridSize *= 2;
-              }
-              const size = gridSize * canvasState.zoom;
-              // Subtract size/2 so the dot (centered in its tile) lands exactly
-              // on the world origin — stays fixed when gridSize doubles.
-              const x = canvasState.panOffset.x - size / 2;
-              const y = canvasState.panOffset.y - size / 2;
-              return `${x}px ${y}px`;
-            })(),
-            ...vnode.attrs.style,
-          },
-        },
-        [
-          (vnode.attrs.toolbarItems !== undefined ||
-            !vnode.attrs.hideControls) &&
-            m('.pf-nodegraph-controls', [
-              vnode.attrs.toolbarItems,
-              !vnode.attrs.hideControls &&
-                m(Button, {
-                  title: 'Auto layout',
-                  icon: 'account_tree',
-                  variant: ButtonVariant.Filled,
-                  onclick: () => autoLayoutApi?.(),
-                }),
-              m(
-                ButtonGroup,
-                m(Button, {
-                  title: 'Fit to screen',
-                  icon: 'center_focus_strong',
-                  variant: ButtonVariant.Filled,
-                  onclick: () => recenterApi?.(),
-                }),
-                m(Button, {
-                  title: 'Reset zoom to 100%',
-                  icon: 'view_real_size',
-                  variant: ButtonVariant.Filled,
-                  onclick: () => resetZoom?.(),
-                }),
-                m(Button, {
-                  title: 'Zoom in',
-                  icon: 'zoom_in',
-                  variant: ButtonVariant.Filled,
-                  onclick: () => zoomBy(0.2),
-                }),
-                m(Button, {
-                  title: 'Zoom out',
-                  icon: 'zoom_out',
-                  variant: ButtonVariant.Filled,
-                  onclick: () => zoomBy(-0.2),
-                }),
-              ),
-            ]),
-
-          // Container for nodes and SVG that gets transformed
-          m(
-            '.pf-canvas-content',
-            {
-              style: `transform: translate(${canvasState.panOffset.x}px, ${canvasState.panOffset.y}px) scale(${canvasState.zoom}); transform-origin: 0 0;`,
-            },
-            [
-              // SVG container for connections (rendered imperatively in oncreate/onupdate)
-              m('svg'),
-
-              // Selection rectangle overlay
-              canvasState.selectionRect &&
-                m('.pf-selection-rect', {
-                  style: {
-                    left: `${Math.min(canvasState.selectionRect.startX, canvasState.selectionRect.currentX)}px`,
-                    top: `${Math.min(canvasState.selectionRect.startY, canvasState.selectionRect.currentY)}px`,
-                    width: `${Math.abs(canvasState.selectionRect.currentX - canvasState.selectionRect.startX)}px`,
-                    height: `${Math.abs(canvasState.selectionRect.currentY - canvasState.selectionRect.startY)}px`,
-                  },
-                }),
-
-              // Render all nodes - wrap dock chains in flex container
-              nodes
-                .map((node: Node) => {
-                  const {id} = node;
-
-                  // Check if this is the root of a dock chain
-                  const chain = getChain(node);
-                  const isChainRoot = chain.length > 1;
-
-                  // Check if we have a temp position for this node (during drag)
-                  const tempPos = canvasState.tempNodePositions.get(id);
-                  const renderPos = tempPos || {x: node.x, y: node.y};
-
-                  // If this is a chain root, wrap all chain nodes in flex container
-                  // Always wrap in a chain root container for consistency
-
-                  if (isChainRoot) {
-                    return m(
-                      '.pf-node-wrapper',
-                      {
-                        key: `chain-${id}`,
-                        style: `left: ${renderPos.x}px; top: ${renderPos.y}px;`,
-                        className: classNames(
-                          canvasState.draggedNode === id &&
-                            'pf-node-wrapper--dragging',
-                        ),
-                      },
-                      chain.map((chainNode) => {
-                        const cIsDockedChild = 'x' in chainNode === false;
-                        const cHasDockedChild = chainNode.next !== undefined;
-                        const cIsDockTarget =
-                          canvasState.dockTarget === chainNode.id &&
-                          canvasState.isDockZone;
-
-                        return renderNode(chainNode, vnode, {
-                          isDockedChild: cIsDockedChild,
-                          hasDockedChild: cHasDockedChild,
-                          isDockTarget: cIsDockTarget,
-                          rootNode: node,
-                          multiselect,
-                          contextMenuOnHover,
-                        });
-                      }),
-                    );
-                  } else {
-                    // Render standalone node (not part of a chain)
-                    const isDockTarget =
-                      canvasState.dockTarget === id && canvasState.isDockZone;
-
-                    return m(
-                      '.pf-node-wrapper',
-                      {
-                        key: `chain-${id}`,
-                        style: `left: ${renderPos.x}px; top: ${renderPos.y}px;`,
-                        className: classNames(
-                          canvasState.draggedNode === id &&
-                            'pf-node-wrapper--dragging',
-                        ),
-                      },
-                      renderNode(node, vnode, {
-                        isDockedChild: false,
-                        hasDockedChild: false,
-                        isDockTarget,
-                        rootNode: undefined,
-                        multiselect,
-                        contextMenuOnHover,
-                      }),
-                    );
-                  }
-                })
-                .filter((vnode) => vnode !== null),
-
-              // Render all labels
-              (vnode.attrs.labels ?? []).map((label: Label) => {
-                return renderLabel(label, vnode);
-              }),
-            ],
-          ),
-        ],
-      );
-    },
-  };
-}
diff --git a/ui/src/widgets/nodegraph/index.ts b/ui/src/widgets/nodegraph/index.ts
new file mode 100644
index 0000000..9210a0c
--- /dev/null
+++ b/ui/src/widgets/nodegraph/index.ts
@@ -0,0 +1,16 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export * from './model';
+export * from './views/nodegraph';
diff --git a/ui/src/widgets/nodegraph/model.ts b/ui/src/widgets/nodegraph/model.ts
new file mode 100644
index 0000000..ec0bd0f
--- /dev/null
+++ b/ui/src/widgets/nodegraph/model.ts
@@ -0,0 +1,178 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {NodeGraphViewport} from './views/nodegraph';
+import {Point2D} from '../../base/geom';
+
+export interface NodeGraphConnection {
+  readonly fromPort: string;
+  readonly toPort: string;
+}
+
+export type Direction = 'north' | 'south' | 'east' | 'west';
+
+export interface NodeGraphPort {
+  // Unique identifier for this port.
+  readonly id: string;
+
+  // Label shown next to the port dot. Only displayed for east/west ports.
+  readonly label?: m.Children;
+
+  // Which edge of the node this port appears on.
+  readonly direction: Direction;
+
+  // Items shown in a context menu when the port is right-clicked or tapped.
+  readonly contextMenuItems?: m.Children;
+}
+
+// A node that is docked (stacked) below another node. Identical to
+// NodeGraphNode but without a position, since its parent determines placement.
+export type NodeGraphDockedNode = Omit<NodeGraphNode, 'pos'>;
+
+export interface NodeGraphNode {
+  // Unique identifier for this node.
+  readonly id: string;
+
+  // Position in canvas coordinates (independent of the current pan/zoom).
+  readonly pos: Point2D;
+
+  // Hue (0–360) used to tint the header and accent bar. The rendered color
+  // adapts to the current light/dark theme.
+  readonly hue: number;
+
+  // Renders a colored strip on the left edge of the card. Mutually exclusive
+  // with headerBar — they don't look good together.
+  readonly accentBar?: boolean;
+
+  // Renders a title bar at the top of the card with an optional icon. Mutually
+  // exclusive with accentBar — they don't look good together.
+  readonly headerBar?: {
+    readonly title: m.Children;
+    readonly icon?: string;
+  };
+
+  // Input ports shown on the node (used to receive connections).
+  readonly inputs?: ReadonlyArray<NodeGraphPort>;
+
+  // Output ports shown on the node (used to originate connections).
+  readonly outputs?: ReadonlyArray<NodeGraphPort>;
+
+  // Arbitrary content rendered in the card body.
+  readonly content?: m.Children;
+
+  // Next node stacked below this one. When set, the two nodes are rendered as
+  // a single visual unit and move together.
+  readonly next?: NodeGraphDockedNode;
+
+  // When true, this node can be dragged and docked below a node that has
+  // canDockBottom set.
+  readonly canDockTop?: boolean;
+
+  // When true, another node with canDockTop can be docked below this one.
+  readonly canDockBottom?: boolean;
+
+  // Items shown in a context menu on the node (e.g. via a triple-dot button).
+  readonly contextMenuItems?: m.Children;
+
+  // Extra CSS class(es) applied to the outermost node element.
+  readonly className?: string;
+}
+
+export interface NodeGraphAPI {
+  // Adjusts pan and zoom so all nodes fit within the visible viewport.
+  autofit(): void;
+
+  // Pans the canvas by (dx, dy) in viewport pixels. Positive values pan right
+  // and down.
+  pan(dx: number, dy: number): void;
+
+  // Zooms by adding deltaZoom to the current zoom level (e.g. +0.1 zooms in).
+  // centerX/centerY are the fixed point in viewport coordinates; defaults to
+  // the canvas center.
+  zoom(deltaZoom: number, centerX?: number, centerY?: number): void;
+
+  // Resets zoom to 1.0, keeping the current viewport center fixed.
+  resetZoom(): void;
+
+  // Finds a canvas position for a new node that doesn't overlap any existing
+  // node. Starts from the viewport center and spirals outward. The node
+  // description is used for context but the position is estimated since the
+  // node hasn't been rendered yet.
+  findPlacementForNode(node: Omit<NodeGraphNode, 'pos'>): Point2D;
+}
+
+export interface NodeGraphAttrs {
+  readonly className?: string;
+  readonly style?: Partial<CSSStyleDeclaration>;
+
+  // The nodes and connections to render.
+  readonly nodes: ReadonlyArray<NodeGraphNode>;
+  readonly connections: ReadonlyArray<NodeGraphConnection>;
+
+  // IDs of currently selected nodes. The graph highlights these but does not
+  // own the selection state — the parent is responsible for updating it.
+  readonly selectedNodeIds?: ReadonlySet<string>;
+
+  // Hide the built-in zoom/fit toolbar.
+  readonly hideControls?: boolean;
+
+  // Make the graph fill the height of its container.
+  readonly fillHeight?: boolean;
+
+  // Extra items rendered in the toolbar alongside the built-in controls.
+  readonly toolbarItems?: m.Children;
+
+  // Called once after the component mounts, with an API object for
+  // programmatic control (pan, zoom, placement, etc.).
+  readonly onReady?: (api: NodeGraphAPI) => void;
+
+  // Called when the user draws a wire between two ports.
+  readonly onConnect?: (connection: NodeGraphConnection) => void;
+
+  // Called when the user removes a connection. The index refers to the
+  // position of the connection in the `connections` array.
+  readonly onDisconnect?: (index: number) => void;
+
+  // Called when the user drops a node at a new position. The position is
+  // already snapped to the grid and adjusted to avoid overlaps.
+  readonly onNodeMove?: (nodeId: string, pos: Point2D) => void;
+
+  // Called when the user docks one node below another.
+  readonly onNodeDock?: (nodeId: string, targetId: string) => void;
+
+  // Called when the user triggers deletion of nodes (e.g. toolbar delete
+  // button). Receives the IDs of all nodes to remove.
+  readonly onNodeRemove?: (nodeIds: string[]) => void;
+
+  // Called when the selection is replaced entirely (e.g. click or box-select).
+  readonly onSelect?: (nodeIds: string[]) => void;
+
+  // Called when a single node is added to the existing selection (Ctrl+click).
+  readonly onSelectionAdd?: (nodeId: string) => void;
+
+  // Called when a single node is removed from the selection (Ctrl+click on a
+  // selected node).
+  readonly onSelectionRemove?: (nodeId: string) => void;
+
+  // Called when the selection is cleared (e.g. clicking the empty canvas).
+  readonly onSelectionClear?: () => void;
+
+  // Called whenever the viewport changes due to pan or zoom.
+  readonly onViewportMove?: (viewport: NodeGraphViewport) => void;
+
+  // Initial pan/zoom applied when the component first mounts. Defaults to
+  // offset (0, 0) and zoom 1.0.
+  readonly initialViewport?: NodeGraphViewport;
+}
diff --git a/ui/src/widgets/nodegraph/svg.ts b/ui/src/widgets/nodegraph/svg.ts
new file mode 100644
index 0000000..057777e
--- /dev/null
+++ b/ui/src/widgets/nodegraph/svg.ts
@@ -0,0 +1,151 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+
+const ARROWHEAD_LENGTH = 4;
+const SHORTEN_END = 16;
+
+export function arrowheadMarker(markerId: string): m.Vnode {
+  return m(
+    'marker',
+    {
+      id: markerId,
+      viewBox: `0 0 ${ARROWHEAD_LENGTH} 10`,
+      refX: '0',
+      refY: '5',
+      markerWidth: `${ARROWHEAD_LENGTH}`,
+      markerHeight: '10',
+      orient: 'auto',
+    },
+    m('polygon', {
+      points: `0 2.5, ${ARROWHEAD_LENGTH} 5, 0 7.5`,
+      fill: 'context-stroke',
+    }),
+  );
+}
+
+export type PortDirection = 'top' | 'bottom' | 'left' | 'right';
+
+export function connectionPath(
+  from: {x: number; y: number},
+  to: {x: number; y: number},
+  markerId: string,
+  fromDir: PortDirection = 'right',
+  toDir: PortDirection = 'left',
+  extraAttrs?: Record<string, unknown>,
+  onRemove?: () => void,
+): m.Vnode {
+  const d = createCurve(
+    from.x,
+    from.y,
+    to.x,
+    to.y,
+    fromDir,
+    toDir,
+    SHORTEN_END,
+  );
+
+  const visiblePath = m('path', {
+    d,
+    'class': 'pf-connection-path',
+    'stroke': 'var(--pf-color-primary)',
+    'stroke-width': 2,
+    'fill': 'none',
+    'stroke-linecap': 'round',
+    'marker-end': `url(#${markerId})`,
+    'style': {pointerEvents: 'none'},
+    ...extraAttrs,
+  });
+
+  if (onRemove === undefined) {
+    return visiblePath;
+  }
+
+  // Wrap in a group with a wider invisible hitbox for easier clicking.
+  return m('g', {'class': 'pf-connection-group'},
+    m('path', {
+      d,
+      'class': 'pf-connection-hitbox',
+      'style': {
+        stroke: 'transparent',
+        strokeWidth: 14,
+        fill: 'none',
+        pointerEvents: 'stroke',
+        cursor: 'pointer',
+      },
+      onpointerdown: (e: PointerEvent) => e.stopPropagation(),
+      onclick: (e: MouseEvent) => {
+        e.stopPropagation();
+        onRemove();
+      },
+    }),
+    visiblePath,
+  );
+}
+
+export function createCurve(
+  x1: number,
+  y1: number,
+  x2: number,
+  y2: number,
+  fromPortType?: 'top' | 'bottom' | 'left' | 'right',
+  toPortType?: 'top' | 'bottom' | 'left' | 'right',
+  shortenEnd = 0,
+): string {
+  const dx = x2 - x1;
+  const dy = y2 - y1;
+  const distance = Math.sqrt(dx * dx + dy * dy);
+
+  let cx1: number;
+  let cy1: number;
+  let cx2: number;
+  let cy2: number;
+
+  if (shortenEnd > 0) {
+    if (toPortType === 'bottom') {
+      y2 += shortenEnd;
+    } else if (toPortType === 'top') {
+      y2 -= shortenEnd;
+    } else if (toPortType === 'left') {
+      x2 -= shortenEnd;
+    } else if (toPortType === 'right') {
+      x2 += shortenEnd;
+    }
+  }
+
+  // For top/bottom ports, control points extend vertically.
+  // For left/right ports, control points extend horizontally.
+  if (fromPortType === 'bottom' || fromPortType === 'top') {
+    const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5);
+    cx1 = x1;
+    cy1 = fromPortType === 'bottom' ? y1 + verticalOffset : y1 - verticalOffset;
+  } else {
+    const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5);
+    cx1 = x1 + horizontalOffset;
+    cy1 = y1;
+  }
+
+  if (toPortType === 'bottom' || toPortType === 'top') {
+    const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5);
+    cx2 = x2;
+    cy2 = toPortType === 'bottom' ? y2 + verticalOffset : y2 - verticalOffset;
+  } else {
+    const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5);
+    cx2 = x2 - horizontalOffset;
+    cy2 = y2;
+  }
+
+  return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
+}
diff --git a/ui/src/widgets/nodegraph/views/node.ts b/ui/src/widgets/nodegraph/views/node.ts
new file mode 100644
index 0000000..2ee0cad
--- /dev/null
+++ b/ui/src/widgets/nodegraph/views/node.ts
@@ -0,0 +1,149 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {classNames} from '../../../base/classnames';
+import {Icon} from '../../icon';
+
+/**
+ * - Node
+ *   - Card
+ *     - Header
+ *     - Body
+ *     - Port 1
+ *     - Port 2
+ *     - Port N
+ *   - Node
+ *     - Card
+ *       - Header
+ *       - Body
+ *       - Port N
+ */
+
+export interface NGNodeAttrs extends m.Attributes {
+  readonly id: string;
+  readonly position?: {readonly x: number; readonly y: number};
+  // The next docked node in the chain (rendered below the body).
+  readonly nextNode?: m.Children;
+}
+
+export const NGNode: m.Component<NGNodeAttrs> = {
+  view({attrs, children}: m.Vnode<NGNodeAttrs>): m.Children {
+    const {id, position, nextNode, ...htmlAttrs} = attrs;
+
+    return m(
+      '.pf-ng__node',
+      {
+        ...htmlAttrs,
+        'data-node-id': id,
+        'style': {
+          ...(position
+            ? {
+                position: 'absolute',
+                left: `${position.x}px`,
+                top: `${position.y}px`,
+              }
+            : {}),
+          ...attrs.style,
+        },
+      },
+      children,
+      nextNode,
+    );
+  },
+};
+
+export interface NGCardAttrs extends m.Attributes {
+  readonly hue?: number;
+  readonly accent?: boolean;
+  readonly selected?: boolean;
+}
+
+// Wraps arbitrary content inside a node body with standard padding.
+export const NGCard: m.Component<NGCardAttrs> = {
+  view({attrs, children}: m.Vnode<NGCardAttrs>): m.Children {
+    const {accent, selected, className, ...htmlAttrs} = attrs;
+    return m(
+      '.pf-ng__card',
+      {
+        ...htmlAttrs,
+        style: {
+          '--pf-ng-hue': attrs.hue ?? 0,
+        },
+        // Styles actually apply to the card
+        className: classNames(
+          accent && 'pf-ng__card--accent',
+          selected && 'pf-ng__card--selected',
+          className,
+        ),
+      },
+      children,
+    );
+  },
+};
+
+export interface NGHeaderAttrs extends m.Attributes {
+  readonly title: m.Children;
+  readonly icon?: string;
+}
+
+export const NGCardHeader: m.Component<NGHeaderAttrs> = {
+  view({attrs}: m.Vnode<NGHeaderAttrs>): m.Children {
+    const {title, icon, ...htmlAttrs} = attrs;
+    return m('.pf-ng__card-header', htmlAttrs, [
+      icon !== undefined && m(Icon, {icon, className: 'pf-node-title-icon'}),
+      m('.pf-node-title', title),
+    ]);
+  },
+};
+
+export const NGCardBody: m.Component<m.Attributes> = {
+  view({attrs, children}: m.Vnode<m.Attributes>): m.Children {
+    return m('.pf-ng__card-body', attrs, children);
+  },
+};
+
+export interface NGPortAttrs extends m.Attributes {
+  // Identifier used as a Mithril key when rendering port lists.
+  readonly id: string;
+  readonly direction: 'north' | 'south' | 'east' | 'west';
+  readonly portType: 'input' | 'output';
+  readonly connected?: boolean;
+  // Label shown alongside the port dot (hidden on north/south ports).
+  readonly label?: m.Children;
+}
+
+export const NGPort: m.Component<NGPortAttrs> = {
+  view({attrs}: m.Vnode<NGPortAttrs>) {
+    // Destructure our own attrs; pass the rest (e.g. onpointerdown) to the dot.
+    const {direction, connected, label, portType, ...dotAttrs} = attrs;
+
+    const portDot = m('.pf-ng__port-dot', {
+      ...dotAttrs,
+      'data-port-id': attrs.id,
+      'className': classNames(
+        portType === 'input' ? 'pf-input' : 'pf-output',
+        `pf-port-${direction}`,
+        connected && 'pf-port--connected',
+      ),
+    });
+
+    // Left/right ports get a labelled row; top/bottom are bare dots.
+    if (direction === 'east' || direction === 'west') {
+      return m(`.pf-ng__port.pf-ng__port--${direction}`, [portDot, label]);
+    } else {
+      return portDot;
+    }
+  },
+};
diff --git a/ui/src/widgets/nodegraph/views/nodegraph.ts b/ui/src/widgets/nodegraph/views/nodegraph.ts
new file mode 100644
index 0000000..866e546
--- /dev/null
+++ b/ui/src/widgets/nodegraph/views/nodegraph.ts
@@ -0,0 +1,1238 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {assertIsInstance} from '../../../base/assert';
+import {captureDrag} from '../../../base/dom_utils';
+import {Point2D, Vector2D} from '../../../base/geom';
+import {MithrilEvent} from '../../../base/mithril_utils';
+import {shortUuid} from '../../../base/uuid';
+import {PopupMenu} from '../../../widgets/menu';
+import {PopupPosition} from '../../../widgets/popup';
+import {
+  NodeGraphAPI,
+  NodeGraphDockedNode,
+  NodeGraphNode,
+  NodeGraphAttrs,
+  NodeGraphPort,
+} from '../model';
+import type {PortDirection} from '../svg';
+import {arrowheadMarker, connectionPath} from '../svg';
+import {NGCard, NGCardBody, NGCardHeader, NGNode, NGPort} from './node';
+import {NGToolbar} from './toolbar';
+
+const WHEEL_ZOOM_SCALING_FACTOR = 0.006;
+const MIN_ZOOM = 0.01;
+const MAX_ZOOM = 3.0;
+const GRID_SIZE = 24;
+const EDGE_DRAG_THRESHOLD = 40; // viewport px from edge where panning begins
+const EDGE_DRAG_SPEED = 5; // viewport px per frame at the very edge
+
+function snapToGrid(p: Point2D): Vector2D {
+  return new Vector2D({
+    x: Math.round(p.x / GRID_SIZE) * GRID_SIZE,
+    y: Math.round(p.y / GRID_SIZE) * GRID_SIZE,
+  });
+}
+
+function portDirFromEl(el: Element): PortDirection {
+  if (el.classList.contains('pf-port-north')) return 'top';
+  if (el.classList.contains('pf-port-south')) return 'bottom';
+  if (el.classList.contains('pf-port-east')) return 'right';
+  if (el.classList.contains('pf-input')) return 'left';
+  return 'right';
+}
+
+function oppositeDir(dir: PortDirection): PortDirection {
+  switch (dir) {
+    case 'top':
+      return 'bottom';
+    case 'bottom':
+      return 'top';
+    case 'left':
+      return 'right';
+    case 'right':
+      return 'left';
+  }
+}
+
+function dotBackground(zoom: number, offset: {x: number; y: number}) {
+  let gridSize = GRID_SIZE;
+  while (gridSize * zoom < 10) gridSize *= 2;
+  const size = gridSize * zoom;
+  return {
+    size,
+    posX: offset.x * zoom - size / 2,
+    posY: offset.y * zoom - size / 2,
+  };
+}
+
+export interface NodeGraphViewport {
+  readonly offset: Point2D;
+  readonly zoom: number;
+}
+
+export class NodeGraph implements m.Component<NodeGraphAttrs> {
+  // Unique ID for this instance's SVG marker, to avoid conflicts when multiple
+  // NodeGraph instances exist in the document (e.g. different tabs).
+  private readonly markerId = `pf-ng-arrow-${shortUuid()}`;
+
+  // The current viewport transform. Updated during zoom and pan operations, and
+  // applied to the canvas via CSS transform.
+  private viewport: NodeGraphViewport = {offset: {x: 0, y: 0}, zoom: 1.0};
+
+  // Cached references to important immutable DOM elements. Set in oncreate,
+  // used in event handlers.
+  private rootEl!: HTMLElement;
+  private workspaceEl!: HTMLElement;
+  private svgEl!: SVGSVGElement;
+
+  // Non-null while the user is dragging a wire from an output port.
+  private wireDrag?: {
+    fromPortId: string;
+    toPoint: Point2D;
+    toDir: PortDirection;
+  };
+
+  // Port ID of the output port whose context menu is currently open.
+  private openPortMenuId?: string;
+
+  // Index into attrs.connections of a connection that has been "picked up" by
+  // dragging from its input port. Hidden during drag; disconnected on drop to
+  // a new input or to empty space, and restored if the drag is cancelled.
+  private pickedUpConnIdx?: number;
+
+  // Latest attrs, kept in sync so the API closures can read onViewportMove etc.
+  private latestAttrs!: NodeGraphAttrs;
+
+  private getNodePosition(nodeEl: HTMLElement) {
+    const rect = nodeEl.getBoundingClientRect();
+    const canvasRect = this.rootEl.getBoundingClientRect();
+    return this.getWorkspacePosition({
+      x: rect.left - canvasRect.left,
+      y: rect.top - canvasRect.top,
+    });
+  }
+
+  private getWorkspacePosition(pos: Point2D) {
+    const {offset, zoom} = this.viewport;
+    return new Vector2D({
+      x: pos.x / zoom + offset.x,
+      y: pos.y / zoom + offset.y,
+    });
+  }
+
+  private client2Workspace(client: Point2D) {
+    const canvasRect = this.rootEl.getBoundingClientRect();
+    return this.getWorkspacePosition({
+      x: client.x - canvasRect.left,
+      y: client.y - canvasRect.top,
+    });
+  }
+
+  private createGhostNode(nodeEl: HTMLElement) {
+    const ghost = nodeEl.cloneNode(true) as HTMLElement;
+    ghost.removeAttribute('data-node-id'); // avoid confusion with the real node
+    ghost.style.position = 'absolute';
+    ghost.style.zIndex = '-1'; // behind the node, but above the connections
+    ghost.style.pointerEvents = 'none';
+    ghost.style.filter = 'brightness(0) opacity(0.5)';
+    this.workspaceEl.appendChild(ghost);
+
+    const workspaceEl = this.workspaceEl; // capture for closure
+
+    return {
+      element: ghost,
+      moveTo(p: Point2D) {
+        ghost.style.left = `${p.x}px`;
+        ghost.style.top = `${p.y}px`;
+      },
+      dockToNode(nodeEl: HTMLElement) {
+        const card = assertIsInstance(
+          nodeEl.querySelector('.pf-ng__card'),
+          HTMLElement,
+        );
+        card.after(ghost);
+        ghost.style.removeProperty('left');
+        ghost.style.removeProperty('top');
+        ghost.style.removeProperty('position');
+      },
+      undock() {
+        ghost.style.position = 'absolute';
+        workspaceEl.appendChild(ghost);
+      },
+      [Symbol.dispose]() {
+        ghost.remove();
+      },
+    };
+  }
+
+  private moveNodeToWorkspace(nodeEl: HTMLElement) {
+    const previousStyle = nodeEl.getAttribute('style') ?? '';
+    const previousParent = nodeEl.parentElement;
+
+    this.workspaceEl.appendChild(nodeEl);
+    nodeEl.style.position = 'absolute';
+
+    return {
+      moveTo(p: Point2D) {
+        nodeEl.style.left = `${p.x}px`;
+        nodeEl.style.top = `${p.y}px`;
+      },
+      [Symbol.dispose]: () => {
+        // Move the element back to its original parent and reset styles
+        previousParent?.appendChild(nodeEl);
+        nodeEl.setAttribute('style', previousStyle);
+      },
+    };
+  }
+
+  view({attrs}: m.Vnode<NodeGraphAttrs>) {
+    const {
+      onViewportMove,
+      onNodeMove,
+      onNodeDock,
+      onSelect,
+      onSelectionAdd,
+      onSelectionRemove,
+      onSelectionClear,
+      onNodeRemove: onRemove,
+      onConnect,
+      nodes = [],
+      selectedNodeIds,
+      toolbarItems,
+      style,
+      className,
+    } = attrs;
+
+    const createNodeDragHandler = (
+      node: NodeGraphDockedNode,
+      parentId: string | undefined,
+    ) => {
+      return async (e: MithrilEvent<PointerEvent>) => {
+        if (e.button !== 0) return; // only left-click drags
+
+        // Let interactive elements (inputs, buttons, selects, etc.) handle
+        // their own events without triggering a node drag.
+        const target = e.target as HTMLElement;
+        if (target.closest('input, button, select, textarea, a')) return;
+
+        // Stop this pointer event from hitting the canvas
+        e.stopPropagation();
+
+        // Don't redraw just yet...
+        e.redraw = false;
+
+        // Secure the node element
+        const nodeEl = assertIsInstance(e.currentTarget, HTMLElement);
+
+        // Find the initial offset within the node in canvas space
+        const pMouse = {x: e.clientX, y: e.clientY};
+        const pMouseWs = this.client2Workspace(pMouse);
+
+        const nodeRect = nodeEl.getBoundingClientRect();
+        const pNode = {x: nodeRect.left, y: nodeRect.top};
+        const pNodeWs = this.client2Workspace(pNode);
+
+        const grabPoint = pMouseWs.sub(pNodeWs);
+
+        // Wait for this to turn into a proper drag
+        const drag = await captureDrag({el: this.rootEl, e, deadzone: 5});
+
+        if (drag) {
+          // Work out where in the workspace the node is right now
+          const startPosition = this.getNodePosition(nodeEl);
+
+          using tempNode = this.moveNodeToWorkspace(nodeEl);
+          tempNode.moveTo(startPosition);
+
+          using ghost = this.createGhostNode(nodeEl);
+          ghost.moveTo(startPosition);
+
+          let pNode = startPosition;
+          let dockTarget: string | undefined = undefined;
+
+          using edgePan = this.startEdgePanning((dx, dy) => {
+            pNode = pNode.add({x: dx, y: dy});
+            tempNode.moveTo(pNode);
+            this.updateConnections(attrs);
+          });
+
+          for await (const mv of drag) {
+            // Find the initial offset within the node in canvas space
+            const pMouseWs = this.client2Workspace(mv.client);
+            pNode = pMouseWs.sub(grabPoint);
+
+            edgePan.updatePointer(mv.client);
+            tempNode.moveTo(pNode);
+
+            dockTarget = node.canDockTop
+              ? this.findDockTarget(nodeEl)
+              : undefined;
+
+            if (dockTarget) {
+              const targetEl = this.getNodeElement(dockTarget);
+              ghost.dockToNode(targetEl);
+            } else {
+              ghost.undock();
+              const snapped = snapToGrid(pNode);
+              ghost.moveTo(
+                this.findNonCollidingPosition(nodeEl, snapped, node.id),
+              );
+            }
+            this.updateConnections(attrs);
+          }
+
+          if (dockTarget) {
+            if (dockTarget !== parentId) {
+              onNodeDock?.(node.id, dockTarget);
+            }
+          } else {
+            const snapped = snapToGrid(pNode);
+            const placed = this.findNonCollidingPosition(
+              nodeEl,
+              snapped,
+              node.id,
+            );
+            onNodeMove?.(node.id, placed);
+          }
+
+          m.redraw();
+        } else {
+          // Failed drag - treat as a click and select the node
+          if (e.ctrlKey || e.metaKey) {
+            if (selectedNodeIds?.has(node.id)) {
+              onSelectionRemove?.(node.id);
+            } else {
+              onSelectionAdd?.(node.id);
+            }
+          } else {
+            // No key held - just select this node and deselect everything else
+            onSelect?.([node.id]);
+          }
+          m.redraw();
+        }
+      };
+    };
+
+    const createOutputPortDragHandler = (
+      node: NodeGraphDockedNode,
+      output: NodeGraphPort,
+    ) => {
+      return async (e: PointerEvent) => {
+        if (e.button !== 0) return; // only left-click drags
+
+        e.stopPropagation();
+        const drag = await captureDrag({
+          el: e.currentTarget as HTMLElement,
+          e,
+          deadzone: 5,
+        });
+
+        if (!drag) {
+          // Click (no drag): open context menu if available
+          if (output.contextMenuItems != null) {
+            this.openPortMenuId = output.id;
+            m.redraw();
+          }
+          return;
+        }
+
+        let clientX = e.clientX;
+        let clientY = e.clientY;
+
+        const toCanvasPoint = (): Point2D => {
+          const {zoom, offset} = this.viewport;
+          const rect = this.rootEl.getBoundingClientRect();
+          return {
+            x: (clientX - rect.left) / zoom + offset.x,
+            y: (clientY - rect.top) / zoom + offset.y,
+          };
+        };
+
+        const nodeDirToPortDir: Record<string, PortDirection> = {
+          north: 'top',
+          south: 'bottom',
+          east: 'right',
+          west: 'left',
+        };
+        const freeToDir = oppositeDir(
+          nodeDirToPortDir[output.direction] ?? 'right',
+        );
+        const makeWireDrag = (snap: typeof snapTarget) => ({
+          fromPortId: output.id,
+          toPoint: snap ? this.portCenterToCanvas(snap.el) : toCanvasPoint(),
+          toDir: snap ? portDirFromEl(snap.el) : freeToDir,
+        });
+
+        this.wireDrag = makeWireDrag(undefined);
+        this.rootEl.classList.add('pf-ng--wire-dragging');
+        this.updateConnections(attrs);
+
+        let snapTarget: {el: HTMLElement; portId: string} | undefined;
+
+        using edgePan = this.startEdgePanning(() => {
+          this.wireDrag = makeWireDrag(snapTarget);
+          this.updateConnections(attrs);
+        });
+
+        const processMove = (client: {x: number; y: number}) => {
+          clientX = client.x;
+          clientY = client.y;
+          edgePan.updatePointer(client);
+          const prevSnap = snapTarget;
+          snapTarget = this.findWireSnapTarget(node.id, clientX, clientY);
+          if (prevSnap?.el !== snapTarget?.el) {
+            prevSnap?.el.classList.remove('pf-wire-snap');
+            snapTarget?.el.classList.add('pf-wire-snap');
+          }
+          this.wireDrag = makeWireDrag(snapTarget);
+          this.updateConnections(attrs);
+        };
+
+        for await (const mv of drag) {
+          processMove(mv.client);
+        }
+        snapTarget?.el.classList.remove('pf-wire-snap');
+        this.rootEl.classList.remove('pf-ng--wire-dragging');
+
+        const connectTo = (toPort: string) => {
+          onConnect?.({fromPort: output.id, toPort});
+        };
+
+        if (snapTarget !== undefined) {
+          connectTo(snapTarget.portId);
+        } else {
+          // Fallback: check if the pointer is directly over an input port.
+          const target = document.elementFromPoint(clientX, clientY);
+          const inputPortEl =
+            target?.closest('.pf-ng__port-dot.pf-input') ?? null;
+          if (inputPortEl) {
+            const nodeEl = inputPortEl.closest(
+              '[data-node-id]',
+            ) as HTMLElement | null;
+            const toNodeId = nodeEl?.getAttribute('data-node-id');
+            const portId = inputPortEl.getAttribute('data-port-id');
+            if (portId && toNodeId && toNodeId !== node.id) {
+              connectTo(portId);
+            }
+          }
+        }
+
+        this.wireDrag = undefined;
+        this.updateConnections(attrs);
+        m.redraw();
+      };
+    };
+
+    const createInputPortDragHandler = (
+      node: NodeGraphDockedNode,
+      input: NodeGraphPort,
+    ) => {
+      return async (e: PointerEvent) => {
+        if (e.button !== 0) return; // only left-click drags
+        e.stopPropagation();
+
+        const connIdx =
+          attrs.connections?.findIndex((c) => c.toPort === input.id) ?? -1;
+
+        const drag = await captureDrag({
+          el: e.currentTarget as HTMLElement,
+          e,
+          deadzone: 5,
+        });
+
+        if (!drag) {
+          // Click: open context menu if available.
+          if (input.contextMenuItems != null) {
+            this.openPortMenuId = input.id;
+            m.redraw();
+          }
+          return;
+        }
+
+        // Only connected input ports can be picked up as a wire.
+        if (connIdx < 0) return;
+
+        const existingConn = attrs.connections![connIdx];
+        const fromNodeId =
+          this.findNodeIdForPort(existingConn.fromPort) ?? node.id;
+
+        // Find the from-port element to determine its direction for the curve.
+        const fromPortEl = this.rootEl.querySelector(
+          `.pf-ng__port-dot[data-port-id="${existingConn.fromPort}"]`,
+        ) as HTMLElement | undefined;
+        const fromDir: PortDirection = fromPortEl
+          ? portDirFromEl(fromPortEl)
+          : 'right';
+        const freeToDir = oppositeDir(fromDir);
+
+        let clientX = e.clientX;
+        let clientY = e.clientY;
+
+        const toCanvasPoint = (): Point2D => {
+          const {zoom, offset} = this.viewport;
+          const rect = this.rootEl.getBoundingClientRect();
+          return {
+            x: (clientX - rect.left) / zoom + offset.x,
+            y: (clientY - rect.top) / zoom + offset.y,
+          };
+        };
+
+        const makeWireDrag = (snap: typeof snapTarget) => ({
+          fromPortId: existingConn.fromPort,
+          toPoint: snap ? this.portCenterToCanvas(snap.el) : toCanvasPoint(),
+          toDir: snap ? portDirFromEl(snap.el) : freeToDir,
+        });
+
+        this.pickedUpConnIdx = connIdx;
+        this.wireDrag = makeWireDrag(undefined);
+        this.rootEl.classList.add('pf-ng--wire-dragging');
+        this.updateConnections(attrs);
+
+        let snapTarget: {el: HTMLElement; portId: string} | undefined;
+
+        using edgePan = this.startEdgePanning(() => {
+          this.wireDrag = makeWireDrag(snapTarget);
+          this.updateConnections(attrs);
+        });
+
+        const processMove = (client: {x: number; y: number}) => {
+          clientX = client.x;
+          clientY = client.y;
+          edgePan.updatePointer(client);
+          const prevSnap = snapTarget;
+          snapTarget = this.findWireSnapTarget(fromNodeId, clientX, clientY);
+          if (prevSnap?.el !== snapTarget?.el) {
+            prevSnap?.el.classList.remove('pf-wire-snap');
+            snapTarget?.el.classList.add('pf-wire-snap');
+          }
+          this.wireDrag = makeWireDrag(snapTarget);
+          this.updateConnections(attrs);
+        };
+
+        for await (const mv of drag) {
+          processMove(mv.client);
+        }
+        snapTarget?.el.classList.remove('pf-wire-snap');
+        this.rootEl.classList.remove('pf-ng--wire-dragging');
+
+        const reconnect = (toPort: string) => {
+          attrs.onDisconnect?.(connIdx);
+          onConnect?.({fromPort: existingConn.fromPort, toPort});
+        };
+
+        if (snapTarget !== undefined) {
+          reconnect(snapTarget.portId);
+        } else {
+          // Fallback: check if pointer is directly over an input port.
+          const target = document.elementFromPoint(clientX, clientY);
+          const inputPortEl =
+            target?.closest('.pf-ng__port-dot.pf-input') ?? null;
+          if (inputPortEl) {
+            const nodeEl = inputPortEl.closest(
+              '[data-node-id]',
+            ) as HTMLElement | null;
+            const toNodeId = nodeEl?.getAttribute('data-node-id');
+            const portId = inputPortEl.getAttribute('data-port-id');
+            if (portId && toNodeId && toNodeId !== fromNodeId) {
+              reconnect(portId);
+            } else {
+              // Dropped on invalid target — disconnect.
+              attrs.onDisconnect?.(connIdx);
+            }
+          } else {
+            // Dropped on empty canvas — disconnect.
+            attrs.onDisconnect?.(connIdx);
+          }
+        }
+
+        this.pickedUpConnIdx = undefined;
+        this.wireDrag = undefined;
+        this.updateConnections(attrs);
+        m.redraw();
+      };
+    };
+
+    // parentId is set for docked nodes; absent for root nodes.
+    const renderNode = (
+      node: NodeGraphDockedNode,
+      parentId?: string,
+    ): m.Children => {
+      const isDocked = parentId !== undefined;
+      const rootNode = !isDocked ? (node as NodeGraphNode) : undefined;
+
+      return m(
+        NGNode,
+        {
+          key: rootNode ? node.id : undefined,
+          id: node.id,
+          position: rootNode?.pos,
+          // Recursively render the whole tree
+          nextNode: node.next && renderNode(node.next, node.id),
+          onpointerdown: createNodeDragHandler(node, parentId),
+        },
+        m(
+          NGCard,
+          {
+            hue: node.hue,
+            accent: node.accentBar,
+            selected: selectedNodeIds?.has(node.id),
+            className: node.className,
+          },
+          [
+            node.headerBar &&
+              m(NGCardHeader, {
+                title: node.headerBar.title,
+                icon: node.headerBar.icon,
+              }),
+            node.inputs?.map((input) =>
+              m(NGPort, {
+                id: input.id,
+                direction: input.direction,
+                portType: 'input',
+                label: input.label,
+                connected:
+                  attrs.connections?.some((c) => c.toPort === input.id) ??
+                  false,
+                onpointerdown: createInputPortDragHandler(node, input),
+              }),
+            ),
+            m(NGCardBody, node.content),
+            node.outputs?.map((output) => {
+              const portEl = m(NGPort, {
+                id: output.id,
+                direction: output.direction,
+                portType: 'output',
+                label: output.label,
+                connected:
+                  attrs.connections?.some((c) => c.fromPort === output.id) ??
+                  false,
+                onpointerdown: createOutputPortDragHandler(node, output),
+              });
+              if (output.contextMenuItems == null) return portEl;
+              return m(
+                PopupMenu,
+                {
+                  trigger: portEl,
+                  position: PopupPosition.Bottom,
+                  isOpen: this.openPortMenuId === output.id,
+                  onChange: (open) => {
+                    if (!open) {
+                      this.openPortMenuId = undefined;
+                    }
+                  },
+                },
+                output.contextMenuItems,
+              );
+            }),
+          ],
+        ),
+      );
+    };
+
+    return m(
+      '.pf-ng',
+      {
+        style,
+        className,
+        onwheel: (e: MithrilEvent<WheelEvent>) => {
+          e.preventDefault();
+          if (e.ctrlKey || e.metaKey) {
+            const newZoom =
+              this.viewport.zoom *
+              Math.exp(-e.deltaY * WHEEL_ZOOM_SCALING_FACTOR);
+            this.zoomViewport(newZoom, {x: e.clientX, y: e.clientY});
+          } else {
+            this.panViewport(e.deltaX, e.deltaY);
+          }
+          onViewportMove?.(this.viewport);
+        },
+        oncontextmenu: (e: MouseEvent) => {
+          e.preventDefault();
+        },
+        onpointerdown: async (e: MithrilEvent<PointerEvent>) => {
+          e.redraw = false;
+          const nodegraph = this.rootEl;
+          const isBoxSelect = e.shiftKey;
+          const drag = await captureDrag({
+            el: nodegraph,
+            e,
+            deadzone: 2,
+          });
+          if (drag) {
+            if (isBoxSelect) {
+              const boxEl = document.createElement('div');
+              boxEl.style.cssText =
+                'position:absolute;pointer-events:none;' +
+                'border:1px dashed var(--pf-color-primary);' +
+                'background:color-mix(in srgb,var(--pf-color-primary) 10%,transparent);' +
+                'z-index:10;';
+              this.rootEl.appendChild(boxEl);
+
+              const updateBox = (currentClient: {x: number; y: number}) => {
+                const ngRect = this.rootEl.getBoundingClientRect();
+                const x1 = Math.min(e.clientX, currentClient.x) - ngRect.left;
+                const y1 = Math.min(e.clientY, currentClient.y) - ngRect.top;
+                const x2 = Math.max(e.clientX, currentClient.x) - ngRect.left;
+                const y2 = Math.max(e.clientY, currentClient.y) - ngRect.top;
+                Object.assign(boxEl.style, {
+                  left: `${x1}px`,
+                  top: `${y1}px`,
+                  width: `${x2 - x1}px`,
+                  height: `${y2 - y1}px`,
+                });
+              };
+
+              let currentClient = {x: e.clientX, y: e.clientY};
+              for await (const mv of drag) {
+                currentClient = {x: mv.client.x, y: mv.client.y};
+                updateBox(currentClient);
+              }
+
+              boxEl.remove();
+
+              const boxLeft = Math.min(e.clientX, currentClient.x);
+              const boxTop = Math.min(e.clientY, currentClient.y);
+              const boxRight = Math.max(e.clientX, currentClient.x);
+              const boxBottom = Math.max(e.clientY, currentClient.y);
+              const ids: string[] = [];
+              for (const nodeEl of this.rootEl.querySelectorAll(
+                '[data-node-id]',
+              )) {
+                const r = nodeEl.getBoundingClientRect();
+                if (
+                  r.left < boxRight &&
+                  r.right > boxLeft &&
+                  r.top < boxBottom &&
+                  r.bottom > boxTop
+                ) {
+                  ids.push(nodeEl.getAttribute('data-node-id')!);
+                }
+              }
+              if (ids.length > 0) {
+                onSelect?.(ids);
+              }
+            } else {
+              for await (const mv of drag) {
+                this.panViewport(-mv.delta.x, -mv.delta.y);
+              }
+              onViewportMove?.(this.viewport);
+            }
+          } else {
+            onSelectionClear?.();
+          }
+          m.redraw();
+        },
+      },
+      m(
+        '.pf-ng__workspace',
+        nodes.map((n) => renderNode(n)),
+        m('svg.pf-ng__connections', {
+          style: {
+            position: 'absolute',
+            left: '0',
+            top: '0',
+            overflow: 'visible',
+            pointerEvents: 'none',
+          },
+        }),
+      ),
+      m(NGToolbar, {
+        zoom: this.viewport.zoom,
+        onZoom: (level) => {
+          this.zoomViewport(level);
+          onViewportMove?.(this.viewport);
+        },
+        onFit: () => {
+          this.autofit();
+          onViewportMove?.(this.viewport);
+        },
+        hasSelection: (selectedNodeIds?.size ?? 0) > 0,
+        onDeleteSelected: () =>
+          onRemove?.(selectedNodeIds ? [...selectedNodeIds] : []),
+        extraItems: toolbarItems,
+      }),
+      m('.pf-ng__trashcan'),
+    );
+  }
+
+  oncreate({dom, attrs}: m.VnodeDOM<NodeGraphAttrs>) {
+    this.rootEl = assertIsInstance(dom, HTMLElement);
+    this.workspaceEl = assertIsInstance(
+      dom.querySelector('.pf-ng__workspace'),
+      HTMLElement,
+    );
+    this.svgEl = assertIsInstance(
+      dom.querySelector('.pf-ng__connections'),
+      SVGSVGElement,
+    );
+    const {initialViewport = {offset: {x: 0, y: 0}, zoom: 1.0}} = attrs;
+    this.viewport = {
+      zoom: initialViewport.zoom,
+      offset: {...initialViewport.offset},
+    };
+    this.latestAttrs = attrs;
+    this.updateViewport();
+    this.updateConnections(attrs);
+    attrs.onReady?.(this.buildAPI());
+  }
+
+  onupdate({attrs}: m.VnodeDOM<NodeGraphAttrs>) {
+    this.latestAttrs = attrs;
+    this.updateConnections(attrs);
+  }
+
+  private getNodeElement(nodeId: string) {
+    return assertIsInstance(
+      this.rootEl.querySelector(`[data-node-id="${nodeId}"]`),
+      HTMLElement,
+    );
+  }
+
+  // Returns the ID of the root node whose chain bottom is close enough to
+  // snap-dock the dropped node onto. The dragged node must have canDockTop and
+  // the candidate must have canDockBottom. Position comparison is done in
+  // viewport space using the rendered wrapper's bounding rect.
+  // Returns the ID of a node whose bottom edge is close to the top of the
+  // dragged node element, with horizontally aligned centers.
+  private findDockTarget(draggedNodeEl: HTMLElement): string | undefined {
+    // Build the set of node IDs that allow docking below them.
+    const dockableIds = new Set<string>();
+    const collect = (node: NodeGraphDockedNode) => {
+      if (node.canDockBottom) dockableIds.add(node.id);
+      if (node.next) collect(node.next);
+    };
+    for (const node of this.latestAttrs.nodes ?? []) collect(node);
+
+    const THRESHOLD = 30; // viewport px
+    const draggedRect = draggedNodeEl.getBoundingClientRect();
+    const draggedCenterX = (draggedRect.left + draggedRect.right) / 2;
+    for (const el of this.rootEl.querySelectorAll('[data-node-id]')) {
+      const id = el.getAttribute('data-node-id')!;
+      if (!dockableIds.has(id)) continue;
+      // Use the node-content bottom so nested docked children don't inflate the rect.
+      const body = el.querySelector(
+        ':scope > .pf-ng__card',
+      ) as HTMLElement | null;
+      const rect = (body ?? (el as HTMLElement)).getBoundingClientRect();
+      const candidateCenterX = (rect.left + rect.right) / 2;
+      if (
+        Math.abs(draggedRect.top - rect.bottom) < THRESHOLD &&
+        Math.abs(draggedCenterX - candidateCenterX) < THRESHOLD
+      ) {
+        return id;
+      }
+    }
+    return undefined;
+  }
+
+  // Returns the input port element + connection target closest to (clientX,
+  // clientY), or undefined if nothing is within the snap threshold.
+  private findWireSnapTarget(
+    fromNodeId: string,
+    clientX: number,
+    clientY: number,
+  ): {el: HTMLElement; portId: string} | undefined {
+    const THRESHOLD = 40; // viewport px
+    let best: {el: HTMLElement; portId: string; dist: number} | undefined;
+
+    for (const el of this.rootEl.querySelectorAll(
+      '.pf-ng__port-dot.pf-input',
+    )) {
+      if (el.closest('.pf-hidden')) continue;
+      const nodeEl = el.closest('[data-node-id]') as HTMLElement | null;
+      const toNodeId = nodeEl?.getAttribute('data-node-id');
+      if (!toNodeId || toNodeId === fromNodeId) continue;
+      const portId = el.getAttribute('data-port-id');
+      if (!portId) continue;
+      const rect = el.getBoundingClientRect();
+      const dist = Math.hypot(
+        clientX - (rect.left + rect.width / 2),
+        clientY - (rect.top + rect.height / 2),
+      );
+      if (dist < THRESHOLD && (!best || dist < best.dist)) {
+        best = {el: el as HTMLElement, portId, dist};
+      }
+    }
+
+    return best ? {el: best.el, portId: best.portId} : undefined;
+  }
+
+  // Searches the full node tree (including docked children) to find which node
+  // owns the given port, returning that node's ID.
+  private findNodeIdForPort(portId: string): string | undefined {
+    const search = (node: NodeGraphDockedNode): string | undefined => {
+      if (node.outputs?.some((p) => p.id === portId)) return node.id;
+      if (node.inputs?.some((p) => p.id === portId)) return node.id;
+      if (node.next) return search(node.next);
+      return undefined;
+    };
+    for (const n of this.latestAttrs.nodes ?? []) {
+      const found = search(n);
+      if (found) return found;
+    }
+    return undefined;
+  }
+
+  // Converts an input port element's center to canvas coordinates.
+  private portCenterToCanvas(portEl: HTMLElement): Point2D {
+    const rect = portEl.getBoundingClientRect();
+    const ngRect = this.rootEl.getBoundingClientRect();
+    const {zoom, offset} = this.viewport;
+    return {
+      x: (rect.left + rect.width / 2 - ngRect.left) / zoom + offset.x,
+      y: (rect.top + rect.height / 2 - ngRect.top) / zoom + offset.y,
+    };
+  }
+
+  private updateViewport() {
+    const {offset, zoom} = this.viewport;
+    const canvas = this.rootEl;
+    this.workspaceEl.style.transform = `scale(${zoom}) translate(${-offset.x}px, ${-offset.y}px)`;
+    const {size, posX, posY} = dotBackground(zoom, offset);
+    canvas.style.setProperty('--bg-size', `${size}px`);
+    canvas.style.setProperty('--bg-pos-x', `${-posX}px`);
+    canvas.style.setProperty('--bg-pos-y', `${-posY}px`);
+  }
+
+  private zoomViewport(zoom: number, center?: Point2D) {
+    const canvas = this.rootEl;
+    const rect = canvas.getBoundingClientRect();
+    if (!center) {
+      center = {x: rect.left + rect.width / 2, y: rect.top + rect.height / 2};
+    }
+    const {offset, zoom: currentZoom} = this.viewport;
+    const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
+    const mouseX = center.x - rect.left;
+    const mouseY = center.y - rect.top;
+    const canvasX = mouseX / currentZoom + offset.x;
+    const canvasY = mouseY / currentZoom + offset.y;
+    this.viewport = {
+      zoom: newZoom,
+      offset: {
+        x: canvasX - mouseX / newZoom,
+        y: canvasY - mouseY / newZoom,
+      },
+    };
+    this.updateViewport();
+  }
+
+  private panViewport(dx: number, dy: number) {
+    const {offset, zoom} = this.viewport;
+    this.viewport = {
+      ...this.viewport,
+      offset: {x: offset.x + dx / zoom, y: offset.y + dy / zoom},
+    };
+    this.updateViewport();
+  }
+
+  // Starts a RAF loop that pans the viewport when the pointer is near an edge.
+  // `getClientPos` returns the current pointer position in client coordinates.
+  // `onPan` (optional) is called with canvas-space deltas so the caller can
+  // update any accumulated canvas-space state (e.g. node position).
+  // Returns a stop function; call it when the drag ends.
+  private startEdgePanning(
+    onPan?: (canvasDx: number, canvasDy: number) => void,
+  ) {
+    const edgeForce = (v: number, lo: number, hi: number): number => {
+      if (v < lo + EDGE_DRAG_THRESHOLD)
+        return (
+          (-(lo + EDGE_DRAG_THRESHOLD - v) / EDGE_DRAG_THRESHOLD) *
+          EDGE_DRAG_SPEED
+        );
+      if (v > hi - EDGE_DRAG_THRESHOLD)
+        return (
+          ((v - (hi - EDGE_DRAG_THRESHOLD)) / EDGE_DRAG_THRESHOLD) *
+          EDGE_DRAG_SPEED
+        );
+      return 0;
+    };
+
+    let rafId: number | undefined;
+    let latestPointerPos: Point2D | undefined;
+
+    const frame = () => {
+      const {x: cx, y: cy} = latestPointerPos!;
+      const rect = this.rootEl.getBoundingClientRect();
+      const vpDx = edgeForce(cx, rect.left, rect.right);
+      const vpDy = edgeForce(cy, rect.top, rect.bottom);
+      if (vpDx !== 0 || vpDy !== 0) {
+        const {zoom} = this.viewport;
+        this.panViewport(vpDx, vpDy);
+        onPan?.(vpDx / zoom, vpDy / zoom);
+      }
+      rafId = requestAnimationFrame(frame);
+    };
+
+    return {
+      updatePointer: (pos: Point2D) => {
+        latestPointerPos = pos;
+        if (rafId === undefined) rafId = requestAnimationFrame(frame);
+      },
+      [Symbol.dispose]: () => {
+        if (rafId !== undefined) cancelAnimationFrame(rafId);
+      },
+    };
+  }
+
+  private updateConnections(attrs: NodeGraphAttrs) {
+    const {connections = []} = attrs;
+    const ngRect = this.rootEl.getBoundingClientRect();
+    const {zoom, offset} = this.viewport;
+
+    const getPortInfo = (portId: string) => {
+      const candidates = this.rootEl.querySelectorAll(
+        `.pf-ng__port-dot[data-port-id="${portId}"]`,
+      );
+      const el = Array.from(candidates).find(
+        (e) => e.closest('.pf-hidden') === null,
+      ) as HTMLElement | undefined;
+      if (!el) return undefined;
+      const rect = el.getBoundingClientRect();
+
+      const dir = portDirFromEl(el);
+      const cx = (rect.left + rect.width / 2 - ngRect.left) / zoom + offset.x;
+      const cy = (rect.top + rect.height / 2 - ngRect.top) / zoom + offset.y;
+      return {x: cx, y: cy, dir};
+    };
+
+    const paths = connections.flatMap((conn, i) => {
+      // Hide a connection that's been picked up by the active wire drag.
+      if (i === this.pickedUpConnIdx) return [];
+      const fromCenter = getPortInfo(conn.fromPort);
+      const to = getPortInfo(conn.toPort);
+      if (!fromCenter || !to) return [];
+      // Offset the start point to the outer edge of the output port dot.
+      const portRadius = 8 / zoom;
+      const from = {
+        x:
+          fromCenter.dir === 'right'
+            ? fromCenter.x + portRadius
+            : fromCenter.dir === 'left'
+              ? fromCenter.x - portRadius
+              : fromCenter.x,
+        y:
+          fromCenter.dir === 'bottom'
+            ? fromCenter.y + portRadius
+            : fromCenter.dir === 'top'
+              ? fromCenter.y - portRadius
+              : fromCenter.y,
+        dir: fromCenter.dir,
+      };
+      const onRemove = () => this.latestAttrs.onDisconnect?.(i);
+      return [
+        connectionPath(
+          from,
+          to,
+          this.markerId,
+          from.dir,
+          to.dir,
+          undefined,
+          onRemove,
+        ),
+      ];
+    });
+
+    if (this.wireDrag) {
+      const {fromPortId, toPoint, toDir} = this.wireDrag;
+      const from = getPortInfo(fromPortId);
+      if (from) {
+        paths.push(
+          connectionPath(from, toPoint, this.markerId, from.dir, toDir, {
+            'stroke-dasharray': '6 3',
+          }),
+        );
+      }
+    }
+
+    m.render(this.svgEl, [m('defs', arrowheadMarker(this.markerId)), ...paths]);
+  }
+
+  private buildAPI(): NodeGraphAPI {
+    return {
+      autofit: () => {
+        this.autofit();
+        m.redraw();
+      },
+      pan: (dx, dy) => {
+        this.panViewport(dx, dy);
+        this.latestAttrs.onViewportMove?.(this.viewport);
+      },
+      zoom: (deltaZoom, centerX, centerY) => {
+        const center =
+          centerX !== undefined && centerY !== undefined
+            ? {x: centerX, y: centerY}
+            : undefined;
+        this.zoomViewport(this.viewport.zoom + deltaZoom, center);
+        this.latestAttrs.onViewportMove?.(this.viewport);
+      },
+      resetZoom: () => {
+        this.zoomViewport(1.0);
+        this.latestAttrs.onViewportMove?.(this.viewport);
+      },
+      findPlacementForNode: (node) => {
+        return this.findPlacementPosition(node);
+      },
+    };
+  }
+
+  // Spiral search: starting from `origin` (already snapped to grid), walks
+  // outward ring by ring and returns the first position where a box of size
+  // (nodeW × nodeH) doesn't overlap any obstacle. Falls back to origin.
+  private spiralSearch(
+    nodeW: number,
+    nodeH: number,
+    origin: Point2D,
+    obstacles: ReadonlyArray<{x: number; y: number; w: number; h: number}>,
+  ): Point2D {
+    const collides = (x: number, y: number): boolean =>
+      obstacles.some(
+        (o) =>
+          x < o.x + o.w && x + nodeW > o.x && y < o.y + o.h && y + nodeH > o.y,
+      );
+
+    const {x: ox, y: oy} = origin;
+    if (!collides(ox, oy)) return origin;
+
+    for (let r = 1; r <= 50; r++) {
+      const d = r * GRID_SIZE;
+      for (let i = -r; i <= r; i++) {
+        const dx = i * GRID_SIZE;
+        if (!collides(ox + dx, oy - d)) return {x: ox + dx, y: oy - d};
+        if (!collides(ox + dx, oy + d)) return {x: ox + dx, y: oy + d};
+      }
+      for (let j = -r + 1; j <= r - 1; j++) {
+        const dy = j * GRID_SIZE;
+        if (!collides(ox - d, oy + dy)) return {x: ox - d, y: oy + dy};
+        if (!collides(ox + d, oy + dy)) return {x: ox + d, y: oy + dy};
+      }
+    }
+
+    return origin; // fallback
+  }
+
+  // Finds a placement position for a newly added node. Starts from the
+  // viewport center and spirals outward until a non-colliding spot is found.
+  // Uses an estimated node size since the node hasn't been rendered yet.
+  private findPlacementPosition(_node: Omit<NodeGraphNode, 'pos'>): Point2D {
+    const ESTIMATED_W = 200;
+    const ESTIMATED_H = 150;
+
+    const obstacles: Array<{x: number; y: number; w: number; h: number}> = [];
+    for (const el of this.workspaceEl.querySelectorAll(
+      ':scope > [data-node-id]',
+    )) {
+      const nodeEl = el as HTMLElement;
+      obstacles.push({
+        x: parseFloat(nodeEl.style.left) || 0,
+        y: parseFloat(nodeEl.style.top) || 0,
+        w: nodeEl.offsetWidth,
+        h: nodeEl.offsetHeight,
+      });
+    }
+
+    const {offset, zoom} = this.viewport;
+    const rect = this.rootEl.getBoundingClientRect();
+    const origin = snapToGrid({
+      x: offset.x + rect.width / (2 * zoom) - ESTIMATED_W / 2,
+      y: offset.y + rect.height / (2 * zoom) - ESTIMATED_H / 2,
+    });
+
+    return this.spiralSearch(ESTIMATED_W, ESTIMATED_H, origin, obstacles);
+  }
+
+  // Finds a non-colliding canvas position for a dropped node using a spiral
+  // search outward from the desired drop position. Returns the first grid-
+  // aligned position that doesn't overlap any other root node.
+  private findNonCollidingPosition(
+    draggedNodeEl: HTMLElement,
+    desiredPos: Vector2D,
+    draggedNodeId: string,
+  ): Vector2D {
+    const nodeW = draggedNodeEl.offsetWidth;
+    const nodeH = draggedNodeEl.offsetHeight;
+
+    const obstacles: Array<{x: number; y: number; w: number; h: number}> = [];
+    for (const el of this.workspaceEl.querySelectorAll(
+      ':scope > [data-node-id]',
+    )) {
+      if (el.getAttribute('data-node-id') === draggedNodeId) continue;
+      const nodeEl = el as HTMLElement;
+      obstacles.push({
+        x: parseFloat(nodeEl.style.left) || 0,
+        y: parseFloat(nodeEl.style.top) || 0,
+        w: nodeEl.offsetWidth,
+        h: nodeEl.offsetHeight,
+      });
+    }
+
+    return new Vector2D(this.spiralSearch(nodeW, nodeH, desiredPos, obstacles));
+  }
+
+  private autofit() {
+    const PADDING = 40; // screen px of breathing room on each side
+    const nodeEls = Array.from(
+      this.workspaceEl.querySelectorAll(':scope > [data-node-id]'),
+    ) as HTMLElement[];
+
+    // If there are no nodes, do nothing.
+    if (nodeEls.length === 0) {
+      return;
+    }
+
+    // Node left/top style is in canvas px; offsetWidth/Height are layout px
+    // (unaffected by the workspace CSS transform), so also canvas px.
+    let minX = Infinity;
+    let minY = Infinity;
+    let maxX = -Infinity;
+    let maxY = -Infinity;
+
+    for (const el of nodeEls) {
+      const x = parseFloat(el.style.left) || 0;
+      const y = parseFloat(el.style.top) || 0;
+      minX = Math.min(minX, x);
+      minY = Math.min(minY, y);
+      maxX = Math.max(maxX, x + el.offsetWidth);
+      maxY = Math.max(maxY, y + el.offsetHeight);
+    }
+
+    const containerW = this.rootEl.offsetWidth;
+    const containerH = this.rootEl.offsetHeight;
+    const bbW = maxX - minX;
+    const bbH = maxY - minY;
+
+    const zoom = Math.max(
+      MIN_ZOOM,
+      Math.min(
+        1.0,
+        Math.min(
+          (containerW - 2 * PADDING) / bbW,
+          (containerH - 2 * PADDING) / bbH,
+        ),
+      ),
+    );
+
+    // Center: canvas midpoint should map to screen midpoint.
+    // screen = (canvas - offset) * zoom  →  offset = canvas - screen / zoom
+    this.viewport = {
+      zoom,
+      offset: {
+        x: (minX + maxX) / 2 - containerW / (2 * zoom),
+        y: (minY + maxY) / 2 - containerH / (2 * zoom),
+      },
+    };
+    this.updateViewport();
+  }
+}
diff --git a/ui/src/widgets/nodegraph/views/toolbar.ts b/ui/src/widgets/nodegraph/views/toolbar.ts
new file mode 100644
index 0000000..a36490c
--- /dev/null
+++ b/ui/src/widgets/nodegraph/views/toolbar.ts
@@ -0,0 +1,85 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import m from 'mithril';
+import {Button, ButtonGroup, ButtonVariant} from '../../button';
+
+export const ZOOM_LEVELS: readonly number[] = [
+  0.01, 0.02, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3,
+];
+
+export interface ToolbarAttrs {
+  readonly zoom: number;
+  readonly onZoom: (level: number) => void;
+  readonly onFit: () => void;
+  readonly hasSelection: boolean;
+  readonly onDeleteSelected: () => void;
+  readonly extraItems?: m.Children;
+}
+
+export const NGToolbar: m.Component<ToolbarAttrs> = {
+  view({attrs: {zoom, onZoom, onFit, hasSelection, onDeleteSelected, extraItems}}) {
+    const zoomIn =
+      ZOOM_LEVELS.find((l) => l > zoom + 0.01) ??
+      ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
+    const zoomOut =
+      [...ZOOM_LEVELS].reverse().find((l) => l < zoom - 0.01) ?? ZOOM_LEVELS[0];
+
+    return m(
+      '.pf-ng__toolbar',
+      {
+        onpointerdown: (e: PointerEvent) => {
+          e.stopPropagation(); // Prevent toolbar clicks from starting a canvas drag
+        },
+      },
+      [
+        extraItems,
+        hasSelection &&
+          m(Button, {
+            title: 'Delete selected',
+            icon: 'delete',
+            variant: ButtonVariant.Filled,
+            onclick: onDeleteSelected,
+          }),
+        m(
+          ButtonGroup,
+          m(Button, {
+            title: 'Fit to screen',
+            icon: 'center_focus_strong',
+            variant: ButtonVariant.Filled,
+            onclick: onFit,
+          }),
+          m(Button, {
+            label: `${Math.round(zoom * 100)}%`,
+            title: 'Select zoom level',
+            variant: ButtonVariant.Filled,
+            onclick: () => onZoom(1),
+          }),
+          m(Button, {
+            title: 'Zoom in',
+            icon: 'zoom_in',
+            variant: ButtonVariant.Filled,
+            onclick: () => onZoom(zoomIn),
+          }),
+          m(Button, {
+            title: 'Zoom out',
+            icon: 'zoom_out',
+            variant: ButtonVariant.Filled,
+            onclick: () => onZoom(zoomOut),
+          }),
+        ),
+      ],
+    );
+  },
+};