{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-tree",
  "title": "Card Tree",
  "author": "ilinxa",
  "description": "JSON-driven recursive card tree with a full structural editor — drag and drop, multi-select, permissions, search, validation, and undo.",
  "dependencies": [
    "lucide-react",
    "@dnd-kit/core",
    "@dnd-kit/sortable",
    "@dnd-kit/utilities"
  ],
  "registryDependencies": [
    "popover"
  ],
  "files": [
    {
      "path": "src/registry/components/data/card-tree/card-tree.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useReducer,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  DndContext,\n  type DragEndEvent,\n  type DragStartEvent,\n} from \"@dnd-kit/core\";\nimport {\n  SortableContext,\n  verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  CardAddedEvent,\n  CardDuplicatedEvent,\n  CardMovedEvent,\n  CardRemovedEvent,\n  CardRenamedEvent,\n  CustomPredefinedKey,\n  EffectivePermissions,\n  FieldAddedEvent,\n  FieldEditedEvent,\n  FieldRemovedEvent,\n  LevelStyle,\n  MetaAddedEvent,\n  MetaChangedEvent,\n  MetaRemovedEvent,\n  PredefinedAddedEvent,\n  PredefinedEditedEvent,\n  PredefinedKey,\n  PredefinedRemovedEvent,\n  CardTreeHandle,\n  CardTreeJsonNode,\n  CardTreeProps,\n  SearchMatch,\n  SearchOptions,\n} from \"./types\";\nimport { parseInput, type ParseError, type ParsedCardTree } from \"./lib/parse\";\nimport { serializeTree, treeToJsonNode } from \"./lib/serialize\";\nimport {\n  createInitialState,\n  findAncestorIds,\n  findCard,\n  findParentId,\n  reducer,\n  visibleIdsInOrder,\n  type DefaultCollapsed,\n  type CardTreeAction,\n  type CardTreePredefinedEntry,\n  type CardTreeState,\n} from \"./lib/reducer\";\nimport {\n  validateCardRename,\n  validateCardRemove,\n  validateFieldAdd,\n  validateFieldEditKey,\n  validateFieldEditValue,\n  validateMetaAdd,\n  validateMetaEdit,\n  validatePredefinedShape,\n} from \"./lib/validate-edit\";\nimport {\n  runHostValidators,\n  type AnyChangeEvent,\n  type ValidatorKey,\n} from \"./lib/validators\";\nimport type { FlatFieldType } from \"./lib/infer-type\";\nimport { useTreeKeyboard } from \"./hooks/use-tree-keyboard\";\nimport { useEditMode } from \"./hooks/use-edit-mode\";\nimport { isDirty as isDirtySelector } from \"./hooks/use-dirty\";\nimport { usePermissions } from \"./hooks/use-permissions\";\nimport { useDndSensors, collisionStrategy } from \"./hooks/use-dnd-config\";\nimport { useSearch } from \"./hooks/use-search\";\nimport { useUndo } from \"./hooks/use-undo\";\nimport { Card, type CardConfig, type EditDispatchers, type EditValidators } from \"./parts/card\";\nimport { EmptyTreePlaceholder } from \"./parts/empty-tree\";\n\n/* ───────── default level styles ───────── */\n\nconst DEFAULT_LEVEL_STYLES: LevelStyle[] = [\n  { containerClassName: \"rounded-xl border border-border bg-card px-3 py-3 shadow-sm\" },\n  { containerClassName: \"rounded-lg border border-border bg-muted/30 px-3 py-2.5\" },\n  { containerClassName: \"rounded-md border border-border/70 bg-muted/20 px-2.5 py-2\" },\n  { containerClassName: \"rounded-md border border-border/50 bg-muted/15 px-2 py-2\" },\n  { containerClassName: \"rounded-sm border border-border/40 bg-muted/10 px-2 py-1.5\" },\n  { containerClassName: \"rounded-sm border border-dashed border-border/30 px-2 py-1.5\" },\n];\n\nfunction clampIndex(level: number, len: number): number {\n  return Math.max(0, Math.min(level - 1, len - 1));\n}\n\nfunction makeErrorTree(message: string): ParsedCardTree {\n  return {\n    id: \"rcc-error-root\",\n    order: 0,\n    level: 1,\n    fields: [{ key: \"error\", value: message, type: \"string\" }],\n    predefined: [],\n    children: [],\n  };\n}\n\nfunction generateId(): string {\n  if (\n    typeof globalThis.crypto !== \"undefined\" &&\n    typeof globalThis.crypto.randomUUID === \"function\"\n  ) {\n    return globalThis.crypto.randomUUID();\n  }\n  return `rc-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;\n}\n\nfunction logParseErrors(errors: ParseError[]): void {\n  if (errors.length === 0) return;\n  for (const err of errors) {\n    console.warn(\n      `[card-tree] ${err.path ? `at ${err.path}: ` : \"\"}${err.message}`,\n    );\n  }\n}\n\n/* ───────── component ───────── */\n\nexport const CardTree = forwardRef<CardTreeHandle, CardTreeProps>(\n  function CardTree(props, ref) {\n    const {\n      defaultValue,\n      levelStyles,\n      getLevelStyle,\n      predefinedKeyStyles,\n      defaultCollapsed = \"none\",\n      metaPresentation = \"hidden\",\n      disabledPredefinedKeys,\n      dateDetection = \"auto\",\n      editable = false,\n      onChange,\n      onFieldEdited,\n      onFieldAdded,\n      onFieldRemoved,\n      onCardAdded,\n      onCardRemoved,\n      onCardRenamed,\n      onPredefinedAdded,\n      onPredefinedEdited,\n      onPredefinedRemoved,\n      onSelectionChange,\n      // v0.3\n      dndScopes,\n      onCardMoved,\n      onCardDuplicated,\n      permissions,\n      canEditField,\n      canAddField,\n      canRemoveField,\n      canEditCard,\n      canAddCard,\n      canRemoveCard,\n      canEditPredefined,\n      canAddPredefined,\n      canRemovePredefined,\n      canDragCard,\n      canDropCard,\n      onPermissionDenied,\n      customPredefinedKeys,\n      allowRootRemoval = false,\n      onRootRemoved,\n      defaultDeletePolicy = \"cascade\",\n      promoteCollisionStrategy = \"suffix\",\n      emptyTreeRenderer,\n      metaRenderers,\n      auditTrail,\n      onMetaChanged,\n      onMetaAdded,\n      onMetaRemoved,\n      search,\n      onSearchResults,\n      validators: hostValidators,\n      validate,\n      onValidationFailed,\n      maxUndoDepth = 50,\n      disableUndoShortcuts = false,\n      onUndo,\n      onRedo,\n      className,\n      \"aria-label\": ariaLabel = \"Card tree\",\n    } = props;\n\n    const initialState = useMemo(() => {\n      const { tree, errors } = parseInput(defaultValue, {\n        disabledPredefinedKeys: disabledPredefinedKeys ?? [],\n        dateDetection,\n      });\n      logParseErrors(errors);\n      const root =\n        tree ?? makeErrorTree(\"Invalid input — defaultValue must be a JSON object.\");\n      return createInitialState(root, defaultCollapsed as DefaultCollapsed);\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, []);\n\n    const [state, dispatch] = useReducer(reducer, initialState);\n    const stateRef = useRef(state);\n    useEffect(() => {\n      stateRef.current = state;\n    }, [state]);\n\n    const { mode: editMode, setMode: setEditMode, clear: clearEditMode } =\n      useEditMode();\n    const [tentativeCardId, setTentativeCardId] = useState<string | null>(null);\n    const [emptyRoot, setEmptyRoot] = useState(false);\n\n    const disabledKeys = useMemo(\n      () => disabledPredefinedKeys ?? [],\n      [disabledPredefinedKeys],\n    );\n\n    /* ───────── permissions ───────── */\n\n    const perms = usePermissions(\n      state.tree,\n      editable,\n      permissions,\n      {\n        canEditField,\n        canAddField,\n        canRemoveField,\n        canEditCard,\n        canAddCard,\n        canRemoveCard,\n        canEditPredefined,\n        canAddPredefined,\n        canRemovePredefined,\n        canDragCard,\n        canDropCard,\n      },\n      onPermissionDenied,\n    );\n\n    /* ───────── search ───────── */\n\n    const searchResult = useSearch(\n      state,\n      dispatch,\n      search,\n      customPredefinedKeys,\n      onSearchResults,\n    );\n\n    /* ───────── validation gate (v0.4) ───────── */\n\n    const gateValidation = useCallback(\n      (\n        validatorKey: ValidatorKey,\n        event: AnyChangeEvent,\n        actionType: string,\n        cardId: string | undefined,\n      ): boolean => {\n        if (!hostValidators && !validate) return true;\n        const treeJson = treeToJsonNode(stateRef.current.tree) as CardTreeJsonNode;\n        const result = runHostValidators(\n          validatorKey,\n          event,\n          actionType,\n          cardId,\n          treeJson,\n          hostValidators,\n          validate,\n        );\n        if (!result.ok) {\n          onValidationFailed?.({\n            action: actionType,\n            cardId,\n            errors: result.errors,\n            layer: result.layer,\n          });\n          return false;\n        }\n        return true;\n      },\n      [hostValidators, validate, onValidationFailed],\n    );\n\n    /* ───────── undo/redo (v0.4) ───────── */\n\n    // Sync maxUndoDepth prop into reducer state\n    useEffect(() => {\n      dispatch({ type: \"set-max-undo-depth\", depth: maxUndoDepth });\n    }, [maxUndoDepth]);\n\n    /* ───────── event firing ───────── */\n\n    const eventQueueRef = useRef<\n      Array<{ action: CardTreeAction; prevState: CardTreeState }>\n    >([]);\n\n    const queueCommit = useCallback((action: CardTreeAction) => {\n      eventQueueRef.current.push({ action, prevState: stateRef.current });\n      dispatch(action);\n    }, []);\n\n    const lastFiredVersionRef = useRef(state.version);\n    useEffect(() => {\n      if (state.version === lastFiredVersionRef.current) {\n        eventQueueRef.current = [];\n        return;\n      }\n      lastFiredVersionRef.current = state.version;\n      const queued = eventQueueRef.current;\n      eventQueueRef.current = [];\n      for (const { action, prevState } of queued) {\n        fireGranularEvent(action, prevState, state, {\n          onFieldEdited,\n          onFieldAdded,\n          onFieldRemoved,\n          onCardAdded,\n          onCardRemoved,\n          onCardRenamed,\n          onPredefinedAdded,\n          onPredefinedEdited,\n          onPredefinedRemoved,\n          onCardMoved,\n          onCardDuplicated,\n          onMetaChanged,\n          onMetaAdded,\n          onMetaRemoved,\n        });\n      }\n      if (onChange) onChange(treeToJsonNode(state.tree));\n    }, [\n      state.version,\n      state.tree,\n      state,\n      onChange,\n      onFieldEdited,\n      onFieldAdded,\n      onFieldRemoved,\n      onCardAdded,\n      onCardRemoved,\n      onCardRenamed,\n      onPredefinedAdded,\n      onPredefinedEdited,\n      onPredefinedRemoved,\n      onCardMoved,\n      onCardDuplicated,\n      onMetaChanged,\n      onMetaAdded,\n      onMetaRemoved,\n    ]);\n\n    // Multi-selection change effect\n    const lastSelectedIdsRef = useRef(state.selectedIds);\n    useEffect(() => {\n      if (state.selectedIds !== lastSelectedIdsRef.current) {\n        lastSelectedIdsRef.current = state.selectedIds;\n        onSelectionChange?.(Array.from(state.selectedIds));\n      }\n    }, [state.selectedIds, onSelectionChange]);\n\n    /* ───────── styles ───────── */\n\n    const resolveLevelStyle = useMemo(() => {\n      if (getLevelStyle) return getLevelStyle;\n      const arr =\n        levelStyles && levelStyles.length > 0 ? levelStyles : DEFAULT_LEVEL_STYLES;\n      return (level: number) => arr[clampIndex(level, arr.length)];\n    }, [getLevelStyle, levelStyles]);\n\n    /* ───────── dispatchers ───────── */\n\n    const dispatchers: EditDispatchers = useMemo(\n      () => ({\n        fieldEditValue: (cardId, key, value, type) => {\n          if (!perms.canEditField(cardId, key)) return;\n          const r = validateFieldEditValue(stateRef.current, cardId, key, value, type);\n          if (!r.ok) return;\n          const oldField = findCard(stateRef.current.tree, cardId)?.fields.find(\n            (f) => f.key === key,\n          );\n          if (!oldField) return;\n          if (\n            !gateValidation(\n              \"fieldEdit\",\n              {\n                cardId,\n                key,\n                oldValue: oldField.value,\n                oldType: oldField.type,\n                newValue: value,\n                newType: type,\n              },\n              \"field-edit-value\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({\n            type: \"field-edit-value\",\n            cardId,\n            key,\n            value,\n            valueType: type,\n          });\n          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);\n          clearEditMode();\n        },\n        fieldEditKey: (cardId, oldKey, newKey) => {\n          if (!perms.canEditField(cardId, oldKey)) return;\n          const r = validateFieldEditKey(\n            stateRef.current,\n            cardId,\n            oldKey,\n            newKey,\n            disabledKeys,\n          );\n          if (!r.ok) return;\n          const oldField = findCard(stateRef.current.tree, cardId)?.fields.find(\n            (f) => f.key === oldKey,\n          );\n          if (!oldField) return;\n          if (\n            !gateValidation(\n              \"fieldEdit\",\n              {\n                cardId,\n                key: newKey,\n                oldValue: oldField.value,\n                oldType: oldField.type,\n                newValue: oldField.value,\n                newType: oldField.type,\n              },\n              \"field-edit-key\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"field-edit-key\", cardId, oldKey, newKey });\n          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);\n          clearEditMode();\n        },\n        fieldAdd: (cardId, key, value, type) => {\n          if (!perms.canAddField(cardId)) return;\n          const r = validateFieldAdd(\n            stateRef.current,\n            cardId,\n            key,\n            value,\n            type,\n            disabledKeys,\n          );\n          if (!r.ok) return;\n          if (\n            !gateValidation(\n              \"fieldAdd\",\n              { cardId, key, value, type },\n              \"field-add\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"field-add\", cardId, key, value, valueType: type });\n          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);\n          clearEditMode();\n        },\n        fieldRemove: (cardId, key) => {\n          if (!perms.canRemoveField(cardId, key)) return;\n          const oldField = findCard(stateRef.current.tree, cardId)?.fields.find(\n            (f) => f.key === key,\n          );\n          if (!oldField) return;\n          if (\n            !gateValidation(\n              \"fieldRemove\",\n              { cardId, key, oldValue: oldField.value, oldType: oldField.type },\n              \"field-remove\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"field-remove\", cardId, key });\n          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);\n        },\n        cardAdd: (parentId) => {\n          if (!perms.canAddCard(parentId)) return;\n          const newId = generateId();\n          const parent = findCard(stateRef.current.tree, parentId);\n          const order = parent\n            ? parent.children.reduce((m, c) => Math.max(m, c.order), -1) + 1\n            : 0;\n          const newCard: ParsedCardTree = {\n            id: newId,\n            order,\n            level: (parent?.level ?? 0) + 1,\n            parentKey: \"untitled\",\n            fields: [],\n            predefined: [],\n            children: [],\n          };\n          if (\n            !gateValidation(\n              \"cardAdd\",\n              {\n                parentId,\n                card: treeToJsonNode(newCard) as CardTreeJsonNode,\n              },\n              \"card-add\",\n              parentId,\n            )\n          )\n            return;\n          queueCommit({ type: \"card-add\", parentId, card: newCard });\n          setTentativeCardId(newId);\n          setEditMode({ kind: \"card-title\", cardId: newId });\n        },\n        cardRemove: (cardId, policy) => {\n          if (!perms.canRemoveCard(cardId)) return;\n          const isRoot = stateRef.current.tree.id === cardId;\n          if (isRoot) {\n            if (!allowRootRemoval) return;\n            const currentJson = treeToJsonNode(stateRef.current.tree);\n            const newRoot = onRootRemoved\n              ? onRootRemoved(currentJson)\n              : null;\n            if (newRoot === null) {\n              setEmptyRoot(true);\n              // We don't actually mutate state here — empty-tree placeholder takes over rendering\n            } else {\n              const { tree } = parseInput(newRoot, {\n                disabledPredefinedKeys: disabledKeys,\n                dateDetection,\n              });\n              if (tree) {\n                queueCommit({ type: \"replace-tree\", tree });\n                setEmptyRoot(false);\n              }\n            }\n            return;\n          }\n          const r = validateCardRemove(stateRef.current, cardId);\n          if (!r.ok) return;\n          const removingCard = findCard(stateRef.current.tree, cardId);\n          if (!removingCard) return;\n          const parentIdForEvent = findParentId(stateRef.current.tree, cardId);\n          if (\n            !gateValidation(\n              \"cardRemove\",\n              {\n                cardId,\n                removed: treeToJsonNode(removingCard) as CardTreeJsonNode,\n                parentId: parentIdForEvent,\n              },\n              \"card-remove\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({\n            type: \"card-remove\",\n            cardId,\n            policy: policy ?? defaultDeletePolicy,\n            collisionStrategy: promoteCollisionStrategy,\n          });\n          clearEditMode();\n        },\n        cardRename: (cardId, newKey) => {\n          if (!perms.canEditCard(cardId)) return;\n          const r = validateCardRename(stateRef.current, cardId, newKey, disabledKeys);\n          if (!r.ok) return;\n          const oldKey = findCard(stateRef.current.tree, cardId)?.parentKey;\n          if (\n            !gateValidation(\n              \"cardRename\",\n              { cardId, oldKey, newKey },\n              \"card-rename\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"card-rename\", cardId, newParentKey: newKey });\n          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);\n          if (tentativeCardId === cardId) setTentativeCardId(null);\n          clearEditMode();\n        },\n        cardCancelTentative: (cardId) => {\n          queueCommit({ type: \"card-remove\", cardId, policy: \"cascade\" });\n          if (tentativeCardId === cardId) setTentativeCardId(null);\n          clearEditMode();\n        },\n        cardDuplicate: (cardId) => {\n          if (!perms.canEditCard(cardId)) return;\n          const newCardId = generateId();\n          const parentForEvent = findParentId(stateRef.current.tree, cardId);\n          if (\n            !gateValidation(\n              \"cardDuplicate\",\n              {\n                sourceCardId: cardId,\n                newCardId,\n                parentId: parentForEvent ?? \"\",\n              },\n              \"card-duplicate\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"card-duplicate\", cardId, newCardId });\n        },\n        predefinedAdd: (cardId, entry) => {\n          if (!perms.canAddPredefined(cardId, String(entry.key))) return;\n          const shape = validatePredefinedShape(entry.key as PredefinedKey, entry.value);\n          if (!shape.ok) return;\n          if (\n            !gateValidation(\n              \"predefinedAdd\",\n              { cardId, key: entry.key, value: entry.value },\n              \"predefined-add\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"predefined-add\", cardId, entry });\n          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);\n          setEditMode({ kind: \"predefined\", cardId, key: entry.key as PredefinedKey });\n        },\n        predefinedEdit: (cardId, key, entry) => {\n          if (!perms.canEditPredefined(cardId, String(key))) return;\n          const shape = validatePredefinedShape(entry.key as PredefinedKey, entry.value);\n          if (!shape.ok) return;\n          const oldEntry = findCard(stateRef.current.tree, cardId)?.predefined.find(\n            (p) => p.key === key,\n          );\n          if (\n            !gateValidation(\n              \"predefinedEdit\",\n              {\n                cardId,\n                key,\n                oldValue: oldEntry?.value ?? null,\n                newValue: entry.value,\n              },\n              \"predefined-edit\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"predefined-edit\", cardId, key, entry });\n          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);\n          clearEditMode();\n        },\n        predefinedRemove: (cardId, key) => {\n          if (!perms.canRemovePredefined(cardId, String(key))) return;\n          const oldEntry = findCard(stateRef.current.tree, cardId)?.predefined.find(\n            (p) => p.key === key,\n          );\n          if (\n            !gateValidation(\n              \"predefinedRemove\",\n              { cardId, key, oldValue: oldEntry?.value ?? null },\n              \"predefined-remove\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"predefined-remove\", cardId, key });\n          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);\n        },\n        metaEdit: (cardId, key, value) => {\n          const r = validateMetaEdit(stateRef.current, cardId, key, value);\n          if (!r.ok) return;\n          const oldValue =\n            findCard(stateRef.current.tree, cardId)?.meta?.[key] ?? null;\n          if (\n            !gateValidation(\n              \"metaEdit\",\n              { cardId, key, oldValue, newValue: value },\n              \"meta-edit\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"meta-edit\", cardId, key, value });\n        },\n        metaAdd: (cardId, key, value) => {\n          const r = validateMetaAdd(stateRef.current, cardId, key, value);\n          if (!r.ok) return;\n          if (\n            !gateValidation(\n              \"metaAdd\",\n              { cardId, key, value },\n              \"meta-add\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"meta-add\", cardId, key, value });\n        },\n        metaRemove: (cardId, key) => {\n          const oldValue =\n            findCard(stateRef.current.tree, cardId)?.meta?.[key] ?? null;\n          if (\n            !gateValidation(\n              \"metaRemove\",\n              { cardId, key, oldValue },\n              \"meta-remove\",\n              cardId,\n            )\n          )\n            return;\n          queueCommit({ type: \"meta-remove\", cardId, key });\n        },\n        selectCard: (cardId, event) => {\n          if (event.shiftKey) {\n            dispatch({ type: \"extend-selection-to\", id: cardId });\n          } else if (event.metaKey || event.ctrlKey) {\n            dispatch({ type: \"toggle-selection\", id: cardId });\n          } else {\n            dispatch({ type: \"set-multi-selection\", ids: [cardId], anchor: cardId });\n          }\n        },\n      }),\n      [\n        queueCommit,\n        clearEditMode,\n        setEditMode,\n        tentativeCardId,\n        perms,\n        disabledKeys,\n        defaultDeletePolicy,\n        promoteCollisionStrategy,\n        allowRootRemoval,\n        onRootRemoved,\n        dateDetection,\n        auditTrail,\n        gateValidation,\n      ],\n    );\n\n    const validators: EditValidators = useMemo(\n      () => ({\n        fieldEditValue: (cardId, key, value, type) =>\n          validateFieldEditValue(stateRef.current, cardId, key, value, type),\n        fieldEditKey: (cardId, oldKey, newKey) =>\n          validateFieldEditKey(stateRef.current, cardId, oldKey, newKey, disabledKeys),\n        fieldAdd: (cardId, key, value, type) =>\n          validateFieldAdd(stateRef.current, cardId, key, value, type, disabledKeys),\n        cardRename: (cardId, newKey) =>\n          validateCardRename(stateRef.current, cardId, newKey, disabledKeys),\n        metaEdit: (cardId, key, value) =>\n          validateMetaEdit(stateRef.current, cardId, key, value),\n        metaAdd: (cardId, key, value) =>\n          validateMetaAdd(stateRef.current, cardId, key, value),\n      }),\n      [disabledKeys],\n    );\n\n    /* ───────── DnD handlers ───────── */\n\n    const dndEnabled =\n      editable &&\n      ((dndScopes?.sameLevel ?? true) || (dndScopes?.crossLevel ?? true));\n\n    const sortableIds = useMemo(\n      () => visibleIdsInOrder(state.tree, state.collapsed).filter((id) => id !== state.tree.id),\n      [state.tree, state.collapsed],\n    );\n\n    const handleDragStart = useCallback(\n      (event: DragStartEvent) => {\n        const id = String(event.active.id);\n        dispatch({ type: \"drag-start\", cardId: id });\n      },\n      [],\n    );\n\n    const handleDragEnd = useCallback(\n      (event: DragEndEvent) => {\n        dispatch({ type: \"drag-end\" });\n        const { active, over } = event;\n        if (!over) return;\n        const activeId = String(active.id);\n        const overId = String(over.id);\n        if (activeId === overId) return;\n\n        // Determine drop semantics: if over is the same parent's sibling → same-level reorder;\n        // otherwise → cross-level reparent.\n        const tree = stateRef.current.tree;\n        const sourceParent = findParentId(tree, activeId);\n        const overParent = findParentId(tree, overId);\n        if (sourceParent === null) return; // can't drag root\n\n        // Check permissions\n        if (!perms.canDragCard(activeId)) return;\n\n        let newParentId: string;\n        let newOrder: number;\n\n        if (sourceParent === overParent) {\n          // Same-level reorder\n          if (!(dndScopes?.sameLevel ?? true)) return;\n          const overCard = findCard(tree, overId);\n          if (!overCard) return;\n          newParentId = overParent;\n          newOrder = overCard.order + 0.5;\n        } else {\n          // Cross-level reparent (drop into target as last child)\n          if (!(dndScopes?.crossLevel ?? true)) return;\n          if (!perms.canDropCard(activeId, overId)) return;\n          // Cycle check\n          const targetAncestors = findAncestorIds(tree, overId);\n          if (targetAncestors.includes(activeId) || activeId === overId) return;\n          newParentId = overId;\n          const overCard = findCard(tree, overId);\n          if (!overCard) return;\n          newOrder =\n            overCard.children.reduce((m, c) => Math.max(m, c.order), -1) + 1;\n        }\n\n        queueCommit({\n          type: \"card-move\",\n          cardId: activeId,\n          newParentId,\n          newOrder,\n        });\n      },\n      [perms, dndScopes, queueCommit],\n    );\n\n    /* ───────── config ───────── */\n\n    const config: CardConfig = useMemo(\n      () => ({\n        resolveLevelStyle,\n        predefinedKeyStyles,\n        metaPresentation,\n        metaRenderers,\n        rootTitle: ariaLabel,\n        disabledPredefinedKeys: disabledKeys,\n        editable,\n        editMode,\n        setEditMode,\n        clearEditMode,\n        tentativeCardId,\n        // eslint-disable-next-line react-hooks/refs\n        dispatchers,\n        // eslint-disable-next-line react-hooks/refs\n        validators,\n        perms,\n        allowRootRemoval,\n        defaultDeletePolicy,\n        searchMatches: searchResult.matches,\n        searchQuery: search?.query ?? \"\",\n        searchCaseSensitive: search?.caseSensitive ?? false,\n        dndEnabled,\n      }),\n      [\n        resolveLevelStyle,\n        predefinedKeyStyles,\n        metaPresentation,\n        metaRenderers,\n        ariaLabel,\n        disabledKeys,\n        editable,\n        editMode,\n        setEditMode,\n        clearEditMode,\n        tentativeCardId,\n        dispatchers,\n        validators,\n        perms,\n        allowRootRemoval,\n        defaultDeletePolicy,\n        searchResult.matches,\n        search?.query,\n        search?.caseSensitive,\n        dndEnabled,\n      ],\n    );\n\n    const onKeyDown = useTreeKeyboard(state, dispatch, editMode);\n\n    /* ───────── undo/redo (v0.4) ───────── */\n\n    const rootContainerRef = useRef<HTMLDivElement | null>(null);\n    const undoApi = useUndo(state, dispatch, {\n      enableShortcuts: !disableUndoShortcuts,\n      rootRef: rootContainerRef,\n      onUndo,\n      onRedo,\n    });\n\n    /* ───────── imperative handle ───────── */\n\n    useImperativeHandle(\n      ref,\n      (): CardTreeHandle => ({\n        getValue: () => serializeTree(state.tree),\n        getTree: () => treeToJsonNode(state.tree) as CardTreeJsonNode,\n        isDirty: () => isDirtySelector(state),\n        markClean: () => dispatch({ type: \"mark-clean\" }),\n        getSelectedId: () =>\n          state.selectedIds.size > 0 ? Array.from(state.selectedIds)[0] ?? null : null,\n        getSelectedIds: () => Array.from(state.selectedIds),\n        setSelection: (ids) => {\n          if (ids === null) {\n            dispatch({ type: \"clear-selection\" });\n            return;\n          }\n          const arr = typeof ids === \"string\" ? [ids] : Array.from(ids);\n          dispatch({\n            type: \"set-multi-selection\",\n            ids: arr,\n            anchor: arr[arr.length - 1] ?? null,\n          });\n        },\n        focusCard: (id) => {\n          dispatch({ type: \"set-focus\", id });\n          requestAnimationFrame(() => {\n            document.querySelector<HTMLElement>(`[data-rcid=\"${id}\"]`)?.focus();\n          });\n        },\n        addCardAt: (parentId) => {\n          const newId = generateId();\n          const parent = findCard(state.tree, parentId);\n          const order = parent\n            ? parent.children.reduce((m, c) => Math.max(m, c.order), -1) + 1\n            : 0;\n          const newCard: ParsedCardTree = {\n            id: newId,\n            order,\n            level: (parent?.level ?? 0) + 1,\n            parentKey: \"untitled\",\n            fields: [],\n            predefined: [],\n            children: [],\n          };\n          queueCommit({ type: \"card-add\", parentId, card: newCard });\n          return newId;\n        },\n        removeCard: (id) => {\n          queueCommit({ type: \"card-remove\", cardId: id, policy: \"cascade\" });\n        },\n        replaceRoot: (newRoot) => {\n          if (newRoot === null) {\n            setEmptyRoot(true);\n            return;\n          }\n          const { tree } = parseInput(newRoot, {\n            disabledPredefinedKeys: disabledKeys,\n            dateDetection,\n          });\n          if (tree) {\n            queueCommit({ type: \"replace-tree\", tree });\n            setEmptyRoot(false);\n          }\n        },\n        getEffectivePermissions: (cardId, target) => {\n          const card = findCard(state.tree, cardId);\n          if (!card) {\n            return {\n              edit: false,\n              add: false,\n              remove: false,\n              reorder: false,\n              reparent: false,\n            } as EffectivePermissions;\n          }\n          return perms.getEffective({\n            cardId,\n            level: card.level,\n            kind: target?.kind ?? \"card\",\n            key: target?.key,\n          });\n        },\n        findNext: () => findNextMatch(state, searchResult, dispatch),\n        findPrevious: () => findPrevMatch(state, searchResult, dispatch),\n        scrollToMatch: (match) => {\n          dispatch({ type: \"set-active-match-index\", index: searchResult.matches.indexOf(match) });\n          requestAnimationFrame(() => {\n            document\n              .querySelector<HTMLElement>(`[data-rcid=\"${match.cardId}\"]`)\n              ?.scrollIntoView({ block: \"center\", behavior: \"smooth\" });\n          });\n        },\n        clearSearch: () => {\n          dispatch({ type: \"clear-search\" });\n        },\n        // v0.4\n        undo: () => undoApi.undo(),\n        redo: () => undoApi.redo(),\n        canUndo: () => undoApi.canUndo,\n        canRedo: () => undoApi.canRedo,\n        clearHistory: () => undoApi.clearHistory(),\n      }),\n      [state, perms, searchResult, queueCommit, disabledKeys, dateDetection, undoApi],\n    );\n\n    /* ───────── render ───────── */\n\n    if (emptyRoot) {\n      return (\n        <div role=\"region\" aria-label={ariaLabel} className={cn(\"w-full\", className)}>\n          {emptyTreeRenderer ? (\n            emptyTreeRenderer()\n          ) : (\n            <EmptyTreePlaceholder\n              onAddRoot={\n                editable\n                  ? () => {\n                      // Build a minimal new root and replace\n                      const newRoot: CardTreeJsonNode = {\n                        title: \"untitled\",\n                      };\n                      const { tree } = parseInput(newRoot, {\n                        disabledPredefinedKeys: disabledKeys,\n                        dateDetection,\n                      });\n                      if (tree) {\n                        dispatch({ type: \"replace-tree\", tree });\n                        setEmptyRoot(false);\n                      }\n                    }\n                  : undefined\n              }\n            />\n          )}\n        </div>\n      );\n    }\n\n    const treeContent = (\n      <ul\n        role=\"tree\"\n        aria-label={ariaLabel}\n        aria-multiselectable={editable ? \"true\" : undefined}\n        onKeyDown={onKeyDown}\n        className=\"block list-none p-0 m-0\"\n      >\n        <Card tree={state.tree} config={config} state={state} dispatch={dispatch} />\n      </ul>\n    );\n\n    return (\n      <div\n        ref={rootContainerRef}\n        role=\"region\"\n        aria-label={ariaLabel}\n        tabIndex={-1}\n        className={cn(\"w-full focus:outline-none\", className)}\n        onClick={(e) => {\n          if (e.target === e.currentTarget) {\n            dispatch({ type: \"clear-selection\" });\n          }\n        }}\n      >\n        {dndEnabled ? (\n          <DndContextWrapper\n            sortableIds={sortableIds}\n            onDragStart={handleDragStart}\n            onDragEnd={handleDragEnd}\n          >\n            {treeContent}\n          </DndContextWrapper>\n        ) : (\n          treeContent\n        )}\n      </div>\n    );\n  },\n);\n\n/* ───────── DnD wrapper (only mounts sensors when needed) ───────── */\n\nfunction DndContextWrapper({\n  sortableIds,\n  onDragStart,\n  onDragEnd,\n  children,\n}: {\n  sortableIds: string[];\n  onDragStart: (event: DragStartEvent) => void;\n  onDragEnd: (event: DragEndEvent) => void;\n  children: React.ReactNode;\n}) {\n  const sensors = useDndSensors();\n  return (\n    <DndContext\n      sensors={sensors}\n      collisionDetection={collisionStrategy}\n      onDragStart={onDragStart}\n      onDragEnd={onDragEnd}\n    >\n      <SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>\n        {children}\n      </SortableContext>\n    </DndContext>\n  );\n}\n\n/* ───────── audit trail helper ───────── */\n\nfunction stampAuditTrail(\n  queueCommit: (action: CardTreeAction) => void,\n  state: CardTreeState,\n  cardId: string,\n  auditTrail: { editor?: string; lastEditedKey?: string; lastEditorKey?: string } | undefined,\n): void {\n  if (!auditTrail) return;\n  const lastEditedKey = auditTrail.lastEditedKey ?? \"_lastEdited\";\n  const lastEditorKey = auditTrail.lastEditorKey ?? \"_lastEditor\";\n  const card = findCard(state.tree, cardId);\n  if (!card) return;\n  const now = new Date().toISOString();\n  if (card.meta?.[lastEditedKey] !== now) {\n    queueCommit({ type: \"meta-edit\", cardId, key: lastEditedKey, value: now });\n  }\n  if (auditTrail.editor && card.meta?.[lastEditorKey] !== auditTrail.editor) {\n    queueCommit({\n      type: \"meta-edit\",\n      cardId,\n      key: lastEditorKey,\n      value: auditTrail.editor,\n    });\n  }\n}\n\n/* ───────── search navigation ───────── */\n\nfunction findNextMatch(\n  state: CardTreeState,\n  result: { matches: SearchMatch[]; activeIndex: number | null },\n  dispatch: (action: CardTreeAction) => void,\n): SearchMatch | null {\n  if (result.matches.length === 0) return null;\n  const next = ((result.activeIndex ?? -1) + 1) % result.matches.length;\n  dispatch({ type: \"set-active-match-index\", index: next });\n  const match = result.matches[next];\n  requestAnimationFrame(() => {\n    document\n      .querySelector<HTMLElement>(`[data-rcid=\"${match.cardId}\"]`)\n      ?.scrollIntoView({ block: \"center\", behavior: \"smooth\" });\n  });\n  return match;\n}\n\nfunction findPrevMatch(\n  state: CardTreeState,\n  result: { matches: SearchMatch[]; activeIndex: number | null },\n  dispatch: (action: CardTreeAction) => void,\n): SearchMatch | null {\n  if (result.matches.length === 0) return null;\n  const prev =\n    (result.activeIndex ?? 0) - 1 < 0\n      ? result.matches.length - 1\n      : (result.activeIndex ?? 0) - 1;\n  dispatch({ type: \"set-active-match-index\", index: prev });\n  const match = result.matches[prev];\n  requestAnimationFrame(() => {\n    document\n      .querySelector<HTMLElement>(`[data-rcid=\"${match.cardId}\"]`)\n      ?.scrollIntoView({ block: \"center\", behavior: \"smooth\" });\n  });\n  return match;\n}\n\n/* ───────── granular event firing ───────── */\n\ntype EventHandlers = {\n  onFieldEdited?: (e: FieldEditedEvent) => void;\n  onFieldAdded?: (e: FieldAddedEvent) => void;\n  onFieldRemoved?: (e: FieldRemovedEvent) => void;\n  onCardAdded?: (e: CardAddedEvent) => void;\n  onCardRemoved?: (e: CardRemovedEvent) => void;\n  onCardRenamed?: (e: CardRenamedEvent) => void;\n  onPredefinedAdded?: (e: PredefinedAddedEvent) => void;\n  onPredefinedEdited?: (e: PredefinedEditedEvent) => void;\n  onPredefinedRemoved?: (e: PredefinedRemovedEvent) => void;\n  onCardMoved?: (e: CardMovedEvent) => void;\n  onCardDuplicated?: (e: CardDuplicatedEvent) => void;\n  onMetaChanged?: (e: MetaChangedEvent) => void;\n  onMetaAdded?: (e: MetaAddedEvent) => void;\n  onMetaRemoved?: (e: MetaRemovedEvent) => void;\n};\n\nfunction fireGranularEvent(\n  action: CardTreeAction,\n  prev: CardTreeState,\n  next: CardTreeState,\n  h: EventHandlers,\n) {\n  switch (action.type) {\n    case \"field-edit-value\": {\n      const prevCard = findCard(prev.tree, action.cardId);\n      const prevField = prevCard?.fields.find((f) => f.key === action.key);\n      if (!prevField) return;\n      h.onFieldEdited?.({\n        cardId: action.cardId,\n        key: action.key,\n        oldValue: prevField.value,\n        oldType: prevField.type,\n        newValue: action.value,\n        newType: action.valueType,\n      });\n      return;\n    }\n    case \"field-edit-key\": {\n      const prevCard = findCard(prev.tree, action.cardId);\n      const prevField = prevCard?.fields.find((f) => f.key === action.oldKey);\n      if (!prevField) return;\n      h.onFieldEdited?.({\n        cardId: action.cardId,\n        key: action.newKey,\n        oldValue: prevField.value,\n        oldType: prevField.type,\n        newValue: prevField.value,\n        newType: prevField.type,\n      });\n      return;\n    }\n    case \"field-add\":\n      h.onFieldAdded?.({\n        cardId: action.cardId,\n        key: action.key,\n        value: action.value,\n        type: action.valueType,\n      });\n      return;\n    case \"field-remove\": {\n      const prevCard = findCard(prev.tree, action.cardId);\n      const prevField = prevCard?.fields.find((f) => f.key === action.key);\n      if (!prevField) return;\n      h.onFieldRemoved?.({\n        cardId: action.cardId,\n        key: action.key,\n        oldValue: prevField.value,\n        oldType: prevField.type,\n      });\n      return;\n    }\n    case \"card-add\":\n      h.onCardAdded?.({\n        parentId: action.parentId,\n        card: treeToJsonNode(action.card) as CardTreeJsonNode,\n      });\n      return;\n    case \"card-remove\": {\n      const prevSubtree = findCard(prev.tree, action.cardId);\n      if (!prevSubtree) return;\n      const parentId = findParentId(prev.tree, action.cardId);\n      h.onCardRemoved?.({\n        cardId: action.cardId,\n        removed: treeToJsonNode(prevSubtree) as CardTreeJsonNode,\n        parentId,\n      });\n      return;\n    }\n    case \"card-rename\": {\n      const prevCard = findCard(prev.tree, action.cardId);\n      h.onCardRenamed?.({\n        cardId: action.cardId,\n        oldKey: prevCard?.parentKey,\n        newKey: action.newParentKey,\n      });\n      return;\n    }\n    case \"card-move\": {\n      const prevCard = findCard(prev.tree, action.cardId);\n      const prevParentId = findParentId(prev.tree, action.cardId);\n      h.onCardMoved?.({\n        cardId: action.cardId,\n        oldParentId: prevParentId ?? \"\",\n        newParentId: action.newParentId,\n        oldOrder: prevCard?.order ?? 0,\n        newOrder: action.newOrder,\n      });\n      return;\n    }\n    case \"card-duplicate\": {\n      const sourceParentId = findParentId(prev.tree, action.cardId);\n      h.onCardDuplicated?.({\n        sourceCardId: action.cardId,\n        newCardId: action.newCardId,\n        parentId: sourceParentId ?? \"\",\n      });\n      return;\n    }\n    case \"predefined-add\":\n      h.onPredefinedAdded?.({\n        cardId: action.cardId,\n        key: action.entry.key,\n        value: action.entry.value,\n      });\n      return;\n    case \"predefined-edit\": {\n      const prevCard = findCard(prev.tree, action.cardId);\n      const prevEntry = prevCard?.predefined.find((p) => p.key === action.key);\n      h.onPredefinedEdited?.({\n        cardId: action.cardId,\n        key: action.key as PredefinedKey,\n        oldValue: prevEntry?.value ?? null,\n        newValue: action.entry.value,\n      });\n      return;\n    }\n    case \"predefined-remove\": {\n      const prevCard = findCard(prev.tree, action.cardId);\n      const prevEntry = prevCard?.predefined.find((p) => p.key === action.key);\n      h.onPredefinedRemoved?.({\n        cardId: action.cardId,\n        key: action.key as PredefinedKey,\n        oldValue: prevEntry?.value ?? null,\n      });\n      return;\n    }\n    case \"meta-edit\": {\n      const prevCard = findCard(prev.tree, action.cardId);\n      const oldValue = prevCard?.meta?.[action.key] ?? null;\n      h.onMetaChanged?.({\n        cardId: action.cardId,\n        key: action.key,\n        oldValue,\n        newValue: action.value,\n      });\n      return;\n    }\n    case \"meta-add\":\n      h.onMetaAdded?.({\n        cardId: action.cardId,\n        key: action.key,\n        value: action.value,\n      });\n      return;\n    case \"meta-remove\": {\n      const prevCard = findCard(prev.tree, action.cardId);\n      const oldValue = prevCard?.meta?.[action.key] ?? null;\n      h.onMetaRemoved?.({\n        cardId: action.cardId,\n        key: action.key,\n        oldValue,\n      });\n      return;\n    }\n    default:\n      return;\n  }\n  // Reference unused imports\n  void ({} as CardTreePredefinedEntry);\n  void ({} as FlatFieldType);\n  void ({} as CustomPredefinedKey);\n  void ({} as SearchOptions);\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/card-tree.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/index.ts",
      "content": "export { CardTree } from \"./card-tree\";\n\n// v0.1 types\nexport type {\n  CodeAreaValue,\n  FlatFieldType,\n  FlatFieldValue,\n  ImageValue,\n  LevelStyle,\n  ListValue,\n  PredefinedKey,\n  QuoteValue,\n  CardTreeHandle,\n  CardTreeJsonNode,\n  CardTreeProps,\n  TableValue,\n} from \"./types\";\n\n// v0.2 event types\nexport type {\n  CardAddedEvent,\n  CardRemovedEvent,\n  CardRenamedEvent,\n  FieldAddedEvent,\n  FieldEditedEvent,\n  FieldRemovedEvent,\n  PredefinedAddedEvent,\n  PredefinedEditedEvent,\n  PredefinedRemovedEvent,\n} from \"./types\";\n\n// v0.4 validator types\nexport type {\n  CardTreeMasterValidator,\n  CardTreeValidationError,\n  CardTreeValidationResponse,\n  CardTreeValidators,\n  ValidationFailedEvent,\n} from \"./types\";\n\n// constants\nexport { PREDEFINED_KEYS, RESERVED_KEYS } from \"./types\";\n\n// optional sibling exports (v0.3 search bar + v0.4 undo toolbar)\nexport { CardTreeSearchBar } from \"./parts/search-bar\";\nexport { CardTreeUndoToolbar } from \"./parts/undo-toolbar\";\n\n// component metadata\n",
      "type": "registry:component",
      "target": "components/card-tree/index.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/types.ts",
      "content": "/**\n * Public types for the card-tree pro-component.\n *\n * v0.1: viewer (typed scalar fields, predefined-key blocks, per-level styling, ARIA tree).\n * v0.2: inline editor (click-to-edit, granular events, dirty tracking, single-select).\n * v0.3 (current): structural management — drag-drop, bulk multi-select, permission matrix,\n *                 custom predefined-keys, virtualization, native search, meta editing,\n *                 root-removal, promote-on-delete.\n *\n * See:\n *   - docs/procomps/card-tree-procomp/card-tree-procomp-plan.md (v0.1)\n *   - docs/procomps/card-tree-procomp/card-tree-procomp-plan-v0.2.md\n *   - docs/procomps/card-tree-procomp/card-tree-procomp-plan-v0.3.md\n */\n\nimport type { ReactNode } from \"react\";\n\n/* ───────── reserved + predefined keys ───────── */\n\nexport const RESERVED_KEYS = [\"__rcid\", \"__rcorder\", \"__rcmeta\"] as const;\nexport type ReservedKey = (typeof RESERVED_KEYS)[number];\n\nexport const PREDEFINED_KEYS = [\n  \"codearea\",\n  \"image\",\n  \"table\",\n  \"quote\",\n  \"list\",\n] as const;\nexport type PredefinedKey = (typeof PREDEFINED_KEYS)[number];\n\n/* ───────── flat-field scalar values ───────── */\n\nexport type FlatFieldValue = string | number | boolean | null;\n\n/** Re-exported from lib/infer-type so it's part of the public surface (referenced by event types). */\nexport type { FlatFieldType } from \"./lib/infer-type\";\n\n/* ───────── predefined-key payload shapes ───────── */\n\nexport type CodeAreaValue = { format: string; content: string };\nexport type ImageValue = { src: string; alt?: string };\nexport type TableValue = { headers: string[]; rows: FlatFieldValue[][] };\nexport type QuoteValue = string;\nexport type ListValue = FlatFieldValue[];\n\n/* ───────── input node shape ───────── */\n\nexport type CardTreeJsonNode = {\n  __rcid?: string;\n  __rcorder?: number;\n  __rcmeta?: Record<string, FlatFieldValue>;\n  [key: string]: unknown;\n};\n\n/* ───────── styling ───────── */\n\nexport type LevelStyle = {\n  containerClassName?: string;\n  headerClassName?: string;\n  fieldsClassName?: string;\n  childrenClassName?: string;\n};\n\n/* ───────── v0.2 event types ───────── */\n\nimport type { FlatFieldType as _FlatFieldType } from \"./lib/infer-type\";\n\nexport type FieldEditedEvent = {\n  cardId: string;\n  key: string;\n  oldValue: FlatFieldValue;\n  oldType: _FlatFieldType;\n  newValue: FlatFieldValue;\n  newType: _FlatFieldType;\n};\n\nexport type FieldAddedEvent = {\n  cardId: string;\n  key: string;\n  value: FlatFieldValue;\n  type: _FlatFieldType;\n};\n\nexport type FieldRemovedEvent = {\n  cardId: string;\n  key: string;\n  oldValue: FlatFieldValue;\n  oldType: _FlatFieldType;\n};\n\nexport type CardAddedEvent = {\n  parentId: string;\n  card: CardTreeJsonNode;\n};\n\nexport type CardRemovedEvent = {\n  cardId: string;\n  removed: CardTreeJsonNode;\n  parentId: string | null;\n};\n\nexport type CardRenamedEvent = {\n  cardId: string;\n  oldKey: string | undefined;\n  newKey: string;\n};\n\nexport type PredefinedAddedEvent = {\n  cardId: string;\n  key: PredefinedKey | string;\n  value: unknown;\n};\n\nexport type PredefinedEditedEvent = {\n  cardId: string;\n  key: PredefinedKey | string;\n  oldValue: unknown;\n  newValue: unknown;\n};\n\nexport type PredefinedRemovedEvent = {\n  cardId: string;\n  key: PredefinedKey | string;\n  oldValue: unknown;\n};\n\n/* ───────── v0.3 event types ───────── */\n\nexport type CardMovedEvent = {\n  cardId: string;\n  oldParentId: string;\n  newParentId: string;\n  oldOrder: number;\n  newOrder: number;\n};\n\nexport type CardDuplicatedEvent = {\n  sourceCardId: string;\n  newCardId: string;\n  parentId: string;\n};\n\nexport type MetaChangedEvent = {\n  cardId: string;\n  key: string;\n  oldValue: FlatFieldValue;\n  newValue: FlatFieldValue;\n};\n\nexport type MetaAddedEvent = {\n  cardId: string;\n  key: string;\n  value: FlatFieldValue;\n};\n\nexport type MetaRemovedEvent = {\n  cardId: string;\n  key: string;\n  oldValue: FlatFieldValue;\n};\n\n/* ───────── v0.3 search ───────── */\n\nexport type SearchOptions = {\n  query: string;\n  caseSensitive?: boolean;\n  matchTitles?: boolean;\n  matchKeys?: boolean;\n  matchValues?: boolean;\n  matchPredefined?: boolean;\n  matchMeta?: boolean;\n};\n\nexport type SearchMatchType =\n  | \"title\"\n  | \"field-key\"\n  | \"field-value\"\n  | \"predefined\"\n  | \"meta-key\"\n  | \"meta-value\";\n\nexport type SearchMatch = {\n  cardId: string;\n  matchType: SearchMatchType;\n  fieldKey?: string;\n  excerpt: string;\n  start: number;\n  length: number;\n};\n\nexport type SearchResult = {\n  matches: SearchMatch[];\n  matchedCardIds: ReadonlySet<string>;\n  activeIndex: number | null;\n};\n\n/* ───────── v0.3 permissions ───────── */\n\nexport type PermissionRule = {\n  edit?: boolean;\n  add?: boolean;\n  remove?: boolean;\n  reorder?: boolean;\n  reparent?: boolean;\n};\n\nexport type CardTreePermissions = {\n  default?: PermissionRule;\n  byLevel?: Record<number, PermissionRule>;\n  byCard?: Record<string, PermissionRule>;\n  byPredefinedKey?: Partial<Record<PredefinedKey, PermissionRule>>;\n  byFieldType?: Partial<Record<_FlatFieldType, PermissionRule>>;\n  inherit?: boolean;\n};\n\nexport type PermissionDenialReason =\n  | \"global-editable-false\"\n  | \"meta-locked\"\n  | \"by-card\"\n  | \"by-predefined-key\"\n  | \"by-field-type\"\n  | \"by-level\"\n  | \"default\"\n  | \"predicate\";\n\nexport type EffectivePermissions = PermissionRule & {\n  reason?: PermissionDenialReason;\n};\n\n/* ───────── v0.3 DnD ───────── */\n\nexport type DndScopes = {\n  sameLevel?: boolean;\n  crossLevel?: boolean;\n};\n\n/* ───────── v0.3 custom predefined keys ───────── */\n\nexport type CustomKeyContext = {\n  cardId: string;\n  level: number;\n  isEditing: boolean;\n  className?: string;\n};\n\nexport type CustomPredefinedKey = {\n  key: string;\n  description?: string;\n  icon?: ReactNode;\n  category?: string;\n  validate: (value: unknown) => { ok: boolean; errors?: { code: string; message: string }[] };\n  render: (value: unknown, ctx: CustomKeyContext) => ReactNode;\n  edit?: (\n    value: unknown,\n    onSave: (next: unknown) => void,\n    onCancel: () => void,\n  ) => ReactNode;\n  defaultValue: () => unknown;\n  /** v0.4 candidate; not consumed in v0.3 */\n  searchableText?: (value: unknown) => string[];\n};\n\n/* ───────── v0.3 meta renderers ───────── */\n\nexport type MetaRendererContext = {\n  cardId: string;\n  metaKey: string;\n  className?: string;\n};\n\nexport type MetaRenderer = (\n  value: FlatFieldValue,\n  ctx: MetaRendererContext,\n) => ReactNode;\n\n/* ───────── v0.3 audit trail ───────── */\n\nexport type AuditTrailConfig = {\n  /** Author identifier stamped on `_lastEditor` meta entry. */\n  editor?: string;\n  /** Reserved meta key names for the audit trail. */\n  lastEditedKey?: string;     // default '_lastEdited'\n  lastEditorKey?: string;     // default '_lastEditor'\n};\n\n/* ───────── v0.4 validation hooks ───────── */\n\nexport type CardTreeValidationError = { code: string; message: string };\n\nexport type CardTreeValidationResponse =\n  | { ok: true }\n  | { ok: false; errors: CardTreeValidationError[] };\n\nexport type CardTreeValidators = {\n  fieldEdit?: (\n    event: FieldEditedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  fieldAdd?: (\n    event: FieldAddedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  fieldRemove?: (\n    event: FieldRemovedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  cardAdd?: (\n    event: CardAddedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  cardRemove?: (\n    event: CardRemovedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  cardRename?: (\n    event: CardRenamedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  cardMove?: (\n    event: CardMovedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  cardDuplicate?: (\n    event: CardDuplicatedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  predefinedAdd?: (\n    event: PredefinedAddedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  predefinedEdit?: (\n    event: PredefinedEditedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  predefinedRemove?: (\n    event: PredefinedRemovedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  metaEdit?: (\n    event: MetaChangedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  metaAdd?: (\n    event: MetaAddedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n  metaRemove?: (\n    event: MetaRemovedEvent,\n    tree: CardTreeJsonNode,\n  ) => CardTreeValidationResponse;\n};\n\nexport type CardTreeMasterValidator = (\n  action: { type: string; cardId?: string },\n  tree: CardTreeJsonNode,\n) => CardTreeValidationResponse;\n\nexport type ValidationFailedEvent = {\n  action: string;\n  cardId?: string;\n  errors: CardTreeValidationError[];\n  layer: \"per-action\" | \"master\";\n};\n\n/* ───────── component props ───────── */\n\nexport type CardTreeProps = {\n  defaultValue: CardTreeJsonNode;\n\n  // styling (v0.1)\n  levelStyles?: LevelStyle[];\n  getLevelStyle?: (level: number) => LevelStyle;\n  predefinedKeyStyles?: Partial<Record<PredefinedKey | string, string | LevelStyle>>;\n\n  // viewer behavior (v0.1)\n  defaultCollapsed?: \"all\" | \"none\" | ((level: number) => boolean);\n  metaPresentation?: \"hidden\" | \"inline\" | \"popover\";\n  disabledPredefinedKeys?: PredefinedKey[];\n  dateDetection?: \"auto\" | \"never\" | ((value: string) => boolean);\n\n  // editor behavior (v0.2)\n  editable?: boolean;\n  onChange?: (tree: CardTreeJsonNode) => void;\n  onFieldEdited?: (event: FieldEditedEvent) => void;\n  onFieldAdded?: (event: FieldAddedEvent) => void;\n  onFieldRemoved?: (event: FieldRemovedEvent) => void;\n  onCardAdded?: (event: CardAddedEvent) => void;\n  onCardRemoved?: (event: CardRemovedEvent) => void;\n  onCardRenamed?: (event: CardRenamedEvent) => void;\n  onPredefinedAdded?: (event: PredefinedAddedEvent) => void;\n  onPredefinedEdited?: (event: PredefinedEditedEvent) => void;\n  onPredefinedRemoved?: (event: PredefinedRemovedEvent) => void;\n\n  // v0.3 — DnD\n  dndScopes?: DndScopes;\n  onCardMoved?: (event: CardMovedEvent) => void;\n  onCardDuplicated?: (event: CardDuplicatedEvent) => void;\n\n  // v0.3 — permissions (declarative + predicate escape hatches)\n  permissions?: CardTreePermissions;\n  canEditField?: (cardId: string, key: string) => boolean;\n  canAddField?: (cardId: string) => boolean;\n  canRemoveField?: (cardId: string, key: string) => boolean;\n  canEditCard?: (cardId: string) => boolean;\n  canAddCard?: (parentId: string) => boolean;\n  canRemoveCard?: (cardId: string) => boolean;\n  canEditPredefined?: (cardId: string, key: string) => boolean;\n  canAddPredefined?: (cardId: string, key: string) => boolean;\n  canRemovePredefined?: (cardId: string, key: string) => boolean;\n  canDragCard?: (cardId: string) => boolean;\n  canDropCard?: (cardId: string, targetParentId: string) => boolean;\n  /** Fired (analytics-only) when an action would have been blocked. */\n  onPermissionDenied?: (\n    action: keyof PermissionRule,\n    cardId: string,\n    target: string | undefined,\n    reason: PermissionDenialReason,\n  ) => void;\n\n  // v0.3 — custom predefined keys\n  customPredefinedKeys?: CustomPredefinedKey[];\n\n  // v0.3 — root-removal + delete policy\n  allowRootRemoval?: boolean;\n  onRootRemoved?: (current: CardTreeJsonNode) => CardTreeJsonNode | null;\n  defaultDeletePolicy?: \"cascade\" | \"promote\";\n  promoteCollisionStrategy?: \"suffix\" | \"qualify\" | \"reject\";\n  emptyTreeRenderer?: () => ReactNode;\n\n  // v0.3 — meta editing + audit\n  metaRenderers?: Record<string, MetaRenderer>;\n  auditTrail?: AuditTrailConfig;\n  onMetaChanged?: (event: MetaChangedEvent) => void;\n  onMetaAdded?: (event: MetaAddedEvent) => void;\n  onMetaRemoved?: (event: MetaRemovedEvent) => void;\n\n  // v0.3 — search (controlled by host)\n  search?: SearchOptions;\n  onSearchResults?: (result: SearchResult) => void;\n\n  // v0.3 — multi-select handler (BREAKING change from v0.2's `id: string | null`)\n  onSelectionChange?: (ids: readonly string[]) => void;\n\n  // v0.4 — validation hooks\n  validators?: CardTreeValidators;\n  validate?: CardTreeMasterValidator;\n  onValidationFailed?: (event: ValidationFailedEvent) => void;\n\n  // v0.4 — undo/redo\n  maxUndoDepth?: number;\n  disableUndoShortcuts?: boolean;\n  onUndo?: () => void;\n  onRedo?: () => void;\n\n  // container\n  className?: string;\n  \"aria-label\"?: string;\n};\n\n/* ───────── imperative ref handle ───────── */\n\nexport type CardTreeHandle = {\n  // v0.1\n  /** Canonical pretty-printed JSON string with auto-attached IDs. */\n  getValue(): string;\n  /** Object form of the parsed tree, with auto-attached IDs preserved. */\n  getTree(): CardTreeJsonNode;\n  // v0.2\n  isDirty(): boolean;\n  markClean(): void;\n  /** Returns the first selected card id, or null. v0.3 retains for backward compat with single-select consumers. */\n  getSelectedId(): string | null;\n  // v0.3\n  getSelectedIds(): readonly string[];\n  setSelection(ids: readonly string[] | string | null): void;\n  focusCard(id: string): void;\n  addCardAt(parentId: string, position?: number): string;\n  removeCard(id: string): void;\n  /** Programmatic root replacement when allowRootRemoval is enabled. */\n  replaceRoot(newRoot: CardTreeJsonNode | null): void;\n  /** Returns effective permission resolution for a card (debug / host-side gating). */\n  getEffectivePermissions(\n    cardId: string,\n    target?: { kind: \"field\" | \"predefined\" | \"card\"; key?: string },\n  ): EffectivePermissions;\n  // v0.3 — search\n  findNext(): SearchMatch | null;\n  findPrevious(): SearchMatch | null;\n  scrollToMatch(match: SearchMatch): void;\n  clearSearch(): void;\n  // v0.4 — undo/redo\n  undo(): boolean;\n  redo(): boolean;\n  canUndo(): boolean;\n  canRedo(): boolean;\n  clearHistory(): void;\n};\n",
      "type": "registry:component",
      "target": "components/card-tree/types.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-bulk-selection.ts",
      "content": "import { useCallback, type Dispatch } from \"react\";\nimport type {\n  CardTreeAction,\n  CardTreeState,\n} from \"../lib/reducer\";\n\n/**\n * v0.3 multi-select helpers. Click semantics:\n *   - plain click       → replace selection with single id\n *   - shift-click       → range select from anchor to id\n *   - cmd/ctrl-click    → toggle id in/out of selection\n */\nexport function useBulkSelection(\n  state: CardTreeState,\n  dispatch: Dispatch<CardTreeAction>,\n) {\n  const select = useCallback(\n    (id: string) => {\n      dispatch({ type: \"set-multi-selection\", ids: [id], anchor: id });\n    },\n    [dispatch],\n  );\n\n  const toggle = useCallback(\n    (id: string) => {\n      dispatch({ type: \"toggle-selection\", id });\n    },\n    [dispatch],\n  );\n\n  const extend = useCallback(\n    (id: string) => {\n      dispatch({ type: \"extend-selection-to\", id });\n    },\n    [dispatch],\n  );\n\n  const clear = useCallback(() => {\n    dispatch({ type: \"clear-selection\" });\n  }, [dispatch]);\n\n  const isSelected = useCallback(\n    (id: string) => state.selectedIds.has(id),\n    [state.selectedIds],\n  );\n\n  const handleClick = useCallback(\n    (id: string, event: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => {\n      if (event.shiftKey) extend(id);\n      else if (event.metaKey || event.ctrlKey) toggle(id);\n      else select(id);\n    },\n    [extend, toggle, select],\n  );\n\n  return {\n    selectedIds: state.selectedIds,\n    anchorId: state.selectionAnchorId,\n    isSelected,\n    select,\n    toggle,\n    extend,\n    clear,\n    handleClick,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-bulk-selection.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-collapse-state.ts",
      "content": "import { useCallback, useMemo, type Dispatch } from \"react\";\nimport type { CardTreeAction, CardTreeState } from \"../lib/reducer\";\n\n/**\n * Memoized selector + dispatcher for collapse state.\n * The Card component reads state.collapsed directly for render; this hook is\n * for callers that need a stable closure over `isCollapsed` / `toggle`.\n */\nexport function useCollapseState(\n  state: CardTreeState,\n  dispatch: Dispatch<CardTreeAction>,\n) {\n  const isCollapsed = useCallback(\n    (id: string) => state.collapsed.has(id),\n    [state.collapsed],\n  );\n\n  const toggle = useCallback(\n    (id: string) => dispatch({ type: \"toggle-collapse\", id }),\n    [dispatch],\n  );\n\n  return useMemo(() => ({ isCollapsed, toggle }), [isCollapsed, toggle]);\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-collapse-state.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-dirty.ts",
      "content": "import type { CardTreeState } from \"../lib/reducer\";\n\n/**\n * Pure selector for dirty state. Counter-based (every commit-action increments\n * `version`; mark-clean snapshots `cleanVersion`). Cheap and predictable.\n *\n * v0.4 will upgrade to structural-diff dirty tracking once undo/redo lands.\n */\nexport function isDirty(state: CardTreeState): boolean {\n  return state.version !== state.cleanVersion;\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-dirty.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-dnd-config.ts",
      "content": "\"use client\";\n\nimport {\n  KeyboardSensor,\n  PointerSensor,\n  closestCenter,\n  useSensor,\n  useSensors,\n  type CollisionDetection,\n} from \"@dnd-kit/core\";\n\nconst DRAG_DISTANCE_THRESHOLD = 6; // px before drag activates\n\n/**\n * Configures @dnd-kit sensors with a small drag-distance threshold so that\n * single-clicks (used for entering edit mode) don't accidentally trigger drag.\n */\nexport function useDndSensors() {\n  return useSensors(\n    useSensor(PointerSensor, {\n      activationConstraint: { distance: DRAG_DISTANCE_THRESHOLD },\n    }),\n    useSensor(KeyboardSensor),\n  );\n}\n\n/**\n * Default collision detection: closest-center against all draggable rectangles.\n * Refinement (between-siblings vs into-card) happens at the drop-event handler.\n */\nexport const collisionStrategy: CollisionDetection = closestCenter;\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-dnd-config.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-edit-mode.ts",
      "content": "import { useCallback, useState } from \"react\";\nimport type { PredefinedKey } from \"../types\";\n\n/**\n * Transient UI state: what's currently being edited.\n *\n * Lives outside the reducer so undo/redo (v0.4) won't replay edit-mode\n * focus. Each card / field / block owns its own input state internally\n * via local useState; this hook just flags WHICH thing is in edit mode.\n */\n\nexport type EditMode =\n  | { kind: \"field-value\"; cardId: string; key: string }\n  | { kind: \"field-key\"; cardId: string; key: string }\n  | { kind: \"card-title\"; cardId: string }\n  | { kind: \"predefined\"; cardId: string; key: PredefinedKey }\n  | { kind: \"field-add\"; cardId: string }\n  | { kind: \"predefined-add\"; cardId: string };\n\nexport function useEditMode() {\n  const [mode, setMode] = useState<EditMode | null>(null);\n\n  const clear = useCallback(() => setMode(null), []);\n\n  const isEditing = useCallback(\n    (target: EditMode): boolean => {\n      if (!mode) return false;\n      if (mode.kind !== target.kind) return false;\n      if (mode.cardId !== target.cardId) return false;\n      if (\"key\" in mode && \"key\" in target && mode.key !== target.key)\n        return false;\n      return true;\n    },\n    [mode],\n  );\n\n  return { mode, setMode, clear, isEditing };\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-edit-mode.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-permissions.ts",
      "content": "import { useCallback, useMemo } from \"react\";\nimport type {\n  EffectivePermissions,\n  PermissionDenialReason,\n  PermissionRule,\n  CardTreePermissions,\n} from \"../types\";\nimport {\n  computeAncestorMap,\n  computeLockedIds,\n  resolveEffectivePermissions,\n  type PermissionAction,\n  type PermissionContext,\n  type PermissionTarget,\n} from \"../lib/permissions\";\nimport type { ParsedCardTree } from \"../lib/parse\";\n\nexport type PermissionPredicates = {\n  canEditField?: (cardId: string, key: string) => boolean;\n  canAddField?: (cardId: string) => boolean;\n  canRemoveField?: (cardId: string, key: string) => boolean;\n  canEditCard?: (cardId: string) => boolean;\n  canAddCard?: (parentId: string) => boolean;\n  canRemoveCard?: (cardId: string) => boolean;\n  canEditPredefined?: (cardId: string, key: string) => boolean;\n  canAddPredefined?: (cardId: string, key: string) => boolean;\n  canRemovePredefined?: (cardId: string, key: string) => boolean;\n  canDragCard?: (cardId: string) => boolean;\n  canDropCard?: (cardId: string, targetParentId: string) => boolean;\n};\n\n/**\n * Returns memoized helpers that resolve effective permissions for any (action, target).\n * Predicate predicates take precedence over declarative permissions.\n */\nexport function usePermissions(\n  tree: ParsedCardTree,\n  editable: boolean,\n  permissions: CardTreePermissions | undefined,\n  predicates: PermissionPredicates,\n  onDenied:\n    | ((\n        action: keyof PermissionRule,\n        cardId: string,\n        target: string | undefined,\n        reason: PermissionDenialReason,\n      ) => void)\n    | undefined,\n) {\n  const metaLockedIds = useMemo(() => computeLockedIds(tree), [tree]);\n  const ancestorMap = useMemo(() => computeAncestorMap(tree), [tree]);\n\n  const ctx: PermissionContext = useMemo(\n    () => ({ editable, permissions, metaLockedIds, ancestorMap }),\n    [editable, permissions, metaLockedIds, ancestorMap],\n  );\n\n  const getEffective = useCallback(\n    (target: PermissionTarget): EffectivePermissions => {\n      return resolveEffectivePermissions(ctx, target);\n    },\n    [ctx],\n  );\n\n  const check = useCallback(\n    (action: PermissionAction, target: PermissionTarget): boolean => {\n      const eff = resolveEffectivePermissions(ctx, target);\n      const allowed = eff[action] !== false;\n      if (!allowed && onDenied) {\n        onDenied(action, target.cardId, target.key, eff.reason ?? \"default\");\n      }\n      return allowed;\n    },\n    [ctx, onDenied],\n  );\n\n  // Public convenience wrappers\n  const canEditField = useCallback(\n    (cardId: string, key: string) => {\n      if (predicates.canEditField) {\n        const r = predicates.canEditField(cardId, key);\n        if (!r && onDenied) onDenied(\"edit\", cardId, key, \"predicate\");\n        return r;\n      }\n      return check(\"edit\", { cardId, level: 0, kind: \"field\", key });\n    },\n    [check, predicates, onDenied],\n  );\n\n  const canAddField = useCallback(\n    (cardId: string) => {\n      if (predicates.canAddField) {\n        const r = predicates.canAddField(cardId);\n        if (!r && onDenied) onDenied(\"add\", cardId, undefined, \"predicate\");\n        return r;\n      }\n      return check(\"add\", { cardId, level: 0, kind: \"field\" });\n    },\n    [check, predicates, onDenied],\n  );\n\n  const canRemoveField = useCallback(\n    (cardId: string, key: string) => {\n      if (predicates.canRemoveField) {\n        const r = predicates.canRemoveField(cardId, key);\n        if (!r && onDenied) onDenied(\"remove\", cardId, key, \"predicate\");\n        return r;\n      }\n      return check(\"remove\", { cardId, level: 0, kind: \"field\", key });\n    },\n    [check, predicates, onDenied],\n  );\n\n  const canEditCard = useCallback(\n    (cardId: string) => {\n      if (predicates.canEditCard) {\n        const r = predicates.canEditCard(cardId);\n        if (!r && onDenied) onDenied(\"edit\", cardId, undefined, \"predicate\");\n        return r;\n      }\n      return check(\"edit\", { cardId, level: 0, kind: \"card\" });\n    },\n    [check, predicates, onDenied],\n  );\n\n  const canAddCard = useCallback(\n    (parentId: string) => {\n      if (predicates.canAddCard) {\n        const r = predicates.canAddCard(parentId);\n        if (!r && onDenied) onDenied(\"add\", parentId, undefined, \"predicate\");\n        return r;\n      }\n      return check(\"add\", { cardId: parentId, level: 0, kind: \"card\" });\n    },\n    [check, predicates, onDenied],\n  );\n\n  const canRemoveCard = useCallback(\n    (cardId: string) => {\n      if (predicates.canRemoveCard) {\n        const r = predicates.canRemoveCard(cardId);\n        if (!r && onDenied) onDenied(\"remove\", cardId, undefined, \"predicate\");\n        return r;\n      }\n      return check(\"remove\", { cardId, level: 0, kind: \"card\" });\n    },\n    [check, predicates, onDenied],\n  );\n\n  const canEditPredefined = useCallback(\n    (cardId: string, key: string) => {\n      if (predicates.canEditPredefined) {\n        const r = predicates.canEditPredefined(cardId, key);\n        if (!r && onDenied) onDenied(\"edit\", cardId, key, \"predicate\");\n        return r;\n      }\n      return check(\"edit\", { cardId, level: 0, kind: \"predefined\", key });\n    },\n    [check, predicates, onDenied],\n  );\n\n  const canAddPredefined = useCallback(\n    (cardId: string, key: string) => {\n      if (predicates.canAddPredefined) return predicates.canAddPredefined(cardId, key);\n      return check(\"add\", { cardId, level: 0, kind: \"predefined\", key });\n    },\n    [check, predicates],\n  );\n\n  const canRemovePredefined = useCallback(\n    (cardId: string, key: string) => {\n      if (predicates.canRemovePredefined) return predicates.canRemovePredefined(cardId, key);\n      return check(\"remove\", { cardId, level: 0, kind: \"predefined\", key });\n    },\n    [check, predicates],\n  );\n\n  const canDragCard = useCallback(\n    (cardId: string) => {\n      if (predicates.canDragCard) return predicates.canDragCard(cardId);\n      return check(\"reorder\", { cardId, level: 0, kind: \"card\" });\n    },\n    [check, predicates],\n  );\n\n  const canDropCard = useCallback(\n    (cardId: string, targetParentId: string) => {\n      if (predicates.canDropCard) return predicates.canDropCard(cardId, targetParentId);\n      return check(\"reparent\", { cardId, level: 0, kind: \"card\" });\n    },\n    [check, predicates],\n  );\n\n  const isLocked = useCallback(\n    (cardId: string) => metaLockedIds.has(cardId),\n    [metaLockedIds],\n  );\n\n  return {\n    canEditField,\n    canAddField,\n    canRemoveField,\n    canEditCard,\n    canAddCard,\n    canRemoveCard,\n    canEditPredefined,\n    canAddPredefined,\n    canRemovePredefined,\n    canDragCard,\n    canDropCard,\n    isLocked,\n    getEffective,\n  };\n}\n\nexport type UsePermissionsReturn = ReturnType<typeof usePermissions>;\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-permissions.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-search.ts",
      "content": "import { useEffect, useMemo, useRef } from \"react\";\nimport type { Dispatch } from \"react\";\nimport type {\n  CustomPredefinedKey,\n  SearchOptions,\n  SearchResult,\n} from \"../types\";\nimport { searchTree } from \"../lib/search\";\nimport { findAncestorIds, type CardTreeAction, type CardTreeState } from \"../lib/reducer\";\n\n/**\n * Wires the controlled `search` prop through the reducer:\n *   - On query change → search the tree, dispatch expand-path-to-matches\n *   - Returns the SearchResult for downstream consumers\n *   - Also tracks the active match index in reducer state\n */\nexport function useSearch(\n  state: CardTreeState,\n  dispatch: Dispatch<CardTreeAction>,\n  options: SearchOptions | undefined,\n  customKeys: readonly CustomPredefinedKey[] | undefined,\n  onSearchResults: ((result: SearchResult) => void) | undefined,\n): SearchResult {\n  const result = useMemo(() => {\n    if (!options || !options.query) {\n      return {\n        matches: [],\n        matchedCardIds: new Set<string>(),\n        activeIndex: null,\n      };\n    }\n    return searchTree(state.tree, options, {\n      customKeys: customKeys as CustomPredefinedKey[] | undefined,\n    });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [\n    state.tree,\n    options?.query,\n    options?.caseSensitive,\n    options?.matchTitles,\n    options?.matchKeys,\n    options?.matchValues,\n    options?.matchPredefined,\n    options?.matchMeta,\n    customKeys,\n  ]);\n\n  // Sync query into reducer (so collapse-state preservation works)\n  const lastQueryRef = useRef(\"\");\n  useEffect(() => {\n    const query = options?.query ?? \"\";\n    if (query !== lastQueryRef.current) {\n      lastQueryRef.current = query;\n      dispatch({ type: \"set-search-query\", query });\n    }\n  }, [options?.query, dispatch]);\n\n  // Sync expand-path-to-matches when results change\n  useEffect(() => {\n    if (result.matchedCardIds.size === 0) return;\n    const ancestorIds = new Set<string>();\n    for (const cardId of result.matchedCardIds) {\n      const ancestors = findAncestorIds(state.tree, cardId);\n      for (const a of ancestors) ancestorIds.add(a);\n    }\n    if (ancestorIds.size > 0) {\n      dispatch({\n        type: \"expand-path-to-matches\",\n        ancestorIds: Array.from(ancestorIds),\n      });\n    }\n  }, [result.matchedCardIds, state.tree, dispatch]);\n\n  // Active index lives in reducer state (so it survives unrelated re-renders)\n  // Sync the result.activeIndex with state.searchActiveIndex\n  const enrichedResult = useMemo(\n    () => ({\n      ...result,\n      activeIndex:\n        state.searchActiveIndex !== null && state.searchActiveIndex < result.matches.length\n          ? state.searchActiveIndex\n          : result.matches.length > 0\n            ? 0\n            : null,\n    }),\n    [result, state.searchActiveIndex],\n  );\n\n  // Fire onSearchResults callback when the result identity changes\n  const lastResultRef = useRef<SearchResult | null>(null);\n  useEffect(() => {\n    if (lastResultRef.current !== enrichedResult && onSearchResults) {\n      onSearchResults(enrichedResult);\n    }\n    lastResultRef.current = enrichedResult;\n  }, [enrichedResult, onSearchResults]);\n\n  return enrichedResult;\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-search.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-selection.ts",
      "content": "import { useCallback, type Dispatch } from \"react\";\nimport type {\n  CardTreeAction,\n  CardTreeState,\n} from \"../lib/reducer\";\n\n/**\n * Stable selector + dispatcher for selection state.\n *\n * Selection is click-driven, distinct from focus (which is keyboard-driven).\n * Single-select only in v0.2. Multi-select arrives in v0.3.\n */\nexport function useSelection(\n  state: CardTreeState,\n  dispatch: Dispatch<CardTreeAction>,\n) {\n  const select = useCallback(\n    (id: string | null) => {\n      dispatch({ type: \"set-selection\", id });\n    },\n    [dispatch],\n  );\n\n  const isSelected = useCallback(\n    (id: string) => state.selectedId === id,\n    [state.selectedId],\n  );\n\n  return {\n    selectedId: state.selectedId,\n    select,\n    isSelected,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-selection.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-tree-focus.ts",
      "content": "import { useMemo } from \"react\";\nimport type { CardTreeState } from \"../lib/reducer\";\nimport { visibleIdsInOrder } from \"../lib/reducer\";\n\n/**\n * Returns the ordered list of card ids currently visible in the tree, plus\n * helpers for prev/next/parent traversal. Recomputes only when the tree or\n * collapse set changes.\n */\nexport function useTreeFocus(state: CardTreeState) {\n  return useMemo(() => {\n    const visible = visibleIdsInOrder(state.tree, state.collapsed);\n    const indexById = new Map<string, number>();\n    visible.forEach((id, i) => indexById.set(id, i));\n\n    return {\n      visible,\n      first: visible[0] ?? null,\n      last: visible[visible.length - 1] ?? null,\n      next: (id: string | null): string | null => {\n        if (id === null) return visible[0] ?? null;\n        const i = indexById.get(id);\n        if (i === undefined) return visible[0] ?? null;\n        return visible[i + 1] ?? null;\n      },\n      prev: (id: string | null): string | null => {\n        if (id === null) return visible[visible.length - 1] ?? null;\n        const i = indexById.get(id);\n        if (i === undefined) return visible[0] ?? null;\n        return visible[i - 1] ?? null;\n      },\n    };\n  }, [state.tree, state.collapsed]);\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-tree-focus.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-tree-keyboard.ts",
      "content": "import { useCallback, type Dispatch, type KeyboardEvent } from \"react\";\nimport type {\n  CardTreeAction,\n  CardTreeState,\n} from \"../lib/reducer\";\nimport { findCard, findParentId } from \"../lib/reducer\";\nimport { useTreeFocus } from \"./use-tree-focus\";\nimport type { EditMode } from \"./use-edit-mode\";\n\n/**\n * Returns an `onKeyDown` handler for the tree root.\n *\n * Keyboard contract (per plan §11):\n *   - ↑ / ↓        prev / next visible card\n *   - →            collapsed → expand; expanded with kids → focus first child\n *   - ←            expanded → collapse; collapsed/leaf → focus parent\n *   - Home / End   first / last visible\n *   - Enter / Space toggle collapse on focused card\n */\nexport function useTreeKeyboard(\n  state: CardTreeState,\n  dispatch: Dispatch<CardTreeAction>,\n  editMode: EditMode | null,\n) {\n  const focus = useTreeFocus(state);\n\n  const focusId = useCallback(\n    (id: string | null, root: HTMLElement | null) => {\n      if (!id) return;\n      dispatch({ type: \"set-focus\", id });\n      // After state commit, move DOM focus.\n      requestAnimationFrame(() => {\n        const el = root?.querySelector<HTMLElement>(`[data-rcid=\"${id}\"]`);\n        el?.focus();\n      });\n    },\n    [dispatch],\n  );\n\n  return useCallback(\n    (event: KeyboardEvent<HTMLElement>) => {\n      // When an editor is active, don't process tree-keyboard nav — keystrokes\n      // belong to the active input.\n      if (editMode) return;\n      const root = event.currentTarget;\n      const focused = state.focusedId;\n\n      switch (event.key) {\n        case \"ArrowDown\": {\n          event.preventDefault();\n          focusId(focus.next(focused), root);\n          return;\n        }\n        case \"ArrowUp\": {\n          event.preventDefault();\n          focusId(focus.prev(focused), root);\n          return;\n        }\n        case \"Home\": {\n          event.preventDefault();\n          focusId(focus.first, root);\n          return;\n        }\n        case \"End\": {\n          event.preventDefault();\n          focusId(focus.last, root);\n          return;\n        }\n        case \"ArrowRight\": {\n          if (!focused) return;\n          event.preventDefault();\n          const card = findCard(state.tree, focused);\n          if (!card) return;\n          const isCollapsed = state.collapsed.has(focused);\n          const hasChildren = card.children.length > 0;\n          const hasBody =\n            card.fields.length > 0 || card.predefined.length > 0;\n          const canCollapse = hasBody || hasChildren;\n          if (canCollapse && isCollapsed) {\n            // Expand body and/or children.\n            dispatch({ type: \"toggle-collapse\", id: focused });\n          } else if (hasChildren && !isCollapsed) {\n            // Already expanded with children — descend.\n            focusId(card.children[0]?.id ?? null, root);\n          }\n          return;\n        }\n        case \"ArrowLeft\": {\n          if (!focused) return;\n          event.preventDefault();\n          const card = findCard(state.tree, focused);\n          if (!card) return;\n          const isCollapsed = state.collapsed.has(focused);\n          const hasChildren = card.children.length > 0;\n          const hasBody =\n            card.fields.length > 0 || card.predefined.length > 0;\n          const canCollapse = hasBody || hasChildren;\n          if (canCollapse && !isCollapsed) {\n            // Expanded — collapse it (body + children).\n            dispatch({ type: \"toggle-collapse\", id: focused });\n          } else {\n            // Collapsed or empty — ascend.\n            const parentId = findParentId(state.tree, focused);\n            if (parentId) focusId(parentId, root);\n          }\n          return;\n        }\n        case \"Enter\":\n        case \" \": {\n          if (!focused) return;\n          const card = findCard(state.tree, focused);\n          if (!card) return;\n          const canCollapse =\n            card.fields.length > 0 ||\n            card.predefined.length > 0 ||\n            card.children.length > 0;\n          if (canCollapse) {\n            event.preventDefault();\n            dispatch({ type: \"toggle-collapse\", id: focused });\n          }\n          return;\n        }\n      }\n    },\n    [state.focusedId, state.tree, state.collapsed, dispatch, focus, focusId, editMode],\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-tree-keyboard.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/hooks/use-undo.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, type Dispatch } from \"react\";\nimport type { CardTreeAction, CardTreeState } from \"../lib/reducer\";\n\nexport function useUndo(\n  state: CardTreeState,\n  dispatch: Dispatch<CardTreeAction>,\n  options: {\n    enableShortcuts?: boolean;\n    rootRef?: { current: HTMLElement | null };\n    onUndo?: () => void;\n    onRedo?: () => void;\n  } = {},\n) {\n  const canUndo = state.undoStack.length > 0;\n  const canRedo = state.redoStack.length > 0;\n\n  const undo = useCallback((): boolean => {\n    if (state.undoStack.length === 0) return false;\n    dispatch({ type: \"undo\" });\n    options.onUndo?.();\n    return true;\n  }, [state.undoStack.length, dispatch, options]);\n\n  const redo = useCallback((): boolean => {\n    if (state.redoStack.length === 0) return false;\n    dispatch({ type: \"redo\" });\n    options.onRedo?.();\n    return true;\n  }, [state.redoStack.length, dispatch, options]);\n\n  const clearHistory = useCallback(() => {\n    dispatch({ type: \"clear-history\" });\n  }, [dispatch]);\n\n  // Keyboard shortcuts: Cmd/Ctrl+Z (undo), Cmd/Ctrl+Shift+Z (redo), Cmd/Ctrl+Y (redo)\n  useEffect(() => {\n    if (options.enableShortcuts === false) return;\n    const root = options.rootRef?.current;\n    if (!root) return;\n    const handler = (e: KeyboardEvent) => {\n      // Don't intercept if focus is in an input / textarea / contenteditable\n      const t = e.target as HTMLElement | null;\n      if (\n        t &&\n        (t.tagName === \"INPUT\" ||\n          t.tagName === \"TEXTAREA\" ||\n          t.isContentEditable)\n      ) {\n        return;\n      }\n      const isMod = e.metaKey || e.ctrlKey;\n      if (!isMod) return;\n      const key = e.key.toLowerCase();\n      if (key === \"z\" && !e.shiftKey) {\n        e.preventDefault();\n        undo();\n      } else if ((key === \"z\" && e.shiftKey) || key === \"y\") {\n        e.preventDefault();\n        redo();\n      }\n    };\n    root.addEventListener(\"keydown\", handler);\n    return () => root.removeEventListener(\"keydown\", handler);\n  }, [options.enableShortcuts, options.rootRef, undo, redo]);\n\n  return { canUndo, canRedo, undo, redo, clearHistory };\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/hooks/use-undo.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/bulk-actions.ts",
      "content": "/**\n * Pure helpers for bulk operations: deep-clone with fresh ids, deepest-first\n * sort for safe bulk-removal, parent-key generation for moved cards.\n */\n\nimport type { FlatFieldValue } from \"../types\";\nimport type { ParsedCardTree } from \"./parse\";\n\n/**\n * Returns a depth map (id → traversal-order index) so callers can sort\n * card-ids deepest-first before bulk-removal.\n */\nexport function buildDepthMap(tree: ParsedCardTree): Map<string, number> {\n  const out = new Map<string, number>();\n  walk(tree, 0, out);\n  return out;\n}\n\nfunction walk(node: ParsedCardTree, depth: number, out: Map<string, number>): void {\n  out.set(node.id, depth);\n  for (const c of node.children) walk(c, depth + 1, out);\n}\n\n/**\n * Returns true if any of `ids` is an ancestor of any other id in the same set.\n * Used to deduplicate selection: when an ancestor is selected, descendants\n * shouldn't be acted on independently.\n */\nexport function deduplicateSelection(\n  tree: ParsedCardTree,\n  ids: readonly string[],\n): string[] {\n  const set = new Set(ids);\n  const out: string[] = [];\n  for (const id of ids) {\n    let hasAncestorSelected = false;\n    const ancestorIds = collectAncestors(tree, id);\n    for (const a of ancestorIds) {\n      if (set.has(a)) {\n        hasAncestorSelected = true;\n        break;\n      }\n    }\n    if (!hasAncestorSelected) out.push(id);\n  }\n  return out;\n}\n\nfunction collectAncestors(tree: ParsedCardTree, id: string): string[] {\n  const out: string[] = [];\n  walkAncestors(tree, id, [], out);\n  return out;\n}\n\nfunction walkAncestors(\n  node: ParsedCardTree,\n  targetId: string,\n  path: string[],\n  out: string[],\n): boolean {\n  if (node.id === targetId) {\n    out.push(...path);\n    return true;\n  }\n  for (const c of node.children) {\n    if (walkAncestors(c, targetId, [...path, node.id], out)) return true;\n  }\n  return false;\n}\n\n/* ───────── selection stats (for bulk-toolbar display) ───────── */\n\nexport type SelectionStats = {\n  count: number;\n  levelMin: number;\n  levelMax: number;\n  totalFields: number;\n  totalPredefined: number;\n  totalChildren: number;\n  lockedCount: number;\n};\n\nexport function computeSelectionStats(\n  tree: ParsedCardTree,\n  ids: ReadonlySet<string>,\n): SelectionStats {\n  const stats: SelectionStats = {\n    count: 0,\n    levelMin: Infinity,\n    levelMax: 0,\n    totalFields: 0,\n    totalPredefined: 0,\n    totalChildren: 0,\n    lockedCount: 0,\n  };\n  walkStats(tree, ids, stats);\n  if (stats.levelMin === Infinity) stats.levelMin = 0;\n  return stats;\n}\n\nfunction walkStats(\n  node: ParsedCardTree,\n  ids: ReadonlySet<string>,\n  stats: SelectionStats,\n): void {\n  if (ids.has(node.id)) {\n    stats.count += 1;\n    stats.levelMin = Math.min(stats.levelMin, node.level);\n    stats.levelMax = Math.max(stats.levelMax, node.level);\n    stats.totalFields += node.fields.length;\n    stats.totalPredefined += node.predefined.length;\n    stats.totalChildren += node.children.length;\n    if (node.meta?.locked === true) stats.lockedCount += 1;\n  }\n  for (const c of node.children) walkStats(c, ids, stats);\n}\n\n/* ───────── bulk-edit field application ───────── */\n\nexport type BulkEditFieldArgs = {\n  cardIds: readonly string[];\n  key: string;\n  value: FlatFieldValue;\n  /**\n   * 'set'   — set the field on every card (creates if missing)\n   * 'unset' — remove the field from every card if present\n   */\n  mode: \"set\" | \"unset\";\n};\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/bulk-actions.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/classify-key.ts",
      "content": "/**\n * Pure key router. Decides which bucket a JSON property belongs in:\n *   - 'reserved'    — a __rc* key handled out-of-band (skipped before this is called)\n *   - 'predefined'  — a known predefined-key (codearea/image/table/quote/list) not opted-out\n *   - 'field'       — a flat scalar field (or a predefined-key the developer opted out of)\n *   - 'child'       — a child card (parser will reject arrays in v0.1, per Q-P4)\n */\n\nimport {\n  PREDEFINED_KEYS,\n  RESERVED_KEYS,\n  type PredefinedKey,\n} from \"../types\";\n\nexport type KeyClassification =\n  | \"reserved\"\n  | \"predefined\"\n  | \"field\"\n  | \"child\";\n\nexport function classifyKey(\n  key: string,\n  value: unknown,\n  disabledPredefinedKeys: readonly PredefinedKey[],\n): KeyClassification {\n  if ((RESERVED_KEYS as readonly string[]).includes(key)) return \"reserved\";\n\n  if ((PREDEFINED_KEYS as readonly string[]).includes(key)) {\n    const predefined = key as PredefinedKey;\n    if (disabledPredefinedKeys.includes(predefined)) return \"field\";\n    return \"predefined\";\n  }\n\n  // Scalars are flat fields.\n  if (\n    value === null ||\n    typeof value === \"string\" ||\n    typeof value === \"number\" ||\n    typeof value === \"boolean\"\n  ) {\n    return \"field\";\n  }\n\n  // Objects + arrays are children. The parser rejects arrays in v0.1\n  // (per Q-P4: arrays of objects break round-trip; use object-keyed children).\n  if (typeof value === \"object\") return \"child\";\n\n  // undefined, function, symbol, bigint — treat as field; type-inference will null out.\n  return \"field\";\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/classify-key.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/dnd-helpers.ts",
      "content": "/**\n * Pure helpers for drag-drop math.\n * Auto-scroll utilities also live here.\n */\n\nimport { findAncestorIds, type ParsedCardTree } from \"./reducer\";\n\nexport type DropPosition = {\n  parentId: string;\n  order: number;\n  /** Whether the drop is \"into\" the card (last child) or \"between\" siblings (specific order). */\n  kind: \"into\" | \"between\";\n};\n\n/**\n * Determine if dragging `sourceId` onto `targetId` would create a cycle.\n * (Target is the source itself OR a descendant of source.)\n */\nexport function wouldCreateCycle(\n  tree: ParsedCardTree,\n  sourceId: string,\n  targetId: string,\n): boolean {\n  if (sourceId === targetId) return true;\n  const ancestorsOfTarget = findAncestorIds(tree, targetId);\n  // If source is an ancestor of target, dropping target into source's subtree (or onto source) is fine;\n  // but dropping source INTO target's subtree (where target is source's descendant) creates a cycle.\n  // So we need to check: is target a descendant of source?\n  return ancestorsOfTarget.includes(sourceId);\n}\n\n/**\n * Resolve drop intent based on pointer position within a target card's bounding rectangle.\n * Same-level reorder when in upper/lower 25%; cross-level reparent when in middle 50%.\n */\nexport function resolveDropIntent(args: {\n  pointerY: number;\n  rectTop: number;\n  rectHeight: number;\n  upperZoneRatio?: number;\n  lowerZoneRatio?: number;\n}): \"before\" | \"after\" | \"into\" {\n  const upper = args.upperZoneRatio ?? 0.25;\n  const lower = args.lowerZoneRatio ?? 0.75;\n  const relative = (args.pointerY - args.rectTop) / args.rectHeight;\n  if (relative < upper) return \"before\";\n  if (relative > lower) return \"after\";\n  return \"into\";\n}\n\n/**\n * Returns the auto-scroll velocity for a pointer near the edges of a scrollable container.\n * Returns 0 when not in the auto-scroll zone.\n *\n * Velocity scales linearly within the trigger zone (top/bottom 60px by default).\n */\nexport function autoScrollVelocity(args: {\n  pointerY: number;\n  containerTop: number;\n  containerBottom: number;\n  triggerZone?: number;\n  maxVelocity?: number;\n}): number {\n  const zone = args.triggerZone ?? 60;\n  const maxV = args.maxVelocity ?? 12;\n\n  const distFromTop = args.pointerY - args.containerTop;\n  const distFromBottom = args.containerBottom - args.pointerY;\n\n  if (distFromTop < zone && distFromTop >= 0) {\n    // Negative velocity = scroll up\n    return -maxV * (1 - distFromTop / zone);\n  }\n  if (distFromBottom < zone && distFromBottom >= 0) {\n    return maxV * (1 - distFromBottom / zone);\n  }\n  return 0;\n}\n\n/**\n * Animation-frame-driven scroll loop. Call to start; call the returned cleanup to stop.\n */\nexport function startAutoScroll(\n  el: HTMLElement,\n  getVelocity: () => number,\n): () => void {\n  let raf = 0;\n  const tick = () => {\n    const v = getVelocity();\n    if (v !== 0) {\n      el.scrollBy({ top: v });\n    }\n    raf = requestAnimationFrame(tick);\n  };\n  raf = requestAnimationFrame(tick);\n  return () => cancelAnimationFrame(raf);\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/dnd-helpers.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/infer-type.ts",
      "content": "/**\n * Pure scalar type inference for flat fields.\n *\n * v0.1 supports five field types:\n *   string | number | boolean | date | null\n *\n * `date` is a string-subtype: an ISO-8601 string that ALSO parses to a real Date.\n * The dual check (regex + Date.parse) rules out false positives like \"2026-12-99\".\n */\n\nexport type FlatFieldType = \"string\" | \"number\" | \"boolean\" | \"date\" | \"null\";\n\nexport type DateDetection =\n  | \"auto\"\n  | \"never\"\n  | ((value: string) => boolean);\n\nconst ISO_8601 =\n  /^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})?)?$/;\n\n/**\n * Returns the resolved `FlatFieldType` for a JSON-scalar value, or `null`\n * if the value isn't a supported scalar (caller should error).\n */\nexport function inferFlatFieldType(\n  value: unknown,\n  dateDetection: DateDetection,\n): FlatFieldType | null {\n  if (value === null) return \"null\";\n  if (typeof value === \"boolean\") return \"boolean\";\n  if (typeof value === \"number\") return Number.isFinite(value) ? \"number\" : \"string\";\n  if (typeof value === \"string\") return inferStringType(value, dateDetection);\n  return null;\n}\n\nfunction inferStringType(\n  value: string,\n  dateDetection: DateDetection,\n): FlatFieldType {\n  if (dateDetection === \"never\") return \"string\";\n  if (typeof dateDetection === \"function\") {\n    return dateDetection(value) ? \"date\" : \"string\";\n  }\n  // 'auto': pattern match + real-date validation\n  if (ISO_8601.test(value) && !Number.isNaN(Date.parse(value))) return \"date\";\n  return \"string\";\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/infer-type.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/parse.ts",
      "content": "/**\n * Pure parser: input `CardTreeJsonNode` → normalized internal `ParsedCardTree`.\n *\n * Errors are collected into an array (not thrown), so the caller can render\n * a best-effort tree alongside diagnostics.\n *\n * See plan §5.1 for the full algorithm.\n */\n\nimport type {\n  CodeAreaValue,\n  FlatFieldValue,\n  ImageValue,\n  ListValue,\n  PredefinedKey,\n  QuoteValue,\n  CardTreeJsonNode,\n  TableValue,\n} from \"../types\";\nimport { classifyKey } from \"./classify-key\";\nimport {\n  inferFlatFieldType,\n  type DateDetection,\n  type FlatFieldType,\n} from \"./infer-type\";\n\n/* ───────── internal tree types ───────── */\n\nexport type CardTreeField = {\n  key: string;\n  value: FlatFieldValue;\n  type: FlatFieldType;\n};\n\nexport type CardTreePredefinedEntry =\n  | { key: \"codearea\"; value: CodeAreaValue }\n  | { key: \"image\"; value: ImageValue }\n  | { key: \"table\"; value: TableValue }\n  | { key: \"quote\"; value: QuoteValue }\n  | { key: \"list\"; value: ListValue };\n\nexport type ParsedCardTree = {\n  id: string;\n  order: number;\n  level: number;\n  /** Property name on the parent that pointed to this card. Undefined only on root. */\n  parentKey?: string;\n  meta?: Record<string, FlatFieldValue>;\n  fields: CardTreeField[];\n  predefined: CardTreePredefinedEntry[];\n  children: ParsedCardTree[];\n};\n\nexport type ParseError = {\n  message: string;\n  path: string;\n};\n\nexport type ParseOptions = {\n  disabledPredefinedKeys: readonly PredefinedKey[];\n  dateDetection: DateDetection;\n};\n\nexport type ParseResult = {\n  tree: ParsedCardTree | null;\n  errors: ParseError[];\n};\n\n/* ───────── helpers ───────── */\n\n/**\n * Deterministic auto-id from a card's tree path, for cards that don't supply an\n * `__rcid`. MUST be deterministic (not a random UUID): parse runs at render-time\n * state-init, so an SSR pass and the client hydration pass both parse the same\n * input tree in the same order — a random id would differ between them and\n * trip a hydration mismatch (`data-rcid` / `aria-labelledby`). Path is unique\n * per node, so these ids are collision-free within a tree. Runtime edits mint\n * random ids elsewhere (client-only, post-hydration — safe).\n */\nfunction autoId(path: string): string {\n  const safe = path.replace(/[^a-zA-Z0-9_-]/g, \"-\");\n  return `rc-auto-${safe || \"root\"}`;\n}\n\nfunction isPlainObject(v: unknown): v is Record<string, unknown> {\n  return typeof v === \"object\" && v !== null && !Array.isArray(v);\n}\n\nfunction pathJoin(parent: string, segment: string | number): string {\n  return parent === \"\" ? String(segment) : `${parent}.${segment}`;\n}\n\n/* ───────── meta parsing ───────── */\n\nfunction parseMeta(\n  raw: unknown,\n  errors: ParseError[],\n  path: string,\n): Record<string, FlatFieldValue> | undefined {\n  if (raw === undefined) return undefined;\n  if (!isPlainObject(raw)) {\n    errors.push({\n      message: \"__rcmeta must be an object of scalar values; ignored\",\n      path,\n    });\n    return undefined;\n  }\n  const out: Record<string, FlatFieldValue> = {};\n  for (const k of Object.keys(raw)) {\n    const v = raw[k];\n    if (\n      v === null ||\n      typeof v === \"string\" ||\n      typeof v === \"number\" ||\n      typeof v === \"boolean\"\n    ) {\n      out[k] = v as FlatFieldValue;\n    } else {\n      errors.push({\n        message: `__rcmeta.${k} must be a scalar; dropped`,\n        path,\n      });\n    }\n  }\n  return out;\n}\n\n/* ───────── predefined-key shape validation ───────── */\n\nfunction validatePredefinedShape(\n  key: PredefinedKey,\n  value: unknown,\n  errors: ParseError[],\n  path: string,\n): CardTreePredefinedEntry | null {\n  switch (key) {\n    case \"codearea\": {\n      if (\n        isPlainObject(value) &&\n        typeof value.format === \"string\" &&\n        typeof value.content === \"string\"\n      ) {\n        return {\n          key: \"codearea\",\n          value: { format: value.format, content: value.content },\n        };\n      }\n      errors.push({\n        message:\n          \"codearea must be { format: string, content: string }; entry dropped\",\n        path,\n      });\n      return null;\n    }\n    case \"image\": {\n      if (\n        isPlainObject(value) &&\n        typeof value.src === \"string\" &&\n        (value.alt === undefined || typeof value.alt === \"string\")\n      ) {\n        return {\n          key: \"image\",\n          value: { src: value.src, alt: value.alt as string | undefined },\n        };\n      }\n      errors.push({\n        message: \"image must be { src: string, alt?: string }; entry dropped\",\n        path,\n      });\n      return null;\n    }\n    case \"table\": {\n      if (\n        isPlainObject(value) &&\n        Array.isArray(value.headers) &&\n        value.headers.every((h) => typeof h === \"string\") &&\n        Array.isArray(value.rows) &&\n        value.rows.every(\n          (row) =>\n            Array.isArray(row) &&\n            row.length === (value.headers as string[]).length &&\n            row.every(\n              (c) =>\n                c === null ||\n                typeof c === \"string\" ||\n                typeof c === \"number\" ||\n                typeof c === \"boolean\",\n            ),\n        )\n      ) {\n        return {\n          key: \"table\",\n          value: {\n            headers: value.headers as string[],\n            rows: value.rows as FlatFieldValue[][],\n          },\n        };\n      }\n      errors.push({\n        message:\n          \"table must be { headers: string[], rows: FlatFieldValue[][] } with consistent row widths; entry dropped\",\n        path,\n      });\n      return null;\n    }\n    case \"quote\": {\n      if (typeof value === \"string\") return { key: \"quote\", value };\n      errors.push({ message: \"quote must be a string; entry dropped\", path });\n      return null;\n    }\n    case \"list\": {\n      if (\n        Array.isArray(value) &&\n        value.every(\n          (v) =>\n            v === null ||\n            typeof v === \"string\" ||\n            typeof v === \"number\" ||\n            typeof v === \"boolean\",\n        )\n      ) {\n        return { key: \"list\", value: value as FlatFieldValue[] };\n      }\n      errors.push({\n        message: \"list must be an array of scalar values; entry dropped\",\n        path,\n      });\n      return null;\n    }\n  }\n}\n\n/* ───────── core recursion ───────── */\n\nfunction parseNode(\n  node: unknown,\n  parentKey: string | undefined,\n  level: number,\n  opts: ParseOptions,\n  errors: ParseError[],\n  path: string,\n  seenIds: Set<string>,\n): ParsedCardTree | null {\n  if (!isPlainObject(node)) {\n    errors.push({\n      message: \"expected an object; found \" + (Array.isArray(node) ? \"array\" : typeof node),\n      path,\n    });\n    return null;\n  }\n\n  // identity\n  let id =\n    typeof node.__rcid === \"string\" && node.__rcid.length > 0\n      ? node.__rcid\n      : autoId(path);\n  if (seenIds.has(id)) {\n    errors.push({\n      message: `duplicate __rcid \"${id}\"; regenerated`,\n      path,\n    });\n    // Path is unique, so the path-derived id deterministically resolves the\n    // collision (and stays SSR-stable).\n    id = autoId(path);\n  }\n  seenIds.add(id);\n\n  let order = 0;\n  if (node.__rcorder !== undefined) {\n    if (typeof node.__rcorder === \"number\" && Number.isFinite(node.__rcorder)) {\n      order = node.__rcorder;\n    } else {\n      errors.push({\n        message: `non-numeric __rcorder coerced to 0`,\n        path,\n      });\n    }\n  }\n\n  const meta = parseMeta(node.__rcmeta, errors, path);\n\n  const fields: CardTreeField[] = [];\n  const predefined: CardTreePredefinedEntry[] = [];\n  const children: ParsedCardTree[] = [];\n  const seenSiblingKeys = new Set<string>();\n\n  for (const key of Object.keys(node)) {\n    if (([\"__rcid\", \"__rcorder\", \"__rcmeta\"] as const).includes(key as never))\n      continue;\n    if (seenSiblingKeys.has(key)) {\n      errors.push({ message: `duplicate sibling key \"${key}\"; ignored`, path });\n      continue;\n    }\n    seenSiblingKeys.add(key);\n\n    const value = node[key];\n    const childPath = pathJoin(path, key);\n    const classification = classifyKey(\n      key,\n      value,\n      opts.disabledPredefinedKeys,\n    );\n\n    switch (classification) {\n      case \"reserved\":\n        // unreachable in this loop (reserved keys filtered above)\n        break;\n\n      case \"predefined\": {\n        const validated = validatePredefinedShape(\n          key as PredefinedKey,\n          value,\n          errors,\n          childPath,\n        );\n        if (validated) predefined.push(validated);\n        break;\n      }\n\n      case \"field\": {\n        const type = inferFlatFieldType(value, opts.dateDetection);\n        if (type === null) {\n          errors.push({\n            message: `unsupported flat-field value type for key \"${key}\" (got ${typeof value}); ignored`,\n            path: childPath,\n          });\n        } else {\n          fields.push({ key, value: value as FlatFieldValue, type });\n        }\n        break;\n      }\n\n      case \"child\": {\n        if (Array.isArray(value)) {\n          errors.push({\n            message: `array values are not supported as children in v0.1 (key \"${key}\"). Use object-keyed children, or the \\`list\\` predefined key for scalar arrays.`,\n            path: childPath,\n          });\n          break;\n        }\n        const child = parseNode(\n          value,\n          key,\n          level + 1,\n          opts,\n          errors,\n          childPath,\n          seenIds,\n        );\n        if (child) children.push(child);\n        break;\n      }\n    }\n  }\n\n  // Stable sort children by order; tie-break by input order (Array.prototype.sort\n  // is stable in all modern engines, including V8 since 7.0).\n  children.sort((a, b) => a.order - b.order);\n\n  return {\n    id,\n    order,\n    level,\n    parentKey,\n    meta,\n    fields,\n    predefined,\n    children,\n  };\n}\n\n/* ───────── public entry point ───────── */\n\nexport function parseInput(\n  input: CardTreeJsonNode,\n  opts: ParseOptions,\n): ParseResult {\n  const errors: ParseError[] = [];\n  const seenIds = new Set<string>();\n  const tree = parseNode(input, undefined, 1, opts, errors, \"\", seenIds);\n  return { tree, errors };\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/parse.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/permissions.ts",
      "content": "/**\n * Pure permission resolver.\n *\n * Resolution order (most → least specific):\n *   1. Predicate (canEditField, canAddCard, etc.) — overrides everything\n *   2. Per-card meta lock (`__rcmeta.locked === true`) — read-only cascade\n *   3. Per-card override (permissions.byCard[id])\n *   4. Per-predefined-key override (permissions.byPredefinedKey[key])\n *   5. Per-field-type override (permissions.byFieldType[type])\n *   6. Per-level override (permissions.byLevel[level])\n *   7. Default (permissions.default)\n *   8. Global `editable` prop — false denies everything\n */\n\nimport type {\n  EffectivePermissions,\n  PermissionDenialReason,\n  PermissionRule,\n  CardTreePermissions,\n} from \"../types\";\nimport type { FlatFieldType } from \"./infer-type\";\nimport type { ParsedCardTree } from \"./reducer\";\n\nexport type PermissionAction = \"edit\" | \"add\" | \"remove\" | \"reorder\" | \"reparent\";\n\nexport type PermissionTarget = {\n  cardId: string;\n  level: number;\n  /** \"card\" → the card itself; \"field\" → flat field with optional `key`; \"predefined\" → predefined entry with `key`. */\n  kind: \"card\" | \"field\" | \"predefined\";\n  key?: string;\n  /** For field-targeted actions, the field's inferred type. */\n  fieldType?: FlatFieldType;\n};\n\nexport type PermissionContext = {\n  editable: boolean;\n  permissions?: CardTreePermissions;\n  metaLockedIds: ReadonlySet<string>;\n  ancestorMap: Map<string, readonly string[]>;\n};\n\n/* ───────── locked-cards computation (cascading) ───────── */\n\nexport function computeLockedIds(tree: ParsedCardTree): Set<string> {\n  const out = new Set<string>();\n  walkLocked(tree, false, out);\n  return out;\n}\n\nfunction walkLocked(node: ParsedCardTree, ancestorLocked: boolean, out: Set<string>): void {\n  const selfLocked = node.meta?.locked === true;\n  const isLocked = ancestorLocked || selfLocked;\n  if (isLocked) out.add(node.id);\n  for (const child of node.children) walkLocked(child, isLocked, out);\n}\n\n/* ───────── ancestor map (for permission inheritance) ───────── */\n\nexport function computeAncestorMap(tree: ParsedCardTree): Map<string, readonly string[]> {\n  const out = new Map<string, readonly string[]>();\n  walkAncestor(tree, [], out);\n  return out;\n}\n\nfunction walkAncestor(\n  node: ParsedCardTree,\n  path: string[],\n  out: Map<string, readonly string[]>,\n): void {\n  out.set(node.id, path);\n  for (const c of node.children) walkAncestor(c, [...path, node.id], out);\n}\n\n/* ───────── effective permissions resolver ───────── */\n\nconst ALL_DENIED: EffectivePermissions = {\n  edit: false,\n  add: false,\n  remove: false,\n  reorder: false,\n  reparent: false,\n};\n\nconst ALL_ALLOWED_DEFAULTS: PermissionRule = {\n  edit: true,\n  add: true,\n  remove: true,\n  reorder: true,\n  reparent: true,\n};\n\nexport function resolveEffectivePermissions(\n  ctx: PermissionContext,\n  target: PermissionTarget,\n): EffectivePermissions {\n  // 1. Global editable=false → deny all\n  if (!ctx.editable) {\n    return { ...ALL_DENIED, reason: \"global-editable-false\" };\n  }\n\n  // 2. Meta-lock cascade\n  if (ctx.metaLockedIds.has(target.cardId)) {\n    return { ...ALL_DENIED, reason: \"meta-locked\" };\n  }\n\n  // Build resolved rule by layering, most-specific overrides win\n  let rule: PermissionRule = { ...ALL_ALLOWED_DEFAULTS };\n  let reason: PermissionDenialReason | undefined;\n\n  // 7. Default\n  if (ctx.permissions?.default) {\n    rule = { ...rule, ...ctx.permissions.default };\n    reason = \"default\";\n  }\n\n  // 6. Per-level\n  if (ctx.permissions?.byLevel) {\n    const levelRule = ctx.permissions.byLevel[target.level];\n    if (levelRule) {\n      rule = { ...rule, ...levelRule };\n      reason = \"by-level\";\n    }\n  }\n\n  // 6.5 Inheritance — when permissions.inherit is true, walk ancestors and OR-combine \"denied\" flags\n  if (ctx.permissions?.inherit && ctx.permissions.byCard) {\n    const ancestors = ctx.ancestorMap.get(target.cardId) ?? [];\n    for (const ancestorId of ancestors) {\n      const ancestorRule = ctx.permissions.byCard[ancestorId];\n      if (ancestorRule) {\n        rule = { ...rule, ...maskDenials(rule, ancestorRule) };\n      }\n    }\n  }\n\n  // 5. Per-field-type (only applicable for field-targeted actions)\n  if (\n    ctx.permissions?.byFieldType &&\n    target.kind === \"field\" &&\n    target.fieldType\n  ) {\n    const fieldTypeRule = ctx.permissions.byFieldType[target.fieldType];\n    if (fieldTypeRule) {\n      rule = { ...rule, ...fieldTypeRule };\n      reason = \"by-field-type\";\n    }\n  }\n\n  // 4. Per-predefined-key\n  if (\n    ctx.permissions?.byPredefinedKey &&\n    target.kind === \"predefined\" &&\n    target.key\n  ) {\n    const keyRule = (\n      ctx.permissions.byPredefinedKey as Record<string, PermissionRule>\n    )[target.key];\n    if (keyRule) {\n      rule = { ...rule, ...keyRule };\n      reason = \"by-predefined-key\";\n    }\n  }\n\n  // 3. Per-card\n  if (ctx.permissions?.byCard) {\n    const cardRule = ctx.permissions.byCard[target.cardId];\n    if (cardRule) {\n      rule = { ...rule, ...cardRule };\n      reason = \"by-card\";\n    }\n  }\n\n  return { ...rule, reason };\n}\n\n/** OR-combine denials: if either rule denies, denied. Used for inheritance. */\nfunction maskDenials(base: PermissionRule, ancestor: PermissionRule): PermissionRule {\n  const out: PermissionRule = {};\n  for (const key of Object.keys(ancestor) as (keyof PermissionRule)[]) {\n    if (ancestor[key] === false) out[key] = false;\n    else if (base[key] !== undefined) out[key] = base[key];\n  }\n  return out;\n}\n\n/* ───────── per-action helpers (used by predicates) ───────── */\n\nexport function canDoAction(\n  ctx: PermissionContext,\n  action: PermissionAction,\n  target: PermissionTarget,\n): { allowed: boolean; reason?: PermissionDenialReason } {\n  const eff = resolveEffectivePermissions(ctx, target);\n  const allowed = eff[action] !== false;\n  return { allowed, reason: allowed ? undefined : eff.reason };\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/permissions.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/reducer.ts",
      "content": "/**\n * Pure reducer for card-tree state.\n *\n * v0.1 actions: toggle-collapse, set-focus, replace-tree\n * v0.2 actions: 12 (selection, mark-clean, field/card/predefined edit/add/remove)\n * v0.3 actions: 12 (multi-select, drag-transient, card move/duplicate, meta edit/add/remove,\n *                   search query/active-match/clear, expand-path-to-matches)\n *\n * Validation runs OUTSIDE the reducer; the dispatch wrapper validates before dispatch.\n */\n\nimport type { FlatFieldValue, PredefinedKey } from \"../types\";\nimport type { FlatFieldType } from \"./infer-type\";\nimport type { CardTreePredefinedEntry, ParsedCardTree } from \"./parse\";\n\nexport type { CardTreeField, CardTreePredefinedEntry, ParsedCardTree } from \"./parse\";\n\n/* ───────── state ───────── */\n\n/** v0.4 — undo snapshot. Captures tree + UI state that \"undo\" should restore. */\nexport type UndoSnapshot = {\n  tree: ParsedCardTree;\n  collapsed: ReadonlySet<string>;\n  selectedIds: ReadonlySet<string>;\n  focusedId: string | null;\n};\n\nexport type CardTreeState = {\n  tree: ParsedCardTree;\n  collapsed: ReadonlySet<string>;\n  /** v0.3 — preserves user-driven collapse separately from search-auto-expand. */\n  userCollapsed: ReadonlySet<string>;\n  focusedId: string | null;\n  /** v0.2 — single id retained for backward compat; in v0.3 derived from selectedIds[0]. */\n  selectedId: string | null;\n  /** v0.3 — multi-select set. */\n  selectedIds: ReadonlySet<string>;\n  /** v0.3 — anchor for shift-click range selection. */\n  selectionAnchorId: string | null;\n  /** v0.3 — id being dragged (transient). */\n  draggingId: string | null;\n  /** v0.3 — current search query (empty string = inactive). */\n  searchQuery: string;\n  /** v0.3 — index into search-result.matches for the currently active match. */\n  searchActiveIndex: number | null;\n  /** v0.4 — undo / redo stacks (most recent at end). */\n  undoStack: ReadonlyArray<UndoSnapshot>;\n  redoStack: ReadonlyArray<UndoSnapshot>;\n  /** v0.4 — configurable history depth. */\n  maxUndoDepth: number;\n  version: number;\n  cleanVersion: number;\n};\n\n/* ───────── actions ───────── */\n\nexport type CardTreeAction =\n  // v0.1\n  | { type: \"toggle-collapse\"; id: string }\n  | { type: \"set-focus\"; id: string | null }\n  | { type: \"replace-tree\"; tree: ParsedCardTree }\n  // v0.2 selection + dirty\n  | { type: \"set-selection\"; id: string | null }\n  | { type: \"mark-clean\" }\n  // v0.2 flat fields\n  | {\n      type: \"field-edit-value\";\n      cardId: string;\n      key: string;\n      value: FlatFieldValue;\n      valueType: FlatFieldType;\n    }\n  | { type: \"field-edit-key\"; cardId: string; oldKey: string; newKey: string }\n  | {\n      type: \"field-add\";\n      cardId: string;\n      key: string;\n      value: FlatFieldValue;\n      valueType: FlatFieldType;\n    }\n  | { type: \"field-remove\"; cardId: string; key: string }\n  // v0.2 cards\n  | { type: \"card-add\"; parentId: string; card: ParsedCardTree }\n  | {\n      type: \"card-remove\";\n      cardId: string;\n      policy?: \"cascade\" | \"promote\";\n      collisionStrategy?: \"suffix\" | \"qualify\" | \"reject\";\n    }\n  | { type: \"card-rename\"; cardId: string; newParentKey: string }\n  // v0.2 predefined\n  | { type: \"predefined-add\"; cardId: string; entry: CardTreePredefinedEntry }\n  | {\n      type: \"predefined-edit\";\n      cardId: string;\n      key: PredefinedKey | string;\n      entry: CardTreePredefinedEntry;\n    }\n  | { type: \"predefined-remove\"; cardId: string; key: PredefinedKey | string }\n  // v0.3 — multi-select\n  | { type: \"set-multi-selection\"; ids: readonly string[]; anchor?: string | null }\n  | { type: \"toggle-selection\"; id: string }\n  | { type: \"extend-selection-to\"; id: string }\n  | { type: \"clear-selection\" }\n  // v0.3 — drag-transient\n  | { type: \"drag-start\"; cardId: string }\n  | { type: \"drag-end\" }\n  // v0.3 — card movement + duplicate\n  | {\n      type: \"card-move\";\n      cardId: string;\n      newParentId: string;\n      newOrder: number;\n    }\n  | { type: \"card-duplicate\"; cardId: string; newCardId: string }\n  // v0.3 — meta\n  | {\n      type: \"meta-edit\";\n      cardId: string;\n      key: string;\n      value: FlatFieldValue;\n    }\n  | {\n      type: \"meta-add\";\n      cardId: string;\n      key: string;\n      value: FlatFieldValue;\n    }\n  | { type: \"meta-remove\"; cardId: string; key: string }\n  // v0.3 — bulk\n  | { type: \"bulk-remove\"; cardIds: readonly string[] }\n  // v0.3 — search\n  | { type: \"set-search-query\"; query: string }\n  | { type: \"set-active-match-index\"; index: number | null }\n  | { type: \"clear-search\" }\n  | { type: \"expand-path-to-matches\"; ancestorIds: readonly string[] }\n  // v0.4 — undo/redo\n  | { type: \"undo\" }\n  | { type: \"redo\" }\n  | { type: \"clear-history\" }\n  | { type: \"set-max-undo-depth\"; depth: number };\n\n/* ───────── initial-state factory ───────── */\n\nexport type DefaultCollapsed = \"all\" | \"none\" | ((level: number) => boolean);\n\nfunction collectCollapsedIds(\n  tree: ParsedCardTree,\n  predicate: DefaultCollapsed,\n  out: Set<string>,\n): void {\n  const shouldCollapse =\n    predicate === \"all\"\n      ? true\n      : predicate === \"none\"\n        ? false\n        : predicate(tree.level);\n  if (shouldCollapse) out.add(tree.id);\n  for (const child of tree.children) {\n    collectCollapsedIds(child, predicate, out);\n  }\n}\n\nexport function createInitialState(\n  tree: ParsedCardTree,\n  defaultCollapsed: DefaultCollapsed,\n  maxUndoDepth = 50,\n): CardTreeState {\n  const collapsed = new Set<string>();\n  collectCollapsedIds(tree, defaultCollapsed, collapsed);\n  return {\n    tree,\n    collapsed,\n    userCollapsed: collapsed,\n    focusedId: null,\n    selectedId: null,\n    selectedIds: new Set<string>(),\n    selectionAnchorId: null,\n    draggingId: null,\n    searchQuery: \"\",\n    searchActiveIndex: null,\n    undoStack: [],\n    redoStack: [],\n    maxUndoDepth,\n    version: 0,\n    cleanVersion: 0,\n  };\n}\n\n/* ───────── immutable tree helpers ───────── */\n\nfunction updateNode(\n  tree: ParsedCardTree,\n  id: string,\n  fn: (node: ParsedCardTree) => ParsedCardTree,\n): ParsedCardTree {\n  if (tree.id === id) return fn(tree);\n  if (tree.children.length === 0) return tree;\n  let changed = false;\n  const next = tree.children.map((c) => {\n    const n = updateNode(c, id, fn);\n    if (n !== c) changed = true;\n    return n;\n  });\n  return changed ? { ...tree, children: next } : tree;\n}\n\nfunction removeNodeById(\n  tree: ParsedCardTree,\n  id: string,\n): { tree: ParsedCardTree; removed: ParsedCardTree | null; parentId: string | null } {\n  const directIdx = tree.children.findIndex((c) => c.id === id);\n  if (directIdx >= 0) {\n    const removed = tree.children[directIdx];\n    return {\n      tree: {\n        ...tree,\n        children: tree.children.filter((_, i) => i !== directIdx),\n      },\n      removed,\n      parentId: tree.id,\n    };\n  }\n  for (let i = 0; i < tree.children.length; i++) {\n    const sub = removeNodeById(tree.children[i], id);\n    if (sub.removed) {\n      const newChildren = [...tree.children];\n      newChildren[i] = sub.tree;\n      return {\n        tree: { ...tree, children: newChildren },\n        removed: sub.removed,\n        parentId: sub.parentId,\n      };\n    }\n  }\n  return { tree, removed: null, parentId: null };\n}\n\nfunction setLevelDeep(node: ParsedCardTree, level: number): ParsedCardTree {\n  const next: ParsedCardTree = { ...node, level };\n  if (node.children.length === 0) return next;\n  next.children = node.children.map((c) => setLevelDeep(c, level + 1));\n  return next;\n}\n\nfunction deepCloneWithFreshIds(node: ParsedCardTree, freshId: () => string): ParsedCardTree {\n  return {\n    ...node,\n    id: freshId(),\n    fields: node.fields.map((f) => ({ ...f })),\n    predefined: node.predefined.map((p) => ({ ...p })),\n    meta: node.meta ? { ...node.meta } : undefined,\n    children: node.children.map((c) => deepCloneWithFreshIds(c, freshId)),\n  };\n}\n\nfunction freshIdGen(): () => string {\n  if (\n    typeof globalThis.crypto !== \"undefined\" &&\n    typeof globalThis.crypto.randomUUID === \"function\"\n  ) {\n    return () => globalThis.crypto.randomUUID();\n  }\n  let i = 0;\n  return () => `rc-${Date.now().toString(36)}-${(i++).toString(36)}`;\n}\n\n/* ───────── promote-on-delete ───────── */\n\nfunction resolveCollision(\n  candidateKey: string,\n  existingKeys: Set<string>,\n  strategy: \"suffix\" | \"qualify\" | \"reject\",\n  qualifyPrefix: string,\n): string | null {\n  if (!existingKeys.has(candidateKey)) return candidateKey;\n  if (strategy === \"reject\") return null;\n  if (strategy === \"qualify\") {\n    let qualified = `${qualifyPrefix}_${candidateKey}`;\n    let i = 2;\n    while (existingKeys.has(qualified)) {\n      qualified = `${qualifyPrefix}_${candidateKey}_${i++}`;\n    }\n    return qualified;\n  }\n  // suffix _2 / _3 / ...\n  let i = 2;\n  while (existingKeys.has(`${candidateKey}_${i}`)) i++;\n  return `${candidateKey}_${i}`;\n}\n\n/* ───────── reducer ───────── */\n\n/** Take a snapshot of state slots that undo restores. */\nfunction snapshot(state: CardTreeState): UndoSnapshot {\n  return {\n    tree: state.tree,\n    collapsed: state.collapsed,\n    selectedIds: state.selectedIds,\n    focusedId: state.focusedId,\n  };\n}\n\n/** After a commit-action, push undo snapshot + clear redo + trim. */\nfunction withUndoBookkeeping(\n  prev: CardTreeState,\n  next: CardTreeState,\n): CardTreeState {\n  if (next.version === prev.version) return next; // not a commit\n  const undoStack = [...prev.undoStack, snapshot(prev)];\n  while (undoStack.length > prev.maxUndoDepth) {\n    undoStack.shift();\n  }\n  return { ...next, undoStack, redoStack: [] };\n}\n\nexport function reducer(\n  state: CardTreeState,\n  action: CardTreeAction,\n): CardTreeState {\n  // v0.4 actions handled before delegating to innerReducer\n  if (action.type === \"undo\") {\n    if (state.undoStack.length === 0) return state;\n    const snap = state.undoStack[state.undoStack.length - 1];\n    const newUndoStack = state.undoStack.slice(0, -1);\n    const newRedoStack = [...state.redoStack, snapshot(state)];\n    return {\n      ...state,\n      tree: snap.tree,\n      collapsed: snap.collapsed,\n      selectedIds: snap.selectedIds,\n      selectedId:\n        snap.selectedIds.size > 0\n          ? Array.from(snap.selectedIds)[0] ?? null\n          : null,\n      focusedId: snap.focusedId,\n      undoStack: newUndoStack,\n      redoStack: newRedoStack,\n    };\n  }\n  if (action.type === \"redo\") {\n    if (state.redoStack.length === 0) return state;\n    const snap = state.redoStack[state.redoStack.length - 1];\n    const newRedoStack = state.redoStack.slice(0, -1);\n    const newUndoStack = [...state.undoStack, snapshot(state)];\n    return {\n      ...state,\n      tree: snap.tree,\n      collapsed: snap.collapsed,\n      selectedIds: snap.selectedIds,\n      selectedId:\n        snap.selectedIds.size > 0\n          ? Array.from(snap.selectedIds)[0] ?? null\n          : null,\n      focusedId: snap.focusedId,\n      undoStack: newUndoStack,\n      redoStack: newRedoStack,\n    };\n  }\n  if (action.type === \"clear-history\") {\n    if (state.undoStack.length === 0 && state.redoStack.length === 0) return state;\n    return { ...state, undoStack: [], redoStack: [] };\n  }\n  if (action.type === \"set-max-undo-depth\") {\n    if (state.maxUndoDepth === action.depth) return state;\n    const undoStack =\n      state.undoStack.length > action.depth\n        ? state.undoStack.slice(state.undoStack.length - action.depth)\n        : state.undoStack;\n    return { ...state, maxUndoDepth: action.depth, undoStack };\n  }\n\n  // All other actions → run through innerReducer + bookkeeping\n  const next = innerReducer(state, action);\n  return withUndoBookkeeping(state, next);\n}\n\n/** v0.1–v0.3 reducer logic, unchanged. */\nfunction innerReducer(\n  state: CardTreeState,\n  action: CardTreeAction,\n): CardTreeState {\n  switch (action.type) {\n    /* v0.1 */\n    case \"toggle-collapse\": {\n      const next = new Set(state.collapsed);\n      if (next.has(action.id)) next.delete(action.id);\n      else next.add(action.id);\n      // Mirror to userCollapsed when search is inactive (so user-driven collapse persists across search clear)\n      const userNext = state.searchQuery === \"\" ? next : state.userCollapsed;\n      return { ...state, collapsed: next, userCollapsed: userNext };\n    }\n    case \"set-focus\": {\n      if (state.focusedId === action.id) return state;\n      return { ...state, focusedId: action.id };\n    }\n    case \"replace-tree\": {\n      return createInitialState(action.tree, \"none\");\n    }\n\n    /* v0.2 selection + dirty */\n    case \"set-selection\": {\n      if (state.selectedId === action.id) return state;\n      const ids = action.id ? new Set<string>([action.id]) : new Set<string>();\n      return {\n        ...state,\n        selectedId: action.id,\n        selectedIds: ids,\n        selectionAnchorId: action.id,\n      };\n    }\n    case \"mark-clean\": {\n      if (state.cleanVersion === state.version) return state;\n      return { ...state, cleanVersion: state.version };\n    }\n\n    /* v0.2 flat-field actions */\n    case \"field-edit-value\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => ({\n        ...card,\n        fields: card.fields.map((f) =>\n          f.key === action.key\n            ? { ...f, value: action.value, type: action.valueType }\n            : f,\n        ),\n      }));\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n    case \"field-edit-key\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => ({\n        ...card,\n        fields: card.fields.map((f) =>\n          f.key === action.oldKey ? { ...f, key: action.newKey } : f,\n        ),\n      }));\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n    case \"field-add\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => ({\n        ...card,\n        fields: [\n          ...card.fields,\n          { key: action.key, value: action.value, type: action.valueType },\n        ],\n      }));\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n    case \"field-remove\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => ({\n        ...card,\n        fields: card.fields.filter((f) => f.key !== action.key),\n      }));\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n\n    /* v0.2 card actions */\n    case \"card-add\": {\n      const tree = updateNode(state.tree, action.parentId, (parent) => {\n        const child = setLevelDeep(action.card, parent.level + 1);\n        const next = [...parent.children, child].sort(\n          (a, b) => a.order - b.order,\n        );\n        return { ...parent, children: next };\n      });\n      if (tree === state.tree) return state;\n      const collapsed = new Set(state.collapsed);\n      collapsed.delete(action.parentId);\n      return { ...state, tree, collapsed, version: state.version + 1 };\n    }\n    case \"card-remove\": {\n      const policy = action.policy ?? \"cascade\";\n      const collisionStrategy = action.collisionStrategy ?? \"suffix\";\n\n      // Find the card and its parent BEFORE removal\n      const target = findCard(state.tree, action.cardId);\n      if (!target) return state;\n\n      // Promote first if requested\n      let workingTree = state.tree;\n      if (policy === \"promote\" && target.children.length > 0) {\n        const parentId = findParentId(state.tree, action.cardId);\n        if (parentId === null) {\n          // Root; can't promote — fall back to cascade\n        } else {\n          // Compute promoted children with collision resolution\n          const parent = findCard(state.tree, parentId)!;\n          const existingKeys = new Set<string>([\n            ...parent.children\n              .filter((c) => c.id !== action.cardId)\n              .map((c) => c.parentKey ?? \"\")\n              .filter((k) => k.length > 0),\n            ...parent.fields.map((f) => f.key),\n          ]);\n\n          const promoted: ParsedCardTree[] = [];\n          for (const child of target.children) {\n            const candidate = child.parentKey ?? `child_${child.id.slice(0, 8)}`;\n            const resolved = resolveCollision(\n              candidate,\n              existingKeys,\n              collisionStrategy,\n              target.parentKey ?? \"promoted\",\n            );\n            if (resolved === null) {\n              // reject mode: refuse this promotion\n              return state;\n            }\n            existingKeys.add(resolved);\n            promoted.push({ ...child, parentKey: resolved, level: parent.level + 1 });\n          }\n\n          workingTree = updateNode(state.tree, parentId, (p) => ({\n            ...p,\n            children: [\n              ...p.children.filter((c) => c.id !== action.cardId),\n              ...promoted.map((c) => setLevelDeep(c, p.level + 1)),\n            ].sort((a, b) => a.order - b.order),\n          }));\n\n          const removedIds = new Set<string>([action.cardId]);\n          // Note: descendants' IDs are preserved; only the removed card's id is dirty.\n          const collapsed = new Set(state.collapsed);\n          removedIds.forEach((id) => collapsed.delete(id));\n          const focusedId = removedIds.has(state.focusedId ?? \"\")\n            ? null\n            : state.focusedId;\n          const selectedIds = new Set(\n            [...state.selectedIds].filter((id) => !removedIds.has(id)),\n          );\n          return {\n            ...state,\n            tree: workingTree,\n            collapsed,\n            focusedId,\n            selectedId: selectedIds.size > 0 ? [...selectedIds][0] ?? null : null,\n            selectedIds,\n            version: state.version + 1,\n          };\n        }\n      }\n\n      // Cascade\n      const result = removeNodeById(state.tree, action.cardId);\n      if (!result.removed) return state;\n      const removedIds = new Set<string>();\n      collectIds(result.removed, removedIds);\n      const collapsed = new Set(state.collapsed);\n      removedIds.forEach((id) => collapsed.delete(id));\n      const focusedId = removedIds.has(state.focusedId ?? \"\")\n        ? null\n        : state.focusedId;\n      const selectedIds = new Set(\n        [...state.selectedIds].filter((id) => !removedIds.has(id)),\n      );\n      return {\n        ...state,\n        tree: result.tree,\n        collapsed,\n        focusedId,\n        selectedId: selectedIds.size > 0 ? [...selectedIds][0] ?? null : null,\n        selectedIds,\n        version: state.version + 1,\n      };\n    }\n    case \"card-rename\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => ({\n        ...card,\n        parentKey: action.newParentKey,\n      }));\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n\n    /* v0.2 predefined */\n    case \"predefined-add\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => ({\n        ...card,\n        predefined: [...card.predefined, action.entry],\n      }));\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n    case \"predefined-edit\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => ({\n        ...card,\n        predefined: card.predefined.map((p) =>\n          p.key === action.key ? action.entry : p,\n        ),\n      }));\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n    case \"predefined-remove\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => ({\n        ...card,\n        predefined: card.predefined.filter((p) => p.key !== action.key),\n      }));\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n\n    /* v0.3 — multi-select */\n    case \"set-multi-selection\": {\n      const ids = new Set(action.ids);\n      const anchor =\n        action.anchor !== undefined\n          ? action.anchor\n          : action.ids.length > 0\n            ? action.ids[action.ids.length - 1]\n            : null;\n      return {\n        ...state,\n        selectedIds: ids,\n        selectedId: ids.size > 0 ? [...ids][0] ?? null : null,\n        selectionAnchorId: anchor,\n      };\n    }\n    case \"toggle-selection\": {\n      const next = new Set(state.selectedIds);\n      if (next.has(action.id)) next.delete(action.id);\n      else next.add(action.id);\n      return {\n        ...state,\n        selectedIds: next,\n        selectedId: next.size > 0 ? [...next][0] ?? null : null,\n        selectionAnchorId: action.id,\n      };\n    }\n    case \"extend-selection-to\": {\n      // Range-select from anchor to action.id along visible-traversal order.\n      const anchor = state.selectionAnchorId ?? action.id;\n      const order = visibleIdsInOrder(state.tree, state.collapsed);\n      const a = order.indexOf(anchor);\n      const b = order.indexOf(action.id);\n      if (a < 0 || b < 0) return state;\n      const [lo, hi] = a <= b ? [a, b] : [b, a];\n      const range = order.slice(lo, hi + 1);\n      const next = new Set([...state.selectedIds, ...range]);\n      return {\n        ...state,\n        selectedIds: next,\n        selectedId: next.size > 0 ? [...next][0] ?? null : null,\n      };\n    }\n    case \"clear-selection\": {\n      if (state.selectedIds.size === 0) return state;\n      return {\n        ...state,\n        selectedIds: new Set<string>(),\n        selectedId: null,\n        selectionAnchorId: null,\n      };\n    }\n\n    /* v0.3 — drag transient */\n    case \"drag-start\": {\n      return { ...state, draggingId: action.cardId };\n    }\n    case \"drag-end\": {\n      if (state.draggingId === null) return state;\n      return { ...state, draggingId: null };\n    }\n\n    /* v0.3 — card movement + duplicate */\n    case \"card-move\": {\n      // Remove the card from its current parent, then insert under newParentId at newOrder.\n      const result = removeNodeById(state.tree, action.cardId);\n      if (!result.removed) return state;\n      const detached = result.removed;\n      const inserted = updateNode(result.tree, action.newParentId, (parent) => ({\n        ...parent,\n        children: [\n          ...parent.children,\n          setLevelDeep({ ...detached, order: action.newOrder }, parent.level + 1),\n        ].sort((a, b) => a.order - b.order),\n      }));\n      return { ...state, tree: inserted, version: state.version + 1 };\n    }\n    case \"card-duplicate\": {\n      const source = findCard(state.tree, action.cardId);\n      if (!source) return state;\n      const parentId = findParentId(state.tree, action.cardId);\n      if (parentId === null) return state; // can't duplicate root\n      const fresh = freshIdGen();\n      const clone = deepCloneWithFreshIds(source, fresh);\n      // Override the top-level clone id with the supplied newCardId\n      const cloneWithId: ParsedCardTree = {\n        ...clone,\n        id: action.newCardId,\n        order: source.order + 0.5,\n        parentKey: source.parentKey ? `${source.parentKey}_copy` : \"copy\",\n      };\n      const tree = updateNode(state.tree, parentId, (parent) => ({\n        ...parent,\n        children: [\n          ...parent.children,\n          setLevelDeep(cloneWithId, parent.level + 1),\n        ].sort((a, b) => a.order - b.order),\n      }));\n      return { ...state, tree, version: state.version + 1 };\n    }\n\n    /* v0.3 — meta */\n    case \"meta-edit\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => ({\n        ...card,\n        meta: { ...(card.meta ?? {}), [action.key]: action.value },\n      }));\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n    case \"meta-add\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => {\n        if (card.meta && action.key in card.meta) return card;\n        return {\n          ...card,\n          meta: { ...(card.meta ?? {}), [action.key]: action.value },\n        };\n      });\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n    case \"meta-remove\": {\n      const tree = updateNode(state.tree, action.cardId, (card) => {\n        if (!card.meta || !(action.key in card.meta)) return card;\n        const next = { ...card.meta };\n        delete next[action.key];\n        return { ...card, meta: Object.keys(next).length > 0 ? next : undefined };\n      });\n      if (tree === state.tree) return state;\n      return { ...state, tree, version: state.version + 1 };\n    }\n\n    /* v0.3 — bulk */\n    case \"bulk-remove\": {\n      // Order ids deepest-first to avoid removing an ancestor before its descendants.\n      const order = depthFirstDepthMap(state.tree);\n      const sorted = [...action.cardIds]\n        .filter((id) => id !== state.tree.id) // root protected\n        .sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0));\n      let workingTree = state.tree;\n      const allRemoved = new Set<string>();\n      for (const id of sorted) {\n        const result = removeNodeById(workingTree, id);\n        if (result.removed) {\n          collectIds(result.removed, allRemoved);\n          workingTree = result.tree;\n        }\n      }\n      if (allRemoved.size === 0) return state;\n      const collapsed = new Set(state.collapsed);\n      allRemoved.forEach((id) => collapsed.delete(id));\n      const focusedId = allRemoved.has(state.focusedId ?? \"\") ? null : state.focusedId;\n      const selectedIds = new Set(\n        [...state.selectedIds].filter((id) => !allRemoved.has(id)),\n      );\n      return {\n        ...state,\n        tree: workingTree,\n        collapsed,\n        focusedId,\n        selectedIds,\n        selectedId: selectedIds.size > 0 ? [...selectedIds][0] ?? null : null,\n        version: state.version + 1,\n      };\n    }\n\n    /* v0.3 — search */\n    case \"set-search-query\": {\n      if (state.searchQuery === action.query) return state;\n      const wasInactive = state.searchQuery === \"\";\n      const isInactive = action.query === \"\";\n      // When transitioning to active, snapshot user-collapsed state (if not already snapshotted).\n      // When transitioning to inactive, restore user-collapsed state.\n      if (wasInactive && !isInactive) {\n        return {\n          ...state,\n          searchQuery: action.query,\n          searchActiveIndex: 0,\n          userCollapsed: state.collapsed,\n        };\n      }\n      if (!wasInactive && isInactive) {\n        return {\n          ...state,\n          searchQuery: \"\",\n          searchActiveIndex: null,\n          collapsed: state.userCollapsed,\n        };\n      }\n      return { ...state, searchQuery: action.query };\n    }\n    case \"set-active-match-index\": {\n      if (state.searchActiveIndex === action.index) return state;\n      return { ...state, searchActiveIndex: action.index };\n    }\n    case \"clear-search\": {\n      if (state.searchQuery === \"\") return state;\n      return {\n        ...state,\n        searchQuery: \"\",\n        searchActiveIndex: null,\n        collapsed: state.userCollapsed,\n      };\n    }\n    case \"expand-path-to-matches\": {\n      // Remove these ancestor ids from collapsed (additively).\n      if (action.ancestorIds.length === 0) return state;\n      const next = new Set(state.collapsed);\n      let changed = false;\n      for (const id of action.ancestorIds) {\n        if (next.delete(id)) changed = true;\n      }\n      return changed ? { ...state, collapsed: next } : state;\n    }\n    // v0.4 actions are handled by the outer reducer wrapper, so they should\n    // never reach innerReducer. Listed here for exhaustiveness.\n    case \"undo\":\n    case \"redo\":\n    case \"clear-history\":\n    case \"set-max-undo-depth\":\n      return state;\n  }\n}\n\n/* ───────── traversal helpers ───────── */\n\nfunction collectIds(node: ParsedCardTree, out: Set<string>): void {\n  out.add(node.id);\n  for (const c of node.children) collectIds(c, out);\n}\n\n/** Depth-first-traversal order map (id → traversal index). Used for deepest-first sort. */\nfunction depthFirstDepthMap(tree: ParsedCardTree): Map<string, number> {\n  const out = new Map<string, number>();\n  walkDepth(tree, 0, out);\n  return out;\n}\n\nfunction walkDepth(node: ParsedCardTree, depth: number, out: Map<string, number>): void {\n  out.set(node.id, depth);\n  for (const c of node.children) walkDepth(c, depth + 1, out);\n}\n\nexport function visibleIdsInOrder(\n  tree: ParsedCardTree,\n  collapsed: ReadonlySet<string>,\n): string[] {\n  const out: string[] = [];\n  walkVisible(tree, collapsed, out);\n  return out;\n}\n\nfunction walkVisible(\n  tree: ParsedCardTree,\n  collapsed: ReadonlySet<string>,\n  out: string[],\n): void {\n  out.push(tree.id);\n  if (collapsed.has(tree.id)) return;\n  for (const child of tree.children) walkVisible(child, collapsed, out);\n}\n\nexport function findParentId(tree: ParsedCardTree, id: string): string | null {\n  if (tree.id === id) return null;\n  for (const child of tree.children) {\n    if (child.id === id) return tree.id;\n    const inSubtree = findParentId(child, id);\n    if (inSubtree) return inSubtree;\n  }\n  return null;\n}\n\nexport function findCard(tree: ParsedCardTree, id: string): ParsedCardTree | null {\n  if (tree.id === id) return tree;\n  for (const child of tree.children) {\n    const found = findCard(child, id);\n    if (found) return found;\n  }\n  return null;\n}\n\n/** Returns the chain of ancestor ids from root → parent (excluding the card itself). */\nexport function findAncestorIds(tree: ParsedCardTree, id: string): string[] {\n  const out: string[] = [];\n  walkAncestors(tree, id, [], out);\n  return out;\n}\n\nfunction walkAncestors(\n  node: ParsedCardTree,\n  targetId: string,\n  path: string[],\n  out: string[],\n): boolean {\n  if (node.id === targetId) {\n    out.push(...path);\n    return true;\n  }\n  for (const c of node.children) {\n    if (walkAncestors(c, targetId, [...path, node.id], out)) return true;\n  }\n  return false;\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/reducer.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/search.ts",
      "content": "/**\n * Pure data-model search.\n *\n * Walks `ParsedCardTree` directly — finds matches in collapsed subtrees and\n * meta entries. No DOM dependence.\n */\n\nimport type {\n  CustomPredefinedKey,\n  FlatFieldValue,\n  PredefinedKey,\n  SearchMatch,\n  SearchOptions,\n  SearchResult,\n} from \"../types\";\nimport type { CardTreePredefinedEntry, ParsedCardTree } from \"./parse\";\n\nconst EXCERPT_RADIUS = 32; // chars on each side of the match\n\nexport type SearchContext = {\n  customKeys?: CustomPredefinedKey[];\n};\n\nexport function searchTree(\n  tree: ParsedCardTree,\n  options: SearchOptions,\n  ctx: SearchContext = {},\n): SearchResult {\n  const matches: SearchMatch[] = [];\n  if (!options.query || options.query.length === 0) {\n    return { matches, matchedCardIds: new Set(), activeIndex: null };\n  }\n\n  const flags = {\n    matchTitles: options.matchTitles ?? true,\n    matchKeys: options.matchKeys ?? true,\n    matchValues: options.matchValues ?? true,\n    matchPredefined: options.matchPredefined ?? true,\n    matchMeta: options.matchMeta ?? true,\n  };\n\n  walk(tree, options.query, options.caseSensitive ?? false, flags, ctx, matches);\n\n  const matchedCardIds = new Set<string>();\n  for (const m of matches) matchedCardIds.add(m.cardId);\n\n  return {\n    matches,\n    matchedCardIds,\n    activeIndex: matches.length > 0 ? 0 : null,\n  };\n}\n\ntype Flags = {\n  matchTitles: boolean;\n  matchKeys: boolean;\n  matchValues: boolean;\n  matchPredefined: boolean;\n  matchMeta: boolean;\n};\n\nfunction walk(\n  node: ParsedCardTree,\n  query: string,\n  caseSensitive: boolean,\n  flags: Flags,\n  ctx: SearchContext,\n  out: SearchMatch[],\n): void {\n  if (flags.matchTitles && node.parentKey) {\n    addOccurrences(out, node.id, \"title\", undefined, node.parentKey, query, caseSensitive);\n  }\n\n  for (const field of node.fields) {\n    if (flags.matchKeys) {\n      addOccurrences(out, node.id, \"field-key\", field.key, field.key, query, caseSensitive);\n    }\n    if (flags.matchValues) {\n      addOccurrences(\n        out,\n        node.id,\n        \"field-value\",\n        field.key,\n        stringifyScalar(field.value),\n        query,\n        caseSensitive,\n      );\n    }\n  }\n\n  if (flags.matchPredefined) {\n    for (const entry of node.predefined) {\n      const text = stringifyPredefined(entry, ctx);\n      if (text) {\n        addOccurrences(out, node.id, \"predefined\", String(entry.key), text, query, caseSensitive);\n      }\n    }\n  }\n\n  if (flags.matchMeta && node.meta) {\n    for (const [key, value] of Object.entries(node.meta)) {\n      addOccurrences(out, node.id, \"meta-key\", key, key, query, caseSensitive);\n      addOccurrences(\n        out,\n        node.id,\n        \"meta-value\",\n        key,\n        stringifyScalar(value),\n        query,\n        caseSensitive,\n      );\n    }\n  }\n\n  for (const child of node.children) {\n    walk(child, query, caseSensitive, flags, ctx, out);\n  }\n}\n\nfunction stringifyScalar(v: FlatFieldValue): string {\n  if (v === null) return \"null\";\n  return String(v);\n}\n\nfunction stringifyPredefined(\n  entry: CardTreePredefinedEntry,\n  ctx: SearchContext,\n): string {\n  switch (entry.key) {\n    case \"codearea\":\n      return `${entry.value.format} ${entry.value.content}`;\n    case \"image\":\n      return `${entry.value.src} ${entry.value.alt ?? \"\"}`;\n    case \"table\": {\n      const t = entry.value;\n      return [\n        ...t.headers,\n        ...t.rows.flat().map(stringifyScalar),\n      ].join(\" \");\n    }\n    case \"quote\":\n      return entry.value;\n    case \"list\":\n      return entry.value.map(stringifyScalar).join(\" \");\n    default: {\n      // Custom predefined-key — v0.3 doesn't search them by default (Q-P15 deferred).\n      // If a `searchableText` is supplied, use it.\n      const customKey = ctx.customKeys?.find(\n        (k) => k.key === (entry as { key: string }).key,\n      );\n      if (customKey?.searchableText) {\n        try {\n          return customKey.searchableText((entry as { value: unknown }).value).join(\" \");\n        } catch {\n          return \"\";\n        }\n      }\n      return \"\";\n    }\n  }\n  // suppress unused\n  void ({} as PredefinedKey);\n}\n\nfunction addOccurrences(\n  out: SearchMatch[],\n  cardId: string,\n  matchType: SearchMatch[\"matchType\"],\n  fieldKey: string | undefined,\n  haystack: string,\n  needle: string,\n  caseSensitive: boolean,\n): void {\n  if (!haystack || !needle) return;\n  const h = caseSensitive ? haystack : haystack.toLowerCase();\n  const n = caseSensitive ? needle : needle.toLowerCase();\n  let from = 0;\n  while (from <= h.length) {\n    const idx = h.indexOf(n, from);\n    if (idx < 0) break;\n    const excerptStart = Math.max(0, idx - EXCERPT_RADIUS);\n    const excerptEnd = Math.min(haystack.length, idx + needle.length + EXCERPT_RADIUS);\n    const excerpt =\n      (excerptStart > 0 ? \"…\" : \"\") +\n      haystack.slice(excerptStart, excerptEnd) +\n      (excerptEnd < haystack.length ? \"…\" : \"\");\n    out.push({\n      cardId,\n      matchType,\n      fieldKey,\n      excerpt,\n      start: idx - excerptStart + (excerptStart > 0 ? 1 : 0),\n      length: needle.length,\n    });\n    from = idx + needle.length;\n  }\n}\n\n/**\n * Returns the ranges within a given string where matches should be highlighted,\n * filtered to a specific (cardId, matchType, fieldKey) context.\n *\n * Used by <MatchHighlight> to wrap matched text in <mark> elements.\n */\nexport function rangesFor(\n  matches: readonly SearchMatch[],\n  haystack: string,\n  cardId: string,\n  matchType: SearchMatch[\"matchType\"],\n  fieldKey: string | undefined,\n  query: string,\n  caseSensitive: boolean,\n): Array<{ start: number; length: number }> {\n  if (!query) return [];\n  const relevant = matches.filter(\n    (m) =>\n      m.cardId === cardId &&\n      m.matchType === matchType &&\n      m.fieldKey === fieldKey,\n  );\n  if (relevant.length === 0) return [];\n  // Re-run the indexOf locally to get exact positions in the haystack\n  // (excerpt-based positions are relative to excerpt, not haystack).\n  const ranges: Array<{ start: number; length: number }> = [];\n  const h = caseSensitive ? haystack : haystack.toLowerCase();\n  const n = caseSensitive ? query : query.toLowerCase();\n  let from = 0;\n  while (from <= h.length) {\n    const idx = h.indexOf(n, from);\n    if (idx < 0) break;\n    ranges.push({ start: idx, length: query.length });\n    from = idx + query.length;\n  }\n  return ranges;\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/search.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/serialize.ts",
      "content": "/**\n * Pure serializer: `ParsedCardTree` → canonical `CardTreeJsonNode` and JSON string.\n *\n * Property emission order per plan §3.1:\n *   1. __rcid\n *   2. __rcorder\n *   3. __rcmeta\n *   4. flat fields (insertion order from parse)\n *   5. predefined-key elements (insertion order)\n *   6. children — emitted under their stored `parentKey` in `order` ascending\n *\n * Round-trip property: parse(serialize(parse(x))) === parse(x). Fixed-point\n * on the second round trip.\n */\n\nimport type { CardTreeJsonNode } from \"../types\";\nimport type { ParsedCardTree } from \"./parse\";\n\nexport function treeToJsonNode(tree: ParsedCardTree): CardTreeJsonNode {\n  const out: CardTreeJsonNode = {\n    __rcid: tree.id,\n    __rcorder: tree.order,\n  };\n\n  if (tree.meta && Object.keys(tree.meta).length > 0) {\n    out.__rcmeta = { ...tree.meta };\n  }\n\n  for (const field of tree.fields) {\n    out[field.key] = field.value;\n  }\n\n  for (const entry of tree.predefined) {\n    out[entry.key] = entry.value as unknown;\n  }\n\n  for (const child of tree.children) {\n    if (!child.parentKey) {\n      // Root has no parentKey; non-root with no parentKey is a parser bug — skip.\n      continue;\n    }\n    out[child.parentKey] = treeToJsonNode(child);\n  }\n\n  return out;\n}\n\nexport function serializeTree(tree: ParsedCardTree): string {\n  return JSON.stringify(treeToJsonNode(tree), null, 2);\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/serialize.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/validate-edit.ts",
      "content": "/**\n * Pure synchronous validators for v0.2 edit operations.\n *\n * Each validator receives the current state + action params, returns either\n * { ok: true } or { ok: false, errors: ValidationError[] }. The reducer calls\n * these before any commit; on failure, state is unchanged and the calling\n * editor surfaces errors via <InlineError>.\n *\n * No async hooks here — those land in v0.4.\n */\n\nimport {\n  PREDEFINED_KEYS,\n  RESERVED_KEYS,\n  type FlatFieldValue,\n  type PredefinedKey,\n} from \"../types\";\nimport {\n  findAncestorIds,\n  findCard,\n  findParentId,\n  type CardTreeState,\n} from \"./reducer\";\nimport type { FlatFieldType } from \"./infer-type\";\n\n/* ───────── result + error types ───────── */\n\nexport type ValidationError = { code: string; message: string };\n\nexport type ValidationResult =\n  | { ok: true }\n  | { ok: false; errors: ValidationError[] };\n\nconst ok: ValidationResult = { ok: true };\nconst fail = (...errors: ValidationError[]): ValidationResult => ({\n  ok: false,\n  errors,\n});\n\n/* ───────── shared key checks ───────── */\n\nfunction isReservedKey(key: string): boolean {\n  return (RESERVED_KEYS as readonly string[]).includes(key);\n}\n\nfunction isPredefinedKey(key: string, disabled: readonly PredefinedKey[]): boolean {\n  return (\n    (PREDEFINED_KEYS as readonly string[]).includes(key) &&\n    !(disabled as readonly string[]).includes(key)\n  );\n}\n\nfunction checkKeyAvailable(\n  newKey: string,\n  cardId: string,\n  state: CardTreeState,\n  disabled: readonly PredefinedKey[],\n  excludeKey?: string,\n): ValidationError[] {\n  const errors: ValidationError[] = [];\n  if (!newKey || newKey.trim().length === 0) {\n    errors.push({ code: \"empty-key\", message: \"Key cannot be empty.\" });\n    return errors;\n  }\n  if (isReservedKey(newKey)) {\n    errors.push({\n      code: \"reserved-key\",\n      message: `\"${newKey}\" is reserved (${RESERVED_KEYS.join(\", \")}).`,\n    });\n  }\n  if (isPredefinedKey(newKey, disabled)) {\n    errors.push({\n      code: \"predefined-key\",\n      message: `\"${newKey}\" is a predefined-key name. Add it to disabledPredefinedKeys to use as a flat field.`,\n    });\n  }\n  const card = findCard(state.tree, cardId);\n  if (card) {\n    const fieldCollision = card.fields.some(\n      (f) => f.key === newKey && f.key !== excludeKey,\n    );\n    if (fieldCollision) {\n      errors.push({\n        code: \"sibling-key-collision\",\n        message: `Field \"${newKey}\" already exists on this card.`,\n      });\n    }\n  }\n  return errors;\n}\n\n/* ───────── flat-field validators ───────── */\n\nexport function validateFieldEditValue(\n  state: CardTreeState,\n  cardId: string,\n  key: string,\n  value: FlatFieldValue,\n  expectedType: FlatFieldType,\n): ValidationResult {\n  const card = findCard(state.tree, cardId);\n  if (!card) return fail({ code: \"no-card\", message: \"Card not found.\" });\n  const field = card.fields.find((f) => f.key === key);\n  if (!field) return fail({ code: \"no-field\", message: `Field \"${key}\" not found.` });\n\n  // Type preservation: v0.2 doesn't allow type-changing on edit (Q-P2).\n  // The editor passes through the inferred type; we just sanity-check it.\n  if (expectedType === \"number\" && typeof value !== \"number\") {\n    return fail({\n      code: \"type-mismatch\",\n      message: \"Value must be a number.\",\n    });\n  }\n  if (expectedType === \"boolean\" && typeof value !== \"boolean\") {\n    return fail({\n      code: \"type-mismatch\",\n      message: \"Value must be a boolean.\",\n    });\n  }\n  if (\n    (expectedType === \"string\" || expectedType === \"date\") &&\n    typeof value !== \"string\"\n  ) {\n    return fail({\n      code: \"type-mismatch\",\n      message: \"Value must be a string.\",\n    });\n  }\n  if (expectedType === \"date\" && typeof value === \"string\") {\n    if (Number.isNaN(Date.parse(value))) {\n      return fail({\n        code: \"invalid-date\",\n        message: \"Must be a valid ISO-8601 date string.\",\n      });\n    }\n  }\n  if (expectedType === \"null\" && value !== null) {\n    return fail({\n      code: \"type-mismatch\",\n      message: \"Null fields are read-only in v0.2.\",\n    });\n  }\n  return ok;\n}\n\nexport function validateFieldEditKey(\n  state: CardTreeState,\n  cardId: string,\n  oldKey: string,\n  newKey: string,\n  disabledPredefinedKeys: readonly PredefinedKey[],\n): ValidationResult {\n  if (oldKey === newKey) return ok;\n  const errors = checkKeyAvailable(\n    newKey,\n    cardId,\n    state,\n    disabledPredefinedKeys,\n    oldKey,\n  );\n  return errors.length === 0 ? ok : fail(...errors);\n}\n\nexport function validateFieldAdd(\n  state: CardTreeState,\n  cardId: string,\n  key: string,\n  value: FlatFieldValue,\n  type: FlatFieldType,\n  disabledPredefinedKeys: readonly PredefinedKey[],\n): ValidationResult {\n  const keyErrors = checkKeyAvailable(\n    key,\n    cardId,\n    state,\n    disabledPredefinedKeys,\n  );\n  if (keyErrors.length > 0) return fail(...keyErrors);\n\n  if (type === \"null\") {\n    return fail({\n      code: \"no-null-add\",\n      message: \"Adding null fields is not supported in v0.2.\",\n    });\n  }\n  // delegate the value/type sanity check to validateFieldEditValue logic minus card/field lookup\n  if (type === \"number\" && typeof value !== \"number\")\n    return fail({ code: \"type-mismatch\", message: \"Value must be a number.\" });\n  if (type === \"boolean\" && typeof value !== \"boolean\")\n    return fail({ code: \"type-mismatch\", message: \"Value must be a boolean.\" });\n  if ((type === \"string\" || type === \"date\") && typeof value !== \"string\")\n    return fail({ code: \"type-mismatch\", message: \"Value must be a string.\" });\n  if (type === \"date\" && typeof value === \"string\" && Number.isNaN(Date.parse(value)))\n    return fail({\n      code: \"invalid-date\",\n      message: \"Must be a valid ISO-8601 date string.\",\n    });\n  return ok;\n}\n\n/* ───────── card validators ───────── */\n\nexport function validateCardRename(\n  state: CardTreeState,\n  cardId: string,\n  newKey: string,\n  disabledPredefinedKeys: readonly PredefinedKey[],\n): ValidationResult {\n  if (!newKey || newKey.trim().length === 0) {\n    return fail({ code: \"empty-key\", message: \"Card name cannot be empty.\" });\n  }\n  if (isReservedKey(newKey)) {\n    return fail({\n      code: \"reserved-key\",\n      message: `\"${newKey}\" is a reserved key.`,\n    });\n  }\n  if (isPredefinedKey(newKey, disabledPredefinedKeys)) {\n    return fail({\n      code: \"predefined-key\",\n      message: `\"${newKey}\" is a predefined-key name; pick a different card name.`,\n    });\n  }\n  // Sibling-key collision: find this card's parent + check siblings (excluding self)\n  const parentId = findParentIdLocal(state.tree, cardId);\n  if (parentId === null) return ok; // root has no siblings\n  const parent = findCard(state.tree, parentId);\n  if (!parent) return ok;\n  const collision = parent.children.some(\n    (c) => c.id !== cardId && c.parentKey === newKey,\n  );\n  if (collision) {\n    return fail({\n      code: \"sibling-collision\",\n      message: `Sibling \"${newKey}\" already exists on the parent.`,\n    });\n  }\n  // Also collide with any flat field on the parent (since on serialize they share namespace)\n  const fieldCollision = parent.fields.some((f) => f.key === newKey);\n  if (fieldCollision) {\n    return fail({\n      code: \"sibling-collision\",\n      message: `Parent already has a field named \"${newKey}\".`,\n    });\n  }\n  return ok;\n}\n\nfunction findParentIdLocal(\n  tree: { id: string; children: { id: string }[] } & {\n    [key: string]: unknown;\n  },\n  childId: string,\n): string | null {\n  for (const c of tree.children) {\n    if (c.id === childId) return tree.id;\n  }\n  for (const c of tree.children) {\n    // @ts-expect-error recurse into ParsedCardTree shape\n    const inner = findParentIdLocal(c, childId);\n    if (inner) return inner;\n  }\n  return null;\n}\n\nexport function validateCardRemove(\n  state: CardTreeState,\n  cardId: string,\n): ValidationResult {\n  if (state.tree.id === cardId) {\n    return fail({\n      code: \"no-root-remove\",\n      message: \"Removing the root card is not supported in v0.2.\",\n    });\n  }\n  if (!findCard(state.tree, cardId)) {\n    return fail({ code: \"no-card\", message: \"Card not found.\" });\n  }\n  return ok;\n}\n\n/* ───────── predefined-key validators ───────── */\n\nexport function validatePredefinedShape(\n  key: PredefinedKey,\n  value: unknown,\n): ValidationResult {\n  switch (key) {\n    case \"codearea\": {\n      if (\n        value &&\n        typeof value === \"object\" &&\n        !Array.isArray(value) &&\n        typeof (value as Record<string, unknown>).format === \"string\" &&\n        typeof (value as Record<string, unknown>).content === \"string\"\n      )\n        return ok;\n      return fail({\n        code: \"shape-mismatch\",\n        message: \"codearea must be { format: string, content: string }.\",\n      });\n    }\n    case \"image\": {\n      if (\n        value &&\n        typeof value === \"object\" &&\n        !Array.isArray(value) &&\n        typeof (value as Record<string, unknown>).src === \"string\"\n      ) {\n        const alt = (value as Record<string, unknown>).alt;\n        if (alt === undefined || typeof alt === \"string\") return ok;\n      }\n      return fail({\n        code: \"shape-mismatch\",\n        message: \"image must be { src: string, alt?: string }.\",\n      });\n    }\n    case \"table\": {\n      if (\n        value &&\n        typeof value === \"object\" &&\n        !Array.isArray(value)\n      ) {\n        const v = value as Record<string, unknown>;\n        if (\n          Array.isArray(v.headers) &&\n          v.headers.every((h) => typeof h === \"string\") &&\n          Array.isArray(v.rows) &&\n          v.rows.every(\n            (row) =>\n              Array.isArray(row) &&\n              row.length === (v.headers as string[]).length &&\n              row.every(\n                (c) =>\n                  c === null ||\n                  typeof c === \"string\" ||\n                  typeof c === \"number\" ||\n                  typeof c === \"boolean\",\n              ),\n          )\n        )\n          return ok;\n      }\n      return fail({\n        code: \"shape-mismatch\",\n        message:\n          \"table must be { headers: string[], rows: scalar[][] } with consistent row widths.\",\n      });\n    }\n    case \"quote\": {\n      if (typeof value === \"string\") return ok;\n      return fail({\n        code: \"shape-mismatch\",\n        message: \"quote must be a string.\",\n      });\n    }\n    case \"list\": {\n      if (\n        Array.isArray(value) &&\n        value.every(\n          (v) =>\n            v === null ||\n            typeof v === \"string\" ||\n            typeof v === \"number\" ||\n            typeof v === \"boolean\",\n        )\n      )\n        return ok;\n      return fail({\n        code: \"shape-mismatch\",\n        message: \"list must be an array of scalar values.\",\n      });\n    }\n  }\n}\n\n/* ───────── v0.3: meta validators ───────── */\n\nexport function validateMetaEdit(\n  state: CardTreeState,\n  cardId: string,\n  key: string,\n  value: FlatFieldValue,\n): ValidationResult {\n  const card = findCard(state.tree, cardId);\n  if (!card) return fail({ code: \"no-card\", message: \"Card not found.\" });\n  if (!card.meta || !(key in card.meta)) {\n    return fail({ code: \"no-meta-key\", message: `Meta key \"${key}\" not found.` });\n  }\n  if (\n    value !== null &&\n    typeof value !== \"string\" &&\n    typeof value !== \"number\" &&\n    typeof value !== \"boolean\"\n  ) {\n    return fail({\n      code: \"invalid-meta-value\",\n      message: \"Meta values must be scalars (string / number / boolean / null).\",\n    });\n  }\n  return ok;\n}\n\nexport function validateMetaAdd(\n  state: CardTreeState,\n  cardId: string,\n  key: string,\n  value: FlatFieldValue,\n): ValidationResult {\n  if (!key || key.trim().length === 0) {\n    return fail({ code: \"empty-key\", message: \"Meta key cannot be empty.\" });\n  }\n  if (isReservedKey(key)) {\n    return fail({\n      code: \"reserved-key\",\n      message: `\"${key}\" is a reserved key.`,\n    });\n  }\n  const card = findCard(state.tree, cardId);\n  if (!card) return fail({ code: \"no-card\", message: \"Card not found.\" });\n  if (card.meta && key in card.meta) {\n    return fail({\n      code: \"meta-key-collision\",\n      message: `Meta key \"${key}\" already exists on this card.`,\n    });\n  }\n  if (\n    value !== null &&\n    typeof value !== \"string\" &&\n    typeof value !== \"number\" &&\n    typeof value !== \"boolean\"\n  ) {\n    return fail({\n      code: \"invalid-meta-value\",\n      message: \"Meta values must be scalars.\",\n    });\n  }\n  return ok;\n}\n\n/* ───────── v0.3: card-move validator ───────── */\n\nexport function validateCardMove(\n  state: CardTreeState,\n  cardId: string,\n  newParentId: string,\n): ValidationResult {\n  if (cardId === newParentId) {\n    return fail({\n      code: \"self-parent\",\n      message: \"Cannot drop a card onto itself.\",\n    });\n  }\n  if (!findCard(state.tree, cardId)) {\n    return fail({ code: \"no-card\", message: \"Card not found.\" });\n  }\n  if (!findCard(state.tree, newParentId)) {\n    return fail({ code: \"no-parent\", message: \"Target parent not found.\" });\n  }\n  // Cycle detection: target cannot be a descendant of source\n  const targetAncestors = findAncestorIds(state.tree, newParentId);\n  if (targetAncestors.includes(cardId)) {\n    return fail({\n      code: \"cycle\",\n      message: \"Cannot drop a card into its own descendant (would create a cycle).\",\n    });\n  }\n  // Prevent moving root\n  if (state.tree.id === cardId) {\n    return fail({\n      code: \"no-root-move\",\n      message: \"Cannot move the root card.\",\n    });\n  }\n  // Suppress unused parent-id helper warning\n  void findParentId;\n  return ok;\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/validate-edit.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/lib/validators.ts",
      "content": "/**\n * Pure validation pipeline runner (v0.4).\n *\n * Three layers, each must pass before commit:\n *   1. Built-in validators — already run by individual dispatchers (v0.2)\n *   2. Per-action validators — typed payloads, host-supplied\n *   3. Master validator — catch-all, host-supplied\n *\n * Async hooks are deferred indefinitely (Q14).\n */\n\nimport type {\n  CardAddedEvent,\n  CardDuplicatedEvent,\n  CardMovedEvent,\n  CardRemovedEvent,\n  CardRenamedEvent,\n  FieldAddedEvent,\n  FieldEditedEvent,\n  FieldRemovedEvent,\n  MetaAddedEvent,\n  MetaChangedEvent,\n  MetaRemovedEvent,\n  PredefinedAddedEvent,\n  PredefinedEditedEvent,\n  PredefinedRemovedEvent,\n  CardTreeJsonNode,\n  CardTreeMasterValidator,\n  CardTreeValidationError,\n  CardTreeValidationResponse,\n  CardTreeValidators,\n  ValidationFailedEvent,\n} from \"../types\";\n\n/** All event payloads passed to per-action validators. */\nexport type AnyChangeEvent =\n  | FieldEditedEvent\n  | FieldAddedEvent\n  | FieldRemovedEvent\n  | CardAddedEvent\n  | CardRemovedEvent\n  | CardRenamedEvent\n  | CardMovedEvent\n  | CardDuplicatedEvent\n  | PredefinedAddedEvent\n  | PredefinedEditedEvent\n  | PredefinedRemovedEvent\n  | MetaChangedEvent\n  | MetaAddedEvent\n  | MetaRemovedEvent;\n\nexport type ValidatorKey = keyof CardTreeValidators;\n\n/**\n * Run host-supplied validators (per-action + master) against the proposed event.\n * Returns ok or the first failing layer's errors.\n */\nexport function runHostValidators(\n  validatorKey: ValidatorKey,\n  event: AnyChangeEvent,\n  actionType: string,\n  cardId: string | undefined,\n  tree: CardTreeJsonNode,\n  validators: CardTreeValidators | undefined,\n  master: CardTreeMasterValidator | undefined,\n):\n  | { ok: true }\n  | {\n      ok: false;\n      errors: CardTreeValidationError[];\n      layer: \"per-action\" | \"master\";\n    } {\n  // Per-action layer\n  if (validators) {\n    const fn = validators[validatorKey];\n    if (fn) {\n      const result = safeCall(() =>\n        (fn as (e: AnyChangeEvent, t: CardTreeJsonNode) => CardTreeValidationResponse)(\n          event,\n          tree,\n        ),\n      );\n      if (!result.ok) {\n        return { ok: false, errors: result.errors, layer: \"per-action\" };\n      }\n    }\n  }\n  // Master layer\n  if (master) {\n    const result = safeCall(() => master({ type: actionType, cardId }, tree));\n    if (!result.ok) {\n      return { ok: false, errors: result.errors, layer: \"master\" };\n    }\n  }\n  return { ok: true };\n}\n\nfunction safeCall(\n  fn: () => CardTreeValidationResponse,\n): CardTreeValidationResponse {\n  try {\n    const r = fn();\n    if (!r || (r.ok !== true && r.ok !== false)) {\n      return {\n        ok: false,\n        errors: [\n          {\n            code: \"validator-bad-return\",\n            message: \"Validator must return { ok: true } or { ok: false, errors: [...] }.\",\n          },\n        ],\n      };\n    }\n    return r;\n  } catch (err) {\n    return {\n      ok: false,\n      errors: [\n        {\n          code: \"validator-error\",\n          message: err instanceof Error ? err.message : String(err),\n        },\n      ],\n    };\n  }\n}\n\n/** Build the typed event for `onValidationFailed` consumers. */\nexport function buildValidationFailedEvent(\n  actionType: string,\n  cardId: string | undefined,\n  errors: CardTreeValidationError[],\n  layer: \"per-action\" | \"master\",\n): ValidationFailedEvent {\n  return { action: actionType, cardId, errors, layer };\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/lib/validators.ts"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/bulk-toolbar.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Copy, Edit3, Lock, Trash2, X } from \"lucide-react\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\nimport type { FlatFieldValue } from \"../types\";\nimport type { FlatFieldType } from \"../lib/infer-type\";\n\n/**\n * Floating toolbar visible when ≥2 cards are selected. Shows count + bulk actions.\n */\nexport function BulkToolbar({\n  count,\n  levelMin,\n  levelMax,\n  totalFields,\n  lockedCount,\n  onDelete,\n  onDuplicate,\n  onClear,\n  onBulkSetField,\n  onToggleLock,\n  className,\n}: {\n  count: number;\n  levelMin: number;\n  levelMax: number;\n  totalFields: number;\n  lockedCount: number;\n  onDelete: () => void;\n  onDuplicate: () => void;\n  onClear: () => void;\n  onBulkSetField: (key: string, value: FlatFieldValue, type: FlatFieldType) => void;\n  onToggleLock: () => void;\n  className?: string;\n}) {\n  if (count < 2) return null;\n  return (\n    <div\n      role=\"toolbar\"\n      aria-label=\"Bulk actions\"\n      className={cn(\n        \"sticky top-2 z-10 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 shadow-md\",\n        className,\n      )}\n    >\n      <span className=\"font-mono text-xs text-foreground/80\">\n        <strong>{count}</strong> cards selected\n      </span>\n      <span className=\"font-mono text-[10px] text-muted-foreground\">\n        · L{levelMin}–L{levelMax} · {totalFields} fields{lockedCount > 0 ? ` · ${lockedCount} locked` : \"\"}\n      </span>\n      <div className=\"ml-auto flex flex-wrap items-center gap-1\">\n        <BulkSetFieldPopover onCommit={onBulkSetField} />\n        <button\n          type=\"button\"\n          onClick={onToggleLock}\n          className=\"inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          aria-label=\"Toggle lock on selected cards\"\n        >\n          <Lock className=\"size-3\" aria-hidden=\"true\" />\n          toggle lock\n        </button>\n        <button\n          type=\"button\"\n          onClick={onDuplicate}\n          className=\"inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <Copy className=\"size-3\" aria-hidden=\"true\" />\n          duplicate\n        </button>\n        <button\n          type=\"button\"\n          onClick={onDelete}\n          className=\"inline-flex items-center gap-1 rounded-md border border-destructive/40 bg-destructive/5 px-2 py-1 font-mono text-[11px] text-destructive transition-colors hover:bg-destructive/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <Trash2 className=\"size-3\" aria-hidden=\"true\" />\n          delete\n        </button>\n        <button\n          type=\"button\"\n          onClick={onClear}\n          aria-label=\"Clear selection\"\n          className=\"inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <X className=\"size-3.5\" aria-hidden=\"true\" />\n        </button>\n      </div>\n    </div>\n  );\n}\n\nfunction BulkSetFieldPopover({\n  onCommit,\n}: {\n  onCommit: (key: string, value: FlatFieldValue, type: FlatFieldType) => void;\n}) {\n  const [open, setOpen] = useState(false);\n  const [key, setKey] = useState(\"\");\n  const [type, setType] = useState<FlatFieldType>(\"string\");\n  const [value, setValue] = useState<string>(\"\");\n\n  const submit = () => {\n    if (!key.trim()) return;\n    let parsedValue: FlatFieldValue;\n    switch (type) {\n      case \"number\":\n        parsedValue = Number(value) || 0;\n        break;\n      case \"boolean\":\n        parsedValue = value === \"true\";\n        break;\n      case \"null\":\n        parsedValue = null;\n        break;\n      case \"string\":\n      case \"date\":\n      default:\n        parsedValue = value;\n    }\n    onCommit(key, parsedValue, type);\n    setKey(\"\");\n    setValue(\"\");\n    setOpen(false);\n  };\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      <PopoverTrigger\n        className=\"inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        aria-label=\"Bulk set field across selection\"\n      >\n        <Edit3 className=\"size-3\" aria-hidden=\"true\" />\n        set field\n      </PopoverTrigger>\n      <PopoverContent align=\"end\" sideOffset={4} className=\"w-72 p-3 space-y-2\">\n        <p className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n          Set field on all selected\n        </p>\n        <input\n          type=\"text\"\n          value={key}\n          onChange={(e) => setKey(e.target.value)}\n          placeholder=\"key\"\n          className=\"block w-full rounded-sm border border-border bg-background px-2 py-1 font-mono text-[12px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        />\n        <select\n          value={type}\n          onChange={(e) => setType(e.target.value as FlatFieldType)}\n          className=\"block w-full rounded-sm border border-border bg-background px-2 py-1 font-mono text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <option value=\"string\">string</option>\n          <option value=\"number\">number</option>\n          <option value=\"boolean\">boolean</option>\n          <option value=\"date\">date</option>\n        </select>\n        <input\n          type={type === \"boolean\" ? \"text\" : type === \"number\" ? \"number\" : \"text\"}\n          value={value}\n          onChange={(e) => setValue(e.target.value)}\n          placeholder={type === \"boolean\" ? \"true / false\" : \"value\"}\n          className=\"block w-full rounded-sm border border-border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        />\n        <button\n          type=\"button\"\n          onClick={submit}\n          disabled={!key.trim()}\n          className=\"block w-full rounded-md bg-primary px-2 py-1 font-mono text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          apply to selected\n        </button>\n      </PopoverContent>\n    </Popover>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/bulk-toolbar.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/card-actions.tsx",
      "content": "\"use client\";\n\nimport { Copy, MoreHorizontal, Plus, Trash2, TreePine } from \"lucide-react\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Compact card-level actions menu for edit mode (... button in header).\n * v0.3 actions: remove (cascade or promote), duplicate. Root-only when allowed.\n */\nexport function CardActionsMenu({\n  onRemoveCascade,\n  onRemovePromote,\n  onDuplicate,\n  canRemove,\n  canDuplicate,\n  isRoot,\n  allowRootRemoval,\n  hasChildren,\n  className,\n}: {\n  onRemoveCascade: () => void;\n  onRemovePromote?: () => void;\n  onDuplicate?: () => void;\n  canRemove: boolean;\n  canDuplicate: boolean;\n  isRoot: boolean;\n  allowRootRemoval: boolean;\n  hasChildren: boolean;\n  className?: string;\n}) {\n  const removeAllowed = canRemove && (!isRoot || allowRootRemoval);\n  return (\n    <Popover>\n      <PopoverTrigger\n        className={cn(\n          \"inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          className,\n        )}\n        aria-label=\"Card actions\"\n      >\n        <MoreHorizontal className=\"size-3.5\" />\n      </PopoverTrigger>\n      <PopoverContent align=\"end\" sideOffset={4} className=\"w-52 p-1\">\n        {onDuplicate ? (\n          <button\n            type=\"button\"\n            onClick={onDuplicate}\n            disabled={!canDuplicate || isRoot}\n            className=\"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:bg-muted\"\n          >\n            <Copy className=\"size-3.5\" aria-hidden=\"true\" />\n            Duplicate\n          </button>\n        ) : null}\n        {hasChildren && onRemovePromote ? (\n          <button\n            type=\"button\"\n            onClick={onRemovePromote}\n            disabled={!removeAllowed}\n            className=\"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:bg-muted\"\n          >\n            <TreePine className=\"size-3.5\" aria-hidden=\"true\" />\n            Remove (promote children)\n          </button>\n        ) : null}\n        <button\n          type=\"button\"\n          onClick={onRemoveCascade}\n          disabled={!removeAllowed}\n          className=\"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-40 focus-visible:outline-none focus-visible:bg-destructive/10\"\n        >\n          <Trash2 className=\"size-3.5\" aria-hidden=\"true\" />\n          Remove{hasChildren ? \" (cascade)\" : \"\"}\n        </button>\n        {!removeAllowed ? (\n          <p className=\"px-2 py-1 text-[11px] text-muted-foreground\">\n            {isRoot && !allowRootRemoval\n              ? \"Root removal disabled (allowRootRemoval=false).\"\n              : \"Removal denied by permissions.\"}\n          </p>\n        ) : null}\n      </PopoverContent>\n    </Popover>\n  );\n}\n\nexport function AddChildButton({\n  onClick,\n  disabled,\n}: {\n  onClick: () => void;\n  disabled?: boolean;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      className={cn(\n        \"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\",\n        disabled && \"cursor-not-allowed opacity-40\",\n      )}\n    >\n      <Plus className=\"size-3\" aria-hidden=\"true\" />\n      child\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/card-actions.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/card-header.tsx",
      "content": "import { Lock } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { FlatFieldValue, MetaRenderer, SearchMatch } from \"../types\";\nimport type { ParsedCardTree } from \"../lib/parse\";\nimport type { EditMode } from \"../hooks/use-edit-mode\";\nimport { rangesFor } from \"../lib/search\";\nimport { ChevronRight } from \"lucide-react\";\nimport { MetaInline } from \"./meta-inline\";\nimport { MetaPopover } from \"./meta-popover\";\nimport { CardTitleEdit } from \"./card-title-edit\";\nimport { CardActionsMenu } from \"./card-actions\";\nimport { DragHandle } from \"./drag-handle\";\nimport { MatchHighlight } from \"./match-highlight\";\nimport type { EditDispatchers, EditValidators } from \"./card\";\n\nexport function CardHeader({\n  tree,\n  title,\n  titleId,\n  collapsed,\n  canCollapse,\n  meta,\n  metaPresentation,\n  metaRenderers,\n  onToggleCollapse,\n  onSelect,\n  editable,\n  canEdit,\n  canRemove,\n  canDuplicate,\n  canDrag,\n  isLocked,\n  isRoot,\n  allowRootRemoval,\n  hasChildren,\n  defaultDeletePolicy,\n  editMode,\n  setEditMode,\n  validators,\n  dispatchers,\n  tentativeCardId,\n  dragHandleListeners,\n  dragHandleAttributes,\n  searchMatches,\n  searchQuery,\n  searchCaseSensitive,\n  className,\n}: {\n  tree: ParsedCardTree;\n  title: string | undefined;\n  titleId: string;\n  collapsed: boolean;\n  canCollapse: boolean;\n  meta?: Record<string, FlatFieldValue>;\n  metaPresentation: \"hidden\" | \"inline\" | \"popover\";\n  metaRenderers?: Record<string, MetaRenderer>;\n  onToggleCollapse: () => void;\n  onSelect: (event: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => void;\n  editable: boolean;\n  canEdit: boolean;\n  canRemove: boolean;\n  canDuplicate: boolean;\n  canDrag: boolean;\n  isLocked: boolean;\n  isRoot: boolean;\n  allowRootRemoval: boolean;\n  hasChildren: boolean;\n  defaultDeletePolicy: \"cascade\" | \"promote\";\n  editMode: EditMode | null;\n  setEditMode: (m: EditMode | null) => void;\n  validators: EditValidators;\n  dispatchers: EditDispatchers;\n  tentativeCardId: string | null;\n  dragHandleListeners?: Record<string, (event: unknown) => void>;\n  dragHandleAttributes?: Record<string, unknown>;\n  searchMatches: readonly SearchMatch[];\n  searchQuery: string;\n  searchCaseSensitive: boolean;\n  className?: string;\n}) {\n  const showInlineMeta =\n    metaPresentation === \"inline\" && meta && Object.keys(meta).length > 0;\n  const hasMeta = meta && Object.keys(meta).length > 0;\n  const isEditingTitle =\n    editable &&\n    canEdit &&\n    editMode?.kind === \"card-title\" &&\n    editMode.cardId === tree.id;\n\n  const canRenameTitle = editable && canEdit && !isRoot;\n  const titleRanges = title\n    ? rangesFor(searchMatches, title, tree.id, \"title\", undefined, searchQuery, searchCaseSensitive)\n    : [];\n\n  return (\n    <div\n      className={cn(\"flex items-center gap-1.5\", className)}\n      onClick={(e) => {\n        if (e.target === e.currentTarget) {\n          onSelect({ shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey });\n        }\n      }}\n    >\n      {/* Drag handle (edit mode only) */}\n      {editable && canDrag ? (\n        <DragHandle\n          listeners={dragHandleListeners}\n          attributes={dragHandleAttributes}\n          disabled={!canDrag}\n        />\n      ) : null}\n\n      {canCollapse ? (\n        <button\n          type=\"button\"\n          onClick={(e) => {\n            e.stopPropagation();\n            onToggleCollapse();\n          }}\n          aria-label={collapsed ? \"Expand card\" : \"Collapse card\"}\n          aria-expanded={!collapsed}\n          className=\"inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <ChevronRight\n            className={cn(\n              \"size-3.5 transition-transform duration-150\",\n              !collapsed && \"rotate-90\",\n            )}\n          />\n        </button>\n      ) : (\n        <span aria-hidden=\"true\" className=\"inline-block size-5 shrink-0\" />\n      )}\n\n      {isLocked ? (\n        <Lock\n          className=\"size-3 shrink-0 text-muted-foreground/70\"\n          aria-label=\"Locked\"\n        />\n      ) : null}\n\n      {isEditingTitle ? (\n        <CardTitleEdit\n          initialTitle={title ?? \"\"}\n          isTentative={tentativeCardId === tree.id}\n          validate={(newKey) => validators.cardRename(tree.id, newKey)}\n          onCommit={(newKey) => dispatchers.cardRename(tree.id, newKey)}\n          onCancel={() => setEditMode(null)}\n          onCancelTentative={() => dispatchers.cardCancelTentative(tree.id)}\n        />\n      ) : (\n        <h3\n          id={titleId}\n          className={cn(\n            \"min-w-0 flex-1 truncate text-sm font-semibold tracking-tight\",\n            canRenameTitle && \"cursor-text rounded-sm hover:bg-muted px-1 -mx-1\",\n          )}\n          onClick={(e) => {\n            e.stopPropagation();\n            onSelect({ shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey });\n            if (canRenameTitle) {\n              setEditMode({ kind: \"card-title\", cardId: tree.id });\n            }\n          }}\n        >\n          {title ? (\n            <MatchHighlight text={title} ranges={titleRanges} />\n          ) : (\n            <span className=\"text-muted-foreground italic font-normal\">\n              Untitled\n            </span>\n          )}\n        </h3>\n      )}\n\n      {showInlineMeta && meta ? (\n        <MetaInline meta={meta} metaRenderers={metaRenderers} />\n      ) : null}\n\n      {metaPresentation === \"popover\" && hasMeta && meta ? (\n        <MetaPopover\n          meta={meta}\n          cardId={tree.id}\n          editable={editable && canEdit}\n          metaRenderers={metaRenderers}\n          dispatchers={dispatchers}\n          validators={validators}\n        />\n      ) : null}\n\n      {editable ? (\n        <CardActionsMenu\n          onRemoveCascade={() => dispatchers.cardRemove(tree.id, \"cascade\")}\n          onRemovePromote={\n            defaultDeletePolicy === \"promote\" || hasChildren\n              ? () => dispatchers.cardRemove(tree.id, \"promote\")\n              : undefined\n          }\n          onDuplicate={\n            !isRoot ? () => dispatchers.cardDuplicate(tree.id) : undefined\n          }\n          canRemove={canRemove}\n          canDuplicate={canDuplicate}\n          isRoot={isRoot}\n          allowRootRemoval={allowRootRemoval}\n          hasChildren={hasChildren}\n        />\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/card-header.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/card-title-edit.tsx",
      "content": "import { useEffect, useRef, useState } from \"react\";\nimport type { ValidationResult } from \"../lib/validate-edit\";\nimport { InlineError } from \"./inline-error\";\n\n/**\n * Inline editor for a card's parentKey (its visible title).\n * Replaces the <h3> while in edit mode.\n */\nexport function CardTitleEdit({\n  initialTitle,\n  validate,\n  onCommit,\n  onCancel,\n  onCancelTentative,\n  isTentative,\n}: {\n  initialTitle: string;\n  validate: (newTitle: string) => ValidationResult;\n  onCommit: (newTitle: string) => void;\n  /** Called on Escape when the card is \"real\" (not tentative). */\n  onCancel: () => void;\n  /** Called on Escape when the card was just-added and should be removed. */\n  onCancelTentative: () => void;\n  isTentative: boolean;\n}) {\n  const [title, setTitle] = useState(initialTitle);\n  const validation = validate(title);\n  const ref = useRef<HTMLInputElement>(null);\n  useEffect(() => {\n    ref.current?.focus();\n    ref.current?.select();\n  }, []);\n\n  const submit = () => {\n    if (validation.ok && title !== initialTitle) onCommit(title);\n    else if (title === initialTitle && !isTentative) onCancel();\n  };\n\n  const cancel = () => {\n    if (isTentative) onCancelTentative();\n    else onCancel();\n  };\n\n  return (\n    <span className=\"inline-flex flex-1 flex-wrap items-center gap-1\">\n      <input\n        ref={ref}\n        type=\"text\"\n        value={title}\n        onChange={(e) => setTitle(e.target.value)}\n        onBlur={submit}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            submit();\n          } else if (e.key === \"Escape\") {\n            e.preventDefault();\n            cancel();\n          }\n        }}\n        className=\"min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm font-semibold tracking-tight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        aria-label=\"Edit card title\"\n      />\n      {!validation.ok ? <InlineError errors={validation.errors} /> : null}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/card-title-edit.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/card.tsx",
      "content": "\"use client\";\n\nimport { type Dispatch } from \"react\";\nimport { useSortable } from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  FlatFieldValue,\n  LevelStyle,\n  MetaRenderer,\n  PredefinedKey,\n  SearchMatch,\n} from \"../types\";\nimport type {\n  CardTreePredefinedEntry,\n  ParsedCardTree,\n} from \"../lib/parse\";\nimport type {\n  CardTreeAction,\n  CardTreeState,\n} from \"../lib/reducer\";\nimport type { FlatFieldType } from \"../lib/infer-type\";\nimport type { ValidationResult } from \"../lib/validate-edit\";\nimport type { EditMode } from \"../hooks/use-edit-mode\";\nimport type { UsePermissionsReturn } from \"../hooks/use-permissions\";\nimport { CardHeader } from \"./card-header\";\nimport { FieldRow } from \"./field-row\";\nimport { PredefinedCodeArea } from \"./predefined-codearea\";\nimport { PredefinedImage } from \"./predefined-image\";\nimport { PredefinedTable } from \"./predefined-table\";\nimport { PredefinedQuote } from \"./predefined-quote\";\nimport { PredefinedList } from \"./predefined-list\";\nimport { PredefinedEdit } from \"./predefined-edit\";\nimport { FieldAddButton, FieldAddForm } from \"./field-add\";\nimport { PredefinedAddMenu } from \"./predefined-add-menu\";\nimport { AddChildButton } from \"./card-actions\";\n\n/* ───────── public config (passed from card-tree.tsx) ───────── */\n\nexport type EditDispatchers = {\n  fieldEditValue: (\n    cardId: string,\n    key: string,\n    value: FlatFieldValue,\n    type: FlatFieldType,\n  ) => void;\n  fieldEditKey: (cardId: string, oldKey: string, newKey: string) => void;\n  fieldAdd: (\n    cardId: string,\n    key: string,\n    value: FlatFieldValue,\n    type: FlatFieldType,\n  ) => void;\n  fieldRemove: (cardId: string, key: string) => void;\n  cardAdd: (parentId: string) => void;\n  cardRemove: (cardId: string, policy?: \"cascade\" | \"promote\") => void;\n  cardRename: (cardId: string, newKey: string) => void;\n  cardCancelTentative: (cardId: string) => void;\n  cardDuplicate: (cardId: string) => void;\n  predefinedAdd: (cardId: string, entry: CardTreePredefinedEntry) => void;\n  predefinedEdit: (\n    cardId: string,\n    key: PredefinedKey | string,\n    entry: CardTreePredefinedEntry,\n  ) => void;\n  predefinedRemove: (cardId: string, key: PredefinedKey | string) => void;\n  metaEdit: (cardId: string, key: string, value: FlatFieldValue) => void;\n  metaAdd: (cardId: string, key: string, value: FlatFieldValue) => void;\n  metaRemove: (cardId: string, key: string) => void;\n  // Selection\n  selectCard: (\n    cardId: string,\n    event: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean },\n  ) => void;\n};\n\nexport type EditValidators = {\n  fieldEditValue: (\n    cardId: string,\n    key: string,\n    value: FlatFieldValue,\n    type: FlatFieldType,\n  ) => ValidationResult;\n  fieldEditKey: (cardId: string, oldKey: string, newKey: string) => ValidationResult;\n  fieldAdd: (\n    cardId: string,\n    key: string,\n    value: FlatFieldValue,\n    type: FlatFieldType,\n  ) => ValidationResult;\n  cardRename: (cardId: string, newKey: string) => ValidationResult;\n  metaEdit: (cardId: string, key: string, value: FlatFieldValue) => ValidationResult;\n  metaAdd: (cardId: string, key: string, value: FlatFieldValue) => ValidationResult;\n};\n\nexport type CardConfig = {\n  resolveLevelStyle: (level: number) => LevelStyle;\n  predefinedKeyStyles?: Partial<Record<PredefinedKey | string, string | LevelStyle>>;\n  metaPresentation: \"hidden\" | \"inline\" | \"popover\";\n  metaRenderers?: Record<string, MetaRenderer>;\n  rootTitle?: string;\n  disabledPredefinedKeys: readonly PredefinedKey[];\n  // v0.2 editor surface\n  editable: boolean;\n  editMode: EditMode | null;\n  setEditMode: (mode: EditMode | null) => void;\n  clearEditMode: () => void;\n  tentativeCardId: string | null;\n  dispatchers: EditDispatchers;\n  validators: EditValidators;\n  // v0.3\n  perms: UsePermissionsReturn;\n  allowRootRemoval: boolean;\n  defaultDeletePolicy: \"cascade\" | \"promote\";\n  searchMatches: readonly SearchMatch[];\n  searchQuery: string;\n  searchCaseSensitive: boolean;\n  dndEnabled: boolean;\n};\n\n/* ───────── helpers ───────── */\n\nfunction resolvePredefinedClass(\n  style: string | LevelStyle | undefined,\n): string | undefined {\n  if (!style) return undefined;\n  if (typeof style === \"string\") return style;\n  return style.containerClassName;\n}\n\nfunction PredefinedRenderer({\n  entry,\n  styleProp,\n  editable,\n  isEditing,\n  onEnterEdit,\n  onCommit,\n  onCancel,\n  onRemove,\n}: {\n  entry: CardTreePredefinedEntry;\n  styleProp: string | LevelStyle | undefined;\n  editable: boolean;\n  isEditing: boolean;\n  onEnterEdit: () => void;\n  onCommit: (next: CardTreePredefinedEntry) => void;\n  onCancel: () => void;\n  onRemove: () => void;\n}) {\n  if (isEditing) {\n    return <PredefinedEdit entry={entry} onCommit={onCommit} onCancel={onCancel} />;\n  }\n  const className = resolvePredefinedClass(styleProp);\n  const block = (() => {\n    switch (entry.key) {\n      case \"codearea\":\n        return <PredefinedCodeArea value={entry.value} className={className} />;\n      case \"image\":\n        return <PredefinedImage value={entry.value} className={className} />;\n      case \"table\":\n        return <PredefinedTable value={entry.value} className={className} />;\n      case \"quote\":\n        return <PredefinedQuote value={entry.value} className={className} />;\n      case \"list\":\n        return <PredefinedList value={entry.value} className={className} />;\n    }\n  })();\n  if (!editable) return block;\n  return (\n    <div className=\"group relative\">\n      <button\n        type=\"button\"\n        onClick={onEnterEdit}\n        className=\"block w-full cursor-text text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md\"\n        aria-label={`Edit ${entry.key}`}\n      >\n        {block}\n      </button>\n      <button\n        type=\"button\"\n        onClick={(e) => {\n          e.stopPropagation();\n          onRemove();\n        }}\n        aria-label={`Remove ${entry.key}`}\n        className=\"absolute right-1 top-1 inline-flex size-5 items-center justify-center rounded bg-card/80 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/20 hover:text-destructive group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        ×\n      </button>\n    </div>\n  );\n}\n\n/* ───────── Card (recursive, sortable) ───────── */\n\nexport function Card({\n  tree,\n  config,\n  state,\n  dispatch,\n}: {\n  tree: ParsedCardTree;\n  config: CardConfig;\n  state: CardTreeState;\n  dispatch: Dispatch<CardTreeAction>;\n}) {\n  const { perms } = config;\n  const collapsed = state.collapsed.has(tree.id);\n  const isFocused = state.focusedId === tree.id;\n  const isSelected = state.selectedIds.has(tree.id);\n  const hasChildren = tree.children.length > 0;\n  const hasFields = tree.fields.length > 0;\n  const hasPredefined = tree.predefined.length > 0;\n  const hasBody = hasFields || hasPredefined;\n  const canCollapse = hasBody || hasChildren;\n  const isLocked = perms.isLocked(tree.id);\n\n  const levelStyle = config.resolveLevelStyle(tree.level);\n  const titleId = `rcc-title-${tree.id}`;\n  const title =\n    tree.parentKey ?? (tree.level === 1 ? config.rootTitle : undefined);\n\n  const isAddingField =\n    config.editable &&\n    perms.canAddField(tree.id) &&\n    config.editMode?.kind === \"field-add\" &&\n    config.editMode.cardId === tree.id;\n\n  const presentPredefinedKeys = tree.predefined.map((p) => p.key);\n  const isRoot = tree.level === 1;\n\n  // v0.3: sortable integration\n  const {\n    attributes: sortableAttributes,\n    listeners: sortableListeners,\n    setNodeRef: sortableSetNodeRef,\n    transform: sortableTransform,\n    transition: sortableTransition,\n    isDragging: sortableIsDragging,\n  } = useSortable({\n    id: tree.id,\n    disabled:\n      !config.dndEnabled || !config.editable || isRoot || !perms.canDragCard(tree.id),\n  });\n  const sortableStyle = {\n    transform: CSS.Translate.toString(sortableTransform),\n    transition: sortableTransition,\n    opacity: sortableIsDragging ? 0.5 : 1,\n  };\n\n  const canEdit = perms.canEditCard(tree.id);\n  const canAddField = perms.canAddField(tree.id);\n  const canRemoveCard = perms.canRemoveCard(tree.id);\n  const canAddCard = perms.canAddCard(tree.id);\n  const canDuplicateCard = !isRoot && perms.canEditCard(tree.id);\n  const canDrag = !isRoot && perms.canDragCard(tree.id);\n\n  return (\n    <li\n      ref={sortableSetNodeRef}\n      role=\"treeitem\"\n      aria-level={tree.level}\n      aria-expanded={canCollapse ? !collapsed : undefined}\n      aria-selected={isSelected}\n      aria-labelledby={titleId}\n      data-readonly={isLocked || undefined}\n      tabIndex={isFocused ? 0 : -1}\n      data-rcid={tree.id}\n      style={sortableStyle}\n      onFocus={(e) => {\n        if (e.target === e.currentTarget) {\n          dispatch({ type: \"set-focus\", id: tree.id });\n        }\n      }}\n      className={cn(\n        \"block focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        levelStyle.containerClassName,\n        isSelected &&\n          \"ring-2 ring-primary/40 ring-offset-1 ring-offset-background\",\n        isLocked && \"opacity-90\",\n      )}\n    >\n      <CardHeader\n        tree={tree}\n        title={title}\n        titleId={titleId}\n        collapsed={collapsed}\n        canCollapse={canCollapse}\n        meta={tree.meta}\n        metaPresentation={config.metaPresentation}\n        metaRenderers={config.metaRenderers}\n        onToggleCollapse={() =>\n          dispatch({ type: \"toggle-collapse\", id: tree.id })\n        }\n        onSelect={(event) => config.dispatchers.selectCard(tree.id, event)}\n        editable={config.editable}\n        canEdit={canEdit}\n        canRemove={canRemoveCard}\n        canDuplicate={canDuplicateCard}\n        canDrag={canDrag}\n        isLocked={isLocked}\n        isRoot={isRoot}\n        allowRootRemoval={config.allowRootRemoval}\n        hasChildren={hasChildren}\n        defaultDeletePolicy={config.defaultDeletePolicy}\n        editMode={config.editMode}\n        setEditMode={config.setEditMode}\n        validators={config.validators}\n        dispatchers={config.dispatchers}\n        tentativeCardId={config.tentativeCardId}\n        dragHandleListeners={\n          sortableListeners as\n            | Record<string, (event: unknown) => void>\n            | undefined\n        }\n        dragHandleAttributes={\n          sortableAttributes as unknown as Record<string, unknown> | undefined\n        }\n        searchMatches={config.searchMatches}\n        searchQuery={config.searchQuery}\n        searchCaseSensitive={config.searchCaseSensitive}\n        className={levelStyle.headerClassName}\n      />\n\n      {hasBody && !collapsed ? (\n        <div className={cn(\"mt-2 space-y-2\", levelStyle.fieldsClassName)}>\n          {hasFields ? (\n            <dl className=\"grid gap-1\">\n              {tree.fields.map((f) => (\n                <FieldRow\n                  key={f.key}\n                  cardId={tree.id}\n                  fieldKey={f.key}\n                  value={f.value}\n                  type={f.type}\n                  editable={config.editable}\n                  canEdit={perms.canEditField(tree.id, f.key)}\n                  canEditKey={perms.canEditField(tree.id, f.key)}\n                  canRemove={perms.canRemoveField(tree.id, f.key)}\n                  editMode={config.editMode}\n                  setEditMode={config.setEditMode}\n                  validators={config.validators}\n                  dispatchers={config.dispatchers}\n                  searchMatches={config.searchMatches}\n                  searchQuery={config.searchQuery}\n                  searchCaseSensitive={config.searchCaseSensitive}\n                />\n              ))}\n            </dl>\n          ) : null}\n\n          {hasPredefined ? (\n            <div className=\"space-y-2\">\n              {tree.predefined.map((entry, i) => {\n                const isEditing =\n                  config.editable &&\n                  perms.canEditPredefined(tree.id, String(entry.key)) &&\n                  config.editMode?.kind === \"predefined\" &&\n                  config.editMode.cardId === tree.id &&\n                  config.editMode.key === entry.key;\n                return (\n                  <PredefinedRenderer\n                    key={`${entry.key}-${i}`}\n                    entry={entry}\n                    styleProp={config.predefinedKeyStyles?.[entry.key]}\n                    editable={\n                      config.editable &&\n                      perms.canEditPredefined(tree.id, String(entry.key))\n                    }\n                    isEditing={isEditing}\n                    onEnterEdit={() =>\n                      config.setEditMode({\n                        kind: \"predefined\",\n                        cardId: tree.id,\n                        key: entry.key as PredefinedKey,\n                      })\n                    }\n                    onCommit={(next) =>\n                      config.dispatchers.predefinedEdit(\n                        tree.id,\n                        entry.key,\n                        next,\n                      )\n                    }\n                    onCancel={() => config.clearEditMode()}\n                    onRemove={() =>\n                      config.dispatchers.predefinedRemove(tree.id, entry.key)\n                    }\n                  />\n                );\n              })}\n            </div>\n          ) : null}\n        </div>\n      ) : null}\n\n      {/* v0.4.2 — add affordances rendered independently of `hasBody` so an\n          empty card (zero fields, zero predefined) can still be populated.\n          Pre-v0.4.2 this block sat inside the body wrapper above, making the\n          \"+ FIELD\" / \"+ BLOCK\" buttons unreachable for a freshly-added child\n          card (cardAdd creates fields:[]/predefined:[], so hasBody=false). */}\n      {config.editable && !collapsed ? (\n        <div className={cn(\"mt-2 flex flex-wrap items-center gap-1.5\", levelStyle.fieldsClassName)}>\n          {isAddingField ? (\n            <FieldAddForm\n              validateAll={(key, value, type) =>\n                config.validators.fieldAdd(tree.id, key, value, type)\n              }\n              onCommit={(key, value, type) =>\n                config.dispatchers.fieldAdd(tree.id, key, value, type)\n              }\n              onCancel={() => config.clearEditMode()}\n            />\n          ) : canAddField ? (\n            <FieldAddButton\n              onClick={() =>\n                config.setEditMode({\n                  kind: \"field-add\",\n                  cardId: tree.id,\n                })\n              }\n            />\n          ) : null}\n          <PredefinedAddMenu\n            presentKeys={presentPredefinedKeys as PredefinedKey[]}\n            disabledKeys={config.disabledPredefinedKeys}\n            onAdd={(entry) =>\n              config.dispatchers.predefinedAdd(tree.id, entry)\n            }\n          />\n        </div>\n      ) : null}\n\n      {hasChildren && !collapsed ? (\n        <ul\n          role=\"group\"\n          className={cn(\n            \"mt-2 space-y-1.5 ps-2 border-s border-border/40\",\n            levelStyle.childrenClassName,\n          )}\n        >\n          {tree.children.map((child) => (\n            <Card\n              key={child.id}\n              tree={child}\n              config={config}\n              state={state}\n              dispatch={dispatch}\n            />\n          ))}\n        </ul>\n      ) : null}\n\n      {/* \"+ child\" button in edit mode */}\n      {config.editable && canAddCard && !collapsed ? (\n        <div className={cn(\"mt-2\", hasChildren && \"ps-2\")}>\n          <AddChildButton\n            onClick={() => config.dispatchers.cardAdd(tree.id)}\n            disabled={!canAddCard}\n          />\n        </div>\n      ) : null}\n    </li>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/card.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/drag-handle.tsx",
      "content": "import { GripVertical } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Visible drag affordance — small grip icon. Lives in card header (edit mode).\n * Connects to @dnd-kit via the `dragListeners` and `dragAttributes` from useSortable\n * applied at the parent (Card).\n */\nexport function DragHandle({\n  listeners,\n  attributes,\n  disabled,\n  className,\n}: {\n  listeners?: Record<string, (event: unknown) => void>;\n  attributes?: Record<string, unknown>;\n  disabled?: boolean;\n  className?: string;\n}) {\n  return (\n    <button\n      type=\"button\"\n      {...attributes}\n      {...listeners}\n      disabled={disabled}\n      aria-label=\"Drag to reorder\"\n      className={cn(\n        \"inline-flex size-5 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:cursor-grabbing focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n        disabled && \"cursor-not-allowed opacity-30\",\n        className,\n      )}\n    >\n      <GripVertical className=\"size-3.5\" aria-hidden=\"true\" />\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/drag-handle.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/drop-indicator.tsx",
      "content": "import { cn } from \"@/lib/utils\";\n\n/**\n * Same-level (between-siblings) drop indicator: thin horizontal line at the drop position.\n */\nexport function BetweenDropIndicator({\n  active,\n  className,\n}: {\n  active: boolean;\n  className?: string;\n}) {\n  return (\n    <div\n      aria-hidden=\"true\"\n      className={cn(\n        \"h-0.5 -my-px rounded-full transition-colors\",\n        active ? \"bg-primary\" : \"bg-transparent\",\n        className,\n      )}\n    />\n  );\n}\n\n/**\n * Cross-level (into-card) drop indicator: outline highlight on the target card.\n * Renders as a wrapper. The Card component conditionally applies it.\n */\nexport function IntoDropOutline({\n  active,\n  rejected,\n  className,\n}: {\n  active: boolean;\n  rejected?: boolean;\n  className?: string;\n}) {\n  return (\n    <div\n      aria-hidden=\"true\"\n      className={cn(\n        \"pointer-events-none absolute inset-0 rounded-md ring-2 ring-offset-1 ring-offset-background transition-opacity\",\n        active ? \"opacity-100\" : \"opacity-0\",\n        rejected ? \"ring-destructive\" : \"ring-primary\",\n        className,\n      )}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/drop-indicator.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/empty-tree.tsx",
      "content": "import { Plus } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Default empty-tree placeholder shown when the root has been removed\n * (allowRootRemoval={true} + onRootRemoved returned null).\n *\n * Hosts can override this entirely via the emptyTreeRenderer prop.\n */\nexport function EmptyTreePlaceholder({\n  onAddRoot,\n  className,\n}: {\n  onAddRoot?: () => void;\n  className?: string;\n}) {\n  return (\n    <div\n      role=\"status\"\n      className={cn(\n        \"flex flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border/70 bg-muted/20 px-4 py-8 text-center\",\n        className,\n      )}\n    >\n      <p className=\"text-sm text-muted-foreground\">Tree is empty.</p>\n      {onAddRoot ? (\n        <button\n          type=\"button\"\n          onClick={onAddRoot}\n          className=\"inline-flex items-center gap-1.5 rounded-md border border-dashed border-border bg-card px-3 py-1.5 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <Plus className=\"size-3\" aria-hidden=\"true\" />\n          add root card\n        </button>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/empty-tree.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/field-add.tsx",
      "content": "import { useEffect, useRef, useState } from \"react\";\nimport { Check, Plus, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { FlatFieldValue } from \"../types\";\nimport type { FlatFieldType } from \"../lib/infer-type\";\nimport type { ValidationResult } from \"../lib/validate-edit\";\nimport { InlineError } from \"./inline-error\";\n\nconst ADDABLE_TYPES: FlatFieldType[] = [\n  \"string\",\n  \"number\",\n  \"boolean\",\n  \"date\",\n];\n\nfunction defaultValueFor(type: FlatFieldType): FlatFieldValue {\n  switch (type) {\n    case \"number\":\n      return 0;\n    case \"boolean\":\n      return false;\n    case \"date\":\n      return new Date().toISOString().slice(0, 10);\n    case \"null\":\n      return null;\n    case \"string\":\n    default:\n      return \"\";\n  }\n}\n\n/**\n * Inline form for adding a flat field. Replaces the \"+ field\" button\n * location while open. Press Enter to add, Escape to cancel.\n */\nexport function FieldAddForm({\n  validateAll,\n  onCommit,\n  onCancel,\n}: {\n  validateAll: (\n    key: string,\n    value: FlatFieldValue,\n    type: FlatFieldType,\n  ) => ValidationResult;\n  onCommit: (\n    key: string,\n    value: FlatFieldValue,\n    type: FlatFieldType,\n  ) => void;\n  onCancel: () => void;\n}) {\n  const [key, setKey] = useState(\"\");\n  const [type, setType] = useState<FlatFieldType>(\"string\");\n  const [value, setValue] = useState<FlatFieldValue>(defaultValueFor(\"string\"));\n  const keyRef = useRef<HTMLInputElement>(null);\n  useEffect(() => {\n    keyRef.current?.focus();\n  }, []);\n\n  const validation = validateAll(key, value, type);\n\n  const submit = () => {\n    if (validation.ok) {\n      onCommit(key, value, type);\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"Enter\") {\n      e.preventDefault();\n      submit();\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      onCancel();\n    }\n  };\n\n  return (\n    <div\n      className=\"space-y-1.5 rounded-md border border-dashed border-border/70 bg-muted/30 p-2\"\n      onKeyDown={handleKeyDown}\n    >\n      <div className=\"grid grid-cols-[minmax(0,auto)_minmax(0,1fr)_minmax(0,auto)] items-center gap-1.5\">\n        <span className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n          key\n        </span>\n        <input\n          ref={keyRef}\n          type=\"text\"\n          value={key}\n          onChange={(e) => setKey(e.target.value)}\n          placeholder=\"field name\"\n          className=\"rounded-sm border border-border bg-background px-1.5 py-0.5 font-mono text-[12px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          aria-label=\"New field key\"\n        />\n        <select\n          value={type}\n          onChange={(e) => {\n            const t = e.target.value as FlatFieldType;\n            setType(t);\n            setValue(defaultValueFor(t));\n          }}\n          className=\"rounded-sm border border-border bg-background px-1.5 py-0.5 font-mono text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          aria-label=\"New field type\"\n        >\n          {ADDABLE_TYPES.map((t) => (\n            <option key={t} value={t}>\n              {t}\n            </option>\n          ))}\n        </select>\n      </div>\n\n      <div className=\"grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] items-center gap-1.5\">\n        <span className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n          value\n        </span>\n        <ValueInputForType type={type} value={value} onChange={setValue} />\n      </div>\n\n      <div className=\"flex items-center justify-between gap-2\">\n        {!validation.ok && key.length > 0 ? (\n          <InlineError errors={validation.errors} />\n        ) : (\n          <span />\n        )}\n        <div className=\"flex items-center gap-1\">\n          <button\n            type=\"button\"\n            onClick={onCancel}\n            className=\"inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            aria-label=\"Cancel add field\"\n          >\n            <X className=\"size-3.5\" aria-hidden=\"true\" />\n          </button>\n          <button\n            type=\"button\"\n            onClick={submit}\n            disabled={!validation.ok}\n            className=\"inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 font-mono text-[11px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <Check className=\"size-3\" aria-hidden=\"true\" />\n            add\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction ValueInputForType({\n  type,\n  value,\n  onChange,\n}: {\n  type: FlatFieldType;\n  value: FlatFieldValue;\n  onChange: (v: FlatFieldValue) => void;\n}) {\n  const baseClass =\n    \"rounded-sm border border-border bg-background px-1.5 py-0.5 text-[12px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\";\n  switch (type) {\n    case \"boolean\":\n      return (\n        <label className=\"inline-flex items-center gap-1.5 text-sm\">\n          <input\n            type=\"checkbox\"\n            checked={value === true}\n            onChange={(e) => onChange(e.target.checked)}\n            className=\"size-4 rounded-sm\"\n          />\n          <span className=\"font-mono text-[11px] text-muted-foreground\">\n            {value === true ? \"true\" : \"false\"}\n          </span>\n        </label>\n      );\n    case \"number\":\n      return (\n        <input\n          type=\"number\"\n          value={typeof value === \"number\" ? String(value) : \"\"}\n          onChange={(e) => {\n            const n = Number(e.target.value);\n            onChange(Number.isFinite(n) ? n : 0);\n          }}\n          className={cn(baseClass, \"w-32 text-right font-mono tabular-nums\")}\n          aria-label=\"New field number value\"\n        />\n      );\n    case \"date\":\n      return (\n        <input\n          type=\"date\"\n          value={typeof value === \"string\" ? value.slice(0, 10) : \"\"}\n          onChange={(e) => onChange(e.target.value)}\n          className={cn(baseClass, \"min-w-32\")}\n          aria-label=\"New field date value\"\n        />\n      );\n    case \"null\":\n      return null;\n    case \"string\":\n    default:\n      return (\n        <input\n          type=\"text\"\n          value={typeof value === \"string\" ? value : \"\"}\n          onChange={(e) => onChange(e.target.value)}\n          placeholder=\"value\"\n          className={cn(baseClass, \"min-w-32\")}\n          aria-label=\"New field string value\"\n        />\n      );\n  }\n}\n\n/**\n * Lightweight \"+ field\" button that triggers FieldAddForm.\n */\nexport function FieldAddButton({\n  onClick,\n}: {\n  onClick: () => void;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\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\"\n    >\n      <Plus className=\"size-3\" aria-hidden=\"true\" />\n      field\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/field-add.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/field-edit.tsx",
      "content": "import {\n  type ChangeEvent,\n  type KeyboardEvent,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { Check, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { FlatFieldValue } from \"../types\";\nimport type { FlatFieldType } from \"../lib/infer-type\";\nimport type { ValidationResult } from \"../lib/validate-edit\";\nimport { InlineError } from \"./inline-error\";\n\n/* ───────── value editor (type-aware) ───────── */\n\nexport function FieldValueEdit({\n  initialValue,\n  type,\n  validate,\n  onCommit,\n  onCancel,\n}: {\n  initialValue: FlatFieldValue;\n  type: FlatFieldType;\n  validate: (value: FlatFieldValue) => ValidationResult;\n  onCommit: (value: FlatFieldValue) => void;\n  onCancel: () => void;\n}) {\n  const [value, setValue] = useState<FlatFieldValue>(initialValue);\n  const validation = validate(value);\n\n  const submit = () => {\n    if (validation.ok) onCommit(value);\n  };\n\n  const handleKey = (e: KeyboardEvent<HTMLElement>) => {\n    if (e.key === \"Enter\" && type !== \"boolean\") {\n      e.preventDefault();\n      submit();\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      onCancel();\n    }\n  };\n\n  return (\n    <span\n      className=\"inline-flex flex-wrap items-center gap-1\"\n      onKeyDown={handleKey}\n    >\n      <ValueInput\n        type={type}\n        value={value}\n        onChange={(v) => {\n          setValue(v);\n          // For booleans, commit immediately on toggle (Q-P9).\n          if (type === \"boolean\" && validation.ok) {\n            onCommit(v);\n          }\n        }}\n        onBlur={() => {\n          // Don't commit on blur from boolean — already committed on toggle.\n          if (type === \"boolean\") return;\n          submit();\n        }}\n      />\n      {type !== \"boolean\" ? (\n        <span className=\"inline-flex items-center gap-0.5\">\n          <button\n            type=\"button\"\n            onClick={submit}\n            disabled={!validation.ok}\n            aria-label=\"Save\"\n            className=\"inline-flex size-5 items-center justify-center rounded text-primary transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <Check className=\"size-3.5\" aria-hidden=\"true\" />\n          </button>\n          <button\n            type=\"button\"\n            onClick={onCancel}\n            aria-label=\"Cancel\"\n            className=\"inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <X className=\"size-3.5\" aria-hidden=\"true\" />\n          </button>\n        </span>\n      ) : null}\n      {!validation.ok ? <InlineError errors={validation.errors} /> : null}\n    </span>\n  );\n}\n\nfunction ValueInput({\n  type,\n  value,\n  onChange,\n  onBlur,\n}: {\n  type: FlatFieldType;\n  value: FlatFieldValue;\n  onChange: (v: FlatFieldValue) => void;\n  onBlur: () => void;\n}) {\n  const ref = useRef<HTMLInputElement>(null);\n  useEffect(() => {\n    ref.current?.focus();\n    if (ref.current && typeof ref.current.select === \"function\") {\n      try {\n        ref.current.select();\n      } catch {\n        /* some input types don't support select() */\n      }\n    }\n  }, []);\n\n  const baseClass =\n    \"rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\";\n\n  switch (type) {\n    case \"boolean\":\n      return (\n        <input\n          ref={ref}\n          type=\"checkbox\"\n          checked={value === true}\n          onChange={(e) => onChange(e.target.checked)}\n          onBlur={onBlur}\n          className=\"size-4 rounded-sm border border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          aria-label=\"Edit boolean value\"\n        />\n      );\n    case \"number\":\n      return (\n        <input\n          ref={ref}\n          type=\"number\"\n          value={value === null || typeof value !== \"number\" ? \"\" : String(value)}\n          onChange={(e: ChangeEvent<HTMLInputElement>) => {\n            const raw = e.target.value;\n            if (raw === \"\") {\n              onChange(0);\n              return;\n            }\n            const n = Number(raw);\n            onChange(Number.isFinite(n) ? n : 0);\n          }}\n          onBlur={onBlur}\n          className={cn(baseClass, \"w-24 text-right font-mono tabular-nums\")}\n          aria-label=\"Edit number value\"\n        />\n      );\n    case \"date\": {\n      const isoString = typeof value === \"string\" ? value : \"\";\n      const hasTime = /T\\d{2}:\\d{2}/.test(isoString);\n      const inputType = hasTime ? \"datetime-local\" : \"date\";\n      // datetime-local doesn't accept Z-suffix; strip for display\n      const inputValue = hasTime\n        ? isoString.replace(/Z$/, \"\").slice(0, 16)\n        : isoString.slice(0, 10);\n      return (\n        <input\n          ref={ref}\n          type={inputType}\n          value={inputValue}\n          onChange={(e) => onChange(e.target.value)}\n          onBlur={onBlur}\n          className={cn(baseClass, \"min-w-32\")}\n          aria-label=\"Edit date value\"\n        />\n      );\n    }\n    case \"null\":\n      return (\n        <span className=\"text-muted-foreground italic\">\n          (read-only — null fields cannot be edited)\n        </span>\n      );\n    case \"string\":\n    default:\n      return (\n        <input\n          ref={ref}\n          type=\"text\"\n          value={typeof value === \"string\" ? value : \"\"}\n          onChange={(e) => onChange(e.target.value)}\n          onBlur={onBlur}\n          className={cn(baseClass, \"min-w-32\")}\n          aria-label=\"Edit string value\"\n        />\n      );\n  }\n}\n\n/* ───────── key editor (rename) ───────── */\n\nexport function FieldKeyEdit({\n  initialKey,\n  validate,\n  onCommit,\n  onCancel,\n}: {\n  initialKey: string;\n  validate: (newKey: string) => ValidationResult;\n  onCommit: (newKey: string) => void;\n  onCancel: () => void;\n}) {\n  const [key, setKey] = useState(initialKey);\n  const validation = validate(key);\n  const ref = useRef<HTMLInputElement>(null);\n  useEffect(() => {\n    ref.current?.focus();\n    ref.current?.select();\n  }, []);\n\n  const submit = () => {\n    if (validation.ok && key !== initialKey) onCommit(key);\n    else if (key === initialKey) onCancel();\n  };\n\n  return (\n    <span className=\"inline-flex flex-wrap items-center gap-1\">\n      <input\n        ref={ref}\n        type=\"text\"\n        value={key}\n        onChange={(e) => setKey(e.target.value)}\n        onBlur={submit}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            submit();\n          } else if (e.key === \"Escape\") {\n            e.preventDefault();\n            onCancel();\n          }\n        }}\n        className=\"rounded-sm border border-border bg-background px-1.5 py-0.5 font-mono text-[11px] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        aria-label=\"Rename field key\"\n      />\n      {!validation.ok ? <InlineError errors={validation.errors} /> : null}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/field-edit.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/field-row.tsx",
      "content": "import { Check, Minus, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { FlatFieldValue, SearchMatch } from \"../types\";\nimport type { FlatFieldType } from \"../lib/infer-type\";\nimport type { EditMode } from \"../hooks/use-edit-mode\";\nimport type { EditDispatchers, EditValidators } from \"./card\";\nimport { FieldKeyEdit, FieldValueEdit } from \"./field-edit\";\nimport { MatchHighlight } from \"./match-highlight\";\nimport { rangesFor } from \"../lib/search\";\n\nfunction formatDate(iso: string): string {\n  const d = new Date(iso);\n  if (Number.isNaN(d.getTime())) return iso;\n  const hasTime = /T\\d{2}:\\d{2}/.test(iso);\n  return new Intl.DateTimeFormat(undefined, {\n    dateStyle: \"medium\",\n    ...(hasTime ? { timeStyle: \"short\" } : {}),\n  }).format(d);\n}\n\nfunction renderTypedValue(value: FlatFieldValue, type: FlatFieldType, ranges: Array<{ start: number; length: number }>) {\n  switch (type) {\n    case \"null\":\n      return <span className=\"text-muted-foreground\">—</span>;\n    case \"boolean\":\n      return (\n        <span className=\"inline-flex items-center gap-1.5\">\n          {value === true ? (\n            <Check className=\"size-3.5 text-primary\" aria-hidden=\"true\" />\n          ) : (\n            <Minus className=\"size-3.5 text-muted-foreground\" aria-hidden=\"true\" />\n          )}\n          <span className=\"sr-only\">{String(value)}</span>\n        </span>\n      );\n    case \"number\":\n      return (\n        <span className=\"font-mono tabular-nums text-foreground/90\">\n          <MatchHighlight text={String(value)} ranges={ranges} />\n        </span>\n      );\n    case \"date\":\n      return (\n        <span title={String(value)} className=\"text-foreground/90\">\n          <MatchHighlight text={formatDate(String(value))} ranges={[]} />\n        </span>\n      );\n    case \"string\":\n    default:\n      return (\n        <span>\n          <MatchHighlight text={String(value)} ranges={ranges} />\n        </span>\n      );\n  }\n}\n\nexport function FieldRow({\n  cardId,\n  fieldKey,\n  value,\n  type,\n  editable,\n  canEdit,\n  canEditKey,\n  canRemove,\n  editMode,\n  setEditMode,\n  validators,\n  dispatchers,\n  searchMatches,\n  searchQuery,\n  searchCaseSensitive,\n}: {\n  cardId: string;\n  fieldKey: string;\n  value: FlatFieldValue;\n  type: FlatFieldType;\n  editable: boolean;\n  canEdit: boolean;\n  canEditKey: boolean;\n  canRemove: boolean;\n  editMode: EditMode | null;\n  setEditMode: (m: EditMode | null) => void;\n  validators: EditValidators;\n  dispatchers: EditDispatchers;\n  searchMatches: readonly SearchMatch[];\n  searchQuery: string;\n  searchCaseSensitive: boolean;\n}) {\n  const isEditingValue =\n    editable &&\n    canEdit &&\n    editMode?.kind === \"field-value\" &&\n    editMode.cardId === cardId &&\n    editMode.key === fieldKey;\n  const isEditingKey =\n    editable &&\n    canEditKey &&\n    editMode?.kind === \"field-key\" &&\n    editMode.cardId === cardId &&\n    editMode.key === fieldKey;\n\n  const canEditValue = editable && canEdit && type !== \"null\";\n\n  const keyRanges = rangesFor(searchMatches, fieldKey, cardId, \"field-key\", fieldKey, searchQuery, searchCaseSensitive);\n  const valueText = value === null ? \"\" : String(value);\n  const valueRanges = rangesFor(searchMatches, valueText, cardId, \"field-value\", fieldKey, searchQuery, searchCaseSensitive);\n\n  return (\n    <div className=\"group grid grid-cols-[minmax(0,auto)_minmax(0,1fr)_auto] items-baseline gap-x-3 gap-y-0.5\">\n      <dt\n        className={cn(\n          \"truncate font-mono text-[11px] text-muted-foreground\",\n          editable && canEditKey && \"cursor-text rounded-sm hover:bg-muted px-1 -mx-1\",\n        )}\n        onClick={() => {\n          if (!editable || !canEditKey) return;\n          if (isEditingKey) return;\n          setEditMode({ kind: \"field-key\", cardId, key: fieldKey });\n        }}\n      >\n        {isEditingKey ? (\n          <FieldKeyEdit\n            initialKey={fieldKey}\n            validate={(newKey) => validators.fieldEditKey(cardId, fieldKey, newKey)}\n            onCommit={(newKey) => dispatchers.fieldEditKey(cardId, fieldKey, newKey)}\n            onCancel={() => setEditMode(null)}\n          />\n        ) : (\n          <MatchHighlight text={fieldKey} ranges={keyRanges} />\n        )}\n      </dt>\n      <dd\n        className=\"min-w-0 wrap-break-word text-sm text-foreground\"\n        onClick={() => {\n          if (!canEditValue) return;\n          if (isEditingValue) return;\n          setEditMode({ kind: \"field-value\", cardId, key: fieldKey });\n        }}\n      >\n        {isEditingValue ? (\n          <FieldValueEdit\n            initialValue={value}\n            type={type}\n            validate={(newValue) =>\n              validators.fieldEditValue(cardId, fieldKey, newValue, type)\n            }\n            onCommit={(newValue) =>\n              dispatchers.fieldEditValue(cardId, fieldKey, newValue, type)\n            }\n            onCancel={() => setEditMode(null)}\n          />\n        ) : (\n          <span\n            className={cn(\n              canEditValue && \"cursor-text rounded-sm hover:bg-muted px-1 -mx-1\",\n            )}\n          >\n            {renderTypedValue(value, type, valueRanges)}\n          </span>\n        )}\n      </dd>\n      {editable && canRemove ? (\n        <button\n          type=\"button\"\n          onClick={(e) => {\n            e.stopPropagation();\n            dispatchers.fieldRemove(cardId, fieldKey);\n          }}\n          aria-label={`Remove field ${fieldKey}`}\n          className=\"inline-flex size-5 items-center justify-center rounded text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <X className=\"size-3\" aria-hidden=\"true\" />\n        </button>\n      ) : (\n        <span aria-hidden=\"true\" />\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/field-row.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/inline-error.tsx",
      "content": "import { AlertCircle } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { ValidationError } from \"../lib/validate-edit\";\n\nexport function InlineError({\n  errors,\n  className,\n}: {\n  errors: ValidationError[];\n  className?: string;\n}) {\n  if (errors.length === 0) return null;\n  return (\n    <div\n      role=\"alert\"\n      aria-live=\"polite\"\n      className={cn(\n        \"mt-1 inline-flex items-start gap-1.5 rounded-sm bg-destructive/10 px-1.5 py-0.5 text-[11px] text-destructive\",\n        className,\n      )}\n    >\n      <AlertCircle className=\"mt-0.5 size-3 shrink-0\" aria-hidden=\"true\" />\n      <span>\n        {errors.length === 1\n          ? errors[0]?.message\n          : errors.map((e) => e.message).join(\" · \")}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/inline-error.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/match-highlight.tsx",
      "content": "import { Fragment } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type HighlightRange = { start: number; length: number };\n\n/**\n * Wraps matched substrings in <mark>. Active ranges get a distinct style.\n * Non-matched portions render as plain text.\n *\n * Used by every text-rendering location in the tree to display search hits.\n * When `ranges` is empty, this is a transparent passthrough.\n */\nexport function MatchHighlight({\n  text,\n  ranges,\n  activeRange,\n  className,\n}: {\n  text: string;\n  ranges: readonly HighlightRange[];\n  activeRange?: HighlightRange;\n  className?: string;\n}) {\n  if (ranges.length === 0) {\n    return className ? <span className={className}>{text}</span> : <>{text}</>;\n  }\n  // Sort and merge ranges\n  const sorted = [...ranges].sort((a, b) => a.start - b.start);\n  const segments: Array<{\n    start: number;\n    end: number;\n    highlight: boolean;\n    isActive: boolean;\n  }> = [];\n  let cursor = 0;\n  for (const r of sorted) {\n    if (r.start < cursor) continue; // skip overlapping\n    if (r.start > cursor) {\n      segments.push({ start: cursor, end: r.start, highlight: false, isActive: false });\n    }\n    const isActive =\n      activeRange !== undefined &&\n      activeRange.start === r.start &&\n      activeRange.length === r.length;\n    segments.push({\n      start: r.start,\n      end: r.start + r.length,\n      highlight: true,\n      isActive,\n    });\n    cursor = r.start + r.length;\n  }\n  if (cursor < text.length) {\n    segments.push({ start: cursor, end: text.length, highlight: false, isActive: false });\n  }\n  return (\n    <span className={className}>\n      {segments.map((s, i) => {\n        const slice = text.slice(s.start, s.end);\n        if (!s.highlight) return <Fragment key={i}>{slice}</Fragment>;\n        return (\n          <mark\n            key={i}\n            className={cn(\n              \"rounded-sm bg-amber-200/70 text-foreground dark:bg-amber-500/30\",\n              s.isActive && \"bg-amber-400 text-foreground dark:bg-amber-400/80\",\n            )}\n          >\n            {slice}\n          </mark>\n        );\n      })}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/match-highlight.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/meta-edit.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { Plus, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { FlatFieldValue, MetaRenderer } from \"../types\";\nimport type { ValidationResult } from \"../lib/validate-edit\";\nimport { InlineError } from \"./inline-error\";\n\n/**\n * Inline meta editor used inside the popover (and inline-strip when desired).\n * Renders meta entries with edit/remove affordances + an \"+ meta\" button.\n */\nexport function MetaEditList({\n  meta,\n  cardId,\n  metaRenderers,\n  onEdit,\n  onAdd,\n  onRemove,\n  validateAdd,\n  validateEdit,\n  className,\n}: {\n  meta: Record<string, FlatFieldValue> | undefined;\n  cardId: string;\n  metaRenderers?: Record<string, MetaRenderer>;\n  onEdit: (key: string, value: FlatFieldValue) => void;\n  onAdd: (key: string, value: FlatFieldValue) => void;\n  onRemove: (key: string) => void;\n  validateAdd: (key: string, value: FlatFieldValue) => ValidationResult;\n  validateEdit: (key: string, value: FlatFieldValue) => ValidationResult;\n  className?: string;\n}) {\n  const entries = meta ? Object.entries(meta) : [];\n  const [adding, setAdding] = useState(false);\n  const [editingKey, setEditingKey] = useState<string | null>(null);\n\n  return (\n    <div className={cn(\"space-y-1.5\", className)}>\n      <div className=\"flex items-center justify-between\">\n        <p className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n          Meta\n        </p>\n        {!adding ? (\n          <button\n            type=\"button\"\n            onClick={() => setAdding(true)}\n            className=\"inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            aria-label=\"Add meta entry\"\n          >\n            <Plus className=\"size-2.5\" aria-hidden=\"true\" />\n            add\n          </button>\n        ) : null}\n      </div>\n\n      <dl className=\"grid grid-cols-[minmax(0,auto)_minmax(0,1fr)_auto] gap-x-2 gap-y-1 text-sm\">\n        {entries.map(([k, v]) => (\n          <MetaRow\n            key={k}\n            metaKey={k}\n            value={v}\n            cardId={cardId}\n            isEditing={editingKey === k}\n            metaRenderers={metaRenderers}\n            onEnterEdit={() => setEditingKey(k)}\n            onCommitEdit={(newValue) => {\n              onEdit(k, newValue);\n              setEditingKey(null);\n            }}\n            onCancelEdit={() => setEditingKey(null)}\n            onRemove={() => onRemove(k)}\n            validate={(newValue) => validateEdit(k, newValue)}\n          />\n        ))}\n      </dl>\n\n      {adding ? (\n        <MetaAddRow\n          validate={validateAdd}\n          onCommit={(k, v) => {\n            onAdd(k, v);\n            setAdding(false);\n          }}\n          onCancel={() => setAdding(false)}\n        />\n      ) : null}\n    </div>\n  );\n}\n\nfunction MetaRow({\n  metaKey,\n  value,\n  cardId,\n  isEditing,\n  metaRenderers,\n  onEnterEdit,\n  onCommitEdit,\n  onCancelEdit,\n  onRemove,\n  validate,\n}: {\n  metaKey: string;\n  value: FlatFieldValue;\n  cardId: string;\n  isEditing: boolean;\n  metaRenderers?: Record<string, MetaRenderer>;\n  onEnterEdit: () => void;\n  onCommitEdit: (newValue: FlatFieldValue) => void;\n  onCancelEdit: () => void;\n  onRemove: () => void;\n  validate: (newValue: FlatFieldValue) => ValidationResult;\n}) {\n  const renderer = metaRenderers?.[metaKey];\n  return (\n    <div className=\"contents\">\n      <dt className=\"truncate font-mono text-[11px] text-muted-foreground\">\n        {metaKey}\n      </dt>\n      <dd className=\"min-w-0\">\n        {isEditing ? (\n          <MetaValueInput\n            initial={value}\n            validate={validate}\n            onCommit={onCommitEdit}\n            onCancel={onCancelEdit}\n          />\n        ) : renderer ? (\n          renderer(value, { cardId, metaKey })\n        ) : (\n          <button\n            type=\"button\"\n            onClick={onEnterEdit}\n            className=\"cursor-text rounded-sm px-1 -mx-1 text-left hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            {value === null ? \"—\" : String(value)}\n          </button>\n        )}\n      </dd>\n      <button\n        type=\"button\"\n        onClick={onRemove}\n        aria-label={`Remove meta ${metaKey}`}\n        className=\"inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        <X className=\"size-3\" aria-hidden=\"true\" />\n      </button>\n    </div>\n  );\n}\n\nfunction MetaValueInput({\n  initial,\n  validate,\n  onCommit,\n  onCancel,\n}: {\n  initial: FlatFieldValue;\n  validate: (v: FlatFieldValue) => ValidationResult;\n  onCommit: (v: FlatFieldValue) => void;\n  onCancel: () => void;\n}) {\n  const [text, setText] = useState(initial === null ? \"\" : String(initial));\n  const ref = useRef<HTMLInputElement>(null);\n  useEffect(() => {\n    ref.current?.focus();\n    ref.current?.select();\n  }, []);\n  const value = parseValue(initial, text);\n  const validation = validate(value);\n\n  return (\n    <span className=\"inline-flex flex-wrap items-center gap-1\">\n      <input\n        ref={ref}\n        type=\"text\"\n        value={text}\n        onChange={(e) => setText(e.target.value)}\n        onBlur={() => {\n          if (validation.ok) onCommit(value);\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            if (validation.ok) onCommit(value);\n          } else if (e.key === \"Escape\") {\n            e.preventDefault();\n            onCancel();\n          }\n        }}\n        className=\"rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        aria-label=\"Edit meta value\"\n      />\n      {!validation.ok ? <InlineError errors={validation.errors} /> : null}\n    </span>\n  );\n}\n\nfunction parseValue(initial: FlatFieldValue, text: string): FlatFieldValue {\n  // Preserve type of original\n  if (typeof initial === \"number\") {\n    const n = Number(text);\n    return Number.isFinite(n) ? n : 0;\n  }\n  if (typeof initial === \"boolean\") {\n    return text === \"true\";\n  }\n  if (initial === null) {\n    return text === \"\" ? null : text;\n  }\n  return text;\n}\n\nfunction MetaAddRow({\n  validate,\n  onCommit,\n  onCancel,\n}: {\n  validate: (key: string, value: FlatFieldValue) => ValidationResult;\n  onCommit: (key: string, value: FlatFieldValue) => void;\n  onCancel: () => void;\n}) {\n  const [key, setKey] = useState(\"\");\n  const [value, setValue] = useState(\"\");\n  const validation = validate(key, value);\n  const ref = useRef<HTMLInputElement>(null);\n  useEffect(() => {\n    ref.current?.focus();\n  }, []);\n\n  return (\n    <div className=\"space-y-1 rounded-sm border border-dashed border-border/70 p-1.5\">\n      <div className=\"flex gap-1\">\n        <input\n          ref={ref}\n          type=\"text\"\n          value={key}\n          onChange={(e) => setKey(e.target.value)}\n          placeholder=\"key\"\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\") {\n              e.preventDefault();\n              onCancel();\n            }\n          }}\n          className=\"rounded-sm border border-border bg-background px-1.5 py-0.5 font-mono text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        />\n        <input\n          type=\"text\"\n          value={value}\n          onChange={(e) => setValue(e.target.value)}\n          placeholder=\"value\"\n          onKeyDown={(e) => {\n            if (e.key === \"Enter\" && validation.ok) {\n              e.preventDefault();\n              onCommit(key, value);\n            } else if (e.key === \"Escape\") {\n              e.preventDefault();\n              onCancel();\n            }\n          }}\n          className=\"flex-1 rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        />\n      </div>\n      {!validation.ok && key.length > 0 ? (\n        <InlineError errors={validation.errors} />\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/meta-edit.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/meta-inline.tsx",
      "content": "import { cn } from \"@/lib/utils\";\nimport type { FlatFieldValue, MetaRenderer } from \"../types\";\n\nfunction format(v: FlatFieldValue) {\n  if (v === null) return \"—\";\n  return String(v);\n}\n\nexport function MetaInline({\n  meta,\n  metaRenderers,\n  className,\n}: {\n  meta: Record<string, FlatFieldValue>;\n  metaRenderers?: Record<string, MetaRenderer>;\n  className?: string;\n}) {\n  const entries = Object.entries(meta);\n  if (entries.length === 0) return null;\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-wrap items-center gap-x-1.5 gap-y-0.5 truncate font-mono text-[10px] text-muted-foreground\",\n        className,\n      )}\n    >\n      {entries.map(([k, v], i) => {\n        const renderer = metaRenderers?.[k];\n        return (\n          <span key={k} className=\"inline-flex items-center gap-1\">\n            {i > 0 ? (\n              <span aria-hidden=\"true\" className=\"opacity-60\">\n                ·\n              </span>\n            ) : null}\n            <span className=\"text-foreground/50\">{k}</span>\n            {renderer ? renderer(v, { cardId: \"\", metaKey: k }) : <span>{format(v)}</span>}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/meta-inline.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/meta-popover.tsx",
      "content": "\"use client\";\n\nimport { Info } from \"lucide-react\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\nimport type { FlatFieldValue, MetaRenderer } from \"../types\";\nimport { MetaEditList } from \"./meta-edit\";\nimport type { EditDispatchers, EditValidators } from \"./card\";\n\nfunction format(v: FlatFieldValue) {\n  if (v === null) return \"—\";\n  return String(v);\n}\n\nexport function MetaPopover({\n  meta,\n  cardId,\n  editable,\n  metaRenderers,\n  dispatchers,\n  validators,\n  className,\n}: {\n  meta: Record<string, FlatFieldValue>;\n  cardId: string;\n  editable: boolean;\n  metaRenderers?: Record<string, MetaRenderer>;\n  dispatchers?: EditDispatchers;\n  validators?: EditValidators;\n  className?: string;\n}) {\n  const entries = Object.entries(meta);\n  if (entries.length === 0 && !editable) return null;\n\n  return (\n    <Popover>\n      <PopoverTrigger\n        className={cn(\n          \"inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n          className,\n        )}\n        aria-label=\"Show meta information\"\n      >\n        <Info className=\"size-3.5\" />\n      </PopoverTrigger>\n      <PopoverContent\n        align=\"end\"\n        sideOffset={6}\n        className=\"w-72 p-3\"\n      >\n        {editable && dispatchers && validators ? (\n          <MetaEditList\n            meta={meta}\n            cardId={cardId}\n            metaRenderers={metaRenderers}\n            onEdit={(k, v) => dispatchers.metaEdit(cardId, k, v)}\n            onAdd={(k, v) => dispatchers.metaAdd(cardId, k, v)}\n            onRemove={(k) => dispatchers.metaRemove(cardId, k)}\n            validateAdd={(k, v) => validators.metaAdd(cardId, k, v)}\n            validateEdit={(k, v) => validators.metaEdit(cardId, k, v)}\n          />\n        ) : (\n          <>\n            <p className=\"mb-2 text-xs font-mono uppercase tracking-wide text-muted-foreground\">\n              Meta\n            </p>\n            <dl className=\"grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm\">\n              {entries.map(([k, v]) => {\n                const renderer = metaRenderers?.[k];\n                return (\n                  <div key={k} className=\"contents\">\n                    <dt className=\"truncate font-mono text-xs text-muted-foreground\">\n                      {k}\n                    </dt>\n                    <dd className=\"min-w-0 wrap-break-word\">\n                      {renderer ? renderer(v, { cardId, metaKey: k }) : format(v)}\n                    </dd>\n                  </div>\n                );\n              })}\n            </dl>\n          </>\n        )}\n      </PopoverContent>\n    </Popover>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/meta-popover.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/permission-tooltip.tsx",
      "content": "import type { PermissionDenialReason } from \"../types\";\nimport { cn } from \"@/lib/utils\";\n\nconst REASON_MESSAGES: Record<PermissionDenialReason, string> = {\n  \"global-editable-false\": \"Editing is disabled (editable={false}).\",\n  \"meta-locked\": \"Card is locked via meta (__rcmeta.locked=true).\",\n  \"by-card\": \"Permissions denied for this card specifically.\",\n  \"by-predefined-key\": \"Permissions denied for this content type.\",\n  \"by-field-type\": \"Permissions denied for this field type.\",\n  \"by-level\": \"Permissions denied at this depth.\",\n  default: \"Permissions denied (default rule).\",\n  predicate: \"Permissions denied (host predicate).\",\n};\n\nexport function PermissionDenialTooltip({\n  reason,\n  className,\n}: {\n  reason: PermissionDenialReason;\n  className?: string;\n}) {\n  return (\n    <span\n      role=\"status\"\n      className={cn(\n        \"inline-block rounded-sm bg-muted/80 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground\",\n        className,\n      )}\n    >\n      {REASON_MESSAGES[reason]}\n    </span>\n  );\n}\n\nexport function permissionReasonText(reason: PermissionDenialReason): string {\n  return REASON_MESSAGES[reason];\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/permission-tooltip.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/predefined-add-menu.tsx",
      "content": "import { Plus } from \"lucide-react\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n  PREDEFINED_KEYS,\n  type PredefinedKey,\n} from \"../types\";\nimport type { CardTreePredefinedEntry } from \"../lib/parse\";\n\n/**\n * Default-shape value for a freshly-added predefined-key entry.\n * The user is auto-entered into the predefined editor immediately after add\n * so they can fill in real content.\n */\nfunction defaultEntry(key: PredefinedKey): CardTreePredefinedEntry {\n  switch (key) {\n    case \"codearea\":\n      return { key: \"codearea\", value: { format: \"text\", content: \"\" } };\n    case \"image\":\n      return { key: \"image\", value: { src: \"\", alt: \"\" } };\n    case \"table\":\n      return {\n        key: \"table\",\n        value: { headers: [\"col\"], rows: [[\"\"]] },\n      };\n    case \"quote\":\n      return { key: \"quote\", value: \"\" };\n    case \"list\":\n      return { key: \"list\", value: [\"\"] };\n  }\n}\n\n/**\n * Hover-revealed \"+ block\" button → menu listing predefined keys not yet\n * present on this card and not in `disabledPredefinedKeys`.\n */\nexport function PredefinedAddMenu({\n  presentKeys,\n  disabledKeys,\n  onAdd,\n}: {\n  presentKeys: readonly PredefinedKey[];\n  disabledKeys: readonly PredefinedKey[];\n  onAdd: (entry: CardTreePredefinedEntry) => void;\n}) {\n  const available = PREDEFINED_KEYS.filter(\n    (k) => !presentKeys.includes(k) && !disabledKeys.includes(k),\n  );\n  if (available.length === 0) return null;\n\n  return (\n    <Popover>\n      <PopoverTrigger 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\">\n        <Plus className=\"size-3\" aria-hidden=\"true\" />\n        block\n      </PopoverTrigger>\n      <PopoverContent align=\"start\" sideOffset={4} className=\"w-44 p-1\">\n        <p className=\"px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n          Add block\n        </p>\n        {available.map((k) => (\n          <button\n            key={k}\n            type=\"button\"\n            onClick={() => onAdd(defaultEntry(k))}\n            className=\"flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left font-mono text-[12px] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:bg-muted\"\n          >\n            {k}\n          </button>\n        ))}\n      </PopoverContent>\n    </Popover>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/predefined-add-menu.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/predefined-codearea.tsx",
      "content": "import { cn } from \"@/lib/utils\";\nimport type { CodeAreaValue } from \"../types\";\n\nexport function PredefinedCodeArea({\n  value,\n  className,\n}: {\n  value: CodeAreaValue;\n  className?: string;\n}) {\n  return (\n    <figure\n      className={cn(\n        \"overflow-hidden rounded-md border border-border/70 bg-muted/50\",\n        className,\n      )}\n    >\n      <figcaption className=\"flex items-center justify-between border-b border-border/70 bg-muted/40 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n        <span>{value.format || \"code\"}</span>\n      </figcaption>\n      <pre className=\"overflow-x-auto px-2.5 py-2 font-mono text-[12.5px] leading-relaxed\">\n        <code>{value.content}</code>\n      </pre>\n    </figure>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/predefined-codearea.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/predefined-edit.tsx",
      "content": "import { useEffect, useRef, useState } from \"react\";\nimport { Check, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  CodeAreaValue,\n  FlatFieldValue,\n  ImageValue,\n  ListValue,\n  QuoteValue,\n  TableValue,\n} from \"../types\";\nimport type { CardTreePredefinedEntry } from \"../lib/parse\";\nimport {\n  validatePredefinedShape,\n  type ValidationResult,\n} from \"../lib/validate-edit\";\nimport { InlineError } from \"./inline-error\";\n\n/* ───────── shared editor frame ───────── */\n\nfunction EditorFrame({\n  children,\n  validation,\n  onSave,\n  onCancel,\n}: {\n  children: React.ReactNode;\n  validation: ValidationResult;\n  onSave: () => void;\n  onCancel: () => void;\n}) {\n  return (\n    <div\n      className=\"space-y-1.5 rounded-md border border-dashed border-primary/40 bg-muted/40 p-2\"\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\") {\n          e.preventDefault();\n          onCancel();\n        }\n      }}\n    >\n      {children}\n      <div className=\"flex items-center justify-between gap-2 pt-1\">\n        {!validation.ok ? (\n          <InlineError errors={validation.errors} />\n        ) : (\n          <span />\n        )}\n        <div className=\"flex items-center gap-1\">\n          <button\n            type=\"button\"\n            onClick={onCancel}\n            className=\"inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n            aria-label=\"Cancel edit\"\n          >\n            <X className=\"size-3.5\" aria-hidden=\"true\" />\n          </button>\n          <button\n            type=\"button\"\n            onClick={onSave}\n            disabled={!validation.ok}\n            className=\"inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 font-mono text-[11px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <Check className=\"size-3\" aria-hidden=\"true\" />\n            save\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst inputBase =\n  \"rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\";\n\n/* ───────── codearea ───────── */\n\nfunction CodeAreaEdit({\n  initial,\n  onSave,\n  onCancel,\n}: {\n  initial: CodeAreaValue;\n  onSave: (v: CodeAreaValue) => void;\n  onCancel: () => void;\n}) {\n  const [format, setFormat] = useState(initial.format);\n  const [content, setContent] = useState(initial.content);\n  const ref = useRef<HTMLTextAreaElement>(null);\n  useEffect(() => {\n    ref.current?.focus();\n  }, []);\n  const value: CodeAreaValue = { format, content };\n  const validation = validatePredefinedShape(\"codearea\", value);\n  return (\n    <EditorFrame\n      validation={validation}\n      onSave={() => validation.ok && onSave(value)}\n      onCancel={onCancel}\n    >\n      <div className=\"grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] items-center gap-1.5\">\n        <span className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n          format\n        </span>\n        <input\n          type=\"text\"\n          value={format}\n          onChange={(e) => setFormat(e.target.value)}\n          className={cn(inputBase, \"w-32 font-mono text-[12px]\")}\n          aria-label=\"Code format\"\n        />\n      </div>\n      <textarea\n        ref={ref}\n        value={content}\n        onChange={(e) => setContent(e.target.value)}\n        rows={6}\n        className={cn(\n          inputBase,\n          \"block w-full resize-y font-mono text-[12.5px] leading-relaxed\",\n        )}\n        aria-label=\"Code content\"\n      />\n    </EditorFrame>\n  );\n}\n\n/* ───────── image ───────── */\n\nfunction ImageEdit({\n  initial,\n  onSave,\n  onCancel,\n}: {\n  initial: ImageValue;\n  onSave: (v: ImageValue) => void;\n  onCancel: () => void;\n}) {\n  const [src, setSrc] = useState(initial.src);\n  const [alt, setAlt] = useState(initial.alt ?? \"\");\n  const ref = useRef<HTMLInputElement>(null);\n  useEffect(() => {\n    ref.current?.focus();\n  }, []);\n  const value: ImageValue = { src, ...(alt ? { alt } : {}) };\n  const validation = validatePredefinedShape(\"image\", value);\n  return (\n    <EditorFrame\n      validation={validation}\n      onSave={() => validation.ok && onSave(value)}\n      onCancel={onCancel}\n    >\n      <div className=\"grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] items-center gap-1.5\">\n        <span className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n          src\n        </span>\n        <input\n          ref={ref}\n          type=\"url\"\n          value={src}\n          onChange={(e) => setSrc(e.target.value)}\n          placeholder=\"https://...\"\n          className={cn(inputBase, \"w-full\")}\n          aria-label=\"Image src\"\n        />\n        <span className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n          alt\n        </span>\n        <input\n          type=\"text\"\n          value={alt}\n          onChange={(e) => setAlt(e.target.value)}\n          placeholder=\"description\"\n          className={cn(inputBase, \"w-full\")}\n          aria-label=\"Image alt text\"\n        />\n      </div>\n      {src ? (\n        <img\n          src={src}\n          alt={alt}\n          className=\"max-h-32 w-auto rounded-sm border border-border/70\"\n        />\n      ) : null}\n    </EditorFrame>\n  );\n}\n\n/* ───────── quote ───────── */\n\nfunction QuoteEdit({\n  initial,\n  onSave,\n  onCancel,\n}: {\n  initial: QuoteValue;\n  onSave: (v: QuoteValue) => void;\n  onCancel: () => void;\n}) {\n  const [value, setValue] = useState(initial);\n  const ref = useRef<HTMLTextAreaElement>(null);\n  useEffect(() => {\n    ref.current?.focus();\n    ref.current?.select();\n  }, []);\n  const validation = validatePredefinedShape(\"quote\", value);\n  return (\n    <EditorFrame\n      validation={validation}\n      onSave={() => validation.ok && onSave(value)}\n      onCancel={onCancel}\n    >\n      <textarea\n        ref={ref}\n        value={value}\n        onChange={(e) => setValue(e.target.value)}\n        rows={3}\n        className={cn(\n          inputBase,\n          \"block w-full resize-y text-sm italic leading-relaxed\",\n        )}\n        aria-label=\"Quote text\"\n      />\n    </EditorFrame>\n  );\n}\n\n/* ───────── list (one item per line) ───────── */\n\nfunction ListEdit({\n  initial,\n  onSave,\n  onCancel,\n}: {\n  initial: ListValue;\n  onSave: (v: ListValue) => void;\n  onCancel: () => void;\n}) {\n  const [text, setText] = useState(\n    initial.map((v) => stringifyScalar(v)).join(\"\\n\"),\n  );\n  const ref = useRef<HTMLTextAreaElement>(null);\n  useEffect(() => {\n    ref.current?.focus();\n  }, []);\n  const value: ListValue = text\n    .split(\"\\n\")\n    .filter((line, i, a) => line.length > 0 || i < a.length - 1)\n    .map((line) => parseScalar(line));\n  const validation = validatePredefinedShape(\"list\", value);\n  return (\n    <EditorFrame\n      validation={validation}\n      onSave={() => validation.ok && onSave(value)}\n      onCancel={onCancel}\n    >\n      <p className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n        one item per line · empty lines dropped · `true` / `false` / `null` /\n        numbers inferred\n      </p>\n      <textarea\n        ref={ref}\n        value={text}\n        onChange={(e) => setText(e.target.value)}\n        rows={5}\n        className={cn(inputBase, \"block w-full resize-y font-mono text-[12px]\")}\n        aria-label=\"List items\"\n      />\n    </EditorFrame>\n  );\n}\n\nfunction stringifyScalar(v: FlatFieldValue): string {\n  if (v === null) return \"null\";\n  return String(v);\n}\n\nfunction parseScalar(line: string): FlatFieldValue {\n  const trimmed = line.trim();\n  if (trimmed === \"\") return \"\";\n  if (trimmed === \"null\") return null;\n  if (trimmed === \"true\") return true;\n  if (trimmed === \"false\") return false;\n  if (/^-?\\d+(\\.\\d+)?$/.test(trimmed)) return Number(trimmed);\n  return line;\n}\n\n/* ───────── table (JSON-textarea fallback per Q-P4) ───────── */\n\nfunction TableEdit({\n  initial,\n  onSave,\n  onCancel,\n}: {\n  initial: TableValue;\n  onSave: (v: TableValue) => void;\n  onCancel: () => void;\n}) {\n  const [text, setText] = useState(JSON.stringify(initial, null, 2));\n  const ref = useRef<HTMLTextAreaElement>(null);\n  useEffect(() => {\n    ref.current?.focus();\n  }, []);\n\n  let parsed: TableValue | null = null;\n  let parseError: string | null = null;\n  try {\n    parsed = JSON.parse(text) as TableValue;\n  } catch (e) {\n    parseError = e instanceof Error ? e.message : \"invalid JSON\";\n  }\n\n  const validation: ValidationResult = parsed\n    ? validatePredefinedShape(\"table\", parsed)\n    : {\n        ok: false,\n        errors: [{ code: \"json-parse\", message: parseError ?? \"invalid JSON\" }],\n      };\n\n  return (\n    <EditorFrame\n      validation={validation}\n      onSave={() => validation.ok && parsed && onSave(parsed)}\n      onCancel={onCancel}\n    >\n      <p className=\"font-mono text-[10px] uppercase tracking-wider text-muted-foreground\">\n        table edit (JSON · cell-editor in v0.3)\n      </p>\n      <textarea\n        ref={ref}\n        value={text}\n        onChange={(e) => setText(e.target.value)}\n        rows={8}\n        className={cn(inputBase, \"block w-full resize-y font-mono text-[12px]\")}\n        aria-label=\"Table JSON\"\n        spellCheck={false}\n      />\n    </EditorFrame>\n  );\n}\n\n/* ───────── dispatcher ───────── */\n\nexport function PredefinedEdit({\n  entry,\n  onCommit,\n  onCancel,\n}: {\n  entry: CardTreePredefinedEntry;\n  onCommit: (next: CardTreePredefinedEntry) => void;\n  onCancel: () => void;\n}) {\n  switch (entry.key) {\n    case \"codearea\":\n      return (\n        <CodeAreaEdit\n          initial={entry.value}\n          onSave={(v) => onCommit({ key: \"codearea\", value: v })}\n          onCancel={onCancel}\n        />\n      );\n    case \"image\":\n      return (\n        <ImageEdit\n          initial={entry.value}\n          onSave={(v) => onCommit({ key: \"image\", value: v })}\n          onCancel={onCancel}\n        />\n      );\n    case \"quote\":\n      return (\n        <QuoteEdit\n          initial={entry.value}\n          onSave={(v) => onCommit({ key: \"quote\", value: v })}\n          onCancel={onCancel}\n        />\n      );\n    case \"list\":\n      return (\n        <ListEdit\n          initial={entry.value}\n          onSave={(v) => onCommit({ key: \"list\", value: v })}\n          onCancel={onCancel}\n        />\n      );\n    case \"table\":\n      return (\n        <TableEdit\n          initial={entry.value}\n          onSave={(v) => onCommit({ key: \"table\", value: v })}\n          onCancel={onCancel}\n        />\n      );\n  }\n}\n\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/predefined-edit.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/predefined-image.tsx",
      "content": "import { cn } from \"@/lib/utils\";\nimport type { ImageValue } from \"../types\";\n\nexport function PredefinedImage({\n  value,\n  className,\n}: {\n  value: ImageValue;\n  className?: string;\n}) {\n  return (\n    <figure\n      className={cn(\n        \"overflow-hidden rounded-md border border-border/70 bg-muted/30\",\n        className,\n      )}\n    >\n      <img\n        src={value.src}\n        alt={value.alt ?? \"\"}\n        className=\"block h-auto w-full max-w-full object-cover\"\n        loading=\"lazy\"\n      />\n      {value.alt ? (\n        <figcaption className=\"border-t border-border/70 bg-muted/40 px-2.5 py-1 text-[11px] text-muted-foreground\">\n          {value.alt}\n        </figcaption>\n      ) : null}\n    </figure>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/predefined-image.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/predefined-list.tsx",
      "content": "import { cn } from \"@/lib/utils\";\nimport type { FlatFieldValue, ListValue } from \"../types\";\n\nfunction renderItem(v: FlatFieldValue) {\n  if (v === null) return <span className=\"text-muted-foreground\">—</span>;\n  if (typeof v === \"boolean\")\n    return (\n      <span className={v ? \"text-primary\" : \"text-muted-foreground\"}>\n        {v ? \"true\" : \"false\"}\n      </span>\n    );\n  if (typeof v === \"number\")\n    return <span className=\"font-mono tabular-nums\">{v}</span>;\n  return String(v);\n}\n\nexport function PredefinedList({\n  value,\n  className,\n}: {\n  value: ListValue;\n  className?: string;\n}) {\n  return (\n    <ul\n      className={cn(\n        \"list-disc space-y-0.5 ps-4 text-[13px] marker:text-muted-foreground/60\",\n        className,\n      )}\n    >\n      {value.map((item, i) => (\n        <li key={i}>{renderItem(item)}</li>\n      ))}\n    </ul>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/predefined-list.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/predefined-quote.tsx",
      "content": "import { cn } from \"@/lib/utils\";\nimport type { QuoteValue } from \"../types\";\n\nexport function PredefinedQuote({\n  value,\n  className,\n}: {\n  value: QuoteValue;\n  className?: string;\n}) {\n  return (\n    <blockquote\n      className={cn(\n        \"rounded-sm border-s-2 border-primary/70 bg-muted/30 ps-3 pe-2.5 py-1.5 text-[13.5px] italic leading-relaxed text-foreground/90\",\n        className,\n      )}\n    >\n      {value}\n    </blockquote>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/predefined-quote.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/predefined-table.tsx",
      "content": "import { cn } from \"@/lib/utils\";\nimport type { FlatFieldValue, TableValue } from \"../types\";\n\nfunction renderCell(v: FlatFieldValue) {\n  if (v === null) return <span className=\"text-muted-foreground\">—</span>;\n  if (typeof v === \"boolean\")\n    return (\n      <span\n        className={\n          v ? \"text-primary\" : \"text-muted-foreground\"\n        }\n      >\n        {v ? \"✓\" : \"—\"}\n      </span>\n    );\n  return String(v);\n}\n\nexport function PredefinedTable({\n  value,\n  className,\n}: {\n  value: TableValue;\n  className?: string;\n}) {\n  return (\n    <div\n      className={cn(\n        \"overflow-x-auto rounded-md border border-border/70 bg-card\",\n        className,\n      )}\n    >\n      <table className=\"w-full text-[13px]\">\n        <thead className=\"border-b border-border/70 bg-muted/40\">\n          <tr>\n            {value.headers.map((h, i) => (\n              <th\n                key={`${h}-${i}`}\n                className=\"px-2.5 py-1.5 text-left font-mono text-[11px] font-medium uppercase tracking-wider text-muted-foreground\"\n              >\n                {h}\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {value.rows.map((row, i) => (\n            <tr\n              key={i}\n              className=\"border-b border-border/40 last:border-0 hover:bg-muted/20\"\n            >\n              {row.map((cell, j) => (\n                <td\n                  key={j}\n                  className={cn(\n                    \"px-2.5 py-1.5 align-top\",\n                    typeof cell === \"number\" &&\n                      \"text-right font-mono tabular-nums\",\n                  )}\n                >\n                  {renderCell(cell)}\n                </td>\n              ))}\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/predefined-table.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/search-bar.tsx",
      "content": "\"use client\";\n\nimport { ChevronDown, ChevronUp, Search, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Optional default search bar — sibling export. Hosts can skip and wire their own input\n * directly into the `search` prop of CardTree.\n *\n * Bindings:\n *   F3 / Shift+F3 → next / previous match (handled by host or via the imperative handle)\n */\nexport function CardTreeSearchBar({\n  value,\n  onChange,\n  matchCount,\n  activeIndex,\n  onNext,\n  onPrevious,\n  onClear,\n  placeholder = \"Search cards…\",\n  className,\n}: {\n  value: string;\n  onChange: (next: string) => void;\n  matchCount: number;\n  activeIndex: number | null;\n  onNext: () => void;\n  onPrevious: () => void;\n  onClear: () => void;\n  placeholder?: string;\n  className?: string;\n}) {\n  const hasMatches = matchCount > 0;\n  const noMatches = value.length > 0 && matchCount === 0;\n  const display =\n    activeIndex !== null && hasMatches\n      ? `${activeIndex + 1} / ${matchCount}`\n      : hasMatches\n        ? `${matchCount} matches`\n        : noMatches\n          ? \"no matches\"\n          : null;\n\n  return (\n    <div\n      role=\"search\"\n      className={cn(\n        \"inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 shadow-sm\",\n        className,\n      )}\n    >\n      <Search className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n      <input\n        type=\"search\"\n        value={value}\n        onChange={(e) => onChange(e.target.value)}\n        placeholder={placeholder}\n        aria-label=\"Search cards\"\n        onKeyDown={(e) => {\n          if (e.key === \"F3\" || (e.key === \"g\" && (e.metaKey || e.ctrlKey))) {\n            e.preventDefault();\n            if (e.shiftKey) onPrevious();\n            else onNext();\n          } else if (e.key === \"Escape\") {\n            e.preventDefault();\n            onClear();\n          }\n        }}\n        className=\"min-w-32 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60\"\n      />\n      {display ? (\n        <span\n          aria-live=\"polite\"\n          className={cn(\n            \"shrink-0 font-mono text-[10px]\",\n            noMatches ? \"text-destructive\" : \"text-muted-foreground\",\n          )}\n        >\n          {display}\n        </span>\n      ) : null}\n      {hasMatches ? (\n        <span className=\"inline-flex shrink-0 items-center gap-0.5 border-l border-border pl-1\">\n          <button\n            type=\"button\"\n            onClick={onPrevious}\n            aria-label=\"Previous match\"\n            className=\"inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <ChevronUp className=\"size-3\" aria-hidden=\"true\" />\n          </button>\n          <button\n            type=\"button\"\n            onClick={onNext}\n            aria-label=\"Next match\"\n            className=\"inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n          >\n            <ChevronDown className=\"size-3\" aria-hidden=\"true\" />\n          </button>\n        </span>\n      ) : null}\n      {value.length > 0 ? (\n        <button\n          type=\"button\"\n          onClick={onClear}\n          aria-label=\"Clear search\"\n          className=\"inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <X className=\"size-3\" aria-hidden=\"true\" />\n        </button>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/search-bar.tsx"
    },
    {
      "path": "src/registry/components/data/card-tree/parts/undo-toolbar.tsx",
      "content": "\"use client\";\n\nimport { Redo2, Undo2 } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Optional sibling export — drop in next to <CardTree /> for default\n * undo / redo affordances. Hosts can wire their own buttons via the\n * imperative ref handle.\n */\nexport function CardTreeUndoToolbar({\n  canUndo,\n  canRedo,\n  onUndo,\n  onRedo,\n  className,\n}: {\n  canUndo: boolean;\n  canRedo: boolean;\n  onUndo: () => void;\n  onRedo: () => void;\n  className?: string;\n}) {\n  return (\n    <div\n      role=\"toolbar\"\n      aria-label=\"Undo / redo\"\n      className={cn(\n        \"inline-flex items-center gap-0.5 rounded-md border border-border bg-card p-0.5 shadow-sm\",\n        className,\n      )}\n    >\n      <button\n        type=\"button\"\n        onClick={onUndo}\n        disabled={!canUndo}\n        aria-label=\"Undo\"\n        title=\"Undo (Cmd/Ctrl+Z)\"\n        aria-disabled={!canUndo}\n        className=\"inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        <Undo2 className=\"size-3.5\" aria-hidden=\"true\" />\n      </button>\n      <button\n        type=\"button\"\n        onClick={onRedo}\n        disabled={!canRedo}\n        aria-label=\"Redo\"\n        title=\"Redo (Cmd/Ctrl+Shift+Z)\"\n        aria-disabled={!canRedo}\n        className=\"inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        <Redo2 className=\"size-3.5\" aria-hidden=\"true\" />\n      </button>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/card-tree/parts/undo-toolbar.tsx"
    }
  ],
  "categories": [
    "data",
    "graph-system"
  ],
  "type": "registry:block"
}