ui: Visual NodeGraph tweaks and bugfixes (#3425)

- Make selected node outline appear above other nodes.
- Remove border and padding between node content and ports.
- Use compact font across entire widget.
- Fix arrowheads.
- Pan and zoom background grid properly.
- Center top and bottom ports on node.
- Remove port transitions.
- Don't turn outputs blue on hover while connecting and add not-allowed
cursor to make it clear they cannot be connected to.
- Fix autofit and improve autolayout function (autolayout is still not
perfect but now it considers stacks at least).
- Fix bug when dragging off the side of the canvas.
- Add `allInputsLeft` and `allInputsRight` options to put all
inputs/outputs on the sides rather than putting the first ones on
top/bottom. Also makes the node undockable in those directions.
- Remove node box shadow when in stack to avoid overlapping shadows.
diff --git a/ui/src/assets/widgets/nodegraph.scss b/ui/src/assets/widgets/nodegraph.scss
index 40d5bd3..d19ce38 100644
--- a/ui/src/assets/widgets/nodegraph.scss
+++ b/ui/src/assets/widgets/nodegraph.scss
@@ -29,6 +29,8 @@
   background-color: var(--pf-color-background);
   position: relative;
   user-select: none;
+  font-family: var(--pf-font-compact);
+  cursor: grab;
 }
 
 .pf-canvas-content {
@@ -63,18 +65,13 @@
   padding-block: 12px;
 }
 
-.pf-node.pf-selected {
-  border-color: var(--pf-color-accent);
-  box-shadow: 0 0 0 2px
-    color-mix(in srgb, var(--pf-color-accent) 30%, transparent);
-}
-
 // Wrapper for dock chains - uses flexbox to make children same width
 .pf-dock-chain {
   position: absolute;
   display: flex;
   flex-direction: column;
   pointer-events: auto;
+  box-shadow: 0 4px 12px var(--pf-color-box-shadow);
 
   // All nodes in chain stretch to same width (determined by widest node)
   .pf-node {
@@ -82,14 +79,22 @@
     position: relative !important;
     left: auto !important;
     top: auto !important;
+    box-shadow: none; // No box shadow for docked nodes
   }
 }
 
