# Flow Canvas

> Node-and-edge canvas with typed ports, pluggable node renderers, edge and port-type registries, and JSON save and load — built on React Flow.

A shadcn/ui block from the [@ilinxa](https://ui.ilinxa.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ilinxa/flow-canvas).

- Page, with a live preview: https://shadcn.exchange/r/ilinxa/flow-canvas
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ilinxa/flow-canvas-light-0ddf8549962e80fd.png
- Registry item: https://ui.ilinxa.com/r/flow-canvas.json
- Categories: data, flow, canvas, node-editor
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.ilinxa.com/r/flow-canvas.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@xyflow/react`, `lucide-react`
- Registry items: `button`, `context-menu`, `dialog`, `textarea`

## Files

### `src/registry/components/data/flow-canvas/flow-canvas.css`

```css
/* flow-canvas selection ring — driven by xyflow's wrapper class.
 *
 * Decouples the selection VISUAL from any React-side render of NodeShell /
 * NodeAdapter / the consumer renderer. React-side re-renders on selection
 * still happen (xyflow flips the `selected` prop on NodeAdapter, which
 * recreates children) — this rule's win is that the visual ring applies
 * as a pure browser-side CSS effect the instant xyflow toggles the
 * `.selected` class, decoupled from React reconciliation timing. v0.2.0
 * Tier 2 plan §5.3 + F-V1.
 *
 * DOM hierarchy when a node is selected:
 *   <div class="react-flow__node ... selected">    ← xyflow's wrapper
 *     <div class="relative outline-none">          ← NodeShell
 *       <renderer-output />                         ← ring target
 *       <span class="lock chip" />?                 ← also rings (matches v0.1.x)
 *     </div>
 *   </div>
 *
 * The `> * > *` selector targets NodeShell's direct children — the same
 * elements v0.1.x ringed via NodeShell's `data-[selected=true]:[&>*]:ring-2`.
 *
 * NO `border-radius: inherit` (plan F-V6): the renderer outputs already
 * carry their own border-radius (e.g. `rounded-md` on CustomJsonNode);
 * `box-shadow` automatically conforms to the element's own border-radius.
 * Setting `border-radius: inherit` here would pull NodeShell's (none) and
 * OVERRIDE the renderer's rounded corners. Don't.
 */
.react-flow__node.selected > * > * {
  box-shadow: 0 0 0 2px var(--ring);
}
```

### `src/registry/components/data/flow-canvas/flow-canvas.tsx`

```tsx
"use client";

import { useEffect, useMemo, useRef } from "react";
import { ReactFlowProvider } from "@xyflow/react";
import { Canvas } from "./parts/canvas";
import {
  FlowCanvasContextProvider,
  type FlowCanvasContextValue,
} from "./registries/canvas-context";
import { defaultPortTypes } from "./registries/port-type-registry";
import { mergeRenderers } from "./registries/renderer-registry";
import { mergeEdgeTypes } from "./registries/edge-type-registry";
import type { FlowCanvasProps, PortType } from "./types";

import "@xyflow/react/dist/style.css";

function mergePortTypes(consumer: PortType[] | undefined): PortType[] {
  const merged: PortType[] = [...defaultPortTypes];
  const seen = new Set<string>(merged.map((p) => p.id));
  for (const p of consumer ?? []) {
    if (seen.has(p.id) && process.env.NODE_ENV !== "production") {
      console.warn(
        `[flow-canvas] port type collision for "${p.id}" — last-wins`,
      );
    }
    const idx = merged.findIndex((m) => m.id === p.id);
    if (idx >= 0) merged[idx] = p;
    else merged.push(p);
    seen.add(p.id);
  }
  return merged;
}

export function FlowCanvas({
  renderers,
  portTypes,
  edgeTypes,
  readOnly = false,
  selectionMode = "multi",
  onNodeUpdate,
  onEditRequest,
  ...rest
}: FlowCanvasProps) {
  // v0.2.1 — ref-mirror consumer's onEditRequest so its identity changing
  // doesn't invalidate the canvas-context memo on every render. Matches the
  // existing defensive posture in use-canvas-data.ts (which ref-mirrors
  // onChange / onBeforeConnect / etc.). Plan §4.3.2 (F-V5 lock).
  const onEditRequestRef = useRef(onEditRequest);
  useEffect(() => {
    onEditRequestRef.current = onEditRequest;
  });

  // Stable wrapper — identity flips ONLY when consumer transitions between
  // wired and unwired states, not when they pass a fresh function identity
  // each render. Common consumer pattern: inline arrow on every render.
  const wiredOnEditRequest = onEditRequest !== undefined;
  const stableOnEditRequest = useMemo<
    FlowCanvasContextValue["onEditRequest"]
  >(
    () =>
      wiredOnEditRequest
        ? (nodeId, subPath) => onEditRequestRef.current?.(nodeId, subPath)
        : undefined,
    [wiredOnEditRequest],
  );

  const ctx = useMemo<FlowCanvasContextValue>(
    () => ({
      renderers: mergeRenderers(renderers),
      portTypes: mergePortTypes(portTypes),
      edgeTypes: mergeEdgeTypes(edgeTypes),
      readOnly,
      selectionMode,
      onNodeUpdate,
      onEditRequest: stableOnEditRequest,
    }),
    [
      renderers,
      portTypes,
      edgeTypes,
      readOnly,
      selectionMode,
      onNodeUpdate,
      stableOnEditRequest,
    ],
  );

  return (
    <ReactFlowProvider>
      <FlowCanvasContextProvider value={ctx}>
        <Canvas
          data={rest.data}
          defaultData={rest.defaultData}
          onChange={rest.onChange}
          onBeforeDrop={rest.onBeforeDrop}
          onBeforeConnect={rest.onBeforeConnect}
          onNodeCreate={rest.onNodeCreate}
          onNodeUpdate={onNodeUpdate}
          onNodeDelete={rest.onNodeDelete}
          onEdgeCreate={rest.onEdgeCreate}
          onEdgeDelete={rest.onEdgeDelete}
          onSubObjectExtract={rest.onSubObjectExtract}
          menuItems={rest.menuItems}
          panOnDrag={rest.panOnDrag}
          zoomOnScroll={rest.zoomOnScroll}
          selectionMode={selectionMode}
          readOnly={readOnly}
          onlyRenderVisibleElements={rest.onlyRenderVisibleElements}
          exportRef={rest.exportRef}
          backgroundConfig={rest.background}
          ariaLabel={rest["aria-label"]}
          className={rest.className}
        />
      </FlowCanvasContextProvider>
    </ReactFlowProvider>
  );
}
```

### `src/registry/components/data/flow-canvas/index.ts`

```ts
export { FlowCanvas } from "./flow-canvas";

export type {
  CanvasData,
  EdgeRecord,
  EdgeRenderContext,
  EdgeRenderer,
  FlowCanvasBackgroundConfig,
  FlowCanvasExportHandle,
  FlowCanvasProps,
  MenuItem,
  NodeData,
  NodeRecord,
  NodeRenderer,
  Port,
  PortDir,
  PortSide,
  PortType,
  PortsAtProps,
  RenderContext,
} from "./types";

export {
  customJsonRenderer,
  findRenderer,
  mergeRenderers,
  useRenderer,
} from "./registries/renderer-registry";
export {
  defaultPortTypes,
  findPortType,
  usePortType,
} from "./registries/port-type-registry";
export {
  defaultEdgeRenderer,
  findEdgeType,
  mergeEdgeTypes,
  useEdgeType,
} from "./registries/edge-type-registry";

export { PortsAt } from "./parts/ports-at";
export { PortHandle } from "./parts/port-handle";

export { findPortInTree, countEdgesAtPort } from "./lib/port-walker";
export type { FoundPort } from "./lib/port-walker";

export { emitSubObjectDrag } from "./lib/emit-sub-object-drag";

export { updateNodeData } from "./lib/update-node-data";
```

### `src/registry/components/data/flow-canvas/types.ts`

```ts
import type { ReactNode, Ref } from "react";

// ───── Port — embedded inside data at any tree depth ─────

export type PortDir = "in" | "out";
export type PortSide = "left" | "right" | "top" | "bottom";

export type Port = {
  id: string;
  side: PortSide;
  dir: PortDir;
  type: string;
  multi?: boolean;
  label?: string;
};

// ───── Node data — pure JSON, schema-discriminated, ports recursive ─────

export type NodeData = {
  __type: string;
  ports?: Port[];
  [key: string]: unknown;
};

export type NodeRecord = {
  id: string;
  position: { x: number; y: number };
  data: NodeData;
  width?: number;
  height?: number;
  selected?: boolean;
  locked?: boolean;
};

export type EdgeRecord = {
  id: string;
  source: `${string}:${string}`;
  target: `${string}:${string}`;
  type?: string;
  selected?: boolean;
};

export type CanvasData = {
  version: 1;
  nodes: NodeRecord[];
  edges: EdgeRecord[];
  viewport?: { x: number; y: number; zoom: number };
};

// ───── Registries ─────

export type RenderContext = {
  nodeId: string;
  isSelected: boolean;
  isDragging: boolean;
  isReadOnly: boolean;
  renderChild: (data: NodeData, opts?: { path?: string }) => ReactNode;

  /**
   * Fire from a renderer's click / keyboard-activate handler to request an
   * edit-dialog open for the current node. v0.2.1 addition.
   *
   * - Bound to the renderer's current `nodeId` — renderers call it with NO
   *   nodeId argument (the host already knows which node fired).
   * - Optional `subPath` argument lets renderers signal sub-targeting (e.g.
   *   a clicked subcard inside a card-tree viewer); the host forwards
   *   `subPath` to `FlowCanvasProps.onEditRequest` verbatim.
   * - `undefined` when the consumer hasn't wired `FlowCanvasProps.onEditRequest`.
   *
   * See the popup-edit renderer convention in the procomp guide §8.3 (v0.2.0
   * docs) + the `card-tree-node` procomp for the canonical implementation.
   */
  onEditRequest?: (subPath?: string) => void;
};

export type NodeRenderer<TData extends NodeData = NodeData> = {
  type: string;
  label: string;
  defaultPorts?: (data: TData) => Port[];
  defaultSubPorts?: (data: TData) => Record<string, Port[]>;
  render: (data: TData, ctx: RenderContext) => ReactNode;
  extractablePaths?: (data: TData) => string[];
};

export type PortType = {
  id: string;
  label?: string;
  color: string;
  icon?: ReactNode;
};

export type EdgeRenderContext = {
  edgeId: string;
  source: { node: NodeRecord; port: Port };
  target: { node: NodeRecord; port: Port };
  isSelected: boolean;
};

export type EdgeRenderer = {
  type: string;
  label?: string;
  render: (edge: EdgeRecord, ctx: EdgeRenderContext) => ReactNode;
};

// ───── Right-click menu ─────

export type MenuItem = {
  id: string;
  label: string;
  icon?: ReactNode;
  onSelect: () => void;
  disabled?: boolean;
  separatorBefore?: boolean;
};

// ───── The canvas component ─────

export type FlowCanvasBackgroundConfig = {
  light?: { from: string; to: string; angle?: number };
  dark?: { from: string; to: string; angle?: number };
  overlay?: "none" | "dots" | "grid" | "cross";
  overlayOpacity?: number;
};

export type FlowCanvasExportHandle = {
  export: (opts: { withPorts: boolean }) => CanvasData;
};

export type FlowCanvasProps = {
  renderers?: NodeRenderer[];
  portTypes?: PortType[];
  edgeTypes?: EdgeRenderer[];

  data?: CanvasData;
  defaultData?: CanvasData;
  onChange?: (next: CanvasData) => void;

  onBeforeDrop?: (
    incoming: unknown,
    point: { x: number; y: number },
  ) => NodeData | null;
  onBeforeConnect?: (
    edge: EdgeRecord,
    ctx: { source: Port; target: Port },
  ) => boolean | EdgeRecord;

  onNodeCreate?: (node: NodeRecord) => void;
  onNodeUpdate?: (node: NodeRecord) => void;
  onNodeDelete?: (nodeId: string) => void;
  onEdgeCreate?: (edge: EdgeRecord) => void;
  onEdgeDelete?: (edgeId: string) => void;
  onSubObjectExtract?: (
    parentId: string,
    path: string,
    gesture: "copy" | "move",
  ) => void;

  menuItems?: {
    canvas?: MenuItem[];
    node?: (node: NodeRecord) => MenuItem[];
    edge?: (edge: EdgeRecord) => MenuItem[];
  };

  background?: FlowCanvasBackgroundConfig;

  readOnly?: boolean;
  panOnDrag?: boolean;
  zoomOnScroll?: boolean;
  selectionMode?: "single" | "multi";

  /**
   * When true (default v0.2.0+), xyflow culls nodes + edges outside the
   * viewport before rendering. Transforms perf at large N (5k+ heavy: ~12×
   * FPS lift on one machine, per 2026-05-14 baseline). Marginal effect
   * below ~200 nodes.
   *
   * Known caveats (don't block adoption):
   * - All nodes still render on initial mount; culling kicks in after the
   *   first viewport movement (xyflow issue #4378).
   * - Offscreen edges may stutter if their offscreen target node has an
   *   explicit height (xyflow issue #4329).
   *
   * Pass `={false}` to opt out (e.g. if you rely on offscreen-node DOM for
   * layout measurement — rare).
   */
  onlyRenderVisibleElements?: boolean;

  /**
   * Fired when a renderer requests an edit-dialog open (typically click).
   * v0.2.1 addition.
   *
   * - `nodeId` is the node firing the request.
   * - `subPath` is an opaque renderer-defined string (e.g. a card-tree subcard's
   *   `__rcid`) for sub-targeting; pass it through to your editor (e.g. via
   *   `CardTreeHandle.focusCard(subPath)` for the card-tree-node case).
   * - Leave `undefined` if you don't want edit affordances; renderers that
   *   honor the convention will silently no-op (their `ctx.onEditRequest`
   *   will be `undefined`).
   *
   * See the popup-edit renderer convention in the procomp guide §8.3 + the
   * `card-tree-node` procomp for the canonical consumer pattern.
   */
  onEditRequest?: (nodeId: string, subPath?: string) => void;

  exportRef?: Ref<FlowCanvasExportHandle>;

  "aria-label"?: string;

  className?: string;
};

// ───── Layout helper props ─────

export type PortsAtProps = {
  ports: Port[] | undefined;
  position: PortSide;
  spacing?: number;
};
```

### `src/registry/components/data/flow-canvas/hooks/use-canvas-data.ts`

```ts
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import {
  addEdge as xyAddEdge,
  applyEdgeChanges,
  applyNodeChanges,
  type Connection,
  type Edge as XyEdge,
  type EdgeChange,
  type Node as XyNode,
  type NodeChange,
} from "@xyflow/react";
import { findPortInTree } from "../lib/port-walker";
import { removeAtPath } from "../lib/sub-object-paths";
import type {
  CanvasData,
  EdgeRecord,
  FlowCanvasProps,
  NodeData,
  NodeRecord,
  Port,
} from "../types";

const EMPTY: CanvasData = { version: 1, nodes: [], edges: [] };

function makeEdgeId(): string {
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
    return `e-${crypto.randomUUID()}`;
  }
  return `e-${Math.random().toString(36).slice(2, 10)}`;
}

