# Network Diagrams Phase 2 — Draw.io-Grade Editing Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Elevate the network diagram editor from a basic canvas to a draw.io-grade editing experience by adding undo/redo, keyboard nudging, alignment/distribution commands, a group node component, improved edge routing, inline label editing, and a command layer that wires all of these consistently across keyboard, context menu, and toolbar.

**Architecture:** All new commands are exposed through a single `useDiagramCommands` hook that is the single source of truth for every action — keyboard shortcuts, context menu items, and toolbar buttons all call the same functions. Undo/redo is implemented as a lightweight snapshot stack in `DiagramEditor.tsx` using `useRef` for the history array and two new state integers (`historyIndex`, stack depth cap of 50). No new state management library is introduced.

**Tech Stack:** React 19, TypeScript, `@xyflow/react` (React Flow v12), Lucide React, Tailwind CSS v4, Zustand (not used for diagram state — stays in DiagramEditor local state).

---

## File Map

| File | Action | Responsibility |
|------|--------|----------------|
| `frontend/src/components/network/hooks/useDiagramCommands.ts` | **Create** | Central command layer: align, distribute, group, ungroup, z-order, nudge, undo, redo |
| `frontend/src/components/network/hooks/useCanvasShortcuts.ts` | **Modify** | Wire undo/redo + nudge shortcuts; delegate all other shortcuts to useDiagramCommands |
| `frontend/src/components/network/nodes/GroupNode.tsx` | **Create** | Visual group/container node with resize and editable label |
| `frontend/src/components/network/nodes/nodeTypes.ts` | **Modify** | Register GroupNode |
| `frontend/src/components/network/ContextMenu.tsx` | **Modify** | Add align/distribute/group/ungroup menu sections |
| `frontend/src/components/network/DiagramHeader.tsx` | **Modify** | Add undo/redo buttons |
| `frontend/src/components/network/panels/PropertiesPanel.tsx` | **Modify** | Add alignment buttons for multi-select, group properties editor |
| `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx` | **Modify** | Add history stack, expose pushHistory/undo/redo, wire useDiagramCommands, selection tracking for multi-select |
| `frontend/src/types/network-diagram.ts` | **Modify** | Add `zIndex` to DiagramNode, add `GroupNodeData` interface |

---

## Task 1: Undo/Redo History Stack in DiagramEditor

**Files:**
- Modify: `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx`

This task adds a snapshot-based undo/redo stack. Every mutation to `nodes` or `edges` must call `pushHistory` before applying the change.

- [ ] **Step 1: Add history state at the top of DiagramEditor component**

In `DiagramEditor.tsx`, after the existing state declarations, add:

```tsx
// History
const historyStack = useRef<{ nodes: Node[]; edges: Edge[] }[]>([])
const historyIndex = useRef<number>(-1)
const MAX_HISTORY = 50

const pushHistory = useCallback((currentNodes: Node[], currentEdges: Edge[]) => {
  // Truncate any redo history beyond current index
  historyStack.current = historyStack.current.slice(0, historyIndex.current + 1)
  historyStack.current.push({
    nodes: JSON.parse(JSON.stringify(currentNodes)),
    edges: JSON.parse(JSON.stringify(currentEdges)),
  })
  if (historyStack.current.length > MAX_HISTORY) {
    historyStack.current.shift()
  } else {
    historyIndex.current += 1
  }
}, [])

const undo = useCallback(() => {
  if (historyIndex.current <= 0) return
  historyIndex.current -= 1
  const snapshot = historyStack.current[historyIndex.current]
  setNodes(snapshot.nodes)
  setEdges(snapshot.edges)
  setIsDirty(true)
}, [setNodes, setEdges])

const redo = useCallback(() => {
  if (historyIndex.current >= historyStack.current.length - 1) return
  historyIndex.current += 1
  const snapshot = historyStack.current[historyIndex.current]
  setNodes(snapshot.nodes)
  setEdges(snapshot.edges)
  setIsDirty(true)
}, [setNodes, setEdges])

const canUndo = historyIndex.current > 0
const canRedo = historyIndex.current < historyStack.current.length - 1
```

- [ ] **Step 2: Push history before every mutation**

Find every place in `DiagramEditor.tsx` that calls `setNodes(...)` or `setEdges(...)` in response to a user action (not load/init), and prepend `pushHistory(nodes, edges)` before it. The main locations are:

- `onNodeUpdate` (node property changes)
- `onEdgeUpdate` (edge property changes)
- `onEdgeTypeChange`
- `onBringToFront`
- `onSendToBack`
- `onDeleteNode`
- `onDeleteEdge`
- `onConnect`
- `handleDrop` (adding new node from palette)
- `handleAIGenerate` (after AI result applied)
- `handleImport`

Example pattern:
```tsx
const onDeleteNode = useCallback((nodeId: string) => {
  pushHistory(nodes, edges)  // ← add this line before every setNodes/setEdges
  setNodes(prev => prev.filter(n => n.id !== nodeId))
  setEdges(prev => prev.filter(e => e.source !== nodeId && e.target !== nodeId))
  if (selectedNodeId === nodeId) setSelectedNodeId(null)
}, [nodes, edges, pushHistory, selectedNodeId])
```

