# Flow Editor React Flow Migration — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Replace the hand-built CSS flexbox tree canvas with @xyflow/react for zoom/pan, dagre auto-layout, collapsible minimap, and side-panel node editing.

**Architecture:** The Zustand store (`treeEditorStore`) remains the single source of truth — no store changes. A `useTreeLayout` hook derives flat `Node[]`/`Edge[]` arrays from the recursive `treeStructure` and positions them with dagre. Custom React Flow node components render compact cards. A right-side `NodeEditorPanel` reuses existing form components (`NodeFormDecision`, `NodeFormAction`, `NodeFormResolution`) for editing.

**Tech Stack:** @xyflow/react, @dagrejs/dagre, React 19, TypeScript, Tailwind CSS, Zustand

**Design Doc:** `docs/plans/2026-02-18-flow-editor-react-flow-design.md`

---

## Phase 1: Dependencies + Dagre Layout Utility

### Task 1: Install dependencies

**Step 1: Install packages**

Run:
```bash
cd frontend && npm install @xyflow/react @dagrejs/dagre
```

`@xyflow/react` includes its own types. `@dagrejs/dagre` includes types via `@types/dagre` bundled.

**Step 2: Verify build still passes**

Run: `cd frontend && npm run build`
Expected: Clean build, no errors.

**Step 3: Commit**

```bash
git add frontend/package.json frontend/package-lock.json
git commit -m "chore: install @xyflow/react and @dagrejs/dagre"
```

---

### Task 2: Create dagre layout utility

**Files:**
- Create: `frontend/src/lib/dagreLayout.ts`

This is a pure function with no React dependencies — it takes nodes and edges and returns positioned nodes.

**Step 1: Create the layout utility**

```typescript
// frontend/src/lib/dagreLayout.ts
import dagre from '@dagrejs/dagre'
import type { Node, Edge } from '@xyflow/react'

const NODE_WIDTH = 280
const DEFAULT_NODE_HEIGHT = 100

interface LayoutOptions {
  direction?: 'TB' | 'LR'
  rankSep?: number
  nodeSep?: number
}

export function getLayoutedElements(
  nodes: Node[],
  edges: Edge[],
  options: LayoutOptions = {}
): Node[] {
  const { direction = 'TB', rankSep = 100, nodeSep = 40 } = options

  const g = new dagre.graphlib.Graph()
  g.setDefaultEdgeLabel(() => ({}))
  g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep })

  nodes.forEach((node) => {
    const height = node.measured?.height ?? node.data?.estimatedHeight ?? DEFAULT_NODE_HEIGHT
    g.setNode(node.id, { width: NODE_WIDTH, height })
  })

  edges.forEach((edge) => {
    g.setEdge(edge.source, edge.target)
  })

  dagre.layout(g)

  return nodes.map((node) => {
    const dagreNode = g.node(node.id)
    // dagre gives center positions — convert to top-left for React Flow
    const x = dagreNode.x - NODE_WIDTH / 2
    const y = dagreNode.y - (dagreNode.height ?? DEFAULT_NODE_HEIGHT) / 2
    return { ...node, position: { x, y } }
  })
}

export { NODE_WIDTH, DEFAULT_NODE_HEIGHT }
```

**Step 2: Verify build**

Run: `cd frontend && npm run build`
Expected: Clean build.

**Step 3: Commit**

```bash
git add frontend/src/lib/dagreLayout.ts
git commit -m "feat: add dagre layout utility for React Flow node positioning"
```

---

## Phase 2: Custom Node Components

### Task 3: Create FlowCanvasNode (compact card for decision/action/solution)

**Files:**
- Create: `frontend/src/components/tree-editor/FlowCanvasNode.tsx`

**Context:** This is a React Flow custom node component. It receives props via `data` from React Flow's node system. It renders a compact, non-editable card. Clicking the card body selects the node (handled by React Flow's `onNodeClick`). The collapse chevron at the bottom is a separate click target.

**Step 1: Create the component**