function makeNodeId(): string {
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
    return crypto.randomUUID();
  }
  return `n-${Math.random().toString(36).slice(2, 10)}`;
}

// xyflow's Node type carries `type`, `data`, `position`, etc. Our adapter
// always sets type to "ilinxa-node"; data carries our NodeData shape with
// a stable `id` mirror so the canvas can find it without our custom store.
type XyNodeData = NodeRecord["data"];

function toXyNode(n: NodeRecord): XyNode<XyNodeData> {
  return {
    id: n.id,
    type: "ilinxa-node",
    position: n.position,
    data: n.data,
    width: n.width,
    height: n.height,
    selected: n.selected,
    draggable: n.locked ? false : undefined,
  };
}

function toXyEdge(e: EdgeRecord): XyEdge {
  // xyflow expects sourceHandle / targetHandle as separate fields. Our
  // EdgeRecord encodes them inline as 'nodeId:portId'.
  const [source, sourceHandle] = e.source.split(":") as [string, string];
  const [target, targetHandle] = e.target.split(":") as [string, string];
  return {
    id: e.id,
    source,
    sourceHandle,
    target,
    targetHandle,
    type: e.type ?? "ilinxa-edge",
    selected: e.selected,
  };
}

function fromXyNode(n: XyNode<XyNodeData>): NodeRecord {
  return {
    id: n.id,
    position: n.position,
    data: n.data as XyNodeData,
    width: typeof n.width === "number" ? n.width : undefined,
    height: typeof n.height === "number" ? n.height : undefined,
    selected: n.selected,
    locked: n.draggable === false ? true : undefined,
  };
}

function fromXyEdge(e: XyEdge): EdgeRecord {
  const sourceHandle = e.sourceHandle ?? "";
  const targetHandle = e.targetHandle ?? "";
  return {
    id: e.id,
    source: `${e.source}:${sourceHandle}`,
    target: `${e.target}:${targetHandle}`,
    type: e.type === "ilinxa-edge" ? undefined : e.type,
    selected: e.selected,
  };
}

// v0.2.3 lock — structural-equality fast check for controlled-mode resync.
// Skips the wholesale-replace path in the data-prop useEffect when the new
// `data` reference is the round-trip echo of our own `fireOnChange` fire.
// Without this guard, every consumer setCanvas (the canonical controlled
// pattern) would wipe xyflow's internal node references → xyflow re-measures
// dimensions every cycle → "trying to drag a node that is not initialized"
// warning during the re-measurement window.
//
// Compares id / position.x / position.y / data-ref on nodes, id / source /
// target on edges. Width/height intentionally NOT compared — those are
// xyflow-managed (`measured` field) and don't round-trip through NodeRecord
// cleanly. O(N) over nodes + edges; short-circuits on first mismatch. Fast
// enough for controlled-mode consumers at N up to a few thousand; for larger
// canvases, prefer `defaultData` (uncontrolled).
//
// v0.2.6 — viewport intentionally NOT compared (it was in v0.2.3–v0.2.5):
// the camera can only be set at mount (`defaultViewport`); a `data.viewport`
// change cannot move it afterwards, while the internal viewport now
// live-tracks the real camera via `onMoveEnd`. Comparing them would make
// every consumer echo that raced a pan/zoom look like a genuine external
// change and trigger exactly the wholesale replace (measured-wipe → xyflow
// #015) this guard exists to prevent. Nodes + edges alone decide.
//
// v0.2.4 — this check alone wasn't sufficient against the stale-snapshot
// race: mid-drag mixed batches (position + dimensions) queued microtasks
// whose snapshots fell behind further drag ticks, causing this check to
// fail on legitimate divergence and trigger wholesale replace anyway. The
// v0.2.4 fix is in onNodesChange + fireOnChange: suppress ALL onChange
// notifications during drag (not just position-only), and have queued
// microtasks bail at fire time if a drag started after they were queued.
// The check below is still load-bearing for the non-drag round-trip path
// (e.g. consumer applies an unrelated state update during steady state).
function canvasMatchesInternalState(
  data: CanvasData,
  internalNodes: XyNode<XyNodeData>[],
  internalEdges: XyEdge[],
): boolean {
  if (data.nodes.length !== internalNodes.length) return false;
  if (data.edges.length !== internalEdges.length) return false;
  for (let i = 0; i < data.nodes.length; i++) {
    const dn = data.nodes[i];
    const xn = internalNodes[i];
    if (dn.id !== xn.id) return false;
    if (dn.position.x !== xn.position.x) return false;
    if (dn.position.y !== xn.position.y) return false;
    if (dn.data !== xn.data) return false;
  }
  for (let i = 0; i < data.edges.length; i++) {
    const de = data.edges[i];
    const xe = internalEdges[i];
    if (de.id !== xe.id) return false;
    const expectedSource = `${xe.source}:${xe.sourceHandle ?? ""}`;
    const expectedTarget = `${xe.target}:${xe.targetHandle ?? ""}`;
    if (de.source !== expectedSource) return false;
    if (de.target !== expectedTarget) return false;
  }
  return true;
}

export type UseCanvasDataResult = {
  // xyflow-shaped state for direct binding to <ReactFlow>
  xyNodes: XyNode<XyNodeData>[];
  xyEdges: XyEdge[];
  onNodesChange: (changes: NodeChange<XyNode<XyNodeData>>[]) => void;
  onEdgesChange: (changes: EdgeChange<XyEdge>[]) => void;
  onConnect: (connection: Connection) => void;
  // Drag lifecycle — bind to <ReactFlow>'s onNodeDragStart/onNodeDragStop
  // props. During a drag, ALL consumer onChange notifications are suppressed
  // (v0.2.4 widened from v0.2.0's position-only short-circuit); onNodeDragStop
  // flushes a single authoritative fire with the committed post-drag state.
  // Internal only — not re-exported from index.ts.
  onNodeDragStart: () => void;
  onNodeDragStop: () => void;
  // Camera tracking — bind to <ReactFlow>'s onMoveEnd prop. Syncs the real
  // viewport into state/ref after every pan/zoom so snapshot()/export/onChange
  // carry the actual camera (v0.2.6; previously the viewport slice stayed
  // frozen at its mount-time value). Camera-only moves deliberately do NOT
  // fire onChange — a pan is not a data edit (and mount-time fitView would
  // otherwise mark consumer documents dirty); the tracked value rides along
  // on the next data-driven fire. Internal only — not re-exported.
  onMoveEnd: (event: unknown, viewport: { x: number; y: number; zoom: number }) => void;
  // Snapshot in our CanvasData shape (used by exportRef + viewport setters)
  snapshot: () => CanvasData;
  // Imperative helpers used by drop pipeline / sub-object extract / menus
  appendNode: (node: NodeRecord) => void;
  updateNodeData: (nodeId: string, mutate: (data: NodeData) => NodeData) => void;
  duplicateNode: (nodeId: string) => void;
  deleteNode: (nodeId: string) => void;
  deleteEdge: (edgeId: string) => void;
  setEdges: (edges: EdgeRecord[]) => void;
  setNodes: (nodes: NodeRecord[]) => void;
  replace: (data: CanvasData) => void;
  // Sub-object extraction (atomic: append + optionally remove from parent)
  extractSubObject: (input: {
    parentId: string;
    path: string;
    gesture: "copy" | "move";
    newNode: NodeRecord;
  }) => void;
  // Ref-based resolvers — used by callers (e.g. <CanvasContextMenu>) that
  // need to look up nodes/edges by id without re-rendering on every state
  // change. The returned functions are stable across renders; the lookup
  // closes over a ref so it always sees the latest state.
  getNodeById: (id: string) => NodeRecord | undefined;
  getEdgeById: (id: string) => EdgeRecord | undefined;
};