- [ ] **Step 3: Initialize history on diagram load**

In the `useEffect` that loads the diagram data (after `setNodes` and `setEdges` are called with loaded data), reset and push the initial snapshot:

```tsx
historyStack.current = []
historyIndex.current = -1
pushHistory(loadedNodes, loadedEdges)
```

- [ ] **Step 4: Pass undo/redo/canUndo/canRedo down to children**

Add these to the props passed to `DiagramHeader` and later to `useDiagramCommands`:

```tsx
<DiagramHeader
  // ... existing props
  onUndo={undo}
  onRedo={redo}
  canUndo={canUndo}
  canRedo={canRedo}
/>
```

- [ ] **Step 5: Build and verify no TypeScript errors**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output (clean).

- [ ] **Step 6: Commit**

```bash
cd /home/coder/resolutionflow
git add frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
git commit -m "feat(network): add undo/redo snapshot history stack to DiagramEditor"
```

---

## Task 2: Undo/Redo Buttons in DiagramHeader

**Files:**
- Modify: `frontend/src/components/network/DiagramHeader.tsx`

- [ ] **Step 1: Add undo/redo props to DiagramHeader**

Find the props interface in `DiagramHeader.tsx` and add:

```tsx
onUndo: () => void
onRedo: () => void
canUndo: boolean
canRedo: boolean
```

- [ ] **Step 2: Add undo/redo buttons to the header UI**

In the header, find the left/center section (near the back button or title area) and add undo/redo buttons. Import `Undo2, Redo2` from `lucide-react`:

```tsx
import { Undo2, Redo2, /* existing imports */ } from 'lucide-react'
```

Add the buttons in the header bar, grouped together:

```tsx
<div className="flex items-center gap-1">
  <button
    onClick={onUndo}
    disabled={!canUndo}
    title="Undo (Ctrl+Z)"
    className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  >
    <Undo2 size={16} />
  </button>
  <button
    onClick={onRedo}
    disabled={!canRedo}
    title="Redo (Ctrl+Y)"
    className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
  >
    <Redo2 size={16} />
  </button>
</div>
```

- [ ] **Step 3: Build clean**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 4: Commit**

```bash
git add frontend/src/components/network/DiagramHeader.tsx
git commit -m "feat(network): add undo/redo buttons to DiagramHeader"
```

---

## Task 3: Undo/Redo + Nudge Keyboard Shortcuts

**Files:**
- Modify: `frontend/src/components/network/hooks/useCanvasShortcuts.ts`

- [ ] **Step 1: Add undo and redo to the keyboard handler**

In `useCanvasShortcuts.ts`, the hook receives callbacks. Add `onUndo` and `onRedo` to the parameters:

```tsx
interface UseCanvasShortcutsParams {
  // ... existing params
  onUndo: () => void
  onRedo: () => void
  onNudge: (dx: number, dy: number) => void
}
```

- [ ] **Step 2: Wire Ctrl+Z and Ctrl+Y in the keydown handler**

Inside the existing `keydown` event listener (where `isInputFocused` is checked), add:

```tsx
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
  e.preventDefault()
  onUndo()
  return
}
if ((e.key === 'y' && (e.ctrlKey || e.metaKey)) || (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey)) {
  e.preventDefault()
  onRedo()
  return
}
```

- [ ] **Step 3: Add arrow key nudging**

Arrow keys move selected nodes by 1px (plain) or 10px (Shift held). These should fire even when no input is focused — but only if nodes are selected. Add inside the keydown handler, after the input-focus guard:

```tsx
const NUDGE_SMALL = 1
const NUDGE_LARGE = 10

if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
  // Only nudge if we have selected nodes (not when input focused)
  if (isInputFocused) return
  e.preventDefault()
  const delta = e.shiftKey ? NUDGE_LARGE : NUDGE_SMALL
  switch (e.key) {
    case 'ArrowUp':    onNudge(0, -delta); break
    case 'ArrowDown':  onNudge(0,  delta); break
    case 'ArrowLeft':  onNudge(-delta, 0); break
    case 'ArrowRight': onNudge( delta, 0); break
  }
  return
}
```

- [ ] **Step 4: Implement onNudge in DiagramEditor**

In `DiagramEditor.tsx`, create and pass the nudge callback:

```tsx
const onNudge = useCallback((dx: number, dy: number) => {
  const selected = nodes.filter(n => n.selected)
  if (selected.length === 0) return
  pushHistory(nodes, edges)
  setNodes(prev => prev.map(n =>
    n.selected
      ? { ...n, position: { x: n.position.x + dx, y: n.position.y + dy } }
      : n
  ))
}, [nodes, edges, pushHistory])
```

Pass `onUndo={undo}`, `onRedo={redo}`, `onNudge={onNudge}` to `useCanvasShortcuts`.

- [ ] **Step 5: Build clean**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 6: Commit**

```bash
git add frontend/src/components/network/hooks/useCanvasShortcuts.ts \
        frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
git commit -m "feat(network): add undo/redo shortcuts (Ctrl+Z/Y) and arrow key nudging"
```

---

## Task 4: useDiagramCommands — Alignment & Distribution Command Layer

**Files:**
- Create: `frontend/src/components/network/hooks/useDiagramCommands.ts`