```tsx
// frontend/src/components/tree-editor/FlowCanvasNode.tsx
import { memo } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TreeStructure, NodeType } from '@/types'

const NODE_TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, {
  icon: typeof HelpCircle
  label: string
  borderClass: string
  badgeClass: string
  minimapColor: string
}> = {
  decision: {
    icon: HelpCircle,
    label: 'Decision',
    borderClass: 'border-l-4 border-l-blue-500',
    badgeClass: 'bg-blue-500/20 text-blue-400',
    minimapColor: '#3b82f6',
  },
  action: {
    icon: Zap,
    label: 'Action',
    borderClass: 'border-l-4 border-l-yellow-500',
    badgeClass: 'bg-yellow-500/20 text-yellow-400',
    minimapColor: '#eab308',
  },
  solution: {
    icon: CheckCircle,
    label: 'Solution',
    borderClass: 'border-l-4 border-l-green-500',
    badgeClass: 'bg-green-500/20 text-green-400',
    minimapColor: '#22c55e',
  },
}

export interface FlowCanvasNodeData {
  node: TreeStructure
  hasChildren: boolean
  isCollapsed: boolean
  hasValidationErrors: boolean
  isNew: boolean
  onToggleCollapse: (nodeId: string) => void
}

function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
  const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as FlowCanvasNodeData
  const nodeType = node.type as Exclude<NodeType, 'answer'>
  const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
  const Icon = config.icon

  const title = node.type === 'decision'
    ? (node.question || 'Untitled Decision')
    : (node.title || `Untitled ${config.label}`)

  const optionCount = node.options?.length ?? 0

  return (
    <>
      {/* Target handle at top */}
      <Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />

      <div
        className={cn(
          'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
          config.borderClass,
          selected && 'ring-1 ring-primary shadow-md'
        )}
      >
        {/* Header */}
        <div className="flex items-center gap-2 px-3 py-2.5">
          {/* Type badge */}
          <span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
            <Icon className="h-3 w-3" />
          </span>

          {/* Title */}
          <span className="flex-1 truncate text-sm font-heading font-medium text-foreground">
            {title}
          </span>

          {/* Badges */}
          {isNew && (
            <span className="rounded-full bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-label text-yellow-400">
              New
            </span>
          )}
          {hasValidationErrors && (
            <AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
          )}
        </div>

        {/* Decision options preview */}
        {node.type === 'decision' && optionCount > 0 && (
          <div className="border-t border-border px-3 py-1.5">
            <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
              <span className="font-label">{optionCount} option{optionCount !== 1 ? 's' : ''}</span>
            </div>
            <div className="mt-1 space-y-0.5">
              {node.options!.slice(0, 3).map((opt, i) => (
                <div key={opt.id} className="truncate text-xs text-muted-foreground">
                  <span className="font-label text-foreground/60">{String.fromCharCode(65 + i)}</span>{' '}
                  {opt.label || '(empty)'}
                </div>
              ))}
              {optionCount > 3 && (
                <div className="text-xs text-muted-foreground">+{optionCount - 3} more</div>
              )}
            </div>
          </div>
        )}

        {/* Description preview for action/solution */}
        {(node.type === 'action' || node.type === 'solution') && node.description && (
          <div className="border-t border-border px-3 py-1.5">
            <div className="line-clamp-2 text-xs text-muted-foreground">{node.description}</div>
          </div>
        )}

        {/* Collapse chevron */}
        {hasChildren && (
          <div className="flex justify-center border-t border-border py-1">
            <button
              type="button"
              onClick={(e) => {
                e.stopPropagation()
                onToggleCollapse(node.id)
              }}
              className="flex items-center gap-1 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
            >
              {isCollapsed ? (
                <>
                  <ChevronRight className="h-3 w-3" />
                  <span className="font-label">Expand</span>
                </>
              ) : (
                <>
                  <ChevronDown className="h-3 w-3" />
                  <span className="font-label">Collapse</span>
                </>
              )}
            </button>
          </div>
        )}
      </div>

      {/* Source handle at bottom */}
      <Handle type="source" position={Position.Bottom} className="!bg-border !w-2 !h-2 !border-0" />
    </>
  )
}

export const FlowCanvasNode = memo(FlowCanvasNodeComponent)
export { NODE_TYPE_CONFIG }
```

**Step 2: Verify build**

Run: `cd frontend && npm run build`
Expected: Clean build.

**Step 3: Commit**

```bash
git add frontend/src/components/tree-editor/FlowCanvasNode.tsx
git commit -m "feat: add FlowCanvasNode compact card for React Flow canvas"
```

---

### Task 4: Create FlowCanvasAnswerNode (answer stub)

**Files:**
- Create: `frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx`

**Context:** This is the React Flow custom node for `type === 'answer'` nodes. It shows the stub label and a "Choose Type" prompt. Clicking opens a type picker inline. Selecting a type calls `onSelectType` which updates the node in the store and opens the editor panel.

**Step 1: Create the component**

```tsx
// frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx
import { memo, useState } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { HelpCircle, Zap, CheckCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TreeStructure } from '@/types'

export interface FlowCanvasAnswerNodeData {
  node: TreeStructure
  onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}

function FlowCanvasAnswerNodeComponent({ data, selected }: NodeProps) {
  const { node, onSelectType } = data as FlowCanvasAnswerNodeData
  const [picking, setPicking] = useState(false)
  const label = node.title || 'Answer'

  return (
    <>
      <Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />

      <div
        className={cn(
          'w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50 transition-all',
          !picking && 'cursor-pointer hover:border-primary/40 hover:bg-accent/30',
          selected && 'ring-1 ring-primary'
        )}
        onClick={() => !picking && setPicking(true)}
      >
        <div className="px-3 pt-2.5 pb-1 text-sm font-heading font-medium text-foreground text-center">
          {label}
        </div>

        {!picking ? (
          <div className="pb-2.5 text-center text-[10px] text-muted-foreground font-label">
            + Choose Type
          </div>
        ) : (
          <div className="flex items-center justify-center gap-1.5 pb-2.5 px-2">
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'decision') }}
              className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20"
            >
              <HelpCircle className="h-2.5 w-2.5" /> Decision
            </button>
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'action') }}
              className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20"
            >
              <Zap className="h-2.5 w-2.5" /> Action
            </button>
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'solution') }}
              className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20"
            >
              <CheckCircle className="h-2.5 w-2.5" /> Solution
            </button>
          </div>
        )}
      </div>

      <Handle type="source" position={Position.Bottom} className="!bg-border !w-2 !h-2 !border-0" />
    </>
  )
}

export const FlowCanvasAnswerNode = memo(FlowCanvasAnswerNodeComponent)
```

**Step 2: Verify build**

Run: `cd frontend && npm run build`
Expected: Clean build.

**Step 3: Commit**

```bash
git add frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx
git commit -m "feat: add FlowCanvasAnswerNode stub card for React Flow canvas"
```

---

## Phase 3: Tree-to-ReactFlow Conversion Hook

### Task 5: Create useTreeLayout hook

**Files:**
- Create: `frontend/src/components/tree-editor/useTreeLayout.ts`

**Context:** This hook converts the Zustand store's recursive `treeStructure` into flat React Flow `Node[]` and `Edge[]` arrays. It runs dagre to compute positions. It tracks collapsed node IDs and skips children of collapsed nodes.

The hook also handles the height-measurement correction: after first render, it checks if any node's actual rendered height differs from the dagre estimate by >10px and re-runs layout if so.

**Step 1: Create the hook**