export function useCanvasData({
  data,
  defaultData,
  onChange,
  onBeforeConnect,
  onNodeCreate,
  onNodeUpdate,
  onNodeDelete,
  onEdgeCreate,
  onEdgeDelete,
  onSubObjectExtract,
}: {
  data?: CanvasData;
  defaultData?: CanvasData;
  onChange?: (next: CanvasData) => void;
  onBeforeConnect?: FlowCanvasProps["onBeforeConnect"];
  onNodeCreate?: FlowCanvasProps["onNodeCreate"];
  onNodeUpdate?: FlowCanvasProps["onNodeUpdate"];
  onNodeDelete?: FlowCanvasProps["onNodeDelete"];
  onEdgeCreate?: FlowCanvasProps["onEdgeCreate"];
  onEdgeDelete?: FlowCanvasProps["onEdgeDelete"];
  onSubObjectExtract?: FlowCanvasProps["onSubObjectExtract"];
}): UseCanvasDataResult {
  const isControlled = data !== undefined;
  const initial = data ?? defaultData ?? EMPTY;

  const [internalNodes, setInternalNodes] = useState<XyNode<XyNodeData>[]>(() =>
    initial.nodes.map(toXyNode),
  );
  const [internalEdges, setInternalEdges] = useState<XyEdge[]>(() =>
    initial.edges.map(toXyEdge),
  );
  const [viewport, setViewport] = useState(initial.viewport);

  // Refs mirror the latest state so we can keep event handlers stable
  // (empty-dep useCallback). This is the canonical xyflow perf pattern —
  // see xyflow-react-pro skill "Performance" + "useCallback every event
  // handler". At 200 nodes, dep-changing handlers cascade into a full
  // ReactFlow reconciliation on every drag tick.
  //
  // v0.2.6 — every mutation site ALSO syncs its ref synchronously (inside
  // its updater / before a direct set) so the coalesced fireOnChange
  // microtask reads current state; the useEffect syncs below remain as the
  // post-commit safety net (they also cover React discarding a render).
  const nodesRef = useRef(internalNodes);
  const edgesRef = useRef(internalEdges);
  const viewportRef = useRef(viewport);
  // Drag-lifecycle ref — flipped true between xyflow's onNodeDragStart and
  // onNodeDragStop. While true, `onNodesChange` suppresses ALL consumer
  // onChange notifications (v0.2.4 widened this from v0.2.0's position-only
  // short-circuit — see the v0.2.4 lock comment on `onNodesChange` below).
  // `fireOnChange`'s microtask body also checks this ref at fire time to
  // drop pre-drag snapshots that would otherwise round-trip during drag.
  // `onNodeDragStop` flushes a single authoritative fire with the committed
  // post-drag state.
  const isDraggingRef = useRef(false);
  useEffect(() => {
    nodesRef.current = internalNodes;
  }, [internalNodes]);
  useEffect(() => {
    edgesRef.current = internalEdges;
  }, [internalEdges]);
  useEffect(() => {
    viewportRef.current = viewport;
  }, [viewport]);

  // Also mirror callback props in refs so changing the consumer's callback
  // identity doesn't churn our internal handlers.
  const onChangeRef = useRef(onChange);
  const onBeforeConnectRef = useRef(onBeforeConnect);
  const onNodeCreateRef = useRef(onNodeCreate);
  const onNodeUpdateRef = useRef(onNodeUpdate);
  const onNodeDeleteRef = useRef(onNodeDelete);
  const onEdgeCreateRef = useRef(onEdgeCreate);
  const onEdgeDeleteRef = useRef(onEdgeDelete);
  const onSubObjectExtractRef = useRef(onSubObjectExtract);
  useEffect(() => {
    onChangeRef.current = onChange;
    onBeforeConnectRef.current = onBeforeConnect;
    onNodeCreateRef.current = onNodeCreate;
    onNodeUpdateRef.current = onNodeUpdate;
    onNodeDeleteRef.current = onNodeDelete;
    onEdgeCreateRef.current = onEdgeCreate;
    onEdgeDeleteRef.current = onEdgeDelete;
    onSubObjectExtractRef.current = onSubObjectExtract;
  });

  // Reflect controlled-mode changes — `data` prop wholesale replacement.
  //
  // v0.2.3: skip the resync when the new `data` prop is the round-trip echo
  // of our own `fireOnChange`. After v0.2.2's microtask defer, consumer
  // setCanvas creates a new top-level data reference even for echoes —
  // reference equality alone (the v0.2.2 guard) misses every round-trip and
  // wholesale-replaces xyflow's internal node refs each cycle, causing
  // xyflow to re-measure dimensions and emit "trying to drag a node that is
  // not initialized" warnings during the re-measurement window. Structural
  // equality catches the echo while still allowing genuine external data
  // changes to trigger the resync.
  const lastControlledData = useRef<CanvasData | undefined>(data);
  useEffect(() => {
    if (!isControlled) return;
    if (data === lastControlledData.current) return;
    lastControlledData.current = data;
    if (
      data &&
      canvasMatchesInternalState(data, nodesRef.current, edgesRef.current)
    ) {
      // Round-trip echo — internal state already correct, no resync.
      return;
    }
    const nextNodes = (data ?? EMPTY).nodes.map(toXyNode);
    const nextEdges = (data ?? EMPTY).edges.map(toXyEdge);
    nodesRef.current = nextNodes;
    edgesRef.current = nextEdges;
    setInternalNodes(nextNodes);
    setInternalEdges(nextEdges);
    // v0.2.6 — viewport deliberately NOT adopted from `data`: the camera
    // cannot be moved post-mount by the data prop (defaultViewport only) and
    // the internal viewport tracks the REAL camera via onMoveEnd; overwriting
    // it here would make snapshot()/onChange report a camera the user isn't
    // seeing.
  }, [isControlled, data]);

  // v0.2.2 lock: notify the consumer in a microtask AFTER React commits the
  // current state update. Why deferred:
  //
  // - `fireOnChange` is called from 13 sites in this file, MOST of which sit
  //   inside `setInternalNodes/Edges/Viewport((prev) => { ... })` reducers
  //   (the "reducer-side-effect" pattern acknowledged in v0.2.0 plan F-V4 as
  //   a v0.3 cleanup candidate). When xyflow emits initial `dimensions`
  //   changes during its own render-phase measurement, the reducer-side-
  //   effect fires the consumer's `onChange` synchronously → consumer's
  //   `setCanvas` → React 19 "setState during render" warning on the
  //   consumer component. Surfaced 2026-05-16 by card-tree-node@v0.1.0's
  //   demo (first controlled-mode consumer in the library).
  //
  // - Wrapping the body in `queueMicrotask` gives ONE invariant: consumer
  //   `onChange` always fires post-commit. Every code path in this file
  //   becomes render-phase-safe — no need to refactor each reducer-side-
  //   effect site individually. Promotes the v0.3-deferred cleanup forward
  //   because the bug surfaced; "consistency over a one-off divergence" per
  //   F-V4 still holds (all 13 sites get the SAME deferred behavior, not a
  //   mixed bag of one-microtask-deferred + twelve-still-synchronous).
  //
  // - v0.2.6 lock — COALESCED, REF-READ-AT-FIRE-TIME (replaces the v0.2.2
  //   "capture semantics" paragraph). Previously each call captured
  //   (nodes, edges, vp) in closure; call sites inside one updater passed
  //   their own `next` but read the SIBLING slice from its ref synchronously
  //   — before the sibling's updater/commit effect had run — so multi-slice
  //   mutations (node delete + edge cascade; keyboard delete's two change
  //   batches) notified with half-stale snapshots that could resurrect
  //   dangling edges through the controlled round-trip. Now:
  //     * every mutation site syncs its ref SYNCHRONOUSLY (inside the
  //       updater, or before a direct set) and calls the zero-arg
  //       fireOnChange();
  //     * multiple requests in one tick coalesce into ONE microtask
  //       (pending flag), whose body reads nodesRef/edgesRef/viewportRef at
  //       fire time — by then every same-tick ref sync has happened → one
  //       complete, self-consistent snapshot;
  //     * StrictMode's double-invoked updaters coalesce to a single fire for
  //       free (the double-fire noted in the 2026-08-10 review).
  //   The v0.2.4 drag guard below is unchanged and composes: fires requested
  //   during a drag are dropped at microtask time; onNodeDragStop requests
  //   the single authoritative post-drag fire.
  //
  // - Latency: one microtask. Imperceptible. React batches the consumer's
  //   resulting setState into the next paint either way.
  const firePendingRef = useRef(false);
  const fireOnChange = useCallback(() => {
    if (firePendingRef.current) return;
    firePendingRef.current = true;
    queueMicrotask(() => {
      firePendingRef.current = false;
      // v0.2.4 lock — drop the fire if a drag started between request and
      // microtask. Mid-drag round-trips would fail the structural-equality
      // check → wholesale replace → measured wiped → xyflow #015.
      // onNodeDragStop fires the authoritative post-drag snapshot.
      if (isDraggingRef.current) return;
      const cb = onChangeRef.current;
      if (!cb) return;
      cb({
        version: 1,
        nodes: nodesRef.current.map(fromXyNode),
        edges: edgesRef.current.map(fromXyEdge),
        viewport: viewportRef.current,
      });
    });
  }, []);

  const onNodesChange = useCallback(
    (changes: NodeChange<XyNode<XyNodeData>>[]) => {
      setInternalNodes((prev) => {
        const next = applyNodeChanges(changes, prev);
        // v0.2.6 — sync the ref inside the updater so the coalesced
        // fireOnChange microtask reads current state (refs also stay live
        // during drag; only the FIRE is suppressed).
        nodesRef.current = next;
        // v0.2.4 lock — suppress ALL consumer onChange notifications during
        // drag, not just position-only batches. Mid-drag mixed batches
        // (position + dimensions for non-dragged nodes during multi-select
        // or auto-layout resize) used to slip through, queue a microtask
        // with a snapshot, and by the time the microtask ran, further drag
        // ticks had moved internal state on. The stale snapshot round-
        // tripped through the consumer, failed structural-equality, and
        // triggered wholesale replace → xyflow #015. onNodeDragStop flushes
        // a single authoritative fire at drag end.
        if (isDraggingRef.current) return next;
        fireOnChange();
        return next;
      });
    },
    [fireOnChange],
  );

  // Drag-lifecycle callbacks — wired to <ReactFlow>'s onNodeDragStart /
  // onNodeDragStop props in canvas.tsx. The pair plus the v0.2.4 drag-time
  // suppression in `onNodesChange` and the microtask drag-guard in
  // `fireOnChange` together implement the description §4.1 Change #2 + #3
  // intent (no per-tick consumer fires during drag; single authoritative
  // fire at drag end), widened in v0.2.4 to cover mixed batches too.
  const onNodeDragStart = useCallback(() => {
    isDraggingRef.current = true;
  }, []);

  const onNodeDragStop = useCallback(() => {
    isDraggingRef.current = false;
    // Flush a final onChange with the committed state. The identity-read
    // updater runs AFTER any still-queued drag-tick updaters (React applies
    // queued updaters in dispatch order), so `latestNodes` — and the ref
    // sync — reflect the final position; returning the same reference bails
    // out of a re-render. The reducer-side-effect pattern (here + the other
    // sites in this file) is safe post-v0.2.2/v0.2.6: `fireOnChange` is
    // microtask-deferred + coalesced, so it never runs synchronously during
    // a reducer and double-invoked reducers request a single fire.
    setInternalNodes((latestNodes) => {
      nodesRef.current = latestNodes;
      fireOnChange();
      return latestNodes;
    });
  }, [fireOnChange]);

  // v0.2.6 — live camera tracking (bound to <ReactFlow>'s onMoveEnd in
  // canvas.tsx). See the UseCanvasDataResult comment for why camera-only
  // moves don't fire onChange.
  const onMoveEnd = useCallback(
    (_event: unknown, vp: { x: number; y: number; zoom: number }) => {
      const prev = viewportRef.current;
      if (prev && prev.x === vp.x && prev.y === vp.y && prev.zoom === vp.zoom) {
        return;
      }
      viewportRef.current = vp;
      setViewport(vp);
    },
    [],
  );

  const onEdgesChange = useCallback(
    (changes: EdgeChange<XyEdge>[]) => {
      setInternalEdges((prev) => {
        const next = applyEdgeChanges(changes, prev);
        edgesRef.current = next;
        fireOnChange();
        return next;
      });
    },
    [fireOnChange],
  );

  // onConnect — adds an edge after typed validation (in canvas.tsx) AND
  // the consumer's onBeforeConnect hook (here). Pipeline:
  //   xyflow drag-release
  //     → isValidConnection (typed: same-type + dir + multi)  [canvas.tsx]
  //     → onConnect          (this fn)
  //         → onBeforeConnect (consumer; can reject or rewrite the edge)
  //         → addEdge to state, fireOnChange, fire onEdgeCreate
  const onConnect = useCallback(
    (connection: Connection) => {
      let candidate: EdgeRecord = {
        id: makeEdgeId(),
        source: `${connection.source}:${connection.sourceHandle ?? ""}`,
        target: `${connection.target}:${connection.targetHandle ?? ""}`,
      };

      const before = onBeforeConnectRef.current;
      if (before) {
        const nodes = nodesRef.current;
        const srcNode = nodes.find((n) => n.id === connection.source);
        const tgtNode = nodes.find((n) => n.id === connection.target);
        const srcPort: Port | undefined =
          srcNode && connection.sourceHandle
            ? findPortInTree(srcNode.data as NodeData, connection.sourceHandle)?.port
            : undefined;
        const tgtPort: Port | undefined =
          tgtNode && connection.targetHandle
            ? findPortInTree(tgtNode.data as NodeData, connection.targetHandle)?.port
            : undefined;
        if (!srcPort || !tgtPort) return;

        const result = before(candidate, { source: srcPort, target: tgtPort });
        if (result === false) return;
        if (typeof result === "object" && result !== null) candidate = result;
      }

      setInternalEdges((prev) => {
        const next = xyAddEdge(
          {
            ...connection,
            id: candidate.id,
            type: "ilinxa-edge",
          },
          prev,
        );
        edgesRef.current = next;
        fireOnChange();
        return next;
      });
      onEdgeCreateRef.current?.(candidate);
    },
    [fireOnChange],
  );

  const snapshot = useCallback<() => CanvasData>(
    () => ({
      version: 1,
      nodes: nodesRef.current.map(fromXyNode),
      edges: edgesRef.current.map(fromXyEdge),
      viewport: viewportRef.current,
    }),
    [],
  );

  const appendNode = useCallback(
    (node: NodeRecord) => {
      setInternalNodes((prev) => {
        const next = [...prev, toXyNode(node)];
        nodesRef.current = next;
        fireOnChange();
        return next;
      });
      onNodeCreateRef.current?.(node);
    },
    [fireOnChange],
  );

  const updateNodeData = useCallback(
    (nodeId: string, mutate: (data: NodeData) => NodeData) => {
      // v0.2.6 (review §6 medium) — compute the next record eagerly from the
      // ref BEFORE dispatch; the updater only applies the precomputed data.
      // Running `mutate` + record capture inside the updater tied the
      // `onNodeUpdate` decision to WHEN React ran it (an eager first-updater
      // ran it in time; a deferred/StrictMode-replayed one ran it after the
      // `if (updatedRecord)` check → the callback silently never fired, and
      // consumer `mutate` code executed twice under StrictMode).
      const target = nodesRef.current.find((n) => n.id === nodeId);
      if (!target) return;
      const updatedData = mutate(target.data as NodeData);
      const updatedRecord = fromXyNode({ ...target, data: updatedData });
      setInternalNodes((prev) => {
        const next = prev.map((n) =>
          n.id === nodeId ? { ...n, data: updatedData } : n,
        );
        nodesRef.current = next;
        fireOnChange();
        return next;
      });
      onNodeUpdateRef.current?.(updatedRecord);
    },
    [fireOnChange],
  );

  const duplicateNode = useCallback(
    (nodeId: string) => {
      const target = nodesRef.current.find((n) => n.id === nodeId);
      if (!target) return;
      const clone: NodeRecord = {
        id: makeNodeId(),
        position: { x: target.position.x + 24, y: target.position.y + 24 },
        data: JSON.parse(JSON.stringify(target.data)),
      };
      appendNode(clone);
    },
    [appendNode],
  );

  const deleteNode = useCallback(
    (nodeId: string) => {
      // Cascade incident edges per Q15. Both updaters sync their ref and
      // request a fire — the single coalesced microtask (v0.2.6) sees the
      // node removal AND the edge cascade in one snapshot (pre-v0.2.6 the
      // node updater read a stale pre-cascade edgesRef → the notified data
      // could resurrect dangling edges through the controlled round-trip).
      setInternalEdges((prev) => {
        const next = prev.filter(
          (e) => e.source !== nodeId && e.target !== nodeId,
        );
        edgesRef.current = next;
        fireOnChange();
        return next;
      });
      setInternalNodes((prev) => {
        const next = prev.filter((n) => n.id !== nodeId);
        nodesRef.current = next;
        fireOnChange();
        return next;
      });
      onNodeDeleteRef.current?.(nodeId);
    },
    [fireOnChange],
  );

  const deleteEdge = useCallback(
    (edgeId: string) => {
      setInternalEdges((prev) => {
        const next = prev.filter((e) => e.id !== edgeId);
        edgesRef.current = next;
        fireOnChange();
        return next;
      });
      onEdgeDeleteRef.current?.(edgeId);
    },
    [fireOnChange],
  );

  const setNodes = useCallback(
    (nodes: NodeRecord[]) => {
      const next = nodes.map(toXyNode);
      nodesRef.current = next;
      setInternalNodes(next);
      fireOnChange();
    },
    [fireOnChange],
  );

  const setEdges = useCallback(
    (edges: EdgeRecord[]) => {
      const next = edges.map(toXyEdge);
      edgesRef.current = next;
      setInternalEdges(next);
      fireOnChange();
    },
    [fireOnChange],
  );

  const replace = useCallback(
    (next: CanvasData) => {
      const xn = next.nodes.map(toXyNode);
      const xe = next.edges.map(toXyEdge);
      nodesRef.current = xn;
      edgesRef.current = xe;
      viewportRef.current = next.viewport;
      setInternalNodes(xn);
      setInternalEdges(xe);
      setViewport(next.viewport);
      fireOnChange();
    },
    [fireOnChange],
  );

  // Atomic sub-object extraction: append the new node AND (if 'move') remove
  // the sub-object from the parent's data tree in a single state update.
  // Plan §3.8 + Q23 — gesture defaults to 'copy' (parent retains).
  const extractSubObject = useCallback(
    ({
      parentId,
      path,
      gesture,
      newNode,
    }: {
      parentId: string;
      path: string;
      gesture: "copy" | "move";
      newNode: NodeRecord;
    }) => {
      // v0.2.6 (review §6 medium) — same eager-compute rule as
      // updateNodeData: the parent's post-removal record is derived from the
      // ref BEFORE dispatch so `onNodeUpdate` cannot be skipped by updater
      // timing; the updater only applies precomputed data.
      let updatedParentData: NodeData | undefined;
      let updatedParentRecord: NodeRecord | undefined;
      if (gesture === "move") {
        const parent = nodesRef.current.find((n) => n.id === parentId);
        if (parent) {
          updatedParentData = removeAtPath(parent.data as NodeData, path);
          updatedParentRecord = fromXyNode({ ...parent, data: updatedParentData });
        }
      }
      setInternalNodes((prev) => {
        const withChild = [...prev, toXyNode(newNode)];
        const parentData = updatedParentData;
        const next =
          parentData === undefined
            ? withChild
            : withChild.map((n) =>
                n.id === parentId ? { ...n, data: parentData } : n,
              );
        nodesRef.current = next;
        fireOnChange();
        return next;
      });
      onNodeCreateRef.current?.(newNode);
      if (updatedParentRecord) onNodeUpdateRef.current?.(updatedParentRecord);
      onSubObjectExtractRef.current?.(parentId, path, gesture);
    },
    [fireOnChange],
  );

  const getNodeById = useCallback((id: string): NodeRecord | undefined => {
    const n = nodesRef.current.find((x) => x.id === id);
    return n ? fromXyNode(n) : undefined;
  }, []);

  const getEdgeById = useCallback((id: string): EdgeRecord | undefined => {
    const e = edgesRef.current.find((x) => x.id === id);
    return e ? fromXyEdge(e) : undefined;
  }, []);

  return useMemo(
    () => ({
      xyNodes: internalNodes,
      xyEdges: internalEdges,
      onNodesChange,
      onEdgesChange,
      onConnect,
      onNodeDragStart,
      onNodeDragStop,
      onMoveEnd,
      snapshot,
      appendNode,
      updateNodeData,
      duplicateNode,
      deleteNode,
      deleteEdge,
      setEdges,
      setNodes,
      replace,
      extractSubObject,
      getNodeById,
      getEdgeById,
    }),
    [
      internalNodes,
      internalEdges,
      onNodesChange,
      onEdgesChange,
      onConnect,
      onNodeDragStart,
      onNodeDragStop,
      onMoveEnd,
      snapshot,
      appendNode,
      updateNodeData,
      duplicateNode,
      deleteNode,
      deleteEdge,
      setEdges,
      setNodes,
      replace,
      extractSubObject,
      getNodeById,
      getEdgeById,
    ],
  );
}
```

### `src/registry/components/data/flow-canvas/hooks/use-drop-pipeline.ts`

```ts
"use client";