This is the central command layer. All alignment and distribution logic lives here and is called by context menu, keyboard, and toolbar buttons.

- [ ] **Step 1: Create the hook file with alignment commands**

Create `frontend/src/components/network/hooks/useDiagramCommands.ts`:

```tsx
import { useCallback } from 'react'
import { Node } from '@xyflow/react'

interface UseDiagramCommandsParams {
  nodes: Node[]
  edges: any[]
  pushHistory: (nodes: Node[], edges: any[]) => void
  setNodes: React.Dispatch<React.SetStateAction<Node[]>>
}

export function useDiagramCommands({
  nodes,
  edges,
  pushHistory,
  setNodes,
}: UseDiagramCommandsParams) {
  const selectedNodes = nodes.filter(n => n.selected)

  // ── Alignment ──────────────────────────────────────────────────────────
  const alignLeft = useCallback(() => {
    if (selectedNodes.length < 2) return
    pushHistory(nodes, edges)
    const minX = Math.min(...selectedNodes.map(n => n.position.x))
    setNodes(prev => prev.map(n =>
      n.selected ? { ...n, position: { ...n.position, x: minX } } : n
    ))
  }, [nodes, edges, selectedNodes, pushHistory, setNodes])

  const alignRight = useCallback(() => {
    if (selectedNodes.length < 2) return
    pushHistory(nodes, edges)
    const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100)))
    setNodes(prev => prev.map(n =>
      n.selected ? { ...n, position: { ...n.position, x: maxX - (n.measured?.width ?? 100) } } : n
    ))
  }, [nodes, edges, selectedNodes, pushHistory, setNodes])

  const alignCenterH = useCallback(() => {
    if (selectedNodes.length < 2) return
    pushHistory(nodes, edges)
    const minX = Math.min(...selectedNodes.map(n => n.position.x))
    const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100)))
    const centerX = (minX + maxX) / 2
    setNodes(prev => prev.map(n =>
      n.selected ? { ...n, position: { ...n.position, x: centerX - (n.measured?.width ?? 100) / 2 } } : n
    ))
  }, [nodes, edges, selectedNodes, pushHistory, setNodes])

  const alignTop = useCallback(() => {
    if (selectedNodes.length < 2) return
    pushHistory(nodes, edges)
    const minY = Math.min(...selectedNodes.map(n => n.position.y))
    setNodes(prev => prev.map(n =>
      n.selected ? { ...n, position: { ...n.position, y: minY } } : n
    ))
  }, [nodes, edges, selectedNodes, pushHistory, setNodes])

  const alignBottom = useCallback(() => {
    if (selectedNodes.length < 2) return
    pushHistory(nodes, edges)
    const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100)))
    setNodes(prev => prev.map(n =>
      n.selected ? { ...n, position: { ...n.position, y: maxY - (n.measured?.height ?? 100) } } : n
    ))
  }, [nodes, edges, selectedNodes, pushHistory, setNodes])

  const alignCenterV = useCallback(() => {
    if (selectedNodes.length < 2) return
    pushHistory(nodes, edges)
    const minY = Math.min(...selectedNodes.map(n => n.position.y))
    const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100)))
    const centerY = (minY + maxY) / 2
    setNodes(prev => prev.map(n =>
      n.selected ? { ...n, position: { ...n.position, y: centerY - (n.measured?.height ?? 100) / 2 } } : n
    ))
  }, [nodes, edges, selectedNodes, pushHistory, setNodes])

  // ── Distribution ───────────────────────────────────────────────────────
  const distributeHorizontally = useCallback(() => {
    if (selectedNodes.length < 3) return
    pushHistory(nodes, edges)
    const sorted = [...selectedNodes].sort((a, b) => a.position.x - b.position.x)
    const minX = sorted[0].position.x
    const maxX = sorted[sorted.length - 1].position.x + (sorted[sorted.length - 1].measured?.width ?? 100)
    const totalWidth = sorted.reduce((sum, n) => sum + (n.measured?.width ?? 100), 0)
    const gap = (maxX - minX - totalWidth) / (sorted.length - 1)
    let cursor = minX
    const positions: Record<string, number> = {}
    for (const n of sorted) {
      positions[n.id] = cursor
      cursor += (n.measured?.width ?? 100) + gap
    }
    setNodes(prev => prev.map(n =>
      n.selected && positions[n.id] !== undefined
        ? { ...n, position: { ...n.position, x: positions[n.id] } }
        : n
    ))
  }, [nodes, edges, selectedNodes, pushHistory, setNodes])

  const distributeVertically = useCallback(() => {
    if (selectedNodes.length < 3) return
    pushHistory(nodes, edges)
    const sorted = [...selectedNodes].sort((a, b) => a.position.y - b.position.y)
    const minY = sorted[0].position.y
    const maxY = sorted[sorted.length - 1].position.y + (sorted[sorted.length - 1].measured?.height ?? 100)
    const totalHeight = sorted.reduce((sum, n) => sum + (n.measured?.height ?? 100), 0)
    const gap = (maxY - minY - totalHeight) / (sorted.length - 1)
    let cursor = minY
    const positions: Record<string, number> = {}
    for (const n of sorted) {
      positions[n.id] = cursor
      cursor += (n.measured?.height ?? 100) + gap
    }
    setNodes(prev => prev.map(n =>
      n.selected && positions[n.id] !== undefined
        ? { ...n, position: { ...n.position, y: positions[n.id] } }
        : n
    ))
  }, [nodes, edges, selectedNodes, pushHistory, setNodes])

  // ── Helpers ────────────────────────────────────────────────────────────
  const canAlign = selectedNodes.length >= 2
  const canDistribute = selectedNodes.length >= 3

  return {
    alignLeft,
    alignRight,
    alignCenterH,
    alignTop,
    alignBottom,
    alignCenterV,
    distributeHorizontally,
    distributeVertically,
    canAlign,
    canDistribute,
    selectedNodes,
  }
}
```