```typescript
// frontend/src/components/tree-editor/useTreeLayout.ts
import { useMemo, useCallback, useState, useRef, useEffect } from 'react'
import { useReactFlow, type Node, type Edge } from '@xyflow/react'
import type { TreeStructure } from '@/types'
import { getLayoutedElements, NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '@/lib/dagreLayout'
import type { FlowCanvasNodeData } from './FlowCanvasNode'
import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode'
import { useTreeEditorStore } from '@/store/treeEditorStore'

const MAX_EDGE_LABEL_LENGTH = 35

function truncateLabel(label: string): string {
  if (label.length <= MAX_EDGE_LABEL_LENGTH) return label
  return label.slice(0, MAX_EDGE_LABEL_LENGTH).trimEnd() + '…'
}

function estimateNodeHeight(node: TreeStructure): number {
  let height = 52 // header baseline
  if (node.type === 'decision' && node.options) {
    height += 24 // options header line
    height += Math.min(node.options.length, 3) * 18 // option rows (max 3 shown)
    if (node.options.length > 3) height += 18 // "+N more" row
  }
  if ((node.type === 'action' || node.type === 'solution') && node.description) {
    height += 36 // description preview (2 lines)
  }
  if (node.type === 'answer') {
    return 70 // fixed height for answer stubs
  }
  return height
}

interface UseTreeLayoutResult {
  nodes: Node[]
  edges: Edge[]
  collapsedNodeIds: Set<string>
  toggleCollapse: (nodeId: string) => void
  onNodesMeasured: (measuredNodes: Node[]) => void
}

export function useTreeLayout(): UseTreeLayoutResult {
  const treeStructure = useTreeEditorStore(s => s.treeStructure)
  const validationErrors = useTreeEditorStore(s => s.validationErrors)
  const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
  const [measuredHeights, setMeasuredHeights] = useState<Map<string, number>>(new Map())
  const correctionDone = useRef(false)

  const toggleCollapse = useCallback((nodeId: string) => {
    setCollapsedNodeIds(prev => {
      const next = new Set(prev)
      if (next.has(nodeId)) next.delete(nodeId)
      else next.add(nodeId)
      return next
    })
  }, [])

  // Convert tree structure to flat nodes and edges
  const { rawNodes, rawEdges } = useMemo(() => {
    const nodes: Node[] = []
    const edges: Edge[] = []

    if (!treeStructure) return { rawNodes: nodes, rawEdges: edges }

    function walk(node: TreeStructure, _parentId: string | null) {
      const isCollapsed = collapsedNodeIds.has(node.id)
      const hasChildren = (node.children?.length ?? 0) > 0
      const hasErrors = validationErrors.some(e => e.nodeId === node.id && e.severity === 'error')

      const estimatedHeight = measuredHeights.get(node.id) ?? estimateNodeHeight(node)

      if (node.type === 'answer') {
        nodes.push({
          id: node.id,
          type: 'answerStub',
          position: { x: 0, y: 0 }, // dagre will set this
          data: {
            node,
            onSelectType: () => {}, // placeholder — set by FlowCanvas
          } satisfies Partial<FlowCanvasAnswerNodeData>,
          style: { width: NODE_WIDTH },
          measured: { width: NODE_WIDTH, height: estimatedHeight },
        })
      } else {
        nodes.push({
          id: node.id,
          type: 'flowNode',
          position: { x: 0, y: 0 },
          data: {
            node,
            hasChildren,
            isCollapsed,
            hasValidationErrors: hasErrors,
            isNew: false,
            onToggleCollapse: () => {}, // placeholder — set by FlowCanvas
          } satisfies Partial<FlowCanvasNodeData>,
          style: { width: NODE_WIDTH },
          measured: { width: NODE_WIDTH, height: estimatedHeight },
        })
      }

      // Skip children if collapsed
      if (isCollapsed) return

      // Create edges and recurse into children
      if (node.children) {
        // For decision nodes: order children by option link, then unlinked
        const orderedChildren = orderChildren(node)

        for (const { child, optionLabel } of orderedChildren) {
          const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined

          edges.push({
            id: `${node.id}->${child.id}`,
            source: node.id,
            target: child.id,
            type: 'smoothstep',
            label: edgeLabel,
            labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 11 },
            labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.9 },
            labelBgPadding: [4, 2] as [number, number],
            style: { stroke: 'hsl(var(--border))' },
          })

          walk(child, node.id)
        }
      }
    }

    walk(treeStructure, null)

    return { rawNodes: nodes, rawEdges: edges }
  }, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights])

  // Run dagre layout
  const { nodes, edges } = useMemo(() => {
    if (rawNodes.length === 0) return { nodes: rawNodes, edges: rawEdges }
    const layouted = getLayoutedElements(rawNodes, rawEdges)
    return { nodes: layouted, edges: rawEdges }
  }, [rawNodes, rawEdges])

  // Height measurement correction callback
  const onNodesMeasured = useCallback((measuredNodes: Node[]) => {
    if (correctionDone.current) return

    let needsCorrection = false
    const newHeights = new Map(measuredHeights)

    for (const mNode of measuredNodes) {
      const actual = mNode.measured?.height
      if (!actual) continue
      const estimated = measuredHeights.get(mNode.id) ?? estimateNodeHeight(
        (mNode.data as FlowCanvasNodeData)?.node ?? (mNode.data as FlowCanvasAnswerNodeData)?.node
      )
      if (Math.abs(actual - estimated) > 10) {
        newHeights.set(mNode.id, actual)
        needsCorrection = true
      }
    }

    if (needsCorrection) {
      correctionDone.current = true
      setMeasuredHeights(newHeights)
    }
  }, [measuredHeights])

  // Reset correction flag when tree structure changes
  useEffect(() => {
    correctionDone.current = false
  }, [treeStructure, collapsedNodeIds])

  return { nodes, edges, collapsedNodeIds, toggleCollapse, onNodesMeasured }
}

// Helper: order children by decision option links
function orderChildren(node: TreeStructure): Array<{ child: TreeStructure; optionLabel?: string }> {
  if (!node.children || node.children.length === 0) return []

  if (node.type === 'decision' && node.options) {
    const linked: Array<{ child: TreeStructure; optionLabel: string }> = []
    const linkedIds = new Set<string>()

    for (const opt of node.options) {
      if (opt.next_node_id) {
        const child = node.children.find(c => c.id === opt.next_node_id)
        if (child) {
          linked.push({ child, optionLabel: opt.label })
          linkedIds.add(child.id)
        }
      }
    }

    const unlinked = node.children
      .filter(c => !linkedIds.has(c.id))
      .map(child => ({ child, optionLabel: undefined }))

    return [...linked, ...unlinked]
  }

  return node.children.map(child => ({ child, optionLabel: undefined }))
}
```