import { useCallback, type DragEvent, type ClipboardEvent } from "react";
import { useReactFlow } from "@xyflow/react";
import { coerceToNodeData } from "../lib/coerce-to-node-data";
import { inflateDefaultPorts } from "../lib/inflate-default-ports";
import { parseJsonSafe } from "../lib/parse-json";
import { findRenderer } from "../registries/renderer-registry";
import type {
  FlowCanvasProps,
  NodeData,
  NodeRecord,
  NodeRenderer,
} from "../types";

const NODE_DROP_MIME = "application/json";
// MIME used by xyflow's own sidebar pattern. We accept this too so consumers
// who already use that convention keep working.
const REACTFLOW_DROP_MIME = "application/reactflow";
// Custom MIME for in-canvas sub-object drag-extract (M5). Carries
// `parentId|path` so the drop handler can route through the extraction
// pipeline instead of treating it as an external JSON drop.
const SUBOBJECT_DROP_MIME = "application/x-ilinxa-subobject";

type DropContext = {
  renderers: NodeRenderer[];
  appendNode: (node: NodeRecord) => void;
  extractSubObject: (input: {
    parentId: string;
    path: string;
    gesture: "copy" | "move";
    newNode: NodeRecord;
  }) => void;
  onBeforeDrop?: FlowCanvasProps["onBeforeDrop"];
  // v0.2.6 — no `onNodeCreate` here anymore: `appendNode` /
  // `extractSubObject` fire it internally (review 5.3 double-fire fix).
};

// Build the drag-over / drop / paste handlers. The caller spreads these onto
// the canvas wrapper element.
//
// `useReactFlow()` requires <ReactFlowProvider> in an ancestor — our root
// already provides it.
export function useDropPipeline({
  renderers,
  appendNode,
  extractSubObject,
  onBeforeDrop,
}: DropContext) {
  const { screenToFlowPosition } = useReactFlow();

  const buildNode = useCallback(
    (rawData: NodeData, point: { x: number; y: number }): NodeRecord | null => {
      let data: NodeData | null = rawData;
      if (onBeforeDrop) {
        const result = onBeforeDrop(rawData, point);
        if (result === null) return null;
        if (result) data = result;
      }
      if (!data) return null;
      const renderer = findRenderer(renderers, data.__type);
      const inflated = inflateDefaultPorts(data, renderer);
      return { id: makeNodeId(), position: point, data: inflated };
    },
    [renderers, onBeforeDrop],
  );

  const dispatch = useCallback(
    (rawData: NodeData, point: { x: number; y: number }) => {
      const node = buildNode(rawData, point);
      if (!node) return;
      // appendNode fires onNodeCreate internally — do NOT also call the prop
      // here (v0.2.6, review 5.3: the extra call double-fired onNodeCreate on
      // every drop/paste, so consumers creating backend records per create
      // inserted duplicates).
      appendNode(node);
    },
    [buildNode, appendNode],
  );

  const dispatchExtraction = useCallback(
    (
      rawData: NodeData,
      point: { x: number; y: number },
      parentId: string,
      path: string,
      gesture: "copy" | "move",
    ) => {
      const node = buildNode(rawData, point);
      if (!node) return;
      extractSubObject({ parentId, path, gesture, newNode: node });
    },
    [buildNode, extractSubObject],
  );

  const onDragOver = useCallback((e: DragEvent) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = "move";
  }, []);

  const onDrop = useCallback(
    (e: DragEvent) => {
      e.preventDefault();
      const dt = e.dataTransfer;

      // Sub-object extraction branch — own-canvas drag tagged by emitSubObjectDrag.
      const subPayload = dt.getData(SUBOBJECT_DROP_MIME);
      const jsonPayload = dt.getData(NODE_DROP_MIME);
      if (subPayload && jsonPayload) {
        const parsed = parseJsonSafe(jsonPayload);
        const data = coerceToNodeData(parsed);
        if (!data) return;
        const meta = parseJsonSafe(subPayload) as
          | { parentId?: string; path?: string }
          | undefined;
        if (!meta?.parentId || !meta.path) return;
        const point = screenToFlowPosition({ x: e.clientX, y: e.clientY });
        // Alt held → move; otherwise copy. Documented as the gesture.
        const gesture = e.altKey ? "move" : "copy";
        dispatchExtraction(data, point, meta.parentId, meta.path, gesture);
        return;
      }

      // Order of preference: explicit JSON, xyflow's reactflow MIME, plain text.
      const payload =
        jsonPayload ||
        dt.getData(REACTFLOW_DROP_MIME) ||
        dt.getData("text/plain");

      // No text payload — try files (first one only).
      if (!payload && dt.files.length > 0) {
        const file = dt.files[0];
        if (!file.type.includes("json") && !file.name.endsWith(".json")) return;
        // Read async; abort if it fails.
        file
          .text()
          .then((text) => {
            const parsed = parseJsonSafe(text);
            const data = coerceToNodeData(parsed);
            if (!data) return;
            const point = screenToFlowPosition({ x: e.clientX, y: e.clientY });
            dispatch(data, point);
          })
          .catch(() => undefined);
        return;
      }

      if (!payload) return;
      const parsed = parseJsonSafe(payload);
      const data = coerceToNodeData(parsed);
      if (!data) return;
      const point = screenToFlowPosition({ x: e.clientX, y: e.clientY });
      dispatch(data, point);
    },
    [screenToFlowPosition, dispatch, dispatchExtraction],
  );

  const onPaste = useCallback(
    (e: ClipboardEvent) => {
      // Don't steal paste from focused inputs/textareas/contenteditables.
      const target = e.target as HTMLElement | null;
      if (target && isEditableTarget(target)) return;

      const text = e.clipboardData.getData("text/plain");
      if (!text) return;
      const parsed = parseJsonSafe(text);
      const data = coerceToNodeData(parsed);
      if (!data) return;

      // Paste lands at the viewport center (no mouse coordinates available).
      // Compute via the canvas root's bounding box.
      const root =
        target && target.closest<HTMLDivElement>("[data-flow-canvas-root]");
      const rect = root?.getBoundingClientRect();
      const sx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2;
      const sy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2;
      const point = screenToFlowPosition({ x: sx, y: sy });

      e.preventDefault();
      dispatch(data, point);
    },
    [screenToFlowPosition, dispatch],
  );

  return { onDragOver, onDrop, onPaste };
}

function isEditableTarget(el: HTMLElement): boolean {
  if (el.isContentEditable) return true;
  const tag = el.tagName;
  return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
}

function makeNodeId(): string {
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
    return crypto.randomUUID();
  }
  return `n-${Math.random().toString(36).slice(2, 10)}`;
}
```

### `src/registry/components/data/flow-canvas/hooks/use-export.ts`

```ts
"use client";

import { useImperativeHandle, type Ref } from "react";
import type {
  CanvasData,
  EdgeRecord,
  FlowCanvasExportHandle,
  NodeData,
  NodeRecord,
} from "../types";

function stripPortsFromNodeData(data: NodeData): NodeData {
  const { ports: _ports, ...rest } = data;
  void _ports;
  const out: NodeData = { __type: data.__type };
  for (const [key, value] of Object.entries(rest)) {
    if (key === "__type") continue;
    out[key] = stripPortsFromValue(value);
  }
  return out;
}

function stripPortsFromValue(value: unknown): unknown {
  if (Array.isArray(value)) {
    return value.map((v) => stripPortsFromValue(v));
  }
  if (value && typeof value === "object") {
    const obj = value as Record<string, unknown>;
    if (typeof obj.__type === "string") {
      return stripPortsFromNodeData(obj as NodeData);
    }
    const cleaned: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === "ports") continue;
      cleaned[k] = stripPortsFromValue(v);
    }
    return cleaned;
  }
  return value;
}

function stripSelected<T extends { selected?: boolean }>(
  record: T,
): Omit<T, "selected"> {
  const { selected: _selected, ...rest } = record;
  void _selected;
  return rest;
}

export function useExportHandle(
  ref: Ref<FlowCanvasExportHandle> | undefined,
  getSnapshot: () => CanvasData,
) {
  useImperativeHandle(
    ref,
    () => ({
      export: ({ withPorts }) => {
        const snap = getSnapshot();
        if (withPorts) {
          return {
            ...snap,
            nodes: snap.nodes.map((n) => stripSelected(n) as NodeRecord),
            edges: snap.edges.map((e) => stripSelected(e) as EdgeRecord),
          };
        }
        return {
          ...snap,
          nodes: snap.nodes.map((n) => ({
            ...stripSelected(n),
            data: stripPortsFromNodeData(n.data),
          })) as NodeRecord[],
          edges: [],
        };
      },
    }),
    [getSnapshot],
  );
}
```

### `src/registry/components/data/flow-canvas/lib/coerce-to-node-data.ts`

```ts
import type { NodeData } from "../types";

// Coerce arbitrary parsed JSON into a `NodeData` shape. Rules:
//   - `null` / non-objects / arrays → undefined (caller aborts)
//   - object with string `__type` → returned as-is (already NodeData)
//   - object without `__type`     → wrapped as `{ __type: 'custom-json', ...obj }`
//
// This is the canonical single-place where unknown payloads get the fallback
// discriminator. M5 sub-object extraction also funnels through here when an
// extracted sub-object lacks `__type` (description risk §10).

export function coerceToNodeData(input: unknown): NodeData | undefined {
  if (!input || typeof input !== "object") return undefined;
  if (Array.isArray(input)) return undefined;

  const obj = input as Record<string, unknown>;
  if (typeof obj.__type === "string" && obj.__type.length > 0) {
    return obj as NodeData;
  }
  return { __type: "custom-json", ...obj };
}
```

### `src/registry/components/data/flow-canvas/lib/emit-sub-object-drag.ts`

```ts
import type { DragEvent } from "react";

// Renderers call this from a sub-object's `onDragStart` to mark the drag as
// a sub-object extraction. The drop handler (canvas root) reads the special
// MIME and routes the drop through the extraction pipeline (copy by default,
// Alt-drag for move).
//
// Pattern (in a renderer):
//
//   <div
//     data-draggable-subobject={`media[${i}]`}
//     draggable
//     onDragStart={(e) => emitSubObjectDrag(
//       e, data.media[i], `media[${i}]`, ctx.nodeId
//     )}
//   >
//     <MediaItem {...data.media[i]} />
//   </div>
//
// Calling stopPropagation prevents xyflow's node-drag from also firing.

export function emitSubObjectDrag(
  e: DragEvent,
  subData: unknown,
  path: string,
  parentId: string,
): void {
  e.stopPropagation();
  e.dataTransfer.setData("application/json", JSON.stringify(subData));
  e.dataTransfer.setData(
    "application/x-ilinxa-subobject",
    JSON.stringify({ parentId, path }),
  );
  e.dataTransfer.effectAllowed = "copyMove";
}
```

### `src/registry/components/data/flow-canvas/lib/inflate-default-ports.ts`

```ts
import type { NodeData, NodeRenderer, Port } from "../types";

// Apply renderer.defaultPorts + renderer.defaultSubPorts to a NodeData ONLY
// where the corresponding `ports` field is absent. Presence of `ports` (even
// empty `[]`) is the deliberate-state signal — never re-inflate (Q23).
//
// Pure: returns a new NodeData; the input is untouched. Source-shape data is
// preserved this way.