- [ ] **Step 2: Wire useDiagramCommands into DiagramEditor**

In `DiagramEditor.tsx`, import and instantiate the hook:

```tsx
import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands'

// Inside the component:
const diagramCommands = useDiagramCommands({
  nodes,
  edges,
  pushHistory,
  setNodes,
})
```

- [ ] **Step 3: Build clean**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 4: Commit**

```bash
git add frontend/src/components/network/hooks/useDiagramCommands.ts \
        frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
git commit -m "feat(network): add useDiagramCommands — alignment and distribution command layer"
```

---

## Task 5: Alignment Buttons in Context Menu

**Files:**
- Modify: `frontend/src/components/network/ContextMenu.tsx`

- [ ] **Step 1: Add alignment commands to ContextMenu props**

Find the ContextMenu props interface and add:

```tsx
onAlignLeft?: () => void
onAlignRight?: () => void
onAlignCenterH?: () => void
onAlignTop?: () => void
onAlignBottom?: () => void
onAlignCenterV?: () => void
onDistributeH?: () => void
onDistributeV?: () => void
canAlign?: boolean
canDistribute?: boolean
onGroupSelection?: () => void
onUngroupSelection?: () => void
canGroup?: boolean
canUngroup?: boolean
```

- [ ] **Step 2: Add align/distribute section to the node context menu**

Inside the node context menu (the section that shows copy/duplicate/delete), add a new section when `canAlign` is true:

```tsx
{canAlign && (
  <>
    <div className="border-t border-default my-1" />
    <div className="px-2 py-0.5 text-[10px] font-medium text-muted uppercase tracking-wider">Align</div>
    <button onClick={() => { onAlignLeft?.(); onClose() }} className={menuItem}>
      <AlignStartVertical size={13} /> Align Left
    </button>
    <button onClick={() => { onAlignCenterH?.(); onClose() }} className={menuItem}>
      <AlignCenterHorizontal size={13} /> Align Center
    </button>
    <button onClick={() => { onAlignRight?.(); onClose() }} className={menuItem}>
      <AlignEndVertical size={13} /> Align Right
    </button>
    <button onClick={() => { onAlignTop?.(); onClose() }} className={menuItem}>
      <AlignStartHorizontal size={13} /> Align Top
    </button>
    <button onClick={() => { onAlignCenterV?.(); onClose() }} className={menuItem}>
      <AlignCenterVertical size={13} /> Align Middle
    </button>
    <button onClick={() => { onAlignBottom?.(); onClose() }} className={menuItem}>
      <AlignEndHorizontal size={13} /> Align Bottom
    </button>
    {canDistribute && (
      <>
        <div className="border-t border-default my-1" />
        <div className="px-2 py-0.5 text-[10px] font-medium text-muted uppercase tracking-wider">Distribute</div>
        <button onClick={() => { onDistributeH?.(); onClose() }} className={menuItem}>
          <AlignHorizontalSpaceAround size={13} /> Space Horizontally
        </button>
        <button onClick={() => { onDistributeV?.(); onClose() }} className={menuItem}>
          <AlignVerticalSpaceAround size={13} /> Space Vertically
        </button>
      </>
    )}
  </>
)}
{(canGroup || canUngroup) && (
  <>
    <div className="border-t border-default my-1" />
    {canGroup && (
      <button onClick={() => { onGroupSelection?.(); onClose() }} className={menuItem}>
        <BoxSelect size={13} /> Group Selection
      </button>
    )}
    {canUngroup && (
      <button onClick={() => { onUngroupSelection?.(); onClose() }} className={menuItem}>
        <Ungroup size={13} /> Ungroup
      </button>
    )}
  </>
)}
```

Add these imports from `lucide-react`:
```tsx
import {
  AlignStartVertical, AlignCenterHorizontal, AlignEndVertical,
  AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal,
  AlignHorizontalSpaceAround, AlignVerticalSpaceAround,
  BoxSelect, Ungroup,
  // ... existing imports
} from 'lucide-react'
```

- [ ] **Step 3: Pass diagramCommands props to ContextMenu in DiagramEditor**

In `DiagramEditor.tsx`, wire the context menu:

```tsx
<ContextMenu
  // ... existing props
  onAlignLeft={diagramCommands.alignLeft}
  onAlignRight={diagramCommands.alignRight}
  onAlignCenterH={diagramCommands.alignCenterH}
  onAlignTop={diagramCommands.alignTop}
  onAlignBottom={diagramCommands.alignBottom}
  onAlignCenterV={diagramCommands.alignCenterV}
  onDistributeH={diagramCommands.distributeHorizontally}
  onDistributeV={diagramCommands.distributeVertically}
  canAlign={diagramCommands.canAlign}
  canDistribute={diagramCommands.canDistribute}
/>
```