**Step 2: Verify build**

Run: `cd frontend && npm run build`
Expected: Clean build.

**Step 3: Commit**

```bash
git add frontend/src/components/tree-editor/useTreeLayout.ts
git commit -m "feat: add useTreeLayout hook for tree-to-ReactFlow conversion with dagre"
```

---

## Phase 4: Node Editor Panel

### Task 6: Create NodeEditorPanel

**Files:**
- Create: `frontend/src/components/tree-editor/NodeEditorPanel.tsx`

**Context:** This is the right-side editing panel that opens when a node is selected. It replaces the old inline-in-card editing. It reuses the existing form components. It follows the local-draft-then-commit pattern from the current `TreeCanvasNode`.

The panel takes up 400px of real layout space (the React Flow container shrinks). It uses the existing form components exactly as `TreeCanvasNode.tsx` uses them: `node={draft}` + `onUpdate={handleDraftUpdate}`.

Important: Import form components from their direct paths, not from the barrel export:
- `import { NodeFormDecision } from './NodeFormDecision'`
- `import { NodeFormAction } from './NodeFormAction'`
- `import { NodeFormResolution } from './NodeFormResolution'`

**Step 1: Create the component**

```tsx
// frontend/src/components/tree-editor/NodeEditorPanel.tsx
import { useState, useCallback, useEffect, useRef } from 'react'
import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react'
import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
import { NodeFormDecision } from './NodeFormDecision'
import { NodeFormAction } from './NodeFormAction'
import { NodeFormResolution } from './NodeFormResolution'
import { cn } from '@/lib/utils'
import type { TreeStructure, NodeType } from '@/types'

interface NodeEditorPanelProps {
  nodeId: string
  onClose: () => void
  onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}

const TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, { icon: typeof HelpCircle; label: string; badgeClass: string }> = {
  decision: { icon: HelpCircle, label: 'Decision', badgeClass: 'bg-blue-500/20 text-blue-400' },
  action: { icon: Zap, label: 'Action', badgeClass: 'bg-yellow-500/20 text-yellow-400' },
  solution: { icon: CheckCircle, label: 'Solution', badgeClass: 'bg-green-500/20 text-green-400' },
}

function cloneWithoutChildren(node: TreeStructure): TreeStructure {
  const { children, ...rest } = node
  return structuredClone(rest) as TreeStructure
}

export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPanelProps) {
  const treeStructure = useTreeEditorStore(s => s.treeStructure)
  const updateNode = useTreeEditorStore(s => s.updateNode)
  const deleteNode = useTreeEditorStore(s => s.deleteNode)
  const duplicateNode = useTreeEditorStore(s => s.duplicateNode)
  const addNode = useTreeEditorStore(s => s.addNode)
  const selectNode = useTreeEditorStore(s => s.selectNode)

  const node = treeStructure ? findNodeInTree(nodeId, treeStructure) : null
  const [draft, setDraft] = useState<TreeStructure | null>(null)
  const [isDirty, setIsDirty] = useState(false)
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
  const panelRef = useRef<HTMLDivElement>(null)

  // Initialize/reset draft when nodeId changes
  useEffect(() => {
    if (node) {
      setDraft(cloneWithoutChildren(node))
      setIsDirty(false)
      setShowDeleteConfirm(false)
    }
  }, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps

  // Escape to close
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        handleClose()
      }
    }
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [isDirty]) // eslint-disable-line react-hooks/exhaustive-deps

  const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
    setDraft(prev => prev ? { ...prev, ...updates } : prev)
    setIsDirty(true)
  }, [])

  const handleSave = useCallback(() => {
    if (!draft || !node) return
    const { children, ...draftWithoutChildren } = draft
    updateNode(nodeId, draftWithoutChildren)

    // Auto-create answer stubs for new decision options without next_node_id
    if (draft.options) {
      const options = draft.options.filter(o => o.label.trim())
      const stubsCreated: Array<{ optId: string; stubId: string }> = []

      options.forEach(opt => {
        if (!opt.next_node_id) {
          const stubId = addNode(nodeId, 'answer')
          updateNode(stubId, { title: opt.label })
          stubsCreated.push({ optId: opt.id, stubId })
        }
      })

      if (stubsCreated.length > 0) {
        const updatedOptions = options.map(o => {
          const stub = stubsCreated.find(s => s.optId === o.id)
          return stub ? { ...o, next_node_id: stub.stubId } : o
        })
        updateNode(nodeId, { options: updatedOptions })
      }
    }

    setIsDirty(false)
  }, [draft, node, nodeId, updateNode, addNode])

  const handleClose = useCallback(() => {
    if (isDirty) {
      // Simple confirm — could be replaced with a nicer modal later
      if (!window.confirm('You have unsaved changes. Discard them?')) return
    }
    onClose()
  }, [isDirty, onClose])

  const handleDelete = useCallback(() => {
    if (!treeStructure) return
    // Clear inbound references before deleting
    clearInboundReferences(nodeId, treeStructure, updateNode)
    deleteNode(nodeId)
    onClose()
  }, [nodeId, treeStructure, updateNode, deleteNode, onClose])

  const handleDuplicate = useCallback(() => {
    const newId = duplicateNode(nodeId)
    if (newId) {
      selectNode(newId)
    }
  }, [nodeId, duplicateNode, selectNode])

  if (!node || !draft) return null

  // Answer stub: show type picker instead of form
  if (node.type === 'answer') {
    return (
      <div ref={panelRef} className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
        <div className="flex items-center justify-between border-b border-border px-4 py-3">
          <span className="text-sm font-heading font-medium text-foreground">
            {node.title || 'Answer Placeholder'}
          </span>
          <button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
            <X className="h-4 w-4" />
          </button>
        </div>
        <div className="flex flex-1 flex-col items-center justify-center gap-4 px-4">
          <p className="text-sm text-muted-foreground text-center">Choose a type for this node:</p>
          <div className="flex gap-2">
            {(['decision', 'action', 'solution'] as const).map(type => {
              const cfg = TYPE_CONFIG[type]
              const TypeIcon = cfg.icon
              return (
                <button
                  key={type}
                  type="button"
                  onClick={() => onSelectType?.(nodeId, type)}
                  className={cn(
                    'flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-label border transition-colors',
                    type === 'decision' && 'border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20',
                    type === 'action' && 'border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20',
                    type === 'solution' && 'border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20',
                  )}
                >
                  <TypeIcon className="h-4 w-4" /> {cfg.label}
                </button>
              )
            })}
          </div>
        </div>
      </div>
    )
  }

  const config = TYPE_CONFIG[node.type as Exclude<NodeType, 'answer'>] ?? TYPE_CONFIG.decision
  const TypeIcon = config.icon
  const title = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${config.label}`)
  const isRoot = treeStructure?.id === nodeId

  return (
    <div ref={panelRef} className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
      {/* Header */}
      <div className="flex items-center gap-2 border-b border-border px-4 py-3">
        <span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
          <TypeIcon className="h-3 w-3" />
        </span>
        <span className="flex-1 truncate text-sm font-heading font-medium text-foreground">{title}</span>
        <button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
          <X className="h-4 w-4" />
        </button>
      </div>

      {/* Body — scrollable form area */}
      <div className="flex-1 overflow-y-auto px-4 py-3">
        {draft.type === 'decision' && <NodeFormDecision node={draft} onUpdate={handleDraftUpdate} />}
        {draft.type === 'action' && <NodeFormAction node={draft} onUpdate={handleDraftUpdate} />}
        {draft.type === 'solution' && <NodeFormResolution node={draft} onUpdate={handleDraftUpdate} />}
      </div>

      {/* Footer */}
      <div className="flex items-center gap-2 border-t border-border px-4 py-3">
        <button
          onClick={handleSave}
          disabled={!isDirty}
          className={cn(
            'flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 transition-opacity',
            isDirty ? 'bg-gradient-brand hover:opacity-90' : 'bg-gradient-brand opacity-50 cursor-not-allowed'
          )}
        >
          <Save className="h-3.5 w-3.5" /> Save
        </button>
        <button
          onClick={handleClose}
          className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
        >
          Cancel
        </button>
        <div className="flex-1" />
        {!isRoot && (
          <>
            <button
              onClick={handleDuplicate}
              className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
              title="Duplicate"
            >
              <Copy className="h-4 w-4" />
            </button>
            {showDeleteConfirm ? (
              <div className="flex items-center gap-1">
                <button
                  onClick={handleDelete}
                  className="rounded-md bg-red-500/20 px-2 py-1 text-xs text-red-400 hover:bg-red-500/30"
                >
                  Confirm
                </button>
                <button
                  onClick={() => setShowDeleteConfirm(false)}
                  className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
                >
                  Cancel
                </button>
              </div>
            ) : (
              <button
                onClick={() => setShowDeleteConfirm(true)}
                className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-red-400"
                title="Delete"
              >
                <Trash2 className="h-4 w-4" />
              </button>
            )}
          </>
        )}
      </div>
    </div>
  )
}

