{
  "schemaVersion": 1,
  "target": "react",
  "name": "graph-canvas",
  "version": "0.2.0",
  "category": "agent",
  "title": "Graph Canvas",
  "description": "The shared graph engine for the graph components: deterministic dagre layout keyed on structure, a React Flow canvas with zoom, pan, and fit-to-view, status icons, and a keyboard listbox.",
  "tags": [
    "agent",
    "graph",
    "react-flow",
    "dagre",
    "layout",
    "canvas"
  ],
  "requires": {
    "projectTypes": [
      "next",
      "vite-react"
    ],
    "styling": "tailwind-v4"
  },
  "npmDependencies": {
    "@xyflow/react": "^12.12.1",
    "@dagrejs/dagre": "^3.1.3"
  },
  "registryDependencies": [
    {
      "name": "agent-run",
      "version": "0.1.1"
    },
    {
      "name": "step-list",
      "version": "0.2.0"
    }
  ],
  "docsPath": "/components/graph-canvas",
  "props": [
    {
      "name": "layoutGraph(nodes, edges, options?)",
      "type": "(GraphNodeRef[], GraphEdgeRef[], GraphLayoutOptions) => GraphLayout",
      "description": "Ranked dagre layout. Deterministic: the same structure always gives the same positions and edge routes."
    },
    {
      "name": "useGraphLayout(nodes, edges, options?)",
      "type": "hook => GraphLayout",
      "description": "Memoizes layoutGraph on structureKey (node ids, edge ends, labels present), so status updates never move nodes."
    },
    {
      "name": "GraphCanvas",
      "type": "{ nodes, edges, layout, selectedNodeId?, selectedEdgeId?, onNodeClick?, onEdgeClick?, onPaneClick?, height?, label, minimap?, animateEdges?, grid?, toolbar? }",
      "description": "React Flow canvas on a black (or white) stage with a faint dot grid: node chips with a status glyph, name, and meta line; solid or dashed edges with swept arrowheads; a hairline toolbar with zoom out, zoom level (click to reset), zoom in, and fit-to-view; and a mini map for graphs over 24 nodes. Selection is instant; only the viewport eases. Edges into running work are drawn in the running color; moving dashes are opt-in with animateEdges and always off with reduced motion. Imports @xyflow/react/dist/style.css."
    },
    {
      "name": "ancestorsOf / descendantsOf / branchOf / isAcyclic",
      "type": "(id, edges) => Set<string>",
      "description": "Graph helpers used for highlighting and branch focus. Safe on cycles."
    },
    {
      "name": "GraphListbox",
      "type": "{ items, getId, selectedId, onSelect, renderItem, label }",
      "description": "Single-select listbox with one tab stop; arrow keys, Home, and End move the selection."
    },
    {
      "name": "GraphStatusIcon",
      "type": "({ status: GraphStatus }) => JSX",
      "description": "StepStatusIcon plus icons for waiting-for-approval and cancelled."
    }
  ],
  "accessibility": [
    "Nodes are not draggable and not individually focusable; the list view each graph component provides is the keyboard path, with selection shared with the canvas.",
    "Zoom in, zoom out, and fit-to-view are labelled 32px buttons with a visible focus outline.",
    "Statuses use an icon and text on every node; edge types use solid or dashed lines plus a text legend.",
    "With prefers-reduced-motion: reduce, fit-to-view, zoom, and pan-to-selection jump instead of animating, and edge dashes never move.",
    "Selecting from the list pans the canvas to the selected node when it is off screen."
  ],
  "states": [
    "queued",
    "running",
    "waiting-for-approval",
    "succeeded",
    "failed",
    "skipped",
    "cancelled"
  ],
  "files": [
    {
      "path": "graph/graph-layout.ts",
      "content": "// Layout and graph helpers shared by the graph components. No React, no DOM:\n// safe to call on the server, in tests, or in a worker.\nimport { graphlib, layout as dagreLayout } from \"@dagrejs/dagre\";\nimport type { RunStatus, StepStatus } from \"../agent/agent-run\";\n\n/** Every status a graph node can show. Step statuses plus the run-level approval and cancel states. */\nexport type GraphStatus = StepStatus | Extract<RunStatus, \"waiting-for-approval\" | \"cancelled\">;\n\nexport const GRAPH_STATUSES: GraphStatus[] = [\"queued\", \"running\", \"waiting-for-approval\", \"succeeded\", \"failed\", \"skipped\", \"cancelled\"];\n\nexport const GRAPH_STATUS_LABELS: Record<GraphStatus, string> = {\n  queued: \"Queued\",\n  running: \"Running\",\n  \"waiting-for-approval\": \"Needs approval\",\n  succeeded: \"Succeeded\",\n  failed: \"Failed\",\n  skipped: \"Skipped\",\n  cancelled: \"Cancelled\",\n};\n\n/** The minimum a node needs for layout: a stable id. */\nexport interface GraphNodeRef {\n  id: string;\n}\n\n/** The minimum an edge needs: a stable id, both ends, and an explicit type. */\nexport interface GraphEdgeRef {\n  id: string;\n  source: string;\n  target: string;\n  type: string;\n  /** Edges with a label get space reserved for it in the layout. */\n  label?: string;\n}\n\nexport type GraphDirection = \"LR\" | \"TB\";\n\nexport interface GraphLayoutOptions {\n  /** Top to bottom (default), which fits narrow panels, or left to right. */\n  direction?: GraphDirection;\n  nodeWidth?: number;\n  nodeHeight?: number;\n  /** Space between nodes in the same rank. */\n  nodeSep?: number;\n  /** Space between ranks. */\n  rankSep?: number;\n  /** Space reserved for labelled edges. */\n  labelWidth?: number;\n  labelHeight?: number;\n}\n\nexport interface Point {\n  x: number;\n  y: number;\n}\n\nexport interface GraphLayout {\n  /** The structure this layout was computed for. Equal keys mean equal layouts. */\n  key: string;\n  direction: GraphDirection;\n  nodeWidth: number;\n  nodeHeight: number;\n  /** Top-left corner of each node. */\n  positions: Record<string, Point>;\n  /** Route of each edge from its source boundary to its target boundary, and the label center. */\n  edges: Record<string, { points: Point[]; label: Point }>;\n  width: number;\n  height: number;\n}\n\nconst DEFAULTS: Required<GraphLayoutOptions> = {\n  direction: \"TB\",\n  nodeWidth: 184,\n  nodeHeight: 52,\n  nodeSep: 24,\n  rankSep: 44,\n  labelWidth: 132,\n  labelHeight: 26,\n};\n\n/** Drops edges whose ends are missing and self-loops, so the layout never invents nodes. */\nexport function layoutEdges<E extends GraphEdgeRef>(nodes: readonly GraphNodeRef[], edges: readonly E[]): E[] {\n  const ids = new Set(nodes.map((node) => node.id));\n  return edges.filter((edge) => edge.source !== edge.target && ids.has(edge.source) && ids.has(edge.target));\n}\n\n/**\n * Identifies the graph's structure: node ids, edge ends, and whether an edge has a label,\n * in input order. Statuses, text, and edge types are not part of it, so live status\n * updates keep the same key and therefore the same positions.\n */\nexport function structureKey(nodes: readonly GraphNodeRef[], edges: readonly GraphEdgeRef[], options: GraphLayoutOptions = {}): string {\n  const o = { ...DEFAULTS, ...options };\n  const edgePart = layoutEdges(nodes, edges).map((edge) => [edge.id, edge.source, edge.target, edge.label ? 1 : 0]);\n  return JSON.stringify([o.direction, o.nodeWidth, o.nodeHeight, o.nodeSep, o.rankSep, o.labelWidth, o.labelHeight, nodes.map((node) => node.id), edgePart]);\n}\n\n/**\n * Lays the graph out in ranks with dagre. Deterministic: the same structure always\n * produces the same positions. Cycles (for example, a hand-off and its return) are allowed.\n */\nexport function layoutGraph(nodes: readonly GraphNodeRef[], edges: readonly GraphEdgeRef[], options: GraphLayoutOptions = {}): GraphLayout {\n  const o = { ...DEFAULTS, ...options };\n  const kept = layoutEdges(nodes, edges);\n  const g = new graphlib.Graph({ multigraph: true });\n  g.setGraph({ rankdir: o.direction, nodesep: o.nodeSep, ranksep: o.rankSep, edgesep: 16, marginx: 0, marginy: 0 });\n  g.setDefaultEdgeLabel(() => ({}));\n  for (const node of nodes) g.setNode(node.id, { width: o.nodeWidth, height: o.nodeHeight });\n  for (const edge of kept) {\n    g.setEdge(edge.source, edge.target, edge.label ? { width: o.labelWidth, height: o.labelHeight, labelpos: \"c\" } : {}, edge.id);\n  }\n  dagreLayout(g);\n\n  const positions: Record<string, Point> = {};\n  for (const node of nodes) {\n    const placed = g.node(node.id) as { x: number; y: number } | undefined;\n    positions[node.id] = placed ? { x: placed.x - o.nodeWidth / 2, y: placed.y - o.nodeHeight / 2 } : { x: 0, y: 0 };\n  }\n  const routes: GraphLayout[\"edges\"] = {};\n  for (const edge of kept) {\n    const placed = g.edge({ v: edge.source, w: edge.target, name: edge.id }) as { points?: Point[]; x?: number; y?: number } | undefined;\n    const points = (placed?.points ?? []).map((point) => ({ x: point.x, y: point.y }));\n    const middle = points[Math.floor(points.length / 2)] ?? { x: 0, y: 0 };\n    routes[edge.id] = { points, label: placed?.x != null && placed.y != null ? { x: placed.x, y: placed.y } : middle };\n  }\n  const size = g.graph() as { width?: number; height?: number };\n  return {\n    key: structureKey(nodes, edges, options),\n    direction: o.direction,\n    nodeWidth: o.nodeWidth,\n    nodeHeight: o.nodeHeight,\n    positions,\n    edges: routes,\n    width: size.width ?? 0,\n    height: size.height ?? 0,\n  };\n}\n\nexport interface Adjacency<E extends GraphEdgeRef> {\n  incoming: Map<string, E[]>;\n  outgoing: Map<string, E[]>;\n}\n\n/** Incoming and outgoing edges per node id. */\nexport function adjacency<E extends GraphEdgeRef>(edges: readonly E[]): Adjacency<E> {\n  const incoming = new Map<string, E[]>();\n  const outgoing = new Map<string, E[]>();\n  for (const edge of edges) {\n    (incoming.get(edge.target) ?? incoming.set(edge.target, []).get(edge.target)!).push(edge);\n    (outgoing.get(edge.source) ?? outgoing.set(edge.source, []).get(edge.source)!).push(edge);\n  }\n  return { incoming, outgoing };\n}\n\nfunction walk(start: string, next: (id: string) => readonly GraphEdgeRef[] | undefined, end: \"source\" | \"target\"): Set<string> {\n  const seen = new Set<string>();\n  const stack = [start];\n  while (stack.length) {\n    const id = stack.pop()!;\n    for (const edge of next(id) ?? []) {\n      const other = edge[end];\n      if (other !== start && !seen.has(other)) {\n        seen.add(other);\n        stack.push(other);\n      }\n    }\n  }\n  return seen;\n}\n\n/** Every node the given node depends on, directly or transitively (not including itself). */\nexport function ancestorsOf(id: string, edges: readonly GraphEdgeRef[]): Set<string> {\n  const { incoming } = adjacency(edges);\n  return walk(id, (node) => incoming.get(node), \"source\");\n}\n\n/** Every node that depends on the given node, directly or transitively (not including itself). */\nexport function descendantsOf(id: string, edges: readonly GraphEdgeRef[]): Set<string> {\n  const { outgoing } = adjacency(edges);\n  return walk(id, (node) => outgoing.get(node), \"target\");\n}\n\n/** The node, its ancestors, and its descendants: what \"focus on branch\" shows. */\nexport function branchOf(id: string, edges: readonly GraphEdgeRef[]): Set<string> {\n  return new Set([id, ...ancestorsOf(id, edges), ...descendantsOf(id, edges)]);\n}\n\n/** True when the edges form no cycle (Kahn's algorithm). */\nexport function isAcyclic(nodes: readonly GraphNodeRef[], edges: readonly GraphEdgeRef[]): boolean {\n  const indegree = new Map(nodes.map((node) => [node.id, 0]));\n  for (const edge of edges) indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1);\n  const { outgoing } = adjacency(edges);\n  const ready = [...indegree].filter(([, count]) => count === 0).map(([id]) => id);\n  let visited = 0;\n  while (ready.length) {\n    const id = ready.pop()!;\n    visited += 1;\n    for (const edge of outgoing.get(id) ?? []) {\n      const count = (indegree.get(edge.target) ?? 0) - 1;\n      indegree.set(edge.target, count);\n      if (count === 0) ready.push(edge.target);\n    }\n  }\n  return visited === indegree.size;\n}\n"
    },
    {
      "path": "graph/graph-ui.tsx",
      "content": "\"use client\";\n\nimport { type ComponentProps, type KeyboardEvent, type ReactNode, useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { StatusGlyph, StepStatusIcon } from \"../agent/step-list\";\nimport { GRAPH_STATUS_LABELS, type GraphStatus } from \"./graph-layout\";\n\nexport function cx(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\n/** Status text colors from the design system. Neutral states stay gray; color is reserved for meaning. */\nexport const GRAPH_STATUS_TEXT: Record<GraphStatus, string> = {\n  queued: \"text-zinc-500\",\n  running: \"text-sky-600 dark:text-sky-400\",\n  \"waiting-for-approval\": \"text-amber-600 dark:text-amber-400\",\n  succeeded: \"text-emerald-600 dark:text-emerald-400\",\n  failed: \"text-red-600 dark:text-red-400\",\n  skipped: \"text-zinc-500\",\n  cancelled: \"text-zinc-500\",\n};\n\n/** Hairline and divider recipes shared by every graph surface. */\nexport const hairline = \"border-zinc-950/10 dark:border-white/10\";\nexport const divider = \"border-zinc-950/[0.07] dark:border-white/[0.08]\";\n\n/** The small secondary button used in graph headers and the canvas toolbar. */\nexport const graphButtonClass = cx(\n  \"inline-flex h-7 items-center justify-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium whitespace-nowrap select-none\",\n  \"border-zinc-950/12 bg-white text-zinc-700 hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] hover:text-zinc-950\",\n  \"dark:border-white/15 dark:bg-black dark:text-zinc-300 dark:hover:border-white/25 dark:hover:bg-white/[0.06] dark:hover:text-white\",\n  \"transition-colors duration-150 ease-out motion-reduce:transition-none\",\n  \"disabled:pointer-events-none disabled:opacity-45\",\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500\",\n);\n\n/**\n * Status icon for graph nodes, from the shared stroke glyph set. Step statuses reuse\n * `StepStatusIcon`; waiting for approval shows the pause glyph and cancelled the slash, so no\n * state relies on color alone. Remount with `key={status}` to play the status-change entry.\n */\nexport function GraphStatusIcon({ status, className = \"\" }: { status: GraphStatus; className?: string }) {\n  if (status === \"waiting-for-approval\") return <StatusGlyph kind=\"waiting\" className={className} />;\n  if (status === \"cancelled\") return <StatusGlyph kind=\"skipped\" className={className} />;\n  return <StepStatusIcon status={status} className={className} />;\n}\n\n/** Icon plus the status as text. */\nexport function GraphStatusLabel({ status, label, className }: { status: GraphStatus; label?: string; className?: string }) {\n  return (\n    <span className={cx(\"inline-flex items-center gap-1.5 text-xs font-medium text-zinc-950 dark:text-white\", className)}>\n      <GraphStatusIcon key={status} status={status} />\n      {label ?? GRAPH_STATUS_LABELS[status]}\n    </span>\n  );\n}\n\n/** Controlled when `value` is defined (including `null`), otherwise keeps its own state. */\nexport function useControllableState<T>(value: T | undefined, defaultValue: T, onChange?: (next: T) => void): [T, (next: T) => void] {\n  const [own, setOwn] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : own;\n  const set = useCallback(\n    (next: T) => {\n      if (!controlled) setOwn(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set];\n}\n\nexport type GraphView = \"graph\" | \"list\";\n\nconst viewIcons: Record<GraphView, ReactNode> = {\n  graph: (\n    <>\n      <rect x=\"2\" y=\"2.5\" width=\"5\" height=\"3.5\" rx=\"1\" />\n      <rect x=\"9\" y=\"10\" width=\"5\" height=\"3.5\" rx=\"1\" />\n      <path d=\"M4.5 6v2.5a1.5 1.5 0 0 0 1.5 1.5h3\" />\n    </>\n  ),\n  list: <path d=\"M2.5 4h11M2.5 8h11M2.5 12h7\" />,\n};\n\n/** Switches between the graph and its list. Both show the same data and share selection. */\nexport function GraphViewSwitch({ view, onChange, className }: { view: GraphView; onChange: (view: GraphView) => void; className?: string }) {\n  return (\n    <div role=\"group\" aria-label=\"View\" className={cx(\"inline-flex rounded-lg border p-0.5\", hairline, className)}>\n      {([\"graph\", \"list\"] as const).map((value) => (\n        <button\n          key={value}\n          type=\"button\"\n          aria-pressed={view === value}\n          onClick={() => onChange(value)}\n          className={cx(\n            \"inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-zinc-500 select-none\",\n            \"transition-colors duration-150 ease-out motion-reduce:transition-none\",\n            \"hover:text-zinc-950 aria-pressed:bg-zinc-950/[0.06] aria-pressed:text-zinc-950\",\n            \"dark:hover:text-white dark:aria-pressed:bg-white/[0.09] dark:aria-pressed:text-white\",\n            \"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-sky-500\",\n          )}\n        >\n          <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-3.5\">\n            {viewIcons[value]}\n          </svg>\n          {value === \"graph\" ? \"Graph\" : \"List\"}\n        </button>\n      ))}\n    </div>\n  );\n}\n\nexport interface GraphListboxProps<T> {\n  items: readonly T[];\n  getId: (item: T) => string;\n  selectedId: string | null;\n  onSelect: (id: string) => void;\n  renderItem: (item: T, selected: boolean) => ReactNode;\n  /** Accessible name of the list. */\n  label: string;\n  className?: string;\n  /** Shown when there are no items. */\n  empty?: ReactNode;\n}\n\n/**\n * A single-select listbox with one tab stop. Arrow keys, Home, and End move the selection\n * (selection follows focus); Enter and Space select the focused option. Scrolls the\n * selected option into view when the selection changes elsewhere, such as in the graph.\n * Selection changes are instant: this is high-frequency inspection UI.\n */\nexport function GraphListbox<T>({ items, getId, selectedId, onSelect, renderItem, label, className, empty }: GraphListboxProps<T>) {\n  const prefix = useId();\n  const refs = useRef(new Map<string, HTMLLIElement>());\n  const index = selectedId == null ? -1 : items.findIndex((item) => getId(item) === selectedId);\n  const tabStop = index >= 0 ? index : 0;\n\n  useEffect(() => {\n    if (selectedId == null) return;\n    const element = refs.current.get(selectedId);\n    if (element && typeof element.scrollIntoView === \"function\") element.scrollIntoView({ block: \"nearest\" });\n  }, [selectedId]);\n\n  const move = (to: number) => {\n    const item = items[Math.max(0, Math.min(items.length - 1, to))];\n    if (!item) return;\n    const id = getId(item);\n    onSelect(id);\n    refs.current.get(id)?.focus();\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLUListElement>) => {\n    const keys: Record<string, () => void> = {\n      ArrowDown: () => move(index < 0 ? 0 : index + 1),\n      ArrowUp: () => move(index < 0 ? 0 : index - 1),\n      Home: () => move(0),\n      End: () => move(items.length - 1),\n    };\n    const action = keys[event.key];\n    if (action) {\n      event.preventDefault();\n      action();\n    }\n  };\n\n  if (!items.length) return <p className=\"px-4 py-6 text-sm text-zinc-500\">{empty ?? \"Nothing to show yet.\"}</p>;\n\n  return (\n    <ul role=\"listbox\" aria-label={label} onKeyDown={onKeyDown} className={cx(\"grid grid-cols-[minmax(0,1fr)] content-start gap-px p-1.5\", className)}>\n      {items.map((item, i) => {\n        const id = getId(item);\n        const selected = id === selectedId;\n        return (\n          <li\n            key={id}\n            id={`${prefix}-${i}`}\n            data-id={id}\n            ref={(element) => {\n              if (element) refs.current.set(id, element);\n              else refs.current.delete(id);\n            }}\n            role=\"option\"\n            aria-selected={selected}\n            tabIndex={i === tabStop ? 0 : -1}\n            onClick={() => onSelect(id)}\n            onKeyDown={(event) => {\n              if (event.key === \"Enter\" || event.key === \" \") {\n                event.preventDefault();\n                onSelect(id);\n              }\n            }}\n            className={cx(\n              \"min-w-0 cursor-pointer rounded-lg px-2.5 py-2 text-left\",\n              \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-500\",\n              selected ? \"bg-zinc-950/[0.06] dark:bg-white/[0.09]\" : \"hover:bg-zinc-950/[0.04] dark:hover:bg-white/[0.06]\",\n            )}\n          >\n            {renderItem(item, selected)}\n          </li>\n        );\n      })}\n    </ul>\n  );\n}\n\n/** One list row: status glyph, a name, an optional trailing value, and a meta line. Shared by every list view. */\nexport function GraphListRow({\n  status,\n  icon,\n  title,\n  mono,\n  muted,\n  struck,\n  trailing,\n  meta,\n  children,\n}: {\n  status?: GraphStatus;\n  /** Replaces the status glyph, for example an avatar. */\n  icon?: ReactNode;\n  title: ReactNode;\n  mono?: boolean;\n  /** Secondary text color for steps that have not started. */\n  muted?: boolean;\n  /** Strike the title through, for skipped steps. */\n  struck?: boolean;\n  /** Right-aligned value such as a duration. */\n  trailing?: ReactNode;\n  meta?: ReactNode;\n  children?: ReactNode;\n}) {\n  return (\n    <div className=\"flex items-start gap-2.5\">\n      <span className=\"flex h-5 shrink-0 items-center\">{icon ?? (status ? <GraphStatusIcon key={status} status={status} /> : null)}</span>\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-baseline gap-2\">\n          <p\n            className={cx(\n              \"min-w-0 flex-1 truncate text-sm leading-5 font-medium\",\n              muted ? \"text-zinc-600 dark:text-zinc-400\" : \"text-zinc-950 dark:text-white\",\n              mono && \"font-mono text-[13px]\",\n              struck && \"line-through decoration-zinc-950/30 dark:decoration-white/30\",\n            )}\n          >\n            {title}\n          </p>\n          {trailing != null ? <span className=\"shrink-0 font-mono text-[11px] text-zinc-500 tabular-nums\">{trailing}</span> : null}\n        </div>\n        {meta ? <p className=\"mt-0.5 truncate text-xs text-zinc-500\">{meta}</p> : null}\n        {children}\n      </div>\n    </div>\n  );\n}\n\n/** Code, JSON, and quoted values inside a panel. */\nexport function Well({ children, className }: { children: ReactNode; className?: string }) {\n  return (\n    <pre\n      className={cx(\n        \"max-h-44 overflow-auto rounded-lg border px-3 py-2.5 font-mono text-xs leading-relaxed whitespace-pre-wrap text-zinc-800\",\n        \"border-zinc-950/[0.07] bg-zinc-950/[0.02] dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-200\",\n        className,\n      )}\n    >\n      {children}\n    </pre>\n  );\n}\n\n/** A labelled block in a details panel. Strings render in a well. */\nexport function DetailSection({ label, children, aside }: { label: string; children: ReactNode; aside?: ReactNode }) {\n  return (\n    <div className=\"min-w-0\">\n      <div className=\"mb-1.5 flex items-baseline justify-between gap-2\">\n        <p className=\"text-xs font-medium text-zinc-500\">{label}</p>\n        {aside ? <span className=\"font-mono text-[11px] text-zinc-500 tabular-nums\">{aside}</span> : null}\n      </div>\n      {typeof children === \"string\" ? <Well>{children}</Well> : <div className=\"text-sm text-zinc-700 dark:text-zinc-300\">{children}</div>}\n    </div>\n  );\n}\n\n/** Title and status line at the top of a details panel. */\nexport function DetailHeader({ title, mono, status, statusLabel, meta }: { title: ReactNode; mono?: boolean; status?: GraphStatus; statusLabel?: string; meta?: ReactNode[] }) {\n  const parts = (meta ?? []).filter((part) => part != null && part !== false && part !== \"\");\n  return (\n    <div className=\"min-w-0\">\n      <h4 className={cx(\"text-sm font-semibold break-words text-zinc-950 dark:text-white\", mono && \"font-mono text-[13px]\")}>{title}</h4>\n      <div className=\"mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-zinc-500\">\n        {status ? <GraphStatusLabel status={status} label={statusLabel} /> : null}\n        {parts.map((part, i) => (\n          <span key={i} className=\"inline-flex items-center gap-2\">\n            {status || i > 0 ? <span aria-hidden=\"true\" className=\"text-zinc-950/25 dark:text-white/25\">·</span> : null}\n            {part}\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/** Key-value facts such as kind, duration, and start time. */\nexport function DetailProperties({ items }: { items: { label: string; value: ReactNode; mono?: boolean }[] }) {\n  return (\n    <dl className=\"grid grid-cols-[minmax(5.5rem,auto)_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-xs\">\n      {items.map((item) => (\n        <div key={item.label} className=\"contents\">\n          <dt className=\"text-zinc-500\">{item.label}</dt>\n          <dd className={cx(\"min-w-0 break-words text-zinc-950 dark:text-white\", item.mono && \"font-mono tabular-nums\")}>{item.value}</dd>\n        </div>\n      ))}\n    </dl>\n  );\n}\n\n/** A failure message with the failure tint. */\nexport function ErrorNote({ children }: { children: ReactNode }) {\n  return (\n    <div className=\"flex items-start gap-2 rounded-lg border border-red-500/35 bg-red-500/[0.08] px-3 py-2 text-sm text-red-700 dark:text-red-300\">\n      <GraphStatusIcon status=\"failed\" className=\"mt-0.5\" />\n      <p className=\"min-w-0 break-words\">{children}</p>\n    </div>\n  );\n}\n\n/** The scrolling stack of sections in a details panel, separated by dividers. */\nexport function DetailStack({ children, className }: { children: ReactNode; className?: string }) {\n  return <div className={cx(\"divide-y divide-zinc-950/[0.07] dark:divide-white/[0.08] [&>*]:px-4 [&>*]:py-3.5\", className)}>{children}</div>;\n}\n\n/** A related node in a details panel. Selecting it moves the selection there. */\nexport function RelatedButton({ label, status, note, onClick, mono }: { label: string; status?: GraphStatus; note?: string; onClick: () => void; mono?: boolean }) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={cx(\n        \"group -mx-2 flex min-h-8 w-[calc(100%+1rem)] items-center gap-2 rounded-md px-2 py-1 text-left text-sm text-zinc-700 dark:text-zinc-300\",\n        \"hover:bg-zinc-950/[0.04] hover:text-zinc-950 dark:hover:bg-white/[0.06] dark:hover:text-white\",\n        \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-500\",\n      )}\n    >\n      {status ? <GraphStatusIcon status={status} /> : null}\n      <span className={cx(\"min-w-0 flex-1 truncate\", mono && \"font-mono text-[13px]\")}>{label}</span>\n      {status ? <span className=\"sr-only\">, {GRAPH_STATUS_LABELS[status]}</span> : null}\n      {note ? <span className=\"shrink-0 text-xs text-zinc-500\">{note}</span> : null}\n      <svg\n        aria-hidden=\"true\"\n        viewBox=\"0 0 16 16\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        className=\"size-3.5 shrink-0 text-zinc-950/25 group-hover:text-zinc-950/60 dark:text-white/25 dark:group-hover:text-white/60\"\n      >\n        <path d=\"m6 4 4 4-4 4\" />\n      </svg>\n    </button>\n  );\n}\n\n/** Quiet text for an empty related list. */\nexport function DetailNote({ children }: { children: ReactNode }) {\n  return <p className=\"text-xs text-zinc-500\">{children}</p>;\n}\n\nexport function formatDuration(ms: number): string {\n  return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;\n}\n\nconst SUMMARY_ORDER: GraphStatus[] = [\"running\", \"waiting-for-approval\", \"failed\", \"succeeded\", \"queued\", \"skipped\", \"cancelled\"];\n\nfunction countStatuses(statuses: readonly GraphStatus[]) {\n  const counts = new Map<GraphStatus, number>();\n  for (const status of statuses) counts.set(status, (counts.get(status) ?? 0) + 1);\n  return SUMMARY_ORDER.filter((status) => counts.get(status)).map((status) => ({ status, count: counts.get(status)! }));\n}\n\n/** Counts per status, in a fixed order, for summaries like \"3 succeeded · 1 failed\". */\nexport function statusSummary(statuses: readonly GraphStatus[]): string {\n  return countStatuses(statuses)\n    .map(({ status, count }) => `${count} ${GRAPH_STATUS_LABELS[status].toLowerCase()}`)\n    .join(\" · \");\n}\n\n/** The status summary drawn as glyph, count, and word, for a header meta line. Reads as plain text. */\nexport function StatusCounts({ statuses, lead }: { statuses: readonly GraphStatus[]; lead?: ReactNode }) {\n  const counts = countStatuses(statuses);\n  return (\n    <span className=\"inline-flex flex-wrap items-center gap-x-3 gap-y-1\">\n      {lead != null ? <span className=\"text-zinc-600 dark:text-zinc-400\">{lead}</span> : null}\n      {counts.map(({ status, count }) => (\n        <span key={status} className=\"inline-flex items-center gap-1 [&>svg]:size-3.5\">\n          <GraphStatusIcon status={status} />\n          <span className=\"font-mono text-zinc-700 tabular-nums dark:text-zinc-300\">{count}</span>\n          {GRAPH_STATUS_LABELS[status].toLowerCase()}\n        </span>\n      ))}\n    </span>\n  );\n}\n\nexport interface GraphShellProps extends Omit<ComponentProps<\"section\">, \"title\"> {\n  title: ReactNode;\n  /** One line under the title, for example a status summary. */\n  meta?: ReactNode;\n  /** Buttons on the right of the header, such as the view switch. */\n  controls?: ReactNode;\n  /** Accessible name of the details panel. */\n  detailsLabel: string;\n  details: ReactNode;\n  /** Height of the main area; the details panel scrolls within it on wide layouts. */\n  height: number;\n  footer?: ReactNode;\n}\n\n/** The container recipe: white in light mode, pure black with a hairline in dark mode. */\nexport const graphSurfaceClass = cx(\n  \"@container w-full overflow-hidden rounded-xl border bg-white text-left shadow-[0_1px_2px_rgb(0_0_0/0.04)]\",\n  \"dark:bg-black dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]\",\n  hairline,\n);\n\n/** Header with title and meta. */\nexport function GraphHeader({ title, meta, controls }: { title: ReactNode; meta?: ReactNode; controls?: ReactNode }) {\n  return (\n    <header className={cx(\"flex flex-wrap items-center justify-between gap-x-4 gap-y-2.5 border-b px-4 py-3\", divider)}>\n      <div className=\"min-w-0 flex-[1_1_16rem]\">\n        <h3 className=\"text-sm font-semibold text-zinc-950 dark:text-white\">{title}</h3>\n        {meta ? <div className=\"mt-1 text-xs text-zinc-500\">{meta}</div> : null}\n      </div>\n      {controls ? <div className=\"flex shrink-0 flex-wrap items-center gap-2\">{controls}</div> : null}\n    </header>\n  );\n}\n\n/** Header, main area, and a details panel beside it (below it when the component is narrow). */\nexport function GraphShell({ title, meta, controls, detailsLabel, details, height, footer, className, children, ...props }: GraphShellProps) {\n  return (\n    <section className={cx(graphSurfaceClass, className)} {...props}>\n      <GraphHeader title={title} meta={meta} controls={controls} />\n      <div className=\"grid @2xl:grid-cols-[minmax(0,1fr)_18rem]\">\n        <div className=\"min-w-0\">{children}</div>\n        <aside\n          aria-label={detailsLabel}\n          style={{ [\"--graph-height\" as string]: `${height}px` }}\n          className={cx(\"min-w-0 border-t @2xl:max-h-(--graph-height) @2xl:overflow-y-auto @2xl:border-t-0 @2xl:border-l\", divider)}\n        >\n          {details}\n        </aside>\n      </div>\n      {footer ? <footer className={cx(\"border-t px-4 py-2.5\", divider)}>{footer}</footer> : null}\n    </section>\n  );\n}\n\n/** Explains edge styles in text, so edge types never rely on color or dash alone. */\nexport function EdgeLegend({ items }: { items: { label: string; dashed?: boolean }[] }) {\n  return (\n    <ul aria-label=\"Edge types\" className=\"flex flex-wrap gap-x-5 gap-y-1 text-xs text-zinc-500\">\n      {items.map((item) => (\n        <li key={item.label} className=\"flex items-center gap-2\">\n          <svg aria-hidden=\"true\" viewBox=\"0 0 28 8\" className=\"h-2 w-7 text-zinc-950/40 dark:text-white/40\">\n            <path d=\"M1 4h20\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeDasharray={item.dashed ? \"3.5 2.5\" : undefined} />\n            <path d=\"M27 4 20.5 0.75 22 4l-1.5 3.25Z\" fill=\"currentColor\" />\n          </svg>\n          {item.label}\n        </li>\n      ))}\n    </ul>\n  );\n}\n\n/** Placeholder for the details panel before anything is selected. */\nexport function EmptyDetails({ children, hint }: { children: ReactNode; hint?: ReactNode }) {\n  return (\n    <div className=\"flex flex-col gap-3 p-4\">\n      <svg aria-hidden=\"true\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-5 text-zinc-950/30 dark:text-white/30\">\n        <rect x=\"2.5\" y=\"3\" width=\"6\" height=\"4.5\" rx=\"1.25\" />\n        <rect x=\"11.5\" y=\"12.5\" width=\"6\" height=\"4.5\" rx=\"1.25\" />\n        <path d=\"M5.5 7.5v3a2 2 0 0 0 2 2h4\" />\n      </svg>\n      <p className=\"text-sm text-pretty text-zinc-600 dark:text-zinc-400\">{children}</p>\n      {hint ? <p className=\"text-xs text-pretty text-zinc-500\">{hint}</p> : null}\n    </div>\n  );\n}\n\n/** Up to three names, then \"+N more\". */\nexport function nameList(names: readonly string[]): string {\n  return names.length <= 3 ? names.join(\", \") : `${names.slice(0, 3).join(\", \")} +${names.length - 3} more`;\n}\n"
    },
    {
      "path": "graph/graph-canvas.tsx",
      "content": "\"use client\";\n\nimport \"@xyflow/react/dist/style.css\";\nimport {\n  Background,\n  BackgroundVariant,\n  BaseEdge,\n  type Edge,\n  EdgeLabelRenderer,\n  type EdgeProps,\n  type FitViewOptions,\n  Handle,\n  MiniMap,\n  type Node,\n  type NodeProps,\n  Panel,\n  type ReactFlowState,\n  Position,\n  ReactFlow,\n  ReactFlowProvider,\n  useReactFlow,\n  useStore,\n  useStoreApi,\n} from \"@xyflow/react\";\nimport { createContext, memo, type ReactNode, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport {\n  GRAPH_STATUS_LABELS,\n  type GraphDirection,\n  type GraphEdgeRef,\n  type GraphLayout,\n  type GraphLayoutOptions,\n  type GraphNodeRef,\n  type GraphStatus,\n  layoutGraph,\n  type Point,\n  structureKey,\n} from \"./graph-layout\";\nimport { cx, GraphStatusIcon } from \"./graph-ui\";\n\n/** How a node relates to the current selection. Shown with a text tag as well as color. */\nexport type GraphNodeTone = \"default\" | \"upstream\" | \"downstream\" | \"muted\";\nexport type GraphEdgeTone = \"default\" | \"highlight\" | \"selected\" | \"muted\";\n\n/** A node as the canvas draws it. Plain data, so unchanged nodes are not re-rendered. */\nexport interface GraphCanvasNode {\n  id: string;\n  title: string;\n  /** Secondary text after the status, for example the node kind. */\n  subtitle?: string;\n  /** Render the title in a monospace font, for tool names. */\n  mono?: boolean;\n  status?: GraphStatus;\n  /** Replaces the default status text, for example \"Pending\" for a hand-off. */\n  statusLabel?: string;\n  /** Initials shown in a badge when the node has no status, for example an actor. */\n  avatar?: string;\n  /** Shape of the avatar badge: a circle (default) for people, a square for agents and systems. */\n  avatarShape?: \"circle\" | \"square\";\n  /** A short text tag at the start of the node's meta line, for example \"Upstream\". */\n  tag?: string;\n  tone?: GraphNodeTone;\n  hidden?: boolean;\n}\n\nexport interface GraphCanvasEdge {\n  id: string;\n  source: string;\n  target: string;\n  type: string;\n  label?: string;\n  /** Status icon shown in the label. */\n  labelStatus?: GraphStatus;\n  dashed?: boolean;\n  tone?: GraphEdgeTone;\n  /**\n   * The edge carries work that is running now. Drawn in the running color; the dashes also\n   * move when `animateEdges` is on and the user has not asked for reduced motion.\n   */\n  animated?: boolean;\n  hidden?: boolean;\n  /** Clicking the edge or its label calls `onEdgeClick`. */\n  selectable?: boolean;\n}\n\nexport interface GraphCanvasProps {\n  nodes: readonly GraphCanvasNode[];\n  edges: readonly GraphCanvasEdge[];\n  /** From `useGraphLayout`. Positions come from here, never from statuses. */\n  layout: GraphLayout;\n  selectedNodeId?: string | null;\n  selectedEdgeId?: string | null;\n  onNodeClick?: (id: string) => void;\n  onEdgeClick?: (id: string) => void;\n  onPaneClick?: () => void;\n  /** React Flow needs a sized container. Defaults to 420px. */\n  height?: number | string;\n  /** Accessible name of the canvas region. */\n  label: string;\n  /** Show the mini map. Defaults to on for graphs with more than 24 nodes. */\n  minimap?: boolean;\n  /**\n   * Move the dashes on edges marked `animated`. Off by default: running edges are already\n   * drawn in the running color, and a looping animation adds nothing a glance needs.\n   * Always off with reduced motion.\n   */\n  animateEdges?: boolean;\n  /** Show the dot grid behind the graph. Defaults to on. */\n  grid?: boolean;\n  className?: string;\n  /** Extra controls rendered in the canvas, top left. */\n  toolbar?: ReactNode;\n}\n\nconst REDUCED_MOTION = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const query = window.matchMedia(REDUCED_MOTION);\n  query.addEventListener(\"change\", callback);\n  return () => query.removeEventListener(\"change\", callback);\n}\n\n/** True when the user asked for reduced motion. False during server rendering. */\nexport function usePrefersReducedMotion(): boolean {\n  return useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_MOTION).matches,\n    () => false,\n  );\n}\n\n/**\n * Lays the graph out once per structure. Status, label, and type changes keep the same\n * layout object, so live updates never move nodes; adding or removing nodes or edges re-runs it.\n */\nexport function useGraphLayout(nodes: readonly GraphNodeRef[], edges: readonly GraphEdgeRef[], options: GraphLayoutOptions = {}): GraphLayout {\n  const key = structureKey(nodes, edges, options);\n  const latest = useRef({ nodes, edges, options });\n  latest.current = { nodes, edges, options };\n  // Keyed on the structure only: dagre is deterministic, so equal keys give equal layouts.\n  return useMemo(() => layoutGraph(latest.current.nodes, latest.current.edges, latest.current.options), [key]);\n}\n\ntype CardData = { node: GraphCanvasNode; selected: boolean; clickable: boolean; direction: GraphDirection; width: number; height: number };\ntype FlowNode = Node<CardData, \"card\">;\ntype RouteData = { edge: GraphCanvasEdge; points: Point[]; label: Point; selected: boolean };\ntype FlowEdge = Edge<RouteData, \"route\">;\n\nconst CanvasContext = createContext<{ onEdgeClick?: (id: string) => void }>({});\n\nconst hiddenHandle = { opacity: 0, pointerEvents: \"none\", width: 1, height: 1, minWidth: 0, minHeight: 0, border: 0 } as const;\n\n// Status frames: a border tint plus a soft fill of the status hue, painted as a flat gradient\n// layer over the opaque card color so the dot grid never shows through.\nconst statusFrame: Partial<Record<GraphStatus, string>> = {\n  running: \"border-sky-500/45 bg-linear-to-b from-sky-500/[0.07] to-sky-500/[0.07]\",\n  \"waiting-for-approval\": \"border-amber-500/50 bg-linear-to-b from-amber-500/[0.09] to-amber-500/[0.09]\",\n  failed: \"border-red-500/50 bg-linear-to-b from-red-500/[0.09] to-red-500/[0.09]\",\n};\n\nconst statusWord: Partial<Record<GraphStatus, string>> = {\n  running: \"text-sky-600 dark:text-sky-400\",\n  \"waiting-for-approval\": \"text-amber-600 dark:text-amber-400\",\n  failed: \"text-red-600 dark:text-red-400\",\n};\n\nconst idleStatuses = new Set<GraphStatus>([\"queued\", \"skipped\", \"cancelled\"]);\n\nconst NodeCard = memo(function NodeCard({ data }: NodeProps<FlowNode>) {\n  const { node, selected, clickable, direction, width, height } = data;\n  const muted = node.tone === \"muted\" && !selected;\n  const idle = node.status ? idleStatuses.has(node.status) : false;\n  const frame = muted ? \"border-zinc-950/[0.07] dark:border-white/[0.08]\" : (node.status && statusFrame[node.status]) || \"border-zinc-950/12 dark:border-white/15\";\n  return (\n    <div\n      style={{ width, height }}\n      className={cx(\n        \"relative flex items-center rounded-lg border bg-white px-3 text-left dark:bg-black\",\n        frame,\n        selected\n          ? \"border-zinc-950! shadow-[0_0_0_3px_rgb(9_9_11/0.1)] dark:border-white! dark:shadow-[0_0_0_3px_rgb(255_255_255/0.2)]\"\n          : clickable && !muted && \"hover:border-zinc-950/35 dark:hover:border-white/40\",\n      )}\n    >\n      <Handle type=\"target\" position={direction === \"LR\" ? Position.Left : Position.Top} isConnectable={false} style={hiddenHandle} />\n      <div className={cx(\"flex min-w-0 flex-1 items-center gap-2.5\", muted && \"opacity-35\")}>\n        {node.status ? (\n          <GraphStatusIcon key={node.status} status={node.status} />\n        ) : node.avatar ? (\n          <span\n            aria-hidden=\"true\"\n            className={cx(\n              \"grid size-7 shrink-0 place-items-center border border-zinc-950/15 text-[10px] font-semibold tracking-wide text-zinc-700 dark:border-white/20 dark:text-zinc-200\",\n              node.avatarShape === \"square\" ? \"rounded-md\" : \"rounded-full\",\n            )}\n          >\n            {node.avatar}\n          </span>\n        ) : null}\n        <div className=\"min-w-0 flex-1\">\n          <p\n            className={cx(\n              \"truncate text-[13px] leading-[18px] font-medium\",\n              idle && !selected ? \"text-zinc-600 dark:text-zinc-400\" : \"text-zinc-950 dark:text-white\",\n              node.mono && \"font-mono text-[12.5px] tracking-tight\",\n              node.status === \"skipped\" && \"line-through decoration-zinc-950/30 dark:decoration-white/30\",\n            )}\n          >\n            {node.title}\n          </p>\n          <p className=\"truncate text-[11px] leading-4 text-zinc-500\">\n            {node.tag ? (\n              <span\n                className={cx(\n                  \"mr-1.5 inline-block rounded-[4px] border px-1 align-[0.5px] text-[10px] leading-[13px] font-medium\",\n                  \"border-zinc-950/20 text-zinc-800 dark:border-white/25 dark:text-zinc-100\",\n                )}\n              >\n                {node.tag}\n              </span>\n            ) : null}\n            {node.status ? <span className={statusWord[node.status]}>{node.statusLabel ?? GRAPH_STATUS_LABELS[node.status]}</span> : null}\n            {node.status && node.subtitle ? <span aria-hidden=\"true\"> · </span> : null}\n            {node.subtitle}\n          </p>\n        </div>\n      </div>\n      <Handle type=\"source\" position={direction === \"LR\" ? Position.Right : Position.Bottom} isConnectable={false} style={hiddenHandle} />\n    </div>\n  );\n});\n\ntype EdgeLook = GraphEdgeTone | \"active\";\n\n// React Flow's stylesheet is not in a cascade layer, so edge colors are set through its CSS\n// variables rather than stroke utilities, which it would override. Colors are opaque mixes\n// for the card background, so crossings and arrowheads never double up.\nconst edgeTone: Record<EdgeLook, string> = {\n  default: \"[--xy-edge-stroke:#b9b9bf] dark:[--xy-edge-stroke:#4a4a4f] [--xy-edge-stroke-width:1.5]\",\n  highlight: \"[--xy-edge-stroke:#52525b] dark:[--xy-edge-stroke:#c8c8cc] [--xy-edge-stroke-width:1.5]\",\n  selected: \"[--xy-edge-stroke:#09090b] dark:[--xy-edge-stroke:#ffffff] [--xy-edge-stroke-width:2]\",\n  muted: \"[--xy-edge-stroke:#ececee] dark:[--xy-edge-stroke:#1f1f22] [--xy-edge-stroke-width:1.5]\",\n  active: \"[--xy-edge-stroke:#0284c7] dark:[--xy-edge-stroke:#38bdf8] [--xy-edge-stroke-width:1.5]\",\n};\n\nconst ARROW = 8;\nconst ARROW_HALF = 3.75;\nconst ARROW_NOTCH = 0.7;\n\n/** A smooth path through dagre's route points, ending in the notch of a swept arrowhead. */\nfunction routePath(points: Point[]): { path: string; arrow: string } {\n  const last = points[points.length - 1]!;\n  const before = points[points.length - 2]!;\n  const length = Math.hypot(last.x - before.x, last.y - before.y) || 1;\n  const ux = (last.x - before.x) / length;\n  const uy = (last.y - before.y) / length;\n  const end = { x: last.x - ux * ARROW * ARROW_NOTCH, y: last.y - uy * ARROW * ARROW_NOTCH };\n  const route = [...points.slice(0, -1), end];\n  let path = `M ${route[0]!.x} ${route[0]!.y}`;\n  for (let i = 1; i < route.length - 1; i += 1) {\n    const point = route[i]!;\n    const next = route[i + 1]!;\n    path += ` Q ${point.x} ${point.y} ${(point.x + next.x) / 2} ${(point.y + next.y) / 2}`;\n  }\n  path += ` L ${end.x} ${end.y}`;\n  const base = { x: last.x - ux * ARROW, y: last.y - uy * ARROW };\n  const arrow = `M ${last.x} ${last.y} L ${base.x - uy * ARROW_HALF} ${base.y + ux * ARROW_HALF} L ${end.x} ${end.y} L ${base.x + uy * ARROW_HALF} ${base.y - ux * ARROW_HALF} Z`;\n  return { path, arrow };\n}\n\nfunction RouteEdge({ id, data, sourceX, sourceY, targetX, targetY }: EdgeProps<FlowEdge>) {\n  const { onEdgeClick } = useContext(CanvasContext);\n  if (!data) return null;\n  const { edge, selected } = data;\n  const points = data.points.length >= 2 ? data.points : [{ x: sourceX, y: sourceY }, { x: targetX, y: targetY }];\n  const { path, arrow } = routePath(points);\n  const muted = edge.tone === \"muted\" && !selected;\n  const look: EdgeLook = selected ? \"selected\" : muted ? \"muted\" : edge.animated ? \"active\" : (edge.tone ?? \"default\");\n  return (\n    <>\n      <g className={edgeTone[look]}>\n        <BaseEdge id={id} path={path} interactionWidth={edge.selectable ? 18 : 0} style={edge.dashed ? { strokeDasharray: \"5 4\" } : undefined} />\n        <path d={arrow} style={{ fill: \"var(--xy-edge-stroke)\", stroke: \"none\" }} />\n      </g>\n      {edge.label ? (\n        <EdgeLabelRenderer>\n          <div\n            style={{ transform: `translate(-50%, -50%) translate(${data.label.x}px, ${data.label.y}px)` }}\n            title={edge.label}\n            onClick={edge.selectable ? () => onEdgeClick?.(id) : undefined}\n            className={cx(\n              \"nodrag nopan pointer-events-auto absolute flex h-5 max-w-[9rem] items-center gap-1 rounded-md border bg-white px-1.5 text-[11px] leading-none whitespace-nowrap dark:bg-black\",\n              \"[&>svg]:size-3.5\",\n              selected\n                ? \"z-10 border-zinc-950 text-zinc-950 shadow-[0_0_0_3px_rgb(9_9_11/0.1)] dark:border-white dark:text-white dark:shadow-[0_0_0_3px_rgb(255_255_255/0.2)]\"\n                : muted\n                  ? \"border-zinc-950/[0.07] text-zinc-400 dark:border-white/[0.08] dark:text-zinc-600 [&>svg]:opacity-40\"\n                  : \"border-zinc-950/12 text-zinc-600 dark:border-white/15 dark:text-zinc-400\",\n              edge.selectable && !selected && \"cursor-pointer hover:border-zinc-950/35 hover:text-zinc-950 dark:hover:border-white/40 dark:hover:text-white\",\n              edge.hidden && \"hidden\",\n            )}\n          >\n            {edge.labelStatus ? <GraphStatusIcon key={edge.labelStatus} status={edge.labelStatus} /> : null}\n            <span className=\"truncate\">{edge.label}</span>\n          </div>\n        </EdgeLabelRenderer>\n      ) : null}\n    </>\n  );\n}\n\nconst nodeTypes = { card: NodeCard };\nconst edgeTypes = { route: RouteEdge };\n\n// Mini map fills read CSS variables set on the canvas, so they follow the theme.\nconst minimapColor: Partial<Record<GraphStatus, string>> = {\n  running: \"#0ea5e9\",\n  \"waiting-for-approval\": \"#f59e0b\",\n  failed: \"#ef4444\",\n};\n\n/**\n * The shared React Flow canvas: laid-out cards, routed edges, zoom, pan, fit-to-view,\n * and an optional mini map. Nodes are not draggable; positions come from the layout.\n * Keyboard users select through the list each graph component renders alongside it.\n */\nexport function GraphCanvas(props: GraphCanvasProps) {\n  return (\n    <ReactFlowProvider>\n      <Canvas {...props} />\n    </ReactFlowProvider>\n  );\n}\n\nfunction Canvas({\n  nodes,\n  edges,\n  layout,\n  selectedNodeId = null,\n  selectedEdgeId = null,\n  onNodeClick,\n  onEdgeClick,\n  onPaneClick,\n  height = 420,\n  label,\n  minimap,\n  animateEdges = false,\n  grid = true,\n  className,\n  toolbar,\n}: GraphCanvasProps) {\n  const reduced = usePrefersReducedMotion();\n  // Only the viewport eases (fit, zoom, bringing a selection into view); selection itself is instant.\n  const duration = reduced ? 0 : 240;\n  const showMinimap = minimap ?? nodes.length > 24;\n  const animate = !reduced && animateEdges;\n  // Keep fitted graphs clear of the zoom controls (bottom left) and the mini map (top right).\n  const padding = useMemo(() => ({ top: showMinimap ? \"116px\" : \"28px\", right: \"28px\", bottom: \"64px\", left: \"28px\" }) as const, [showMinimap]);\n  const flow = useReactFlow<FlowNode, FlowEdge>();\n  const store = useStoreApi<FlowNode, FlowEdge>();\n  const { nodeWidth: width, nodeHeight: nodeHeight, direction } = layout;\n  const clickable = Boolean(onNodeClick);\n\n  // Reuse node and edge objects whose content did not change, so React Flow re-renders only what changed.\n  const nodeCache = useRef(new Map<string, { signature: string; value: FlowNode }>());\n  const flowNodes = useMemo(() => {\n    const next = new Map<string, { signature: string; value: FlowNode }>();\n    const result = nodes.map((node) => {\n      const position = layout.positions[node.id] ?? { x: 0, y: 0 };\n      const selected = node.id === selectedNodeId;\n      const signature = JSON.stringify([node, position, selected, clickable, direction, width, nodeHeight]);\n      const cached = nodeCache.current.get(node.id);\n      const value: FlowNode =\n        cached && cached.signature === signature\n          ? cached.value\n          : {\n              id: node.id,\n              type: \"card\",\n              position,\n              width,\n              height: nodeHeight,\n              hidden: node.hidden,\n              zIndex: selected ? 1 : 0,\n              className: clickable ? \"cursor-pointer\" : undefined,\n              data: { node, selected, clickable, direction, width, height: nodeHeight },\n            };\n      next.set(node.id, { signature, value });\n      return value;\n    });\n    nodeCache.current = next;\n    return result;\n  }, [nodes, layout, selectedNodeId, direction, width, nodeHeight, clickable]);\n\n  const edgeCache = useRef(new Map<string, { signature: string; value: FlowEdge }>());\n  const flowEdges = useMemo(() => {\n    const next = new Map<string, { signature: string; value: FlowEdge }>();\n    const result: FlowEdge[] = [];\n    for (const edge of edges) {\n      const route = layout.edges[edge.id];\n      if (!route) continue;\n      const selected = edge.id === selectedEdgeId;\n      const animated = Boolean(edge.animated && animate);\n      const signature = JSON.stringify([edge, route.points.length, route.label, selected, animated]);\n      const cached = edgeCache.current.get(edge.id);\n      const value: FlowEdge =\n        cached && cached.signature === signature && cached.value.data?.points === route.points\n          ? cached.value\n          : {\n              id: edge.id,\n              source: edge.source,\n              target: edge.target,\n              type: \"route\",\n              hidden: edge.hidden,\n              animated,\n              zIndex: selected ? 3 : edge.tone === \"highlight\" ? 2 : edge.animated ? 1 : 0,\n              data: { edge, points: route.points, label: route.label, selected },\n            };\n      next.set(edge.id, { signature, value });\n      result.push(value);\n    }\n    edgeCache.current = next;\n    return result;\n  }, [edges, layout, selectedEdgeId, animate]);\n\n  // Refit when the visible structure changes (nodes added or removed, or a branch focused), not on status changes.\n  const hiddenKey = nodes.filter((node) => node.hidden).map((node) => node.id).join(\",\");\n  const fitKey = `${layout.key}|${hiddenKey}`;\n  const lastFit = useRef(fitKey);\n  useEffect(() => {\n    if (lastFit.current === fitKey) return;\n    lastFit.current = fitKey;\n    const frame = requestAnimationFrame(() => void flow.fitView({ padding, maxZoom: 1, duration }));\n    return () => cancelAnimationFrame(frame);\n  }, [fitKey, flow, duration, padding]);\n\n  // Bring a selection made elsewhere (the list, a details link) into view, keeping the zoom.\n  const target = useMemo(() => {\n    if (selectedNodeId && layout.positions[selectedNodeId]) {\n      const position = layout.positions[selectedNodeId]!;\n      return { x: position.x + width / 2, y: position.y + nodeHeight / 2, halfWidth: width / 2, halfHeight: nodeHeight / 2 };\n    }\n    if (selectedEdgeId && layout.edges[selectedEdgeId]) {\n      const point = layout.edges[selectedEdgeId]!.label;\n      return { x: point.x, y: point.y, halfWidth: 60, halfHeight: 14 };\n    }\n    return null;\n  }, [selectedNodeId, selectedEdgeId, layout, width, nodeHeight]);\n  useEffect(() => {\n    if (!target) return;\n    const { width: viewWidth, height: viewHeight, transform } = store.getState();\n    if (!viewWidth || !viewHeight) return;\n    const [tx, ty, zoom] = transform;\n    const left = (target.x - target.halfWidth) * zoom + tx;\n    const right = (target.x + target.halfWidth) * zoom + tx;\n    const top = (target.y - target.halfHeight) * zoom + ty;\n    const bottom = (target.y + target.halfHeight) * zoom + ty;\n    const margin = 16;\n    if (left < margin || top < margin || right > viewWidth - margin || bottom > viewHeight - margin) {\n      void flow.setCenter(target.x, target.y, { zoom: Math.max(zoom, 0.6), duration });\n    }\n    // Only when the selection target changes; not on every viewport change.\n  }, [target]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const context = useMemo(() => ({ onEdgeClick }), [onEdgeClick]);\n\n  return (\n    <CanvasContext.Provider value={context}>\n      <div\n        role=\"group\"\n        aria-label={label}\n        style={{ height }}\n        className={cx(\n          \"relative w-full bg-zinc-950/[0.015] text-zinc-950 dark:bg-black dark:text-white\",\n          \"[--xy-background-pattern-color:rgb(9_9_11/0.13)] dark:[--xy-background-pattern-color:rgb(255_255_255/0.11)]\",\n          \"[--xy-minimap-background-color:#fff] dark:[--xy-minimap-background-color:#000]\",\n          \"[--xy-minimap-mask-background-color:rgb(9_9_11/0.035)] dark:[--xy-minimap-mask-background-color:rgb(255_255_255/0.045)]\",\n          \"[--xy-minimap-mask-stroke-color:rgb(9_9_11/0.35)] dark:[--xy-minimap-mask-stroke-color:rgb(255_255_255/0.45)]\",\n          \"[--graph-minimap-node:#d4d4d8] dark:[--graph-minimap-node:#3f3f46] [--graph-minimap-selected:#09090b] dark:[--graph-minimap-selected:#ffffff]\",\n          className,\n        )}\n      >\n        <ReactFlow<FlowNode, FlowEdge>\n          nodes={flowNodes}\n          edges={flowEdges}\n          nodeTypes={nodeTypes}\n          edgeTypes={edgeTypes}\n          onNodeClick={onNodeClick ? (_, node) => onNodeClick(node.id) : undefined}\n          onEdgeClick={onEdgeClick ? (_, edge) => (edge.data?.edge.selectable ? onEdgeClick(edge.id) : undefined) : undefined}\n          onPaneClick={onPaneClick}\n          nodesDraggable={false}\n          nodesConnectable={false}\n          nodesFocusable={false}\n          edgesFocusable={false}\n          elementsSelectable={false}\n          disableKeyboardA11y\n          fitView\n          fitViewOptions={{ padding, maxZoom: 1 }}\n          minZoom={0.05}\n          maxZoom={2}\n          // The attribution link is drawn below in the canvas style instead of React Flow's default badge.\n          proOptions={{ hideAttribution: true }}\n        >\n          {grid ? <Background variant={BackgroundVariant.Dots} gap={16} size={1} /> : null}\n          {toolbar ? (\n            <Panel position=\"top-left\" className=\"flex flex-wrap items-center gap-1.5\">\n              {toolbar}\n            </Panel>\n          ) : null}\n          <Panel position=\"bottom-left\">\n            <CanvasControls duration={duration} padding={padding} />\n          </Panel>\n          <Panel position=\"bottom-right\" className=\"m-2!\">\n            <a\n              href=\"https://reactflow.dev\"\n              target=\"_blank\"\n              rel=\"noreferrer\"\n              className=\"rounded px-1 text-[10px] text-zinc-950/35 hover:text-zinc-950/60 focus-visible:outline-2 focus-visible:outline-sky-500 dark:text-white/30 dark:hover:text-white/60\"\n            >\n              React Flow\n            </a>\n          </Panel>\n          {showMinimap ? (\n            <MiniMap<FlowNode>\n              position=\"top-right\"\n              pannable\n              zoomable\n              ariaLabel=\"Mini map\"\n              nodeColor={(node) => (node.data.selected ? \"var(--graph-minimap-selected)\" : (node.data.node.status && minimapColor[node.data.node.status]) || \"var(--graph-minimap-node)\")}\n              nodeStrokeWidth={0}\n              nodeBorderRadius={4}\n              style={{ width: 140, height: 92 }}\n              className=\"overflow-hidden rounded-lg border border-zinc-950/10 dark:border-white/15\"\n            />\n          ) : null}\n        </ReactFlow>\n      </div>\n    </CanvasContext.Provider>\n  );\n}\n\nconst zoomSelector = (state: ReactFlowState) => Math.round(state.transform[2] * 100);\n\n/** Zoom out, the zoom level (click to reset to 100%), zoom in, and fit to view, in one hairline group. */\nfunction CanvasControls({ duration, padding }: { duration: number; padding: FitViewOptions[\"padding\"] }) {\n  const flow = useReactFlow();\n  const zoom = useStore(zoomSelector);\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Canvas controls\"\n      className=\"flex items-center gap-0.5 rounded-lg border border-zinc-950/10 bg-white p-0.5 shadow-[0_1px_2px_rgb(0_0_0/0.05)] dark:border-white/15 dark:bg-black dark:shadow-none\"\n    >\n      <CanvasButton label=\"Zoom out\" onClick={() => void flow.zoomOut({ duration })}>\n        <path d=\"M3.5 8h9\" />\n      </CanvasButton>\n      <button\n        type=\"button\"\n        aria-label={`Zoom ${zoom}%. Reset to 100%`}\n        title=\"Reset to 100%\"\n        onClick={() => void flow.zoomTo(1, { duration })}\n        className={cx(\n          \"h-7 min-w-11 rounded-md px-1 font-mono text-[11px] text-zinc-600 tabular-nums dark:text-zinc-400\",\n          \"transition-colors duration-150 ease-out hover:bg-zinc-950/[0.05] hover:text-zinc-950 motion-reduce:transition-none dark:hover:bg-white/[0.08] dark:hover:text-white\",\n          \"focus-visible:outline-2 focus-visible:outline-sky-500\",\n        )}\n      >\n        {zoom}%\n      </button>\n      <CanvasButton label=\"Zoom in\" onClick={() => void flow.zoomIn({ duration })}>\n        <path d=\"M8 3.5v9M3.5 8h9\" />\n      </CanvasButton>\n      <span aria-hidden=\"true\" className=\"mx-0.5 h-4 w-px bg-zinc-950/10 dark:bg-white/15\" />\n      <CanvasButton label=\"Fit to view\" onClick={() => void flow.fitView({ padding, maxZoom: 1, duration })}>\n        <path d=\"M2.75 6V3.75a1 1 0 0 1 1-1H6M10 2.75h2.25a1 1 0 0 1 1 1V6M13.25 10v2.25a1 1 0 0 1-1 1H10M6 13.25H3.75a1 1 0 0 1-1-1V10\" />\n      </CanvasButton>\n    </div>\n  );\n}\n\nfunction CanvasButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      onClick={onClick}\n      className={cx(\n        \"grid size-7 place-items-center rounded-md text-zinc-600 dark:text-zinc-400\",\n        \"transition-colors duration-150 ease-out hover:bg-zinc-950/[0.05] hover:text-zinc-950 motion-reduce:transition-none dark:hover:bg-white/[0.08] dark:hover:text-white\",\n        \"focus-visible:outline-2 focus-visible:outline-sky-500\",\n      )}\n    >\n      <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-4\">\n        {children}\n      </svg>\n    </button>\n  );\n}\n"
    }
  ],
  "example": {
    "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { GraphCanvas, type GraphCanvasEdge, type GraphCanvasNode, useGraphLayout } from \"@/components/seamless/graph/graph-canvas\";\n\nconst nodes: GraphCanvasNode[] = [\n  { id: \"a\", title: \"Fetch data\", subtitle: \"step\", status: \"succeeded\" },\n  { id: \"b\", title: \"Clean rows\", subtitle: \"step\", status: \"running\" },\n  { id: \"c\", title: \"Train model\", subtitle: \"step\", status: \"queued\" },\n];\n\nconst edges: GraphCanvasEdge[] = [\n  { id: \"a-b\", source: \"a\", target: \"b\", type: \"sequence\", animated: true },\n  { id: \"b-c\", source: \"b\", target: \"c\", type: \"sequence\" },\n];\n\nexport default function GraphCanvasExample() {\n  // The layout is computed once per structure; status changes reuse it.\n  const layout = useGraphLayout(nodes, edges);\n  const [selected, setSelected] = useState<string | null>(\"b\");\n  return (\n    <GraphCanvas\n      label=\"Pipeline\"\n      nodes={nodes}\n      edges={edges}\n      layout={layout}\n      selectedNodeId={selected}\n      onNodeClick={setSelected}\n      onPaneClick={() => setSelected(null)}\n      height={240}\n      className=\"max-w-2xl overflow-hidden rounded-xl border border-zinc-950/10 dark:border-white/10\"\n    />\n  );\n}\n"
  }
}
