{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flow-canvas",
  "title": "Flow Canvas",
  "author": "ilinxa",
  "description": "Node-and-edge canvas with typed ports, pluggable node renderers, edge and port-type registries, and JSON save and load — built on React Flow.",
  "dependencies": [
    "@xyflow/react",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "context-menu",
    "dialog",
    "textarea"
  ],
  "files": [
    {
      "path": "src/registry/components/data/flow-canvas/flow-canvas.css",
      "content": "/* flow-canvas selection ring — driven by xyflow's wrapper class.\n *\n * Decouples the selection VISUAL from any React-side render of NodeShell /\n * NodeAdapter / the consumer renderer. React-side re-renders on selection\n * still happen (xyflow flips the `selected` prop on NodeAdapter, which\n * recreates children) — this rule's win is that the visual ring applies\n * as a pure browser-side CSS effect the instant xyflow toggles the\n * `.selected` class, decoupled from React reconciliation timing. v0.2.0\n * Tier 2 plan §5.3 + F-V1.\n *\n * DOM hierarchy when a node is selected:\n *   <div class=\"react-flow__node ... selected\">    ← xyflow's wrapper\n *     <div class=\"relative outline-none\">          ← NodeShell\n *       <renderer-output />                         ← ring target\n *       <span class=\"lock chip\" />?                 ← also rings (matches v0.1.x)\n *     </div>\n *   </div>\n *\n * The `> * > *` selector targets NodeShell's direct children — the same\n * elements v0.1.x ringed via NodeShell's `data-[selected=true]:[&>*]:ring-2`.\n *\n * NO `border-radius: inherit` (plan F-V6): the renderer outputs already\n * carry their own border-radius (e.g. `rounded-md` on CustomJsonNode);\n * `box-shadow` automatically conforms to the element's own border-radius.\n * Setting `border-radius: inherit` here would pull NodeShell's (none) and\n * OVERRIDE the renderer's rounded corners. Don't.\n */\n.react-flow__node.selected > * > * {\n  box-shadow: 0 0 0 2px var(--ring);\n}\n",
      "type": "registry:file",
      "target": "components/flow-canvas/flow-canvas.css"
    },
    {
      "path": "src/registry/components/data/flow-canvas/flow-canvas.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef } from \"react\";\nimport { ReactFlowProvider } from \"@xyflow/react\";\nimport { Canvas } from \"./parts/canvas\";\nimport {\n  FlowCanvasContextProvider,\n  type FlowCanvasContextValue,\n} from \"./registries/canvas-context\";\nimport { defaultPortTypes } from \"./registries/port-type-registry\";\nimport { mergeRenderers } from \"./registries/renderer-registry\";\nimport { mergeEdgeTypes } from \"./registries/edge-type-registry\";\nimport type { FlowCanvasProps, PortType } from \"./types\";\n\nimport \"@xyflow/react/dist/style.css\";\n\nfunction mergePortTypes(consumer: PortType[] | undefined): PortType[] {\n  const merged: PortType[] = [...defaultPortTypes];\n  const seen = new Set<string>(merged.map((p) => p.id));\n  for (const p of consumer ?? []) {\n    if (seen.has(p.id) && process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        `[flow-canvas] port type collision for \"${p.id}\" — last-wins`,\n      );\n    }\n    const idx = merged.findIndex((m) => m.id === p.id);\n    if (idx >= 0) merged[idx] = p;\n    else merged.push(p);\n    seen.add(p.id);\n  }\n  return merged;\n}\n\nexport function FlowCanvas({\n  renderers,\n  portTypes,\n  edgeTypes,\n  readOnly = false,\n  selectionMode = \"multi\",\n  onNodeUpdate,\n  onEditRequest,\n  ...rest\n}: FlowCanvasProps) {\n  // v0.2.1 — ref-mirror consumer's onEditRequest so its identity changing\n  // doesn't invalidate the canvas-context memo on every render. Matches the\n  // existing defensive posture in use-canvas-data.ts (which ref-mirrors\n  // onChange / onBeforeConnect / etc.). Plan §4.3.2 (F-V5 lock).\n  const onEditRequestRef = useRef(onEditRequest);\n  useEffect(() => {\n    onEditRequestRef.current = onEditRequest;\n  });\n\n  // Stable wrapper — identity flips ONLY when consumer transitions between\n  // wired and unwired states, not when they pass a fresh function identity\n  // each render. Common consumer pattern: inline arrow on every render.\n  const wiredOnEditRequest = onEditRequest !== undefined;\n  const stableOnEditRequest = useMemo<\n    FlowCanvasContextValue[\"onEditRequest\"]\n  >(\n    () =>\n      wiredOnEditRequest\n        ? (nodeId, subPath) => onEditRequestRef.current?.(nodeId, subPath)\n        : undefined,\n    [wiredOnEditRequest],\n  );\n\n  const ctx = useMemo<FlowCanvasContextValue>(\n    () => ({\n      renderers: mergeRenderers(renderers),\n      portTypes: mergePortTypes(portTypes),\n      edgeTypes: mergeEdgeTypes(edgeTypes),\n      readOnly,\n      selectionMode,\n      onNodeUpdate,\n      onEditRequest: stableOnEditRequest,\n    }),\n    [\n      renderers,\n      portTypes,\n      edgeTypes,\n      readOnly,\n      selectionMode,\n      onNodeUpdate,\n      stableOnEditRequest,\n    ],\n  );\n\n  return (\n    <ReactFlowProvider>\n      <FlowCanvasContextProvider value={ctx}>\n        <Canvas\n          data={rest.data}\n          defaultData={rest.defaultData}\n          onChange={rest.onChange}\n          onBeforeDrop={rest.onBeforeDrop}\n          onBeforeConnect={rest.onBeforeConnect}\n          onNodeCreate={rest.onNodeCreate}\n          onNodeUpdate={onNodeUpdate}\n          onNodeDelete={rest.onNodeDelete}\n          onEdgeCreate={rest.onEdgeCreate}\n          onEdgeDelete={rest.onEdgeDelete}\n          onSubObjectExtract={rest.onSubObjectExtract}\n          menuItems={rest.menuItems}\n          panOnDrag={rest.panOnDrag}\n          zoomOnScroll={rest.zoomOnScroll}\n          selectionMode={selectionMode}\n          readOnly={readOnly}\n          onlyRenderVisibleElements={rest.onlyRenderVisibleElements}\n          exportRef={rest.exportRef}\n          backgroundConfig={rest.background}\n          ariaLabel={rest[\"aria-label\"]}\n          className={rest.className}\n        />\n      </FlowCanvasContextProvider>\n    </ReactFlowProvider>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/flow-canvas.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/index.ts",
      "content": "export { FlowCanvas } from \"./flow-canvas\";\n\nexport type {\n  CanvasData,\n  EdgeRecord,\n  EdgeRenderContext,\n  EdgeRenderer,\n  FlowCanvasBackgroundConfig,\n  FlowCanvasExportHandle,\n  FlowCanvasProps,\n  MenuItem,\n  NodeData,\n  NodeRecord,\n  NodeRenderer,\n  Port,\n  PortDir,\n  PortSide,\n  PortType,\n  PortsAtProps,\n  RenderContext,\n} from \"./types\";\n\nexport {\n  customJsonRenderer,\n  findRenderer,\n  mergeRenderers,\n  useRenderer,\n} from \"./registries/renderer-registry\";\nexport {\n  defaultPortTypes,\n  findPortType,\n  usePortType,\n} from \"./registries/port-type-registry\";\nexport {\n  defaultEdgeRenderer,\n  findEdgeType,\n  mergeEdgeTypes,\n  useEdgeType,\n} from \"./registries/edge-type-registry\";\n\nexport { PortsAt } from \"./parts/ports-at\";\nexport { PortHandle } from \"./parts/port-handle\";\n\nexport { findPortInTree, countEdgesAtPort } from \"./lib/port-walker\";\nexport type { FoundPort } from \"./lib/port-walker\";\n\nexport { emitSubObjectDrag } from \"./lib/emit-sub-object-drag\";\n\nexport { updateNodeData } from \"./lib/update-node-data\";\n\n",
      "type": "registry:component",
      "target": "components/flow-canvas/index.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/types.ts",
      "content": "import type { ReactNode, Ref } from \"react\";\n\n// ───── Port — embedded inside data at any tree depth ─────\n\nexport type PortDir = \"in\" | \"out\";\nexport type PortSide = \"left\" | \"right\" | \"top\" | \"bottom\";\n\nexport type Port = {\n  id: string;\n  side: PortSide;\n  dir: PortDir;\n  type: string;\n  multi?: boolean;\n  label?: string;\n};\n\n// ───── Node data — pure JSON, schema-discriminated, ports recursive ─────\n\nexport type NodeData = {\n  __type: string;\n  ports?: Port[];\n  [key: string]: unknown;\n};\n\nexport type NodeRecord = {\n  id: string;\n  position: { x: number; y: number };\n  data: NodeData;\n  width?: number;\n  height?: number;\n  selected?: boolean;\n  locked?: boolean;\n};\n\nexport type EdgeRecord = {\n  id: string;\n  source: `${string}:${string}`;\n  target: `${string}:${string}`;\n  type?: string;\n  selected?: boolean;\n};\n\nexport type CanvasData = {\n  version: 1;\n  nodes: NodeRecord[];\n  edges: EdgeRecord[];\n  viewport?: { x: number; y: number; zoom: number };\n};\n\n// ───── Registries ─────\n\nexport type RenderContext = {\n  nodeId: string;\n  isSelected: boolean;\n  isDragging: boolean;\n  isReadOnly: boolean;\n  renderChild: (data: NodeData, opts?: { path?: string }) => ReactNode;\n\n  /**\n   * Fire from a renderer's click / keyboard-activate handler to request an\n   * edit-dialog open for the current node. v0.2.1 addition.\n   *\n   * - Bound to the renderer's current `nodeId` — renderers call it with NO\n   *   nodeId argument (the host already knows which node fired).\n   * - Optional `subPath` argument lets renderers signal sub-targeting (e.g.\n   *   a clicked subcard inside a card-tree viewer); the host forwards\n   *   `subPath` to `FlowCanvasProps.onEditRequest` verbatim.\n   * - `undefined` when the consumer hasn't wired `FlowCanvasProps.onEditRequest`.\n   *\n   * See the popup-edit renderer convention in the procomp guide §8.3 (v0.2.0\n   * docs) + the `card-tree-node` procomp for the canonical implementation.\n   */\n  onEditRequest?: (subPath?: string) => void;\n};\n\nexport type NodeRenderer<TData extends NodeData = NodeData> = {\n  type: string;\n  label: string;\n  defaultPorts?: (data: TData) => Port[];\n  defaultSubPorts?: (data: TData) => Record<string, Port[]>;\n  render: (data: TData, ctx: RenderContext) => ReactNode;\n  extractablePaths?: (data: TData) => string[];\n};\n\nexport type PortType = {\n  id: string;\n  label?: string;\n  color: string;\n  icon?: ReactNode;\n};\n\nexport type EdgeRenderContext = {\n  edgeId: string;\n  source: { node: NodeRecord; port: Port };\n  target: { node: NodeRecord; port: Port };\n  isSelected: boolean;\n};\n\nexport type EdgeRenderer = {\n  type: string;\n  label?: string;\n  render: (edge: EdgeRecord, ctx: EdgeRenderContext) => ReactNode;\n};\n\n// ───── Right-click menu ─────\n\nexport type MenuItem = {\n  id: string;\n  label: string;\n  icon?: ReactNode;\n  onSelect: () => void;\n  disabled?: boolean;\n  separatorBefore?: boolean;\n};\n\n// ───── The canvas component ─────\n\nexport type FlowCanvasBackgroundConfig = {\n  light?: { from: string; to: string; angle?: number };\n  dark?: { from: string; to: string; angle?: number };\n  overlay?: \"none\" | \"dots\" | \"grid\" | \"cross\";\n  overlayOpacity?: number;\n};\n\nexport type FlowCanvasExportHandle = {\n  export: (opts: { withPorts: boolean }) => CanvasData;\n};\n\nexport type FlowCanvasProps = {\n  renderers?: NodeRenderer[];\n  portTypes?: PortType[];\n  edgeTypes?: EdgeRenderer[];\n\n  data?: CanvasData;\n  defaultData?: CanvasData;\n  onChange?: (next: CanvasData) => void;\n\n  onBeforeDrop?: (\n    incoming: unknown,\n    point: { x: number; y: number },\n  ) => NodeData | null;\n  onBeforeConnect?: (\n    edge: EdgeRecord,\n    ctx: { source: Port; target: Port },\n  ) => boolean | EdgeRecord;\n\n  onNodeCreate?: (node: NodeRecord) => void;\n  onNodeUpdate?: (node: NodeRecord) => void;\n  onNodeDelete?: (nodeId: string) => void;\n  onEdgeCreate?: (edge: EdgeRecord) => void;\n  onEdgeDelete?: (edgeId: string) => void;\n  onSubObjectExtract?: (\n    parentId: string,\n    path: string,\n    gesture: \"copy\" | \"move\",\n  ) => void;\n\n  menuItems?: {\n    canvas?: MenuItem[];\n    node?: (node: NodeRecord) => MenuItem[];\n    edge?: (edge: EdgeRecord) => MenuItem[];\n  };\n\n  background?: FlowCanvasBackgroundConfig;\n\n  readOnly?: boolean;\n  panOnDrag?: boolean;\n  zoomOnScroll?: boolean;\n  selectionMode?: \"single\" | \"multi\";\n\n  /**\n   * When true (default v0.2.0+), xyflow culls nodes + edges outside the\n   * viewport before rendering. Transforms perf at large N (5k+ heavy: ~12×\n   * FPS lift on one machine, per 2026-05-14 baseline). Marginal effect\n   * below ~200 nodes.\n   *\n   * Known caveats (don't block adoption):\n   * - All nodes still render on initial mount; culling kicks in after the\n   *   first viewport movement (xyflow issue #4378).\n   * - Offscreen edges may stutter if their offscreen target node has an\n   *   explicit height (xyflow issue #4329).\n   *\n   * Pass `={false}` to opt out (e.g. if you rely on offscreen-node DOM for\n   * layout measurement — rare).\n   */\n  onlyRenderVisibleElements?: boolean;\n\n  /**\n   * Fired when a renderer requests an edit-dialog open (typically click).\n   * v0.2.1 addition.\n   *\n   * - `nodeId` is the node firing the request.\n   * - `subPath` is an opaque renderer-defined string (e.g. a card-tree subcard's\n   *   `__rcid`) for sub-targeting; pass it through to your editor (e.g. via\n   *   `CardTreeHandle.focusCard(subPath)` for the card-tree-node case).\n   * - Leave `undefined` if you don't want edit affordances; renderers that\n   *   honor the convention will silently no-op (their `ctx.onEditRequest`\n   *   will be `undefined`).\n   *\n   * See the popup-edit renderer convention in the procomp guide §8.3 + the\n   * `card-tree-node` procomp for the canonical consumer pattern.\n   */\n  onEditRequest?: (nodeId: string, subPath?: string) => void;\n\n  exportRef?: Ref<FlowCanvasExportHandle>;\n\n  \"aria-label\"?: string;\n\n  className?: string;\n};\n\n// ───── Layout helper props ─────\n\nexport type PortsAtProps = {\n  ports: Port[] | undefined;\n  position: PortSide;\n  spacing?: number;\n};\n",
      "type": "registry:component",
      "target": "components/flow-canvas/types.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/hooks/use-canvas-data.ts",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  addEdge as xyAddEdge,\n  applyEdgeChanges,\n  applyNodeChanges,\n  type Connection,\n  type Edge as XyEdge,\n  type EdgeChange,\n  type Node as XyNode,\n  type NodeChange,\n} from \"@xyflow/react\";\nimport { findPortInTree } from \"../lib/port-walker\";\nimport { removeAtPath } from \"../lib/sub-object-paths\";\nimport type {\n  CanvasData,\n  EdgeRecord,\n  FlowCanvasProps,\n  NodeData,\n  NodeRecord,\n  Port,\n} from \"../types\";\n\nconst EMPTY: CanvasData = { version: 1, nodes: [], edges: [] };\n\nfunction makeEdgeId(): string {\n  if (typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\") {\n    return `e-${crypto.randomUUID()}`;\n  }\n  return `e-${Math.random().toString(36).slice(2, 10)}`;\n}\n\nfunction makeNodeId(): string {\n  if (typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\") {\n    return crypto.randomUUID();\n  }\n  return `n-${Math.random().toString(36).slice(2, 10)}`;\n}\n\n// xyflow's Node type carries `type`, `data`, `position`, etc. Our adapter\n// always sets type to \"ilinxa-node\"; data carries our NodeData shape with\n// a stable `id` mirror so the canvas can find it without our custom store.\ntype XyNodeData = NodeRecord[\"data\"];\n\nfunction toXyNode(n: NodeRecord): XyNode<XyNodeData> {\n  return {\n    id: n.id,\n    type: \"ilinxa-node\",\n    position: n.position,\n    data: n.data,\n    width: n.width,\n    height: n.height,\n    selected: n.selected,\n    draggable: n.locked ? false : undefined,\n  };\n}\n\nfunction toXyEdge(e: EdgeRecord): XyEdge {\n  // xyflow expects sourceHandle / targetHandle as separate fields. Our\n  // EdgeRecord encodes them inline as 'nodeId:portId'.\n  const [source, sourceHandle] = e.source.split(\":\") as [string, string];\n  const [target, targetHandle] = e.target.split(\":\") as [string, string];\n  return {\n    id: e.id,\n    source,\n    sourceHandle,\n    target,\n    targetHandle,\n    type: e.type ?? \"ilinxa-edge\",\n    selected: e.selected,\n  };\n}\n\nfunction fromXyNode(n: XyNode<XyNodeData>): NodeRecord {\n  return {\n    id: n.id,\n    position: n.position,\n    data: n.data as XyNodeData,\n    width: typeof n.width === \"number\" ? n.width : undefined,\n    height: typeof n.height === \"number\" ? n.height : undefined,\n    selected: n.selected,\n    locked: n.draggable === false ? true : undefined,\n  };\n}\n\nfunction fromXyEdge(e: XyEdge): EdgeRecord {\n  const sourceHandle = e.sourceHandle ?? \"\";\n  const targetHandle = e.targetHandle ?? \"\";\n  return {\n    id: e.id,\n    source: `${e.source}:${sourceHandle}`,\n    target: `${e.target}:${targetHandle}`,\n    type: e.type === \"ilinxa-edge\" ? undefined : e.type,\n    selected: e.selected,\n  };\n}\n\n// v0.2.3 lock — structural-equality fast check for controlled-mode resync.\n// Skips the wholesale-replace path in the data-prop useEffect when the new\n// `data` reference is the round-trip echo of our own `fireOnChange` fire.\n// Without this guard, every consumer setCanvas (the canonical controlled\n// pattern) would wipe xyflow's internal node references → xyflow re-measures\n// dimensions every cycle → \"trying to drag a node that is not initialized\"\n// warning during the re-measurement window.\n//\n// Compares id / position.x / position.y / data-ref on nodes, id / source /\n// target on edges. Width/height intentionally NOT compared — those are\n// xyflow-managed (`measured` field) and don't round-trip through NodeRecord\n// cleanly. O(N) over nodes + edges; short-circuits on first mismatch. Fast\n// enough for controlled-mode consumers at N up to a few thousand; for larger\n// canvases, prefer `defaultData` (uncontrolled).\n//\n// v0.2.6 — viewport intentionally NOT compared (it was in v0.2.3–v0.2.5):\n// the camera can only be set at mount (`defaultViewport`); a `data.viewport`\n// change cannot move it afterwards, while the internal viewport now\n// live-tracks the real camera via `onMoveEnd`. Comparing them would make\n// every consumer echo that raced a pan/zoom look like a genuine external\n// change and trigger exactly the wholesale replace (measured-wipe → xyflow\n// #015) this guard exists to prevent. Nodes + edges alone decide.\n//\n// v0.2.4 — this check alone wasn't sufficient against the stale-snapshot\n// race: mid-drag mixed batches (position + dimensions) queued microtasks\n// whose snapshots fell behind further drag ticks, causing this check to\n// fail on legitimate divergence and trigger wholesale replace anyway. The\n// v0.2.4 fix is in onNodesChange + fireOnChange: suppress ALL onChange\n// notifications during drag (not just position-only), and have queued\n// microtasks bail at fire time if a drag started after they were queued.\n// The check below is still load-bearing for the non-drag round-trip path\n// (e.g. consumer applies an unrelated state update during steady state).\nfunction canvasMatchesInternalState(\n  data: CanvasData,\n  internalNodes: XyNode<XyNodeData>[],\n  internalEdges: XyEdge[],\n): boolean {\n  if (data.nodes.length !== internalNodes.length) return false;\n  if (data.edges.length !== internalEdges.length) return false;\n  for (let i = 0; i < data.nodes.length; i++) {\n    const dn = data.nodes[i];\n    const xn = internalNodes[i];\n    if (dn.id !== xn.id) return false;\n    if (dn.position.x !== xn.position.x) return false;\n    if (dn.position.y !== xn.position.y) return false;\n    if (dn.data !== xn.data) return false;\n  }\n  for (let i = 0; i < data.edges.length; i++) {\n    const de = data.edges[i];\n    const xe = internalEdges[i];\n    if (de.id !== xe.id) return false;\n    const expectedSource = `${xe.source}:${xe.sourceHandle ?? \"\"}`;\n    const expectedTarget = `${xe.target}:${xe.targetHandle ?? \"\"}`;\n    if (de.source !== expectedSource) return false;\n    if (de.target !== expectedTarget) return false;\n  }\n  return true;\n}\n\nexport type UseCanvasDataResult = {\n  // xyflow-shaped state for direct binding to <ReactFlow>\n  xyNodes: XyNode<XyNodeData>[];\n  xyEdges: XyEdge[];\n  onNodesChange: (changes: NodeChange<XyNode<XyNodeData>>[]) => void;\n  onEdgesChange: (changes: EdgeChange<XyEdge>[]) => void;\n  onConnect: (connection: Connection) => void;\n  // Drag lifecycle — bind to <ReactFlow>'s onNodeDragStart/onNodeDragStop\n  // props. During a drag, ALL consumer onChange notifications are suppressed\n  // (v0.2.4 widened from v0.2.0's position-only short-circuit); onNodeDragStop\n  // flushes a single authoritative fire with the committed post-drag state.\n  // Internal only — not re-exported from index.ts.\n  onNodeDragStart: () => void;\n  onNodeDragStop: () => void;\n  // Camera tracking — bind to <ReactFlow>'s onMoveEnd prop. Syncs the real\n  // viewport into state/ref after every pan/zoom so snapshot()/export/onChange\n  // carry the actual camera (v0.2.6; previously the viewport slice stayed\n  // frozen at its mount-time value). Camera-only moves deliberately do NOT\n  // fire onChange — a pan is not a data edit (and mount-time fitView would\n  // otherwise mark consumer documents dirty); the tracked value rides along\n  // on the next data-driven fire. Internal only — not re-exported.\n  onMoveEnd: (event: unknown, viewport: { x: number; y: number; zoom: number }) => void;\n  // Snapshot in our CanvasData shape (used by exportRef + viewport setters)\n  snapshot: () => CanvasData;\n  // Imperative helpers used by drop pipeline / sub-object extract / menus\n  appendNode: (node: NodeRecord) => void;\n  updateNodeData: (nodeId: string, mutate: (data: NodeData) => NodeData) => void;\n  duplicateNode: (nodeId: string) => void;\n  deleteNode: (nodeId: string) => void;\n  deleteEdge: (edgeId: string) => void;\n  setEdges: (edges: EdgeRecord[]) => void;\n  setNodes: (nodes: NodeRecord[]) => void;\n  replace: (data: CanvasData) => void;\n  // Sub-object extraction (atomic: append + optionally remove from parent)\n  extractSubObject: (input: {\n    parentId: string;\n    path: string;\n    gesture: \"copy\" | \"move\";\n    newNode: NodeRecord;\n  }) => void;\n  // Ref-based resolvers — used by callers (e.g. <CanvasContextMenu>) that\n  // need to look up nodes/edges by id without re-rendering on every state\n  // change. The returned functions are stable across renders; the lookup\n  // closes over a ref so it always sees the latest state.\n  getNodeById: (id: string) => NodeRecord | undefined;\n  getEdgeById: (id: string) => EdgeRecord | undefined;\n};\n\nexport function useCanvasData({\n  data,\n  defaultData,\n  onChange,\n  onBeforeConnect,\n  onNodeCreate,\n  onNodeUpdate,\n  onNodeDelete,\n  onEdgeCreate,\n  onEdgeDelete,\n  onSubObjectExtract,\n}: {\n  data?: CanvasData;\n  defaultData?: CanvasData;\n  onChange?: (next: CanvasData) => void;\n  onBeforeConnect?: FlowCanvasProps[\"onBeforeConnect\"];\n  onNodeCreate?: FlowCanvasProps[\"onNodeCreate\"];\n  onNodeUpdate?: FlowCanvasProps[\"onNodeUpdate\"];\n  onNodeDelete?: FlowCanvasProps[\"onNodeDelete\"];\n  onEdgeCreate?: FlowCanvasProps[\"onEdgeCreate\"];\n  onEdgeDelete?: FlowCanvasProps[\"onEdgeDelete\"];\n  onSubObjectExtract?: FlowCanvasProps[\"onSubObjectExtract\"];\n}): UseCanvasDataResult {\n  const isControlled = data !== undefined;\n  const initial = data ?? defaultData ?? EMPTY;\n\n  const [internalNodes, setInternalNodes] = useState<XyNode<XyNodeData>[]>(() =>\n    initial.nodes.map(toXyNode),\n  );\n  const [internalEdges, setInternalEdges] = useState<XyEdge[]>(() =>\n    initial.edges.map(toXyEdge),\n  );\n  const [viewport, setViewport] = useState(initial.viewport);\n\n  // Refs mirror the latest state so we can keep event handlers stable\n  // (empty-dep useCallback). This is the canonical xyflow perf pattern —\n  // see xyflow-react-pro skill \"Performance\" + \"useCallback every event\n  // handler\". At 200 nodes, dep-changing handlers cascade into a full\n  // ReactFlow reconciliation on every drag tick.\n  //\n  // v0.2.6 — every mutation site ALSO syncs its ref synchronously (inside\n  // its updater / before a direct set) so the coalesced fireOnChange\n  // microtask reads current state; the useEffect syncs below remain as the\n  // post-commit safety net (they also cover React discarding a render).\n  const nodesRef = useRef(internalNodes);\n  const edgesRef = useRef(internalEdges);\n  const viewportRef = useRef(viewport);\n  // Drag-lifecycle ref — flipped true between xyflow's onNodeDragStart and\n  // onNodeDragStop. While true, `onNodesChange` suppresses ALL consumer\n  // onChange notifications (v0.2.4 widened this from v0.2.0's position-only\n  // short-circuit — see the v0.2.4 lock comment on `onNodesChange` below).\n  // `fireOnChange`'s microtask body also checks this ref at fire time to\n  // drop pre-drag snapshots that would otherwise round-trip during drag.\n  // `onNodeDragStop` flushes a single authoritative fire with the committed\n  // post-drag state.\n  const isDraggingRef = useRef(false);\n  useEffect(() => {\n    nodesRef.current = internalNodes;\n  }, [internalNodes]);\n  useEffect(() => {\n    edgesRef.current = internalEdges;\n  }, [internalEdges]);\n  useEffect(() => {\n    viewportRef.current = viewport;\n  }, [viewport]);\n\n  // Also mirror callback props in refs so changing the consumer's callback\n  // identity doesn't churn our internal handlers.\n  const onChangeRef = useRef(onChange);\n  const onBeforeConnectRef = useRef(onBeforeConnect);\n  const onNodeCreateRef = useRef(onNodeCreate);\n  const onNodeUpdateRef = useRef(onNodeUpdate);\n  const onNodeDeleteRef = useRef(onNodeDelete);\n  const onEdgeCreateRef = useRef(onEdgeCreate);\n  const onEdgeDeleteRef = useRef(onEdgeDelete);\n  const onSubObjectExtractRef = useRef(onSubObjectExtract);\n  useEffect(() => {\n    onChangeRef.current = onChange;\n    onBeforeConnectRef.current = onBeforeConnect;\n    onNodeCreateRef.current = onNodeCreate;\n    onNodeUpdateRef.current = onNodeUpdate;\n    onNodeDeleteRef.current = onNodeDelete;\n    onEdgeCreateRef.current = onEdgeCreate;\n    onEdgeDeleteRef.current = onEdgeDelete;\n    onSubObjectExtractRef.current = onSubObjectExtract;\n  });\n\n  // Reflect controlled-mode changes — `data` prop wholesale replacement.\n  //\n  // v0.2.3: skip the resync when the new `data` prop is the round-trip echo\n  // of our own `fireOnChange`. After v0.2.2's microtask defer, consumer\n  // setCanvas creates a new top-level data reference even for echoes —\n  // reference equality alone (the v0.2.2 guard) misses every round-trip and\n  // wholesale-replaces xyflow's internal node refs each cycle, causing\n  // xyflow to re-measure dimensions and emit \"trying to drag a node that is\n  // not initialized\" warnings during the re-measurement window. Structural\n  // equality catches the echo while still allowing genuine external data\n  // changes to trigger the resync.\n  const lastControlledData = useRef<CanvasData | undefined>(data);\n  useEffect(() => {\n    if (!isControlled) return;\n    if (data === lastControlledData.current) return;\n    lastControlledData.current = data;\n    if (\n      data &&\n      canvasMatchesInternalState(data, nodesRef.current, edgesRef.current)\n    ) {\n      // Round-trip echo — internal state already correct, no resync.\n      return;\n    }\n    const nextNodes = (data ?? EMPTY).nodes.map(toXyNode);\n    const nextEdges = (data ?? EMPTY).edges.map(toXyEdge);\n    nodesRef.current = nextNodes;\n    edgesRef.current = nextEdges;\n    setInternalNodes(nextNodes);\n    setInternalEdges(nextEdges);\n    // v0.2.6 — viewport deliberately NOT adopted from `data`: the camera\n    // cannot be moved post-mount by the data prop (defaultViewport only) and\n    // the internal viewport tracks the REAL camera via onMoveEnd; overwriting\n    // it here would make snapshot()/onChange report a camera the user isn't\n    // seeing.\n  }, [isControlled, data]);\n\n  // v0.2.2 lock: notify the consumer in a microtask AFTER React commits the\n  // current state update. Why deferred:\n  //\n  // - `fireOnChange` is called from 13 sites in this file, MOST of which sit\n  //   inside `setInternalNodes/Edges/Viewport((prev) => { ... })` reducers\n  //   (the \"reducer-side-effect\" pattern acknowledged in v0.2.0 plan F-V4 as\n  //   a v0.3 cleanup candidate). When xyflow emits initial `dimensions`\n  //   changes during its own render-phase measurement, the reducer-side-\n  //   effect fires the consumer's `onChange` synchronously → consumer's\n  //   `setCanvas` → React 19 \"setState during render\" warning on the\n  //   consumer component. Surfaced 2026-05-16 by card-tree-node@v0.1.0's\n  //   demo (first controlled-mode consumer in the library).\n  //\n  // - Wrapping the body in `queueMicrotask` gives ONE invariant: consumer\n  //   `onChange` always fires post-commit. Every code path in this file\n  //   becomes render-phase-safe — no need to refactor each reducer-side-\n  //   effect site individually. Promotes the v0.3-deferred cleanup forward\n  //   because the bug surfaced; \"consistency over a one-off divergence\" per\n  //   F-V4 still holds (all 13 sites get the SAME deferred behavior, not a\n  //   mixed bag of one-microtask-deferred + twelve-still-synchronous).\n  //\n  // - v0.2.6 lock — COALESCED, REF-READ-AT-FIRE-TIME (replaces the v0.2.2\n  //   \"capture semantics\" paragraph). Previously each call captured\n  //   (nodes, edges, vp) in closure; call sites inside one updater passed\n  //   their own `next` but read the SIBLING slice from its ref synchronously\n  //   — before the sibling's updater/commit effect had run — so multi-slice\n  //   mutations (node delete + edge cascade; keyboard delete's two change\n  //   batches) notified with half-stale snapshots that could resurrect\n  //   dangling edges through the controlled round-trip. Now:\n  //     * every mutation site syncs its ref SYNCHRONOUSLY (inside the\n  //       updater, or before a direct set) and calls the zero-arg\n  //       fireOnChange();\n  //     * multiple requests in one tick coalesce into ONE microtask\n  //       (pending flag), whose body reads nodesRef/edgesRef/viewportRef at\n  //       fire time — by then every same-tick ref sync has happened → one\n  //       complete, self-consistent snapshot;\n  //     * StrictMode's double-invoked updaters coalesce to a single fire for\n  //       free (the double-fire noted in the 2026-08-10 review).\n  //   The v0.2.4 drag guard below is unchanged and composes: fires requested\n  //   during a drag are dropped at microtask time; onNodeDragStop requests\n  //   the single authoritative post-drag fire.\n  //\n  // - Latency: one microtask. Imperceptible. React batches the consumer's\n  //   resulting setState into the next paint either way.\n  const firePendingRef = useRef(false);\n  const fireOnChange = useCallback(() => {\n    if (firePendingRef.current) return;\n    firePendingRef.current = true;\n    queueMicrotask(() => {\n      firePendingRef.current = false;\n      // v0.2.4 lock — drop the fire if a drag started between request and\n      // microtask. Mid-drag round-trips would fail the structural-equality\n      // check → wholesale replace → measured wiped → xyflow #015.\n      // onNodeDragStop fires the authoritative post-drag snapshot.\n      if (isDraggingRef.current) return;\n      const cb = onChangeRef.current;\n      if (!cb) return;\n      cb({\n        version: 1,\n        nodes: nodesRef.current.map(fromXyNode),\n        edges: edgesRef.current.map(fromXyEdge),\n        viewport: viewportRef.current,\n      });\n    });\n  }, []);\n\n  const onNodesChange = useCallback(\n    (changes: NodeChange<XyNode<XyNodeData>>[]) => {\n      setInternalNodes((prev) => {\n        const next = applyNodeChanges(changes, prev);\n        // v0.2.6 — sync the ref inside the updater so the coalesced\n        // fireOnChange microtask reads current state (refs also stay live\n        // during drag; only the FIRE is suppressed).\n        nodesRef.current = next;\n        // v0.2.4 lock — suppress ALL consumer onChange notifications during\n        // drag, not just position-only batches. Mid-drag mixed batches\n        // (position + dimensions for non-dragged nodes during multi-select\n        // or auto-layout resize) used to slip through, queue a microtask\n        // with a snapshot, and by the time the microtask ran, further drag\n        // ticks had moved internal state on. The stale snapshot round-\n        // tripped through the consumer, failed structural-equality, and\n        // triggered wholesale replace → xyflow #015. onNodeDragStop flushes\n        // a single authoritative fire at drag end.\n        if (isDraggingRef.current) return next;\n        fireOnChange();\n        return next;\n      });\n    },\n    [fireOnChange],\n  );\n\n  // Drag-lifecycle callbacks — wired to <ReactFlow>'s onNodeDragStart /\n  // onNodeDragStop props in canvas.tsx. The pair plus the v0.2.4 drag-time\n  // suppression in `onNodesChange` and the microtask drag-guard in\n  // `fireOnChange` together implement the description §4.1 Change #2 + #3\n  // intent (no per-tick consumer fires during drag; single authoritative\n  // fire at drag end), widened in v0.2.4 to cover mixed batches too.\n  const onNodeDragStart = useCallback(() => {\n    isDraggingRef.current = true;\n  }, []);\n\n  const onNodeDragStop = useCallback(() => {\n    isDraggingRef.current = false;\n    // Flush a final onChange with the committed state. The identity-read\n    // updater runs AFTER any still-queued drag-tick updaters (React applies\n    // queued updaters in dispatch order), so `latestNodes` — and the ref\n    // sync — reflect the final position; returning the same reference bails\n    // out of a re-render. The reducer-side-effect pattern (here + the other\n    // sites in this file) is safe post-v0.2.2/v0.2.6: `fireOnChange` is\n    // microtask-deferred + coalesced, so it never runs synchronously during\n    // a reducer and double-invoked reducers request a single fire.\n    setInternalNodes((latestNodes) => {\n      nodesRef.current = latestNodes;\n      fireOnChange();\n      return latestNodes;\n    });\n  }, [fireOnChange]);\n\n  // v0.2.6 — live camera tracking (bound to <ReactFlow>'s onMoveEnd in\n  // canvas.tsx). See the UseCanvasDataResult comment for why camera-only\n  // moves don't fire onChange.\n  const onMoveEnd = useCallback(\n    (_event: unknown, vp: { x: number; y: number; zoom: number }) => {\n      const prev = viewportRef.current;\n      if (prev && prev.x === vp.x && prev.y === vp.y && prev.zoom === vp.zoom) {\n        return;\n      }\n      viewportRef.current = vp;\n      setViewport(vp);\n    },\n    [],\n  );\n\n  const onEdgesChange = useCallback(\n    (changes: EdgeChange<XyEdge>[]) => {\n      setInternalEdges((prev) => {\n        const next = applyEdgeChanges(changes, prev);\n        edgesRef.current = next;\n        fireOnChange();\n        return next;\n      });\n    },\n    [fireOnChange],\n  );\n\n  // onConnect — adds an edge after typed validation (in canvas.tsx) AND\n  // the consumer's onBeforeConnect hook (here). Pipeline:\n  //   xyflow drag-release\n  //     → isValidConnection (typed: same-type + dir + multi)  [canvas.tsx]\n  //     → onConnect          (this fn)\n  //         → onBeforeConnect (consumer; can reject or rewrite the edge)\n  //         → addEdge to state, fireOnChange, fire onEdgeCreate\n  const onConnect = useCallback(\n    (connection: Connection) => {\n      let candidate: EdgeRecord = {\n        id: makeEdgeId(),\n        source: `${connection.source}:${connection.sourceHandle ?? \"\"}`,\n        target: `${connection.target}:${connection.targetHandle ?? \"\"}`,\n      };\n\n      const before = onBeforeConnectRef.current;\n      if (before) {\n        const nodes = nodesRef.current;\n        const srcNode = nodes.find((n) => n.id === connection.source);\n        const tgtNode = nodes.find((n) => n.id === connection.target);\n        const srcPort: Port | undefined =\n          srcNode && connection.sourceHandle\n            ? findPortInTree(srcNode.data as NodeData, connection.sourceHandle)?.port\n            : undefined;\n        const tgtPort: Port | undefined =\n          tgtNode && connection.targetHandle\n            ? findPortInTree(tgtNode.data as NodeData, connection.targetHandle)?.port\n            : undefined;\n        if (!srcPort || !tgtPort) return;\n\n        const result = before(candidate, { source: srcPort, target: tgtPort });\n        if (result === false) return;\n        if (typeof result === \"object\" && result !== null) candidate = result;\n      }\n\n      setInternalEdges((prev) => {\n        const next = xyAddEdge(\n          {\n            ...connection,\n            id: candidate.id,\n            type: \"ilinxa-edge\",\n          },\n          prev,\n        );\n        edgesRef.current = next;\n        fireOnChange();\n        return next;\n      });\n      onEdgeCreateRef.current?.(candidate);\n    },\n    [fireOnChange],\n  );\n\n  const snapshot = useCallback<() => CanvasData>(\n    () => ({\n      version: 1,\n      nodes: nodesRef.current.map(fromXyNode),\n      edges: edgesRef.current.map(fromXyEdge),\n      viewport: viewportRef.current,\n    }),\n    [],\n  );\n\n  const appendNode = useCallback(\n    (node: NodeRecord) => {\n      setInternalNodes((prev) => {\n        const next = [...prev, toXyNode(node)];\n        nodesRef.current = next;\n        fireOnChange();\n        return next;\n      });\n      onNodeCreateRef.current?.(node);\n    },\n    [fireOnChange],\n  );\n\n  const updateNodeData = useCallback(\n    (nodeId: string, mutate: (data: NodeData) => NodeData) => {\n      // v0.2.6 (review §6 medium) — compute the next record eagerly from the\n      // ref BEFORE dispatch; the updater only applies the precomputed data.\n      // Running `mutate` + record capture inside the updater tied the\n      // `onNodeUpdate` decision to WHEN React ran it (an eager first-updater\n      // ran it in time; a deferred/StrictMode-replayed one ran it after the\n      // `if (updatedRecord)` check → the callback silently never fired, and\n      // consumer `mutate` code executed twice under StrictMode).\n      const target = nodesRef.current.find((n) => n.id === nodeId);\n      if (!target) return;\n      const updatedData = mutate(target.data as NodeData);\n      const updatedRecord = fromXyNode({ ...target, data: updatedData });\n      setInternalNodes((prev) => {\n        const next = prev.map((n) =>\n          n.id === nodeId ? { ...n, data: updatedData } : n,\n        );\n        nodesRef.current = next;\n        fireOnChange();\n        return next;\n      });\n      onNodeUpdateRef.current?.(updatedRecord);\n    },\n    [fireOnChange],\n  );\n\n  const duplicateNode = useCallback(\n    (nodeId: string) => {\n      const target = nodesRef.current.find((n) => n.id === nodeId);\n      if (!target) return;\n      const clone: NodeRecord = {\n        id: makeNodeId(),\n        position: { x: target.position.x + 24, y: target.position.y + 24 },\n        data: JSON.parse(JSON.stringify(target.data)),\n      };\n      appendNode(clone);\n    },\n    [appendNode],\n  );\n\n  const deleteNode = useCallback(\n    (nodeId: string) => {\n      // Cascade incident edges per Q15. Both updaters sync their ref and\n      // request a fire — the single coalesced microtask (v0.2.6) sees the\n      // node removal AND the edge cascade in one snapshot (pre-v0.2.6 the\n      // node updater read a stale pre-cascade edgesRef → the notified data\n      // could resurrect dangling edges through the controlled round-trip).\n      setInternalEdges((prev) => {\n        const next = prev.filter(\n          (e) => e.source !== nodeId && e.target !== nodeId,\n        );\n        edgesRef.current = next;\n        fireOnChange();\n        return next;\n      });\n      setInternalNodes((prev) => {\n        const next = prev.filter((n) => n.id !== nodeId);\n        nodesRef.current = next;\n        fireOnChange();\n        return next;\n      });\n      onNodeDeleteRef.current?.(nodeId);\n    },\n    [fireOnChange],\n  );\n\n  const deleteEdge = useCallback(\n    (edgeId: string) => {\n      setInternalEdges((prev) => {\n        const next = prev.filter((e) => e.id !== edgeId);\n        edgesRef.current = next;\n        fireOnChange();\n        return next;\n      });\n      onEdgeDeleteRef.current?.(edgeId);\n    },\n    [fireOnChange],\n  );\n\n  const setNodes = useCallback(\n    (nodes: NodeRecord[]) => {\n      const next = nodes.map(toXyNode);\n      nodesRef.current = next;\n      setInternalNodes(next);\n      fireOnChange();\n    },\n    [fireOnChange],\n  );\n\n  const setEdges = useCallback(\n    (edges: EdgeRecord[]) => {\n      const next = edges.map(toXyEdge);\n      edgesRef.current = next;\n      setInternalEdges(next);\n      fireOnChange();\n    },\n    [fireOnChange],\n  );\n\n  const replace = useCallback(\n    (next: CanvasData) => {\n      const xn = next.nodes.map(toXyNode);\n      const xe = next.edges.map(toXyEdge);\n      nodesRef.current = xn;\n      edgesRef.current = xe;\n      viewportRef.current = next.viewport;\n      setInternalNodes(xn);\n      setInternalEdges(xe);\n      setViewport(next.viewport);\n      fireOnChange();\n    },\n    [fireOnChange],\n  );\n\n  // Atomic sub-object extraction: append the new node AND (if 'move') remove\n  // the sub-object from the parent's data tree in a single state update.\n  // Plan §3.8 + Q23 — gesture defaults to 'copy' (parent retains).\n  const extractSubObject = useCallback(\n    ({\n      parentId,\n      path,\n      gesture,\n      newNode,\n    }: {\n      parentId: string;\n      path: string;\n      gesture: \"copy\" | \"move\";\n      newNode: NodeRecord;\n    }) => {\n      // v0.2.6 (review §6 medium) — same eager-compute rule as\n      // updateNodeData: the parent's post-removal record is derived from the\n      // ref BEFORE dispatch so `onNodeUpdate` cannot be skipped by updater\n      // timing; the updater only applies precomputed data.\n      let updatedParentData: NodeData | undefined;\n      let updatedParentRecord: NodeRecord | undefined;\n      if (gesture === \"move\") {\n        const parent = nodesRef.current.find((n) => n.id === parentId);\n        if (parent) {\n          updatedParentData = removeAtPath(parent.data as NodeData, path);\n          updatedParentRecord = fromXyNode({ ...parent, data: updatedParentData });\n        }\n      }\n      setInternalNodes((prev) => {\n        const withChild = [...prev, toXyNode(newNode)];\n        const parentData = updatedParentData;\n        const next =\n          parentData === undefined\n            ? withChild\n            : withChild.map((n) =>\n                n.id === parentId ? { ...n, data: parentData } : n,\n              );\n        nodesRef.current = next;\n        fireOnChange();\n        return next;\n      });\n      onNodeCreateRef.current?.(newNode);\n      if (updatedParentRecord) onNodeUpdateRef.current?.(updatedParentRecord);\n      onSubObjectExtractRef.current?.(parentId, path, gesture);\n    },\n    [fireOnChange],\n  );\n\n  const getNodeById = useCallback((id: string): NodeRecord | undefined => {\n    const n = nodesRef.current.find((x) => x.id === id);\n    return n ? fromXyNode(n) : undefined;\n  }, []);\n\n  const getEdgeById = useCallback((id: string): EdgeRecord | undefined => {\n    const e = edgesRef.current.find((x) => x.id === id);\n    return e ? fromXyEdge(e) : undefined;\n  }, []);\n\n  return useMemo(\n    () => ({\n      xyNodes: internalNodes,\n      xyEdges: internalEdges,\n      onNodesChange,\n      onEdgesChange,\n      onConnect,\n      onNodeDragStart,\n      onNodeDragStop,\n      onMoveEnd,\n      snapshot,\n      appendNode,\n      updateNodeData,\n      duplicateNode,\n      deleteNode,\n      deleteEdge,\n      setEdges,\n      setNodes,\n      replace,\n      extractSubObject,\n      getNodeById,\n      getEdgeById,\n    }),\n    [\n      internalNodes,\n      internalEdges,\n      onNodesChange,\n      onEdgesChange,\n      onConnect,\n      onNodeDragStart,\n      onNodeDragStop,\n      onMoveEnd,\n      snapshot,\n      appendNode,\n      updateNodeData,\n      duplicateNode,\n      deleteNode,\n      deleteEdge,\n      setEdges,\n      setNodes,\n      replace,\n      extractSubObject,\n      getNodeById,\n      getEdgeById,\n    ],\n  );\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/hooks/use-canvas-data.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/hooks/use-drop-pipeline.ts",
      "content": "\"use client\";\n\nimport { useCallback, type DragEvent, type ClipboardEvent } from \"react\";\nimport { useReactFlow } from \"@xyflow/react\";\nimport { coerceToNodeData } from \"../lib/coerce-to-node-data\";\nimport { inflateDefaultPorts } from \"../lib/inflate-default-ports\";\nimport { parseJsonSafe } from \"../lib/parse-json\";\nimport { findRenderer } from \"../registries/renderer-registry\";\nimport type {\n  FlowCanvasProps,\n  NodeData,\n  NodeRecord,\n  NodeRenderer,\n} from \"../types\";\n\nconst NODE_DROP_MIME = \"application/json\";\n// MIME used by xyflow's own sidebar pattern. We accept this too so consumers\n// who already use that convention keep working.\nconst REACTFLOW_DROP_MIME = \"application/reactflow\";\n// Custom MIME for in-canvas sub-object drag-extract (M5). Carries\n// `parentId|path` so the drop handler can route through the extraction\n// pipeline instead of treating it as an external JSON drop.\nconst SUBOBJECT_DROP_MIME = \"application/x-ilinxa-subobject\";\n\ntype DropContext = {\n  renderers: NodeRenderer[];\n  appendNode: (node: NodeRecord) => void;\n  extractSubObject: (input: {\n    parentId: string;\n    path: string;\n    gesture: \"copy\" | \"move\";\n    newNode: NodeRecord;\n  }) => void;\n  onBeforeDrop?: FlowCanvasProps[\"onBeforeDrop\"];\n  // v0.2.6 — no `onNodeCreate` here anymore: `appendNode` /\n  // `extractSubObject` fire it internally (review 5.3 double-fire fix).\n};\n\n// Build the drag-over / drop / paste handlers. The caller spreads these onto\n// the canvas wrapper element.\n//\n// `useReactFlow()` requires <ReactFlowProvider> in an ancestor — our root\n// already provides it.\nexport function useDropPipeline({\n  renderers,\n  appendNode,\n  extractSubObject,\n  onBeforeDrop,\n}: DropContext) {\n  const { screenToFlowPosition } = useReactFlow();\n\n  const buildNode = useCallback(\n    (rawData: NodeData, point: { x: number; y: number }): NodeRecord | null => {\n      let data: NodeData | null = rawData;\n      if (onBeforeDrop) {\n        const result = onBeforeDrop(rawData, point);\n        if (result === null) return null;\n        if (result) data = result;\n      }\n      if (!data) return null;\n      const renderer = findRenderer(renderers, data.__type);\n      const inflated = inflateDefaultPorts(data, renderer);\n      return { id: makeNodeId(), position: point, data: inflated };\n    },\n    [renderers, onBeforeDrop],\n  );\n\n  const dispatch = useCallback(\n    (rawData: NodeData, point: { x: number; y: number }) => {\n      const node = buildNode(rawData, point);\n      if (!node) return;\n      // appendNode fires onNodeCreate internally — do NOT also call the prop\n      // here (v0.2.6, review 5.3: the extra call double-fired onNodeCreate on\n      // every drop/paste, so consumers creating backend records per create\n      // inserted duplicates).\n      appendNode(node);\n    },\n    [buildNode, appendNode],\n  );\n\n  const dispatchExtraction = useCallback(\n    (\n      rawData: NodeData,\n      point: { x: number; y: number },\n      parentId: string,\n      path: string,\n      gesture: \"copy\" | \"move\",\n    ) => {\n      const node = buildNode(rawData, point);\n      if (!node) return;\n      extractSubObject({ parentId, path, gesture, newNode: node });\n    },\n    [buildNode, extractSubObject],\n  );\n\n  const onDragOver = useCallback((e: DragEvent) => {\n    e.preventDefault();\n    e.dataTransfer.dropEffect = \"move\";\n  }, []);\n\n  const onDrop = useCallback(\n    (e: DragEvent) => {\n      e.preventDefault();\n      const dt = e.dataTransfer;\n\n      // Sub-object extraction branch — own-canvas drag tagged by emitSubObjectDrag.\n      const subPayload = dt.getData(SUBOBJECT_DROP_MIME);\n      const jsonPayload = dt.getData(NODE_DROP_MIME);\n      if (subPayload && jsonPayload) {\n        const parsed = parseJsonSafe(jsonPayload);\n        const data = coerceToNodeData(parsed);\n        if (!data) return;\n        const meta = parseJsonSafe(subPayload) as\n          | { parentId?: string; path?: string }\n          | undefined;\n        if (!meta?.parentId || !meta.path) return;\n        const point = screenToFlowPosition({ x: e.clientX, y: e.clientY });\n        // Alt held → move; otherwise copy. Documented as the gesture.\n        const gesture = e.altKey ? \"move\" : \"copy\";\n        dispatchExtraction(data, point, meta.parentId, meta.path, gesture);\n        return;\n      }\n\n      // Order of preference: explicit JSON, xyflow's reactflow MIME, plain text.\n      const payload =\n        jsonPayload ||\n        dt.getData(REACTFLOW_DROP_MIME) ||\n        dt.getData(\"text/plain\");\n\n      // No text payload — try files (first one only).\n      if (!payload && dt.files.length > 0) {\n        const file = dt.files[0];\n        if (!file.type.includes(\"json\") && !file.name.endsWith(\".json\")) return;\n        // Read async; abort if it fails.\n        file\n          .text()\n          .then((text) => {\n            const parsed = parseJsonSafe(text);\n            const data = coerceToNodeData(parsed);\n            if (!data) return;\n            const point = screenToFlowPosition({ x: e.clientX, y: e.clientY });\n            dispatch(data, point);\n          })\n          .catch(() => undefined);\n        return;\n      }\n\n      if (!payload) return;\n      const parsed = parseJsonSafe(payload);\n      const data = coerceToNodeData(parsed);\n      if (!data) return;\n      const point = screenToFlowPosition({ x: e.clientX, y: e.clientY });\n      dispatch(data, point);\n    },\n    [screenToFlowPosition, dispatch, dispatchExtraction],\n  );\n\n  const onPaste = useCallback(\n    (e: ClipboardEvent) => {\n      // Don't steal paste from focused inputs/textareas/contenteditables.\n      const target = e.target as HTMLElement | null;\n      if (target && isEditableTarget(target)) return;\n\n      const text = e.clipboardData.getData(\"text/plain\");\n      if (!text) return;\n      const parsed = parseJsonSafe(text);\n      const data = coerceToNodeData(parsed);\n      if (!data) return;\n\n      // Paste lands at the viewport center (no mouse coordinates available).\n      // Compute via the canvas root's bounding box.\n      const root =\n        target && target.closest<HTMLDivElement>(\"[data-flow-canvas-root]\");\n      const rect = root?.getBoundingClientRect();\n      const sx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;\n      const sy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;\n      const point = screenToFlowPosition({ x: sx, y: sy });\n\n      e.preventDefault();\n      dispatch(data, point);\n    },\n    [screenToFlowPosition, dispatch],\n  );\n\n  return { onDragOver, onDrop, onPaste };\n}\n\nfunction isEditableTarget(el: HTMLElement): boolean {\n  if (el.isContentEditable) return true;\n  const tag = el.tagName;\n  return tag === \"INPUT\" || tag === \"TEXTAREA\" || tag === \"SELECT\";\n}\n\nfunction makeNodeId(): string {\n  if (typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\") {\n    return crypto.randomUUID();\n  }\n  return `n-${Math.random().toString(36).slice(2, 10)}`;\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/hooks/use-drop-pipeline.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/hooks/use-export.ts",
      "content": "\"use client\";\n\nimport { useImperativeHandle, type Ref } from \"react\";\nimport type {\n  CanvasData,\n  EdgeRecord,\n  FlowCanvasExportHandle,\n  NodeData,\n  NodeRecord,\n} from \"../types\";\n\nfunction stripPortsFromNodeData(data: NodeData): NodeData {\n  const { ports: _ports, ...rest } = data;\n  void _ports;\n  const out: NodeData = { __type: data.__type };\n  for (const [key, value] of Object.entries(rest)) {\n    if (key === \"__type\") continue;\n    out[key] = stripPortsFromValue(value);\n  }\n  return out;\n}\n\nfunction stripPortsFromValue(value: unknown): unknown {\n  if (Array.isArray(value)) {\n    return value.map((v) => stripPortsFromValue(v));\n  }\n  if (value && typeof value === \"object\") {\n    const obj = value as Record<string, unknown>;\n    if (typeof obj.__type === \"string\") {\n      return stripPortsFromNodeData(obj as NodeData);\n    }\n    const cleaned: Record<string, unknown> = {};\n    for (const [k, v] of Object.entries(obj)) {\n      if (k === \"ports\") continue;\n      cleaned[k] = stripPortsFromValue(v);\n    }\n    return cleaned;\n  }\n  return value;\n}\n\nfunction stripSelected<T extends { selected?: boolean }>(\n  record: T,\n): Omit<T, \"selected\"> {\n  const { selected: _selected, ...rest } = record;\n  void _selected;\n  return rest;\n}\n\nexport function useExportHandle(\n  ref: Ref<FlowCanvasExportHandle> | undefined,\n  getSnapshot: () => CanvasData,\n) {\n  useImperativeHandle(\n    ref,\n    () => ({\n      export: ({ withPorts }) => {\n        const snap = getSnapshot();\n        if (withPorts) {\n          return {\n            ...snap,\n            nodes: snap.nodes.map((n) => stripSelected(n) as NodeRecord),\n            edges: snap.edges.map((e) => stripSelected(e) as EdgeRecord),\n          };\n        }\n        return {\n          ...snap,\n          nodes: snap.nodes.map((n) => ({\n            ...stripSelected(n),\n            data: stripPortsFromNodeData(n.data),\n          })) as NodeRecord[],\n          edges: [],\n        };\n      },\n    }),\n    [getSnapshot],\n  );\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/hooks/use-export.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/lib/coerce-to-node-data.ts",
      "content": "import type { NodeData } from \"../types\";\n\n// Coerce arbitrary parsed JSON into a `NodeData` shape. Rules:\n//   - `null` / non-objects / arrays → undefined (caller aborts)\n//   - object with string `__type` → returned as-is (already NodeData)\n//   - object without `__type`     → wrapped as `{ __type: 'custom-json', ...obj }`\n//\n// This is the canonical single-place where unknown payloads get the fallback\n// discriminator. M5 sub-object extraction also funnels through here when an\n// extracted sub-object lacks `__type` (description risk §10).\n\nexport function coerceToNodeData(input: unknown): NodeData | undefined {\n  if (!input || typeof input !== \"object\") return undefined;\n  if (Array.isArray(input)) return undefined;\n\n  const obj = input as Record<string, unknown>;\n  if (typeof obj.__type === \"string\" && obj.__type.length > 0) {\n    return obj as NodeData;\n  }\n  return { __type: \"custom-json\", ...obj };\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/lib/coerce-to-node-data.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/lib/emit-sub-object-drag.ts",
      "content": "import type { DragEvent } from \"react\";\n\n// Renderers call this from a sub-object's `onDragStart` to mark the drag as\n// a sub-object extraction. The drop handler (canvas root) reads the special\n// MIME and routes the drop through the extraction pipeline (copy by default,\n// Alt-drag for move).\n//\n// Pattern (in a renderer):\n//\n//   <div\n//     data-draggable-subobject={`media[${i}]`}\n//     draggable\n//     onDragStart={(e) => emitSubObjectDrag(\n//       e, data.media[i], `media[${i}]`, ctx.nodeId\n//     )}\n//   >\n//     <MediaItem {...data.media[i]} />\n//   </div>\n//\n// Calling stopPropagation prevents xyflow's node-drag from also firing.\n\nexport function emitSubObjectDrag(\n  e: DragEvent,\n  subData: unknown,\n  path: string,\n  parentId: string,\n): void {\n  e.stopPropagation();\n  e.dataTransfer.setData(\"application/json\", JSON.stringify(subData));\n  e.dataTransfer.setData(\n    \"application/x-ilinxa-subobject\",\n    JSON.stringify({ parentId, path }),\n  );\n  e.dataTransfer.effectAllowed = \"copyMove\";\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/lib/emit-sub-object-drag.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/lib/inflate-default-ports.ts",
      "content": "import type { NodeData, NodeRenderer, Port } from \"../types\";\n\n// Apply renderer.defaultPorts + renderer.defaultSubPorts to a NodeData ONLY\n// where the corresponding `ports` field is absent. Presence of `ports` (even\n// empty `[]`) is the deliberate-state signal — never re-inflate (Q23).\n//\n// Pure: returns a new NodeData; the input is untouched. Source-shape data is\n// preserved this way.\n\nexport function inflateDefaultPorts(\n  data: NodeData,\n  renderer: NodeRenderer | undefined,\n): NodeData {\n  if (!renderer) return data;\n\n  let out: NodeData = data;\n\n  // Root-level ports inflation\n  if (!Object.prototype.hasOwnProperty.call(data, \"ports\") && renderer.defaultPorts) {\n    const ports: Port[] = renderer.defaultPorts(data);\n    out = { ...out, ports };\n  }\n\n  // Sub-object port inflation, by JSON path. We support a small subset of\n  // path syntax: dot-path and array-index (`media[0].thumb`). Plan §3.5\n  // commits to this shape; richer JSONPath is a future extension.\n  if (renderer.defaultSubPorts) {\n    const subMap = renderer.defaultSubPorts(data);\n    for (const [path, defaultPorts] of Object.entries(subMap)) {\n      out = setPortsAtPath(out, path, defaultPorts);\n    }\n  }\n\n  return out;\n}\n\n// Apply `ports` to the object at `path` inside `data`, ONLY if absent.\n// Immutable — clones the affected branches.\nfunction setPortsAtPath(\n  data: NodeData,\n  path: string,\n  ports: Port[],\n): NodeData {\n  const segments = parsePath(path);\n  if (segments.length === 0) return data;\n\n  const cloned = JSON.parse(JSON.stringify(data)) as NodeData;\n  let cursor: unknown = cloned;\n  for (let i = 0; i < segments.length; i++) {\n    const seg = segments[i];\n    if (cursor === null || typeof cursor !== \"object\") return data;\n    if (seg.kind === \"key\") {\n      cursor = (cursor as Record<string, unknown>)[seg.value];\n    } else {\n      if (!Array.isArray(cursor)) return data;\n      cursor = cursor[seg.value];\n    }\n  }\n  if (!cursor || typeof cursor !== \"object\" || Array.isArray(cursor)) return data;\n  const obj = cursor as Record<string, unknown>;\n  if (Object.prototype.hasOwnProperty.call(obj, \"ports\")) return data; // respect existing\n  obj.ports = ports;\n  return cloned;\n}\n\ntype PathSegment =\n  | { kind: \"key\"; value: string }\n  | { kind: \"index\"; value: number };\n\nfunction parsePath(path: string): PathSegment[] {\n  // Splits \"media[0].thumb\" → [{key,'media'},{index,0},{key,'thumb'}]\n  const out: PathSegment[] = [];\n  const re = /([^.[\\]]+)|\\[(\\d+)\\]/g;\n  let m: RegExpExecArray | null;\n  while ((m = re.exec(path)) !== null) {\n    if (m[1] !== undefined) out.push({ kind: \"key\", value: m[1] });\n    else if (m[2] !== undefined) out.push({ kind: \"index\", value: Number(m[2]) });\n  }\n  return out;\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/lib/inflate-default-ports.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/lib/parse-json.ts",
      "content": "// Safe JSON parse — does NOT throw. Returns the parsed value on success or\n// `undefined` on any error (with a dev-only `console.warn`). Used by the\n// drop / paste pipelines so an invalid payload aborts cleanly.\n\nexport function parseJsonSafe(text: string): unknown {\n  if (!text) return undefined;\n  try {\n    return JSON.parse(text);\n  } catch (err) {\n    if (process.env.NODE_ENV !== \"production\") {\n      console.warn(\"[flow-canvas] could not parse JSON payload:\", err);\n    }\n    return undefined;\n  }\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/lib/parse-json.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/lib/port-walker.ts",
      "content": "import type { NodeData, Port } from \"../types\";\n\n// Pure tree-walker. Returns the first port matching `portId` at any depth,\n// plus the JSON path to its containing object. Walker assumes port ids are\n// unique within a node (locked decision Q9 / plan §3.4).\n\nexport type FoundPort = {\n  port: Port;\n  path: string; // JSONPath to the containing object — \"\" for root\n};\n\nexport function findPortInTree(\n  data: NodeData,\n  portId: string,\n): FoundPort | undefined {\n  return walk(data, portId, \"\");\n}\n\nfunction walk(\n  value: unknown,\n  portId: string,\n  path: string,\n): FoundPort | undefined {\n  if (!value || typeof value !== \"object\") return undefined;\n\n  if (Array.isArray(value)) {\n    for (let i = 0; i < value.length; i++) {\n      const found = walk(value[i], portId, `${path}[${i}]`);\n      if (found) return found;\n    }\n    return undefined;\n  }\n\n  const obj = value as Record<string, unknown>;\n\n  // Direct port match — this object owns ports[]\n  const ports = obj.ports;\n  if (Array.isArray(ports)) {\n    for (const p of ports) {\n      if (p && typeof p === \"object\" && (p as Port).id === portId) {\n        return { port: p as Port, path };\n      }\n    }\n  }\n\n  // Recurse into children\n  for (const [key, child] of Object.entries(obj)) {\n    if (key === \"ports\") continue;\n    if (key === \"__type\") continue;\n    const childPath = path ? `${path}.${key}` : key;\n    const found = walk(child, portId, childPath);\n    if (found) return found;\n  }\n  return undefined;\n}\n\n// Convenience: count edges whose `nodeId:portId` matches a side.\n// Used by the connection validator for `multi: false` enforcement.\nexport function countEdgesAtPort(\n  edges: ReadonlyArray<{\n    source: string;\n    target: string;\n    sourceHandle?: string | null;\n    targetHandle?: string | null;\n  }>,\n  nodeId: string,\n  portId: string,\n  side: \"source\" | \"target\",\n): number {\n  if (side === \"source\") {\n    return edges.filter(\n      (e) => e.source === nodeId && (e.sourceHandle ?? \"\") === portId,\n    ).length;\n  }\n  return edges.filter(\n    (e) => e.target === nodeId && (e.targetHandle ?? \"\") === portId,\n  ).length;\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/lib/port-walker.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/lib/shallow.ts",
      "content": "/**\n * Inline shallow-equality helper. Matches zustand's `shallow` algorithm\n * (zustand v3/v4/v5 — algorithm unchanged) without taking on a transitive\n * dep on zustand itself. Used by xyflow's `useStore(selector, equality?)`\n * second arg where the default identity equality thrashes — e.g. a\n * selector that looks up an object in a Map and returns it: the lookup\n * resolves to the same underlying object, but xyflow's store updates the\n * Map reference on every change, so the selector returns a fresh reference\n * even when the meaningful contents are unchanged.\n *\n * Map / Set comparison is intentionally omitted — selectors in this\n * codebase never return those.\n *\n * Kept in the sealed folder (rather than importing from `zustand/shallow`)\n * for registry-portability: this avoids leaking a transitive zustand dep\n * to consumers of `@ilinxa/flow-canvas` who haven't otherwise installed\n * it. xyflow's own zustand peer is nested and doesn't surface to consumer\n * package.json. v0.2.0 Tier 2 plan F-01.\n */\nexport function shallow<T>(a: T, b: T): boolean {\n  if (Object.is(a, b)) return true;\n  if (typeof a !== \"object\" || a === null) return false;\n  if (typeof b !== \"object\" || b === null) return false;\n  const aKeys = Object.keys(a as object);\n  const bKeys = Object.keys(b as object);\n  if (aKeys.length !== bKeys.length) return false;\n  for (const k of aKeys) {\n    if (!Object.prototype.hasOwnProperty.call(b, k)) return false;\n    if (\n      !Object.is(\n        (a as Record<string, unknown>)[k],\n        (b as Record<string, unknown>)[k],\n      )\n    ) {\n      return false;\n    }\n  }\n  return true;\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/lib/shallow.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/lib/sub-object-paths.ts",
      "content": "import type { NodeData } from \"../types\";\n\n// Mutate `data` to remove the value at `path`. Pure: returns a new tree;\n// the input is not modified. Supports dot-paths and array indices —\n// `media[0]`, `slots[2].thumb`.\n//\n// Used by the sub-object extraction pipeline when the user holds Alt on\n// drop (move semantics): the new node spawns AND the parent loses the\n// sub-object via `onNodeUpdate`.\n\nexport function removeAtPath(data: NodeData, path: string): NodeData {\n  const segments = parsePath(path);\n  if (segments.length === 0) return data;\n  const cloned = JSON.parse(JSON.stringify(data)) as NodeData;\n  removeAtPathMut(cloned as unknown, segments);\n  return cloned;\n}\n\ntype Segment =\n  | { kind: \"key\"; value: string }\n  | { kind: \"index\"; value: number };\n\nfunction parsePath(path: string): Segment[] {\n  const out: Segment[] = [];\n  const re = /([^.[\\]]+)|\\[(\\d+)\\]/g;\n  let m: RegExpExecArray | null;\n  while ((m = re.exec(path)) !== null) {\n    if (m[1] !== undefined) out.push({ kind: \"key\", value: m[1] });\n    else if (m[2] !== undefined) out.push({ kind: \"index\", value: Number(m[2]) });\n  }\n  return out;\n}\n\nfunction removeAtPathMut(cursor: unknown, segments: Segment[]): void {\n  if (segments.length === 0) return;\n  if (cursor === null || typeof cursor !== \"object\") return;\n  const last = segments[segments.length - 1];\n  const parents = segments.slice(0, -1);\n\n  let parent: unknown = cursor;\n  for (const seg of parents) {\n    if (parent === null || typeof parent !== \"object\") return;\n    if (seg.kind === \"key\") {\n      parent = (parent as Record<string, unknown>)[seg.value];\n    } else {\n      if (!Array.isArray(parent)) return;\n      parent = parent[seg.value];\n    }\n  }\n  if (parent === null || typeof parent !== \"object\") return;\n\n  if (last.kind === \"index\") {\n    if (!Array.isArray(parent)) return;\n    parent.splice(last.value, 1);\n  } else {\n    delete (parent as Record<string, unknown>)[last.value];\n  }\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/lib/sub-object-paths.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/lib/update-node-data.ts",
      "content": "import type { CanvasData, NodeData } from \"../types\";\n\n/**\n * Walk-and-replace the `data` field of a single node by id. Returns a new\n * `CanvasData` reference (immutable update — does NOT mutate the input).\n * Returns the input unchanged if `nodeId` is not found.\n *\n * Shipped as a typed utility so consumers wiring `onEditRequest` + a dialog\n * don't reinvent it. See the `card-tree-node` procomp guide for the\n * canonical popup-edit pattern. v0.2.1 addition.\n *\n * @example\n *   const next = updateNodeData(canvas, editingNodeId, newCardTree);\n *   setCanvas(next);\n */\nexport function updateNodeData(\n  canvas: CanvasData,\n  nodeId: string,\n  nextData: NodeData,\n): CanvasData {\n  const idx = canvas.nodes.findIndex((n) => n.id === nodeId);\n  if (idx < 0) return canvas;\n  const nextNodes = canvas.nodes.slice();\n  nextNodes[idx] = { ...nextNodes[idx], data: nextData };\n  return { ...canvas, nodes: nextNodes };\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/lib/update-node-data.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/parts/background.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { Background as XyBackground, BackgroundVariant } from \"@xyflow/react\";\nimport { cn } from \"@/lib/utils\";\nimport type { FlowCanvasBackgroundConfig } from \"../types\";\n\nconst DEFAULT_LIGHT = {\n  from: \"var(--background)\",\n  to: \"var(--muted)\",\n  angle: 145,\n};\nconst DEFAULT_DARK = {\n  from: \"var(--background)\",\n  to: \"var(--card)\",\n  angle: 145,\n};\n\nconst VARIANT_MAP = {\n  dots: BackgroundVariant.Dots,\n  grid: BackgroundVariant.Lines,\n  cross: BackgroundVariant.Cross,\n} as const;\n\nfunction FlowCanvasBackgroundImpl({\n  config,\n}: {\n  config?: FlowCanvasBackgroundConfig;\n}) {\n  const light = { ...DEFAULT_LIGHT, ...config?.light };\n  const dark = { ...DEFAULT_DARK, ...config?.dark };\n  const overlay = config?.overlay ?? \"dots\";\n  const overlayOpacity = config?.overlayOpacity ?? 0.4;\n\n  return (\n    <>\n      <div\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute inset-0 transition-opacity\",\n          \"[background:linear-gradient(var(--flow-canvas-light-angle),var(--flow-canvas-light-from),var(--flow-canvas-light-to))]\",\n          \"dark:[background:linear-gradient(var(--flow-canvas-dark-angle),var(--flow-canvas-dark-from),var(--flow-canvas-dark-to))]\",\n        )}\n        style={\n          {\n            \"--flow-canvas-light-from\": light.from,\n            \"--flow-canvas-light-to\": light.to,\n            \"--flow-canvas-light-angle\": `${light.angle}deg`,\n            \"--flow-canvas-dark-from\": dark.from,\n            \"--flow-canvas-dark-to\": dark.to,\n            \"--flow-canvas-dark-angle\": `${dark.angle}deg`,\n          } as React.CSSProperties\n        }\n      />\n      {overlay !== \"none\" && (\n        <XyBackground\n          variant={VARIANT_MAP[overlay]}\n          gap={20}\n          size={1}\n          color=\"var(--border)\"\n          style={{ opacity: overlayOpacity }}\n        />\n      )}\n    </>\n  );\n}\n\nexport const FlowCanvasBackground = memo(FlowCanvasBackgroundImpl);\n",
      "type": "registry:component",
      "target": "components/flow-canvas/parts/background.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/parts/canvas-context-menu.tsx",
      "content": "\"use client\";\n\nimport {\n  Copy,\n  CopyPlus,\n  FileJson,\n  Maximize2,\n  RotateCcw,\n  Scissors,\n  Trash2,\n} from \"lucide-react\";\nimport { useCallback, useState, type ReactNode } from \"react\";\nimport { useReactFlow } from \"@xyflow/react\";\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuLabel,\n  ContextMenuSeparator,\n  ContextMenuTrigger,\n} from \"@/components/ui/context-menu\";\nimport { findRenderer } from \"../registries/renderer-registry\";\nimport { useFlowCanvasContext } from \"../registries/canvas-context\";\nimport type {\n  EdgeRecord,\n  FlowCanvasProps,\n  MenuItem,\n  NodeData,\n  NodeRecord,\n} from \"../types\";\nimport { PasteDialog } from \"./paste-dialog\";\n\ntype Target =\n  | { kind: \"canvas\"; point: { x: number; y: number } }\n  | { kind: \"node\"; node: NodeRecord }\n  | { kind: \"edge\"; edge: EdgeRecord };\n\ntype Actions = {\n  duplicateNode: (id: string) => void;\n  deleteNode: (id: string) => void;\n  deleteEdge: (id: string) => void;\n  updateNodeData: (id: string, mutate: (d: NodeData) => NodeData) => void;\n  extractSubObject: (input: {\n    parentId: string;\n    path: string;\n    gesture: \"copy\" | \"move\";\n    newNode: NodeRecord;\n  }) => void;\n  appendNode: (node: NodeRecord) => void;\n};\n\nexport function CanvasContextMenu({\n  children,\n  readOnly,\n  menuItems,\n  getNodeById,\n  getEdgeById,\n  actions,\n}: {\n  children: ReactNode;\n  readOnly: boolean;\n  menuItems: FlowCanvasProps[\"menuItems\"];\n  // Resolver-based lookup — avoids re-rendering the menu on every drag tick.\n  // Pre-v0.1.2 took `nodes: NodeRecord[]` + `edges: EdgeRecord[]` which\n  // forced a full ~200-element re-map per state update.\n  getNodeById: (id: string) => NodeRecord | undefined;\n  getEdgeById: (id: string) => EdgeRecord | undefined;\n  actions: Actions;\n}) {\n  const { renderers } = useFlowCanvasContext();\n  const { fitView, zoomTo, screenToFlowPosition } = useReactFlow();\n  const [target, setTarget] = useState<Target | null>(null);\n  const [pasteOpen, setPasteOpen] = useState(false);\n  const [pastePoint, setPastePoint] = useState<{ x: number; y: number } | null>(\n    null,\n  );\n\n  // Resolve what was right-clicked. The xyflow DOM exposes data-id on\n  // .react-flow__node and .react-flow__edge.\n  const handleContextMenu = useCallback(\n    (e: React.MouseEvent<HTMLElement>) => {\n      const el = e.target as HTMLElement;\n      const nodeEl = el.closest(\"[data-id]\") as HTMLElement | null;\n      const isNode = nodeEl?.classList.contains(\"react-flow__node\") ?? false;\n      const edgeEl = el.closest(\".react-flow__edge\") as HTMLElement | null;\n      const isEdge = !!edgeEl;\n\n      if (isNode && nodeEl) {\n        const id = nodeEl.getAttribute(\"data-id\");\n        const node = id ? getNodeById(id) : undefined;\n        if (node) {\n          setTarget({ kind: \"node\", node });\n          return;\n        }\n      }\n      if (isEdge && edgeEl) {\n        const id = edgeEl.getAttribute(\"data-id\");\n        const edge = id ? getEdgeById(id) : undefined;\n        if (edge) {\n          setTarget({ kind: \"edge\", edge });\n          return;\n        }\n      }\n      const point = screenToFlowPosition({ x: e.clientX, y: e.clientY });\n      setTarget({ kind: \"canvas\", point });\n    },\n    [getNodeById, getEdgeById, screenToFlowPosition],\n  );\n\n  const copyAsJson = useCallback((node: NodeRecord) => {\n    const text = JSON.stringify(node.data, null, 2);\n    if (typeof navigator !== \"undefined\" && navigator.clipboard) {\n      navigator.clipboard.writeText(text).catch(() => undefined);\n    }\n  }, []);\n\n  const convertToCustomJson = useCallback(\n    (node: NodeRecord) => {\n      actions.updateNodeData(node.id, (d) => ({ ...d, __type: \"custom-json\" }));\n    },\n    [actions],\n  );\n\n  const addCustomNode = useCallback(\n    (point: { x: number; y: number }) => {\n      const node: NodeRecord = {\n        id:\n          typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\"\n            ? crypto.randomUUID()\n            : `n-${Math.random().toString(36).slice(2, 10)}`,\n        position: point,\n        data: { __type: \"custom-json\", _label: \"Custom JSON\" },\n      };\n      actions.appendNode(node);\n    },\n    [actions],\n  );\n\n  return (\n    <>\n      <ContextMenu>\n        {/* F-cross-13: no `asChild` — trigger is a display:contents wrapper.\n            Right-click bubbles children → resolver div (sets target) →\n            trigger (opens menu) in both backends. */}\n        <ContextMenuTrigger className=\"contents\">\n          <div onContextMenu={handleContextMenu} className=\"contents\">\n            {children}\n          </div>\n        </ContextMenuTrigger>\n        <ContextMenuContent>\n          {target?.kind === \"node\" && (\n            <NodeMenu\n              node={target.node}\n              renderers={renderers}\n              readOnly={readOnly}\n              consumerItems={menuItems?.node}\n              onCopyJson={copyAsJson}\n              onConvert={convertToCustomJson}\n              onDuplicate={actions.duplicateNode}\n              onDelete={actions.deleteNode}\n              onExtract={(path) =>\n                extractFromNode(target.node, path, actions.extractSubObject)\n              }\n            />\n          )}\n          {target?.kind === \"edge\" && (\n            <EdgeMenu\n              edge={target.edge}\n              readOnly={readOnly}\n              consumerItems={menuItems?.edge}\n              onDelete={actions.deleteEdge}\n            />\n          )}\n          {target?.kind === \"canvas\" && (\n            <PaneMenu\n              readOnly={readOnly}\n              consumerItems={menuItems?.canvas}\n              onPaste={() => {\n                setPastePoint(target.point);\n                setPasteOpen(true);\n              }}\n              onAddCustom={() => addCustomNode(target.point)}\n              onFitView={() => fitView({ duration: 200 })}\n              onResetZoom={() => zoomTo(1, { duration: 150 })}\n            />\n          )}\n        </ContextMenuContent>\n      </ContextMenu>\n      <PasteDialog\n        open={pasteOpen}\n        onOpenChange={setPasteOpen}\n        onSubmit={(data) => {\n          if (!pastePoint) return;\n          const node: NodeRecord = {\n            id:\n              typeof crypto !== \"undefined\" &&\n              typeof crypto.randomUUID === \"function\"\n                ? crypto.randomUUID()\n                : `n-${Math.random().toString(36).slice(2, 10)}`,\n            position: pastePoint,\n            data,\n          };\n          actions.appendNode(node);\n          setPasteOpen(false);\n        }}\n      />\n    </>\n  );\n}\n\n// ─── Pane (canvas) menu ───────────────────────────────────────────────\n\nfunction PaneMenu({\n  readOnly,\n  consumerItems,\n  onPaste,\n  onAddCustom,\n  onFitView,\n  onResetZoom,\n}: {\n  readOnly: boolean;\n  consumerItems?: MenuItem[];\n  onPaste: () => void;\n  onAddCustom: () => void;\n  onFitView: () => void;\n  onResetZoom: () => void;\n}) {\n  return (\n    <>\n      <ContextMenuLabel>Canvas</ContextMenuLabel>\n      {!readOnly && (\n        <>\n          <ContextMenuItem onSelect={onPaste}>\n            <FileJson aria-hidden />\n            Paste JSON…\n          </ContextMenuItem>\n          <ContextMenuItem onSelect={onAddCustom}>\n            <CopyPlus aria-hidden />\n            Add custom node\n          </ContextMenuItem>\n          <ContextMenuSeparator />\n        </>\n      )}\n      <ContextMenuItem onSelect={onFitView}>\n        <Maximize2 aria-hidden />\n        Fit view\n      </ContextMenuItem>\n      <ContextMenuItem onSelect={onResetZoom}>\n        <RotateCcw aria-hidden />\n        Reset zoom\n      </ContextMenuItem>\n      {consumerItems && consumerItems.length > 0 && (\n        <>\n          <ContextMenuSeparator />\n          <ConsumerItems items={consumerItems} />\n        </>\n      )}\n    </>\n  );\n}\n\n// ─── Node menu ────────────────────────────────────────────────────────\n\nfunction NodeMenu({\n  node,\n  renderers,\n  readOnly,\n  consumerItems,\n  onCopyJson,\n  onConvert,\n  onDuplicate,\n  onDelete,\n  onExtract,\n}: {\n  node: NodeRecord;\n  renderers: ReturnType<typeof useFlowCanvasContext>[\"renderers\"];\n  readOnly: boolean;\n  consumerItems?: (n: NodeRecord) => MenuItem[];\n  onCopyJson: (n: NodeRecord) => void;\n  onConvert: (n: NodeRecord) => void;\n  onDuplicate: (id: string) => void;\n  onDelete: (id: string) => void;\n  onExtract: (path: string) => void;\n}) {\n  const renderer = findRenderer(renderers, node.data.__type);\n  const extractablePaths = renderer?.extractablePaths?.(node.data) ?? [];\n  const consumerExtras = consumerItems ? consumerItems(node) : [];\n\n  return (\n    <>\n      <ContextMenuLabel className=\"truncate\">\n        {renderer?.label ?? node.data.__type}\n      </ContextMenuLabel>\n      <ContextMenuItem onSelect={() => onCopyJson(node)}>\n        <Copy aria-hidden />\n        Copy as JSON\n      </ContextMenuItem>\n      {!readOnly && (\n        <>\n          <ContextMenuItem onSelect={() => onDuplicate(node.id)}>\n            <CopyPlus aria-hidden />\n            Duplicate\n          </ContextMenuItem>\n          {node.data.__type !== \"custom-json\" && (\n            <ContextMenuItem onSelect={() => onConvert(node)}>\n              <FileJson aria-hidden />\n              Convert to custom-JSON\n            </ContextMenuItem>\n          )}\n          {extractablePaths.length > 0 && (\n            <>\n              <ContextMenuSeparator />\n              <ContextMenuLabel className=\"text-[10px]\">\n                Extract sub-object\n              </ContextMenuLabel>\n              {extractablePaths.map((path) => (\n                <ContextMenuItem key={path} onSelect={() => onExtract(path)}>\n                  <Scissors aria-hidden />\n                  {path}\n                </ContextMenuItem>\n              ))}\n            </>\n          )}\n          <ContextMenuSeparator />\n          <ContextMenuItem\n            variant=\"destructive\"\n            onSelect={() => onDelete(node.id)}\n          >\n            <Trash2 aria-hidden />\n            Delete\n          </ContextMenuItem>\n        </>\n      )}\n      {consumerExtras.length > 0 && (\n        <>\n          <ContextMenuSeparator />\n          <ConsumerItems items={consumerExtras} />\n        </>\n      )}\n    </>\n  );\n}\n\n// ─── Edge menu ────────────────────────────────────────────────────────\n\nfunction EdgeMenu({\n  edge,\n  readOnly,\n  consumerItems,\n  onDelete,\n}: {\n  edge: EdgeRecord;\n  readOnly: boolean;\n  consumerItems?: (e: EdgeRecord) => MenuItem[];\n  onDelete: (id: string) => void;\n}) {\n  const consumerExtras = consumerItems ? consumerItems(edge) : [];\n  return (\n    <>\n      <ContextMenuLabel className=\"truncate\">Edge</ContextMenuLabel>\n      {!readOnly && (\n        <ContextMenuItem\n          variant=\"destructive\"\n          onSelect={() => onDelete(edge.id)}\n        >\n          <Trash2 aria-hidden />\n          Delete\n        </ContextMenuItem>\n      )}\n      {consumerExtras.length > 0 && (\n        <>\n          <ContextMenuSeparator />\n          <ConsumerItems items={consumerExtras} />\n        </>\n      )}\n    </>\n  );\n}\n\n// ─── Consumer-supplied items ──────────────────────────────────────────\n\nfunction ConsumerItems({ items }: { items: MenuItem[] }) {\n  return (\n    <>\n      {items.map((item) =>\n        item.separatorBefore ? (\n          <ContextMenuSeparator key={`sep-${item.id}`} />\n        ) : (\n          <ContextMenuItem\n            key={item.id}\n            disabled={item.disabled}\n            onSelect={item.onSelect}\n          >\n            {item.icon}\n            {item.label}\n          </ContextMenuItem>\n        ),\n      )}\n    </>\n  );\n}\n\n// ─── Helpers ──────────────────────────────────────────────────────────\n\nfunction extractFromNode(\n  parent: NodeRecord,\n  path: string,\n  extractSubObject: Actions[\"extractSubObject\"],\n) {\n  // Walk parent.data at `path` to grab the sub-data, then dispatch as a\n  // copy-extract (no Alt-modifier through the menu — keyboard-first\n  // accessibility per Q10 / plan §3.8).\n  const sub = walkPathRead(parent.data, path);\n  if (!sub) return;\n\n  const subData =\n    typeof (sub as Record<string, unknown>).__type === \"string\"\n      ? (sub as NodeData)\n      : { __type: \"custom-json\" as const, ...(sub as Record<string, unknown>) };\n\n  const newNode: NodeRecord = {\n    id:\n      typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\"\n        ? crypto.randomUUID()\n        : `n-${Math.random().toString(36).slice(2, 10)}`,\n    position: { x: parent.position.x + 320, y: parent.position.y + 40 },\n    data: subData,\n  };\n  extractSubObject({\n    parentId: parent.id,\n    path,\n    gesture: \"copy\",\n    newNode,\n  });\n}\n\nfunction walkPathRead(data: unknown, path: string): unknown {\n  const re = /([^.[\\]]+)|\\[(\\d+)\\]/g;\n  let cursor: unknown = data;\n  let m: RegExpExecArray | null;\n  while ((m = re.exec(path)) !== null) {\n    if (cursor === null || typeof cursor !== \"object\") return undefined;\n    if (m[1] !== undefined) {\n      cursor = (cursor as Record<string, unknown>)[m[1]];\n    } else if (m[2] !== undefined) {\n      if (!Array.isArray(cursor)) return undefined;\n      cursor = cursor[Number(m[2])];\n    }\n  }\n  return cursor;\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/parts/canvas-context-menu.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/parts/canvas.tsx",
      "content": "\"use client\";\n\n// Side-effect import — selection-ring CSS keyed on xyflow's `.react-flow__node.selected`\n// wrapper class. Sealed-folder file; auto-bundled by the host build (Turbopack /\n// shadcn-registry). v0.2.0 Tier 2 plan §5.3.\nimport \"../flow-canvas.css\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  ConnectionMode,\n  Controls,\n  ReactFlow,\n  type Connection,\n  type Edge as XyEdge,\n  type IsValidConnection,\n  type Node as XyNode,\n} from \"@xyflow/react\";\nimport { cn } from \"@/lib/utils\";\nimport { useCanvasData } from \"../hooks/use-canvas-data\";\nimport { useDropPipeline } from \"../hooks/use-drop-pipeline\";\nimport { useExportHandle } from \"../hooks/use-export\";\nimport { countEdgesAtPort, findPortInTree } from \"../lib/port-walker\";\nimport { useFlowCanvasContext } from \"../registries/canvas-context\";\nimport type {\n  FlowCanvasBackgroundConfig,\n  FlowCanvasProps,\n  NodeData,\n} from \"../types\";\nimport { FlowCanvasBackground } from \"./background\";\nimport { CanvasContextMenu } from \"./canvas-context-menu\";\nimport { DefaultEdge } from \"./default-edge\";\nimport { NodeAdapter } from \"./node-adapter\";\n\n// Module-scope nodeTypes / edgeTypes — required (see xyflow-react-pro skill).\n// Recreating these in render would tear down + remount every node.\nconst NODE_TYPES = { \"ilinxa-node\": NodeAdapter };\nconst EDGE_TYPES = { \"ilinxa-edge\": DefaultEdge };\n\ntype CanvasInnerProps = Pick<\n  FlowCanvasProps,\n  | \"data\"\n  | \"defaultData\"\n  | \"onChange\"\n  | \"onBeforeDrop\"\n  | \"onBeforeConnect\"\n  | \"onNodeCreate\"\n  | \"onNodeUpdate\"\n  | \"onNodeDelete\"\n  | \"onEdgeCreate\"\n  | \"onEdgeDelete\"\n  | \"onSubObjectExtract\"\n  | \"menuItems\"\n  | \"panOnDrag\"\n  | \"zoomOnScroll\"\n  | \"selectionMode\"\n  | \"readOnly\"\n  | \"onlyRenderVisibleElements\"\n  | \"exportRef\"\n  | \"background\"\n  | \"className\"\n> & {\n  ariaLabel?: string;\n  backgroundConfig: FlowCanvasBackgroundConfig | undefined;\n};\n\nexport function Canvas({\n  data,\n  defaultData,\n  onChange,\n  onBeforeDrop,\n  onBeforeConnect,\n  onNodeCreate,\n  onNodeUpdate,\n  onNodeDelete,\n  onEdgeCreate,\n  onEdgeDelete,\n  onSubObjectExtract,\n  menuItems,\n  panOnDrag = true,\n  zoomOnScroll = true,\n  selectionMode = \"multi\",\n  readOnly = false,\n  onlyRenderVisibleElements = true,\n  exportRef,\n  ariaLabel,\n  backgroundConfig,\n  className,\n}: CanvasInnerProps) {\n  const canvas = useCanvasData({\n    data,\n    defaultData,\n    onChange,\n    onBeforeConnect,\n    onNodeCreate,\n    onNodeUpdate,\n    onNodeDelete,\n    onEdgeCreate,\n    onEdgeDelete,\n    onSubObjectExtract,\n  });\n  const { renderers } = useFlowCanvasContext();\n  const drop = useDropPipeline({\n    renderers,\n    appendNode: canvas.appendNode,\n    extractSubObject: canvas.extractSubObject,\n    onBeforeDrop,\n    // onNodeCreate deliberately NOT passed (v0.2.6, review 5.3):\n    // canvas.appendNode fires it internally; passing it here double-fired.\n  });\n\n  useExportHandle(exportRef, canvas.snapshot);\n\n  const interactive = !readOnly;\n  const multiSelectionKeyCode = useMemo(\n    () => (selectionMode === \"multi\" ? \"Shift\" : null),\n    [selectionMode],\n  );\n\n  // Honor an explicit initial viewport from CanvasData. When the consumer\n  // supplies one (e.g. the stress fixture starts at zoom 0.9), we hand it\n  // to ReactFlow as `defaultViewport` and disable `fitView` — otherwise\n  // fitView wins and every node ends up on-screen, defeating\n  // `onlyRenderVisibleElements` culling. Snapshot at mount with\n  // useState(initializer) so subsequent pans/zooms don't reset the viewport.\n  const [initialViewport] = useState(\n    () => data?.viewport ?? defaultData?.viewport,\n  );\n\n  // Refs mirror latest xyNodes/xyEdges so isValidConnection — a stable\n  // <ReactFlow> prop — can read fresh state without rebuilding on every\n  // drag tick. Without this, at 200+ nodes ReactFlow reconciles every\n  // tick (see xyflow-react-pro skill \"Performance\" + the v0.1.2 perf patch).\n  const nodesRef = useRef(canvas.xyNodes);\n  const edgesRef = useRef(canvas.xyEdges);\n  useEffect(() => {\n    nodesRef.current = canvas.xyNodes;\n  }, [canvas.xyNodes]);\n  useEffect(() => {\n    edgesRef.current = canvas.xyEdges;\n  }, [canvas.xyEdges]);\n\n  // M3 typed-connection validator. Same `type` on both ends + opposite dirs\n  // (out → in). Enforces `multi: false` by counting existing edges at the\n  // candidate port. Per-handle override hooks land in plan §3.4 layer 3.\n  const isValidConnection = useCallback<IsValidConnection<XyEdge>>(\n    (c: Connection | XyEdge) => {\n      const source = c.source;\n      const target = c.target;\n      const sourceHandle = c.sourceHandle ?? \"\";\n      const targetHandle = c.targetHandle ?? \"\";\n      if (!source || !target || !sourceHandle || !targetHandle) return false;\n\n      const nodes = nodesRef.current;\n      const edges = edgesRef.current;\n      const srcNode = nodes.find((n) => n.id === source);\n      const tgtNode = nodes.find((n) => n.id === target);\n      if (!srcNode || !tgtNode) return false;\n\n      const srcFound = findPortInTree(srcNode.data as NodeData, sourceHandle);\n      const tgtFound = findPortInTree(tgtNode.data as NodeData, targetHandle);\n      if (!srcFound || !tgtFound) return false;\n\n      // Direction: source handle must be `out`, target handle must be `in`.\n      if (srcFound.port.dir !== \"out\" || tgtFound.port.dir !== \"in\") return false;\n\n      // Type match.\n      if (srcFound.port.type !== tgtFound.port.type) return false;\n\n      // multi: false enforcement.\n      if (\n        !srcFound.port.multi &&\n        countEdgesAtPort(edges, source, sourceHandle, \"source\") > 0\n      ) {\n        return false;\n      }\n      if (\n        !tgtFound.port.multi &&\n        countEdgesAtPort(edges, target, targetHandle, \"target\") > 0\n      ) {\n        return false;\n      }\n      return true;\n    },\n    [],\n  );\n\n  // Actions wrapper for the context menu. Stable object reference — its\n  // members are already stable from useCanvasData's ref-based callbacks.\n  const menuActions = useMemo(\n    () => ({\n      duplicateNode: canvas.duplicateNode,\n      deleteNode: canvas.deleteNode,\n      deleteEdge: canvas.deleteEdge,\n      updateNodeData: canvas.updateNodeData,\n      extractSubObject: canvas.extractSubObject,\n      appendNode: canvas.appendNode,\n    }),\n    [\n      canvas.duplicateNode,\n      canvas.deleteNode,\n      canvas.deleteEdge,\n      canvas.updateNodeData,\n      canvas.extractSubObject,\n      canvas.appendNode,\n    ],\n  );\n\n  return (\n    <CanvasContextMenu\n      readOnly={readOnly}\n      menuItems={menuItems}\n      getNodeById={canvas.getNodeById}\n      getEdgeById={canvas.getEdgeById}\n      actions={menuActions}\n    >\n      <div\n        role=\"region\"\n        aria-label={ariaLabel ?? \"Flow canvas\"}\n        data-flow-canvas-root=\"\"\n        tabIndex={0}\n        className={cn(\n          \"relative h-full w-full overflow-hidden rounded-lg border border-border bg-background\",\n          \"outline-none focus-visible:ring-2 focus-visible:ring-ring/50\",\n          className,\n        )}\n        onDragOver={interactive ? drop.onDragOver : undefined}\n        onDrop={interactive ? drop.onDrop : undefined}\n        onPaste={interactive ? drop.onPaste : undefined}\n      >\n        <FlowCanvasBackground config={backgroundConfig} />\n        <ReactFlow<XyNode<NodeData>, XyEdge>\n          nodes={canvas.xyNodes}\n          edges={canvas.xyEdges}\n          onNodesChange={canvas.onNodesChange}\n          onEdgesChange={canvas.onEdgesChange}\n          onConnect={canvas.onConnect}\n          onNodeDragStart={canvas.onNodeDragStart}\n          onNodeDragStop={canvas.onNodeDragStop}\n          onMoveEnd={canvas.onMoveEnd}\n          isValidConnection={isValidConnection}\n          connectionMode={ConnectionMode.Strict}\n          nodeTypes={NODE_TYPES}\n          edgeTypes={EDGE_TYPES}\n          panOnDrag={panOnDrag}\n          zoomOnScroll={zoomOnScroll}\n          nodesDraggable={interactive}\n          nodesConnectable={interactive}\n          elementsSelectable={interactive}\n          deleteKeyCode={interactive ? [\"Backspace\", \"Delete\"] : null}\n          multiSelectionKeyCode={multiSelectionKeyCode}\n          onlyRenderVisibleElements={onlyRenderVisibleElements}\n          proOptions={{ hideAttribution: true }}\n          defaultViewport={initialViewport}\n          fitView={!initialViewport}\n        >\n          <Controls position=\"bottom-left\" />\n        </ReactFlow>\n      </div>\n    </CanvasContextMenu>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/parts/canvas.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/parts/custom-json-node.tsx",
      "content": "\"use client\";\n\nimport { memo, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { NodeData, NodeRenderer } from \"../types\";\nimport { PortsAt } from \"./ports-at\";\n\ntype CustomJsonData = NodeData & { _label?: string };\n\nfunction deriveLabel(data: CustomJsonData): string {\n  if (typeof data._label === \"string\" && data._label.length > 0) {\n    return data._label;\n  }\n  if (data.__type === \"custom-json\") return \"Custom JSON\";\n  return data.__type;\n}\n\nfunction CustomJsonNodeImpl({ data }: { data: CustomJsonData }) {\n  const [expanded, setExpanded] = useState(false);\n  const label = deriveLabel(data);\n\n  // Collapsed mode is the hot path — at 200 nodes, the prior implementation\n  // ran JSON.stringify on every node + rendered a CSS-truncated <pre> + the\n  // whole card carried a box-shadow. Skip all three when collapsed: render\n  // the label only, no <pre>, no shadow. Expanded mode keeps the full\n  // stringified payload (only one node is expanded at a time in practice).\n  return (\n    <div\n      className={cn(\n        \"relative min-w-45 max-w-90 rounded-md border border-border bg-card text-card-foreground\",\n        expanded && \"shadow-sm\",\n      )}\n    >\n      <button\n        type=\"button\"\n        onClick={() => setExpanded((v) => !v)}\n        className={cn(\n          \"flex w-full items-center justify-between gap-2 px-3 py-2\",\n          \"text-left text-xs font-medium\",\n          expanded && \"border-b border-border\",\n        )}\n      >\n        <span className=\"truncate font-mono text-muted-foreground\">\n          {label}\n        </span>\n        <span aria-hidden className=\"text-muted-foreground\">\n          {expanded ? \"−\" : \"+\"}\n        </span>\n      </button>\n      {expanded && (\n        <pre className=\"max-h-80 overflow-auto whitespace-pre p-3 font-mono text-[11px] leading-snug text-muted-foreground\">\n          <code>{JSON.stringify(data, null, 2)}</code>\n        </pre>\n      )}\n      {/* Render port handles for any ports declared in data.ports. Without\n       * these, xyflow's edge layer fails getEdgePosition() for every edge\n       * referencing this node and logs hundreds of warnings per frame.\n       * Discovered via 2026-05-14 baseline measurement — the cliff at\n       * N≥100 in vis-off was warning-spam CPU cost, not React reconciliation\n       * cost. v0.1.4 patch. */}\n      <PortsAt ports={data.ports} position=\"left\" />\n      <PortsAt ports={data.ports} position=\"right\" />\n      <PortsAt ports={data.ports} position=\"top\" />\n      <PortsAt ports={data.ports} position=\"bottom\" />\n    </div>\n  );\n}\n\nexport const CustomJsonNode = memo(CustomJsonNodeImpl);\n\nexport const customJsonRenderer: NodeRenderer<CustomJsonData> = {\n  type: \"custom-json\",\n  label: \"Custom JSON\",\n  render: (data) => <CustomJsonNode data={data} />,\n};\n",
      "type": "registry:component",
      "target": "components/flow-canvas/parts/custom-json-node.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/parts/default-edge.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport {\n  BaseEdge,\n  getSmoothStepPath,\n  useStore,\n  type EdgeProps,\n} from \"@xyflow/react\";\nimport { findPortInTree } from \"../lib/port-walker\";\nimport { usePortType } from \"../registries/port-type-registry\";\nimport type { EdgeRenderer, NodeData, Port } from \"../types\";\n\n// M3 polish: pull the source port's type color and use it as the edge\n// stroke. Each connection gets the visual identity of its data type — text\n// edges = blue, image = emerald, card = lime — making the graph readable at\n// a glance without requiring a legend.\n\n// Narrow comparator for the source-port useStore selector. The returned\n// shape is `Port | undefined`; only three fields (`id`, `type`, `multi`)\n// affect this renderer — `type` drives the stroke color via portType\n// lookup; `id` and `multi` are kept stable for identity guards. Without\n// this, xyflow's store updates the nodeLookup Map reference per change\n// and the default identity equality re-runs the selector + re-renders the\n// edge for every node change anywhere in the canvas. v0.2.0 Tier 2 plan F-01.\nconst portEqual = (a?: Port, b?: Port) =>\n  a === b || (a?.id === b?.id && a?.type === b?.type && a?.multi === b?.multi);\n\nfunction DefaultEdgeImpl(props: EdgeProps) {\n  const {\n    id,\n    source,\n    sourceHandleId,\n    sourceX,\n    sourceY,\n    targetX,\n    targetY,\n    sourcePosition,\n    targetPosition,\n    markerStart,\n    markerEnd,\n    style,\n    selected,\n  } = props;\n\n  // Reactive lookup of the source port via xyflow's store. Recomputes if the\n  // source node's data tree changes. `portEqual` narrows re-render triggers\n  // to the three fields this renderer actually reads (see comparator above).\n  const sourcePort: Port | undefined = useStore(\n    (s) => {\n      if (!source || !sourceHandleId) return undefined;\n      const node = s.nodeLookup.get(source);\n      if (!node) return undefined;\n      return findPortInTree(node.data as NodeData, sourceHandleId)?.port;\n    },\n    portEqual,\n  );\n\n  const portType = usePortType(sourcePort?.type ?? \"\");\n  const color = portType?.color;\n\n  const [path] = getSmoothStepPath({\n    sourceX,\n    sourceY,\n    targetX,\n    targetY,\n    sourcePosition,\n    targetPosition,\n    borderRadius: 8,\n  });\n\n  return (\n    <BaseEdge\n      id={id}\n      path={path}\n      markerStart={markerStart}\n      markerEnd={markerEnd}\n      style={{\n        stroke: selected ? \"var(--ring)\" : (color ?? \"var(--xy-edge-stroke)\"),\n        strokeWidth: selected ? 2.5 : 1.5,\n        ...style,\n      }}\n    />\n  );\n}\n\nexport const DefaultEdge = memo(DefaultEdgeImpl);\n\nexport const defaultEdgeRenderer: EdgeRenderer = {\n  type: \"smoothstep\",\n  label: \"Smooth step\",\n  // For consumer-facing dispatch (M3 polish has wired this internally — the\n  // single 'ilinxa-edge' xyflow type calls DefaultEdge directly). Custom\n  // edge renderers via the edge-type registry are exposed in v0.2.\n  render: () => null,\n};\n",
      "type": "registry:component",
      "target": "components/flow-canvas/parts/default-edge.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/parts/node-adapter.tsx",
      "content": "\"use client\";\n\nimport { memo, useCallback, useMemo } from \"react\";\nimport type { NodeProps } from \"@xyflow/react\";\nimport { useFlowCanvasContext } from \"../registries/canvas-context\";\nimport { findRenderer } from \"../registries/renderer-registry\";\nimport type { NodeData, NodeRenderer, RenderContext } from \"../types\";\nimport { customJsonRenderer } from \"./custom-json-node\";\nimport { NodeShell } from \"./node-shell\";\n\n// Build a self-referential renderChild via function declaration. Function\n// declarations are hoisted within their own body so `rc` can reference itself\n// without React Compiler's temporal-dead-zone warning.\nfunction makeRenderChild(\n  renderers: NodeRenderer[],\n  nodeId: string,\n  readOnly: boolean,\n): RenderContext[\"renderChild\"] {\n  function rc(childData: NodeData) {\n    const childRenderer =\n      findRenderer(renderers, childData.__type) ?? customJsonRenderer;\n    const childCtx: RenderContext = {\n      nodeId,\n      isSelected: false,\n      isDragging: false,\n      isReadOnly: readOnly,\n      renderChild: rc,\n    };\n    return childRenderer.render(childData, childCtx);\n  }\n  return rc;\n}\n\nfunction NodeAdapterImpl(props: NodeProps) {\n  const { id, data, selected, dragging, draggable } = props;\n  const { renderers, readOnly, onEditRequest } = useFlowCanvasContext();\n\n  const nodeData = data as NodeData;\n  const renderer =\n    findRenderer(renderers, nodeData.__type) ?? customJsonRenderer;\n\n  const renderChild = useMemo(\n    () => makeRenderChild(renderers, id, readOnly),\n    [renderers, id, readOnly],\n  );\n\n  // v0.2.1 — bind canvas-level onEditRequest to this node's id so renderers\n  // fire `ctx.onEditRequest(subPath?)` without knowing their own nodeId.\n  // `onEditRequest` from context is itself stable (ref-mirrored in\n  // flow-canvas.tsx, F-V5 lock); identity flips only on wired/unwired.\n  const handleEditRequest = useCallback(\n    (subPath?: string) => onEditRequest?.(id, subPath),\n    [onEditRequest, id],\n  );\n\n  const ctx: RenderContext = {\n    nodeId: id,\n    isSelected: !!selected,\n    isDragging: !!dragging,\n    isReadOnly: readOnly,\n    renderChild,\n    // Leave undefined when consumer hasn't wired onEditRequest so renderers\n    // can gate their click affordances via `if (ctx.onEditRequest)`.\n    onEditRequest: onEditRequest ? handleEditRequest : undefined,\n  };\n\n  return (\n    <NodeShell isLocked={draggable === false}>\n      {renderer.render(nodeData, ctx)}\n    </NodeShell>\n  );\n}\n\nexport const NodeAdapter = memo(NodeAdapterImpl);\n",
      "type": "registry:component",
      "target": "components/flow-canvas/parts/node-adapter.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/parts/node-shell.tsx",
      "content": "\"use client\";\n\nimport { Lock } from \"lucide-react\";\nimport { memo, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { NodeRecord } from \"../types\";\n\n// Wraps every consumer renderer's output. Owns:\n// - Lock chip (when node.locked is true)\n// - Focus-visible outline for keyboard navigation\n//\n// Consumer renderers focus on content + handles; the shell handles chrome.\n//\n// v0.2.0 Tier 2 (plan §5.3 + F-02): selection ring decoupled from this\n// component. It now lives in the sealed-folder `flow-canvas.css` keyed\n// on xyflow's own `.react-flow__node.selected` class — pure CSS, no React\n// prop / re-render coupling for the visual. RenderContext.isSelected is\n// still populated for renderers that opt into selection-conditional content.\nfunction NodeShellImpl({\n  isLocked,\n  className,\n  children,\n}: {\n  isLocked: boolean;\n  className?: string;\n  children: ReactNode;\n}) {\n  return (\n    <div\n      data-locked={isLocked}\n      tabIndex={0}\n      className={cn(\n        \"relative outline-none\",\n        // Focus-visible ring stays here — xyflow doesn't own focus state.\n        // Tailwind v4 canonical `*:` shorthand (replaces v3's `[&>*]:`).\n        \"focus-visible:*:ring-2 focus-visible:*:ring-ring/60\",\n        className,\n      )}\n    >\n      {children}\n      {isLocked ? (\n        <span\n          aria-label=\"Locked\"\n          title=\"Locked\"\n          className={cn(\n            \"absolute -right-1 -top-1 z-10 grid h-5 w-5 place-items-center\",\n            \"rounded-full border border-border bg-card text-muted-foreground shadow-sm\",\n          )}\n        >\n          <Lock aria-hidden className=\"h-3 w-3\" />\n        </span>\n      ) : null}\n    </div>\n  );\n}\n\nexport const NodeShell = memo(NodeShellImpl);\n\n// Helper: did the xyflow node arrive with locked: true?\n// (NodeRecord.locked maps to xyflow draggable: false on the way out; the\n// adapter passes the flag explicitly.)\nexport type NodeShellOf = (n: NodeRecord) => { isLocked: boolean };\n",
      "type": "registry:component",
      "target": "components/flow-canvas/parts/node-shell.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/parts/paste-dialog.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { coerceToNodeData } from \"../lib/coerce-to-node-data\";\nimport { parseJsonSafe } from \"../lib/parse-json\";\nimport type { NodeData } from \"../types\";\n\nexport function PasteDialog({\n  open,\n  onOpenChange,\n  onSubmit,\n}: {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  onSubmit: (data: NodeData) => void;\n}) {\n  const [text, setText] = useState(\"\");\n  const [error, setError] = useState<string | null>(null);\n\n  const handleSubmit = () => {\n    setError(null);\n    const parsed = parseJsonSafe(text);\n    if (parsed === undefined) {\n      setError(\"Could not parse — make sure the input is valid JSON.\");\n      return;\n    }\n    const data = coerceToNodeData(parsed);\n    if (!data) {\n      setError(\"JSON must be an object (arrays and primitives aren't nodes).\");\n      return;\n    }\n    onSubmit(data);\n    setText(\"\");\n  };\n\n  return (\n    <Dialog open={open} onOpenChange={onOpenChange}>\n      <DialogContent>\n        <DialogHeader>\n          <DialogTitle>Paste JSON</DialogTitle>\n          <DialogDescription>\n            Paste a JSON object to spawn it as a node. Unknown shapes render as\n            a custom-JSON node automatically.\n          </DialogDescription>\n        </DialogHeader>\n        <Textarea\n          value={text}\n          onChange={(e) => setText(e.target.value)}\n          placeholder={`{\"__type\":\"prompt\",\"template\":\"Translate to French\"}`}\n          className=\"h-40 font-mono text-xs\"\n          autoFocus\n        />\n        {error && (\n          <p className=\"text-xs text-destructive\" role=\"alert\">\n            {error}\n          </p>\n        )}\n        <DialogFooter>\n          <Button variant=\"ghost\" onClick={() => onOpenChange(false)}>\n            Cancel\n          </Button>\n          <Button onClick={handleSubmit} disabled={!text.trim()}>\n            Add node\n          </Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/parts/paste-dialog.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/parts/port-handle.tsx",
      "content": "\"use client\";\n\nimport { Handle, Position, type IsValidConnection } from \"@xyflow/react\";\nimport { memo, type CSSProperties } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { usePortType } from \"../registries/port-type-registry\";\nimport type { Port } from \"../types\";\n\nconst POSITION_MAP = {\n  left: Position.Left,\n  right: Position.Right,\n  top: Position.Top,\n  bottom: Position.Bottom,\n} as const;\n\n// Wraps xyflow's <Handle /> with our typed-port semantics:\n// - Color comes from the port-type registry (via usePortType).\n// - id is forced (xyflow needs unique handle ids per node when same type).\n// - type is derived from port.dir ('out' → 'source', 'in' → 'target').\n// - position from port.side via POSITION_MAP.\n//\n// In M2 the handle renders correctly. M3 wires `isValidConnection` on the\n// handle to the global typed-connection validator + multi-edge enforcement.\nfunction PortHandleImpl({\n  port,\n  isValidConnection,\n  className,\n  style,\n}: {\n  port: Port;\n  isValidConnection?: IsValidConnection;\n  className?: string;\n  style?: CSSProperties;\n}) {\n  const portType = usePortType(port.type);\n  const color = portType?.color ?? \"var(--muted-foreground)\";\n\n  return (\n    <Handle\n      id={port.id}\n      type={port.dir === \"out\" ? \"source\" : \"target\"}\n      position={POSITION_MAP[port.side]}\n      isValidConnection={isValidConnection}\n      aria-label={`${port.label ?? port.id} (${port.dir} ${port.type})`}\n      className={cn(\"h-2.5! w-2.5! border-2!\", className)}\n      style={{\n        background: color,\n        borderColor: \"var(--background)\",\n        ...style,\n      }}\n    />\n  );\n}\n\nexport const PortHandle = memo(PortHandleImpl);\n",
      "type": "registry:component",
      "target": "components/flow-canvas/parts/port-handle.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/parts/ports-at.tsx",
      "content": "\"use client\";\n\nimport { memo, type CSSProperties } from \"react\";\nimport type { Port, PortSide, PortsAtProps } from \"../types\";\nimport { PortHandle } from \"./port-handle\";\n\n// Layout helper: stacks handles along an edge of the renderer's bounding\n// box. Handles are direct DOM children of the node (xyflow's <Handle> is\n// `position: absolute` and uses its own `position` prop to anchor; wrapping\n// it in another absolute div would break that). We stack by computing\n// `top` (for left/right edges) or `left` (for top/bottom edges) inline.\n//\n// For the easy case — a node has 1–N ports on each edge, evenly stacked.\n// For complex layouts (per-sub-object positioning), emit <PortHandle /> in\n// the renderer directly with your own style overrides.\n\nfunction isVertical(side: PortSide): boolean {\n  return side === \"left\" || side === \"right\";\n}\n\nfunction offsetFor(\n  position: PortSide,\n  index: number,\n  count: number,\n  spacing: number,\n): CSSProperties {\n  // Centered stack: middle item at 50%, others spaced by `spacing` px.\n  const centered = (index - (count - 1) / 2) * spacing;\n  if (isVertical(position)) {\n    return { top: `calc(50% + ${centered}px)` };\n  }\n  return { left: `calc(50% + ${centered}px)` };\n}\n\nfunction PortsAtImpl({ ports, position, spacing = 24 }: PortsAtProps) {\n  if (!ports || ports.length === 0) return null;\n  const filtered = ports.filter((p: Port) => p.side === position);\n  if (filtered.length === 0) return null;\n\n  return (\n    <>\n      {filtered.map((p, i) => (\n        <PortHandle\n          key={p.id}\n          port={p}\n          style={offsetFor(position, i, filtered.length, spacing)}\n        />\n      ))}\n    </>\n  );\n}\n\nexport const PortsAt = memo(PortsAtImpl);\n",
      "type": "registry:component",
      "target": "components/flow-canvas/parts/ports-at.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/registries/canvas-context.tsx",
      "content": "\"use client\";\n\nimport { createContext, useContext, type ReactNode } from \"react\";\nimport type {\n  EdgeRenderer,\n  NodeRecord,\n  NodeRenderer,\n  PortType,\n} from \"../types\";\n\nexport type FlowCanvasContextValue = {\n  renderers: NodeRenderer[];\n  portTypes: PortType[];\n  edgeTypes: EdgeRenderer[];\n  readOnly: boolean;\n  selectionMode: \"single\" | \"multi\";\n  onNodeUpdate?: (node: NodeRecord) => void;\n  // v0.2.1 — bubbled up from a renderer's ctx.onEditRequest call.\n  // Identity is stabilized inside flow-canvas.tsx via ref-mirror so consumers\n  // passing inline `onEditRequest={...}` don't cascade re-renders across every\n  // NodeAdapter. Identity only flips on the wired/unwired transition.\n  onEditRequest?: (nodeId: string, subPath?: string) => void;\n};\n\nexport const FlowCanvasContext = createContext<FlowCanvasContextValue | null>(\n  null,\n);\n\nexport function useFlowCanvasContext(): FlowCanvasContextValue {\n  const ctx = useContext(FlowCanvasContext);\n  if (!ctx) {\n    throw new Error(\n      \"useFlowCanvasContext must be used inside <FlowCanvas /> — wrap your tree in the FlowCanvas component before calling registry hooks.\",\n    );\n  }\n  return ctx;\n}\n\nexport function FlowCanvasContextProvider({\n  value,\n  children,\n}: {\n  value: FlowCanvasContextValue;\n  children: ReactNode;\n}) {\n  return (\n    <FlowCanvasContext.Provider value={value}>\n      {children}\n    </FlowCanvasContext.Provider>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/registries/canvas-context.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/registries/edge-type-registry.tsx",
      "content": "\"use client\";\n\nimport type { EdgeRenderer } from \"../types\";\nimport { defaultEdgeRenderer } from \"../parts/default-edge\";\nimport { useFlowCanvasContext } from \"./canvas-context\";\n\nexport { defaultEdgeRenderer };\n\nexport function findEdgeType(\n  edgeTypes: EdgeRenderer[],\n  type: string,\n): EdgeRenderer | undefined {\n  return edgeTypes.find((t) => t.type === type);\n}\n\nexport function useEdgeType(type: string): EdgeRenderer | undefined {\n  const { edgeTypes } = useFlowCanvasContext();\n  return findEdgeType(edgeTypes, type);\n}\n\nexport function mergeEdgeTypes(\n  consumer: EdgeRenderer[] | undefined,\n): EdgeRenderer[] {\n  const merged: EdgeRenderer[] = [defaultEdgeRenderer];\n  const seen = new Set<string>([defaultEdgeRenderer.type]);\n  for (const t of consumer ?? []) {\n    if (seen.has(t.type) && process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        `[flow-canvas] edge type collision for \"${t.type}\" — last-wins`,\n      );\n    }\n    const idx = merged.findIndex((m) => m.type === t.type);\n    if (idx >= 0) merged[idx] = t;\n    else merged.push(t);\n    seen.add(t.type);\n  }\n  return merged;\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/registries/edge-type-registry.tsx"
    },
    {
      "path": "src/registry/components/data/flow-canvas/registries/port-type-registry.ts",
      "content": "\"use client\";\n\nimport type { PortType } from \"../types\";\nimport { useFlowCanvasContext } from \"./canvas-context\";\n\n// Built-in port-type palette. Maps to design tokens, not raw colors.\n// — data:  neutral grey (untyped / generic)\n// — text:  blue   (--chart-5)\n// — image: emerald (--chart-2)\n// — card:  signal-lime (--primary, the accent)\n// — event: cyan  (--chart-4)\n// — doc:   teal  (--chart-3) — v0.2.5 add; bottom-only side enforced editor-side\n//                              by @ilinxa/card-tree-node's PortEditorStrip\n//                              (no runtime enforcement here)\n//\n// Color descriptions in the description doc said \"blue / orange / lime / rose\" —\n// the actual chart palette is lime → emerald → teal → cyan → blue, so we map to\n// the closest semantic neighbor. Consumers override via the `portTypes` prop.\nexport const defaultPortTypes: PortType[] = [\n  { id: \"data\", color: \"var(--muted-foreground)\", label: \"Data\" },\n  { id: \"text\", color: \"var(--chart-5)\", label: \"Text\" },\n  { id: \"image\", color: \"var(--chart-2)\", label: \"Image\" },\n  { id: \"card\", color: \"var(--primary)\", label: \"Card\" },\n  { id: \"event\", color: \"var(--chart-4)\", label: \"Event\" },\n  { id: \"doc\", color: \"var(--chart-3)\", label: \"Doc\" },\n];\n\nexport function findPortType(\n  portTypes: PortType[],\n  id: string,\n): PortType | undefined {\n  return portTypes.find((t) => t.id === id);\n}\n\nexport function usePortType(id: string): PortType | undefined {\n  const { portTypes } = useFlowCanvasContext();\n  return findPortType(portTypes, id);\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/registries/port-type-registry.ts"
    },
    {
      "path": "src/registry/components/data/flow-canvas/registries/renderer-registry.tsx",
      "content": "\"use client\";\n\nimport type { NodeRenderer } from \"../types\";\nimport { customJsonRenderer } from \"../parts/custom-json-node\";\nimport { useFlowCanvasContext } from \"./canvas-context\";\n\nexport { customJsonRenderer };\n\nexport function findRenderer(\n  renderers: NodeRenderer[],\n  type: string,\n): NodeRenderer | undefined {\n  return renderers.find((r) => r.type === type);\n}\n\nexport function useRenderer(type: string): NodeRenderer | undefined {\n  const { renderers } = useFlowCanvasContext();\n  return findRenderer(renderers, type);\n}\n\n// Merges built-in 'custom-json' renderer with consumer-supplied renderers.\n// Last-wins on collision (consumer can deliberately override). Dev-mode warns.\nexport function mergeRenderers(\n  consumer: NodeRenderer[] | undefined,\n): NodeRenderer[] {\n  const merged: NodeRenderer[] = [customJsonRenderer];\n  const seen = new Set<string>([customJsonRenderer.type]);\n  for (const r of consumer ?? []) {\n    if (seen.has(r.type) && process.env.NODE_ENV !== \"production\") {\n      console.warn(\n        `[flow-canvas] renderer collision for \"${r.type}\" — last-wins`,\n      );\n    }\n    const idx = merged.findIndex((m) => m.type === r.type);\n    if (idx >= 0) merged[idx] = r;\n    else merged.push(r);\n    seen.add(r.type);\n  }\n  return merged;\n}\n",
      "type": "registry:component",
      "target": "components/flow-canvas/registries/renderer-registry.tsx"
    }
  ],
  "categories": [
    "data",
    "flow",
    "canvas",
    "node-editor"
  ],
  "type": "registry:block"
}