// Same helper used in TreeCanvas.tsx — clear all next_node_id references to a node before deleting
function clearInboundReferences(
  nodeId: string,
  treeStructure: TreeStructure,
  updateNode: (id: string, updates: Partial<TreeStructure>) => void
) {
  function walk(node: TreeStructure) {
    if (node.type === 'decision' && node.options) {
      const needsUpdate = node.options.some(o => o.next_node_id === nodeId)
      if (needsUpdate) {
        updateNode(node.id, {
          options: node.options.map(o => o.next_node_id === nodeId ? { ...o, next_node_id: '' } : o),
        })
      }
    }
    if (node.type === 'action' && node.next_node_id === nodeId) {
      updateNode(node.id, { next_node_id: '' })
    }
    node.children?.forEach(walk)
  }
  walk(treeStructure)
}
```

**Step 2: Verify build**

Run: `cd frontend && npm run build`
Expected: Clean build.

**Step 3: Commit**

```bash
git add frontend/src/components/tree-editor/NodeEditorPanel.tsx
git commit -m "feat: add NodeEditorPanel side panel for React Flow canvas editing"
```

---

## Phase 5: Main FlowCanvas Component

### Task 7: Create FlowCanvas

**Files:**
- Create: `frontend/src/components/tree-editor/FlowCanvas.tsx`

**Context:** This is the main React Flow canvas component that replaces `TreeCanvas.tsx`. It orchestrates everything: the React Flow instance, node types, the `useTreeLayout` hook, minimap toggle, add-node buttons (via a floating toolbar or context), and the node click → panel selection flow.

Important: Import the React Flow CSS at the top: `import '@xyflow/react/dist/style.css'`

The component receives `selectedNodeId` and `onNodeSelect` props from its parent (`TreeEditorLayout`) so the panel state lives above.

**Step 1: Create the component**

```tsx
// frontend/src/components/tree-editor/FlowCanvas.tsx
import { useCallback, useMemo, useState, useEffect } from 'react'
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  BackgroundVariant,
  useReactFlow,
  useNodesState,
  useEdgesState,
  ReactFlowProvider,
  type OnNodesChange,
  type OnEdgesChange,
  type NodeMouseHandler,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'