+.pf-node.pf-selected {
+  border-color: var(--pf-color-accent);
+  box-shadow: 0 0 0 2px
+    color-mix(in srgb, var(--pf-color-accent) 30%, 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;
-  border-top: none;
+  margin-top: -2px; // Overlap border with parent
 }
 
 // Parent with docked child: flush bottom edge with no rounded corners
@@ -135,8 +140,6 @@
 
 .pf-node-content {
   padding-inline: 12px;
-  padding-bottom: 8px;
-  border-bottom: 1px solid var(--pf-color-border-secondary);
 }
 
 .pf-port-row {
@@ -168,14 +171,12 @@
   cursor: crosshair;
   position: absolute;
   top: 50%;
-  transition: all 0.2s;
   z-index: 10;
 }
 
-.pf-port:hover {
+.pf-output:hover {
   background: var(--pf-color-accent);
   border-color: var(--pf-color-accent);
-  transform: translateY(-50%) scale(1.2);
 }
 
 .pf-port.pf-connected {
@@ -204,13 +205,6 @@
   right: -8px;
 }
 
-.pf-port.pf-active {
-  background: var(--pf-color-accent);
-  border-color: var(--pf-color-accent);
-  box-shadow: 0 0 8px
-    color-mix(in srgb, var(--pf-color-accent) 80%, transparent);
-}
-
 svg {
   position: absolute;
   top: 0;
@@ -248,3 +242,29 @@
   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);
+  cursor: crosshair;
+}
+
+.pf-panning {
+  cursor: grabbing;
+}
diff --git a/ui/src/base/classnames.ts b/ui/src/base/classnames.ts
index fa2f1b4..5505802 100644
--- a/ui/src/base/classnames.ts
+++ b/ui/src/base/classnames.ts
@@ -15,7 +15,7 @@
 // It's common to want to have a class depending on a boolean flag, in which
 // case we use `flag && className` which evaluates to either false or a string,
 // which is why false is included in definition of ArgType.
-type ArgType = string | false | undefined;
+type ArgType = string | false | undefined | null;
 
 // Join class names together into valid HTML class attributes
 // Falsy elements are ignored
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/nodegraph_demo.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/nodegraph_demo.ts
index 58cc940..9452485 100644
--- a/ui/src/plugins/dev.perfetto.WidgetsPage/nodegraph_demo.ts
+++ b/ui/src/plugins/dev.perfetto.WidgetsPage/nodegraph_demo.ts
@@ -36,6 +36,7 @@
   inputs: string[];
   outputs: string[];
   content?: m.Children;
+  allInputsLeft?: boolean;
 }
 
 export function NodeGraphDemo() {
@@ -138,6 +139,7 @@
     join: {
       inputs: ['Left', 'Right'],
       outputs: ['Output'],
+      allInputsLeft: true,
       content: m(
         '',
         {style: {display: 'flex', flexDirection: 'column', gap: '4px'}},
@@ -195,6 +197,7 @@
     const template = nodeTemplates[model.type];
     const hasNext = model.nextId !== undefined;
     const nextModel = hasNext ? modelNodes.get(model.nextId!) : undefined;
+    const allInputsLeft = template.allInputsLeft ?? false;
 
     return {
       id: model.id,
@@ -204,6 +207,7 @@
       outputs: template.outputs,
       content: template.content,
       next: nextModel ? renderChildNode(nextModel) : undefined,
+      allInputsLeft,
       addMenuItems: [
         m(MenuItem, {
           label: 'Select',
diff --git a/ui/src/widgets/nodegraph.ts b/ui/src/widgets/nodegraph.ts
index bc2ca9c..a688a40 100644
--- a/ui/src/widgets/nodegraph.ts
+++ b/ui/src/widgets/nodegraph.ts
@@ -13,8 +13,9 @@
 // limitations under the License.
 
 import m from 'mithril';
-import {Button} from './button';
+import {Button, ButtonVariant} from './button';
 import {PopupMenu} from './menu';
+import {classNames} from '../base/classnames';
 
 // ========================================
 // TYPE DEFINITIONS
@@ -43,6 +44,8 @@
   contextMenu?: m.Children; // Optional context menu items
   next?: Omit<Node, 'x' | 'y'>; // Next node in chain (linked list)
   addMenuItems?: m.Children;
+  allInputsLeft?: boolean;
+  allOutputsRight?: boolean;
 }
 
 interface ConnectingState {
@@ -101,12 +104,6 @@
   readonly onNodeRemove?: (nodeId: string) => void;
 }
 
-interface NodeGraphDOM extends Element {
-  _handleMouseMove?: (e: MouseEvent) => void;
-  _handleMouseUp?: () => void;
-  _handleWheel?: (e: WheelEvent) => void;
-}
-
 // ========================================
 // CONSTANTS
 // ========================================
@@ -150,6 +147,7 @@
   y2: number,
   fromPortType?: 'top' | 'bottom' | 'left' | 'right',
   toPortType?: 'top' | 'bottom' | 'left' | 'right',
+  shortenEnd = 0,
 ): string {
   const dx = x2 - x1;
   const dy = y2 - y1;
@@ -164,28 +162,40 @@
   // 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.6, distance * 0.4);
+    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.6, distance * 0.4);
+    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.6, distance * 0.4);
+    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.6, distance * 0.4);
+    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}`;
 }
 
@@ -267,6 +277,162 @@
     undockCandidate: null,
   };
 
+  let latestVnode: m.Vnode<NodeGraphAttrs> | null = null;
+  let canvasElement: HTMLElement | null = null;
+
+  const handleMouseMove = (e: MouseEvent) => {
+    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,
+    };
+
+    if (canvasState.isPanning) {
+      // Pan the canvas
+      const dx = e.clientX - canvasState.panStart.x;
+      const dy = e.clientY - canvasState.panStart.y;
+      canvasState.panOffset = {
+        x: canvasState.panOffset.x + dx,
+        y: canvasState.panOffset.y + 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 - perform undock
+        const {onUndock, onNodeDrag} = vnode.attrs;
+        if (onUndock && onNodeDrag) {
+          onUndock(canvasState.undockCandidate.parentId);
+          onNodeDrag(
+            canvasState.undockCandidate.nodeId,
+            (canvasState.undockCandidate.startX -
+              canvasRect.left -
+              canvasState.panOffset.x) /
+              canvasState.zoom -
+              canvasState.dragOffset.x / canvasState.zoom,
+            canvasState.undockCandidate.renderY,
+          );
+          m.redraw(); // Force update so nodes array regenerates
+        }
+        canvasState.undockCandidate = null;
+      }
+    } 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;
+
+      // ONLY move the dragged node itself
+      // Children follow automatically via render position calculation
+      const {onNodeDrag, nodes = []} = vnode.attrs;
+      if (onNodeDrag !== undefined) {
+        onNodeDrag(canvasState.draggedNode, newX, newY);
+      }
+
+      // Check if we're in a dock zone (exclude the parent we just undocked from)
+      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 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 (only for non-docked nodes)
+    if (canvasState.draggedNode !== null) {
+      const {nodes = [], onNodeDrag} = vnode.attrs;
+      const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
+
+      // Only do overlap checking if NOT being docked
+      if (draggedNode && !canvasState.isDockZone && onNodeDrag) {
+        // 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 (
+          checkNodeOverlap(
+            draggedNode.x,
+            draggedNode.y,
+            draggedNode.id,
+            nodes,
+            dims.width,
+            chainHeight,
+          )
+        ) {
+          // Find nearest non-overlapping position
+          const newPos = findNearestNonOverlappingPosition(
+            draggedNode.x,
+            draggedNode.y,
+            draggedNode.id,
+            nodes,
+            dims.width,
+            chainHeight,
+          );
+          // Update to the non-overlapping position
+          onNodeDrag(draggedNode.id, newPos.x, newPos.y);
+        }
+      }
+    }
+
+    canvasState.draggedNode = null;
+    canvasState.connecting = null;
+    canvasState.isPanning = false;
+    canvasState.dockTarget = null;
+    canvasState.isDockZone = false;
+    canvasState.undockCandidate = null;
+    m.redraw();
+  };
+
   // Helper to determine port type based on port index
   function getPortType(
     nodeId: string,
@@ -275,7 +441,9 @@
     nodes: Node[],
   ): 'top' | 'bottom' | 'left' | 'right' {
     // Search in main nodes array
-    let node = nodes.find((n) => n.id === nodeId);
+    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) {
@@ -283,7 +451,7 @@
         let current = rootNode.next;
         while (current) {
           if (current.id === nodeId) {
-            node = current as Node;
+            node = current;
             break;
           }
           current = current.next;
@@ -295,8 +463,10 @@
     if (!node) return portType === 'input' ? 'left' : 'right';
 
     if (portType === 'input') {
+      if (node.allInputsLeft) return 'left';
       return portIndex === 0 ? 'top' : 'left';
     } else {
+      if (node.allOutputsRight) return 'right';
       return portIndex === 0 ? 'bottom' : 'right';
     }
   }
@@ -310,6 +480,9 @@
     // Clear existing paths
     svg.innerHTML = '';
 
+    const shortenLength = 16;
+    const arrowheadLength = 4;
+
     // Create arrow marker definition
     const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
     const marker = document.createElementNS(
@@ -317,18 +490,18 @@
       'marker',
     );
     marker.setAttribute('id', 'arrowhead');
-    marker.setAttribute('markerWidth', '10');
-    marker.setAttribute('markerHeight', '6');
-    marker.setAttribute('refX', '10');
-    marker.setAttribute('refY', '3');
+    marker.setAttribute('viewBox', `0 0 ${arrowheadLength} 10`);
+    marker.setAttribute('refX', '0');
+    marker.setAttribute('refY', '5');
+    marker.setAttribute('markerWidth', `${arrowheadLength}`);
+    marker.setAttribute('markerHeight', '10');
     marker.setAttribute('orient', 'auto');
-    marker.setAttribute('markerUnits', 'strokeWidth');
 
     const polygon = document.createElementNS(
       'http://www.w3.org/2000/svg',
       'polygon',
     );
-    polygon.setAttribute('points', '0 0, 6 3, 0 6');
+    polygon.setAttribute('points', `0 2.5, ${arrowheadLength} 5, 0 7.5`);
     polygon.setAttribute('fill', 'var(--pf-color-accent)');
 
     marker.appendChild(polygon);
@@ -362,7 +535,15 @@
 
         path.setAttribute(
           'd',
-          createCurve(from.x, from.y, to.x, to.y, fromPortType, toPortType),
+          createCurve(
+            from.x,
+            from.y,
+            to.x,
+            to.y,
+            fromPortType,
+            toPortType,
+            shortenLength,
+          ),
         );
         path.setAttribute('marker-end', 'url(#arrowhead)');
         path.style.pointerEvents = 'stroke';
@@ -485,10 +666,11 @@
     const DOCK_DISTANCE = 30;
     const HORIZONTAL_TOLERANCE = 100;
 
-    // Check if dragged node can be docked above others
-    // It can dock above if it has inputs (top port exists)
-    const draggedCanDockAbove = (draggedNode.inputs?.length ?? 0) > 0;
-    if (!draggedCanDockAbove) {
+    // Check if dragged node can be docked.
+    // It can be docked if it has a top input port.
+    const draggedCanDock =
+      (draggedNode.inputs?.length ?? 0) > 0 && !draggedNode.allInputsLeft;
+    if (!draggedCanDock) {
       return {targetNodeId: null, isValidZone: false};
     }
 
@@ -503,9 +685,10 @@
         lastInChain = lastInChain.next;
       }
 
-      // Check if last node in chain allows docking below it
-      // It can have nodes dock below if it has outputs (bottom port exists)
-      const lastCanDockBelow = (lastInChain.outputs?.length ?? 0) > 0;
+      // Check if last node in chain allows docking below it.
+      // It can have nodes dock below if it has a bottom output port.
+      const lastCanDockBelow =
+        (lastInChain.outputs?.length ?? 0) > 0 && !lastInChain.allOutputsRight;
       if (!lastCanDockBelow) {
         continue; // Skip this node as a dock target
       }
@@ -629,8 +812,233 @@
     return {x: startX, y: startY};
   }
 
+  function getNodesBoundingBox(
+    nodes: 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: Node[],
+    connections: Connection[],
+    onNodeDrag: ((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 && onNodeDrag) {
+          onNodeDrag(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: Node[], canvas: HTMLElement) {
+    if (nodes.length === 0) return;
+
+    const {minX, minY, maxX, maxY} = getNodesBoundingBox(nodes, true);
+
+    // 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(5.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
+      canvasState.panOffset = {
+        x: canvasState.panOffset.x - e.deltaX,
+        y: canvasState.panOffset.y - e.deltaY,
+      };
+    }
+
+    m.redraw();
+  };
+
   return {
     oncreate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
+      latestVnode = vnode;
+      canvasElement = vnode.dom as HTMLElement;
+      document.addEventListener('mousemove', handleMouseMove);
+      document.addEventListener('mouseup', handleMouseUp);
+      canvasElement.addEventListener('wheel', handleWheel, {passive: false});
+
       const {
         connections = [],
         nodes = [],
@@ -652,179 +1060,24 @@
       // Create auto-layout function that uses actual DOM dimensions
       const autoLayout = () => {
         const {nodes = [], connections = [], onNodeDrag} = vnode.attrs;
-
-        // Find root nodes (nodes with no incoming connections)
-        const incomingCounts = new Map<string, number>();
-        nodes.forEach((node) => incomingCounts.set(node.id, 0));
-        connections.forEach((conn) => {
-          const currentCount = incomingCounts.get(conn.toNode) ?? 0;
-          incomingCounts.set(conn.toNode, 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
-          connections
-            .filter((conn) => conn.fromNode === id)
-            .forEach((conn) => {
-              if (!visited.has(conn.toNode)) {
-                queue.push({id: conn.toNode, 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
-          let maxWidth = 0;
-          layer.forEach((nodeId) => {
-            const dims = getNodeDimensions(nodeId);
-            maxWidth = Math.max(maxWidth, dims.width);
-          });
-
-          // Position each node in this layer
-          let currentY = 50;
-          layer.forEach((nodeId) => {
-            const node = nodes.find((n) => n.id === nodeId);
-            if (node && onNodeDrag) {
-              onNodeDrag(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();
+        autoLayoutGraph(nodes, connections, onNodeDrag);
       };
 
       // Create recenter function that brings all nodes into view
       const recenter = () => {
         const {nodes = []} = vnode.attrs;
-
-        if (nodes.length === 0) return;
-
-        // Calculate bounding box of all nodes
-        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);
-          maxY = Math.max(maxY, node.y + dims.height);
-        });
-
-        // Calculate center of bounding box
-        const centerX = (minX + maxX) / 2;
-        const centerY = (minY + maxY) / 2;
-
-        // Get canvas dimensions
         const canvas = vnode.dom as HTMLElement;
-        const canvasRect = canvas.getBoundingClientRect();
-        const viewportCenterX = canvasRect.width / 2;
-        const viewportCenterY = canvasRect.height / 2;
-
-        // Calculate required pan offset to center the nodes
-        canvasState.panOffset = {
-          x: viewportCenterX - centerX,
-          y: viewportCenterY - centerY,
-        };
-
-        m.redraw();
+        autofit(nodes, canvas);
       };
 
       // Provide API to parent
       if (onReady) {
         onReady({autoLayout, recenter});
       }
-
-      const handleWheel = (e: WheelEvent) => {
-        e.preventDefault();
-
-        // Zoom with Ctrl+wheel, pan without Ctrl
-        if (e.ctrlKey || e.metaKey) {
-          // Zoom around mouse position
-          const canvas = vnode.dom as HTMLElement;
-          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
-          canvasState.panOffset = {
-            x: canvasState.panOffset.x - e.deltaX,
-            y: canvasState.panOffset.y - e.deltaY,
-          };
-        }
-
-        m.redraw();
-      };
-
-      const canvas = vnode.dom as HTMLElement;
-      canvas.addEventListener('wheel', handleWheel, {passive: false});
-
-      // Store handlers on the vnode's dom for cleanup
-      const dom = vnode.dom as NodeGraphDOM;
-      dom._handleWheel = handleWheel;
     },
 
     onupdate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
+      latestVnode = vnode;
       const {connections = [], nodes = [], onConnectionRemove} = vnode.attrs;
 
       // Re-render connections when component updates
@@ -840,25 +1093,13 @@
     },
 
     onremove: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
-      // Clean up event listeners
-      const dom = vnode.dom as NodeGraphDOM;
-      const handleMouseMove = dom._handleMouseMove;
-      const handleMouseUp = dom._handleMouseUp;
-
-      if (handleMouseMove !== undefined) {
-        document.removeEventListener('mousemove', handleMouseMove);
-      }
-      if (handleMouseUp !== undefined) {
-        document.removeEventListener('mouseup', handleMouseUp);
-      }
-
-      const handleWheel = dom._handleWheel;
-      if (handleWheel !== undefined) {
-        (vnode.dom as HTMLElement).removeEventListener('wheel', handleWheel);
-      }
+      document.removeEventListener('mousemove', handleMouseMove);
+      document.removeEventListener('mouseup', handleMouseUp);
+      (vnode.dom as HTMLElement).removeEventListener('wheel', handleWheel);
     },
 
     view: (vnode: m.Vnode<NodeGraphAttrs>) => {
+      latestVnode = vnode;
       const {
         nodes = [],
         connections = [],
@@ -866,165 +1107,17 @@
         selectedNodeId,
       } = vnode.attrs;
 
-      const handleMouseMove = (e: MouseEvent) => {
-        const canvas = (e.target as HTMLElement).closest(
-          '.pf-canvas',
-        ) as HTMLElement | null;
-        if (!canvas) return;
-        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,
-        };
-
-        if (canvasState.isPanning) {
-          // Pan the canvas
-          const dx = e.clientX - canvasState.panStart.x;
-          const dy = e.clientY - canvasState.panStart.y;
-          canvasState.panOffset = {
-            x: canvasState.panOffset.x + dx,
-            y: canvasState.panOffset.y + 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 - perform undock
-            const {onUndock, onNodeDrag} = vnode.attrs;
-            if (onUndock && onNodeDrag) {
-              onUndock(canvasState.undockCandidate.parentId);
-              onNodeDrag(
-                canvasState.undockCandidate.nodeId,
-                (canvasState.undockCandidate.startX -
-                  canvasRect.left -
-                  canvasState.panOffset.x) /
-                  canvasState.zoom -
-                  canvasState.dragOffset.x / canvasState.zoom,
-                canvasState.undockCandidate.renderY,
-              );
-              m.redraw(); // Force update so nodes array regenerates
-            }
-            canvasState.undockCandidate = null;
-          }
-        } 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;
-
-          // ONLY move the dragged node itself
-          // Children follow automatically via render position calculation
-          const {onNodeDrag, nodes = []} = vnode.attrs;
-          if (onNodeDrag !== undefined) {
-            onNodeDrag(canvasState.draggedNode, newX, newY);
-          }
-
-          // Check if we're in a dock zone (exclude the parent we just undocked from)
-          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;
-          }
-        }
-      };
-
-      const handleMouseUp = () => {
-        // 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 (only for non-docked nodes)
-        if (canvasState.draggedNode !== null) {
-          const {nodes = [], onNodeDrag} = vnode.attrs;
-          const draggedNode = nodes.find(
-            (n) => n.id === canvasState.draggedNode,
-          );
-
-          // Only do overlap checking if NOT being docked
-          if (draggedNode && !canvasState.isDockZone && onNodeDrag) {
-            // 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 (
-              checkNodeOverlap(
-                draggedNode.x,
-                draggedNode.y,
-                draggedNode.id,
-                nodes,
-                dims.width,
-                chainHeight,
-              )
-            ) {
-              // Find nearest non-overlapping position
-              const newPos = findNearestNonOverlappingPosition(
-                draggedNode.x,
-                draggedNode.y,
-                draggedNode.id,
-                nodes,
-                dims.width,
-                chainHeight,
-              );
-              // Update to the non-overlapping position
-              onNodeDrag(draggedNode.id, newPos.x, newPos.y);
-            }
-          }
-        }
-
-        canvasState.draggedNode = null;
-        canvasState.connecting = null;
-        canvasState.isPanning = false;
-        canvasState.dockTarget = null;
-        canvasState.isDockZone = false;
-        canvasState.undockCandidate = null;
-        m.redraw();
-      };
+      const className = classNames(
+        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
           onmousedown: (e: MouseEvent) => {
             const target = e.target as HTMLElement;
@@ -1055,9 +1148,8 @@
               }
             }
           },
-          onmousemove: handleMouseMove,
-          onmouseup: handleMouseUp,
-          style: `cursor: ${canvasState.isPanning ? 'grabbing' : 'grab'}`,
+          style: `background-size: ${20 * canvasState.zoom}px ${20 * canvasState.zoom}px;
+            background-position: ${canvasState.panOffset.x}px ${canvasState.panOffset.y}px;`,
         },
         [
           // Control buttons
@@ -1065,129 +1157,23 @@
             m(Button, {
               label: 'Auto Layout',
               icon: 'account_tree',
-              compact: true,
+              variant: ButtonVariant.Filled,
               onclick: () => {
                 const {nodes = [], connections = [], onNodeDrag} = vnode.attrs;
-
-                // Find root nodes (nodes with no incoming connections)
-                const incomingCounts = new Map<string, number>();
-                nodes.forEach((node) => incomingCounts.set(node.id, 0));
-                connections.forEach((conn) => {
-                  const currentCount = incomingCounts.get(conn.toNode) ?? 0;
-                  incomingCounts.set(conn.toNode, 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
-                  connections
-                    .filter((conn) => conn.fromNode === id)
-                    .forEach((conn) => {
-                      if (!visited.has(conn.toNode)) {
-                        queue.push({id: conn.toNode, 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
-                  let maxWidth = 0;
-                  layer.forEach((nodeId) => {
-                    const dims = getNodeDimensions(nodeId);
-                    maxWidth = Math.max(maxWidth, dims.width);
-                  });
-
-                  // Position each node in this layer
-                  let currentY = 50;
-                  layer.forEach((nodeId) => {
-                    const node = nodes.find((n) => n.id === nodeId);
-                    if (node && onNodeDrag) {
-                      onNodeDrag(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();
+                autoLayoutGraph(nodes, connections, onNodeDrag);
               },
             }),
             m(Button, {
-              label: 'Recenter',
+              label: 'Fit to Screen',
               icon: 'center_focus_strong',
-              compact: true,
+              variant: ButtonVariant.Filled,
               onclick: (e: MouseEvent) => {
                 const {nodes = []} = vnode.attrs;
-
-                if (nodes.length === 0) return;
-
-                // Calculate bounding box of all nodes
-                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);
-                  maxY = Math.max(maxY, node.y + dims.height);
-                });
-
-                // Calculate center of bounding box
-                const centerX = (minX + maxX) / 2;
-                const centerY = (minY + maxY) / 2;
-
-                // Get canvas dimensions
                 const canvas = (e.currentTarget as HTMLElement).closest(
                   '.pf-canvas',
                 );
                 if (canvas) {
-                  const canvasRect = canvas.getBoundingClientRect();
-                  const viewportCenterX = canvasRect.width / 2;
-                  const viewportCenterY = canvasRect.height / 2;
-
-                  // Calculate required pan offset to center the nodes
-                  canvasState.panOffset = {
-                    x: viewportCenterX - centerX,
-                    y: viewportCenterY - centerY,
-                  };
+                  autofit(nodes, canvas as HTMLElement);
                 }
               },
             }),
@@ -1308,7 +1294,8 @@
                           },
                           [
                             // First input on top (if exists and not docked child)
-                            cInputs.length > 0 &&
+                            !chainNode.allInputsLeft &&
+                              cInputs.length > 0 &&
                               !cIsDockedChild &&
                               m('.pf-port.pf-input.pf-port-top', {
                                 'data-port': 'input-0',
@@ -1401,67 +1388,26 @@
                               ),
 
                             // Remaining inputs on left side (inputs[1+])
-                            cInputs.slice(1).map((input: string, i: number) =>
-                              m(
-                                '.pf-port-row.pf-port-input',
-                                {
-                                  'data-port': `input-${i + 1}`,
-                                },
-                                [
-                                  m('.pf-port.pf-input', {
-                                    class: isPortConnected(
-                                      cId,
-                                      'input',
-                                      i + 1,
-                                      connections,
-                                    )
-                                      ? 'pf-connected'
-                                      : '',
-                                    onmousedown: (e: MouseEvent) => {
-                                      e.stopPropagation();
-                                      const existingConnIdx =
-                                        connections.findIndex(
-                                          (conn) =>
-                                            conn.toNode === cId &&
-                                            conn.toPort === i + 1,
-                                        );
-                                      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();
-                                      }
-                                    },
-                                    onmouseup: (e: MouseEvent) => {
-                                      e.stopPropagation();
-                                      if (
-                                        canvasState.connecting &&
-                                        canvasState.connecting.type === 'output'
-                                      ) {
+                            cInputs
+                              .slice(chainNode.allInputsLeft ? 0 : 1)
+                              .map((input: string, i: number) =>
+                                m(
+                                  '.pf-port-row.pf-port-input',
+                                  {
+                                    'data-port': `input-${i + 1}`,
+                                  },
+                                  [
+                                    m('.pf-port.pf-input', {
+                                      class: isPortConnected(
+                                        cId,
+                                        'input',
+                                        i + 1,
+                                        connections,
+                                      )
+                                        ? 'pf-connected'
+                                        : '',
+                                      onmousedown: (e: MouseEvent) => {
+                                        e.stopPropagation();
                                         const existingConnIdx =
                                           connections.findIndex(
                                             (conn) =>
@@ -1469,6 +1415,8 @@
                                               conn.toPort === i + 1,
                                           );
                                         if (existingConnIdx !== -1) {
+                                          const existingConn =
+                                            connections[existingConnIdx];
                                           const {onConnectionRemove} =
                                             vnode.attrs;
                                           if (
@@ -1476,79 +1424,129 @@
                                           ) {
                                             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();
                                         }
-                                        const connection = {
-                                          fromNode:
-                                            canvasState.connecting.nodeId,
-                                          fromPort:
-                                            canvasState.connecting.portIndex,
-                                          toNode: cId,
-                                          toPort: i + 1,
-                                        };
-                                        if (onConnect !== undefined) {
-                                          onConnect(connection);
+                                      },
+                                      onmouseup: (e: MouseEvent) => {
+                                        e.stopPropagation();
+                                        if (
+                                          canvasState.connecting &&
+                                          canvasState.connecting.type ===
+                                            'output'
+                                        ) {
+                                          const existingConnIdx =
+                                            connections.findIndex(
+                                              (conn) =>
+                                                conn.toNode === cId &&
+                                                conn.toPort === i + 1,
+                                            );
+                                          if (existingConnIdx !== -1) {
+                                            const {onConnectionRemove} =
+                                              vnode.attrs;
+                                            if (
+                                              onConnectionRemove !== undefined
+                                            ) {
+                                              onConnectionRemove(
+                                                existingConnIdx,
+                                              );
+                                            }
+                                          }
+                                          const connection = {
+                                            fromNode:
+                                              canvasState.connecting.nodeId,
+                                            fromPort:
+                                              canvasState.connecting.portIndex,
+                                            toNode: cId,
+                                            toPort: i + 1,
+                                          };
+                                          if (onConnect !== undefined) {
+                                            onConnect(connection);
+                                          }
+                                          canvasState.connecting = null;
                                         }
-                                        canvasState.connecting = null;
-                                      }
-                                    },
-                                  }),
-                                  m('span', input),
-                                ],
+                                      },
+                                    }),
+                                    m('span', input),
+                                  ],
+                                ),
                               ),
-                            ),
 
                             // Remaining outputs on right side (outputs[1+])
-                            cOutputs.slice(1).map((output: string, i: number) =>
-                              m(
-                                '.pf-port-row.pf-port-output',
-                                {
-                                  'data-port': `output-${i + 1}`,
-                                },
-                                [
-                                  m('span', output),
-                                  m('.pf-port.pf-output', {
-                                    class: [
-                                      isPortConnected(
-                                        cId,
-                                        'output',
-                                        i + 1,
-                                        connections,
-                                      )
-                                        ? 'pf-connected'
-                                        : '',
-                                      canvasState.connecting &&
-                                      canvasState.connecting.nodeId === cId &&
-                                      canvasState.connecting.portIndex === i + 1
-                                        ? 'pf-active'
-                                        : '',
-                                    ]
-                                      .filter(Boolean)
-                                      .join(' '),
-                                    onmousedown: (e: MouseEvent) => {
-                                      e.stopPropagation();
-                                      const portPos = getPortPosition(
-                                        cId,
-                                        'output',
-                                        i + 1,
-                                      );
-                                      canvasState.connecting = {
-                                        nodeId: cId,
-                                        portIndex: i + 1,
-                                        type: 'output',
-                                        portType: 'right',
-                                        x: 0,
-                                        y: 0,
-                                        transformedX: portPos.x,
-                                        transformedY: portPos.y,
-                                      };
-                                    },
-                                  }),
-                                ],
+                            cOutputs
+                              .slice(chainNode.allOutputsRight ? 0 : 1)
+                              .map((output: string, i: number) =>
+                                m(
+                                  '.pf-port-row.pf-port-output',
+                                  {
+                                    'data-port': `output-${i + 1}`,
+                                  },
+                                  [
+                                    m('span', output),
+                                    m('.pf-port.pf-output', {
+                                      class: [
+                                        isPortConnected(
+                                          cId,
+                                          'output',
+                                          i + 1,
+                                          connections,
+                                        )
+                                          ? 'pf-connected'
+                                          : '',
+                                        canvasState.connecting &&
+                                        canvasState.connecting.nodeId === cId &&
+                                        canvasState.connecting.portIndex ===
+                                          i + 1
+                                          ? 'pf-active'
+                                          : '',
+                                      ]
+                                        .filter(Boolean)
+                                        .join(' '),
+                                      onmousedown: (e: MouseEvent) => {
+                                        e.stopPropagation();
+                                        const portPos = getPortPosition(
+                                          cId,
+                                          'output',
+                                          i + 1,
+                                        );
+                                        canvasState.connecting = {
+                                          nodeId: cId,
+                                          portIndex: i + 1,
+                                          type: 'output',
+                                          portType: 'right',
+                                          x: 0,
+                                          y: 0,
+                                          transformedX: portPos.x,
+                                          transformedY: portPos.y,
+                                        };
+                                      },
+                                    }),
+                                  ],
+                                ),
                               ),
-                            ),
 
                             // First output on bottom (if exists and no docked child below)
-                            cOutputs.length > 0 &&
+                            !chainNode.allOutputsRight &&
+                              cOutputs.length > 0 &&
                               !cHasDockedChild &&
                               m(
                                 PopupMenu,
@@ -1649,7 +1647,8 @@
                     [
                       // First input on top (if exists)
                       // Note: standalone nodes are never docked children, so always show if inputs exist
-                      inputs.length > 0 &&
+                      !node.allInputsLeft &&
+                        inputs.length > 0 &&
                         m('.pf-port.pf-input.pf-port-top', {
                           'data-port': 'input-0',
                           'class': isPortConnected(id, 'input', 0, connections)
@@ -1748,162 +1747,170 @@
                         ),
 
                       // Remaining inputs on left side (inputs[1+])
-                      inputs.slice(1).map((input: string, i: number) =>
-                        m(
-                          '.pf-port-row.pf-port-input',
-                          {
-                            'data-port': `input-${i + 1}`,
-                          },
-                          [
-                            m('.pf-port.pf-input', {
-                              class: isPortConnected(
-                                id,
-                                'input',
-                                i + 1,
-                                connections,
-                              )
-                                ? 'pf-connected'
-                                : '',
-                              onmousedown: (e: MouseEvent) => {
-                                e.stopPropagation();
+                      inputs
+                        .slice(node.allInputsLeft ? 0 : 1)
+                        .map((input: string, i: number) =>
+                          m(
+                            '.pf-port-row.pf-port-input',
+                            {
+                              'data-port': `input-${i + 1}`,
+                            },
+                            [
+                              m('.pf-port.pf-input', {
+                                class: isPortConnected(
+                                  id,
+                                  'input',
+                                  i + 1,
+                                  connections,
+                                )
+                                  ? 'pf-connected'
+                                  : '',
+                                onmousedown: (e: MouseEvent) => {
+                                  e.stopPropagation();
 
-                                // Check if this input is already connected
-                                const existingConnIdx = connections.findIndex(
-                                  (conn) =>
-                                    conn.toNode === id && conn.toPort === i + 1,
-                                );
-
-                                if (existingConnIdx !== -1) {
-                                  const existingConn =
-                                    connections[existingConnIdx];
-
-                                  // Remove the existing connection
-                                  const {onConnectionRemove} = vnode.attrs;
-                                  if (onConnectionRemove !== undefined) {
-                                    onConnectionRemove(existingConnIdx);
-                                  }
-
-                                  // Start a new connection from the original output port
-                                  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();
-                                }
-                              },
-                              onmouseup: (e: MouseEvent) => {
-                                e.stopPropagation();
-                                if (
-                                  canvasState.connecting &&
-                                  canvasState.connecting.type === 'output'
-                                ) {
-                                  // Check if this input already has a connection
+                                  // Check if this input is already connected
                                   const existingConnIdx = connections.findIndex(
                                     (conn) =>
                                       conn.toNode === id &&
                                       conn.toPort === i + 1,
                                   );
 
-                                  // Remove existing connection if present
                                   if (existingConnIdx !== -1) {
+                                    const existingConn =
+                                      connections[existingConnIdx];
+
+                                    // Remove the existing connection
                                     const {onConnectionRemove} = vnode.attrs;
                                     if (onConnectionRemove !== undefined) {
                                       onConnectionRemove(existingConnIdx);
                                     }
+
+                                    // Start a new connection from the original output port
+                                    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();
                                   }
+                                },
+                                onmouseup: (e: MouseEvent) => {
+                                  e.stopPropagation();
+                                  if (
+                                    canvasState.connecting &&
+                                    canvasState.connecting.type === 'output'
+                                  ) {
+                                    // Check if this input already has a connection
+                                    const existingConnIdx =
+                                      connections.findIndex(
+                                        (conn) =>
+                                          conn.toNode === id &&
+                                          conn.toPort === i + 1,
+                                      );
 
-                                  const connection = {
-                                    fromNode: canvasState.connecting.nodeId,
-                                    fromPort: canvasState.connecting.portIndex,
-                                    toNode: id,
-                                    toPort: i + 1,
-                                  };
+                                    // Remove existing connection if present
+                                    if (existingConnIdx !== -1) {
+                                      const {onConnectionRemove} = vnode.attrs;
+                                      if (onConnectionRemove !== undefined) {
+                                        onConnectionRemove(existingConnIdx);
+                                      }
+                                    }
 
-                                  // Call onConnect callback if provided
-                                  if (onConnect !== undefined) {
-                                    onConnect(connection);
+                                    const connection = {
+                                      fromNode: canvasState.connecting.nodeId,
+                                      fromPort:
+                                        canvasState.connecting.portIndex,
+                                      toNode: id,
+                                      toPort: i + 1,
+                                    };
+
+                                    // Call onConnect callback if provided
+                                    if (onConnect !== undefined) {
+                                      onConnect(connection);
+                                    }
+
+                                    canvasState.connecting = null;
                                   }
-
-                                  canvasState.connecting = null;
-                                }
-                              },
-                            }),
-                            m('span', input),
-                          ],
+                                },
+                              }),
+                              m('span', input),
+                            ],
+                          ),
                         ),
-                      ),
 
                       // Remaining outputs on right side (outputs[1+])
-                      outputs.slice(1).map((output: string, i: number) =>
-                        m(
-                          '.pf-port-row.pf-port-output',
-                          {
-                            'data-port': `output-${i + 1}`,
-                          },
-                          [
-                            m('span', output),
-                            m('.pf-port.pf-output', {
-                              class: [
-                                isPortConnected(
-                                  id,
-                                  'output',
-                                  i + 1,
-                                  connections,
-                                )
-                                  ? 'pf-connected'
-                                  : '',
-                                canvasState.connecting &&
-                                canvasState.connecting.nodeId === id &&
-                                canvasState.connecting.portIndex === i + 1
-                                  ? 'pf-active'
-                                  : '',
-                              ]
-                                .filter(Boolean)
-                                .join(' '),
-                              onmousedown: (e: MouseEvent) => {
-                                e.stopPropagation();
-                                const portPos = getPortPosition(
-                                  id,
-                                  'output',
-                                  i + 1,
-                                );
-                                canvasState.connecting = {
-                                  nodeId: id,
-                                  portIndex: i + 1,
-                                  type: 'output',
-                                  portType: 'right',
-                                  x: 0,
-                                  y: 0,
-                                  transformedX: portPos.x,
-                                  transformedY: portPos.y,
-                                };
-                              },
-                            }),
-                          ],
+                      outputs
+                        .slice(node.allOutputsRight ? 0 : 1)
+                        .map((output: string, i: number) =>
+                          m(
+                            '.pf-port-row.pf-port-output',
+                            {
+                              'data-port': `output-${i + 1}`,
+                            },
+                            [
+                              m('span', output),
+                              m('.pf-port.pf-output', {
+                                class: [
+                                  isPortConnected(
+                                    id,
+                                    'output',
+                                    i + 1,
+                                    connections,
+                                  )
+                                    ? 'pf-connected'
+                                    : '',
+                                  canvasState.connecting &&
+                                  canvasState.connecting.nodeId === id &&
+                                  canvasState.connecting.portIndex === i + 1
+                                    ? 'pf-active'
+                                    : '',
+                                ]
+                                  .filter(Boolean)
+                                  .join(' '),
+                                onmousedown: (e: MouseEvent) => {
+                                  e.stopPropagation();
+                                  const portPos = getPortPosition(
+                                    id,
+                                    'output',
+                                    i + 1,
+                                  );
+                                  canvasState.connecting = {
+                                    nodeId: id,
+                                    portIndex: i + 1,
+                                    type: 'output',
+                                    portType: 'right',
+                                    x: 0,
+                                    y: 0,
+                                    transformedX: portPos.x,
+                                    transformedY: portPos.y,
+                                  };
+                                },
+                              }),
+                            ],
+                          ),
                         ),
-                      ),
 
                       // First output on bottom (if exists)
                       // Note: standalone nodes never have docked children, so always show if outputs exist
-                      outputs.length > 0 &&
+                      !node.allOutputsRight &&
+                        outputs.length > 0 &&
                         m(
                           PopupMenu,
                           {