export function inflateDefaultPorts(
  data: NodeData,
  renderer: NodeRenderer | undefined,
): NodeData {
  if (!renderer) return data;

  let out: NodeData = data;

  // Root-level ports inflation
  if (!Object.prototype.hasOwnProperty.call(data, "ports") && renderer.defaultPorts) {
    const ports: Port[] = renderer.defaultPorts(data);
    out = { ...out, ports };
  }

  // Sub-object port inflation, by JSON path. We support a small subset of
  // path syntax: dot-path and array-index (`media[0].thumb`). Plan §3.5
  // commits to this shape; richer JSONPath is a future extension.
  if (renderer.defaultSubPorts) {
    const subMap = renderer.defaultSubPorts(data);
    for (const [path, defaultPorts] of Object.entries(subMap)) {
      out = setPortsAtPath(out, path, defaultPorts);
    }
  }

  return out;
}

// Apply `ports` to the object at `path` inside `data`, ONLY if absent.
// Immutable — clones the affected branches.
function setPortsAtPath(
  data: NodeData,
  path: string,
  ports: Port[],
): NodeData {
  const segments = parsePath(path);
  if (segments.length === 0) return data;

  const cloned = JSON.parse(JSON.stringify(data)) as NodeData;
  let cursor: unknown = cloned;
  for (let i = 0; i < segments.length; i++) {
    const seg = segments[i];
    if (cursor === null || typeof cursor !== "object") return data;
    if (seg.kind === "key") {
      cursor = (cursor as Record<string, unknown>)[seg.value];
    } else {
      if (!Array.isArray(cursor)) return data;
      cursor = cursor[seg.value];
    }
  }
  if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return data;
  const obj = cursor as Record<string, unknown>;
  if (Object.prototype.hasOwnProperty.call(obj, "ports")) return data; // respect existing
  obj.ports = ports;
  return cloned;
}

type PathSegment =
  | { kind: "key"; value: string }
  | { kind: "index"; value: number };

function parsePath(path: string): PathSegment[] {
  // Splits "media[0].thumb" → [{key,'media'},{index,0},{key,'thumb'}]
  const out: PathSegment[] = [];
  const re = /([^.[\]]+)|\[(\d+)\]/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(path)) !== null) {
    if (m[1] !== undefined) out.push({ kind: "key", value: m[1] });
    else if (m[2] !== undefined) out.push({ kind: "index", value: Number(m[2]) });
  }
  return out;
}
```

### `src/registry/components/data/flow-canvas/lib/parse-json.ts`

```ts
// Safe JSON parse — does NOT throw. Returns the parsed value on success or
// `undefined` on any error (with a dev-only `console.warn`). Used by the
// drop / paste pipelines so an invalid payload aborts cleanly.

export function parseJsonSafe(text: string): unknown {
  if (!text) return undefined;
  try {
    return JSON.parse(text);
  } catch (err) {
    if (process.env.NODE_ENV !== "production") {
      console.warn("[flow-canvas] could not parse JSON payload:", err);
    }
    return undefined;
  }
}
```

### `src/registry/components/data/flow-canvas/lib/port-walker.ts`

```ts
import type { NodeData, Port } from "../types";

// Pure tree-walker. Returns the first port matching `portId` at any depth,
// plus the JSON path to its containing object. Walker assumes port ids are
// unique within a node (locked decision Q9 / plan §3.4).

export type FoundPort = {
  port: Port;
  path: string; // JSONPath to the containing object — "" for root
};

export function findPortInTree(
  data: NodeData,
  portId: string,
): FoundPort | undefined {
  return walk(data, portId, "");
}

function walk(
  value: unknown,
  portId: string,
  path: string,
): FoundPort | undefined {
  if (!value || typeof value !== "object") return undefined;

  if (Array.isArray(value)) {
    for (let i = 0; i < value.length; i++) {
      const found = walk(value[i], portId, `${path}[${i}]`);
      if (found) return found;
    }
    return undefined;
  }

  const obj = value as Record<string, unknown>;

  // Direct port match — this object owns ports[]
  const ports = obj.ports;
  if (Array.isArray(ports)) {
    for (const p of ports) {
      if (p && typeof p === "object" && (p as Port).id === portId) {
        return { port: p as Port, path };
      }
    }
  }

  // Recurse into children
  for (const [key, child] of Object.entries(obj)) {
    if (key === "ports") continue;
    if (key === "__type") continue;
    const childPath = path ? `${path}.${key}` : key;
    const found = walk(child, portId, childPath);
    if (found) return found;
  }
  return undefined;
}

// Convenience: count edges whose `nodeId:portId` matches a side.
// Used by the connection validator for `multi: false` enforcement.
export function countEdgesAtPort(
  edges: ReadonlyArray<{
    source: string;
    target: string;
    sourceHandle?: string | null;
    targetHandle?: string | null;
  }>,
  nodeId: string,
  portId: string,
  side: "source" | "target",
): number {
  if (side === "source") {
    return edges.filter(
      (e) => e.source === nodeId && (e.sourceHandle ?? "") === portId,
    ).length;
  }
  return edges.filter(
    (e) => e.target === nodeId && (e.targetHandle ?? "") === portId,
  ).length;
}
```

### `src/registry/components/data/flow-canvas/lib/shallow.ts`

```ts
/**
 * Inline shallow-equality helper. Matches zustand's `shallow` algorithm
 * (zustand v3/v4/v5 — algorithm unchanged) without taking on a transitive
 * dep on zustand itself. Used by xyflow's `useStore(selector, equality?)`
 * second arg where the default identity equality thrashes — e.g. a
 * selector that looks up an object in a Map and returns it: the lookup
 * resolves to the same underlying object, but xyflow's store updates the
 * Map reference on every change, so the selector returns a fresh reference
 * even when the meaningful contents are unchanged.
 *
 * Map / Set comparison is intentionally omitted — selectors in this
 * codebase never return those.
 *
 * Kept in the sealed folder (rather than importing from `zustand/shallow`)
 * for registry-portability: this avoids leaking a transitive zustand dep
 * to consumers of `@ilinxa/flow-canvas` who haven't otherwise installed
 * it. xyflow's own zustand peer is nested and doesn't surface to consumer
 * package.json. v0.2.0 Tier 2 plan F-01.
 */
export function shallow<T>(a: T, b: T): boolean {
  if (Object.is(a, b)) return true;
  if (typeof a !== "object" || a === null) return false;
  if (typeof b !== "object" || b === null) return false;
  const aKeys = Object.keys(a as object);
  const bKeys = Object.keys(b as object);
  if (aKeys.length !== bKeys.length) return false;
  for (const k of aKeys) {
    if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
    if (
      !Object.is(
        (a as Record<string, unknown>)[k],
        (b as Record<string, unknown>)[k],
      )
    ) {
      return false;
    }
  }
  return true;
}
```

### `src/registry/components/data/flow-canvas/lib/sub-object-paths.ts`

```ts
import type { NodeData } from "../types";

// Mutate `data` to remove the value at `path`. Pure: returns a new tree;
// the input is not modified. Supports dot-paths and array indices —
// `media[0]`, `slots[2].thumb`.
//
// Used by the sub-object extraction pipeline when the user holds Alt on
// drop (move semantics): the new node spawns AND the parent loses the
// sub-object via `onNodeUpdate`.

export function removeAtPath(data: NodeData, path: string): NodeData {
  const segments = parsePath(path);
  if (segments.length === 0) return data;
  const cloned = JSON.parse(JSON.stringify(data)) as NodeData;
  removeAtPathMut(cloned as unknown, segments);
  return cloned;
}

type Segment =
  | { kind: "key"; value: string }
  | { kind: "index"; value: number };

function parsePath(path: string): Segment[] {
  const out: Segment[] = [];
  const re = /([^.[\]]+)|\[(\d+)\]/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(path)) !== null) {
    if (m[1] !== undefined) out.push({ kind: "key", value: m[1] });
    else if (m[2] !== undefined) out.push({ kind: "index", value: Number(m[2]) });
  }
  return out;
}

function removeAtPathMut(cursor: unknown, segments: Segment[]): void {
  if (segments.length === 0) return;
  if (cursor === null || typeof cursor !== "object") return;
  const last = segments[segments.length - 1];
  const parents = segments.slice(0, -1);

  let parent: unknown = cursor;
  for (const seg of parents) {
    if (parent === null || typeof parent !== "object") return;
    if (seg.kind === "key") {
      parent = (parent as Record<string, unknown>)[seg.value];
    } else {
      if (!Array.isArray(parent)) return;
      parent = parent[seg.value];
    }
  }
  if (parent === null || typeof parent !== "object") return;

  if (last.kind === "index") {
    if (!Array.isArray(parent)) return;
    parent.splice(last.value, 1);
  } else {
    delete (parent as Record<string, unknown>)[last.value];
  }
}
```

### `src/registry/components/data/flow-canvas/lib/update-node-data.ts`

```ts
import type { CanvasData, NodeData } from "../types";

/**
 * Walk-and-replace the `data` field of a single node by id. Returns a new
 * `CanvasData` reference (immutable update — does NOT mutate the input).
 * Returns the input unchanged if `nodeId` is not found.
 *
 * Shipped as a typed utility so consumers wiring `onEditRequest` + a dialog
 * don't reinvent it. See the `card-tree-node` procomp guide for the
 * canonical popup-edit pattern. v0.2.1 addition.
 *
 * @example
 *   const next = updateNodeData(canvas, editingNodeId, newCardTree);
 *   setCanvas(next);
 */
export function updateNodeData(
  canvas: CanvasData,
  nodeId: string,
  nextData: NodeData,
): CanvasData {
  const idx = canvas.nodes.findIndex((n) => n.id === nodeId);
  if (idx < 0) return canvas;
  const nextNodes = canvas.nodes.slice();
  nextNodes[idx] = { ...nextNodes[idx], data: nextData };
  return { ...canvas, nodes: nextNodes };
}
```

### `src/registry/components/data/flow-canvas/parts/background.tsx`

```tsx
"use client";

import { memo } from "react";
import { Background as XyBackground, BackgroundVariant } from "@xyflow/react";
import { cn } from "@/lib/utils";
import type { FlowCanvasBackgroundConfig } from "../types";

const DEFAULT_LIGHT = {
  from: "var(--background)",
  to: "var(--muted)",
  angle: 145,
};
const DEFAULT_DARK = {
  from: "var(--background)",
  to: "var(--card)",
  angle: 145,
};

const VARIANT_MAP = {
  dots: BackgroundVariant.Dots,
  grid: BackgroundVariant.Lines,
  cross: BackgroundVariant.Cross,
} as const;

function FlowCanvasBackgroundImpl({
  config,
}: {
  config?: FlowCanvasBackgroundConfig;
}) {
  const light = { ...DEFAULT_LIGHT, ...config?.light };
  const dark = { ...DEFAULT_DARK, ...config?.dark };
  const overlay = config?.overlay ?? "dots";
  const overlayOpacity = config?.overlayOpacity ?? 0.4;

  return (
    <>
      <div
        aria-hidden
        className={cn(
          "pointer-events-none absolute inset-0 transition-opacity",
          "[background:linear-gradient(var(--flow-canvas-light-angle),var(--flow-canvas-light-from),var(--flow-canvas-light-to))]",
          "dark:[background:linear-gradient(var(--flow-canvas-dark-angle),var(--flow-canvas-dark-from),var(--flow-canvas-dark-to))]",
        )}
        style={
          {
            "--flow-canvas-light-from": light.from,
            "--flow-canvas-light-to": light.to,
            "--flow-canvas-light-angle": `${light.angle}deg`,
            "--flow-canvas-dark-from": dark.from,
            "--flow-canvas-dark-to": dark.to,
            "--flow-canvas-dark-angle": `${dark.angle}deg`,
          } as React.CSSProperties
        }
      />
      {overlay !== "none" && (
        <XyBackground
          variant={VARIANT_MAP[overlay]}
          gap={20}
          size={1}
          color="var(--border)"
          style={{ opacity: overlayOpacity }}
        />
      )}
    </>
  );
}

export const FlowCanvasBackground = memo(FlowCanvasBackgroundImpl);
```

### `src/registry/components/data/flow-canvas/parts/canvas-context-menu.tsx`

```tsx
"use client";

import {
  Copy,
  CopyPlus,
  FileJson,
  Maximize2,
  RotateCcw,
  Scissors,
  Trash2,
} from "lucide-react";
import { useCallback, useState, type ReactNode } from "react";
import { useReactFlow } from "@xyflow/react";
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuLabel,
  ContextMenuSeparator,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { findRenderer } from "../registries/renderer-registry";
import { useFlowCanvasContext } from "../registries/canvas-context";
import type {
  EdgeRecord,
  FlowCanvasProps,
  MenuItem,
  NodeData,
  NodeRecord,
} from "../types";
import { PasteDialog } from "./paste-dialog";

type Target =
  | { kind: "canvas"; point: { x: number; y: number } }
  | { kind: "node"; node: NodeRecord }
  | { kind: "edge"; edge: EdgeRecord };

type Actions = {
  duplicateNode: (id: string) => void;
  deleteNode: (id: string) => void;
  deleteEdge: (id: string) => void;
  updateNodeData: (id: string, mutate: (d: NodeData) => NodeData) => void;
  extractSubObject: (input: {
    parentId: string;
    path: string;
    gesture: "copy" | "move";
    newNode: NodeRecord;
  }) => void;
  appendNode: (node: NodeRecord) => void;
};