import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode'
import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
import { useTreeLayout } from './useTreeLayout'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { cn } from '@/lib/utils'
import { Map as MapIcon, MapOff } from 'lucide-react'
import type { FlowCanvasNodeData } from './FlowCanvasNode'
import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode'

const nodeTypes = {
  flowNode: FlowCanvasNode,
  answerStub: FlowCanvasAnswerNode,
}

interface FlowCanvasProps {
  selectedNodeId: string | null
  onNodeSelect: (nodeId: string | null) => void
  onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}

function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) {
  const { fitView, setCenter } = useReactFlow()
  const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
  const [minimapVisible, setMinimapVisible] = useState(true)

  // Inject callbacks into node data (because useTreeLayout creates placeholder functions)
  const nodesWithCallbacks = useMemo(() => {
    return layoutNodes.map(n => {
      if (n.type === 'flowNode') {
        const data = n.data as FlowCanvasNodeData
        return {
          ...n,
          selected: n.id === selectedNodeId,
          data: { ...data, onToggleCollapse: toggleCollapse },
        }
      }
      if (n.type === 'answerStub') {
        const data = n.data as FlowCanvasAnswerNodeData
        return {
          ...n,
          selected: n.id === selectedNodeId,
          data: { ...data, onSelectType: onSelectAnswerType },
        }
      }
      return n
    })
  }, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType])

  const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
  const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)

  // Sync layout changes into React Flow state
  useEffect(() => {
    setNodes(nodesWithCallbacks)
    setEdges(layoutEdges)
  }, [nodesWithCallbacks, layoutEdges, setNodes, setEdges])

  // Fit view after layout changes
  useEffect(() => {
    // Small delay to let React Flow process the node updates
    const timer = setTimeout(() => {
      fitView({ padding: 0.1, duration: 200 })
    }, 50)
    return () => clearTimeout(timer)
  }, [layoutNodes.length, collapsedNodeIds.size]) // eslint-disable-line react-hooks/exhaustive-deps

  // Auto-center on selected node when panel opens
  useEffect(() => {
    if (!selectedNodeId) return
    const node = nodes.find(n => n.id === selectedNodeId)
    if (node) {
      const x = node.position.x + 140 // center of 280px node
      const y = node.position.y + 50
      setCenter(x, y, { duration: 300, zoom: 1 })
    }
  }, [selectedNodeId]) // eslint-disable-line react-hooks/exhaustive-deps

  // Height measurement correction
  useEffect(() => {
    if (nodes.length > 0 && nodes.some(n => n.measured?.height)) {
      onNodesMeasured(nodes)
    }
  }, [nodes]) // eslint-disable-line react-hooks/exhaustive-deps

  const handleNodeClick: NodeMouseHandler = useCallback((_event, node) => {
    onNodeSelect(node.id)
  }, [onNodeSelect])

  const handlePaneClick = useCallback(() => {
    onNodeSelect(null)
  }, [onNodeSelect])

  const getNodeColor = useCallback((node: { type?: string }) => {
    if (node.type === 'answerStub') return 'hsl(var(--muted-foreground))'
    // Use NODE_TYPE_CONFIG minimap colors
    const config = NODE_TYPE_CONFIG[(node.type === 'flowNode' ? 'decision' : 'decision') as keyof typeof NODE_TYPE_CONFIG]
    return config?.minimapColor ?? '#6b7280'
  }, [])

  // Custom minimap node color based on actual tree node type
  const minimapNodeColor = useCallback((rfNode: { data?: unknown }) => {
    const data = rfNode.data as FlowCanvasNodeData | FlowCanvasAnswerNodeData | undefined
    if (!data || !('node' in data)) return '#6b7280'
    const treeNode = data.node
    if (treeNode.type === 'answer') return '#6b7280'
    const config = NODE_TYPE_CONFIG[treeNode.type as keyof typeof NODE_TYPE_CONFIG]
    return config?.minimapColor ?? '#6b7280'
  }, [])

  return (
    <div className="relative h-full w-full">
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        nodeTypes={nodeTypes}
        onNodeClick={handleNodeClick}
        onPaneClick={handlePaneClick}
        fitView
        minZoom={0.25}
        maxZoom={2}
        zoomOnScroll={false}
        zoomOnPinch={true}
        panOnScroll={true}
        panOnScrollMode="vertical"
        selectionOnDrag={false}
        nodesDraggable={false}
        nodesConnectable={false}
        proOptions={{ hideAttribution: true }}
        className="bg-background"
      >
        <Background variant={BackgroundVariant.Dots} gap={24} size={1} color="hsl(var(--border))" />
        <Controls showInteractive={false} className="!bg-card !border-border !shadow-lg" />
        {minimapVisible && (
          <MiniMap
            pannable
            zoomable
            nodeColor={minimapNodeColor}
            className="!bg-card !border-border"
            nodeStrokeWidth={2}
          />
        )}
      </ReactFlow>

      {/* Minimap toggle button */}
      <button
        onClick={() => setMinimapVisible(v => !v)}
        className={cn(
          'absolute bottom-2 right-2 z-10 rounded-lg border border-border bg-card p-2 text-muted-foreground shadow-lg hover:bg-accent hover:text-foreground transition-colors',
          minimapVisible && 'bottom-[170px]'
        )}
        title={minimapVisible ? 'Hide minimap' : 'Show minimap'}
      >
        {minimapVisible ? <MapOff className="h-4 w-4" /> : <MapIcon className="h-4 w-4" />}
      </button>
    </div>
  )
}

