{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "approval-node",
  "title": "Approval node",
  "description": "The approval gate card for React Flow: approvers with unassigned seats, quorum badge, condition pill, SLA badge, plus diff, validation, and live-status rings.",
  "dependencies": [
    "@xyflow/react",
    "lucide-react"
  ],
  "registryDependencies": [
    "badge",
    "https://approvals-ui.vercel.app/r/approvals-core.json"
  ],
  "files": [
    {
      "path": "components/approvals-ui/approval-node.tsx",
      "content": "\"use client\";\n\nimport { Handle, type Node, type NodeProps, Position } from \"@xyflow/react\";\nimport { CircleCheck, CircleX, Clock, UserRound } from \"lucide-react\";\nimport { useMemo } from \"react\";\n\nimport type { StepChange } from \"@/lib/approvals-ui/diff\";\nimport type { IssueSeverity } from \"@/lib/approvals-ui/validate\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { type ApprovalStep, humanizeCondition, summarizeMode } from \"@/lib/approvals-ui/policy\";\nimport { cn } from \"@/lib/utils\";\n\n/** Live run overlay: where a given request currently sits. */\nexport type StepStatus = \"pending\" | \"approved\" | \"rejected\" | \"skipped\";\n\nexport type ApprovalNodeData = {\n  step: ApprovalStep;\n  /** Diff overlay from a pending proposal. */\n  change?: StepChange[\"kind\"];\n  /** Worst validation severity touching this step. */\n  issue?: IssueSeverity;\n  status?: StepStatus;\n  selected?: boolean;\n  vertical?: boolean;\n  hasIncoming?: boolean;\n  hasOutgoing?: boolean;\n  [key: string]: unknown;\n};\n\nexport type ApprovalFlowNode = Node<ApprovalNodeData, \"approval\">;\n\nexport const stateRing = (data: {\n  change?: StepChange[\"kind\"];\n  issue?: IssueSeverity;\n  selected?: boolean;\n}): string => {\n  if (data.change === \"added\") return \"ring-2 ring-emerald-500/70 border-emerald-500/40\";\n  if (data.change === \"changed\") return \"ring-2 ring-amber-500/70 border-amber-500/40\";\n  if (data.change === \"removed\") return \"opacity-50 border-dashed ring-2 ring-red-500/50\";\n  if (data.issue === \"error\") return \"ring-2 ring-destructive/70\";\n  if (data.issue === \"warning\") return \"ring-2 ring-amber-500/50\";\n  if (data.selected) return \"ring-2 ring-ring\";\n  return \"\";\n};\n\nconst initials = (name: string): string => {\n  return name\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map((part) => part.charAt(0).toUpperCase())\n    .join(\"\");\n};\n\n/**\n * Approvers carry no id, and a gate can legitimately hold two identical seats\n * (e.g. two unassigned \"Finance Director\" seats), so content alone is not a\n * unique key. Derive a stable key from the content plus a per-content\n * occurrence count: identical seats get \"…#0\", \"…#1\", and the key stays tied to\n * the item rather than to the raw array position.\n */\nconst approverKeys = (approvers: readonly ApprovalStep[\"approvers\"][number][]): string[] => {\n  const seen = new Map<string, number>();\n  return approvers.map((approver) => {\n    const base = `${approver.name ?? \"open\"}|${approver.title}`;\n    const n = seen.get(base) ?? 0;\n    seen.set(base, n + 1);\n    return `${base}#${n}`;\n  });\n};\n\nconst StatusBadge = ({ status }: { status: StepStatus }) => {\n  if (status === \"approved\") {\n    return (\n      <span className=\"inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400\">\n        <CircleCheck className=\"size-3.5\" /> Approved\n      </span>\n    );\n  }\n  if (status === \"rejected\") {\n    return (\n      <span className=\"inline-flex items-center gap-1 text-[11px] font-medium text-red-600 dark:text-red-400\">\n        <CircleX className=\"size-3.5\" /> Rejected\n      </span>\n    );\n  }\n  if (status === \"pending\") {\n    return (\n      <span className=\"text-foreground inline-flex items-center gap-1 text-[11px] font-medium\">\n        <span className=\"relative flex size-2\">\n          <span className=\"absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-500 opacity-60\" />\n          <span className=\"relative inline-flex size-2 rounded-full bg-amber-500\" />\n        </span>\n        Awaiting decision\n      </span>\n    );\n  }\n  return <span className=\"text-muted-foreground text-[11px]\">Skipped</span>;\n};\n\nexport const ApprovalNode = ({ data }: NodeProps<ApprovalFlowNode>) => {\n  const { step } = data;\n  const isVertical = data.vertical !== false;\n  const condition = humanizeCondition(step.when);\n  const keys = useMemo(() => approverKeys(step.approvers), [step.approvers]);\n\n  return (\n    <div\n      className={cn(\n        \"bg-card text-card-foreground w-64 rounded-xl border shadow-sm\",\n        data.status === \"skipped\" && \"opacity-45\",\n        stateRing(data)\n      )}\n    >\n      {data.hasIncoming !== false && (\n        <Handle\n          type=\"target\"\n          position={isVertical ? Position.Top : Position.Left}\n          className=\"!bg-border !size-2 !border-none\"\n        />\n      )}\n\n      <div className=\"space-y-1 px-3.5 pt-3\">\n        <div className=\"flex items-start justify-between gap-2\">\n          <p className=\"text-sm leading-tight font-medium\">{step.label}</p>\n          {data.status && <StatusBadge status={data.status} />}\n        </div>\n        {condition !== \"always\" && (\n          <Badge\n            variant=\"outline\"\n            className=\"text-muted-foreground max-w-full font-mono text-[10px] font-normal\"\n          >\n            <span className=\"truncate\">{condition}</span>\n          </Badge>\n        )}\n      </div>\n\n      <div className=\"space-y-1.5 px-3.5 py-3\">\n        {step.approvers.map((approver, index) => (\n          <div key={keys[index]} className=\"flex items-center gap-2\">\n            {approver.name === null ? (\n              <span className=\"border-muted-foreground/50 text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full border border-dashed\">\n                <UserRound className=\"size-3\" />\n              </span>\n            ) : (\n              <span className=\"bg-muted flex size-6 shrink-0 items-center justify-center rounded-full text-[10px] font-medium\">\n                {initials(approver.name)}\n              </span>\n            )}\n            <div className=\"min-w-0 leading-tight\">\n              <p\n                className={cn(\n                  \"truncate text-xs\",\n                  approver.name === null && \"text-muted-foreground italic\"\n                )}\n              >\n                {approver.name ?? \"Unassigned\"}\n              </p>\n              <p className=\"text-muted-foreground truncate text-[10px]\">{approver.title}</p>\n            </div>\n          </div>\n        ))}\n      </div>\n\n      {(step.approvers.length > 1 || step.sla) && (\n        <div className=\"flex flex-wrap items-center gap-1.5 border-t px-3.5 py-2\">\n          {step.approvers.length > 1 && (\n            <Badge variant=\"secondary\" className=\"text-[10px] font-normal\">\n              {summarizeMode(step)}\n            </Badge>\n          )}\n          {step.sla && (\n            <Badge variant=\"secondary\" className=\"gap-1 text-[10px] font-normal\">\n              <Clock className=\"size-3\" />\n              {step.sla.hours}h{step.sla.escalateTo ? ` then ${step.sla.escalateTo.title}` : \"\"}\n            </Badge>\n          )}\n        </div>\n      )}\n\n      {data.hasOutgoing !== false && (\n        <Handle\n          type=\"source\"\n          position={isVertical ? Position.Bottom : Position.Right}\n          className=\"!bg-border !size-2 !border-none\"\n        />\n      )}\n    </div>\n  );\n};\n",
      "type": "registry:component",
      "target": "components/approvals-ui/approval-node.tsx"
    }
  ],
  "type": "registry:component"
}