export function CanvasContextMenu({
  children,
  readOnly,
  menuItems,
  getNodeById,
  getEdgeById,
  actions,
}: {
  children: ReactNode;
  readOnly: boolean;
  menuItems: FlowCanvasProps["menuItems"];
  // Resolver-based lookup — avoids re-rendering the menu on every drag tick.
  // Pre-v0.1.2 took `nodes: NodeRecord[]` + `edges: EdgeRecord[]` which
  // forced a full ~200-element re-map per state update.
  getNodeById: (id: string) => NodeRecord | undefined;
  getEdgeById: (id: string) => EdgeRecord | undefined;
  actions: Actions;
}) {
  const { renderers } = useFlowCanvasContext();
  const { fitView, zoomTo, screenToFlowPosition } = useReactFlow();
  const [target, setTarget] = useState<Target | null>(null);
  const [pasteOpen, setPasteOpen] = useState(false);
  const [pastePoint, setPastePoint] = useState<{ x: number; y: number } | null>(
    null,
  );

  // Resolve what was right-clicked. The xyflow DOM exposes data-id on
  // .react-flow__node and .react-flow__edge.
  const handleContextMenu = useCallback(
    (e: React.MouseEvent<HTMLElement>) => {
      const el = e.target as HTMLElement;
      const nodeEl = el.closest("[data-id]") as HTMLElement | null;
      const isNode = nodeEl?.classList.contains("react-flow__node") ?? false;
      const edgeEl = el.closest(".react-flow__edge") as HTMLElement | null;
      const isEdge = !!edgeEl;

      if (isNode && nodeEl) {
        const id = nodeEl.getAttribute("data-id");
        const node = id ? getNodeById(id) : undefined;
        if (node) {
          setTarget({ kind: "node", node });
          return;
        }
      }
      if (isEdge && edgeEl) {
        const id = edgeEl.getAttribute("data-id");
        const edge = id ? getEdgeById(id) : undefined;
        if (edge) {
          setTarget({ kind: "edge", edge });
          return;
        }
      }
      const point = screenToFlowPosition({ x: e.clientX, y: e.clientY });
      setTarget({ kind: "canvas", point });
    },
    [getNodeById, getEdgeById, screenToFlowPosition],
  );

  const copyAsJson = useCallback((node: NodeRecord) => {
    const text = JSON.stringify(node.data, null, 2);
    if (typeof navigator !== "undefined" && navigator.clipboard) {
      navigator.clipboard.writeText(text).catch(() => undefined);
    }
  }, []);

  const convertToCustomJson = useCallback(
    (node: NodeRecord) => {
      actions.updateNodeData(node.id, (d) => ({ ...d, __type: "custom-json" }));
    },
    [actions],
  );

  const addCustomNode = useCallback(
    (point: { x: number; y: number }) => {
      const node: NodeRecord = {
        id:
          typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
            ? crypto.randomUUID()
            : `n-${Math.random().toString(36).slice(2, 10)}`,
        position: point,
        data: { __type: "custom-json", _label: "Custom JSON" },
      };
      actions.appendNode(node);
    },
    [actions],
  );

  return (
    <>
      <ContextMenu>
        {/* F-cross-13: no `asChild` — trigger is a display:contents wrapper.
            Right-click bubbles children → resolver div (sets target) →
            trigger (opens menu) in both backends. */}
        <ContextMenuTrigger className="contents">
          <div onContextMenu={handleContextMenu} className="contents">
            {children}
          </div>
        </ContextMenuTrigger>
        <ContextMenuContent>
          {target?.kind === "node" && (
            <NodeMenu
              node={target.node}
              renderers={renderers}
              readOnly={readOnly}
              consumerItems={menuItems?.node}
              onCopyJson={copyAsJson}
              onConvert={convertToCustomJson}
              onDuplicate={actions.duplicateNode}
              onDelete={actions.deleteNode}
              onExtract={(path) =>
                extractFromNode(target.node, path, actions.extractSubObject)
              }
            />
          )}
          {target?.kind === "edge" && (
            <EdgeMenu
              edge={target.edge}
              readOnly={readOnly}
              consumerItems={menuItems?.edge}
              onDelete={actions.deleteEdge}
            />
          )}
          {target?.kind === "canvas" && (
            <PaneMenu
              readOnly={readOnly}
              consumerItems={menuItems?.canvas}
              onPaste={() => {
                setPastePoint(target.point);
                setPasteOpen(true);
              }}
              onAddCustom={() => addCustomNode(target.point)}
              onFitView={() => fitView({ duration: 200 })}
              onResetZoom={() => zoomTo(1, { duration: 150 })}
            />
          )}
        </ContextMenuContent>
      </ContextMenu>
      <PasteDialog
        open={pasteOpen}
        onOpenChange={setPasteOpen}
        onSubmit={(data) => {
          if (!pastePoint) return;
          const node: NodeRecord = {
            id:
              typeof crypto !== "undefined" &&
              typeof crypto.randomUUID === "function"
                ? crypto.randomUUID()
                : `n-${Math.random().toString(36).slice(2, 10)}`,
            position: pastePoint,
            data,
          };
          actions.appendNode(node);
          setPasteOpen(false);
        }}
      />
    </>
  );
}

// ─── Pane (canvas) menu ───────────────────────────────────────────────

function PaneMenu({
  readOnly,
  consumerItems,
  onPaste,
  onAddCustom,
  onFitView,
  onResetZoom,
}: {
  readOnly: boolean;
  consumerItems?: MenuItem[];
  onPaste: () => void;
  onAddCustom: () => void;
  onFitView: () => void;
  onResetZoom: () => void;
}) {
  return (
    <>
      <ContextMenuLabel>Canvas</ContextMenuLabel>
      {!readOnly && (
        <>
          <ContextMenuItem onSelect={onPaste}>
            <FileJson aria-hidden />
            Paste JSON…
          </ContextMenuItem>
          <ContextMenuItem onSelect={onAddCustom}>
            <CopyPlus aria-hidden />
            Add custom node
          </ContextMenuItem>
          <ContextMenuSeparator />
        </>
      )}
      <ContextMenuItem onSelect={onFitView}>
        <Maximize2 aria-hidden />
        Fit view
      </ContextMenuItem>
      <ContextMenuItem onSelect={onResetZoom}>
        <RotateCcw aria-hidden />
        Reset zoom
      </ContextMenuItem>
      {consumerItems && consumerItems.length > 0 && (
        <>
          <ContextMenuSeparator />
          <ConsumerItems items={consumerItems} />
        </>
      )}
    </>
  );
}

// ─── Node menu ────────────────────────────────────────────────────────

function NodeMenu({
  node,
  renderers,
  readOnly,
  consumerItems,
  onCopyJson,
  onConvert,
  onDuplicate,
  onDelete,
  onExtract,
}: {
  node: NodeRecord;
  renderers: ReturnType<typeof useFlowCanvasContext>["renderers"];
  readOnly: boolean;
  consumerItems?: (n: NodeRecord) => MenuItem[];
  onCopyJson: (n: NodeRecord) => void;
  onConvert: (n: NodeRecord) => void;
  onDuplicate: (id: string) => void;
  onDelete: (id: string) => void;
  onExtract: (path: string) => void;
}) {
  const renderer = findRenderer(renderers, node.data.__type);
  const extractablePaths = renderer?.extractablePaths?.(node.data) ?? [];
  const consumerExtras = consumerItems ? consumerItems(node) : [];

  return (
    <>
      <ContextMenuLabel className="truncate">
        {renderer?.label ?? node.data.__type}
      </ContextMenuLabel>
      <ContextMenuItem onSelect={() => onCopyJson(node)}>
        <Copy aria-hidden />
        Copy as JSON
      </ContextMenuItem>
      {!readOnly && (
        <>
          <ContextMenuItem onSelect={() => onDuplicate(node.id)}>
            <CopyPlus aria-hidden />
            Duplicate
          </ContextMenuItem>
          {node.data.__type !== "custom-json" && (
            <ContextMenuItem onSelect={() => onConvert(node)}>
              <FileJson aria-hidden />
              Convert to custom-JSON
            </ContextMenuItem>
          )}
          {extractablePaths.length > 0 && (
            <>
              <ContextMenuSeparator />
              <ContextMenuLabel className="text-[10px]">
                Extract sub-object
              </ContextMenuLabel>
              {extractablePaths.map((path) => (
                <ContextMenuItem key={path} onSelect={() => onExtract(path)}>
                  <Scissors aria-hidden />
                  {path}
                </ContextMenuItem>
              ))}
            </>
          )}
          <ContextMenuSeparator />
          <ContextMenuItem
            variant="destructive"
            onSelect={() => onDelete(node.id)}
          >
            <Trash2 aria-hidden />
            Delete
          </ContextMenuItem>
        </>
      )}
      {consumerExtras.length > 0 && (
        <>
          <ContextMenuSeparator />
          <ConsumerItems items={consumerExtras} />
        </>
      )}
    </>
  );
}

// ─── Edge menu ────────────────────────────────────────────────────────

function EdgeMenu({
  edge,
  readOnly,
  consumerItems,
  onDelete,
}: {
  edge: EdgeRecord;
  readOnly: boolean;
  consumerItems?: (e: EdgeRecord) => MenuItem[];
  onDelete: (id: string) => void;
}) {
  const consumerExtras = consumerItems ? consumerItems(edge) : [];
  return (
    <>
      <ContextMenuLabel className="truncate">Edge</ContextMenuLabel>
      {!readOnly && (
        <ContextMenuItem
          variant="destructive"
          onSelect={() => onDelete(edge.id)}
        >
          <Trash2 aria-hidden />
          Delete
        </ContextMenuItem>
      )}
      {consumerExtras.length > 0 && (
        <>
          <ContextMenuSeparator />
          <ConsumerItems items={consumerExtras} />
        </>
      )}
    </>
  );
}

// ─── Consumer-supplied items ──────────────────────────────────────────

function ConsumerItems({ items }: { items: MenuItem[] }) {
  return (
    <>
      {items.map((item) =>
        item.separatorBefore ? (
          <ContextMenuSeparator key={`sep-${item.id}`} />
        ) : (
          <ContextMenuItem
            key={item.id}
            disabled={item.disabled}
            onSelect={item.onSelect}
          >
            {item.icon}
            {item.label}
          </ContextMenuItem>
        ),
      )}
    </>
  );
}

// ─── Helpers ──────────────────────────────────────────────────────────

function extractFromNode(
  parent: NodeRecord,
  path: string,
  extractSubObject: Actions["extractSubObject"],
) {
  // Walk parent.data at `path` to grab the sub-data, then dispatch as a
  // copy-extract (no Alt-modifier through the menu — keyboard-first
  // accessibility per Q10 / plan §3.8).
  const sub = walkPathRead(parent.data, path);
  if (!sub) return;

  const subData =
    typeof (sub as Record<string, unknown>).__type === "string"
      ? (sub as NodeData)
      : { __type: "custom-json" as const, ...(sub as Record<string, unknown>) };

  const newNode: NodeRecord = {
    id:
      typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
        ? crypto.randomUUID()
        : `n-${Math.random().toString(36).slice(2, 10)}`,
    position: { x: parent.position.x + 320, y: parent.position.y + 40 },
    data: subData,
  };
  extractSubObject({
    parentId: parent.id,
    path,
    gesture: "copy",
    newNode,
  });
}

function walkPathRead(data: unknown, path: string): unknown {
  const re = /([^.[\]]+)|\[(\d+)\]/g;
  let cursor: unknown = data;
  let m: RegExpExecArray | null;
  while ((m = re.exec(path)) !== null) {
    if (cursor === null || typeof cursor !== "object") return undefined;
    if (m[1] !== undefined) {
      cursor = (cursor as Record<string, unknown>)[m[1]];
    } else if (m[2] !== undefined) {
      if (!Array.isArray(cursor)) return undefined;
      cursor = cursor[Number(m[2])];
    }
  }
  return cursor;
}
```

### `src/registry/components/data/flow-canvas/parts/canvas.tsx`

```tsx
"use client";

// Side-effect import — selection-ring CSS keyed on xyflow's `.react-flow__node.selected`
// wrapper class. Sealed-folder file; auto-bundled by the host build (Turbopack /
// shadcn-registry). v0.2.0 Tier 2 plan §5.3.
import "../flow-canvas.css";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  ConnectionMode,
  Controls,
  ReactFlow,
  type Connection,
  type Edge as XyEdge,
  type IsValidConnection,
  type Node as XyNode,
} from "@xyflow/react";
import { cn } from "@/lib/utils";
import { useCanvasData } from "../hooks/use-canvas-data";
import { useDropPipeline } from "../hooks/use-drop-pipeline";
import { useExportHandle } from "../hooks/use-export";
import { countEdgesAtPort, findPortInTree } from "../lib/port-walker";
import { useFlowCanvasContext } from "../registries/canvas-context";
import type {
  FlowCanvasBackgroundConfig,
  FlowCanvasProps,
  NodeData,
} from "../types";
import { FlowCanvasBackground } from "./background";
import { CanvasContextMenu } from "./canvas-context-menu";
import { DefaultEdge } from "./default-edge";
import { NodeAdapter } from "./node-adapter";

// Module-scope nodeTypes / edgeTypes — required (see xyflow-react-pro skill).
// Recreating these in render would tear down + remount every node.
const NODE_TYPES = { "ilinxa-node": NodeAdapter };
const EDGE_TYPES = { "ilinxa-edge": DefaultEdge };

type CanvasInnerProps = Pick<
  FlowCanvasProps,
  | "data"
  | "defaultData"
  | "onChange"
  | "onBeforeDrop"
  | "onBeforeConnect"
  | "onNodeCreate"
  | "onNodeUpdate"
  | "onNodeDelete"
  | "onEdgeCreate"
  | "onEdgeDelete"
  | "onSubObjectExtract"
  | "menuItems"
  | "panOnDrag"
  | "zoomOnScroll"
  | "selectionMode"
  | "readOnly"
  | "onlyRenderVisibleElements"
  | "exportRef"
  | "background"
  | "className"
> & {
  ariaLabel?: string;
  backgroundConfig: FlowCanvasBackgroundConfig | undefined;
};

