{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flowchart",
  "type": "registry:component",
  "title": "Flowchart",
  "description": "Workflow trigger and condition steps on a dotted canvas.",
  "categories": [
    "ai-interface"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json"
  ],
  "files": [
    {
      "path": "src/registry/components/flowchart/flowchart.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FLOWCHART — an agent workflow on a dotted editor canvas.\n * Two steps: a Trigger card and an If/Else condition card,\n * joined by a measured connector. Cards drag anywhere on\n * the canvas; the connector follows. Condition chips open\n * real dropdowns (same menu as the PromptBar model picker).\n * ───────────────────────────────────────────────────────── */\n\nconst PURPLE = \"#9a5cff\";\nconst AMBER = \"#f09a2f\";\n\nconst mix = (hue: string, pct: number, base = \"var(--surface)\") => `color-mix(in srgb, ${hue} ${pct}%, ${base})`;\n\n/* ── layout constants ── */\nconst PAD_Y = 24;\nconst ROW_GAP = 64;\nconst PILL_OFFSET = 30; // kind pill + gap above a card\n\nexport type StepNode = {\n  id: string;\n  row: number;\n  x: number; // 0–1 center of the node\n  w: number;\n  kind?: { label: string; hue: string };\n  hue?: string;\n  title?: string;\n  caption?: string;\n  condition?: boolean; // renders the if/else chip rows instead\n};\n\nconst NODES: StepNode[] = [\n  {\n    id: \"trigger\",\n    row: 0,\n    x: 0.5,\n    w: 300,\n    kind: { label: \"Trigger\", hue: PURPLE },\n    hue: PURPLE,\n    title: \"Session moved to review\",\n    caption: \"Trigger when a session needs a reviewer\",\n  },\n  {\n    id: \"cond\",\n    row: 1,\n    x: 0.5,\n    w: 356,\n    kind: { label: \"If / Else\", hue: AMBER },\n    condition: true,\n  },\n];\n\nconst EDGES = [{ from: \"trigger\", to: \"cond\" }];\n\n/* estimated heights for the first paint; measured immediately after */\nconst EST_H: Record<string, number> = { trigger: 92, cond: 134 };\n\nconst PROPERTIES = [\"purpose\", \"country\", \"check\", \"level\"];\nconst PURPOSES = [\n  { name: \"rental_lister\", tag: \"Rentals\" },\n  { name: \"rental_seeker\", tag: \"Rentals\" },\n  { name: \"aml_cdd\", tag: \"AML\" },\n  { name: \"age_check\", tag: \"Age\" },\n];\nconst CHECKS = [\n  { name: \"Property link (reviewer, utility bill)\" },\n  { name: \"Sanctions and PEP\" },\n  { name: \"Face match\" },\n  { name: \"Name match\" },\n];\n\n/* ── icons ── */\nfunction ShieldIcon({ size = 16 }: { size?: number }) {\n  return (\n    <svg\n      width={size}\n      height={size}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.8\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\" />\n      <path d=\"m9 12 2 2 4-4\" />\n    </svg>\n  );\n}\n\nfunction Chevron() {\n  return (\n    <svg\n      width=\"11\"\n      height=\"11\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2.4\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className=\"shrink-0 text-ink-3\"\n    >\n      <path d=\"m6 9 6 6 6-6\" />\n    </svg>\n  );\n}\n\nfunction Handle() {\n  return (\n    <svg width=\"10\" height=\"16\" viewBox=\"0 0 10 16\" className=\"shrink-0 cursor-grab text-ink-3/70\">\n      {[3, 8, 13].flatMap((y) => [\n        <circle key={`l${y}`} cx=\"3\" cy={y} r=\"1.1\" fill=\"currentColor\" />,\n        <circle key={`r${y}`} cx=\"7.5\" cy={y} r=\"1.1\" fill=\"currentColor\" />,\n      ])}\n    </svg>\n  );\n}\n\nfunction CheckIcon() {\n  return (\n    <svg\n      width=\"13\"\n      height=\"13\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2.5\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n    >\n      <path d=\"M20 6L9 17l-5-5\" />\n    </svg>\n  );\n}\n\n/* ── dropdown menu — same pattern as the PromptBar model picker ── */\nfunction Menu({\n  items,\n  value,\n  width,\n  align,\n  onPick,\n}: {\n  items: { name: string; tag?: string }[];\n  value: string;\n  width: string;\n  align: \"left\" | \"right\";\n  onPick: (name: string) => void;\n}) {\n  const [hovered, setHovered] = useState<number | null>(null);\n  const rowRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const [box, setBox] = useState<{ top: number; height: number } | null>(null);\n\n  const valueIndex = items.findIndex((item) => item.name === value);\n  useLayoutEffect(() => {\n    const row = rowRefs.current[hovered ?? valueIndex];\n    if (row) setBox({ top: row.offsetTop, height: row.offsetHeight });\n  }, [hovered, valueIndex]);\n\n  return (\n    <div\n      onMouseLeave={() => setHovered(null)}\n      className={`absolute bottom-full z-20 mb-1.5 rounded-[10px] bg-surface p-1 shadow-raised ${width}\n        ${align === \"right\" ? \"right-0\" : \"left-0\"}`}\n      style={{\n        animation: \"pop-in 180ms cubic-bezier(0.23,1,0.32,1) both\",\n        transformOrigin: align === \"right\" ? \"bottom right\" : \"bottom left\",\n      }}\n    >\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-1 rounded-[6px] bg-hover\"\n        style={{\n          top: box?.top ?? 0,\n          height: box?.height ?? 0,\n          opacity: box && hovered !== null ? 1 : 0,\n          transition:\n            \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n        }}\n      />\n      {items.map((item, i) => (\n        <button\n          key={item.name}\n          type=\"button\"\n          ref={(el) => {\n            rowRefs.current[i] = el;\n          }}\n          onMouseEnter={() => setHovered(i)}\n          onClick={() => onPick(item.name)}\n          className=\"relative z-10 flex h-7.5 w-full cursor-pointer items-center gap-2 rounded-[6px] px-2 text-left\"\n        >\n          <span className=\"min-w-0 flex-1 truncate text-[12.5px] font-medium text-ink\">{item.name}</span>\n          {item.tag && <span className=\"shrink-0 text-[11px] text-ink-3\">{item.tag}</span>}\n          <span className={`shrink-0 text-ink ${item.name === value ? \"\" : \"invisible\"}`}>\n            <CheckIcon />\n          </span>\n        </button>\n      ))}\n    </div>\n  );\n}\n\n/* ── chips used inside the condition card ── */\nfunction SourceChip() {\n  return (\n    <span\n      data-ui\n      className=\"inline-flex h-6 shrink-0 items-center gap-1 rounded-[6px] bg-surface px-1.5 text-[12px] font-medium text-ink shadow-btn\"\n    >\n      <span className=\"text-ink-2\">\n        <ShieldIcon size={12} />\n      </span>\n      session\n    </span>\n  );\n}\n\nfunction SelectChip({\n  id,\n  value,\n  dot,\n  items,\n  width,\n  align = \"left\",\n  open,\n  onToggle,\n  onPick,\n}: {\n  id: string;\n  value: string;\n  dot?: boolean;\n  items: { name: string; tag?: string }[];\n  width: string;\n  align?: \"left\" | \"right\";\n  open: boolean;\n  onToggle: (id: string) => void;\n  onPick: (id: string, name: string) => void;\n}) {\n  return (\n    <span data-ui className=\"relative inline-flex min-w-0\">\n      <button\n        type=\"button\"\n        aria-expanded={open}\n        onClick={() => onToggle(id)}\n        className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 rounded-button px-2\n          text-[12px] font-medium text-ink transition-colors duration-100\n          ${open ? \"bg-hover-2\" : \"bg-field hover:bg-hover-2\"}`}\n      >\n        {dot && <span className=\"size-1.5 shrink-0 rounded-full\" style={{ background: AMBER }} />}\n        <span className=\"min-w-0 truncate\">{value}</span>\n        <Chevron />\n      </button>\n      {open && <Menu items={items} value={value} width={width} align={align} onPick={(name) => onPick(id, name)} />}\n    </span>\n  );\n}\n\nfunction ConditionBody() {\n  const [values, setValues] = useState<Record<string, string>>({\n    prop1: \"purpose\",\n    val1: \"rental_lister\",\n    prop2: \"check\",\n    val2: \"Property link (reviewer, utility bill)\",\n  });\n  const [open, setOpen] = useState<string | null>(null);\n\n  /* click anywhere else closes the menu */\n  useEffect(() => {\n    if (!open) return undefined;\n    const close = (event: PointerEvent) => {\n      if (!(event.target instanceof Element && event.target.closest(\"[data-ui]\"))) setOpen(null);\n    };\n    document.addEventListener(\"pointerdown\", close);\n    return () => document.removeEventListener(\"pointerdown\", close);\n  }, [open]);\n\n  const toggle = (id: string) => setOpen((current) => (current === id ? null : id));\n  const pick = (id: string, name: string) => {\n    setValues((current) => ({ ...current, [id]: name }));\n    setOpen(null);\n  };\n\n  const chip = (id: string, items: { name: string; tag?: string }[], width: string, extra?: object) => (\n    <SelectChip\n      id={id}\n      value={values[id]}\n      items={items}\n      width={width}\n      open={open === id}\n      onToggle={toggle}\n      onPick={pick}\n      {...extra}\n    />\n  );\n\n  return (\n    <div className=\"flex flex-col gap-1.5 px-3 py-2.5\">\n      <div className=\"flex min-w-0 items-center gap-1.5\">\n        <Handle />\n        <span className=\"w-7 text-[12.5px] text-ink-2\">If</span>\n        <SourceChip />\n        {chip(\n          \"prop1\",\n          PROPERTIES.map((name) => ({ name })),\n          \"w-36\",\n        )}\n        <span className=\"text-[12.5px] text-ink-2\">is</span>\n        {chip(\"val1\", PURPOSES, \"w-44\", { dot: true, align: \"right\" })}\n      </div>\n      <div className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1.5\">\n        <Handle />\n        <span className=\"w-7 text-[12.5px] text-ink-2\">and</span>\n        <SourceChip />\n        {chip(\n          \"prop2\",\n          PROPERTIES.map((name) => ({ name })),\n          \"w-36\",\n        )}\n        <span className=\"text-[12.5px] text-ink-2\">is</span>\n        <span className=\"max-w-full pl-[49px]\">{chip(\"val2\", CHECKS, \"w-64\", { dot: true })}</span>\n      </div>\n    </div>\n  );\n}\n\nfunction StepBody({ node }: { node: StepNode }) {\n  return (\n    <div className=\"flex items-center gap-2.5 p-2.5\">\n      <span\n        className=\"flex size-9 shrink-0 items-center justify-center rounded-[8px]\"\n        style={{\n          background: mix(node.hue!, 12),\n          color: node.hue,\n          boxShadow: `0 0 0 1px ${mix(node.hue!, 20)}`,\n        }}\n      >\n        <ShieldIcon />\n      </span>\n      <span className=\"min-w-0 text-left\">\n        <span className=\"block truncate text-[13px] font-semibold leading-tight text-ink\">{node.title}</span>\n        <span className=\"mt-0.5 block text-[12px] leading-snug text-ink-2\">{node.caption}</span>\n      </span>\n    </div>\n  );\n}\n\n/* ── the canvas ── */\nexport default function Flowchart({ steps = NODES }: { steps?: StepNode[]; variant?: string } = {}) {\n  const canvasRef = useRef<HTMLDivElement>(null);\n  const nodeRefs = useRef(new Map<string, HTMLElement>());\n  const [width, setWidth] = useState(0);\n  const [heights, setHeights] = useState<Record<string, number>>(EST_H);\n  const [selected, setSelected] = useState<string | null>(null);\n  const [offsets, setOffsets] = useState<Record<string, { dx: number; dy: number }>>({});\n  /* the card being dragged stacks above the others */\n  const [dragId, setDragId] = useState<string | null>(null);\n  const drag = useRef<{\n    id: string;\n    startX: number;\n    startY: number;\n    baseDx: number;\n    baseDy: number;\n    moved: boolean;\n  } | null>(null);\n\n  useLayoutEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return undefined;\n\n    const measure = () => {\n      setWidth(canvas.clientWidth);\n      setHeights((prev) => {\n        const next = { ...prev };\n        let changed = false;\n        nodeRefs.current.forEach((el, id) => {\n          const h = el.offsetHeight;\n          if (h && Math.abs(h - (next[id] ?? 0)) > 0.5) {\n            next[id] = h;\n            changed = true;\n          }\n        });\n        return changed ? next : prev;\n      });\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(canvas);\n    nodeRefs.current.forEach((el) => observer.observe(el));\n    return () => observer.disconnect();\n  }, []);\n\n  /* rows → y offsets from measured node heights */\n  const rows = [...new Set(steps.map((n) => n.row))].toSorted((a, b) => a - b);\n  const rowH = rows.map((r) => Math.max(...steps.filter((n) => n.row === r).map((n) => heights[n.id] ?? 90)));\n  const rowY: number[] = [];\n  rows.forEach((_, i) => {\n    rowY[i] = i === 0 ? PAD_Y : rowY[i - 1] + rowH[i - 1] + ROW_GAP;\n  });\n  const canvasH = rowY[rows.length - 1] + rowH[rows.length - 1] + PAD_Y;\n\n  const cw = width || 480;\n  const place = (n: StepNode) => {\n    const w = Math.min(n.w, cw * 0.92);\n    const off = offsets[n.id];\n    return {\n      w,\n      cx: n.x * cw + (off?.dx ?? 0),\n      top: rowY[rows.indexOf(n.row)] + (off?.dy ?? 0),\n    };\n  };\n\n  /* card anchor points (pills sit above the card, so offset the top) */\n  const anchors = (n: StepNode) => {\n    const { cx, top } = place(n);\n    return {\n      top: { x: cx, y: top + (n.kind ? PILL_OFFSET : 0) },\n      bottom: { x: cx, y: top + (heights[n.id] ?? 90) },\n    };\n  };\n\n  const bezier = (edge: { from: string; to: string }) => {\n    const from = anchors(steps.find((n) => n.id === edge.from)!).bottom;\n    const to = anchors(steps.find((n) => n.id === edge.to)!).top;\n    const k = Math.min(Math.max(Math.abs(to.y - from.y) * 0.55, 24), 84);\n    return `M ${from.x} ${from.y} C ${from.x} ${from.y + k}, ${to.x} ${to.y - k}, ${to.x} ${to.y}`;\n  };\n\n  /* ── dragging ── */\n  const onPointerDown = (node: StepNode) => (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.target instanceof Element && event.target.closest(\"[data-ui]\")) return;\n    const off = offsets[node.id];\n    drag.current = {\n      id: node.id,\n      startX: event.clientX,\n      startY: event.clientY,\n      baseDx: off?.dx ?? 0,\n      baseDy: off?.dy ?? 0,\n      moved: false,\n    };\n    setDragId(node.id);\n    event.currentTarget.setPointerCapture(event.pointerId);\n  };\n\n  const onPointerMove = (node: StepNode) => (event: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d || d.id !== node.id) return;\n    const dx = d.baseDx + event.clientX - d.startX;\n    const dy = d.baseDy + event.clientY - d.startY;\n    if (!d.moved && Math.hypot(dx - d.baseDx, dy - d.baseDy) < 3) return;\n    d.moved = true;\n\n    /* keep the card inside the canvas */\n    const { w } = place(node);\n    const h = heights[node.id] ?? 90;\n    const baseCx = node.x * cw;\n    const baseTop = rowY[rows.indexOf(node.row)];\n    const cx = Math.min(Math.max(baseCx + dx, w / 2 + 8), cw - w / 2 - 8);\n    const top = Math.min(Math.max(baseTop + dy, 8), canvasH - h - 8);\n    setOffsets((current) => ({ ...current, [node.id]: { dx: cx - baseCx, dy: top - baseTop } }));\n  };\n\n  const onPointerUp = (node: StepNode) => () => {\n    setDragId(null);\n    const d = drag.current;\n    if (d?.id === node.id) {\n      /* a real drag shouldn't also toggle selection */\n      if (d.moved) setTimeout(() => (drag.current = null), 0);\n      else drag.current = null;\n    }\n  };\n\n  const wasDragged = () => drag.current?.moved === true;\n\n  const isLit = (edge: { from: string; to: string }) => selected === edge.from || selected === edge.to;\n\n  return (\n    <div\n      ref={canvasRef}\n      className=\"relative w-full select-none overflow-hidden rounded-card bg-page shadow-hairline\"\n      style={{\n        height: canvasH,\n        backgroundImage: \"radial-gradient(var(--line-strong) 1px, transparent 1.25px)\",\n        backgroundSize: \"22px 22px\",\n        backgroundPosition: \"center\",\n      }}\n    >\n      {/* connectors */}\n      <svg width={cw} height={canvasH} className=\"pointer-events-none absolute inset-0\">\n        {EDGES.map((edge) => (\n          <path\n            key={`${edge.from}-${edge.to}`}\n            d={bezier(edge)}\n            fill=\"none\"\n            stroke={isLit(edge) ? \"var(--accent)\" : \"var(--line-strong)\"}\n            strokeWidth=\"1.25\"\n            className=\"transition-[stroke] duration-150\"\n          />\n        ))}\n      </svg>\n\n      {/* nodes */}\n      {steps.map((node) => {\n        const { w, cx, top } = place(node);\n        const active = selected === node.id;\n        return (\n          <div\n            key={node.id}\n            ref={(el) => {\n              if (el) nodeRefs.current.set(node.id, el);\n              else nodeRefs.current.delete(node.id);\n            }}\n            onPointerDown={onPointerDown(node)}\n            onPointerMove={onPointerMove(node)}\n            onPointerUp={onPointerUp(node)}\n            className=\"absolute flex -translate-x-1/2 touch-none flex-col items-start gap-1.5\"\n            style={{ left: cx, top, width: w, zIndex: dragId === node.id ? 2 : 1 }}\n          >\n            {node.kind && (\n              <span\n                className=\"inline-flex h-6 items-center rounded-[6px] px-2 text-[11.5px] font-medium\"\n                style={{\n                  background: mix(node.kind.hue, 14, \"var(--page)\"),\n                  color: mix(node.kind.hue, 80, \"var(--ink)\"),\n                }}\n              >\n                {node.kind.label}\n              </span>\n            )}\n            {node.condition ? (\n              <div className=\"w-full rounded-[18px] bg-surface shadow-card transition-shadow duration-150 hover:shadow-raised\">\n                <ConditionBody />\n              </div>\n            ) : (\n              <button\n                type=\"button\"\n                onClick={() => {\n                  if (wasDragged()) return;\n                  setSelected(active ? null : node.id);\n                }}\n                aria-pressed={active}\n                className={`w-full cursor-pointer rounded-[18px] bg-surface text-left outline-none\n                  transition-shadow duration-150 focus-visible:shadow-[0_0_0_1.5px_var(--accent)]\n                  ${\n                    active\n                      ? \"shadow-[0_0_0_1.5px_var(--accent),0_2px_10px_rgba(0,0,0,0.045)]\"\n                      : \"shadow-card hover:shadow-raised\"\n                  }`}\n              >\n                <StepBody node={node} />\n              </button>\n            )}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/flowchart/flowchart.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/flowchart",
  "meta": {
    "kind": "component",
    "group": "AI interface",
    "export": "Flowchart",
    "docs": "https://irisui.dev/components/flowchart"
  }
}