// Wrap in ReactFlowProvider (required by useReactFlow hook)
export function FlowCanvas(props: FlowCanvasProps) {
  return (
    <ReactFlowProvider>
      <FlowCanvasInner {...props} />
    </ReactFlowProvider>
  )
}
```

**Step 2: Verify build**

Run: `cd frontend && npm run build`
Expected: Clean build.

**Step 3: Commit**

```bash
git add frontend/src/components/tree-editor/FlowCanvas.tsx
git commit -m "feat: add FlowCanvas main React Flow component with zoom/pan/minimap"
```

---

## Phase 6: Integration — Wire Into Editor Layout

### Task 8: Update TreeEditorLayout to use FlowCanvas + NodeEditorPanel

**Files:**
- Modify: `frontend/src/components/tree-editor/TreeEditorLayout.tsx`

**Context:** Currently Flow mode renders `<TreeCanvas />` full-width with `<MetadataSidePanel>` as an overlay. We need to change it to render `<FlowCanvas>` that shrinks when `<NodeEditorPanel>` is open. Both the node editor panel and metadata panel take up real layout space on the right.

The panel state (which node is being edited) needs to come from the parent `TreeEditorPage.tsx` via props.

**Step 1: Read the current file to understand exact structure**

Read: `frontend/src/components/tree-editor/TreeEditorLayout.tsx`

**Step 2: Update the component**

Add new props and update the Flow mode rendering. The key change is the Flow mode now renders:
```tsx
<div className="flex flex-1 overflow-hidden">
  <div className="flex-1 overflow-hidden">
    <FlowCanvas
      selectedNodeId={editingNodeId}
      onNodeSelect={onNodeSelect}
      onSelectAnswerType={onSelectAnswerType}
    />
  </div>
  {editingNodeId && (
    <NodeEditorPanel
      nodeId={editingNodeId}
      onClose={() => onNodeSelect(null)}
      onSelectType={onSelectAnswerType}
    />
  )}
  {isMetadataOpen && !editingNodeId && (
    <MetadataSidePanel isOpen={true} onClose={onCloseMetadata} />
  )}
</div>
```

New props needed on `TreeEditorLayoutProps`:
```typescript
interface TreeEditorLayoutProps {
  isMobile?: boolean
  isMetadataOpen?: boolean
  onCloseMetadata?: () => void
  editingNodeId: string | null          // NEW
  onNodeSelect: (nodeId: string | null) => void  // NEW
  onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void  // NEW
}
```

**Important:** `MetadataSidePanel` currently uses `position: fixed` overlay. For the new layout, when the node editor is open, metadata panel should be hidden (single-panel-at-a-time rule from the design doc). When neither is open, metadata can still use its existing overlay behavior OR be changed to use the same flex layout pattern. Start with the simpler approach: just hide metadata when node editor is open.

**Step 3: Verify build**

Run: `cd frontend && npm run build`
Expected: May have type errors in `TreeEditorPage.tsx` because it doesn't pass the new props yet. That's fine — we fix it in Task 9.

**Step 4: Commit**

```bash
git add frontend/src/components/tree-editor/TreeEditorLayout.tsx
git commit -m "feat: wire FlowCanvas and NodeEditorPanel into TreeEditorLayout"
```

---

### Task 9: Update TreeEditorPage to manage panel state

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

**Context:** `TreeEditorPage` already manages `isMetadataOpen`. Now it also needs to manage `editingNodeId` and enforce the single-panel-at-a-time rule.

**Step 1: Read the current file**

Read: `frontend/src/pages/TreeEditorPage.tsx`

**Step 2: Add state and handlers**

Add these state variables and handlers:
```typescript
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)

const handleNodeSelect = useCallback((nodeId: string | null) => {
  if (nodeId) {
    setIsMetadataOpen(false)  // close metadata when opening node editor
  }
  setEditingNodeId(nodeId)
  selectNode(nodeId)
}, [selectNode])

const handleSelectAnswerType = useCallback((nodeId: string, type: 'decision' | 'action' | 'solution') => {
  updateNode(nodeId, { type })
  // Keep the panel open on the same node — it will now show the form for the new type
  setEditingNodeId(nodeId)
  selectNode(nodeId)
}, [updateNode, selectNode])
```

Update the metadata toggle to close the node editor:
```typescript
const handleMetadataToggle = () => {
  if (!isMetadataOpen) {
    setEditingNodeId(null)  // close node editor when opening metadata
  }
  setIsMetadataOpen(!isMetadataOpen)
}
```

Pass the new props to `TreeEditorLayout`:
```tsx
<TreeEditorLayout
  isMobile={isMobile}
  isMetadataOpen={isMetadataOpen}
  onCloseMetadata={() => setIsMetadataOpen(false)}
  editingNodeId={editingNodeId}
  onNodeSelect={handleNodeSelect}
  onSelectAnswerType={handleSelectAnswerType}
/>
```

Also: close the node editor when switching to Code mode:
```typescript
// In the mode switch handler:
setEditingNodeId(null)
```

**Step 3: Verify build**

Run: `cd frontend && npm run build`
Expected: Clean build with zero errors.

**Step 4: Commit**

```bash
git add frontend/src/pages/TreeEditorPage.tsx
git commit -m "feat: add panel state management for node editor in TreeEditorPage"
```

---

## Phase 7: Styling + Polish

### Task 10: Override React Flow default styles for dark theme

**Files:**
- Modify: `frontend/src/index.css` (or create `frontend/src/components/tree-editor/flow-canvas.css`)

**Context:** React Flow ships with its own light-mode styles. We need to override them to match the dark-first design system. The overrides target React Flow's CSS class names.

**Step 1: Add style overrides**

Add to `index.css` (after the existing Tailwind layers) or create a dedicated CSS file imported by `FlowCanvas.tsx`:

```css
/* React Flow dark theme overrides */
.react-flow__background {
  background-color: hsl(var(--background));
}

