{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"workflow-canvas","type":"registry:component","title":"Workflow canvas","description":"Connected steps you can move, inspect and follow as work flows through them.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/workflow-canvas.tsx","type":"registry:component","content":"// Hairline UI · workflow-canvas · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { Circle, Maximize2, Minus, Plus } from 'lucide-react';\nimport { WorkflowConnections } from './workflow-connections';\nimport { useWorkflowViewport } from './use-workflow-viewport';\nimport { INTERACTION_FOCUS, useInteractionReducedMotion, useInteractionActivity } from './ui-interaction-motion';\nimport { WorkflowStatusMark, WORKFLOW_STATUS as STATUS } from './workflow-status';\nimport { WorkflowInspector } from './workflow-inspector';\nimport { NumberRoll } from './number-roll';\nimport { TextMorph } from './text-morph';\nimport { NODE_WIDTH, NODE_HEIGHT, type WorkflowNode, type WorkflowEdge, type WorkflowPoint, type WorkflowStatus } from './workflow-types';\nexport type { WorkflowNode, WorkflowEdge, WorkflowPoint, WorkflowStatus } from './workflow-types';\n\nexport interface WorkflowCanvasProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onSelect'> {\n  nodes: WorkflowNode[];\n  edges: WorkflowEdge[];\n  width?: number;\n  height?: number;\n  label?: string;\n  onNodeMove?: (id: string, position: WorkflowPoint) => void;\n  onNodeSelect?: (id: string | null) => void;\n}\nconst CONTROL = `cursor-pointer disabled:cursor-default flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 ${INTERACTION_FOCUS}`;\n\n/** A bounded workflow viewer with movable nodes, a shared pan/zoom transform and live execution states. */\nexport function WorkflowCanvas({ nodes, edges, width = 660, height = 340, label = 'Workflow', onNodeMove, onNodeSelect, className = '', ...props }: WorkflowCanvasProps) {\n  const reduced = useInteractionReducedMotion();\n  const activity = useInteractionActivity<HTMLDivElement>();\n  const [selected, setSelected] = React.useState<string | null>(null);\n  const nodeRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const canvasWidth = Math.max(NODE_WIDTH, width), canvasHeight = Math.max(NODE_HEIGHT, height);\n  const { viewport, world, positions, view, dragging, fit, zoom, revealNode, moveNode, handlers } = useWorkflowViewport(nodes, edges, canvasWidth, canvasHeight, reduced, onNodeMove);\n  const selectedNode = nodes.find((node) => node.id === selected);\n  const detailsId = React.useId();\n  const choose = (id: string | null) => { setSelected(id); onNodeSelect?.(id); };\n\n  return (\n    <div ref={activity.ref} role=\"region\" aria-label={label} className={`w-full overflow-clip rounded-[20px] border border-border bg-background text-foreground ${className}`} {...props}>\n      <div className=\"flex items-center justify-between gap-3 border-b border-border px-4 py-3\">\n        <span className=\"text-[12px] font-medium\">{label}</span>\n        <span className=\"font-mono text-[10px] text-muted-foreground\"><NumberRoll value={nodes.length} /> nodes · <NumberRoll value={edges.length} /> connections</span>\n      </div>\n      <div ref={viewport} {...handlers} data-lenis-prevent className={`relative h-[300px] overflow-clip bg-muted/35 ${dragging ? 'cursor-grabbing' : 'cursor-grab'}`}\n        style={{ touchAction: 'none', backgroundImage: 'radial-gradient(var(--border) 1px, transparent 1px)', backgroundSize: '16px 16px' }}>\n        <div ref={world} className=\"absolute left-0 top-0 origin-top-left\" style={{ width: canvasWidth, height: canvasHeight, transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}>\n          <WorkflowConnections nodes={nodes} edges={edges} positions={positions} reduced={reduced} width={canvasWidth} height={canvasHeight} />\n          {nodes.map((node, index) => {\n            const status = node.status ?? 'idle';\n            return <button key={node.id} type=\"button\" ref={(el) => { if (el) nodeRefs.current.set(node.id, el); else nodeRefs.current.delete(node.id); }}\n              data-workflow-node={node.id} aria-label={`${node.title}, ${STATUS[status]}`} aria-expanded={selected === node.id} aria-controls={selected === node.id ? detailsId : undefined}\n              onClick={() => choose(selected === node.id ? null : node.id)}\n              onFocus={() => revealNode(node.id)}\n              onKeyDown={(event) => {\n                if (event.key === 'Escape') { event.preventDefault(); choose(null); }\n                if (event.key === 'ArrowLeft' || event.key === 'ArrowRight' || event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n                  event.preventDefault();\n                  if (event.altKey) { moveNode(node.id, event.key === 'ArrowRight' ? 12 : event.key === 'ArrowLeft' ? -12 : 0, event.key === 'ArrowDown' ? 12 : event.key === 'ArrowUp' ? -12 : 0); return; }\n                  const offset = event.key === 'ArrowRight' || event.key === 'ArrowDown' ? 1 : -1;\n                  nodeRefs.current.get(nodes[(index + offset + nodes.length) % nodes.length].id)?.focus({ preventScroll: true });\n                }\n              }}\n              className={`cursor-pointer absolute left-0 top-0 flex flex-col justify-between rounded-xl border bg-card px-3 py-2.5 text-left text-card-foreground transition-[border-color] duration-300 ${INTERACTION_FOCUS} ${status === 'error' ? 'border-destructive/50' : selected === node.id || status === 'running' ? 'border-primary/70' : 'border-border'}`}\n              style={{ width: NODE_WIDTH, height: NODE_HEIGHT, transform: `translate(${positions[node.id].x}px, ${positions[node.id].y}px)` }}>\n              <span className=\"flex w-full items-center gap-2\"><span className=\"flex size-5 shrink-0 items-center justify-center rounded-md border border-border bg-muted/40 text-muted-foreground\">{node.icon ?? <Circle className=\"size-3\" aria-hidden=\"true\" />}</span><span className=\"truncate text-[12px] font-medium\"><TextMorph>{node.title}</TextMorph></span></span>\n              <span className=\"flex w-full items-center justify-between gap-2\"><span className=\"truncate text-[11px] text-muted-foreground\"><TextMorph>{node.subtitle ?? STATUS[status]}</TextMorph></span><WorkflowStatusMark status={status} reduced={reduced} active={activity.active} /></span>\n            </button>;\n          })}\n        </div>\n        <div data-workflow-control className=\"absolute bottom-3 left-3 flex items-center gap-0.5 rounded-xl border border-border bg-background p-1\">\n          <button type=\"button\" aria-label=\"Zoom out\" disabled={view.scale <= 0.25} onClick={() => zoom(1 / 1.2)} className={CONTROL}><Minus className=\"size-3.5\" aria-hidden=\"true\" /></button>\n          <output aria-label=\"Zoom level\" className=\"w-11 text-center font-mono text-[10px] tabular-nums text-muted-foreground\"><NumberRoll value={Math.round(view.scale * 100)} />%</output>\n          <button type=\"button\" aria-label=\"Zoom in\" disabled={view.scale >= 1.75} onClick={() => zoom(1.2)} className={CONTROL}><Plus className=\"size-3.5\" aria-hidden=\"true\" /></button>\n          <span className=\"mx-1 h-3 w-px bg-border\" />\n          <button type=\"button\" aria-label=\"Fit workflow\" onClick={fit} className={CONTROL}><Maximize2 className=\"size-3.5\" aria-hidden=\"true\" /></button>\n        </div>\n      </div>\n      <WorkflowInspector id={detailsId} node={selectedNode} reduced={reduced} onClose={() => { choose(null); if (selectedNode) nodeRefs.current.get(selectedNode.id)?.focus({ preventScroll: true }); }} />\n      <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">{nodes.filter((node) => node.status === 'running').map((node) => `${node.title} running`).join('. ') || (nodes.some((node) => node.status === 'error') ? 'Workflow has a failed step.' : nodes.length > 0 && nodes.every((node) => node.status === 'complete') ? 'Workflow complete.' : 'Workflow ready.')}</p>\n    </div>\n  );\n}\n"},{"path":"registry/workflow-types.ts","type":"registry:component","content":"// Hairline UI · workflow-canvas · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\nimport type * as React from 'react';\n\nexport type WorkflowStatus = 'idle' | 'queued' | 'running' | 'complete' | 'error';\nexport type WorkflowPoint = { x: number; y: number };\nexport interface WorkflowNode extends WorkflowPoint {\n  id: string;\n  title: string;\n  subtitle?: string;\n  icon?: React.ReactNode;\n  status?: WorkflowStatus;\n  detail?: React.ReactNode;\n}\nexport interface WorkflowEdge { id: string; from: string; to: string }\nexport const NODE_WIDTH = 164;\nexport const NODE_HEIGHT = 78;\n\n/** Rounded elbows for forward connections; a smooth loop for nodes dragged behind their source. */\nexport function workflowPath(from: WorkflowPoint, to: WorkflowPoint) {\n  const x1 = from.x + NODE_WIDTH, y1 = from.y + NODE_HEIGHT / 2;\n  const x2 = to.x, y2 = to.y + NODE_HEIGHT / 2;\n  if (x2 - x1 < 40) {\n    const reach = Math.max(60, Math.abs(x2 - x1) * 0.6);\n    return `M ${x1} ${y1} C ${x1 + reach} ${y1}, ${x2 - reach} ${y2}, ${x2} ${y2}`;\n  }\n  if (Math.abs(y2 - y1) < 2) return `M ${x1} ${y1} H ${x2}`;\n  const mid = (x1 + x2) / 2, sign = Math.sign(y2 - y1), radius = Math.min(14, Math.abs(y2 - y1) / 2);\n  return `M ${x1} ${y1} H ${mid - radius} Q ${mid} ${y1} ${mid} ${y1 + sign * radius} V ${y2 - sign * radius} Q ${mid} ${y2} ${mid + radius} ${y2} H ${x2}`;\n}\n"},{"path":"registry/workflow-connections.tsx","type":"registry:component","content":"// Hairline UI · workflow-canvas · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { workflowPath, type WorkflowEdge, type WorkflowNode, type WorkflowPoint } from './workflow-types';\n\nfunction Connection({ edge, nodes, positions, reduced, playing }: { edge: WorkflowEdge; nodes: WorkflowNode[]; positions: Record<string, WorkflowPoint>; reduced: boolean; playing: boolean }) {\n  const ref = React.useRef<SVGPathElement>(null);\n  const from = nodes.find((node) => node.id === edge.from), to = nodes.find((node) => node.id === edge.to);\n  const active = from?.status === 'complete' && to?.status === 'running';\n  const completed = from?.status === 'complete' && to?.status === 'complete';\n  const d = from && to ? workflowPath(positions[from.id] ?? from, positions[to.id] ?? to) : '';\n  React.useEffect(() => {\n    const path = ref.current;\n    if (!path || !active || !playing || reduced) return;\n    const length = path.getTotalLength();\n    path.style.strokeDasharray = `14 ${length}`;\n    const animation = path.animate([{ strokeDashoffset: '0' }, { strokeDashoffset: String(-length - 14) }], { duration: Math.max(700, length / 0.2), iterations: Infinity, easing: 'linear' });\n    return () => { animation.cancel(); path.style.strokeDasharray = ''; };\n  }, [active, playing, reduced, d]);\n  if (!from || !to) return null;\n  return (\n    <g>\n      <path data-workflow-edge={edge.id} d={d} fill=\"none\" stroke={active || completed ? 'var(--primary)' : 'var(--border)'} strokeOpacity={active || completed ? 0.35 : 1} strokeWidth=\"1.5\" />\n      {active && <path ref={ref} data-workflow-edge={edge.id} d={d} fill=\"none\" stroke=\"var(--primary)\" strokeWidth=\"2\" strokeLinecap=\"round\" opacity={reduced ? 0.6 : 1} />}\n    </g>\n  );\n}\n\nexport function WorkflowConnections({ nodes, edges, positions, reduced, width, height }: { nodes: WorkflowNode[]; edges: WorkflowEdge[]; positions: Record<string, WorkflowPoint>; reduced: boolean; width: number; height: number }) {\n  const ref = React.useRef<SVGSVGElement>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const svg = ref.current;\n    if (!svg) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(svg);\n    const visibility = () => setHidden(document.hidden);\n    visibility();\n    document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return <svg ref={ref} aria-hidden=\"true\" width={width} height={height} className=\"pointer-events-none absolute inset-0 overflow-visible\">\n    {edges.map((edge) => <Connection key={edge.id} edge={edge} nodes={nodes} positions={positions} reduced={reduced} playing={visible && !hidden} />)}\n  </svg>;\n}\n"},{"path":"registry/workflow-status.tsx","type":"registry:component","content":"// Hairline UI · workflow-canvas · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport React from 'react';\nimport { Circle, CircleAlert } from 'lucide-react';\nimport { interactionIcon } from './ui-interaction-motion';\nimport type { WorkflowStatus } from './workflow-types';\n\nexport const WORKFLOW_STATUS: Record<WorkflowStatus, string> = { idle: 'Ready', queued: 'Queued', running: 'Running', complete: 'Complete', error: 'Failed' };\n\nexport function WorkflowStatusMark({ status, reduced, active }: { status: WorkflowStatus; reduced: boolean; active: boolean }) {\n  return <span aria-hidden=\"true\" className=\"relative size-3.5 shrink-0\">\n    <span className=\"absolute inset-0 flex items-center justify-center\" style={interactionIcon(status === 'idle' || status === 'queued', reduced)}><Circle className={`size-3 text-muted-foreground ${status === 'queued' ? 'fill-muted-foreground/20' : ''}`} /></span>\n    <span className=\"absolute inset-0\" style={interactionIcon(status === 'running', reduced)}><span className={`block size-3.5 rounded-full border-[1.5px] border-primary/25 border-t-primary ${status === 'running' && !reduced ? 'animate-spin' : ''}`} style={{ animationPlayState: active ? 'running' : 'paused' }} /></span>\n    <svg viewBox=\"0 0 16 16\" className=\"absolute inset-0 size-3.5 text-primary\" style={interactionIcon(status === 'complete', reduced)}><path d=\"m3 8 3 3 7-7\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" pathLength={1} style={{ strokeDasharray: 1, strokeDashoffset: status === 'complete' ? 0 : 1, transition: reduced ? 'none' : 'stroke-dashoffset 380ms ease-out 80ms' }} /></svg>\n    <CircleAlert className=\"absolute inset-0 size-3.5 text-destructive\" style={interactionIcon(status === 'error', reduced)} />\n  </span>;\n}\n"},{"path":"registry/workflow-inspector.tsx","type":"registry:component","content":"// Hairline UI · workflow-canvas · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { X } from 'lucide-react';\nimport { TextMorph } from './text-morph';\nimport { EASE_OUT, INTERACTION_FOCUS } from './ui-interaction-motion';\nimport { WORKFLOW_STATUS } from './workflow-status';\nimport type { WorkflowNode } from './workflow-types';\n\nexport function WorkflowInspector({ node, onClose, reduced, id }: { node?: WorkflowNode; onClose: () => void; reduced: boolean; id: string }) {\n  const last = React.useRef<WorkflowNode | undefined>(undefined);\n  if (node) last.current = node;\n  const displayed = node ?? last.current;\n  const content = React.useRef<HTMLDivElement>(null);\n  const measure = React.useRef<HTMLDivElement>(null);\n  const [height, setHeight] = React.useState(0);\n  React.useLayoutEffect(() => {\n    const el = measure.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => setHeight((previous) => previous === el.offsetHeight ? previous : el.offsetHeight));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [Boolean(displayed)]);\n  React.useLayoutEffect(() => {\n    const el = content.current;\n    if (!el || !node || reduced) return;\n    const animation = el.animate([{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], { duration: 300, easing: EASE_OUT });\n    return () => animation.cancel();\n  }, [node?.id, reduced]);\n  return <div id={id} aria-hidden={!node || undefined} inert={!node} className=\"overflow-hidden\" style={{ height: node ? height : 0, transition: reduced ? 'none' : `height 380ms ${EASE_OUT}` }}>\n      {displayed && <div ref={measure} className=\"border-t border-border bg-card p-4\">\n        <div className=\"flex items-center justify-between gap-3\">\n          <div><p className=\"text-[13px] font-medium\"><TextMorph>{displayed.title}</TextMorph></p><p className={`mt-0.5 text-[12px] ${displayed.status === 'error' ? 'text-destructive' : 'text-muted-foreground'}`}><TextMorph>{WORKFLOW_STATUS[displayed.status ?? 'idle']}</TextMorph></p></div>\n          <button type=\"button\" aria-label=\"Close node details\" onClick={onClose} className={`cursor-pointer flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${INTERACTION_FOCUS}`}><X className=\"size-3.5\" aria-hidden=\"true\" /></button>\n        </div>\n        <div ref={content} className=\"mt-3 text-[13px] leading-relaxed text-muted-foreground\">{displayed.detail}</div>\n      </div>}\n  </div>;\n}\n"},{"path":"registry/use-workflow-viewport.ts","type":"registry:component","content":"// Hairline UI · workflow-canvas · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { NODE_WIDTH, NODE_HEIGHT, workflowPath, type WorkflowNode, type WorkflowEdge, type WorkflowPoint } from './workflow-types';\n\ntype View = { x: number; y: number; scale: number };\ntype Gesture = { pointer: number; startX: number; startY: number; origin: WorkflowPoint; node?: string; moved: boolean };\n\nexport function useWorkflowViewport(nodes: WorkflowNode[], edges: WorkflowEdge[], width: number, height: number, reduced: boolean, onNodeMove?: (id: string, point: WorkflowPoint) => void) {\n  const viewport = React.useRef<HTMLDivElement>(null);\n  const world = React.useRef<HTMLDivElement>(null);\n  const overrides = React.useRef<Record<string, WorkflowPoint>>({});\n  const positions: Record<string, WorkflowPoint> = Object.fromEntries(nodes.map((node) => [node.id, overrides.current[node.id] ?? { x: node.x, y: node.y }]));\n  const currentPositions = React.useRef(positions);\n  currentPositions.current = positions;\n  const viewRef = React.useRef<View>({ x: 0, y: 0, scale: 1 });\n  const gesture = React.useRef<Gesture | null>(null);\n  const suppressClick = React.useRef(false);\n  const [view, setView] = React.useState(viewRef.current);\n  const [, commit] = React.useReducer((n: number) => n + 1, 0);\n  const [dragging, setDragging] = React.useState(false);\n\n  const paintView = (next: View, animate = false) => {\n    viewRef.current = next;\n    if (world.current) {\n      world.current.style.transition = animate && !reduced ? 'transform 320ms cubic-bezier(0.16,1,0.3,1)' : 'none';\n      world.current.style.transform = `translate(${next.x}px, ${next.y}px) scale(${next.scale})`;\n    }\n  };\n  const fit = (minimum = 0.2) => {\n    const frame = viewport.current;\n    if (!frame) return;\n    const scale = Math.max(minimum, Math.min(1.25, (frame.clientWidth - 36) / width, (frame.clientHeight - 36) / height));\n    const next = { x: width * scale > frame.clientWidth - 36 ? 18 : (frame.clientWidth - width * scale) / 2, y: (frame.clientHeight - height * scale) / 2, scale };\n    paintView(next, true); setView(next);\n  };\n  React.useLayoutEffect(() => {\n    const frame = viewport.current;\n    if (!frame) return;\n    const observer = new ResizeObserver(() => fit(0.85));\n    observer.observe(frame);\n    return () => observer.disconnect();\n    // Refit when the canvas bounds change. Status updates preserve the view.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [width, height, reduced]);\n\n  const zoom = (factor: number) => {\n    const frame = viewport.current;\n    if (!frame) return;\n    const previous = viewRef.current;\n    const scale = Math.max(0.25, Math.min(1.75, previous.scale * factor));\n    const cx = frame.clientWidth / 2, cy = frame.clientHeight / 2;\n    const next = { scale, x: cx - (cx - previous.x) * scale / previous.scale, y: cy - (cy - previous.y) * scale / previous.scale };\n    paintView(next, true); setView(next);\n  };\n  const revealNode = (id: string) => {\n    const point = currentPositions.current[id], frame = viewport.current;\n    if (!point || !frame) return;\n    const previous = viewRef.current;\n    const left = point.x * previous.scale + previous.x, top = point.y * previous.scale + previous.y;\n    let x = previous.x, y = previous.y;\n    if (left < 12) x += 12 - left;\n    else if (left + NODE_WIDTH * previous.scale > frame.clientWidth - 12) x -= left + NODE_WIDTH * previous.scale - frame.clientWidth + 12;\n    if (top < 12) y += 12 - top;\n    else if (top + NODE_HEIGHT * previous.scale > frame.clientHeight - 12) y -= top + NODE_HEIGHT * previous.scale - frame.clientHeight + 12;\n    if (x !== previous.x || y !== previous.y) { const next = { ...previous, x, y }; paintView(next, true); setView(next); }\n  };\n  const paintNode = (id: string, point: WorkflowPoint) => {\n    currentPositions.current[id] = point;\n    // Keep the draft position through status renders while the pointer is down.\n    overrides.current[id] = point;\n    const node = world.current?.querySelector<HTMLElement>(`[data-workflow-node=\"${CSS.escape(id)}\"]`);\n    if (node) node.style.transform = `translate(${point.x}px, ${point.y}px)`;\n    edges.filter((edge) => edge.from === id || edge.to === id).forEach((edge) => {\n      const from = currentPositions.current[edge.from], to = currentPositions.current[edge.to];\n      if (from && to) world.current?.querySelectorAll<SVGPathElement>(`[data-workflow-edge=\"${CSS.escape(edge.id)}\"]`).forEach((path) => path.setAttribute('d', workflowPath(from, to)));\n    });\n  };\n  const moveNode = (id: string, dx: number, dy: number) => {\n    const point = currentPositions.current[id];\n    if (!point) return;\n    const next = { x: Math.max(0, Math.min(width - NODE_WIDTH, point.x + dx)), y: Math.max(0, Math.min(height - NODE_HEIGHT, point.y + dy)) };\n    paintNode(id, next); commit(); onNodeMove?.(id, next); revealNode(id);\n  };\n  /**\n   * While a gesture runs, the moving part gets its own compositor layer so panning and dragging move pixels instead of\n   * repainting them. The layer is dropped afterwards so text re-rasterises crisply at the new zoom.\n   */\n  const layer = (el: HTMLElement | null | undefined, on: boolean) => {\n    if (el) el.style.willChange = on ? 'transform' : '';\n  };\n  const nodeEl = (id?: string) => (id ? world.current?.querySelector<HTMLElement>(`[data-workflow-node=\"${CSS.escape(id)}\"]`) : null);\n  const finish = (event: React.PointerEvent, cancelled: boolean) => {\n    const state = gesture.current;\n    if (!state || state.pointer !== event.pointerId) return;\n    gesture.current = null;\n    layer(state.node ? nodeEl(state.node) : world.current, false);\n    setDragging(false);\n    suppressClick.current = state.moved;\n    if (state.node) {\n      if (cancelled) paintNode(state.node, state.origin);\n      else if (state.moved) {\n        const point = currentPositions.current[state.node];\n        overrides.current[state.node] = point;\n        onNodeMove?.(state.node, point);\n      }\n      commit();\n    } else {\n      if (cancelled) paintView({ ...viewRef.current, ...state.origin });\n      setView(viewRef.current);\n    }\n  };\n\n  return {\n    viewport, world, positions, view, dragging, fit: () => fit(), zoom, revealNode, moveNode,\n    handlers: {\n      onPointerDown(event: React.PointerEvent<HTMLDivElement>) {\n        if (event.button !== 0 || !event.isPrimary || (event.target as HTMLElement).closest('[data-workflow-control]')) return;\n        const target = (event.target as HTMLElement).closest<HTMLElement>('[data-workflow-node]');\n        const node = target?.dataset.workflowNode;\n        suppressClick.current = false;\n        (target ?? event.currentTarget).setPointerCapture(event.pointerId);\n        if (target) target.focus({ preventScroll: true });\n        gesture.current = { pointer: event.pointerId, startX: event.clientX, startY: event.clientY, node, origin: node ? { ...currentPositions.current[node] } : { x: viewRef.current.x, y: viewRef.current.y }, moved: false };\n      },\n      onPointerMove(event: React.PointerEvent<HTMLDivElement>) {\n        const state = gesture.current;\n        if (!state || state.pointer !== event.pointerId) return;\n        const dx = event.clientX - state.startX, dy = event.clientY - state.startY;\n        if (!state.moved && Math.hypot(dx, dy) < 5) return;\n        if (!state.moved) { state.moved = true; setDragging(true); layer(state.node ? nodeEl(state.node) : world.current, true); }\n        if (state.node) paintNode(state.node, {\n          x: Math.max(0, Math.min(width - NODE_WIDTH, state.origin.x + dx / viewRef.current.scale)),\n          y: Math.max(0, Math.min(height - NODE_HEIGHT, state.origin.y + dy / viewRef.current.scale)),\n        });\n        else paintView({ ...viewRef.current, x: Math.max(-width * viewRef.current.scale + 60, Math.min((viewport.current?.clientWidth ?? width) - 60, state.origin.x + dx)), y: Math.max(-height * viewRef.current.scale + 60, Math.min((viewport.current?.clientHeight ?? height) - 60, state.origin.y + dy)) });\n      },\n      onPointerUp: (event: React.PointerEvent) => finish(event, false),\n      onPointerCancel: (event: React.PointerEvent) => finish(event, true),\n      onLostPointerCapture: (event: React.PointerEvent) => finish(event, true),\n      onKeyDown(event: React.KeyboardEvent) {\n        const state = gesture.current;\n        if (event.key !== 'Escape' || !state) return;\n        event.preventDefault(); gesture.current = null; setDragging(false); suppressClick.current = true;\n        layer(state.node ? nodeEl(state.node) : world.current, false);\n        if (state.node) { paintNode(state.node, state.origin); commit(); }\n        else { paintView({ ...viewRef.current, ...state.origin }); setView(viewRef.current); }\n      },\n      onClickCapture(event: React.MouseEvent) { if (suppressClick.current) { event.stopPropagation(); suppressClick.current = false; } },\n    },\n  };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · workflow-canvas · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/workflow-canvas. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}