export function Canvas({
  data,
  defaultData,
  onChange,
  onBeforeDrop,
  onBeforeConnect,
  onNodeCreate,
  onNodeUpdate,
  onNodeDelete,
  onEdgeCreate,
  onEdgeDelete,
  onSubObjectExtract,
  menuItems,
  panOnDrag = true,
  zoomOnScroll = true,
  selectionMode = "multi",
  readOnly = false,
  onlyRenderVisibleElements = true,
  exportRef,
  ariaLabel,
  backgroundConfig,
  className,
}: CanvasInnerProps) {
  const canvas = useCanvasData({
    data,
    defaultData,
    onChange,
    onBeforeConnect,
    onNodeCreate,
    onNodeUpdate,
    onNodeDelete,
    onEdgeCreate,
    onEdgeDelete,
    onSubObjectExtract,
  });
  const { renderers } = useFlowCanvasContext();
  const drop = useDropPipeline({
    renderers,
    appendNode: canvas.appendNode,
    extractSubObject: canvas.extractSubObject,
    onBeforeDrop,
    // onNodeCreate deliberately NOT passed (v0.2.6, review 5.3):
    // canvas.appendNode fires it internally; passing it here double-fired.
  });

  useExportHandle(exportRef, canvas.snapshot);

  const interactive = !readOnly;
  const multiSelectionKeyCode = useMemo(
    () => (selectionMode === "multi" ? "Shift" : null),
    [selectionMode],
  );

  // Honor an explicit initial viewport from CanvasData. When the consumer
  // supplies one (e.g. the stress fixture starts at zoom 0.9), we hand it
  // to ReactFlow as `defaultViewport` and disable `fitView` — otherwise
  // fitView wins and every node ends up on-screen, defeating
  // `onlyRenderVisibleElements` culling. Snapshot at mount with
  // useState(initializer) so subsequent pans/zooms don't reset the viewport.
  const [initialViewport] = useState(
    () => data?.viewport ?? defaultData?.viewport,
  );

  // Refs mirror latest xyNodes/xyEdges so isValidConnection — a stable
  // <ReactFlow> prop — can read fresh state without rebuilding on every
  // drag tick. Without this, at 200+ nodes ReactFlow reconciles every
  // tick (see xyflow-react-pro skill "Performance" + the v0.1.2 perf patch).
  const nodesRef = useRef(canvas.xyNodes);
  const edgesRef = useRef(canvas.xyEdges);
  useEffect(() => {
    nodesRef.current = canvas.xyNodes;
  }, [canvas.xyNodes]);
  useEffect(() => {
    edgesRef.current = canvas.xyEdges;
  }, [canvas.xyEdges]);

  // M3 typed-connection validator. Same `type` on both ends + opposite dirs
  // (out → in). Enforces `multi: false` by counting existing edges at the
  // candidate port. Per-handle override hooks land in plan §3.4 layer 3.
  const isValidConnection = useCallback<IsValidConnection<XyEdge>>(
    (c: Connection | XyEdge) => {
      const source = c.source;
      const target = c.target;
      const sourceHandle = c.sourceHandle ?? "";
      const targetHandle = c.targetHandle ?? "";
      if (!source || !target || !sourceHandle || !targetHandle) return false;

      const nodes = nodesRef.current;
      const edges = edgesRef.current;
      const srcNode = nodes.find((n) => n.id === source);
      const tgtNode = nodes.find((n) => n.id === target);
      if (!srcNode || !tgtNode) return false;

      const srcFound = findPortInTree(srcNode.data as NodeData, sourceHandle);
      const tgtFound = findPortInTree(tgtNode.data as NodeData, targetHandle);
      if (!srcFound || !tgtFound) return false;

      // Direction: source handle must be `out`, target handle must be `in`.
      if (srcFound.port.dir !== "out" || tgtFound.port.dir !== "in") return false;

      // Type match.
      if (srcFound.port.type !== tgtFound.port.type) return false;

      // multi: false enforcement.
      if (
        !srcFound.port.multi &&
        countEdgesAtPort(edges, source, sourceHandle, "source") > 0
      ) {
        return false;
      }
      if (
        !tgtFound.port.multi &&
        countEdgesAtPort(edges, target, targetHandle, "target") > 0
      ) {
        return false;
      }
      return true;
    },
    [],
  );

  // Actions wrapper for the context menu. Stable object reference — its
  // members are already stable from useCanvasData's ref-based callbacks.
  const menuActions = useMemo(
    () => ({
      duplicateNode: canvas.duplicateNode,
      deleteNode: canvas.deleteNode,
      deleteEdge: canvas.deleteEdge,
      updateNodeData: canvas.updateNodeData,
      extractSubObject: canvas.extractSubObject,
      appendNode: canvas.appendNode,
    }),
    [
      canvas.duplicateNode,
      canvas.deleteNode,
      canvas.deleteEdge,
      canvas.updateNodeData,
      canvas.extractSubObject,
      canvas.appendNode,
    ],
  );

  return (
    <CanvasContextMenu
      readOnly={readOnly}
      menuItems={menuItems}
      getNodeById={canvas.getNodeById}
      getEdgeById={canvas.getEdgeById}
      actions={menuActions}
    >
      <div
        role="region"
        aria-label={ariaLabel ?? "Flow canvas"}
        data-flow-canvas-root=""
        tabIndex={0}
        className={cn(
          "relative h-full w-full overflow-hidden rounded-lg border border-border bg-background",
          "outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
          className,
        )}
        onDragOver={interactive ? drop.onDragOver : undefined}
        onDrop={interactive ? drop.onDrop : undefined}
        onPaste={interactive ? drop.onPaste : undefined}
      >
        <FlowCanvasBackground config={backgroundConfig} />
        <ReactFlow<XyNode<NodeData>, XyEdge>
          nodes={canvas.xyNodes}
          edges={canvas.xyEdges}
          onNodesChange={canvas.onNodesChange}
          onEdgesChange={canvas.onEdgesChange}
          onConnect={canvas.onConnect}
          onNodeDragStart={canvas.onNodeDragStart}
          onNodeDragStop={canvas.onNodeDragStop}
          onMoveEnd={canvas.onMoveEnd}
          isValidConnection={isValidConnection}
          connectionMode={ConnectionMode.Strict}
          nodeTypes={NODE_TYPES}
          edgeTypes={EDGE_TYPES}
          panOnDrag={panOnDrag}
          zoomOnScroll={zoomOnScroll}
          nodesDraggable={interactive}
          nodesConnectable={interactive}
          elementsSelectable={interactive}
          deleteKeyCode={interactive ? ["Backspace", "Delete"] : null}
          multiSelectionKeyCode={multiSelectionKeyCode}
          onlyRenderVisibleElements={onlyRenderVisibleElements}
          proOptions={{ hideAttribution: true }}
          defaultViewport={initialViewport}
          fitView={!initialViewport}
        >
          <Controls position="bottom-left" />
        </ReactFlow>
      </div>
    </CanvasContextMenu>
  );
}
```

### `src/registry/components/data/flow-canvas/parts/custom-json-node.tsx`

```tsx
"use client";

import { memo, useState } from "react";
import { cn } from "@/lib/utils";
import type { NodeData, NodeRenderer } from "../types";
import { PortsAt } from "./ports-at";

type CustomJsonData = NodeData & { _label?: string };

function deriveLabel(data: CustomJsonData): string {
  if (typeof data._label === "string" && data._label.length > 0) {
    return data._label;
  }
  if (data.__type === "custom-json") return "Custom JSON";
  return data.__type;
}

function CustomJsonNodeImpl({ data }: { data: CustomJsonData }) {
  const [expanded, setExpanded] = useState(false);
  const label = deriveLabel(data);

  // Collapsed mode is the hot path — at 200 nodes, the prior implementation
  // ran JSON.stringify on every node + rendered a CSS-truncated <pre> + the
  // whole card carried a box-shadow. Skip all three when collapsed: render
  // the label only, no <pre>, no shadow. Expanded mode keeps the full
  // stringified payload (only one node is expanded at a time in practice).
  return (
    <div
      className={cn(
        "relative min-w-45 max-w-90 rounded-md border border-border bg-card text-card-foreground",
        expanded && "shadow-sm",
      )}
    >
      <button
        type="button"
        onClick={() => setExpanded((v) => !v)}
        className={cn(
          "flex w-full items-center justify-between gap-2 px-3 py-2",
          "text-left text-xs font-medium",
          expanded && "border-b border-border",
        )}
      >
        <span className="truncate font-mono text-muted-foreground">
          {label}
        </span>
        <span aria-hidden className="text-muted-foreground">
          {expanded ? "−" : "+"}
        </span>
      </button>
      {expanded && (
        <pre className="max-h-80 overflow-auto whitespace-pre p-3 font-mono text-[11px] leading-snug text-muted-foreground">
          <code>{JSON.stringify(data, null, 2)}</code>
        </pre>
      )}
      {/* Render port handles for any ports declared in data.ports. Without
       * these, xyflow's edge layer fails getEdgePosition() for every edge
       * referencing this node and logs hundreds of warnings per frame.
       * Discovered via 2026-05-14 baseline measurement — the cliff at
       * N≥100 in vis-off was warning-spam CPU cost, not React reconciliation
       * cost. v0.1.4 patch. */}
      <PortsAt ports={data.ports} position="left" />
      <PortsAt ports={data.ports} position="right" />
      <PortsAt ports={data.ports} position="top" />
      <PortsAt ports={data.ports} position="bottom" />
    </div>
  );
}

export const CustomJsonNode = memo(CustomJsonNodeImpl);

export const customJsonRenderer: NodeRenderer<CustomJsonData> = {
  type: "custom-json",
  label: "Custom JSON",
  render: (data) => <CustomJsonNode data={data} />,
};
```

### `src/registry/components/data/flow-canvas/parts/default-edge.tsx`

```tsx
"use client";

import { memo } from "react";
import {
  BaseEdge,
  getSmoothStepPath,
  useStore,
  type EdgeProps,
} from "@xyflow/react";
import { findPortInTree } from "../lib/port-walker";
import { usePortType } from "../registries/port-type-registry";
import type { EdgeRenderer, NodeData, Port } from "../types";

// M3 polish: pull the source port's type color and use it as the edge
// stroke. Each connection gets the visual identity of its data type — text
// edges = blue, image = emerald, card = lime — making the graph readable at
// a glance without requiring a legend.

// Narrow comparator for the source-port useStore selector. The returned
// shape is `Port | undefined`; only three fields (`id`, `type`, `multi`)
// affect this renderer — `type` drives the stroke color via portType
// lookup; `id` and `multi` are kept stable for identity guards. Without
// this, xyflow's store updates the nodeLookup Map reference per change
// and the default identity equality re-runs the selector + re-renders the
// edge for every node change anywhere in the canvas. v0.2.0 Tier 2 plan F-01.
const portEqual = (a?: Port, b?: Port) =>
  a === b || (a?.id === b?.id && a?.type === b?.type && a?.multi === b?.multi);

function DefaultEdgeImpl(props: EdgeProps) {
  const {
    id,
    source,
    sourceHandleId,
    sourceX,
    sourceY,
    targetX,
    targetY,
    sourcePosition,
    targetPosition,
    markerStart,
    markerEnd,
    style,
    selected,
  } = props;

  // Reactive lookup of the source port via xyflow's store. Recomputes if the
  // source node's data tree changes. `portEqual` narrows re-render triggers
  // to the three fields this renderer actually reads (see comparator above).
  const sourcePort: Port | undefined = useStore(
    (s) => {
      if (!source || !sourceHandleId) return undefined;
      const node = s.nodeLookup.get(source);
      if (!node) return undefined;
      return findPortInTree(node.data as NodeData, sourceHandleId)?.port;
    },
    portEqual,
  );

  const portType = usePortType(sourcePort?.type ?? "");
  const color = portType?.color;

  const [path] = getSmoothStepPath({
    sourceX,
    sourceY,
    targetX,
    targetY,
    sourcePosition,
    targetPosition,
    borderRadius: 8,
  });

  return (
    <BaseEdge
      id={id}
      path={path}
      markerStart={markerStart}
      markerEnd={markerEnd}
      style={{
        stroke: selected ? "var(--ring)" : (color ?? "var(--xy-edge-stroke)"),
        strokeWidth: selected ? 2.5 : 1.5,
        ...style,
      }}
    />
  );
}

export const DefaultEdge = memo(DefaultEdgeImpl);

export const defaultEdgeRenderer: EdgeRenderer = {
  type: "smoothstep",
  label: "Smooth step",
  // For consumer-facing dispatch (M3 polish has wired this internally — the
  // single 'ilinxa-edge' xyflow type calls DefaultEdge directly). Custom
  // edge renderers via the edge-type registry are exposed in v0.2.
  render: () => null,
};
```

### `src/registry/components/data/flow-canvas/parts/node-adapter.tsx`

```tsx
"use client";

import { memo, useCallback, useMemo } from "react";
import type { NodeProps } from "@xyflow/react";
import { useFlowCanvasContext } from "../registries/canvas-context";
import { findRenderer } from "../registries/renderer-registry";
import type { NodeData, NodeRenderer, RenderContext } from "../types";
import { customJsonRenderer } from "./custom-json-node";
import { NodeShell } from "./node-shell";

// Build a self-referential renderChild via function declaration. Function
// declarations are hoisted within their own body so `rc` can reference itself
// without React Compiler's temporal-dead-zone warning.
function makeRenderChild(
  renderers: NodeRenderer[],
  nodeId: string,
  readOnly: boolean,
): RenderContext["renderChild"] {
  function rc(childData: NodeData) {
    const childRenderer =
      findRenderer(renderers, childData.__type) ?? customJsonRenderer;
    const childCtx: RenderContext = {
      nodeId,
      isSelected: false,
      isDragging: false,
      isReadOnly: readOnly,
      renderChild: rc,
    };
    return childRenderer.render(childData, childCtx);
  }
  return rc;
}