- [ ] **Step 4: Build clean**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 5: Commit**

```bash
git add frontend/src/components/network/ContextMenu.tsx \
        frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
git commit -m "feat(network): add align/distribute/group sections to context menu"
```

---

## Task 6: Alignment Buttons in PropertiesPanel (Multi-Select)

**Files:**
- Modify: `frontend/src/components/network/panels/PropertiesPanel.tsx`

When multiple nodes are selected (no single node to inspect), show an alignment toolbar instead of the node properties form.

- [ ] **Step 1: Add multi-select props to PropertiesPanel**

Add to the PropertiesPanel props interface:

```tsx
selectedNodeCount: number
onAlignLeft: () => void
onAlignRight: () => void
onAlignCenterH: () => void
onAlignTop: () => void
onAlignBottom: () => void
onAlignCenterV: () => void
onDistributeH: () => void
onDistributeV: () => void
canAlign: boolean
canDistribute: boolean
```

- [ ] **Step 2: Add a multi-select view**

At the top of the PropertiesPanel render, before the existing single-node/edge views, add:

```tsx
if (!selectedNodeId && !selectedEdgeId && selectedNodeCount >= 2) {
  return (
    <div className="w-[260px] border-l border-default bg-sidebar flex flex-col">
      <div className="px-4 py-3 border-b border-default">
        <div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
          {selectedNodeCount} nodes selected
        </div>
      </div>
      <div className="p-3 flex flex-col gap-4">
        <div>
          <div className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">Align</div>
          <div className="grid grid-cols-3 gap-1">
            {[
              { label: 'Left', icon: AlignStartVertical, action: onAlignLeft },
              { label: 'Center', icon: AlignCenterHorizontal, action: onAlignCenterH },
              { label: 'Right', icon: AlignEndVertical, action: onAlignRight },
              { label: 'Top', icon: AlignStartHorizontal, action: onAlignTop },
              { label: 'Middle', icon: AlignCenterVertical, action: onAlignCenterV },
              { label: 'Bottom', icon: AlignEndHorizontal, action: onAlignBottom },
            ].map(({ label, icon: Icon, action }) => (
              <button
                key={label}
                onClick={action}
                title={`Align ${label}`}
                className="flex flex-col items-center gap-1 p-2 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary"
              >
                <Icon size={14} />
                <span className="text-[9px]">{label}</span>
              </button>
            ))}
          </div>
        </div>
        {canDistribute && (
          <div>
            <div className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">Distribute</div>
            <div className="grid grid-cols-2 gap-1">
              <button
                onClick={onDistributeH}
                className="flex items-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
              >
                <AlignHorizontalSpaceAround size={13} /> Horizontal
              </button>
              <button
                onClick={onDistributeV}
                className="flex items-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
              >
                <AlignVerticalSpaceAround size={13} /> Vertical
              </button>
            </div>
          </div>
        )}
      </div>
    </div>
  )
}
```

Add the same Lucide imports as Task 5.

- [ ] **Step 3: Track multi-select count in DiagramEditor**

In `DiagramEditor.tsx`, compute selected node count and pass it to PropertiesPanel:

```tsx
const selectedNodeCount = nodes.filter(n => n.selected).length

<PropertiesPanel
  // ... existing props
  selectedNodeCount={selectedNodeCount}
  onAlignLeft={diagramCommands.alignLeft}
  onAlignRight={diagramCommands.alignRight}
  onAlignCenterH={diagramCommands.alignCenterH}
  onAlignTop={diagramCommands.alignTop}
  onAlignBottom={diagramCommands.alignBottom}
  onAlignCenterV={diagramCommands.alignCenterV}
  onDistributeH={diagramCommands.distributeHorizontally}
  onDistributeV={diagramCommands.distributeVertically}
  canAlign={diagramCommands.canAlign}
  canDistribute={diagramCommands.canDistribute}
/>
```

- [ ] **Step 4: Build clean**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 5: Commit**

```bash
git add frontend/src/components/network/panels/PropertiesPanel.tsx \
        frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
git commit -m "feat(network): add alignment toolbar to PropertiesPanel for multi-select"
```

---

## Task 7: GroupNode Component

**Files:**
- Create: `frontend/src/components/network/nodes/GroupNode.tsx`
- Modify: `frontend/src/components/network/nodes/nodeTypes.ts`
- Modify: `frontend/src/types/network-diagram.ts`

- [ ] **Step 1: Add GroupNodeData type**

In `frontend/src/types/network-diagram.ts`, add:

```ts
export interface GroupNodeData {
  label: string
  groupType: 'subnet' | 'vlan' | 'site' | 'dmz' | 'custom'
  [key: string]: unknown
}
```

- [ ] **Step 2: Create GroupNode.tsx**

Create `frontend/src/components/network/nodes/GroupNode.tsx`:

