NodeGraph animations and toolbar

- Animate nodes when they move automatically
- Add resetZoom API (reset to 100% zoom)
- Tweak built-in toolbar
diff --git a/ui/src/assets/widgets/nodegraph.scss b/ui/src/assets/widgets/nodegraph.scss
index 97ec54b..555f059 100644
--- a/ui/src/assets/widgets/nodegraph.scss
+++ b/ui/src/assets/widgets/nodegraph.scss
@@ -93,29 +93,104 @@
   &:hover .pf-show-on-hover {
     visibility: visible;
   }
-}
 
-.pf-node--has-accent-bar::before {
-  content: "";
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 10px;
-  height: 100%;
-  background-color: var(
-    --pf-color-border
-  ); /* Fallback color when hue not set */
-  border-top-left-radius: 6px;
-  border-bottom-left-radius: 6px;
-}
+  &__dock-indicator-bottom {
+    position: absolute;
+    bottom: -8px;
+    left: 50%;
+    width: calc(var(--tab-width) - 6px);
+    height: 8px;
+    transform: translateX(-50%);
+    background-color: var(--pf-color-background);
+    border-bottom-right-radius: 5px;
+    border-bottom-left-radius: 5px;
+    box-shadow: 0 2px 6px var(--pf-color-box-shadow);
+    clip-path: inset(0 -10px -10px -10px);
+  }
 
-/* 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 {
-  background: color-mix(
-    in srgb,
-    hsl(var(--pf-node-hue), 60%, 50%) 50%,
-    var(--pf-color-background)
-  );
+  &.pf-selected {
+    .pf-node__dock-indicator-bottom {
+      outline: 3px solid
+        color-mix(
+          in srgb,
+          var(--pf-color-accent) 50%,
+          var(--pf-color-background)
+        );
+    }
+  }
+
+  &__dock-indicator-top {
+    position: absolute;
+    top: 0;
+    left: 50%;
+    width: var(--tab-width);
+    height: 8px;
+    transform: translateX(-50%);
+    background-color: var(--pf-color-border);
+    border-bottom-right-radius: 8px;
+    border-bottom-left-radius: 8px;
+  }
+
+  &--has-docked-child {
+    border-bottom-left-radius: 0;
+    border-bottom-right-radius: 0;
+
+    &.pf-node--has-accent-bar:before {
+      border-bottom-left-radius: 0;
+    }
+
+    .pf-node__dock-indicator-bottom {
+      box-shadow: none;
+    }
+  }
+
+  &--is-docked-child {
+    border-top: solid 3px var(--pf-color-border);
+    border-top-left-radius: 0;
+    border-top-right-radius: 0;
+
+    &.pf-node--has-accent-bar:before {
+      border-top-left-radius: 0;
+    }
+
+    &.pf-selected {
+      border-top: none;
+      margin-top: 3px;
+    }
+  }
+
+  &--has-accent-bar::before {
+    content: "";
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 10px;
+    height: 100%;
+    background-color: var(
+      --pf-color-border
+    ); /* Fallback color when hue not set */
+    border-top-left-radius: 8px;
+    border-bottom-left-radius: 8px;
+  }
+
+  &.pf-selected {
+    .pf-node__dock-indicator-top {
+      background-color: color-mix(
+        in srgb,
+        var(--pf-color-accent) 50%,
+        var(--pf-color-background)
+      );
+    }
+  }
+
+  /* Override with hue-based color when --pf-node-hue is set via inline styles */
+  &[style*="--pf-node-hue"].pf-node--has-accent-bar::before {
+    background: color-mix(
+      in srgb,
+      hsl(var(--pf-node-hue, gray), 50%, 50%) 80%,
+      var(--pf-color-background)
+    );
+  }
 }
 
 .pf-node-body {
@@ -132,15 +207,52 @@
   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;
+
+  // Pop-in animation when node first appears
+  animation: pop-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
 
   &--dragging {
     z-index: 1; // Ensure dragging entire chain appears above other nodes.
+    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);
   }
