{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"flowchart","type":"registry:component","title":"Flowchart","description":"A sequence of steps on a dotted canvas, connected by curves that measure the actual cards and follow as you drag one. Click a step to light up its connectors, or edit an if/else step's chips with real dropdowns.","author":"Ryan","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/flowchart.tsx","type":"registry:component","target":"@components/motion/flowchart.tsx","content":"\"use client\";\n// easeui.dev/components/agents/flowchart\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\n  type ReactNode,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Badge, type BadgeVariant } from \"@/components/motion/badge\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\n/** One dropdown chip inside a condition clause: its current value and the choices it offers. */\nexport interface ConditionField {\n  value: string;\n  options: string[];\n}\n\nexport interface ConditionClause {\n  id: string;\n  /** \"if\" for the first clause in a step, \"and\" for every clause after it. */\n  connector: \"if\" | \"and\";\n  /** The record a comparison reads from, shown as a small chip, e.g. \"order\". */\n  source: string;\n  property: ConditionField;\n  value: ConditionField;\n}\n\nexport interface FlowStep {\n  id: string;\n  /** Small label above the card, such as \"Trigger\" or \"If / Else\". */\n  kind?: string;\n  /** Color treatment for the kind pill and icon tint. Default \"accent\". */\n  kindVariant?: BadgeVariant;\n  title?: string;\n  description?: string;\n  icon?: ReactNode;\n  /** Renders an editable if/else condition list instead of the title and description. */\n  condition?: ConditionClause[];\n  /** Card width in px. Default 256, or 340 for a condition card. */\n  width?: number;\n}\n\nexport interface FlowchartProps {\n  /** Stacked top to bottom by default; drag a card anywhere on the canvas. */\n  steps: FlowStep[];\n  className?: string;\n}\n\nconst GAP = 64;\nconst PAD = 24;\nconst EST_HEIGHT = 76;\nconst CARD_WIDTH = 256;\nconst CONDITION_WIDTH = 340;\n/** Pointer movement, in px, before a press counts as a drag rather than a click. */\nconst DRAG_THRESHOLD = 3;\nconst OPEN = { duration: 0.15, ease: EASE_OUT } as const;\nconst CLOSE = { duration: 0.1, ease: EASE_OUT } as const;\n\ntype Offset = { dx: number; dy: number };\ntype DragState = { id: string; startX: number; startY: number; baseDx: number; baseDy: number; moved: boolean };\n\nconst ICON_TINT: Record<BadgeVariant, string> = {\n  neutral: \"bg-muted text-muted-foreground\",\n  accent: \"bg-accent/10 text-accent\",\n  success: \"bg-success/15 text-success\",\n  warning: \"bg-warning/15 text-warning\",\n  destructive: \"bg-destructive/15 text-destructive\",\n};\n\n/** A small chip that opens an upward dropdown, used for the property and value in a condition clause. */\nfunction ChipSelect({\n  value,\n  options,\n  align = \"left\",\n  onChange,\n}: {\n  value: string;\n  options: string[];\n  align?: \"left\" | \"right\";\n  onChange: (value: string) => void;\n}) {\n  const [open, setOpen] = useState(false);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const reduce = useReducedMotion();\n\n  useEffect(() => {\n    if (!open) return;\n    const onPointerDown = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    window.addEventListener(\"pointerdown\", onPointerDown);\n    return () => window.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [open]);\n\n  return (\n    <div ref={rootRef} data-no-drag className=\"relative inline-flex min-w-0\">\n      <button\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={open}\n        onClick={() => setOpen((current) => !current)}\n        onKeyDown={(event) => {\n          if (event.key === \"Escape\") setOpen(false);\n        }}\n        className={cn(\n          \"inline-flex h-6 min-w-0 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-foreground outline-none transition-colors duration-150\",\n          open ? \"bg-muted\" : \"bg-card hover:bg-muted\",\n        )}\n      >\n        <span className=\"min-w-0 truncate\">{value}</span>\n        <ChevronDown\n          aria-hidden=\"true\"\n          className={cn(\"h-3 w-3 shrink-0 text-muted-foreground transition-transform duration-150\", open && \"rotate-180\")}\n        />\n      </button>\n      <motion.div\n        role=\"listbox\"\n        aria-hidden={!open}\n        inert={!open}\n        initial={false}\n        animate={reduce ? { opacity: open ? 1 : 0 } : { opacity: open ? 1 : 0, scale: open ? 1 : 0.97 }}\n        transition={reduce ? { duration: 0 } : open ? OPEN : CLOSE}\n        style={{\n          transformOrigin: `bottom ${align === \"right\" ? \"right\" : \"left\"}`,\n          pointerEvents: open ? \"auto\" : \"none\",\n        }}\n        className={cn(\n          \"absolute bottom-full z-20 mb-1.5 flex min-w-36 max-w-[calc(100vw-2rem)] flex-col gap-0.5 rounded-lg bg-background p-1\",\n          \"shadow-[0_0_0_1px_var(--border-strong),0_12px_24px_-12px_rgb(0_0_0/0.3)]\",\n          align === \"right\" ? \"right-0\" : \"left-0\",\n        )}\n      >\n        {options.map((option) => (\n          <button\n            key={option}\n            type=\"button\"\n            role=\"option\"\n            aria-selected={option === value}\n            onClick={() => {\n              onChange(option);\n              setOpen(false);\n            }}\n            className={cn(\n              \"flex min-h-8 w-full touch-manipulation items-center justify-between gap-2 rounded-md px-2 text-left text-xs outline-none transition-colors duration-150\",\n              option === value ? \"text-foreground\" : \"text-muted-foreground\",\n              \"hover:bg-muted hover:text-foreground\",\n            )}\n          >\n            <span className=\"truncate\">{option}</span>\n            {option === value ? <Check aria-hidden=\"true\" className=\"h-3 w-3 shrink-0\" /> : null}\n          </button>\n        ))}\n      </motion.div>\n    </div>\n  );\n}\n\n/** The if/else editor body: one row per clause, each a \"connector, source, property is value\" line. */\nfunction ConditionBody({ clauses }: { clauses: ConditionClause[] }) {\n  const [selection, setSelection] = useState<Record<string, { property: string; value: string }>>(() =>\n    Object.fromEntries(clauses.map((clause) => [clause.id, { property: clause.property.value, value: clause.value.value }])),\n  );\n\n  return (\n    <div className=\"flex flex-col gap-2 p-3\">\n      {clauses.map((clause) => (\n        <div key={clause.id} className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1.5\">\n          <span className=\"w-7 shrink-0 text-xs text-muted-foreground\">{clause.connector === \"if\" ? \"If\" : \"and\"}</span>\n          <span className=\"inline-flex h-6 shrink-0 items-center rounded-md bg-card px-1.5 text-xs font-medium text-foreground shadow-[0_0_0_1px_var(--border)]\">\n            {clause.source}\n          </span>\n          <ChipSelect\n            value={selection[clause.id].property}\n            options={clause.property.options}\n            onChange={(next) => setSelection((current) => ({ ...current, [clause.id]: { ...current[clause.id], property: next } }))}\n          />\n          <span className=\"text-xs text-muted-foreground\">is</span>\n          <ChipSelect\n            value={selection[clause.id].value}\n            options={clause.value.options}\n            align=\"right\"\n            onChange={(next) => setSelection((current) => ({ ...current, [clause.id]: { ...current[clause.id], value: next } }))}\n          />\n        </div>\n      ))}\n    </div>\n  );\n}\n\n/**\n * A sequence of steps on a dotted canvas, connected by curves that measure\n * the actual rendered cards. Each card can be dragged anywhere on the\n * canvas and the connector follows it live; click a card (without dragging\n * it) to light up the connectors on either side of it. A step can also\n * render as an if/else condition editor, with its property and value chips\n * opening real dropdowns, instead of the usual title and description.\n */\nexport function Flowchart({ steps, className }: FlowchartProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const cardRefs = useRef<(HTMLElement | null)[]>([]);\n  const [containerWidth, setContainerWidth] = useState(0);\n  const [heights, setHeights] = useState<number[]>(() => steps.map(() => EST_HEIGHT));\n  const [offsets, setOffsets] = useState<Record<string, Offset>>({});\n  const [selected, setSelected] = useState<string | null>(null);\n  const dragRef = useRef<DragState | null>(null);\n\n  // steps.length is never read directly, but a changed step count means new\n  // cards to observe, so the effect needs to re-run and re-attach.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: steps.length is intentional, see comment above.\n  useLayoutEffect(() => {\n    const container = containerRef.current;\n    if (!container) return;\n\n    const measure = () => {\n      setContainerWidth(container.clientWidth);\n      setHeights((previous) =>\n        cardRefs.current.map((card, index) => {\n          const measured = card?.offsetHeight;\n          return measured && measured > 0 ? measured : (previous[index] ?? EST_HEIGHT);\n        }),\n      );\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(container);\n    for (const card of cardRefs.current) {\n      if (card) observer.observe(card);\n    }\n    return () => observer.disconnect();\n  }, [steps.length]);\n\n  const widths = steps.map((step) => {\n    const base = step.width ?? (step.condition ? CONDITION_WIDTH : CARD_WIDTH);\n    return containerWidth > 0 ? Math.min(base, containerWidth * 0.92) : base;\n  });\n\n  // Where each card sits before any dragging: stacked with a gap for the connector to arc through.\n  const baseTops: number[] = [];\n  steps.forEach((_, index) => {\n    baseTops[index] = index === 0 ? PAD : baseTops[index - 1] + heights[index - 1] + GAP;\n  });\n  const canvasHeight = (baseTops.at(-1) ?? PAD) + (heights.at(-1) ?? EST_HEIGHT) + PAD;\n  const centerX = containerWidth / 2;\n\n  const positionOf = (index: number) => {\n    const offset = offsets[steps[index].id];\n    return { x: centerX + (offset?.dx ?? 0), top: baseTops[index] + (offset?.dy ?? 0) };\n  };\n\n  const onPointerDown = (id: string) => (event: ReactPointerEvent<HTMLDivElement>) => {\n    // A press on a chip's own dropdown should open it, not drag the card.\n    if ((event.target as Element).closest(\"[data-no-drag]\")) return;\n    const offset = offsets[id];\n    dragRef.current = {\n      id,\n      startX: event.clientX,\n      startY: event.clientY,\n      baseDx: offset?.dx ?? 0,\n      baseDy: offset?.dy ?? 0,\n      moved: false,\n    };\n    event.currentTarget.setPointerCapture(event.pointerId);\n  };\n\n  const onPointerMove = (id: string, index: number) => (event: ReactPointerEvent<HTMLDivElement>) => {\n    const drag = dragRef.current;\n    if (!drag || drag.id !== id) return;\n    const dx = drag.baseDx + event.clientX - drag.startX;\n    const dy = drag.baseDy + event.clientY - drag.startY;\n    if (!drag.moved && Math.hypot(dx - drag.baseDx, dy - drag.baseDy) < DRAG_THRESHOLD) return;\n    drag.moved = true;\n\n    // Keep the card inside the canvas.\n    const halfWidth = widths[index] / 2;\n    const clampedDx = Math.min(Math.max(dx, halfWidth + PAD - centerX), containerWidth - halfWidth - PAD - centerX);\n    const height = heights[index] ?? EST_HEIGHT;\n    const clampedDy = Math.min(\n      Math.max(dy, PAD - baseTops[index]),\n      canvasHeight - PAD - height - baseTops[index],\n    );\n    setOffsets((current) => ({ ...current, [id]: { dx: clampedDx, dy: clampedDy } }));\n  };\n\n  const onPointerUp = (id: string) => () => {\n    const drag = dragRef.current;\n    if (drag?.id !== id) return;\n    // A real drag shouldn't also toggle selection on release, but the click\n    // still needs to see `moved`, so clear the ref one tick later.\n    if (drag.moved) setTimeout(() => { dragRef.current = null; }, 0);\n    else dragRef.current = null;\n  };\n\n  const toggleSelected = (id: string) => setSelected((current) => (current === id ? null : id));\n\n  const onCardClick = (id: string) => (event: ReactMouseEvent<HTMLElement>) => {\n    if ((event.target as Element).closest(\"[data-no-drag]\")) return;\n    if (dragRef.current?.moved) return;\n    toggleSelected(id);\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        // No overflow-hidden here: border-radius already clips the dotted\n        // background on its own, and a condition card's dropdown needs to\n        // spill past the canvas's tightly-fit height without being clipped.\n        \"relative w-full touch-none select-none rounded-2xl bg-card shadow-[0_0_0_1px_var(--border)]\",\n        className,\n      )}\n      style={{\n        height: canvasHeight,\n        backgroundImage: \"radial-gradient(var(--border-strong) 1px, transparent 1.25px)\",\n        backgroundSize: \"20px 20px\",\n      }}\n    >\n      <svg aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\">\n        {steps.slice(1).map((step, index) => {\n          const from = positionOf(index);\n          const to = positionOf(index + 1);\n          const fromBottom = from.top + (heights[index] ?? EST_HEIGHT);\n          const lit = selected === steps[index].id || selected === step.id;\n          const bend = Math.min(Math.max((to.top - fromBottom) * 0.6, 16), 48);\n          return (\n            <path\n              key={step.id}\n              d={`M ${from.x} ${fromBottom} C ${from.x} ${fromBottom + bend}, ${to.x} ${to.top - bend}, ${to.x} ${to.top}`}\n              fill=\"none\"\n              stroke={lit ? \"var(--accent)\" : \"var(--border-strong)\"}\n              strokeWidth={1.5}\n              className=\"transition-[stroke] duration-150\"\n            />\n          );\n        })}\n      </svg>\n\n      {steps.map((step, index) => {\n        const { x, top } = positionOf(index);\n        const active = selected === step.id;\n        const variant = step.kindVariant ?? \"accent\";\n        return (\n          <div\n            key={step.id}\n            ref={(el) => {\n              cardRefs.current[index] = el;\n            }}\n            onPointerDown={onPointerDown(step.id)}\n            onPointerMove={onPointerMove(step.id, index)}\n            onPointerUp={onPointerUp(step.id)}\n            className=\"absolute flex -translate-x-1/2 cursor-grab touch-none flex-col items-center gap-1.5 active:cursor-grabbing\"\n            style={{ left: x, top, width: widths[index], zIndex: dragRef.current?.id === step.id ? 2 : 1 }}\n          >\n            {step.kind ? <Badge variant={variant}>{step.kind}</Badge> : null}\n            {step.condition ? (\n              // biome-ignore lint/a11y/useSemanticElements: a <button> can't nest the chip buttons inside it.\n              <div\n                role=\"button\"\n                tabIndex={0}\n                onClick={onCardClick(step.id)}\n                onKeyDown={(event) => {\n                  if (event.target === event.currentTarget && (event.key === \"Enter\" || event.key === \" \")) {\n                    event.preventDefault();\n                    toggleSelected(step.id);\n                  }\n                }}\n                aria-pressed={active}\n                className={cn(\n                  \"w-full cursor-pointer rounded-2xl bg-background text-left outline-none transition-shadow duration-150\",\n                  active\n                    ? \"shadow-[0_0_0_1.5px_var(--accent)]\"\n                    : \"shadow-[0_0_0_1px_var(--border)] hover:shadow-[0_0_0_1px_var(--border-strong)]\",\n                )}\n              >\n                <ConditionBody clauses={step.condition} />\n              </div>\n            ) : (\n              <button\n                type=\"button\"\n                onClick={onCardClick(step.id)}\n                aria-pressed={active}\n                className={cn(\n                  \"flex w-full items-center gap-3 rounded-2xl bg-background p-3 text-left outline-none transition-shadow duration-150\",\n                  active\n                    ? \"shadow-[0_0_0_1.5px_var(--accent)]\"\n                    : \"shadow-[0_0_0_1px_var(--border)] hover:shadow-[0_0_0_1px_var(--border-strong)]\",\n                )}\n              >\n                {step.icon ? (\n                  <span className={cn(\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg\", ICON_TINT[variant])}>\n                    {step.icon}\n                  </span>\n                ) : null}\n                <span className=\"min-w-0\">\n                  <span className=\"block truncate text-sm font-semibold text-foreground\">{step.title}</span>\n                  {step.description ? (\n                    <span className=\"mt-0.5 block text-pretty text-xs leading-snug text-muted-foreground\">\n                      {step.description}\n                    </span>\n                  ) : null}\n                </span>\n              </button>\n            )}\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n"},{"path":"components/motion/badge.tsx","type":"registry:component","target":"@components/motion/badge.tsx","content":"import type { HTMLAttributes } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type BadgeVariant = \"neutral\" | \"accent\" | \"success\" | \"warning\" | \"destructive\";\n\nexport interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {\n  /** Color treatment. Default \"neutral\". */\n  variant?: BadgeVariant;\n}\n\nconst VARIANT_CLASS: Record<BadgeVariant, string> = {\n  neutral: \"bg-muted text-muted-foreground shadow-[0_0_0_1px_var(--border)]\",\n  accent: \"bg-accent/15 text-accent\",\n  success: \"bg-success/15 text-success\",\n  warning: \"bg-warning/15 text-warning\",\n  destructive: \"bg-destructive/15 text-destructive\",\n};\n\n/** A small status pill. Crossfades color when its variant changes, such as pending to success. */\nexport function Badge({ variant = \"neutral\", className, ...props }: BadgeProps) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-colors duration-150 ease-out\",\n        VARIANT_CLASS[variant],\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI\n// 150 to 250ms, and panels up to 300ms.\n// Easing curves mirror the CSS custom properties in globals.css.\n\n/** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */\nexport const EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** ease-in-out-cubic. Elements already on screen moving to a new spot. */\nexport const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\n/** Sheet and drawer glide. */\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\n// Springs are described by duration and bounce, which is easier to reason about\n// than stiffness and damping. Bounce stays at zero for product UI.\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  duration: 0.15,\n  bounce: 0,\n} as const;\n\n/** Content swaps, label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  duration: 0.2,\n  bounce: 0,\n} as const;\n\n/** Overlay panel entrances, modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  duration: 0.25,\n  bounce: 0,\n} as const;\n\n/** Shared-layout glides, pills and indicators moving between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  duration: 0.22,\n  bounce: 0,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */\nexport const SPRING_MOUSE = {\n  stiffness: 320,\n  damping: 26,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders). Critically damped `useSpring` config,\n * so the value follows the pointer closely and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}