```tsx
import { memo, useState, useRef, useEffect } from 'react'
import { NodeProps, NodeResizer } from '@xyflow/react'
import { GroupNodeData } from '@/types/network-diagram'

const GROUP_COLORS: Record<string, string> = {
  subnet: '#60a5fa',  // blue
  vlan:   '#a78bfa',  // violet
  site:   '#34d399',  // green
  dmz:    '#f87171',  // red
  custom: '#94a3b8',  // slate
}

const GroupNode = memo(({ data, selected, id }: NodeProps) => {
  const groupData = data as GroupNodeData
  const color = GROUP_COLORS[groupData.groupType] ?? GROUP_COLORS.custom
  const [editing, setEditing] = useState(false)
  const [labelValue, setLabelValue] = useState(groupData.label ?? '')
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    if (editing) inputRef.current?.focus()
  }, [editing])

  const handleLabelCommit = () => {
    setEditing(false)
    // Label changes propagate via React Flow's onNodesChange — data update
    // handled by parent via onNodeUpdate
  }

  return (
    <>
      <NodeResizer
        isVisible={selected}
        minWidth={120}
        minHeight={80}
        lineStyle={{ border: `1px solid ${color}` }}
        handleStyle={{ width: 8, height: 8, borderRadius: 2, background: color }}
      />
      <div
        className="w-full h-full rounded-lg relative"
        style={{
          border: `1.5px dashed ${color}`,
          background: `${color}0d`,  // 5% opacity
          boxSizing: 'border-box',
        }}
      >
        {/* Label at top-left */}
        <div className="absolute top-0 left-0 -translate-y-full pb-0.5 pl-1">
          {editing ? (
            <input
              ref={inputRef}
              value={labelValue}
              onChange={e => setLabelValue(e.target.value)}
              onBlur={handleLabelCommit}
              onKeyDown={e => {
                if (e.key === 'Enter' || e.key === 'Escape') handleLabelCommit()
                e.stopPropagation()
              }}
              className="text-[11px] font-medium bg-transparent border-none outline-none text-primary min-w-[40px] max-w-[200px]"
              style={{ color }}
            />
          ) : (
            <span
              className="text-[11px] font-medium cursor-text select-none"
              style={{ color }}
              onDoubleClick={() => setEditing(true)}
            >
              {labelValue || groupData.groupType}
            </span>
          )}
        </div>
      </div>
    </>
  )
})

GroupNode.displayName = 'GroupNode'
export default GroupNode
```

- [ ] **Step 3: Register GroupNode in nodeTypes.ts**

Open `frontend/src/components/network/nodes/nodeTypes.ts` and add:

```tsx
import GroupNode from './GroupNode'

export const nodeTypes = {
  device: DeviceNode,
  group: GroupNode,   // ← add this
}
```

- [ ] **Step 4: Build clean**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 5: Commit**

```bash
git add frontend/src/components/network/nodes/GroupNode.tsx \
        frontend/src/components/network/nodes/nodeTypes.ts \
        frontend/src/types/network-diagram.ts
git commit -m "feat(network): add GroupNode component with resize, inline label, and group type colors"
```

---

## Task 8: Group/Ungroup Commands

**Files:**
- Modify: `frontend/src/components/network/hooks/useDiagramCommands.ts`

- [ ] **Step 1: Add groupSelection and ungroupSelection to useDiagramCommands**

Append to the hook (before the return statement):

```tsx
const groupSelection = useCallback(() => {
  if (selectedNodes.length < 2) return
  pushHistory(nodes, edges)

  // Compute bounding box of selected nodes with padding
  const PADDING = 24
  const minX = Math.min(...selectedNodes.map(n => n.position.x)) - PADDING
  const minY = Math.min(...selectedNodes.map(n => n.position.y)) - PADDING
  const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100))) + PADDING
  const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100))) + PADDING

  const groupId = `group-${Date.now()}`

  const groupNode: Node = {
    id: groupId,
    type: 'group',
    position: { x: minX, y: minY },
    style: { width: maxX - minX, height: maxY - minY },
    data: { label: 'Group', groupType: 'custom' },
    selected: false,
  }

  // Re-position selected nodes relative to group origin
  setNodes(prev => [
    groupNode,
    ...prev.map(n =>
      n.selected
        ? {
            ...n,
            parentId: groupId,
            extent: 'parent' as const,
            position: { x: n.position.x - minX, y: n.position.y - minY },
            selected: false,
          }
        : n
    ),
  ])
}, [nodes, edges, selectedNodes, pushHistory, setNodes])

const ungroupSelection = useCallback(() => {
  // Find selected group nodes
  const selectedGroups = selectedNodes.filter(n => n.type === 'group')
  if (selectedGroups.length === 0) return
  pushHistory(nodes, edges)

  const groupIds = new Set(selectedGroups.map(g => g.id))

  setNodes(prev => {
    const groupPositions = Object.fromEntries(
      prev.filter(n => groupIds.has(n.id)).map(n => [n.id, n.position])
    )
    return prev
      .filter(n => !groupIds.has(n.id))
      .map(n => {
        if (n.parentId && groupIds.has(n.parentId)) {
          const groupPos = groupPositions[n.parentId] ?? { x: 0, y: 0 }
          return {
            ...n,
            parentId: undefined,
            extent: undefined,
            position: {
              x: groupPos.x + n.position.x,
              y: groupPos.y + n.position.y,
            },
          }
        }
        return n
      })
  })
}, [nodes, edges, selectedNodes, pushHistory, setNodes])

const canGroup = selectedNodes.length >= 2 && !selectedNodes.some(n => n.type === 'group')
const canUngroup = selectedNodes.some(n => n.type === 'group')
```