+
+  &--no-animation {
+    animation: none; // Disable animation for temporary/measurement nodes
+  }
+}
+
+@keyframes pop-in {
+  0% {
+    opacity: 0;
+    transform: scale(0.8);
+  }
+  100% {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+@keyframes pop-out {
+  0% {
+    opacity: 1;
+    transform: scale(1);
+  }
+  100% {
+    opacity: 0;
+    transform: scale(0.8);
+  }
+}
+
+.pf-node-wrapper--removing {
+  animation: pop-out 0.1s cubic-bezier(0.4, 0, 0.68, 0.06) forwards;
 }
 
 .pf-node.pf-selected {
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 75180bc..2ab4529 100644
--- a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts
+++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/nodegraph_demo.ts
@@ -15,7 +15,7 @@
 import m from 'mithril';
 import {produce} from 'immer';
 import {uuidv4} from '../../../base/uuid';
-import {Button, ButtonVariant} from '../../../widgets/button';
+import {Button, ButtonGroup, ButtonVariant} from '../../../widgets/button';
 import {Checkbox} from '../../../widgets/checkbox';
 import {MenuItem, PopupMenu} from '../../../widgets/menu';
 import {
@@ -31,6 +31,8 @@
 import {TextInput} from '../../../widgets/text_input';
 import {renderDocSection, renderWidgetShowcase} from '../widgets_page_utils';
 
+const MAX_HISTORY_DEPTH = 500;
+
 // Base node data interface
 interface BaseNodeData {
   readonly id: string;
@@ -106,41 +108,30 @@
 
 const NODE_CONFIGS: Record<NodeData['type'], NodeConfig> = {
   table: {
-    outputs: [{content: 'Output', direction: 'bottom'}],
     canDockBottom: true,
     hue: 200,
     icon: 'table_chart',
   },
   select: {
-    inputs: [{content: 'Input', direction: 'top'}],
-    outputs: [{content: 'Output', direction: 'bottom'}],
+    outputs: [{content: 'Output', direction: 'right'}],
     canDockTop: true,
-    canDockBottom: true,
     hue: 100,
     icon: 'checklist',
   },
   filter: {
-    inputs: [{content: 'Input', direction: 'top'}],
-    outputs: [{content: 'Output', direction: 'bottom'}],
     canDockTop: true,
     canDockBottom: true,
     hue: 50,
     icon: 'filter_alt',
   },
   sort: {
-    inputs: [{content: 'Input', direction: 'top'}],
-    outputs: [{content: 'Output', direction: 'bottom'}],
     canDockTop: true,
     canDockBottom: true,
     hue: 150,
     icon: 'sort',
   },
   join: {
-    inputs: [
-      {content: 'Left', direction: 'top'},
-      {content: 'Right', direction: 'left'},
-    ],
-    outputs: [{content: 'Output', direction: 'bottom'}],
+    inputs: [{content: 'Right', direction: 'left'}],
     canDockTop: true,
     canDockBottom: true,
     hue: 300,
@@ -148,17 +139,14 @@
   },
   union: {
     inputs: [
-      {content: 'Input 1', direction: 'top'},
+      {content: 'Input 1', direction: 'left'},
       {content: 'Input 2', direction: 'left'},
     ],
-    outputs: [{content: 'Output', direction: 'bottom'}],
-    canDockTop: true,
     canDockBottom: true,
     hue: 240,
     icon: 'merge',
   },
   result: {
-    inputs: [{content: 'Input', direction: 'top'}],
     canDockTop: true,
     hue: 0,
     icon: 'output',
@@ -525,8 +513,8 @@
     history.push(store);
     historyIndex = history.length - 1;
 
-    // Limit history to prevent memory issues (keep last 50 states)
-    if (history.length > 50) {
+    // Limit history to prevent memory issues
+    if (history.length > MAX_HISTORY_DEPTH) {
       history.shift();
       historyIndex--;
     }
@@ -741,10 +729,21 @@
     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);
@@ -761,9 +760,14 @@
         const colList = selectedCols.length > 0 ? selectedCols.join(', ') : '*';
 
         const inputSql = dockedParent
-          ? buildSqlFromNode(nodes, connections, dockedParent.id)
+          ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited)
           : connectedInputs.get(0)
-            ? buildSqlFromNode(nodes, connections, connectedInputs.get(0)!.id)
+            ? buildSqlFromNode(
+                nodes,
+                connections,
+                connectedInputs.get(0)!.id,
+                newVisited,
+              )
             : '';
 
         if (!inputSql) return `SELECT ${colList}`;
@@ -774,9 +778,14 @@
         const filterExpr = node.filterExpression || '';
 
         const inputSql = dockedParent
-          ? buildSqlFromNode(nodes, connections, dockedParent.id)
+          ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited)
           : connectedInputs.get(0)
-            ? buildSqlFromNode(nodes, connections, connectedInputs.get(0)!.id)
+            ? buildSqlFromNode(
+                nodes,
+                connections,
+                connectedInputs.get(0)!.id,
+                newVisited,
+              )
             : '';
 
         if (!inputSql) return '';
@@ -789,9 +798,14 @@
         const sortOrder = node.sortOrder || 'ASC';
 
         const inputSql = dockedParent
-          ? buildSqlFromNode(nodes, connections, dockedParent.id)
+          ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited)
           : connectedInputs.get(0)
-            ? buildSqlFromNode(nodes, connections, connectedInputs.get(0)!.id)
+            ? buildSqlFromNode(
+                nodes,
+                connections,
+                connectedInputs.get(0)!.id,
+                newVisited,
+              )
             : '';
 
         if (!inputSql) return '';
@@ -803,15 +817,18 @@
         const joinType = node.joinType || 'INNER';
         const joinOn = node.joinOn || 'true';
 
-        // Join needs two inputs: one docked (or from top connection) and one from left connection
+        // Join needs two inputs: one docked and one from left connection
         const leftInput = dockedParent
-          ? buildSqlFromNode(nodes, connections, dockedParent.id)
-          : connectedInputs.get(0)
-            ? buildSqlFromNode(nodes, connections, connectedInputs.get(0)!.id)
-            : '';
+          ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited)
+          : '';
 
-        const rightInput = connectedInputs.get(1)
-          ? buildSqlFromNode(nodes, connections, connectedInputs.get(1)!.id)
+        const rightInput = connectedInputs.get(0)
+          ? buildSqlFromNode(
+              nodes,
+              connections,
+              connectedInputs.get(0)!.id,
+              newVisited,
+            )
           : '';
 
         if (!leftInput || !rightInput) return leftInput || rightInput || '';
@@ -823,12 +840,16 @@
 
         const inputs: string[] = [];
 
-        // Collect all inputs (docked + connections)
+        // Collect all inputs from left connections (no docked parent for union)
         if (dockedParent) {
-          inputs.push(buildSqlFromNode(nodes, connections, dockedParent.id));
+          inputs.push(
+            buildSqlFromNode(nodes, connections, dockedParent.id, newVisited),
+          );
         }
         for (const [_, inputNode] of connectedInputs) {
-          inputs.push(buildSqlFromNode(nodes, connections, inputNode.id));
+          inputs.push(
+            buildSqlFromNode(nodes, connections, inputNode.id, newVisited),
+          );
         }
 
         const validInputs = inputs.filter((sql) => sql);
@@ -839,9 +860,14 @@
 
       case 'result': {
         const inputSql = dockedParent
-          ? buildSqlFromNode(nodes, connections, dockedParent.id)
+          ? buildSqlFromNode(nodes, connections, dockedParent.id, newVisited)
           : connectedInputs.get(0)
-            ? buildSqlFromNode(nodes, connections, connectedInputs.get(0)!.id)
+            ? buildSqlFromNode(
+                nodes,
+                connections,
+                connectedInputs.get(0)!.id,
+                newVisited,
+              )
             : '';
         return inputSql;
       }
@@ -1102,23 +1128,12 @@
 
       const nodeGraphAttrs: NodeGraphAttrs = {
         toolbarItems: [
-          m(Button, {
-            label: 'Undo',
-            icon: 'undo',
-            disabled: !canUndo(),
-            onclick: undo,
-          }),
-          m(Button, {
-            label: 'Redo',
-            icon: 'redo',
-            disabled: !canRedo(),
-            onclick: redo,
-          }),
           m(
             PopupMenu,
             {
               trigger: m(Button, {
                 label: 'Add Node',
+                title: 'Add a new node to the graph',
                 icon: 'add',
                 variant: ButtonVariant.Filled,
               }),
@@ -1182,13 +1197,45 @@
               }),
             ],
           ),
-          m(Button, {
-            label: 'Stress Test',
-            icon: 'science',
-            variant: ButtonVariant.Filled,
-            title: 'Generate a large random graph for performance testing',
-            onclick: () => runStressTest(),
-          }),
+          m(
+            ButtonGroup,
+            m(Button, {
+              icon: 'undo',
+              title: 'Undo',
+              disabled: !canUndo(),
+              variant: ButtonVariant.Filled,
+              onclick: undo,
+            }),
+            m(Button, {
+              icon: 'redo',
+              title: 'Redo',
+              disabled: !canRedo(),
+              variant: ButtonVariant.Filled,
+              onclick: redo,
+            }),
+          ),
+          m(
+            ButtonGroup,
+            m(Button, {
+              title:
+                'Add a large number of random nodes and connections for performance testing',
+              icon: 'science',
+              variant: ButtonVariant.Filled,
+              onclick: () => runStressTest(),
+            }),
+            m(Button, {
+              title: 'Remove all nodes from the graph',
+              icon: 'delete',
+              variant: ButtonVariant.Filled,
+              onclick: () => {
+                updateStore((draft) => {
+                  draft.nodes.clear();
+                  draft.connections.length = 0;
+                });
+                selectedNodeIds.clear();
+              },
+            }),
+          ),
         ],
         nodes: renderNodes(),
         connections: store.connections,
diff --git a/ui/src/widgets/nodegraph.ts b/ui/src/widgets/nodegraph.ts
index 72abedb..33fec12 100644
--- a/ui/src/widgets/nodegraph.ts
+++ b/ui/src/widgets/nodegraph.ts
@@ -48,7 +48,7 @@
  * ```
  */
 import m from 'mithril';
-import {Button, ButtonVariant} from './button';
+import {Button, ButtonGroup, ButtonVariant} from './button';
 import {Icon} from './icon';
 import {PopupMenu} from './menu';
 import {classNames} from '../base/classnames';
@@ -191,6 +191,11 @@
    * @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 {
@@ -415,6 +420,7 @@
   // 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;
@@ -2087,45 +2093,48 @@
         tempContainer.style.visibility = 'hidden';
         canvas.appendChild(tempContainer);
 
-        // Render the node into the temporary container
+        // Render the node into the temporary container with animation disabled
         m.render(
           tempContainer,
           m(
-            '.pf-node',
-            {
-              'data-node': tempNode.id,
-              'style': {
-                ...(tempNode.hue !== undefined
-                  ? {'--pf-node-hue': `${tempNode.hue}`}
-                  : {}),
+            '.pf-node-wrapper.pf-node-wrapper--no-animation',
+            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),
+              [
+                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'),
+                      ]),
+                    ),
                 ]),
-              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'),
-                    ]),
-                  ),
-              ]),
-            ],
+              ],
+            ),
           ),
         );
 
@@ -2156,6 +2165,31 @@
         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 &&
@@ -2169,6 +2203,7 @@
           findPlacementForNode: findPlacementForNodeApi,
           panBy,
           zoomBy,
+          resetZoom,
         });
       }
     },
@@ -2199,7 +2234,8 @@
         onReady !== undefined &&
         autoLayoutApi !== null &&
         recenterApi !== null &&
-        findPlacementForNodeApi !== null
+        findPlacementForNodeApi !== null &&
+        resetZoom !== null
       ) {
         onReady({
           autoLayout: autoLayoutApi,
@@ -2207,6 +2243,7 @@
           findPlacementForNode: findPlacementForNodeApi,
           panBy,
           zoomBy,
+          resetZoom,
         });
       }
     },
@@ -2222,7 +2259,6 @@
       const {
         nodes,
         selectedNodeIds = new Set<string>(),
-        hideControls = false,
         multiselect = true,
         contextMenuOnHover = false,
         fillHeight,
@@ -2340,37 +2376,44 @@
           },
         },
         [
-          // Control buttons (can be hidden via hideControls prop)
-          !hideControls &&
+          (vnode.attrs.toolbarItems !== undefined ||
+            !vnode.attrs.hideControls) &&
             m('.pf-nodegraph-controls', [
               vnode.attrs.toolbarItems,
-              m(Button, {
-                label: 'Auto Layout',
-                icon: 'account_tree',
-                variant: ButtonVariant.Filled,
-                onclick: () => {
-                  const {
-                    nodes = [],
-                    connections = [],
-                    onNodeMove,
-                  } = vnode.attrs;
-                  autoLayoutGraph(nodes, connections, onNodeMove);
-                },
-              }),
-              m(Button, {
-                label: 'Fit to Screen',
-                icon: 'center_focus_strong',
-                variant: ButtonVariant.Filled,
-                onclick: (e: PointerEvent) => {
-                  const {nodes = [], labels = []} = vnode.attrs;
-                  const canvas = (e.currentTarget as HTMLElement).closest(
-                    '.pf-canvas',
-                  );
-                  if (canvas) {
-                    autofit(nodes, labels, canvas as HTMLElement);
-                  }
-                },
-              }),
+              !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
@@ -2420,6 +2463,14 @@
                           canvasState.draggedNode === id &&
                             'pf-node-wrapper--dragging',
                         ),
+                        onbeforeremove: (vnode: m.VnodeDOM) => {
+                          return new Promise((resolve) => {
+                            vnode.dom.classList.add(
+                              'pf-node-wrapper--removing',
+                            );
+                            setTimeout(resolve, 200); // Match animation duration
+                          });
+                        },
                       },
                       chain.map((chainNode) => {
                         const cIsDockedChild = 'x' in chainNode === false;
@@ -2452,6 +2503,14 @@
                           canvasState.draggedNode === id &&
                             'pf-node-wrapper--dragging',
                         ),
+                        onbeforeremove: (vnode: m.VnodeDOM) => {
+                          return new Promise((resolve) => {
+                            vnode.dom.classList.add(
+                              'pf-node-wrapper--removing',
+                            );
+                            setTimeout(resolve, 200); // Match animation duration
+                          });
+                        },
                       },
                       renderNode(node, vnode, {
                         isDockedChild: false,