{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-tree-node",
  "title": "Card Tree Node",
  "author": "ilinxa",
  "description": "Card-tree renderer for flow canvas nodes — read-only viewer, a consumer-owned edit dialog pattern, and a typed port editor strip.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "popover",
    "select",
    "checkbox",
    "input",
    "label",
    "button",
    "@ilinxa/card-tree",
    "@ilinxa/flow-canvas"
  ],
  "files": [
    {
      "path": "src/registry/components/data/card-tree-node/index.ts",
      "content": "export { cardTreeViewerRenderer } from \"./parts/card-tree-viewer\";\n\n// v0.2 — PortEditorStrip + types (rcif-internal symbols, safe to re-export\n// from the barrel per F-09 lock — only cross-procomp re-exports trip the\n// shadcn path rewriter F-S1 bug).\nexport {\n  PortEditorStrip,\n  type PortEditorStripProps,\n} from \"./parts/port-editor-strip\";\n\n// Type re-exports for consumers writing typed canvas data\nexport type {\n  CardTreeCanvasNode, // F-V6 lock — canvas-node form\n  PortEditorPermissions, // v0.2 — for typed consumer permission predicates\n  PortField, // v0.2 — for typed canEditPortField bodies\n} from \"./types\";\n\n// F-S1 lock (per json-form v0.1.4 smoke precedent + extended via card-tree-in-\n// flow's smoke surfacing): cross-procomp re-exports from a barrel index.ts get\n// mis-rewritten by shadcn's path rewriter — observed broken outputs include\n// `@/components/data/card-tree/types` (preserves `data/`) and\n// `@/lib/update-node-data` (strips most of the path). Workaround: DROP the\n// cross-procomp re-exports here entirely. Consumers import from each procomp\n// directly:\n//\n//   import { cardTreeViewerRenderer, type CardTreeCanvasNode } from \"@ilinxa/card-tree-node\";\n//   import type { CardTreeJsonNode } from \"@ilinxa/card-tree\";\n//   import { updateNodeData } from \"@ilinxa/flow-canvas\";\n//\n// One extra import, much more robust against the rewriter. Documented in\n// usage.tsx + the Stage 3 procomp guide.\n",
      "type": "registry:component",
      "target": "components/card-tree-node/index.ts"
    },
    {
      "path": "src/registry/components/data/card-tree-node/types.ts",
      "content": "// F-S1 lock (extended by card-tree-node's v0.1.0 smoke): use RELATIVE\n// imports for cross-procomp types — shadcn's path rewriter has a bug where\n// same-category cross-procomp imports of `<other-slug>/types` get the\n// current procomp's slug substituted (`flow-canvas/types` →\n// `card-tree-node/types`). Relative paths bypass the alias rewriter and\n// translate verbatim through the producer→consumer tree (both have sibling\n// procomp dirs at the same level).\nimport type { NodeData } from \"../flow-canvas/types\";\nimport type { CardTreeJsonNode } from \"../card-tree/types\";\n\n// Public type re-export for consumer convenience\n// (per Stage 1 description §3 \"Type re-exports\" in-scope)\nexport type { CardTreeJsonNode } from \"../card-tree/types\";\n\n/**\n * The canvas-node form of a card-tree tree — intersection of `NodeData` (which\n * the flow-canvas renderer registry requires; `__type: string` + optional\n * `ports?: Port[]`) with card-tree's open-shape `CardTreeJsonNode` (`__rcid?` /\n * `__rcorder?` / `__rcmeta?` + index signature).\n *\n * Consumers writing typed canvas data should type their card-tree-bearing nodes\n * as `NodeRecord & { data: CardTreeCanvasNode }`. The renderer is registered as\n * `NodeRenderer<CardTreeCanvasNode>` (see parts/card-tree-viewer.tsx).\n *\n * F-V6 lock — see procomp plan §3.5 + §5.2. Precedent: `customJsonRenderer`'s\n * `type CustomJsonData = NodeData & { _label?: string }` in flow-canvas.\n */\nexport type CardTreeCanvasNode = NodeData & CardTreeJsonNode;\n\n/**\n * Flat-field value classification for the viewer's type-aware rendering.\n * Used by `lib/derive-flat-fields.ts` + `lib/format-value.ts`.\n */\nexport type FlatFieldType = \"string\" | \"number\" | \"boolean\" | \"date\";\n\nexport type FlatField = {\n  key: string;\n  value: unknown;\n  type: FlatFieldType;\n};\n\n/* ───────── v0.2 — PortEditorStrip ───────── */\n\n/**\n * Editable port fields that consumer permission predicates can gate\n * individually via `PortEditorPermissions.canEditPortField`. Mirrors the\n * mutable subset of `Port` (id + type + side + dir + multi + label).\n *\n * v0.2.0 addition.\n */\nexport type PortField = \"type\" | \"side\" | \"dir\" | \"multi\" | \"label\" | \"id\";\n\n/**\n * Optional consumer-supplied predicates that gate port editing affordances\n * in `<PortEditorStrip>`. Default: everything allowed when `editable=true`.\n * Same predicate-shape pattern as card-tree's permission predicates.\n *\n * v0.2.0 addition.\n */\nexport type PortEditorPermissions = {\n  canAddPort?: (cardId: string) => boolean;\n  canRemovePort?: (cardId: string, portId: string) => boolean;\n  canEditPort?: (cardId: string, portId: string) => boolean;\n  canEditPortField?: (cardId: string, portId: string, field: PortField) => boolean;\n};\n",
      "type": "registry:component",
      "target": "components/card-tree-node/types.ts"
    },
    {
      "path": "src/registry/components/data/card-tree-node/lib/derive-flat-fields.ts",
      "content": "// F-S1 lock — RELATIVE import for cross-procomp types. Same-category alias\n// imports get the slug name substituted by shadcn's rewriter; relative paths\n// bypass that and translate verbatim.\nimport type { CardTreeJsonNode } from \"../../card-tree/types\";\nimport type { FlatField, FlatFieldType } from \"../types\";\n\nconst RESERVED_PREFIX = \"__rc\";\nconst SKIP_KEYS = new Set([\"__type\", \"ports\", \"title\"]);\n\n// ISO-8601 date detection. Accepts:\n//   2024-05-16\n//   2024-05-16T12:30:00\n//   2024-05-16T12:30:00.123Z\n//   2024-05-16T12:30:00+02:00\nconst ISO_DATE_RE =\n  /^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:?\\d{2})?)?$/;\n\n/**\n * Return the first N \"flat field\" entries from a card-tree tree, in\n * `Object.entries` order. Skips:\n *   - card-tree metadata (`__rcid` / `__rcorder` / `__rcmeta`)\n *   - canvas discriminators (`__type`)\n *   - the title (rendered separately by the title strip)\n *   - port arrays (`ports`)\n *   - nested cards (anything that `enumerateSubcards` would pick up)\n *\n * Type detection:\n *   - `boolean` primitive  → \"boolean\"\n *   - `number` primitive   → \"number\"\n *   - `string` matching ISO-8601 → \"date\"\n *   - `string` otherwise → \"string\"\n *   - other (object / array / null / undefined) → not a flat field, skipped\n */\nexport function deriveFlatFields(\n  data: CardTreeJsonNode,\n  max: number,\n): FlatField[] {\n  const out: FlatField[] = [];\n\n  for (const [key, value] of Object.entries(data)) {\n    if (out.length >= max) break;\n    if (key.startsWith(RESERVED_PREFIX)) continue;\n    if (SKIP_KEYS.has(key)) continue;\n\n    const type = classifyFlatValue(value);\n    if (!type) continue;\n\n    out.push({ key, value, type });\n  }\n\n  return out;\n}\n\nfunction classifyFlatValue(value: unknown): FlatFieldType | undefined {\n  if (typeof value === \"boolean\") return \"boolean\";\n  if (typeof value === \"number\" && Number.isFinite(value)) return \"number\";\n  if (typeof value === \"string\") {\n    if (ISO_DATE_RE.test(value)) return \"date\";\n    return \"string\";\n  }\n  return undefined;\n}\n",
      "type": "registry:component",
      "target": "components/card-tree-node/lib/derive-flat-fields.ts"
    },
    {
      "path": "src/registry/components/data/card-tree-node/lib/derive-title.ts",
      "content": "// F-S1 lock — RELATIVE import for cross-procomp types. Same-category alias\n// imports get the slug name substituted by shadcn's rewriter; relative paths\n// bypass that and translate verbatim.\nimport type { CardTreeJsonNode } from \"../../card-tree/types\";\n\n// Skip these keys when scanning for the title fallback.\nconst RESERVED_PREFIX = \"__rc\"; // __rcid / __rcorder / __rcmeta\nconst SKIP_KEYS = new Set([\"__type\", \"ports\"]);\n\n/**\n * Derive a viewer title from a card-tree tree.\n *\n * Order of precedence (per plan §5.5):\n *  1. `data.title` if it's a non-empty string.\n *  2. The first non-reserved string flat field by `Object.entries` order.\n *  3. `undefined` (caller renders a neutral placeholder, e.g. \"Untitled card-tree\").\n */\nexport function deriveTitle(data: CardTreeJsonNode): string | undefined {\n  const t = data.title;\n  if (typeof t === \"string\" && t.length > 0) return t;\n\n  for (const [key, value] of Object.entries(data)) {\n    if (key.startsWith(RESERVED_PREFIX)) continue;\n    if (SKIP_KEYS.has(key)) continue;\n    if (typeof value === \"string\" && value.length > 0) return value;\n  }\n\n  return undefined;\n}\n",
      "type": "registry:component",
      "target": "components/card-tree-node/lib/derive-title.ts"
    },
    {
      "path": "src/registry/components/data/card-tree-node/lib/enumerate-subcards.ts",
      "content": "// F-S1 lock — RELATIVE import for cross-procomp types. Same-category alias\n// imports get the slug name substituted by shadcn's rewriter; relative paths\n// bypass that and translate verbatim.\nimport type { CardTreeJsonNode } from \"../../card-tree/types\";\n\n/**\n * Walk `data` shallow (depth 1) and return the entries that look like nested\n * card-tree subcards. v0.1 of this helper is intentionally heuristic — card-tree\n * uses an open-shape `CardTreeJsonNode` (`[key: string]: unknown`); there is no\n * canonical \"is-card\" predicate exported from card-tree today. v0.2 may\n * tighten if card-tree ships such a predicate (F-04 lock in procomp plan §3).\n *\n * Keep this helper PRIVATE in v0.1 — its signature depends on the heuristic\n * which is marked for tightening. Re-exporting from `index.ts` would freeze\n * the signature; see plan §10 (F-rev-3) for the revisit trigger.\n */\nexport function enumerateSubcards(\n  data: CardTreeJsonNode,\n): Array<{ key: string; card: CardTreeJsonNode }> {\n  const out: Array<{ key: string; card: CardTreeJsonNode }> = [];\n\n  for (const [key, value] of Object.entries(data)) {\n    if (key.startsWith(\"__rc\")) continue; // skip __rcid / __rcorder / __rcmeta\n    if (key === \"__type\") continue; // canvas discriminator (when present)\n    if (key === \"ports\") continue; // ports handled by port-walker / PortsAt\n    if (!isCardLike(value)) continue;\n    out.push({ key, card: value });\n  }\n\n  return out;\n}\n\nfunction isCardLike(value: unknown): value is CardTreeJsonNode {\n  if (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n  const obj = value as Record<string, unknown>;\n  // F-04 heuristic: an object value is \"card-like\" when it carries any\n  // card-tree metadata OR its own ports array.\n  return (\n    obj.__rcid !== undefined ||\n    obj.__rcorder !== undefined ||\n    obj.__rcmeta !== undefined ||\n    Array.isArray(obj.ports)\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree-node/lib/enumerate-subcards.ts"
    },
    {
      "path": "src/registry/components/data/card-tree-node/lib/format-value.ts",
      "content": "import type { FlatFieldType } from \"../types\";\n\nconst NUMBER_FORMAT = new Intl.NumberFormat(undefined);\nconst DATE_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: \"short\" });\n\n/**\n * Render a flat field's value as a display string. Type-aware:\n *   - `number`  → locale-aware separator formatting (`1,234.56`)\n *   - `boolean` → \"✓\" / \"—\" (true / false). Dashboards-friendly; avoids the\n *     ambiguity of \"Yes\"/\"No\" without consuming the field-key column.\n *   - `date`    → short locale date (`5/16/26` US, `16/05/2026` EU)\n *   - `string`  → as-is\n */\nexport function formatValue(value: unknown, type: FlatFieldType): string {\n  switch (type) {\n    case \"number\":\n      return typeof value === \"number\" ? NUMBER_FORMAT.format(value) : String(value);\n    case \"boolean\":\n      return value === true ? \"✓\" : \"—\";\n    case \"date\": {\n      if (typeof value !== \"string\") return String(value);\n      const d = new Date(value);\n      if (Number.isNaN(d.getTime())) return value;\n      return DATE_FORMAT.format(d);\n    }\n    case \"string\":\n    default:\n      return typeof value === \"string\" ? value : String(value);\n  }\n}\n",
      "type": "registry:component",
      "target": "components/card-tree-node/lib/format-value.ts"
    },
    {
      "path": "src/registry/components/data/card-tree-node/lib/find-port-target.ts",
      "content": "// F-S1 lock — RELATIVE imports for cross-procomp types/helpers. Same-category\n// alias imports get the slug name substituted by shadcn's rewriter; relative\n// paths bypass that and translate verbatim.\nimport type {\n  CanvasData,\n  NodeData,\n  NodeRecord,\n  Port,\n} from \"../../flow-canvas/types\";\nimport { updateNodeData } from \"../../flow-canvas/lib/update-node-data\";\nimport type { CardTreeJsonNode } from \"../../card-tree/types\";\n\n/**\n * The card-level slot that `<PortEditorStrip>` targets — either the root\n * `node.data` (when subPath is undefined) or a nested subcard located by\n * matching `__rcid`. Includes a closure that produces an updated CanvasData\n * when given a new ports[] array.\n *\n * Walker logic mirrors `lib/enumerate-subcards.ts`'s heuristic (skip __rc-\n * prefixed keys, skip \"__type\", skip \"ports\"; only descend into objects\n * that look card-like).\n *\n * v0.2.0 addition.\n */\nexport type PortTarget = {\n  node: NodeRecord;\n  cardData: CardTreeJsonNode;\n  cardRcid: string | undefined;\n  ports: Port[];\n  /**\n   * Closure that walks the same path back through the tree, replaces the\n   * `ports` array at that level, and returns a new `CanvasData` via\n   * `updateNodeData`. Pure — does not mutate the input canvas.\n   */\n  updateIn: (next: Port[]) => CanvasData;\n};\n\n/**\n * Resolve the (node, card-by-rcid, ports, updater-closure) tuple for a given\n * `(nodeId, subPath?)` pair. Returns `null` when:\n *   - The node id is not found in `canvas.nodes`\n *   - `subPath` is defined but no descendant card has matching `__rcid`\n *\n * Callers (the strip) render an empty-state when this returns null — common\n * path during dialog open transitions before canvas state settles.\n */\nexport function findPortTarget(\n  canvas: CanvasData,\n  nodeId: string,\n  subPath?: string,\n): PortTarget | null {\n  const node = canvas.nodes.find((n) => n.id === nodeId);\n  if (!node) return null;\n\n  const rootData = node.data as CardTreeJsonNode;\n\n  // No subPath → target the root card (node.data itself).\n  if (subPath === undefined) {\n    return makeTarget(canvas, node, rootData, []);\n  }\n\n  // subPath defined → recursive walk for the descendant card with matching __rcid.\n  const path = findCardPath(rootData, subPath, []);\n  if (path === null) return null;\n\n  const subData = walkPath(rootData, path);\n  if (!subData) return null;\n  return makeTarget(canvas, node, subData, path);\n}\n\nfunction makeTarget(\n  canvas: CanvasData,\n  node: NodeRecord,\n  cardData: CardTreeJsonNode,\n  pathFromRoot: string[],\n): PortTarget {\n  const cardRcid =\n    typeof cardData.__rcid === \"string\" ? cardData.__rcid : undefined;\n  const ports = Array.isArray(cardData.ports) ? (cardData.ports as Port[]) : [];\n\n  const updateIn = (next: Port[]): CanvasData => {\n    const nextRoot = setPortsAtPath(\n      node.data as CardTreeJsonNode,\n      pathFromRoot,\n      next,\n    );\n    return updateNodeData(canvas, node.id, nextRoot as NodeData);\n  };\n\n  return { node, cardData, cardRcid, ports, updateIn };\n}\n\n/** Find the path (array of object keys) from rootData to the card whose __rcid === targetRcid. */\nfunction findCardPath(\n  data: CardTreeJsonNode,\n  targetRcid: string,\n  pathSoFar: string[],\n): string[] | null {\n  if (data.__rcid === targetRcid) return pathSoFar;\n\n  for (const [key, value] of Object.entries(data)) {\n    if (key.startsWith(\"__rc\")) continue;\n    if (key === \"__type\") continue;\n    if (key === \"ports\") continue;\n    if (!isCardLike(value)) continue;\n\n    const childPath = findCardPath(\n      value as CardTreeJsonNode,\n      targetRcid,\n      [...pathSoFar, key],\n    );\n    if (childPath !== null) return childPath;\n  }\n  return null;\n}\n\n/** Walk `data` following the path of keys. Returns null if any step doesn't resolve to a card-like object. */\nfunction walkPath(\n  data: CardTreeJsonNode,\n  path: string[],\n): CardTreeJsonNode | null {\n  let curr: CardTreeJsonNode = data;\n  for (const key of path) {\n    const next = curr[key];\n    if (!isCardLike(next)) return null;\n    curr = next as CardTreeJsonNode;\n  }\n  return curr;\n}\n\n/** Immutable update: replace `ports[]` at `path` (rooted at `data`); returns a new root. */\nfunction setPortsAtPath(\n  data: CardTreeJsonNode,\n  path: string[],\n  nextPorts: Port[],\n): CardTreeJsonNode {\n  if (path.length === 0) {\n    return { ...data, ports: nextPorts };\n  }\n  const [head, ...rest] = path;\n  const child = data[head];\n  if (!isCardLike(child)) return data; // path broken; bail without mutating\n  return {\n    ...data,\n    [head]: setPortsAtPath(child as CardTreeJsonNode, rest, nextPorts),\n  };\n}\n\nfunction isCardLike(value: unknown): value is CardTreeJsonNode {\n  if (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n  const obj = value as Record<string, unknown>;\n  return (\n    obj.__rcid !== undefined ||\n    obj.__rcorder !== undefined ||\n    obj.__rcmeta !== undefined ||\n    Array.isArray(obj.ports)\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree-node/lib/find-port-target.ts"
    },
    {
      "path": "src/registry/components/data/card-tree-node/lib/port-mutators.ts",
      "content": "// F-S1 lock — RELATIVE import for cross-procomp types. Same-category alias\n// imports get the slug name substituted by shadcn's rewriter; relative paths\n// bypass that and translate verbatim.\nimport type { Port, PortSide } from \"../../flow-canvas/types\";\n\n/**\n * 8-char short UUID with non-crypto fallback. Matches the id length used\n * by `makeEdgeId` / `makeNodeId` in flow-canvas's `use-canvas-data.ts`\n * (lines 34-45) for visual + diagnostic consistency.\n */\nfunction shortUuid(): string {\n  if (typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\") {\n    return crypto.randomUUID().slice(0, 8);\n  }\n  return Math.random().toString(36).slice(2, 10);\n}\n\n/**\n * Stable port id namespaced under the card's __rcid. v0.2.0 lock per Q5.\n *   `p-{cardRcid ?? \"card\"}-{shortUuid}`\n *\n * @example\n *   makePortId(\"card-llm-system\") → \"p-card-llm-system-a3f2c8d1\"\n *   makePortId(undefined)         → \"p-card-a3f2c8d1\"\n */\nexport function makePortId(cardRcid: string | undefined): string {\n  const base = cardRcid ?? \"card\";\n  return `p-${base}-${shortUuid()}`;\n}\n\n/**\n * Create an in/out pair of ports sharing the same type / side / multi / label\n * at create time. Both share a `{p-cardRcid-shortUuid}` base id with `-in`\n * and `-out` suffixes for traceability. Used by `<PortEditorAddPopover>`\n * when the user checks both `[✓in] [✓out]` checkboxes.\n *\n * Per Q3 lock: after save, the two ports are fully independent rows in the\n * editor. No auto-grouping at re-render time.\n */\nexport function makeInOutPair(\n  cardRcid: string | undefined,\n  type: string,\n  side: PortSide,\n  multi: boolean,\n  label?: string,\n): [Port, Port] {\n  const base = `p-${cardRcid ?? \"card\"}-${shortUuid()}`;\n  const inPort: Port = { id: `${base}-in`, side, dir: \"in\", type, multi, label };\n  const outPort: Port = { id: `${base}-out`, side, dir: \"out\", type, multi, label };\n  return [inPort, outPort];\n}\n\nexport function addPort(existing: Port[], port: Port): Port[] {\n  return [...existing, port];\n}\n\nexport function updatePort(\n  existing: Port[],\n  portId: string,\n  mut: Partial<Port>,\n): Port[] {\n  return existing.map((p) => (p.id === portId ? { ...p, ...mut } : p));\n}\n\nexport function removePort(existing: Port[], portId: string): Port[] {\n  return existing.filter((p) => p.id !== portId);\n}\n\n/**\n * Returns true when `id` is already used by another port in `existing`.\n * `excludePortId` skips a specific port (used during inline-rename — the\n * port being renamed shouldn't fail its own dup check).\n */\nexport function isDuplicateId(\n  existing: Port[],\n  id: string,\n  excludePortId?: string,\n): boolean {\n  return existing.some((p) => p.id === id && p.id !== excludePortId);\n}\n",
      "type": "registry:component",
      "target": "components/card-tree-node/lib/port-mutators.ts"
    },
    {
      "path": "src/registry/components/data/card-tree-node/parts/flat-field-strip.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { FlatField } from \"../types\";\nimport { formatValue } from \"../lib/format-value\";\n\n/**\n * Paints the first N flat-field entries from a card-tree tree as a definition\n * list. Type-aware styling: numbers right-aligned (tabular), booleans + dates\n * narrow, strings fluid.\n */\nfunction FlatFieldStripImpl({ fields }: { fields: FlatField[] }) {\n  if (fields.length === 0) return null;\n  return (\n    <dl className=\"grid grid-cols-[max-content_1fr] gap-x-2 gap-y-0.5 px-3 py-2 text-xs\">\n      {fields.map((field) => (\n        <div key={field.key} className=\"contents\">\n          <dt className=\"truncate font-medium text-muted-foreground\">{field.key}</dt>\n          <dd\n            className={cn(\n              \"truncate\",\n              field.type === \"number\" && \"text-right font-mono tabular-nums\",\n              field.type === \"boolean\" && \"text-center\",\n              field.type === \"date\" && \"font-mono\",\n            )}\n          >\n            {formatValue(field.value, field.type)}\n          </dd>\n        </div>\n      ))}\n    </dl>\n  );\n}\n\nexport const FlatFieldStrip = memo(FlatFieldStripImpl);\n",
      "type": "registry:component",
      "target": "components/card-tree-node/parts/flat-field-strip.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree-node/parts/card-tree-viewer.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 lock — RELATIVE imports for cross-procomp types/files. Same-category\n// alias imports get the slug name substituted by shadcn's rewriter; relative\n// paths bypass that and translate verbatim.\nimport type { NodeRenderer, RenderContext } from \"../../flow-canvas/types\";\nimport { PortsAt } from \"../../flow-canvas/parts/ports-at\";\nimport type { CardTreeCanvasNode } from \"../types\";\nimport { enumerateSubcards } from \"../lib/enumerate-subcards\";\nimport { deriveTitle } from \"../lib/derive-title\";\nimport { deriveFlatFields } from \"../lib/derive-flat-fields\";\nimport { FlatFieldStrip } from \"./flat-field-strip\";\nimport { SubcardBlock } from \"./subcard-block\";\n\n// Locked constants (Q6 — v0.2 may open these as CardTreeViewerOptions).\nconst MAX_FLAT_FIELDS = 3;\nconst MAX_NESTED_OUTLINES = 4;\n\n/**\n * Read-only renderer that paints a card-tree tree as a flow-canvas node.\n * Title strip + first 3 flat fields + nested-card outlines with their own\n * ports + root-level port handles. Clicks fire `ctx.onEditRequest(subPath?)`;\n * consumer routes to a dialog mounting `<CardTree editable>`.\n *\n * Composition (F-V1 lock — see procomp plan §3.5):\n *   <div role=\"group\">                ← outer (NOT a button)\n *     <button>title strip</button>    ← root edit affordance\n *     <FlatFieldStrip />              ← read-only fields (no buttons)\n *     <SubcardBlock /> × N            ← each a <button> with its own ports\n *     <PortsAt /> × 4 sides           ← root-level ports\n *   </div>\n *\n * Position-relative chain (F-05 + G1 lock):\n *   NodeShell → CardTreeViewer outer → SubcardBlock — each MUST be position:\n *   relative or xyflow's `<Handle>` (position: absolute) anchors to a wrong\n *   positioned ancestor. The \"relative\" className below is load-bearing.\n */\nfunction CardTreeViewerImpl({\n  data,\n  ctx,\n}: {\n  data: CardTreeCanvasNode;\n  ctx: RenderContext;\n}) {\n  const title = deriveTitle(data);\n  const flatFields = deriveFlatFields(data, MAX_FLAT_FIELDS);\n  const subcards = enumerateSubcards(data).slice(0, MAX_NESTED_OUTLINES);\n  const ports = data.ports;\n\n  // F-V1 lock: outer is <div role=\"group\">, NOT a button. Nested buttons\n  // (title strip + each subcard) compose cleanly because the outer is a\n  // grouping role, not an interactive element.\n  return (\n    <div\n      role=\"group\"\n      aria-label={`Card tree: ${title ?? \"Untitled\"}`}\n      className={cn(\n        // F-05 + G1: position: relative is REQUIRED — xyflow handles anchor here.\n        \"relative min-w-60 max-w-90 rounded-md border border-border\",\n        \"bg-card text-card-foreground shadow-sm\",\n      )}\n    >\n      {/* Title strip — click opens root dialog */}\n      <button\n        type=\"button\"\n        onClick={() => ctx.onEditRequest?.()}\n        disabled={!ctx.onEditRequest}\n        className={cn(\n          \"flex w-full items-center gap-2 border-b border-border px-3 py-2\",\n          \"text-left text-sm font-semibold\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          ctx.onEditRequest && \"cursor-pointer hover:bg-accent/40\",\n          !ctx.onEditRequest && \"cursor-default\",\n        )}\n        aria-haspopup={ctx.onEditRequest ? \"dialog\" : undefined}\n      >\n        <span className=\"truncate\">{title ?? \"Untitled card-tree\"}</span>\n      </button>\n\n      {/* First-N flat fields */}\n      {flatFields.length > 0 && <FlatFieldStrip fields={flatFields} />}\n\n      {/* Subcard outlines (one level deep in v0.1) */}\n      {subcards.length > 0 && (\n        <div className=\"space-y-1.5 p-2\">\n          {subcards.map(({ key, card }) => (\n            <SubcardBlock\n              key={card.__rcid ?? key}\n              cardKey={key}\n              card={card}\n              onEdit={(rcid) => ctx.onEditRequest?.(rcid)}\n            />\n          ))}\n        </div>\n      )}\n\n      {/* Root-level port handles */}\n      <PortsAt ports={ports} position=\"left\" />\n      <PortsAt ports={ports} position=\"right\" />\n      <PortsAt ports={ports} position=\"top\" />\n      <PortsAt ports={ports} position=\"bottom\" />\n    </div>\n  );\n}\n\nconst CardTreeViewer = memo(CardTreeViewerImpl);\n\n/**\n * `NodeRenderer<CardTreeCanvasNode>` — drop-in for flow-canvas's\n * `renderers` prop. Consumer wiring:\n *\n *   <FlowCanvas\n *     renderers={[cardTreeViewerRenderer]}\n *     onEditRequest={(nodeId, subPath) => openDialog(nodeId, subPath)}\n *     data={canvasData}\n *     onChange={setCanvasData}\n *   />\n *\n * F-V6 lock — `NodeRenderer<TData extends NodeData>` requires `TData` to\n * extend `NodeData` (which mandates `__type: string`). CardTreeJsonNode alone\n * doesn't satisfy that constraint; `CardTreeCanvasNode = NodeData &\n * CardTreeJsonNode` is the type that flows into the registry. Precedent:\n * `customJsonRenderer`'s `NodeData & { _label?: string }` in flow-canvas.\n */\nexport const cardTreeViewerRenderer: NodeRenderer<CardTreeCanvasNode> = {\n  type: \"card-tree\",\n  label: \"Card tree\",\n  render: (data, ctx) => <CardTreeViewer data={data} ctx={ctx} />,\n};\n",
      "type": "registry:component",
      "target": "components/card-tree-node/parts/card-tree-viewer.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree-node/parts/subcard-block.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 lock — RELATIVE imports for cross-procomp types/files. Same-category\n// alias imports get the slug name substituted by shadcn's rewriter; relative\n// paths bypass that and translate verbatim.\nimport { PortsAt } from \"../../flow-canvas/parts/ports-at\";\nimport type { NodeData } from \"../../flow-canvas/types\";\nimport type { CardTreeJsonNode } from \"../../card-tree/types\";\nimport { deriveTitle } from \"../lib/derive-title\";\n\n/**\n * Visual block for one nested card-tree subcard inside a `<CardTreeViewer>`.\n * Renders the subcard's title + its own port handles (recursively via xyflow's\n * `<Handle>` model — flow-canvas's port-walker traverses `Object.entries`\n * and finds these without any new plumbing per F-05 in the plan).\n *\n * MUST stay `position: relative` — xyflow's `<Handle>` is `position: absolute`\n * and anchors to the nearest positioned ancestor. Removing `relative` here\n * will silently break subcard handle visual positioning (G1 lock).\n */\nfunction SubcardBlockImpl({\n  cardKey,\n  card,\n  onEdit,\n}: {\n  cardKey: string;\n  card: CardTreeJsonNode;\n  onEdit: (rcid: string) => void;\n}) {\n  const rcid = card.__rcid;\n  const title = deriveTitle(card) ?? cardKey;\n  // Subcards stay typed as CardTreeJsonNode (they're inner tree nodes, not\n  // flow-canvas nodes). NodeData cast lets us read the optional ports[] —\n  // structurally sound because NodeData.ports?: Port[] flows through\n  // CardTreeJsonNode's `[key: string]: unknown` index signature.\n  const ports = (card as NodeData).ports;\n  const canFocusThisSubcard = rcid !== undefined;\n\n  // F-03 lock: only fire onEdit(rcid) when __rcid is present.\n  // Missing __rcid → click bubbles to the title strip → root edit.\n  const handleClick = (e: React.MouseEvent) => {\n    if (canFocusThisSubcard) {\n      e.stopPropagation(); // F-V1: keep subcard click from bubbling to title strip\n      onEdit(rcid!);\n    }\n    // else: fall through; parent title-strip handler opens dialog at root.\n  };\n\n  // F-03 dev-mode warning. Cheap; helps the consumer notice missing __rcid.\n  if (!canFocusThisSubcard && process.env.NODE_ENV === \"development\") {\n    console.warn(\n      `[card-tree-node] Subcard \"${cardKey}\" has no __rcid — click-to-focus disabled. ` +\n        \"Pass the canvas data through <CardTree> once or use card-tree's ID-attach helper.\",\n    );\n  }\n\n  return (\n    <button\n      type=\"button\"\n      onClick={handleClick}\n      // Consumer can style \"subcard missing __rcid\" via [data-subcard-focusable=false]\n      data-subcard-focusable={canFocusThisSubcard ? undefined : \"false\"}\n      // F-05 + G1 lock — MUST stay position: relative; do not remove.\n      className={cn(\n        \"relative w-full rounded-sm border border-border/60 bg-muted/30 p-2 text-left\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        canFocusThisSubcard && \"cursor-pointer hover:border-border hover:bg-muted/50\",\n        !canFocusThisSubcard && \"cursor-default\",\n      )}\n      aria-haspopup={canFocusThisSubcard ? \"dialog\" : undefined}\n      aria-label={`Subcard: ${title}`}\n    >\n      <div className=\"text-xs font-medium text-muted-foreground\">{title}</div>\n      {/* Subcard's own ports — port-walker finds them via Object.entries recursion. */}\n      <PortsAt ports={ports} position=\"left\" />\n      <PortsAt ports={ports} position=\"right\" />\n      <PortsAt ports={ports} position=\"top\" />\n      <PortsAt ports={ports} position=\"bottom\" />\n    </button>\n  );\n}\n\nexport const SubcardBlock = memo(SubcardBlockImpl);\n",
      "type": "registry:component",
      "target": "components/card-tree-node/parts/subcard-block.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree-node/parts/port-editor-add-popover.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Plus } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n// F-S1 lock — RELATIVE cross-procomp imports\nimport type { Port, PortSide, PortType } from \"../../flow-canvas/types\";\nimport { makeInOutPair, makePortId } from \"../lib/port-mutators\";\n\nconst SIDES: PortSide[] = [\"left\", \"right\", \"top\", \"bottom\"];\n\n/**\n * v0.2.0 — popover triggered by the \"+ add port\" button. Lets the user pick\n * one or both directions via [✓in][✓out] checkboxes + type / side / multi /\n * label. On commit, calls `onAdd` with 1 or 2 atomic ports (per Q3 lock:\n * \"both\" creates a pair at create time; rows are independent post-save).\n *\n * Doc-typed ports have their `side` forced to `\"bottom\"` editor-side per Q4.\n */\nexport function PortEditorAddPopover({\n  cardRcid,\n  portTypes,\n  onAdd,\n  disabled,\n}: {\n  cardRcid: string | undefined;\n  portTypes: PortType[];\n  onAdd: (newPorts: Port[]) => void;\n  disabled?: boolean;\n}) {\n  const [open, setOpen] = useState(false);\n  const [inChecked, setInChecked] = useState(false);\n  const [outChecked, setOutChecked] = useState(true);\n  const [type, setType] = useState<string>(portTypes[0]?.id ?? \"data\");\n  const [side, setSide] = useState<PortSide>(\"right\");\n  const [multi, setMulti] = useState(false);\n  const [label, setLabel] = useState(\"\");\n\n  const isDocType = type === \"doc\";\n  const effectiveSide: PortSide = isDocType ? \"bottom\" : side;\n  const canCommit = inChecked || outChecked;\n\n  function reset() {\n    setInChecked(false);\n    setOutChecked(true);\n    setType(portTypes[0]?.id ?? \"data\");\n    setSide(\"right\");\n    setMulti(false);\n    setLabel(\"\");\n  }\n\n  function commit() {\n    if (!canCommit) return;\n    const trimmedLabel = label.trim() === \"\" ? undefined : label.trim();\n    let newPorts: Port[];\n    if (inChecked && outChecked) {\n      newPorts = makeInOutPair(\n        cardRcid,\n        type,\n        effectiveSide,\n        multi,\n        trimmedLabel,\n      );\n    } else {\n      const dir = inChecked ? \"in\" : \"out\";\n      newPorts = [\n        {\n          id: makePortId(cardRcid),\n          side: effectiveSide,\n          dir,\n          type,\n          multi,\n          label: trimmedLabel,\n        },\n      ];\n    }\n    onAdd(newPorts);\n    reset();\n    setOpen(false);\n  }\n\n  return (\n    <Popover\n      open={open}\n      onOpenChange={(o) => {\n        setOpen(o);\n        if (!o) reset();\n      }}\n    >\n      {/* F-cross-13: no `asChild` — PopoverTrigger renders its own <button> in\n          both backends; the \"+ add port\" styling sits on the trigger directly.\n          Matches card-tree's \"+ FIELD\" / \"+ BLOCK\" pattern per description Q11.\n          See: card-tree/parts/predefined-add-menu.tsx:56-59 */}\n      <PopoverTrigger\n        type=\"button\"\n        disabled={disabled}\n        className=\"inline-flex items-center gap-1 rounded-md border border-dashed border-border/70 bg-transparent px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n      >\n        <Plus className=\"size-3\" aria-hidden=\"true\" />\n        add port\n      </PopoverTrigger>\n      <PopoverContent\n        align=\"start\"\n        sideOffset={4}\n        className=\"w-72 space-y-3 p-3\"\n      >\n        <p className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n          Add port\n        </p>\n\n        {/* Direction multi-select per Q3 — both checked creates an in/out pair */}\n        <div className=\"space-y-1.5\">\n          <Label className=\"text-xs font-medium\">Direction</Label>\n          <div className=\"flex items-center gap-3\">\n            <label className=\"flex items-center gap-1.5 text-xs\">\n              <Checkbox\n                checked={inChecked}\n                onCheckedChange={(c) => setInChecked(c === true)}\n              />\n              in\n            </label>\n            <label className=\"flex items-center gap-1.5 text-xs\">\n              <Checkbox\n                checked={outChecked}\n                onCheckedChange={(c) => setOutChecked(c === true)}\n              />\n              out\n            </label>\n          </div>\n        </div>\n\n        <div className=\"space-y-1.5\">\n          <Label className=\"text-xs font-medium\">Type</Label>\n          <Select value={type} onValueChange={(v: string | null) => v && setType(v)}>\n            <SelectTrigger className=\"h-8 w-full text-xs\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              {portTypes.map((t) => (\n                <SelectItem key={t.id} value={t.id} className=\"text-xs\">\n                  <span className=\"flex items-center gap-2\">\n                    <span\n                      className=\"inline-block size-2.5 rounded-full\"\n                      style={{ background: t.color }}\n                      aria-hidden=\"true\"\n                    />\n                    {t.label ?? t.id}\n                  </span>\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n        </div>\n\n        <div className=\"space-y-1.5\">\n          <Label className=\"text-xs font-medium\">Side</Label>\n          <Select\n            value={effectiveSide}\n            onValueChange={(v: string | null) => v && setSide(v as PortSide)}\n            disabled={isDocType}\n          >\n            <SelectTrigger className=\"h-8 w-full text-xs\">\n              <SelectValue />\n            </SelectTrigger>\n            <SelectContent>\n              {SIDES.map((s) => (\n                <SelectItem\n                  key={s}\n                  value={s}\n                  className=\"text-xs\"\n                  disabled={isDocType && s !== \"bottom\"}\n                >\n                  {s}\n                </SelectItem>\n              ))}\n            </SelectContent>\n          </Select>\n          {isDocType && (\n            <p className=\"text-[10px] text-muted-foreground\">\n              Doc-type ports are forced to the bottom side.\n            </p>\n          )}\n        </div>\n\n        <label className=\"flex items-center gap-2\">\n          <Checkbox\n            checked={multi}\n            onCheckedChange={(c) => setMulti(c === true)}\n          />\n          <span className=\"text-xs\">Allow multiple connections</span>\n        </label>\n\n        <div className=\"space-y-1.5\">\n          <Label htmlFor=\"rcif-port-add-label\" className=\"text-xs font-medium\">\n            Label (optional)\n          </Label>\n          <Input\n            id=\"rcif-port-add-label\"\n            value={label}\n            onChange={(e) => setLabel(e.target.value)}\n            placeholder=\"e.g. response, doc-ref\"\n            className=\"h-8 text-xs\"\n          />\n        </div>\n\n        <div className=\"flex items-center justify-end gap-2 pt-1\">\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => {\n              reset();\n              setOpen(false);\n            }}\n          >\n            Cancel\n          </Button>\n          <Button size=\"sm\" onClick={commit} disabled={!canCommit}>\n            Add\n          </Button>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree-node/parts/port-editor-add-popover.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree-node/parts/port-editor-row.tsx",
      "content": "\"use client\";\n\nimport { memo, useState } from \"react\";\nimport { AlertCircle, X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n// F-S1 lock — RELATIVE cross-procomp imports\nimport type {\n  Port,\n  PortDir,\n  PortSide,\n  PortType,\n} from \"../../flow-canvas/types\";\nimport { isDuplicateId } from \"../lib/port-mutators\";\nimport type { PortEditorPermissions, PortField } from \"../types\";\n\nconst SIDES: PortSide[] = [\"left\", \"right\", \"top\", \"bottom\"];\nconst DIRS: PortDir[] = [\"in\", \"out\"];\n\nexport type PortEditorRowProps = {\n  cardId: string;\n  port: Port;\n  portTypes: PortType[];\n  existingPorts: Port[];\n  liveEdgeCount: { asSource: number; asTarget: number };\n  editable: boolean;\n  permissions: PortEditorPermissions;\n  onUpdate: (mut: Partial<Port>) => void;\n  onRemove: () => void;\n};\n\nfunction PortEditorRowImpl({\n  cardId,\n  port,\n  portTypes,\n  existingPorts,\n  liveEdgeCount,\n  editable,\n  permissions,\n  onUpdate,\n  onRemove,\n}: PortEditorRowProps) {\n  // Local buffers for commit-on-blur text fields (id, label). Live-save for\n  // selects + checkbox per Q6/Q7 — id renames have edge implications so they\n  // commit on blur; label is no-op for runtime but still uses blur for consistency.\n  //\n  // Note: the parent strip uses key={port.id} so id-rename commits remount the\n  // row (drafts reset cleanly from the new port props). External label-only\n  // mutations mid-edit don't re-sync the draft (edge case; v0.3 fix if a\n  // consumer hits it). Avoids the React Compiler \"setState during effect\"\n  // cascading-renders warning.\n  const [idDraft, setIdDraft] = useState(port.id);\n  const [labelDraft, setLabelDraft] = useState(port.label ?? \"\");\n\n  const isDocType = port.type === \"doc\";\n  const totalLiveEdges = liveEdgeCount.asSource + liveEdgeCount.asTarget;\n  const hasLiveEdges = totalLiveEdges > 0;\n  const idIsDirty = idDraft !== port.id;\n\n  const canEdit =\n    editable && (permissions.canEditPort?.(cardId, port.id) ?? true);\n  const canEditField = (field: PortField): boolean =>\n    canEdit && (permissions.canEditPortField?.(cardId, port.id, field) ?? true);\n  const canRemove =\n    editable && (permissions.canRemovePort?.(cardId, port.id) ?? true);\n\n  const idError =\n    idDraft.trim() === \"\"\n      ? \"Port id required\"\n      : isDuplicateId(existingPorts, idDraft, port.id)\n        ? \"Port id must be unique within this node\"\n        : null;\n\n  function commitId() {\n    const trimmed = idDraft.trim();\n    if (trimmed === \"\" || trimmed === port.id) {\n      setIdDraft(port.id);\n      return;\n    }\n    if (isDuplicateId(existingPorts, trimmed, port.id)) {\n      setIdDraft(port.id);\n      return;\n    }\n    onUpdate({ id: trimmed });\n  }\n\n  function commitLabel() {\n    const trimmed = labelDraft.trim();\n    if (trimmed === (port.label ?? \"\")) return;\n    onUpdate({ label: trimmed === \"\" ? undefined : trimmed });\n  }\n\n  function handleTypeChange(v: string | null) {\n    if (!v) return;\n    // Q4 auto-correct: switching to \"doc\" forces side to \"bottom\".\n    if (v === \"doc\" && port.side !== \"bottom\") {\n      onUpdate({ type: v, side: \"bottom\" });\n    } else {\n      onUpdate({ type: v });\n    }\n  }\n\n  if (!editable) {\n    // F-08 read-only summary row. Mirrors the editable row's fixed-pixel\n    // column widths so rows align cleanly. No remove / no multi columns.\n    return (\n      <div className=\"grid grid-cols-[220px_120px_100px_80px_200px] items-center gap-1 rounded-sm border border-border/40 bg-card/30 px-2 py-1.5 text-xs\">\n        <div className=\"truncate font-mono\">{port.id}</div>\n        <PortTypeBadge type={port.type} portTypes={portTypes} />\n        <span className=\"text-muted-foreground\">{port.side}</span>\n        <span className=\"text-muted-foreground\">{port.dir}</span>\n        {port.label ? (\n          <span className=\"truncate text-muted-foreground\">{port.label}</span>\n        ) : (\n          <span />\n        )}\n      </div>\n    );\n  }\n\n  // F-cross-13: the shadcn Tooltip trigger renders a <button> in BOTH backends\n  // and an <Input> cannot nest inside one — the error / rename-warning hint is\n  // a native `title` instead (also the accessible description; the styled\n  // signals — destructive border, aria-invalid, warning icon — stay).\n  const idEdgeWarning =\n    !idError && hasLiveEdges && idIsDirty\n      ? `Renaming this port will not auto-update ${totalLiveEdges} existing edge${totalLiveEdges === 1 ? \"\" : \"s\"} — consumer must update edge references.`\n      : null;\n  const idField = (\n    <div className=\"relative\">\n      <Input\n        value={idDraft}\n        onChange={(e) => setIdDraft(e.target.value)}\n        onBlur={commitId}\n        disabled={!canEditField(\"id\")}\n        className={`h-7 pr-6 font-mono text-xs ${idError ? \"border-destructive\" : \"\"}`}\n        aria-invalid={idError !== null}\n        title={idError ?? idEdgeWarning ?? undefined}\n      />\n      {hasLiveEdges && idIsDirty && !idError && (\n        <AlertCircle\n          className=\"pointer-events-none absolute right-1.5 top-1.5 size-3 text-warning\"\n          aria-label=\"Rename will affect existing edges\"\n        />\n      )}\n    </div>\n  );\n\n  return (\n    // Fixed-pixel column widths sized for actual control content (no fr\n    // stretching — at wide parents the fr-grown columns made selects look\n    // unnecessarily wide). Row sits at natural width (~860px) anchored left;\n    // the strip's overflow-x-auto wrapper kicks in if the parent is narrower.\n    // Widths: id 220 / type 120 / side 100 / dir 80 / multi 80 / label 200 /\n    // remove 36 = 836 + 6×4 gaps = 860.\n    <div className=\"grid grid-cols-[220px_120px_100px_80px_80px_200px_36px] items-center gap-1 rounded-sm border border-border/40 bg-card/30 px-2 py-1.5\">\n      {/* ID — commit on blur; native-title hint when error OR live-edges warning */}\n      {idField}\n\n      {/* Type — live-save; auto-corrects side when switching to \"doc\" */}\n      <Select\n        value={port.type}\n        onValueChange={handleTypeChange}\n        disabled={!canEditField(\"type\")}\n      >\n        <SelectTrigger className=\"h-7 w-full text-xs\">\n          <SelectValue />\n        </SelectTrigger>\n        <SelectContent>\n          {portTypes.map((t) => (\n            <SelectItem key={t.id} value={t.id} className=\"text-xs\">\n              <span className=\"flex items-center gap-2\">\n                <span\n                  className=\"inline-block size-2.5 rounded-full\"\n                  style={{ background: t.color }}\n                  aria-hidden=\"true\"\n                />\n                {t.label ?? t.id}\n              </span>\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n\n      {/* Side — disabled for doc-type per Q4 */}\n      <Select\n        value={port.side}\n        onValueChange={(v: string | null) => v && onUpdate({ side: v as PortSide })}\n        disabled={!canEditField(\"side\") || isDocType}\n      >\n        <SelectTrigger className=\"h-7 w-full text-xs\">\n          <SelectValue />\n        </SelectTrigger>\n        <SelectContent>\n          {SIDES.map((s) => (\n            <SelectItem\n              key={s}\n              value={s}\n              className=\"text-xs\"\n              disabled={isDocType && s !== \"bottom\"}\n            >\n              {s}\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n\n      {/* Dir */}\n      <Select\n        value={port.dir}\n        onValueChange={(v: string | null) => v && onUpdate({ dir: v as PortDir })}\n        disabled={!canEditField(\"dir\")}\n      >\n        <SelectTrigger className=\"h-7 w-full text-xs\">\n          <SelectValue />\n        </SelectTrigger>\n        <SelectContent>\n          {DIRS.map((d) => (\n            <SelectItem key={d} value={d} className=\"text-xs\">\n              {d}\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n\n      {/* Multi */}\n      <label className=\"flex items-center gap-1 text-[10px] uppercase tracking-wide text-muted-foreground\">\n        <Checkbox\n          checked={port.multi === true}\n          onCheckedChange={(c) => onUpdate({ multi: c === true })}\n          disabled={!canEditField(\"multi\")}\n        />\n        multi\n      </label>\n\n      {/* Label — commit on blur */}\n      <Input\n        value={labelDraft}\n        onChange={(e) => setLabelDraft(e.target.value)}\n        onBlur={commitLabel}\n        disabled={!canEditField(\"label\")}\n        placeholder=\"label\"\n        className=\"h-7 text-xs\"\n      />\n\n      {/* Remove */}\n      <Button\n        variant=\"ghost\"\n        size=\"sm\"\n        onClick={onRemove}\n        disabled={!canRemove}\n        className=\"size-7 p-0\"\n        aria-label={`Remove port ${port.id}`}\n      >\n        <X className=\"size-3.5\" />\n      </Button>\n    </div>\n  );\n}\n\nfunction PortTypeBadge({\n  type,\n  portTypes,\n}: {\n  type: string;\n  portTypes: PortType[];\n}) {\n  const pt = portTypes.find((t) => t.id === type);\n  return (\n    <span className=\"flex items-center gap-1.5 text-xs text-muted-foreground\">\n      <span\n        className=\"inline-block size-2.5 rounded-full\"\n        style={{ background: pt?.color ?? \"var(--muted-foreground)\" }}\n        aria-hidden=\"true\"\n      />\n      {pt?.label ?? type}\n    </span>\n  );\n}\n\nexport const PortEditorRow = memo(PortEditorRowImpl);\n",
      "type": "registry:component",
      "target": "components/card-tree-node/parts/port-editor-row.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree-node/parts/port-editor-strip.tsx",
      "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 lock — RELATIVE cross-procomp imports\nimport { defaultPortTypes } from \"../../flow-canvas/registries/port-type-registry\";\nimport type { CanvasData, Port } from \"../../flow-canvas/types\";\nimport { findPortTarget } from \"../lib/find-port-target\";\nimport { removePort, updatePort } from \"../lib/port-mutators\";\nimport type { PortEditorPermissions } from \"../types\";\nimport { PortEditorAddPopover } from \"./port-editor-add-popover\";\nimport { PortEditorRow } from \"./port-editor-row\";\n\nexport type PortEditorStripProps = {\n  /** ID of the flow-canvas node whose ports are being edited. */\n  nodeId: string;\n  /**\n   * Optional subPath: the `__rcid` of a nested card-tree subcard. When\n   * undefined, the strip targets the node's root card. When defined, walks\n   * the data tree to find the matching subcard.\n   */\n  subPath?: string;\n  /** Current canvas data (uncontrolled — strip reads + computes mutations). */\n  canvas: CanvasData;\n  /** Fires with the next CanvasData after every port mutation (live save per Q6). */\n  onChange: (next: CanvasData) => void;\n  /** When false, renders read-only summary rows. Default `true`. */\n  editable?: boolean;\n  /** Optional per-card / per-port / per-field permission predicates. */\n  permissions?: PortEditorPermissions;\n  /** Optional className applied to the strip root. */\n  className?: string;\n};\n\nconst EMPTY_PERMISSIONS: PortEditorPermissions = {};\n\n/**\n * Editor strip for the `ports[]` array of a single card-tree / subcard inside\n * a flow-canvas node. Mount alongside `<CardTree editable>` inside a\n * consumer-owned dialog. v0.2.0 addition.\n *\n * **Uncontrolled by design** (operates on the `canvas` prop directly). No\n * `key={nodeId}` remount needed — re-reads ports on prop change.\n *\n * **Live save:** every mutation calls `onChange(updatedCanvas)`. There is no\n * commit / cancel button. Per Q6 lock.\n *\n * **Add-flow supports \"both\"** via `[✓in] [✓out]` checkboxes — splits into 2\n * atomic port rows that are independent post-save (per description Q3 lock —\n * no auto-grouping at re-render).\n *\n * @example\n * ```tsx\n * <PortEditorStrip\n *   nodeId={editing.nodeId}\n *   subPath={editing.subPath}\n *   canvas={canvas}\n *   onChange={setCanvas}\n *   editable={true}\n * />\n * <CardTree editable defaultValue={editingTree} onChange={...} />\n * ```\n */\nexport function PortEditorStrip({\n  nodeId,\n  subPath,\n  canvas,\n  onChange,\n  editable = true,\n  permissions = EMPTY_PERMISSIONS,\n  className,\n}: PortEditorStripProps) {\n  // v0.2 uses defaults only — Q5-bis lock; consumer-registered custom types\n  // deferred to v0.3 with proper shared-context plumbing.\n  const portTypes = defaultPortTypes;\n\n  const target = useMemo(\n    () => findPortTarget(canvas, nodeId, subPath),\n    [canvas, nodeId, subPath],\n  );\n\n  // Pre-compute live-edges map per F-07 — one O(E) pass over the whole canvas.\n  // Key is `${nodeId}:${portId}` (matches EdgeRecord's inline encoding).\n  const liveEdgesMap = useMemo(() => {\n    const out = new Map<string, { asSource: number; asTarget: number }>();\n    for (const edge of canvas.edges) {\n      bumpCount(out, edge.source, \"asSource\");\n      bumpCount(out, edge.target, \"asTarget\");\n    }\n    return out;\n  }, [canvas.edges]);\n\n  if (!target) {\n    return (\n      <div\n        className={cn(\n          \"rounded-md border border-dashed border-border/50 bg-muted/30 px-3 py-4 text-xs text-muted-foreground\",\n          className,\n        )}\n      >\n        No card found at this path.\n      </div>\n    );\n  }\n\n  const cardId = target.cardRcid ?? target.node.id;\n  const canAdd = editable && (permissions.canAddPort?.(cardId) ?? true);\n\n  // `target!` is safe here — narrowed by the `if (!target) return` guard\n  // above; the assertion is needed only because the inner-function scope\n  // loses TS flow-analysis.\n  function commit(next: Port[]) {\n    onChange(target!.updateIn(next));\n  }\n\n  // F-cross-13: TooltipProvider dropped with the row tooltips (the id-field\n  // hint is a native `title` now — see port-editor-row.tsx).\n  return (\n    <div\n      className={cn(\n        \"space-y-2 rounded-md border border-border/60 bg-card/20 p-3\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center justify-between\">\n        <p className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n          Ports {target.ports.length > 0 ? `(${target.ports.length})` : \"\"}\n        </p>\n        {canAdd && (\n          <PortEditorAddPopover\n            cardRcid={target.cardRcid}\n            portTypes={portTypes}\n            onAdd={(newPorts) => commit([...target.ports, ...newPorts])}\n          />\n        )}\n      </div>\n\n      {target.ports.length === 0 ? (\n        <p className=\"py-2 text-center text-xs text-muted-foreground\">\n          {editable\n            ? \"No ports yet. Click + add port to begin.\"\n            : \"No ports.\"}\n        </p>\n      ) : (\n        // Horizontal scroll wrapper — when the dialog or strip parent is\n        // narrower than the row's combined column min-widths, rows stay at\n        // their natural width and the strip becomes swipeable in x.\n        <div className=\"-mx-3 overflow-x-auto px-3\">\n          <div className=\"min-w-max space-y-1.5\">\n            {target.ports.map((port) => (\n              <PortEditorRow\n                key={port.id}\n                cardId={cardId}\n                port={port}\n                portTypes={portTypes}\n                existingPorts={target.ports}\n                liveEdgeCount={\n                  liveEdgesMap.get(`${target.node.id}:${port.id}`) ?? {\n                    asSource: 0,\n                    asTarget: 0,\n                  }\n                }\n                editable={editable}\n                permissions={permissions}\n                onUpdate={(mut) =>\n                  commit(updatePort(target.ports, port.id, mut))\n                }\n                onRemove={() => commit(removePort(target.ports, port.id))}\n              />\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction bumpCount(\n  map: Map<string, { asSource: number; asTarget: number }>,\n  edgeRef: `${string}:${string}`,\n  key: \"asSource\" | \"asTarget\",\n): void {\n  const existing = map.get(edgeRef) ?? { asSource: 0, asTarget: 0 };\n  existing[key] += 1;\n  map.set(edgeRef, existing);\n}\n",
      "type": "registry:component",
      "target": "components/card-tree-node/parts/port-editor-strip.tsx"
    }
  ],
  "categories": [
    "data",
    "flow",
    "rich-content"
  ],
  "type": "registry:block"
}