Update the return to include:
```tsx
return {
  // ... existing
  groupSelection,
  ungroupSelection,
  canGroup,
  canUngroup,
}
```

- [ ] **Step 2: Pass group commands to ContextMenu in DiagramEditor**

```tsx
<ContextMenu
  // ... existing
  onGroupSelection={diagramCommands.groupSelection}
  onUngroupSelection={diagramCommands.ungroupSelection}
  canGroup={diagramCommands.canGroup}
  canUngroup={diagramCommands.canUngroup}
/>
```

- [ ] **Step 3: Build clean**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 4: Commit**

```bash
git add frontend/src/components/network/hooks/useDiagramCommands.ts \
        frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
git commit -m "feat(network): add group/ungroup commands with bounding box calculation"
```

---

## Task 9: Orthogonal Edge Routing Option

**Files:**
- Modify: `frontend/src/components/network/edges/ConnectionEdge.tsx`
- Modify: `frontend/src/components/network/panels/PropertiesPanel.tsx`
- Modify: `frontend/src/types/network-diagram.ts`

The existing routing options are `null` (straight), `'curved'` (Bezier), `'step'` (SmoothStep). We add `'orthogonal'` as a true right-angle routing option using React Flow's `SmoothStepEdge` with `borderRadius: 0`.

- [ ] **Step 1: Update routing type in network-diagram.ts**

In `DiagramEdge`, change:
```ts
routing?: string | null
```
to:
```ts
routing?: 'curved' | 'step' | 'orthogonal' | null
```

- [ ] **Step 2: Add orthogonal path to ConnectionEdge**

In `ConnectionEdge.tsx`, import `SmoothStepEdge` and handle the new routing value. Find where routing determines path type and add:

```tsx
import {
  getStraightPath,
  getBezierPath,
  getSmoothStepPath,
  EdgeLabelRenderer,
  BaseEdge,
} from '@xyflow/react'

// In the component, where routing is checked:
let pathData = ''
let labelX = 0
let labelY = 0

if (routing === 'curved') {
  ;[pathData, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition })
} else if (routing === 'step') {
  ;[pathData, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 8 })
} else if (routing === 'orthogonal') {
  ;[pathData, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 0 })
} else {
  ;[pathData, labelX, labelY] = getStraightPath({ sourceX, sourceY, targetX, targetY })
}
```

- [ ] **Step 3: Add orthogonal button to PropertiesPanel edge routing**

In `PropertiesPanel.tsx`, find the three routing style buttons (Minus/Spline/GitBranch) and add a fourth for orthogonal:

```tsx
import { Minus, Spline, GitBranch, CornerUpRight } from 'lucide-react'

// In the routing buttons row, add:
<button
  onClick={() => onEdgeUpdate(selectedEdgeId, { routing: 'orthogonal' })}
  title="Orthogonal"
  className={cn(routingBtn, edge.data?.routing === 'orthogonal' && routingBtnActive)}
>
  <CornerUpRight size={14} />
</button>
```

- [ ] **Step 4: Build clean**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 5: Commit**

```bash
git add frontend/src/components/network/edges/ConnectionEdge.tsx \
        frontend/src/components/network/panels/PropertiesPanel.tsx \
        frontend/src/types/network-diagram.ts
git commit -m "feat(network): add orthogonal edge routing option"
```

---

## Task 10: Inline Label Editing on DeviceNode

**Files:**
- Modify: `frontend/src/components/network/nodes/DeviceNode.tsx`

Double-clicking a node's label enters inline edit mode. On blur or Enter, the new label is committed via React Flow's node data update mechanism.

- [ ] **Step 1: Add inline editing state to DeviceNode**

In `DeviceNode.tsx`, add:

```tsx
import { memo, useState, useRef, useEffect } from 'react'

// Inside the component:
const [editing, setEditing] = useState(false)
const [labelValue, setLabelValue] = useState(data.label ?? '')
const inputRef = useRef<HTMLInputElement>(null)

useEffect(() => {
  if (editing) {
    inputRef.current?.focus()
    inputRef.current?.select()
  }
}, [editing])

// Keep local state in sync if data.label changes externally
useEffect(() => {
  if (!editing) setLabelValue(data.label ?? '')
}, [data.label, editing])
```

- [ ] **Step 2: Replace the label span with conditional edit/display**

Find the label `<div>` or `<span>` in the node render and replace it:

```tsx
{editing ? (
  <input
    ref={inputRef}
    value={labelValue}
    onChange={e => setLabelValue(e.target.value)}
    onBlur={() => {
      setEditing(false)
      // Emit update via React Flow's updateNodeData if available,
      // or store in a ref for parent to pick up via onNodesChange
      if (labelValue !== data.label) {
        // Use the updateNodeData callback passed from React Flow
        // This is handled via the onNodeUpdate prop chain in DiagramEditor
      }
    }}
    onKeyDown={e => {
      if (e.key === 'Enter') inputRef.current?.blur()
      if (e.key === 'Escape') {
        setLabelValue(data.label ?? '')
        setEditing(false)
      }
      e.stopPropagation()
    }}
    style={{ fontSize: labelFontSize, width: '80%' }}
    className="bg-transparent border-none outline-none text-center text-primary font-medium"
  />
) : (
  <span
    style={{ fontSize: labelFontSize }}
    className="font-medium text-primary text-center leading-tight line-clamp-2 cursor-default"
    onDoubleClick={() => setEditing(true)}
  >
    {labelValue}
  </span>
)}
```