function NodeAdapterImpl(props: NodeProps) {
  const { id, data, selected, dragging, draggable } = props;
  const { renderers, readOnly, onEditRequest } = useFlowCanvasContext();

  const nodeData = data as NodeData;
  const renderer =
    findRenderer(renderers, nodeData.__type) ?? customJsonRenderer;

  const renderChild = useMemo(
    () => makeRenderChild(renderers, id, readOnly),
    [renderers, id, readOnly],
  );

  // v0.2.1 — bind canvas-level onEditRequest to this node's id so renderers
  // fire `ctx.onEditRequest(subPath?)` without knowing their own nodeId.
  // `onEditRequest` from context is itself stable (ref-mirrored in
  // flow-canvas.tsx, F-V5 lock); identity flips only on wired/unwired.
  const handleEditRequest = useCallback(
    (subPath?: string) => onEditRequest?.(id, subPath),
    [onEditRequest, id],
  );

  const ctx: RenderContext = {
    nodeId: id,
    isSelected: !!selected,
    isDragging: !!dragging,
    isReadOnly: readOnly,
    renderChild,
    // Leave undefined when consumer hasn't wired onEditRequest so renderers
    // can gate their click affordances via `if (ctx.onEditRequest)`.
    onEditRequest: onEditRequest ? handleEditRequest : undefined,
  };

  return (
    <NodeShell isLocked={draggable === false}>
      {renderer.render(nodeData, ctx)}
    </NodeShell>
  );
}

export const NodeAdapter = memo(NodeAdapterImpl);
```

### `src/registry/components/data/flow-canvas/parts/node-shell.tsx`

```tsx
"use client";

import { Lock } from "lucide-react";
import { memo, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { NodeRecord } from "../types";

// Wraps every consumer renderer's output. Owns:
// - Lock chip (when node.locked is true)
// - Focus-visible outline for keyboard navigation
//
// Consumer renderers focus on content + handles; the shell handles chrome.
//
// v0.2.0 Tier 2 (plan §5.3 + F-02): selection ring decoupled from this
// component. It now lives in the sealed-folder `flow-canvas.css` keyed
// on xyflow's own `.react-flow__node.selected` class — pure CSS, no React
// prop / re-render coupling for the visual. RenderContext.isSelected is
// still populated for renderers that opt into selection-conditional content.
function NodeShellImpl({
  isLocked,
  className,
  children,
}: {
  isLocked: boolean;
  className?: string;
  children: ReactNode;
}) {
  return (
    <div
      data-locked={isLocked}
      tabIndex={0}
      className={cn(
        "relative outline-none",
        // Focus-visible ring stays here — xyflow doesn't own focus state.
        // Tailwind v4 canonical `*:` shorthand (replaces v3's `[&>*]:`).
        "focus-visible:*:ring-2 focus-visible:*:ring-ring/60",
        className,
      )}
    >
      {children}
      {isLocked ? (
        <span
          aria-label="Locked"
          title="Locked"
          className={cn(
            "absolute -right-1 -top-1 z-10 grid h-5 w-5 place-items-center",
            "rounded-full border border-border bg-card text-muted-foreground shadow-sm",
          )}
        >
          <Lock aria-hidden className="h-3 w-3" />
        </span>
      ) : null}
    </div>
  );
}

export const NodeShell = memo(NodeShellImpl);

// Helper: did the xyflow node arrive with locked: true?
// (NodeRecord.locked maps to xyflow draggable: false on the way out; the
// adapter passes the flag explicitly.)
export type NodeShellOf = (n: NodeRecord) => { isLocked: boolean };
```

### `src/registry/components/data/flow-canvas/parts/paste-dialog.tsx`

```tsx
"use client";

import { useState } from "react";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { coerceToNodeData } from "../lib/coerce-to-node-data";
import { parseJsonSafe } from "../lib/parse-json";
import type { NodeData } from "../types";

export function PasteDialog({
  open,
  onOpenChange,
  onSubmit,
}: {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  onSubmit: (data: NodeData) => void;
}) {
  const [text, setText] = useState("");
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = () => {
    setError(null);
    const parsed = parseJsonSafe(text);
    if (parsed === undefined) {
      setError("Could not parse — make sure the input is valid JSON.");
      return;
    }
    const data = coerceToNodeData(parsed);
    if (!data) {
      setError("JSON must be an object (arrays and primitives aren't nodes).");
      return;
    }
    onSubmit(data);
    setText("");
  };

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Paste JSON</DialogTitle>
          <DialogDescription>
            Paste a JSON object to spawn it as a node. Unknown shapes render as
            a custom-JSON node automatically.
          </DialogDescription>
        </DialogHeader>
        <Textarea
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder={`{"__type":"prompt","template":"Translate to French"}`}
          className="h-40 font-mono text-xs"
          autoFocus
        />
        {error && (
          <p className="text-xs text-destructive" role="alert">
            {error}
          </p>
        )}
        <DialogFooter>
          <Button variant="ghost" onClick={() => onOpenChange(false)}>
            Cancel
          </Button>
          <Button onClick={handleSubmit} disabled={!text.trim()}>
            Add node
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```

### `src/registry/components/data/flow-canvas/parts/port-handle.tsx`

```tsx
"use client";

import { Handle, Position, type IsValidConnection } from "@xyflow/react";
import { memo, type CSSProperties } from "react";
import { cn } from "@/lib/utils";
import { usePortType } from "../registries/port-type-registry";
import type { Port } from "../types";

const POSITION_MAP = {
  left: Position.Left,
  right: Position.Right,
  top: Position.Top,
  bottom: Position.Bottom,
} as const;

// Wraps xyflow's <Handle /> with our typed-port semantics:
// - Color comes from the port-type registry (via usePortType).
// - id is forced (xyflow needs unique handle ids per node when same type).
// - type is derived from port.dir ('out' → 'source', 'in' → 'target').
// - position from port.side via POSITION_MAP.
//
// In M2 the handle renders correctly. M3 wires `isValidConnection` on the
// handle to the global typed-connection validator + multi-edge enforcement.
function PortHandleImpl({
  port,
  isValidConnection,
  className,
  style,
}: {
  port: Port;
  isValidConnection?: IsValidConnection;
  className?: string;
  style?: CSSProperties;
}) {
  const portType = usePortType(port.type);
  const color = portType?.color ?? "var(--muted-foreground)";

  return (
    <Handle
      id={port.id}
      type={port.dir === "out" ? "source" : "target"}
      position={POSITION_MAP[port.side]}
      isValidConnection={isValidConnection}
      aria-label={`${port.label ?? port.id} (${port.dir} ${port.type})`}
      className={cn("h-2.5! w-2.5! border-2!", className)}
      style={{
        background: color,
        borderColor: "var(--background)",
        ...style,
      }}
    />
  );
}

export const PortHandle = memo(PortHandleImpl);
```

### `src/registry/components/data/flow-canvas/parts/ports-at.tsx`

```tsx
"use client";

import { memo, type CSSProperties } from "react";
import type { Port, PortSide, PortsAtProps } from "../types";
import { PortHandle } from "./port-handle";

// Layout helper: stacks handles along an edge of the renderer's bounding
// box. Handles are direct DOM children of the node (xyflow's <Handle> is
// `position: absolute` and uses its own `position` prop to anchor; wrapping
// it in another absolute div would break that). We stack by computing
// `top` (for left/right edges) or `left` (for top/bottom edges) inline.
//
// For the easy case — a node has 1–N ports on each edge, evenly stacked.
// For complex layouts (per-sub-object positioning), emit <PortHandle /> in
// the renderer directly with your own style overrides.

function isVertical(side: PortSide): boolean {
  return side === "left" || side === "right";
}

function offsetFor(
  position: PortSide,
  index: number,
  count: number,
  spacing: number,
): CSSProperties {
  // Centered stack: middle item at 50%, others spaced by `spacing` px.
  const centered = (index - (count - 1) / 2) * spacing;
  if (isVertical(position)) {
    return { top: `calc(50% + ${centered}px)` };
  }
  return { left: `calc(50% + ${centered}px)` };
}

function PortsAtImpl({ ports, position, spacing = 24 }: PortsAtProps) {
  if (!ports || ports.length === 0) return null;
  const filtered = ports.filter((p: Port) => p.side === position);
  if (filtered.length === 0) return null;

  return (
    <>
      {filtered.map((p, i) => (
        <PortHandle
          key={p.id}
          port={p}
          style={offsetFor(position, i, filtered.length, spacing)}
        />
      ))}
    </>
  );
}

export const PortsAt = memo(PortsAtImpl);
```

### `src/registry/components/data/flow-canvas/registries/canvas-context.tsx`

```tsx
"use client";

import { createContext, useContext, type ReactNode } from "react";
import type {
  EdgeRenderer,
  NodeRecord,
  NodeRenderer,
  PortType,
} from "../types";

export type FlowCanvasContextValue = {
  renderers: NodeRenderer[];
  portTypes: PortType[];
  edgeTypes: EdgeRenderer[];
  readOnly: boolean;
  selectionMode: "single" | "multi";
  onNodeUpdate?: (node: NodeRecord) => void;
  // v0.2.1 — bubbled up from a renderer's ctx.onEditRequest call.
  // Identity is stabilized inside flow-canvas.tsx via ref-mirror so consumers
  // passing inline `onEditRequest={...}` don't cascade re-renders across every
  // NodeAdapter. Identity only flips on the wired/unwired transition.
  onEditRequest?: (nodeId: string, subPath?: string) => void;
};

export const FlowCanvasContext = createContext<FlowCanvasContextValue | null>(
  null,
);

export function useFlowCanvasContext(): FlowCanvasContextValue {
  const ctx = useContext(FlowCanvasContext);
  if (!ctx) {
    throw new Error(
      "useFlowCanvasContext must be used inside <FlowCanvas /> — wrap your tree in the FlowCanvas component before calling registry hooks.",
    );
  }
  return ctx;
}

export function FlowCanvasContextProvider({
  value,
  children,
}: {
  value: FlowCanvasContextValue;
  children: ReactNode;
}) {
  return (
    <FlowCanvasContext.Provider value={value}>
      {children}
    </FlowCanvasContext.Provider>
  );
}
```

### `src/registry/components/data/flow-canvas/registries/edge-type-registry.tsx`

```tsx
"use client";

import type { EdgeRenderer } from "../types";
import { defaultEdgeRenderer } from "../parts/default-edge";
import { useFlowCanvasContext } from "./canvas-context";

export { defaultEdgeRenderer };

export function findEdgeType(
  edgeTypes: EdgeRenderer[],
  type: string,
): EdgeRenderer | undefined {
  return edgeTypes.find((t) => t.type === type);
}

export function useEdgeType(type: string): EdgeRenderer | undefined {
  const { edgeTypes } = useFlowCanvasContext();
  return findEdgeType(edgeTypes, type);
}

export function mergeEdgeTypes(
  consumer: EdgeRenderer[] | undefined,
): EdgeRenderer[] {
  const merged: EdgeRenderer[] = [defaultEdgeRenderer];
  const seen = new Set<string>([defaultEdgeRenderer.type]);
  for (const t of consumer ?? []) {
    if (seen.has(t.type) && process.env.NODE_ENV !== "production") {
      console.warn(
        `[flow-canvas] edge type collision for "${t.type}" — last-wins`,
      );
    }
    const idx = merged.findIndex((m) => m.type === t.type);
    if (idx >= 0) merged[idx] = t;
    else merged.push(t);
    seen.add(t.type);
  }
  return merged;
}
```

### `src/registry/components/data/flow-canvas/registries/port-type-registry.ts`

```ts
"use client";

import type { PortType } from "../types";
import { useFlowCanvasContext } from "./canvas-context";

// Built-in port-type palette. Maps to design tokens, not raw colors.
// — data:  neutral grey (untyped / generic)
// — text:  blue   (--chart-5)
// — image: emerald (--chart-2)
// — card:  signal-lime (--primary, the accent)
// — event: cyan  (--chart-4)
// — doc:   teal  (--chart-3) — v0.2.5 add; bottom-only side enforced editor-side
//                              by @ilinxa/card-tree-node's PortEditorStrip
//                              (no runtime enforcement here)
//
// Color descriptions in the description doc said "blue / orange / lime / rose" —
// the actual chart palette is lime → emerald → teal → cyan → blue, so we map to
// the closest semantic neighbor. Consumers override via the `portTypes` prop.
export const defaultPortTypes: PortType[] = [
  { id: "data", color: "var(--muted-foreground)", label: "Data" },
  { id: "text", color: "var(--chart-5)", label: "Text" },
  { id: "image", color: "var(--chart-2)", label: "Image" },
  { id: "card", color: "var(--primary)", label: "Card" },
  { id: "event", color: "var(--chart-4)", label: "Event" },
  { id: "doc", color: "var(--chart-3)", label: "Doc" },
];

export function findPortType(
  portTypes: PortType[],
  id: string,
): PortType | undefined {
  return portTypes.find((t) => t.id === id);
}

export function usePortType(id: string): PortType | undefined {
  const { portTypes } = useFlowCanvasContext();
  return findPortType(portTypes, id);
}
```

### `src/registry/components/data/flow-canvas/registries/renderer-registry.tsx`

```tsx
"use client";

import type { NodeRenderer } from "../types";
import { customJsonRenderer } from "../parts/custom-json-node";
import { useFlowCanvasContext } from "./canvas-context";

export { customJsonRenderer };

export function findRenderer(
  renderers: NodeRenderer[],
  type: string,
): NodeRenderer | undefined {
  return renderers.find((r) => r.type === type);
}

export function useRenderer(type: string): NodeRenderer | undefined {
  const { renderers } = useFlowCanvasContext();
  return findRenderer(renderers, type);
}

// Merges built-in 'custom-json' renderer with consumer-supplied renderers.
// Last-wins on collision (consumer can deliberately override). Dev-mode warns.
export function mergeRenderers(
  consumer: NodeRenderer[] | undefined,
): NodeRenderer[] {
  const merged: NodeRenderer[] = [customJsonRenderer];
  const seen = new Set<string>([customJsonRenderer.type]);
  for (const r of consumer ?? []) {
    if (seen.has(r.type) && process.env.NODE_ENV !== "production") {
      console.warn(
        `[flow-canvas] renderer collision for "${r.type}" — last-wins`,
      );
    }
    const idx = merged.findIndex((m) => m.type === r.type);
    if (idx >= 0) merged[idx] = r;
    else merged.push(r);
    seen.add(r.type);
  }
  return merged;
}
```
