{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"flowchart","title":"Flowchart","description":"Workflow trigger and condition steps on a dotted canvas.","dependencies":[],"registryDependencies":["https://ward.so/r/foundation.json"],"files":[{"path":"registry/ward/Flowchart.tsx","content":"\"use client\";\n/*\n * MIT License\n *\n * Copyright (c) 2026 Shane Levine\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport \"./styles/foundation.css\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { useLayoutEffect } 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(--card)\") =>\n  `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: \"New order created\",\n    caption: \"Trigger when a new order is created\",\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 = [\"project\", \"topping\", \"size\", \"works\"];\nconst FLAVORS = [\n  { name: \"Rocky Road\", tag: \"Classic\" },\n  { name: \"Mint Chip\", tag: \"Classic\" },\n  { name: \"Pistachio\", tag: \"Seasonal\" },\n  { name: \"Bubblegum\", tag: \"Retro\" },\n];\nconst TOPPINGS = [\n  { name: \"Brown butter bourbon brittle crunch\" },\n  { name: \"Rainbow sprinkles\" },\n  { name: \"Hot fudge\" },\n  { name: \"Candied pecans\" },\n];\n\n/* ── icons ── */\nfunction AssetIcon({ size = 16 }: { size?: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n      <path d=\"m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11\" />\n      <path d=\"M17 7A5 5 0 0 0 7 7\" />\n      <path d=\"M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4\" />\n    </svg>\n  );\n}\n\nfunction Chevron() {\n  return (\n    <svg width=\"11\" height=\"11\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"shrink-0 text-ink-3\">\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 width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\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-card 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-card px-1.5 text-[12px] font-medium text-ink shadow-btn\"\n    >\n      <span className=\"text-ink-2\">\n        <AssetIcon size={12} />\n      </span>\n      order\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-[6px] px-1.5\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 && (\n        <Menu\n          items={items}\n          value={value}\n          width={width}\n          align={align}\n          onPick={(name) => onPick(id, name)}\n        />\n      )}\n    </span>\n  );\n}\n\nfunction ConditionBody() {\n  const [values, setValues] = useState<Record<string, string>>({\n    prop1: \"project\",\n    val1: \"Rocky Road\",\n    prop2: \"topping\",\n    val2: \"Brown butter bourbon brittle crunch\",\n  });\n  const [open, setOpen] = useState<string | null>(null);\n\n  /* click anywhere else closes the menu */\n  useEffect(() => {\n    if (!open) return;\n    const close = (event: PointerEvent) => {\n      if (!(event.target as Element).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(\"prop1\", PROPERTIES.map((name) => ({ name })), \"w-36\")}\n        <span className=\"text-[12.5px] text-ink-2\">is</span>\n        {chip(\"val1\", FLAVORS, \"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(\"prop2\", PROPERTIES.map((name) => ({ name })), \"w-36\")}\n        <span className=\"text-[12.5px] text-ink-2\">is</span>\n        <span className=\"max-w-full pl-[49px]\">\n          {chip(\"val2\", TOPPINGS, \"w-64\", { dot: true })}\n        </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        <AssetIcon />\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 [draggedId, setDraggedId] = useState<string | null>(null);\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  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;\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))].sort((a, b) => a - b);\n  const rowH = rows.map((r) =>\n    Math.max(...steps.filter((n) => n.row === r).map((n) => heights[n.id] ?? 90)),\n  );\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 as Element).closest(\"[data-ui]\")) return;\n    const off = offsets[node.id];\n    setDraggedId(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    (event.currentTarget as HTMLElement).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    setDraggedId(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 }) =>\n    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(--highlight)\" : \"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: draggedId === 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-card 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-card text-left outline-none\n                  transition-shadow duration-150 focus-visible:shadow-[0_0_0_1.5px_var(--highlight)]\n                  ${\n                    active\n                      ? \"shadow-[0_0_0_1.5px_var(--highlight),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/ward/Flowchart.tsx"}],"meta":{"variants":["Default"],"version":"1.1.0","source":"https://github.com/slev12397/beautiful-ui/blob/44a274e598395ab61e7c96c26fda2758780253b7/components/primitives/Flowchart.tsx","access":"free"},"categories":["data"],"type":"registry:component"}