- [ ] **Step 3: Wire label commit through useUpdateNodeInternals or onNodesChange**

In `DiagramEditor.tsx`, update the `onNodeUpdate` handler to accept a label update triggered by inline edit. The cleanest pattern is to use React Flow's `useReactFlow().updateNodeData`:

In `NetworkCanvas.tsx`, pass the `useReactFlow` hook's `updateNodeData` down, or handle it inside `DeviceNode` itself using `useReactFlow`:

```tsx
// In DeviceNode.tsx, import useReactFlow
import { useReactFlow } from '@xyflow/react'

// Inside component:
const { updateNodeData } = useReactFlow()

// In onBlur:
onBlur={() => {
  setEditing(false)
  if (labelValue !== data.label) {
    updateNodeData(id, { ...data, label: labelValue })
  }
}}
```

This keeps DiagramEditor's `onNodeUpdate` callback for external changes while inline edits go through React Flow's own data update mechanism.

- [ ] **Step 4: Build clean**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 5: Commit**

```bash
git add frontend/src/components/network/nodes/DeviceNode.tsx
git commit -m "feat(network): add inline label editing on DeviceNode (double-click)"
```

---

## Task 11: Z-Order Normalization

**Files:**
- Modify: `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx`

The current bringToFront/sendToBack uses `Math.max`/`Math.min` increments that can overflow. This task normalizes z-order to always be 1..N after every operation.

- [ ] **Step 1: Add normalizeZOrder utility**

In `DiagramEditor.tsx`, add a helper near the top of the file (outside the component):

```tsx
function normalizeZOrder(nodes: Node[]): Node[] {
  const sorted = [...nodes].sort((a, b) => ((a.zIndex ?? 0) - (b.zIndex ?? 0)))
  return sorted.map((n, i) => ({ ...n, zIndex: i + 1 }))
}
```

- [ ] **Step 2: Apply normalization in bringToFront and sendToBack**

Find `onBringToFront` and `onSendToBack` in `DiagramEditor.tsx` and update them:

```tsx
const onBringToFront = useCallback((nodeId: string) => {
  pushHistory(nodes, edges)
  const maxZ = Math.max(...nodes.map(n => n.zIndex ?? 0))
  setNodes(prev => normalizeZOrder(
    prev.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n)
  ))
}, [nodes, edges, pushHistory])

const onSendToBack = useCallback((nodeId: string) => {
  pushHistory(nodes, edges)
  setNodes(prev => normalizeZOrder(
    prev.map(n => n.id === nodeId ? { ...n, zIndex: 0 } : n)
  ))
}, [nodes, edges, pushHistory])
```

- [ ] **Step 3: Build clean**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 4: Commit**

```bash
git add frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
git commit -m "fix(network): normalize z-order to 1..N after bring-to-front/send-to-back"
```

---

## Task 12: Push and Verify

- [ ] **Step 1: Final build check**

```bash
cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1
```

Expected: no output.

- [ ] **Step 2: Push to remote**

```bash
cd /home/coder/resolutionflow && git push origin feat/network-diagrams
```

- [ ] **Step 3: Confirm PR #139 is updated**

```bash
gh pr view 139
```

The PR should show all commits from Tasks 1–11.

---

## Self-Review Against Phase 2 Spec

Checking against `docs/superpowers/plans/2026-04-13-network-diagram-drawio-style-implementation.md` Phase 2 scope:

| Requirement | Task | Covered? |
|-------------|------|----------|
| Snap-to-guides (in addition to snap-to-grid) | — | ❌ Not in this plan — React Flow doesn't expose guide-snapping natively; deferred to Phase 2.5 |
| Alignment commands | Tasks 4, 5, 6 | ✅ |
| Distribution commands | Tasks 4, 5, 6 | ✅ |
| Multi-select improvements | Tasks 4, 6 | ✅ |
| Better z-order handling | Task 11 | ✅ |
| Inline text editing | Task 10 | ✅ |
| Better group/container behavior | Tasks 7, 8 | ✅ |
| Rich edge routing choices | Task 9 | ✅ (straight/curved/step/orthogonal) |
| Manual bend points | — | ❌ Deferred — requires custom edge with draggable waypoints, significant scope |
| Port-aware connection handling | — | ❌ Deferred — DeviceNode already has 4 handles; advanced port config is Phase 3 |
| Keyboard nudging | Task 3 | ✅ |
| Undo/redo | Tasks 1, 2, 3 | ✅ |

**Deferred items** (snap-to-guides, manual bend points, port-aware connections) are noted above. They are not gaps — they are intentionally scoped out of this plan as they each require significant standalone implementations. They should be planned as Phase 2.5 or Phase 3 items.
