{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "workflow-canvas",
  "title": "Workflow canvas",
  "description": "Renders an ApprovalPolicy as a laid-out React Flow graph: automatic layout on real node sizes (react-flow-auto-layout), aligned step edges, diff and status overlays, click-to-select and pan-to-focus.",
  "dependencies": [
    "@xyflow/react",
    "react-flow-auto-layout",
    "dagre"
  ],
  "devDependencies": [
    "@types/dagre"
  ],
  "registryDependencies": [
    "https://approvals-ui.vercel.app/r/approval-node.json",
    "https://approvals-ui.vercel.app/r/terminal-node.json",
    "https://approvals-ui.vercel.app/r/approvals-core.json"
  ],
  "files": [
    {
      "path": "components/approvals-ui/workflow-canvas.tsx",
      "content": "\"use client\";\n\nimport \"@xyflow/react/dist/style.css\";\nimport {\n  Background,\n  Controls,\n  type Edge,\n  type EdgeTypes,\n  type Node,\n  type NodeTypes,\n  ReactFlow,\n  ReactFlowProvider,\n  useReactFlow,\n} from \"@xyflow/react\";\nimport { useEffect, useMemo } from \"react\";\nimport { AlignedStepEdge, useAutoLayout, withAlignedElbows } from \"react-flow-auto-layout/react\";\n\nimport type { StepChange } from \"@/lib/approvals-ui/diff\";\nimport type { ApprovalPolicy } from \"@/lib/approvals-ui/policy\";\nimport type { PolicyIssue } from \"@/lib/approvals-ui/validate\";\n\nimport {\n  type ApprovalFlowNode,\n  ApprovalNode,\n  type StepStatus,\n} from \"@/components/approvals-ui/approval-node\";\nimport { type TerminalFlowNode, TerminalNode } from \"@/components/approvals-ui/terminal-node\";\nimport { cn } from \"@/lib/utils\";\n\nexport type CanvasNode = ApprovalFlowNode | TerminalFlowNode;\n\nconst NODE_TYPES = {\n  approval: ApprovalNode,\n  terminal: TerminalNode,\n} satisfies NodeTypes;\n\nconst EDGE_TYPES = {\n  alignedStep: AlignedStepEdge,\n} satisfies EdgeTypes;\n\nexport type WorkflowCanvasProps = {\n  policy: ApprovalPolicy;\n  /** Diff overlay from a pending proposal: rings added/changed steps. */\n  changes?: StepChange[];\n  /** Validation overlay: rings steps carrying an error or warning. */\n  issues?: PolicyIssue[];\n  /** Live run overlay: per-step status for one request. */\n  statuses?: Record<string, StepStatus>;\n  /** \"LR\" (default) lays out left to right, \"TB\" top to bottom. */\n  direction?: \"TB\" | \"LR\";\n  /** Gap between sibling branches. Defaults to the layout library's 40. */\n  nodeSep?: number;\n  /** Gap between ranks. Defaults to the layout library's 110. */\n  rankSep?: number;\n  selectedId?: string | null;\n  onSelectStep?: (stepId: string | null) => void;\n  /** Pass a fresh object to pan the viewport to a step. */\n  focus?: { stepId: string } | null;\n  className?: string;\n  children?: React.ReactNode;\n};\n\nconst buildNodes = (\n  policy: ApprovalPolicy,\n  props: Pick<WorkflowCanvasProps, \"changes\" | \"issues\" | \"statuses\" | \"selectedId\">,\n  isVertical: boolean\n): CanvasNode[] => {\n  const changes = props.changes ?? [];\n  const issues = props.issues ?? [];\n\n  const changeById = new Map<string, StepChange[\"kind\"]>();\n  for (const change of changes) {\n    if (change.kind !== \"unchanged\") changeById.set(change.id, change.kind);\n  }\n\n  const issueById = new Map<string, \"error\" | \"warning\">();\n  for (const issue of issues) {\n    for (const stepId of issue.stepIds) {\n      if (issue.severity === \"error\" || !issueById.has(stepId)) {\n        issueById.set(stepId, issue.severity);\n      }\n    }\n  }\n\n  const incomingIds = new Set<string>();\n  for (const step of policy.steps) {\n    for (const nextId of step.next) incomingIds.add(nextId);\n  }\n\n  return policy.steps.map((step) => {\n    const shared = {\n      change: changeById.get(step.id),\n      issue: issueById.get(step.id),\n      status: props.statuses?.[step.id],\n      selected: props.selectedId === step.id,\n      vertical: isVertical,\n    };\n    if (step.kind === \"terminal\") {\n      return {\n        id: step.id,\n        type: \"terminal\",\n        position: { x: 0, y: 0 },\n        data: { step, ...shared },\n      } satisfies TerminalFlowNode;\n    }\n    return {\n      id: step.id,\n      type: \"approval\",\n      position: { x: 0, y: 0 },\n      data: {\n        step,\n        ...shared,\n        hasIncoming: incomingIds.has(step.id),\n        hasOutgoing: step.next.length > 0,\n      },\n    } satisfies ApprovalFlowNode;\n  });\n};\n\nconst buildEdges = (policy: ApprovalPolicy): Edge[] => {\n  const ids = new Set(policy.steps.map((s) => s.id));\n  const edges: Edge[] = [];\n  for (const step of policy.steps) {\n    for (const nextId of step.next) {\n      if (ids.has(nextId)) {\n        edges.push({\n          id: `${step.id}->${nextId}`,\n          source: step.id,\n          target: nextId,\n          style: { stroke: \"var(--border)\", strokeWidth: 1.5 },\n        });\n      }\n    }\n  }\n  return withAlignedElbows(edges).map((edge) => ({ ...edge, type: \"alignedStep\" }));\n};\n\nconst CanvasInner = ({\n  policy,\n  changes,\n  issues,\n  statuses,\n  direction = \"LR\",\n  nodeSep,\n  rankSep,\n  selectedId,\n  onSelectStep,\n  focus,\n  children,\n}: WorkflowCanvasProps) => {\n  const isVertical = direction !== \"LR\";\n  const reactFlow = useReactFlow();\n\n  const sourceNodes = useMemo<Node[]>(\n    () => buildNodes(policy, { changes, issues, statuses, selectedId }, isVertical),\n    [policy, changes, issues, statuses, selectedId, isVertical]\n  );\n  const sourceEdges = useMemo(() => buildEdges(policy), [policy]);\n\n  const { nodes, edges, onNodesChange, onEdgesChange } = useAutoLayout({\n    nodes: sourceNodes,\n    edges: sourceEdges,\n    vertical: isVertical,\n    nodeSep,\n    rankSep,\n  });\n\n  const structureKey = useMemo(\n    () => `${direction}:${policy.steps.map((s) => `${s.id}>${s.next.join(\"|\")}`).join(\",\")}`,\n    [policy, direction]\n  );\n\n  useEffect(() => {\n    const frame = setTimeout(() => {\n      void reactFlow.fitView({ duration: 300, padding: 0.2, maxZoom: 1 });\n    }, 180);\n    return () => clearTimeout(frame);\n  }, [structureKey, reactFlow]);\n\n  useEffect(() => {\n    if (!focus) return;\n    const node = reactFlow.getNode(focus.stepId);\n    if (!node) return;\n    void reactFlow.fitView({ nodes: [node], duration: 400, padding: 0.4, maxZoom: 1.1 });\n  }, [focus, reactFlow]);\n\n  return (\n    <ReactFlow\n      nodes={nodes}\n      edges={edges}\n      onNodesChange={onNodesChange}\n      onEdgesChange={onEdgesChange}\n      nodeTypes={NODE_TYPES}\n      edgeTypes={EDGE_TYPES}\n      fitView\n      fitViewOptions={{ padding: 0.2, maxZoom: 1 }}\n      minZoom={0.3}\n      nodesDraggable={false}\n      nodesConnectable={false}\n      elementsSelectable={false}\n      onNodeClick={(_, node) => onSelectStep?.(node.id)}\n      onPaneClick={() => onSelectStep?.(null)}\n      className=\"bg-background\"\n    >\n      <Background gap={18} />\n      <Controls showInteractive={false} />\n      {children}\n    </ReactFlow>\n  );\n};\n\n/**\n * Renders an ApprovalPolicy as a laid-out React Flow graph. Layout is\n * automatic (react-flow-auto-layout measures the real node sizes), so the\n * policy JSON is the only input: no positions to manage.\n *\n * The parent element must have a height.\n */\nconst FLOW_THEME: React.CSSProperties & Record<`--${string}`, string> = {\n  \"--xy-controls-button-background-color\": \"var(--card)\",\n  \"--xy-controls-button-background-color-hover\": \"var(--muted)\",\n  \"--xy-controls-button-color\": \"var(--foreground)\",\n  \"--xy-controls-button-color-hover\": \"var(--foreground)\",\n  \"--xy-controls-button-border-color\": \"var(--border)\",\n  \"--xy-attribution-background-color\": \"transparent\",\n};\n\nexport const WorkflowCanvas = ({ className, ...props }: WorkflowCanvasProps) => {\n  return (\n    <div className={cn(\"h-full w-full\", className)} style={FLOW_THEME}>\n      <ReactFlowProvider>\n        <CanvasInner {...props} />\n      </ReactFlowProvider>\n    </div>\n  );\n};\n",
      "type": "registry:component",
      "target": "components/approvals-ui/workflow-canvas.tsx"
    }
  ],
  "type": "registry:component"
}