.react-flow__controls {
  background-color: hsl(var(--card));
  border-color: hsl(var(--border));
  border-radius: 0.75rem;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}

.react-flow__controls-button {
  background-color: hsl(var(--card));
  border-color: hsl(var(--border));
  fill: hsl(var(--muted-foreground));
}

.react-flow__controls-button:hover {
  background-color: hsl(var(--accent));
}

.react-flow__minimap {
  background-color: hsl(var(--card));
  border-color: hsl(var(--border));
  border-radius: 0.75rem;
}

.react-flow__edge-path {
  stroke: hsl(var(--border));
}

.react-flow__edge-text {
  fill: hsl(var(--muted-foreground));
}

.react-flow__edge-textbg {
  fill: hsl(var(--card));
}

/* Hide default React Flow attribution */
.react-flow__attribution {
  display: none;
}

/* Handle styles */
.react-flow__handle {
  background-color: hsl(var(--border));
}
```

**Step 2: Verify build**

Run: `cd frontend && npm run build`
Expected: Clean build.

**Step 3: Commit**

```bash
git add frontend/src/index.css
git commit -m "style: add React Flow dark theme overrides for canvas"
```

---

### Task 11: Update tree-editor barrel export

**Files:**
- Modify: `frontend/src/components/tree-editor/index.ts`

**Step 1: Add new exports**

Add these lines to the existing exports (don't remove any existing exports):
```typescript
export { FlowCanvas } from './FlowCanvas'
export { FlowCanvasNode } from './FlowCanvasNode'
export { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
export { NodeEditorPanel } from './NodeEditorPanel'
```

**Step 2: Verify build**

Run: `cd frontend && npm run build`
Expected: Clean build.

**Step 3: Commit**

```bash
git add frontend/src/components/tree-editor/index.ts
git commit -m "chore: export new React Flow canvas components from barrel"
```

---

## Phase 8: Final Verification

### Task 12: Full build verification + manual test

**Step 1: Run frontend build**

Run: `cd frontend && npm run build`
Expected: Clean build with zero TypeScript errors.

**Step 2: Run backend tests (ensure nothing broken)**

Run: `cd backend && source venv/bin/activate && pytest --override-ini="addopts=" -q`
Expected: All tests pass (backend is unchanged, but sanity check).

**Step 3: Manual test checklist**

- [ ] Open any troubleshooting tree in Flow mode → see React Flow canvas with nodes
- [ ] Ctrl+scroll zooms in/out
- [ ] Click and drag on empty space pans the canvas
- [ ] Plain scroll pans vertically
- [ ] Click a node → right panel opens with the correct form, canvas shrinks
- [ ] Edit a field in the panel → Save → node card updates on canvas
- [ ] Cancel discards changes
- [ ] Delete a node → node removed, edges cleaned up
- [ ] Click collapse chevron on a node with children → subtree disappears, "N nodes hidden" pill shows
- [ ] Click expand → subtree reappears
- [ ] Minimap visible in bottom-right, shows node overview
- [ ] Click minimap toggle → minimap hides, click again → shows
- [ ] Zoom controls in bottom-left work (zoom in, zoom out, fit view)
- [ ] Click "Metadata" toolbar button → metadata panel opens, node editor closes
- [ ] Switch to Code mode → canvas replaced with Monaco, node editor closes
- [ ] Switch back to Flow mode → React Flow canvas renders
- [ ] Answer stub nodes show dashed card → click → type picker → pick type → panel opens with correct form
- [ ] Create a new decision with options → save → answer stubs appear for each option
- [ ] Publish guard still works (can't publish with answer stubs)
- [ ] Large tree (Password Reset / Account Lockout) → no overlap, can zoom to see full tree

**Step 4: Commit any fixes from manual testing**

If any issues found during manual testing, fix and commit individually.

**Step 5: Final commit**

```bash
git add -A
git commit -m "feat: complete React Flow migration for flow editor canvas"
```

---

## Summary of Files Changed

### New Files (6)
| File | Purpose |
|------|---------|
| `frontend/src/lib/dagreLayout.ts` | Pure dagre layout utility |
| `frontend/src/components/tree-editor/FlowCanvas.tsx` | Main React Flow canvas component |
| `frontend/src/components/tree-editor/FlowCanvasNode.tsx` | Custom compact node (decision/action/solution) |
| `frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx` | Custom answer stub node |
| `frontend/src/components/tree-editor/NodeEditorPanel.tsx` | Right-side editor panel |
| `frontend/src/components/tree-editor/useTreeLayout.ts` | Tree→ReactFlow conversion hook with dagre |

### Modified Files (4)
| File | Changes |
|------|---------|
| `frontend/src/components/tree-editor/TreeEditorLayout.tsx` | Flow mode uses FlowCanvas + NodeEditorPanel |
| `frontend/src/pages/TreeEditorPage.tsx` | Panel state management, single-panel-at-a-time |
| `frontend/src/components/tree-editor/index.ts` | New component exports |
| `frontend/src/index.css` | React Flow dark theme overrides |

### New Dependencies (2)
| Package | Purpose |
|---------|---------|
| `@xyflow/react` | React Flow canvas framework |
| `@dagrejs/dagre` | Directed graph layout algorithm |

### No Changes Required
- `treeEditorStore.ts` — no store changes
- `NodeFormDecision.tsx`, `NodeFormAction.tsx`, `NodeFormResolution.tsx` — reused as-is
- `MetadataSidePanel.tsx` — works as-is with single-panel-at-a-time rule
- Code mode — untouched
- All backend files — untouched
