shadimage
ilinxa
blockcard-treesource: ok · hit

Card Tree

JSON-driven recursive card tree with a full structural editor — drag and drop, multi-select, permissions, search, validation, and undo.

npx shadcn add "https://ui.ilinxa.com/r/card-tree.json"
free

Interaction

dragdropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

popover

Files53 · source cached 10/14/2026, 3:08:31 AM

src/registry/components/data/card-tree/card-tree.tsx
"use client";

import {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
  useMemo,
  useReducer,
  useRef,
  useState,
} from "react";
import {
  DndContext,
  type DragEndEvent,
  type DragStartEvent,
} from "@dnd-kit/core";
import {
  SortableContext,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { cn } from "@/lib/utils";
import type {
  CardAddedEvent,
  CardDuplicatedEvent,
  CardMovedEvent,
  CardRemovedEvent,
  CardRenamedEvent,
  EffectivePermissions,
  FieldAddedEvent,
  FieldEditedEvent,
  FieldRemovedEvent,
  LevelStyle,
  MetaAddedEvent,
  MetaChangedEvent,
  MetaRemovedEvent,
  PredefinedAddedEvent,
  PredefinedEditedEvent,
  PredefinedRemovedEvent,
  CardTreeHandle,
  CardTreeJsonNode,
  CardTreeProps,
  SearchMatch,
} from "./types";
import {
  isCustomEntry,
  parseInput,
  type CardTreePredefinedEntry,
  type ParseError,
  type ParsedCardTree,
} from "./lib/parse";
import { findCustomKey, resolveCustomKeys } from "./lib/custom-keys";
import { serializeTree, treeToJsonNode } from "./lib/serialize";
import {
  createInitialState,
  findAncestorIds,
  findCard,
  findParentId,
  reducer,
  visibleIdsInOrder,
  type DefaultCollapsed,
  type CardTreeAction,
  type CardTreeState,
} from "./lib/reducer";
import {
  validateCardRename,
  validateCardRemove,
  validateFieldAdd,
  validateFieldEditKey,
  validateFieldEditValue,
  validateMetaAdd,
  validateMetaEdit,
  validatePredefinedShape,
} from "./lib/validate-edit";
import {
  runHostValidators,
  type AnyChangeEvent,
  type ValidatorKey,
} from "./lib/validators";
import { useTreeKeyboard } from "./hooks/use-tree-keyboard";
import { useEditMode } from "./hooks/use-edit-mode";
import { isDirty as isDirtySelector } from "./hooks/use-dirty";
import { usePermissions } from "./hooks/use-permissions";
import { useDndSensors, collisionStrategy } from "./hooks/use-dnd-config";
import { useSearch } from "./hooks/use-search";
import { useUndo } from "./hooks/use-undo";
import { Card, type CardConfig, type EditDispatchers, type EditValidators } from "./parts/card";
import { EmptyTreePlaceholder } from "./parts/empty-tree";

/* ───────── default level styles ───────── */

const DEFAULT_LEVEL_STYLES: LevelStyle[] = [
  { containerClassName: "rounded-xl border border-border bg-card px-3 py-3 shadow-sm" },
  { containerClassName: "rounded-lg border border-border bg-muted/30 px-3 py-2.5" },
  { containerClassName: "rounded-md border border-border/70 bg-muted/20 px-2.5 py-2" },
  { containerClassName: "rounded-md border border-border/50 bg-muted/15 px-2 py-2" },
  { containerClassName: "rounded-sm border border-border/40 bg-muted/10 px-2 py-1.5" },
  { containerClassName: "rounded-sm border border-dashed border-border/30 px-2 py-1.5" },
];

function clampIndex(level: number, len: number): number {
  return Math.max(0, Math.min(level - 1, len - 1));
}

function makeErrorTree(message: string): ParsedCardTree {
  return {
    id: "rcc-error-root",
    order: 0,
    level: 1,
    fields: [{ key: "error", value: message, type: "string" }],
    predefined: [],
    children: [],
  };
}

function generateId(): string {
  if (
    typeof globalThis.crypto !== "undefined" &&
    typeof globalThis.crypto.randomUUID === "function"
  ) {
    return globalThis.crypto.randomUUID();
  }
  return `rc-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}

function logParseErrors(errors: ParseError[]): void {
  if (errors.length === 0) return;
  for (const err of errors) {
    console.warn(
      `[card-tree] ${err.path ? `at ${err.path}: ` : ""}${err.message}`,
    );
  }
}

/* ───────── component ───────── */

export const CardTree = forwardRef<CardTreeHandle, CardTreeProps>(
  function CardTree(props, ref) {
    const {
      defaultValue,
      levelStyles,
      getLevelStyle,
      predefinedKeyStyles,
      defaultCollapsed = "none",
      metaPresentation = "hidden",
      disabledPredefinedKeys,
      dateDetection = "auto",
      editable = false,
      onChange,
      onFieldEdited,
      onFieldAdded,
      onFieldRemoved,
      onCardAdded,
      onCardRemoved,
      onCardRenamed,
      onPredefinedAdded,
      onPredefinedEdited,
      onPredefinedRemoved,
      onSelectionChange,
      // v0.3
      dndScopes,
      onCardMoved,
      onCardDuplicated,
      permissions,
      canEditField,
      canAddField,
      canRemoveField,
      canEditCard,
      canAddCard,
      canRemoveCard,
      canEditPredefined,
      canAddPredefined,
      canRemovePredefined,
      canDragCard,
      canDropCard,
      onPermissionDenied,
      customPredefinedKeys,
      allowRootRemoval = false,
      onRootRemoved,
      defaultDeletePolicy = "cascade",
      promoteCollisionStrategy = "suffix",
      emptyTreeRenderer,
      metaRenderers,
      auditTrail,
      onMetaChanged,
      onMetaAdded,
      onMetaRemoved,
      search,
      onSearchResults,
      validators: hostValidators,
      validate,
      onValidationFailed,
      maxUndoDepth = 50,
      disableUndoShortcuts = false,
      onUndo,
      onRedo,
      className,
      "aria-label": ariaLabel = "Card tree",
    } = props;

    /* ───────── custom predefined-keys (v0.6) ─────────
     * Registration is mount-only: resolve once, reject collisions/duplicates
     * loudly, then thread the SAME resolved list through parse, the key
     * validators, search and render. Every parse call must see it — a parse that
     * doesn't know the registry re-classifies a custom block as a child card
     * (or, for arrays, drops it), which is precisely the 0.5.0 failure.
     *
     * Identity is keyed on the registered NAMES, not on the array reference.
     * That is not an optimization — it is required. Registration is mount-only
     * by contract, and the documented usage passes an inline literal
     * (`customPredefinedKeys={[metricKey]}`), which is a NEW array every render.
     * Keying on the array would recompute this every render, flow into
     * useSearch's result memo, and make `onSearchResults` fire every render —
     * a host that setStates in that callback (its only purpose) would get an
     * unbounded render loop.
     */
    // NOTE: the NUL/SOH sentinels below MUST stay as \u escapes, never raw
    // bytes — raw control characters make git classify this file as binary.
    const customKeySignature = (customPredefinedKeys ?? [])
      .map((k) => k?.key ?? "\u0001invalid")
      .join("\u0000");

    const customKeys = useMemo(
      () => resolveCustomKeys(customPredefinedKeys),
      // Signature, not array identity — see the note above. Registrations are
      // mount-only, so a changed name is the only change that can matter.
      // eslint-disable-next-line react-hooks/exhaustive-deps
      [customKeySignature],
    );

    useEffect(() => {
      // Loud by design. The v0.3–0.5.0 bug cost an integrator a day *because*
      // a bad registration was indistinguishable from a good one.
      for (const d of customKeys.diagnostics) {
        console.error(`[card-tree] ${d.message}`);
      }
    }, [customKeys]);

    const initialState = useMemo(() => {
      const { tree, errors } = parseInput(defaultValue, {
        disabledPredefinedKeys: disabledPredefinedKeys ?? [],
        dateDetection,
        customKeys: customKeys.keys,
      });
      logParseErrors(errors);
      const root =
        tree ?? makeErrorTree("Invalid input — defaultValue must be a JSON object.");
      return createInitialState(root, defaultCollapsed as DefaultCollapsed);
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    const [state, dispatch] = useReducer(reducer, initialState);
    const stateRef = useRef(state);
    useEffect(() => {
      stateRef.current = state;
    }, [state]);

    const { mode: editMode, setMode: setEditMode, clear: clearEditMode } =
      useEditMode();
    const [tentativeCardId, setTentativeCardId] = useState<string | null>(null);
    const [emptyRoot, setEmptyRoot] = useState(false);

    const disabledKeys = useMemo(
      () => disabledPredefinedKeys ?? [],
      [disabledPredefinedKeys],
    );

    /* ───────── permissions ───────── */

    const perms = usePermissions(
      state.tree,
      editable,
      permissions,
      {
        canEditField,
        canAddField,
        canRemoveField,
        canEditCard,
        canAddCard,
        canRemoveCard,
        canEditPredefined,
        canAddPredefined,
        canRemovePredefined,
        canDragCard,
        canDropCard,
      },
      onPermissionDenied,
    );

    /* ───────── search ───────── */

    const searchResult = useSearch(
      state,
      dispatch,
      search,
      // Resolved list, not the raw prop — a dropped registration must not be
      // searchable either.
      customKeys.keys,
      onSearchResults,
    );

    /**
     * Shape-gate a predefined entry before commit.
     *
     * Built-ins go through the internal shape validator; custom blocks go
     * through the HOST's own `validate`. Routing a custom key into
     * `validatePredefinedShape` is not merely wrong — its switch is exhaustive
     * over the five built-in keys with no `default`, so it returns `undefined`
     * and the caller's `!shape.ok` throws a TypeError. Adding or editing any
     * custom block would crash.
     */
    const validateEntryShape = useCallback(
      (entry: CardTreePredefinedEntry): boolean => {
        if (isCustomEntry(entry)) {
          const registration = findCustomKey(customKeys.keys, entry.key);
          if (!registration) return false;
          try {
            return registration.validate(entry.value)?.ok === true;
          } catch {
            return false; // host code — a throwing validator rejects, never crashes
          }
        }
        return validatePredefinedShape(entry.key, entry.value).ok;
      },
      [customKeys.keys],
    );

    /* ───────── validation gate (v0.4) ───────── */

    const gateValidation = useCallback(
      (
        validatorKey: ValidatorKey,
        event: AnyChangeEvent,
        actionType: string,
        cardId: string | undefined,
      ): boolean => {
        if (!hostValidators && !validate) return true;
        const treeJson = treeToJsonNode(stateRef.current.tree) as CardTreeJsonNode;
        const result = runHostValidators(
          validatorKey,
          event,
          actionType,
          cardId,
          treeJson,
          hostValidators,
          validate,
        );
        if (!result.ok) {
          onValidationFailed?.({
            action: actionType,
            cardId,
            errors: result.errors,
            layer: result.layer,
          });
          return false;
        }
        return true;
      },
      [hostValidators, validate, onValidationFailed],
    );

    /* ───────── undo/redo (v0.4) ───────── */

    // Sync maxUndoDepth prop into reducer state
    useEffect(() => {
      dispatch({ type: "set-max-undo-depth", depth: maxUndoDepth });
    }, [maxUndoDepth]);

    /* ───────── event firing ───────── */

    const eventQueueRef = useRef<
      Array<{ action: CardTreeAction; prevState: CardTreeState }>
    >([]);

    const queueCommit = useCallback((action: CardTreeAction) => {
      eventQueueRef.current.push({ action, prevState: stateRef.current });
      dispatch(action);
    }, []);

    const lastFiredVersionRef = useRef(state.version);
    useEffect(() => {
      if (state.version === lastFiredVersionRef.current) {
        eventQueueRef.current = [];
        return;
      }
      lastFiredVersionRef.current = state.version;
      const queued = eventQueueRef.current;
      eventQueueRef.current = [];
      for (const { action, prevState } of queued) {
        fireGranularEvent(action, prevState, state, {
          onFieldEdited,
          onFieldAdded,
          onFieldRemoved,
          onCardAdded,
          onCardRemoved,
          onCardRenamed,
          onPredefinedAdded,
          onPredefinedEdited,
          onPredefinedRemoved,
          onCardMoved,
          onCardDuplicated,
          onMetaChanged,
          onMetaAdded,
          onMetaRemoved,
        });
      }
      if (onChange) onChange(treeToJsonNode(state.tree));
    }, [
      state.version,
      state.tree,
      state,
      onChange,
      onFieldEdited,
      onFieldAdded,
      onFieldRemoved,
      onCardAdded,
      onCardRemoved,
      onCardRenamed,
      onPredefinedAdded,
      onPredefinedEdited,
      onPredefinedRemoved,
      onCardMoved,
      onCardDuplicated,
      onMetaChanged,
      onMetaAdded,
      onMetaRemoved,
    ]);

    // Multi-selection change effect
    const lastSelectedIdsRef = useRef(state.selectedIds);
    useEffect(() => {
      if (state.selectedIds !== lastSelectedIdsRef.current) {
        lastSelectedIdsRef.current = state.selectedIds;
        onSelectionChange?.(Array.from(state.selectedIds));
      }
    }, [state.selectedIds, onSelectionChange]);

    /* ───────── styles ───────── */

    const resolveLevelStyle = useMemo(() => {
      if (getLevelStyle) return getLevelStyle;
      const arr =
        levelStyles && levelStyles.length > 0 ? levelStyles : DEFAULT_LEVEL_STYLES;
      return (level: number) => arr[clampIndex(level, arr.length)];
    }, [getLevelStyle, levelStyles]);

    /* ───────── dispatchers ───────── */

    const dispatchers: EditDispatchers = useMemo(
      () => ({
        fieldEditValue: (cardId, key, value, type) => {
          if (!perms.canEditField(cardId, key)) return;
          const r = validateFieldEditValue(stateRef.current, cardId, key, value, type);
          if (!r.ok) return;
          const oldField = findCard(stateRef.current.tree, cardId)?.fields.find(
            (f) => f.key === key,
          );
          if (!oldField) return;
          if (
            !gateValidation(
              "fieldEdit",
              {
                cardId,
                key,
                oldValue: oldField.value,
                oldType: oldField.type,
                newValue: value,
                newType: type,
              },
              "field-edit-value",
              cardId,
            )
          )
            return;
          queueCommit({
            type: "field-edit-value",
            cardId,
            key,
            value,
            valueType: type,
          });
          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
          clearEditMode();
        },
        fieldEditKey: (cardId, oldKey, newKey) => {
          if (!perms.canEditField(cardId, oldKey)) return;
          const r = validateFieldEditKey(
            stateRef.current,
            cardId,
            oldKey,
            newKey,
            disabledKeys,
            customKeys.names,
          );
          if (!r.ok) return;
          const oldField = findCard(stateRef.current.tree, cardId)?.fields.find(
            (f) => f.key === oldKey,
          );
          if (!oldField) return;
          if (
            !gateValidation(
              "fieldEdit",
              {
                cardId,
                key: newKey,
                oldValue: oldField.value,
                oldType: oldField.type,
                newValue: oldField.value,
                newType: oldField.type,
              },
              "field-edit-key",
              cardId,
            )
          )
            return;
          queueCommit({ type: "field-edit-key", cardId, oldKey, newKey });
          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
          clearEditMode();
        },
        fieldAdd: (cardId, key, value, type) => {
          if (!perms.canAddField(cardId)) return;
          const r = validateFieldAdd(
            stateRef.current,
            cardId,
            key,
            value,
            type,
            disabledKeys,
            customKeys.names,
          );
          if (!r.ok) return;
          if (
            !gateValidation(
              "fieldAdd",
              { cardId, key, value, type },
              "field-add",
              cardId,
            )
          )
            return;
          queueCommit({ type: "field-add", cardId, key, value, valueType: type });
          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
          clearEditMode();
        },
        fieldRemove: (cardId, key) => {
          if (!perms.canRemoveField(cardId, key)) return;
          const oldField = findCard(stateRef.current.tree, cardId)?.fields.find(
            (f) => f.key === key,
          );
          if (!oldField) return;
          if (
            !gateValidation(
              "fieldRemove",
              { cardId, key, oldValue: oldField.value, oldType: oldField.type },
              "field-remove",
              cardId,
            )
          )
            return;
          queueCommit({ type: "field-remove", cardId, key });
          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
        },
        cardAdd: (parentId) => {
          if (!perms.canAddCard(parentId)) return;
          const newId = generateId();
          const parent = findCard(stateRef.current.tree, parentId);
          const order = parent
            ? parent.children.reduce((m, c) => Math.max(m, c.order), -1) + 1
            : 0;
          const newCard: ParsedCardTree = {
            id: newId,
            order,
            level: (parent?.level ?? 0) + 1,
            parentKey: "untitled",
            fields: [],
            predefined: [],
            children: [],
          };
          if (
            !gateValidation(
              "cardAdd",
              {
                parentId,
                card: treeToJsonNode(newCard) as CardTreeJsonNode,
              },
              "card-add",
              parentId,
            )
          )
            return;
          queueCommit({ type: "card-add", parentId, card: newCard });
          setTentativeCardId(newId);
          setEditMode({ kind: "card-title", cardId: newId });
        },
        cardRemove: (cardId, policy) => {
          if (!perms.canRemoveCard(cardId)) return;
          const isRoot = stateRef.current.tree.id === cardId;
          if (isRoot) {
            if (!allowRootRemoval) return;
            const currentJson = treeToJsonNode(stateRef.current.tree);
            const newRoot = onRootRemoved
              ? onRootRemoved(currentJson)
              : null;
            if (newRoot === null) {
              setEmptyRoot(true);
              // We don't actually mutate state here — empty-tree placeholder takes over rendering
            } else {
              const { tree } = parseInput(newRoot, {
                disabledPredefinedKeys: disabledKeys,
                dateDetection,
                customKeys: customKeys.keys,
              });
              if (tree) {
                queueCommit({ type: "replace-tree", tree });
                setEmptyRoot(false);
              }
            }
            return;
          }
          const r = validateCardRemove(stateRef.current, cardId);
          if (!r.ok) return;
          const removingCard = findCard(stateRef.current.tree, cardId);
          if (!removingCard) return;
          const parentIdForEvent = findParentId(stateRef.current.tree, cardId);
          if (
            !gateValidation(
              "cardRemove",
              {
                cardId,
                removed: treeToJsonNode(removingCard) as CardTreeJsonNode,
                parentId: parentIdForEvent,
              },
              "card-remove",
              cardId,
            )
          )
            return;
          queueCommit({
            type: "card-remove",
            cardId,
            policy: policy ?? defaultDeletePolicy,
            collisionStrategy: promoteCollisionStrategy,
          });
          clearEditMode();
        },
        cardRename: (cardId, newKey) => {
          if (!perms.canEditCard(cardId)) return;
          const r = validateCardRename(stateRef.current, cardId, newKey, disabledKeys, customKeys.names);
          if (!r.ok) return;
          const oldKey = findCard(stateRef.current.tree, cardId)?.parentKey;
          if (
            !gateValidation(
              "cardRename",
              { cardId, oldKey, newKey },
              "card-rename",
              cardId,
            )
          )
            return;
          queueCommit({ type: "card-rename", cardId, newParentKey: newKey });
          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
          if (tentativeCardId === cardId) setTentativeCardId(null);
          clearEditMode();
        },
        cardCancelTentative: (cardId) => {
          queueCommit({ type: "card-remove", cardId, policy: "cascade" });
          if (tentativeCardId === cardId) setTentativeCardId(null);
          clearEditMode();
        },
        cardDuplicate: (cardId) => {
          if (!perms.canEditCard(cardId)) return;
          const newCardId = generateId();
          const parentForEvent = findParentId(stateRef.current.tree, cardId);
          if (
            !gateValidation(
              "cardDuplicate",
              {
                sourceCardId: cardId,
                newCardId,
                parentId: parentForEvent ?? "",
              },
              "card-duplicate",
              cardId,
            )
          )
            return;
          queueCommit({ type: "card-duplicate", cardId, newCardId });
        },
        predefinedAdd: (cardId, entry) => {
          if (!perms.canAddPredefined(cardId, String(entry.key))) return;
          if (!validateEntryShape(entry)) return;
          if (
            !gateValidation(
              "predefinedAdd",
              { cardId, key: entry.key, value: entry.value },
              "predefined-add",
              cardId,
            )
          )
            return;
          queueCommit({ type: "predefined-add", cardId, entry });
          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
          setEditMode({ kind: "predefined", cardId, key: entry.key });
        },
        predefinedEdit: (cardId, key, entry) => {
          if (!perms.canEditPredefined(cardId, String(key))) return;
          if (!validateEntryShape(entry)) return;
          const oldEntry = findCard(stateRef.current.tree, cardId)?.predefined.find(
            (p) => p.key === key,
          );
          if (
            !gateValidation(
              "predefinedEdit",
              {
                cardId,
                key,
                oldValue: oldEntry?.value ?? null,
                newValue: entry.value,
              },
              "predefined-edit",
              cardId,
            )
          )
            return;
          queueCommit({ type: "predefined-edit", cardId, key, entry });
          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
          clearEditMode();
        },
        predefinedRemove: (cardId, key) => {
          if (!perms.canRemovePredefined(cardId, String(key))) return;
          const oldEntry = findCard(stateRef.current.tree, cardId)?.predefined.find(
            (p) => p.key === key,
          );
          if (
            !gateValidation(
              "predefinedRemove",
              { cardId, key, oldValue: oldEntry?.value ?? null },
              "predefined-remove",
              cardId,
            )
          )
            return;
          queueCommit({ type: "predefined-remove", cardId, key });
          stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
        },
        metaEdit: (cardId, key, value) => {
          const r = validateMetaEdit(stateRef.current, cardId, key, value);
          if (!r.ok) return;
          const oldValue =
            findCard(stateRef.current.tree, cardId)?.meta?.[key] ?? null;
          if (
            !gateValidation(
              "metaEdit",
              { cardId, key, oldValue, newValue: value },
              "meta-edit",
              cardId,
            )
          )
            return;
          queueCommit({ type: "meta-edit", cardId, key, value });
        },
        metaAdd: (cardId, key, value) => {
          const r = validateMetaAdd(stateRef.current, cardId, key, value);
          if (!r.ok) return;
          if (
            !gateValidation(
              "metaAdd",
              { cardId, key, value },
              "meta-add",
              cardId,
            )
          )
            return;
          queueCommit({ type: "meta-add", cardId, key, value });
        },
        metaRemove: (cardId, key) => {
          const oldValue =
            findCard(stateRef.current.tree, cardId)?.meta?.[key] ?? null;
          if (
            !gateValidation(
              "metaRemove",
              { cardId, key, oldValue },
              "meta-remove",
              cardId,
            )
          )
            return;
          queueCommit({ type: "meta-remove", cardId, key });
        },
        selectCard: (cardId, event) => {
          if (event.shiftKey) {
            dispatch({ type: "extend-selection-to", id: cardId });
          } else if (event.metaKey || event.ctrlKey) {
            dispatch({ type: "toggle-selection", id: cardId });
          } else {
            dispatch({ type: "set-multi-selection", ids: [cardId], anchor: cardId });
          }
        },
      }),
      [
        queueCommit,
        clearEditMode,
        setEditMode,
        tentativeCardId,
        perms,
        disabledKeys,
        defaultDeletePolicy,
        promoteCollisionStrategy,
        allowRootRemoval,
        onRootRemoved,
        dateDetection,
        auditTrail,
        gateValidation,
        customKeys.keys,
        customKeys.names,
        validateEntryShape,
      ],
    );

    const validators: EditValidators = useMemo(
      () => ({
        fieldEditValue: (cardId, key, value, type) =>
          validateFieldEditValue(stateRef.current, cardId, key, value, type),
        fieldEditKey: (cardId, oldKey, newKey) =>
          validateFieldEditKey(stateRef.current, cardId, oldKey, newKey, disabledKeys, customKeys.names),
        fieldAdd: (cardId, key, value, type) =>
          validateFieldAdd(stateRef.current, cardId, key, value, type, disabledKeys, customKeys.names),
        cardRename: (cardId, newKey) =>
          validateCardRename(stateRef.current, cardId, newKey, disabledKeys, customKeys.names),
        metaEdit: (cardId, key, value) =>
          validateMetaEdit(stateRef.current, cardId, key, value),
        metaAdd: (cardId, key, value) =>
          validateMetaAdd(stateRef.current, cardId, key, value),
      }),
      [disabledKeys, customKeys.names],
    );

    /* ───────── DnD handlers ───────── */

    const dndEnabled =
      editable &&
      ((dndScopes?.sameLevel ?? true) || (dndScopes?.crossLevel ?? true));

    const sortableIds = useMemo(
      () => visibleIdsInOrder(state.tree, state.collapsed).filter((id) => id !== state.tree.id),
      [state.tree, state.collapsed],
    );

    const handleDragStart = useCallback(
      (event: DragStartEvent) => {
        const id = String(event.active.id);
        dispatch({ type: "drag-start", cardId: id });
      },
      [],
    );

    const handleDragEnd = useCallback(
      (event: DragEndEvent) => {
        dispatch({ type: "drag-end" });
        const { active, over } = event;
        if (!over) return;
        const activeId = String(active.id);
        const overId = String(over.id);
        if (activeId === overId) return;

        // Determine drop semantics: if over is the same parent's sibling → same-level reorder;
        // otherwise → cross-level reparent.
        const tree = stateRef.current.tree;
        const sourceParent = findParentId(tree, activeId);
        const overParent = findParentId(tree, overId);
        if (sourceParent === null) return; // can't drag root

        // Check permissions
        if (!perms.canDragCard(activeId)) return;

        let newParentId: string;
        let newOrder: number;

        if (sourceParent === overParent) {
          // Same-level reorder
          if (!(dndScopes?.sameLevel ?? true)) return;
          const overCard = findCard(tree, overId);
          if (!overCard) return;
          newParentId = overParent;
          newOrder = overCard.order + 0.5;
        } else {
          // Cross-level reparent (drop into target as last child)
          if (!(dndScopes?.crossLevel ?? true)) return;
          if (!perms.canDropCard(activeId, overId)) return;
          // Cycle check
          const targetAncestors = findAncestorIds(tree, overId);
          if (targetAncestors.includes(activeId) || activeId === overId) return;
          newParentId = overId;
          const overCard = findCard(tree, overId);
          if (!overCard) return;
          newOrder =
            overCard.children.reduce((m, c) => Math.max(m, c.order), -1) + 1;
        }

        queueCommit({
          type: "card-move",
          cardId: activeId,
          newParentId,
          newOrder,
        });
      },
      [perms, dndScopes, queueCommit],
    );

    /* ───────── config ───────── */

    const config: CardConfig = useMemo(
      () => ({
        resolveLevelStyle,
        predefinedKeyStyles,
        metaPresentation,
        metaRenderers,
        rootTitle: ariaLabel,
        disabledPredefinedKeys: disabledKeys,
        // Resolved registrations — parts look renderers/editors up by name.
        customKeys: customKeys.keys,
        editable,
        editMode,
        setEditMode,
        clearEditMode,
        tentativeCardId,
        // eslint-disable-next-line react-hooks/refs
        dispatchers,
        // eslint-disable-next-line react-hooks/refs
        validators,
        perms,
        allowRootRemoval,
        defaultDeletePolicy,
        searchMatches: searchResult.matches,
        searchQuery: search?.query ?? "",
        searchCaseSensitive: search?.caseSensitive ?? false,
        dndEnabled,
      }),
      [
        resolveLevelStyle,
        predefinedKeyStyles,
        metaPresentation,
        metaRenderers,
        ariaLabel,
        disabledKeys,
        customKeys.keys,
        editable,
        editMode,
        setEditMode,
        clearEditMode,
        tentativeCardId,
        dispatchers,
        validators,
        perms,
        allowRootRemoval,
        defaultDeletePolicy,
        searchResult.matches,
        search?.query,
        search?.caseSensitive,
        dndEnabled,
      ],
    );

    const onKeyDown = useTreeKeyboard(state, dispatch, editMode);

    /* ───────── undo/redo (v0.4) ───────── */

    const rootContainerRef = useRef<HTMLDivElement | null>(null);
    const undoApi = useUndo(state, dispatch, {
      enableShortcuts: !disableUndoShortcuts,
      rootRef: rootContainerRef,
      onUndo,
      onRedo,
    });

    /* ───────── imperative handle ───────── */

    useImperativeHandle(
      ref,
      (): CardTreeHandle => ({
        getValue: () => serializeTree(state.tree),
        getTree: () => treeToJsonNode(state.tree) as CardTreeJsonNode,
        isDirty: () => isDirtySelector(state),
        markClean: () => dispatch({ type: "mark-clean" }),
        getSelectedId: () =>
          state.selectedIds.size > 0 ? Array.from(state.selectedIds)[0] ?? null : null,
        getSelectedIds: () => Array.from(state.selectedIds),
        setSelection: (ids) => {
          if (ids === null) {
            dispatch({ type: "clear-selection" });
            return;
          }
          const arr = typeof ids === "string" ? [ids] : Array.from(ids);
          dispatch({
            type: "set-multi-selection",
            ids: arr,
            anchor: arr[arr.length - 1] ?? null,
          });
        },
        focusCard: (id) => {
          dispatch({ type: "set-focus", id });
          requestAnimationFrame(() => {
            document.querySelector<HTMLElement>(`[data-rcid="${id}"]`)?.focus();
          });
        },
        addCardAt: (parentId) => {
          const newId = generateId();
          const parent = findCard(state.tree, parentId);
          const order = parent
            ? parent.children.reduce((m, c) => Math.max(m, c.order), -1) + 1
            : 0;
          const newCard: ParsedCardTree = {
            id: newId,
            order,
            level: (parent?.level ?? 0) + 1,
            parentKey: "untitled",
            fields: [],
            predefined: [],
            children: [],
          };
          queueCommit({ type: "card-add", parentId, card: newCard });
          return newId;
        },
        removeCard: (id) => {
          queueCommit({ type: "card-remove", cardId: id, policy: "cascade" });
        },
        replaceRoot: (newRoot) => {
          if (newRoot === null) {
            setEmptyRoot(true);
            return;
          }
          const { tree } = parseInput(newRoot, {
            disabledPredefinedKeys: disabledKeys,
            dateDetection,
            customKeys: customKeys.keys,
          });
          if (tree) {
            queueCommit({ type: "replace-tree", tree });
            setEmptyRoot(false);
          }
        },
        getEffectivePermissions: (cardId, target) => {
          const card = findCard(state.tree, cardId);
          if (!card) {
            return {
              edit: false,
              add: false,
              remove: false,
              reorder: false,
              reparent: false,
            } as EffectivePermissions;
          }
          return perms.getEffective({
            cardId,
            level: card.level,
            kind: target?.kind ?? "card",
            key: target?.key,
          });
        },
        findNext: () => findNextMatch(state, searchResult, dispatch),
        findPrevious: () => findPrevMatch(state, searchResult, dispatch),
        scrollToMatch: (match) => {
          dispatch({ type: "set-active-match-index", index: searchResult.matches.indexOf(match) });
          requestAnimationFrame(() => {
            document
              .querySelector<HTMLElement>(`[data-rcid="${match.cardId}"]`)
              ?.scrollIntoView({ block: "center", behavior: "smooth" });
          });
        },
        clearSearch: () => {
          dispatch({ type: "clear-search" });
        },
        // v0.4
        undo: () => undoApi.undo(),
        redo: () => undoApi.redo(),
        canUndo: () => undoApi.canUndo,
        canRedo: () => undoApi.canRedo,
        clearHistory: () => undoApi.clearHistory(),
      }),
      [
        state,
        perms,
        searchResult,
        queueCommit,
        disabledKeys,
        dateDetection,
        undoApi,
        customKeys.keys,
      ],
    );

    /* ───────── render ───────── */

    if (emptyRoot) {
      return (
        <div role="region" aria-label={ariaLabel} className={cn("w-full", className)}>
          {emptyTreeRenderer ? (
            emptyTreeRenderer()
          ) : (
            <EmptyTreePlaceholder
              onAddRoot={
                editable
                  ? () => {
                      // Build a minimal new root and replace
                      const newRoot: CardTreeJsonNode = {
                        title: "untitled",
                      };
                      const { tree } = parseInput(newRoot, {
                        disabledPredefinedKeys: disabledKeys,
                        dateDetection,
                        customKeys: customKeys.keys,
                      });
                      if (tree) {
                        dispatch({ type: "replace-tree", tree });
                        setEmptyRoot(false);
                      }
                    }
                  : undefined
              }
            />
          )}
        </div>
      );
    }

    const treeContent = (
      <ul
        role="tree"
        aria-label={ariaLabel}
        aria-multiselectable={editable ? "true" : undefined}
        onKeyDown={onKeyDown}
        className="block list-none p-0 m-0"
      >
        <Card tree={state.tree} config={config} state={state} dispatch={dispatch} />
      </ul>
    );

    return (
      <div
        ref={rootContainerRef}
        role="region"
        aria-label={ariaLabel}
        tabIndex={-1}
        className={cn("w-full focus:outline-none", className)}
        onClick={(e) => {
          if (e.target === e.currentTarget) {
            dispatch({ type: "clear-selection" });
          }
        }}
      >
        {dndEnabled ? (
          <DndContextWrapper
            sortableIds={sortableIds}
            onDragStart={handleDragStart}
            onDragEnd={handleDragEnd}
          >
            {treeContent}
          </DndContextWrapper>
        ) : (
          treeContent
        )}
      </div>
    );
  },
);

/* ───────── DnD wrapper (only mounts sensors when needed) ───────── */

function DndContextWrapper({
  sortableIds,
  onDragStart,
  onDragEnd,
  children,
}: {
  sortableIds: string[];
  onDragStart: (event: DragStartEvent) => void;
  onDragEnd: (event: DragEndEvent) => void;
  children: React.ReactNode;
}) {
  const sensors = useDndSensors();
  return (
    <DndContext
      sensors={sensors}
      collisionDetection={collisionStrategy}
      onDragStart={onDragStart}
      onDragEnd={onDragEnd}
    >
      <SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
        {children}
      </SortableContext>
    </DndContext>
  );
}

/* ───────── audit trail helper ───────── */

function stampAuditTrail(
  queueCommit: (action: CardTreeAction) => void,
  state: CardTreeState,
  cardId: string,
  auditTrail: { editor?: string; lastEditedKey?: string; lastEditorKey?: string } | undefined,
): void {
  if (!auditTrail) return;
  const lastEditedKey = auditTrail.lastEditedKey ?? "_lastEdited";
  const lastEditorKey = auditTrail.lastEditorKey ?? "_lastEditor";
  const card = findCard(state.tree, cardId);
  if (!card) return;
  const now = new Date().toISOString();
  if (card.meta?.[lastEditedKey] !== now) {
    queueCommit({ type: "meta-edit", cardId, key: lastEditedKey, value: now });
  }
  if (auditTrail.editor && card.meta?.[lastEditorKey] !== auditTrail.editor) {
    queueCommit({
      type: "meta-edit",
      cardId,
      key: lastEditorKey,
      value: auditTrail.editor,
    });
  }
}

/* ───────── search navigation ───────── */

function findNextMatch(
  state: CardTreeState,
  result: { matches: SearchMatch[]; activeIndex: number | null },
  dispatch: (action: CardTreeAction) => void,
): SearchMatch | null {
  if (result.matches.length === 0) return null;
  const next = ((result.activeIndex ?? -1) + 1) % result.matches.length;
  dispatch({ type: "set-active-match-index", index: next });
  const match = result.matches[next];
  requestAnimationFrame(() => {
    document
      .querySelector<HTMLElement>(`[data-rcid="${match.cardId}"]`)
      ?.scrollIntoView({ block: "center", behavior: "smooth" });
  });
  return match;
}

function findPrevMatch(
  state: CardTreeState,
  result: { matches: SearchMatch[]; activeIndex: number | null },
  dispatch: (action: CardTreeAction) => void,
): SearchMatch | null {
  if (result.matches.length === 0) return null;
  const prev =
    (result.activeIndex ?? 0) - 1 < 0
      ? result.matches.length - 1
      : (result.activeIndex ?? 0) - 1;
  dispatch({ type: "set-active-match-index", index: prev });
  const match = result.matches[prev];
  requestAnimationFrame(() => {
    document
      .querySelector<HTMLElement>(`[data-rcid="${match.cardId}"]`)
      ?.scrollIntoView({ block: "center", behavior: "smooth" });
  });
  return match;
}

/* ───────── granular event firing ───────── */

type EventHandlers = {
  onFieldEdited?: (e: FieldEditedEvent) => void;
  onFieldAdded?: (e: FieldAddedEvent) => void;
  onFieldRemoved?: (e: FieldRemovedEvent) => void;
  onCardAdded?: (e: CardAddedEvent) => void;
  onCardRemoved?: (e: CardRemovedEvent) => void;
  onCardRenamed?: (e: CardRenamedEvent) => void;
  onPredefinedAdded?: (e: PredefinedAddedEvent) => void;
  onPredefinedEdited?: (e: PredefinedEditedEvent) => void;
  onPredefinedRemoved?: (e: PredefinedRemovedEvent) => void;
  onCardMoved?: (e: CardMovedEvent) => void;
  onCardDuplicated?: (e: CardDuplicatedEvent) => void;
  onMetaChanged?: (e: MetaChangedEvent) => void;
  onMetaAdded?: (e: MetaAddedEvent) => void;
  onMetaRemoved?: (e: MetaRemovedEvent) => void;
};

function fireGranularEvent(
  action: CardTreeAction,
  prev: CardTreeState,
  next: CardTreeState,
  h: EventHandlers,
) {
  switch (action.type) {
    case "field-edit-value": {
      const prevCard = findCard(prev.tree, action.cardId);
      const prevField = prevCard?.fields.find((f) => f.key === action.key);
      if (!prevField) return;
      h.onFieldEdited?.({
        cardId: action.cardId,
        key: action.key,
        oldValue: prevField.value,
        oldType: prevField.type,
        newValue: action.value,
        newType: action.valueType,
      });
      return;
    }
    case "field-edit-key": {
      const prevCard = findCard(prev.tree, action.cardId);
      const prevField = prevCard?.fields.find((f) => f.key === action.oldKey);
      if (!prevField) return;
      h.onFieldEdited?.({
        cardId: action.cardId,
        key: action.newKey,
        oldValue: prevField.value,
        oldType: prevField.type,
        newValue: prevField.value,
        newType: prevField.type,
      });
      return;
    }
    case "field-add":
      h.onFieldAdded?.({
        cardId: action.cardId,
        key: action.key,
        value: action.value,
        type: action.valueType,
      });
      return;
    case "field-remove": {
      const prevCard = findCard(prev.tree, action.cardId);
      const prevField = prevCard?.fields.find((f) => f.key === action.key);
      if (!prevField) return;
      h.onFieldRemoved?.({
        cardId: action.cardId,
        key: action.key,
        oldValue: prevField.value,
        oldType: prevField.type,
      });
      return;
    }
    case "card-add":
      h.onCardAdded?.({
        parentId: action.parentId,
        card: treeToJsonNode(action.card) as CardTreeJsonNode,
      });
      return;
    case "card-remove": {
      const prevSubtree = findCard(prev.tree, action.cardId);
      if (!prevSubtree) return;
      const parentId = findParentId(prev.tree, action.cardId);
      h.onCardRemoved?.({
        cardId: action.cardId,
        removed: treeToJsonNode(prevSubtree) as CardTreeJsonNode,
        parentId,
      });
      return;
    }
    case "card-rename": {
      const prevCard = findCard(prev.tree, action.cardId);
      h.onCardRenamed?.({
        cardId: action.cardId,
        oldKey: prevCard?.parentKey,
        newKey: action.newParentKey,
      });
      return;
    }
    case "card-move": {
      const prevCard = findCard(prev.tree, action.cardId);
      const prevParentId = findParentId(prev.tree, action.cardId);
      h.onCardMoved?.({
        cardId: action.cardId,
        oldParentId: prevParentId ?? "",
        newParentId: action.newParentId,
        oldOrder: prevCard?.order ?? 0,
        newOrder: action.newOrder,
      });
      return;
    }
    case "card-duplicate": {
      const sourceParentId = findParentId(prev.tree, action.cardId);
      h.onCardDuplicated?.({
        sourceCardId: action.cardId,
        newCardId: action.newCardId,
        parentId: sourceParentId ?? "",
      });
      return;
    }
    case "predefined-add":
      h.onPredefinedAdded?.({
        cardId: action.cardId,
        key: action.entry.key,
        value: action.entry.value,
      });
      return;
    case "predefined-edit": {
      const prevCard = findCard(prev.tree, action.cardId);
      const prevEntry = prevCard?.predefined.find((p) => p.key === action.key);
      h.onPredefinedEdited?.({
        cardId: action.cardId,
        key: action.key,
        oldValue: prevEntry?.value ?? null,
        newValue: action.entry.value,
      });
      return;
    }
    case "predefined-remove": {
      const prevCard = findCard(prev.tree, action.cardId);
      const prevEntry = prevCard?.predefined.find((p) => p.key === action.key);
      h.onPredefinedRemoved?.({
        cardId: action.cardId,
        key: action.key,
        oldValue: prevEntry?.value ?? null,
      });
      return;
    }
    case "meta-edit": {
      const prevCard = findCard(prev.tree, action.cardId);
      const oldValue = prevCard?.meta?.[action.key] ?? null;
      h.onMetaChanged?.({
        cardId: action.cardId,
        key: action.key,
        oldValue,
        newValue: action.value,
      });
      return;
    }
    case "meta-add":
      h.onMetaAdded?.({
        cardId: action.cardId,
        key: action.key,
        value: action.value,
      });
      return;
    case "meta-remove": {
      const prevCard = findCard(prev.tree, action.cardId);
      const oldValue = prevCard?.meta?.[action.key] ?? null;
      h.onMetaRemoved?.({
        cardId: action.cardId,
        key: action.key,
        oldValue,
      });
      return;
    }
    default:
      return;
  }
}
src/registry/components/data/card-tree/index.ts
export { CardTree } from "./card-tree";

// v0.1 types
export type {
  CodeAreaValue,
  FlatFieldType,
  FlatFieldValue,
  ImageValue,
  LevelStyle,
  ListValue,
  PredefinedKey,
  QuoteValue,
  CardTreeHandle,
  CardTreeJsonNode,
  CardTreeProps,
  TableValue,
} from "./types";

// v0.2 event types
export type {
  CardAddedEvent,
  CardRemovedEvent,
  CardRenamedEvent,
  FieldAddedEvent,
  FieldEditedEvent,
  FieldRemovedEvent,
  PredefinedAddedEvent,
  PredefinedEditedEvent,
  PredefinedRemovedEvent,
} from "./types";

// v0.3 types — permissions, search, dnd, custom keys, meta renderers, audit trail.
// These shipped into types.ts in v0.3 but were never re-exported here, so every
// one of them was unreachable from the package root until v0.6 (guide §7.1 and
// §meta-renderers both showed root imports that could not compile).
export type {
  AuditTrailConfig,
  CardTreePermissions,
  CustomKeyContext,
  CustomPredefinedKey,
  DndScopes,
  EffectivePermissions,
  MetaRenderer,
  MetaRendererContext,
  PermissionDenialReason,
  PermissionRule,
  ReservedKey,
  SearchMatch,
  SearchMatchType,
  SearchOptions,
  SearchResult,
} from "./types";

// v0.3 event types
export type {
  CardDuplicatedEvent,
  CardMovedEvent,
  MetaAddedEvent,
  MetaChangedEvent,
  MetaRemovedEvent,
} from "./types";

// v0.4 validator types
export type {
  CardTreeMasterValidator,
  CardTreeValidationError,
  CardTreeValidationResponse,
  CardTreeValidators,
  ValidationFailedEvent,
} from "./types";

// constants
export { PREDEFINED_KEYS, RESERVED_KEYS } from "./types";

// optional sibling exports (v0.3 search bar + v0.4 undo toolbar)
export { CardTreeSearchBar } from "./parts/search-bar";
export { CardTreeUndoToolbar } from "./parts/undo-toolbar";

// component metadata
src/registry/components/data/card-tree/types.ts
/**
 * Public types for the card-tree pro-component.
 *
 * v0.1: viewer (typed scalar fields, predefined-key blocks, per-level styling, ARIA tree).
 * v0.2: inline editor (click-to-edit, granular events, dirty tracking, single-select).
 * v0.3: structural management — drag-drop, bulk multi-select, permission matrix,
 *       virtualization, native search, meta editing, root-removal, promote-on-delete.
 * v0.4: validation hooks + per-commit undo/redo.
 * v0.6 (current): **custom predefined-keys actually work.** The prop, the types and the
 *       docs shipped in v0.3, but nothing between `parse` and the renderers ever read
 *       them — registration was inert through 0.5.0. v0.6 wires the whole path
 *       (classify → parse/validate → render/edit → serialize verbatim), which also
 *       makes array-valued blocks (Plate Value, editor.js) registrable.
 *
 * See:
 *   - docs/procomps/card-tree-procomp/card-tree-procomp-plan.md (v0.1)
 *   - docs/procomps/card-tree-procomp/card-tree-procomp-plan-v0.2.md
 *   - docs/procomps/card-tree-procomp/card-tree-procomp-plan-v0.3.md (§9 = the custom-key spec)
 *   - docs/procomps/card-tree-procomp/card-tree-procomp-plan-v0.4.md
 *   - docs/procomps/card-tree-procomp/card-tree-loop.md (v0.6 U-loop)
 */

import type { ReactNode } from "react";

/* ───────── reserved + predefined keys ───────── */

export const RESERVED_KEYS = ["__rcid", "__rcorder", "__rcmeta"] as const;
export type ReservedKey = (typeof RESERVED_KEYS)[number];

export const PREDEFINED_KEYS = [
  "codearea",
  "image",
  "table",
  "quote",
  "list",
] as const;
export type PredefinedKey = (typeof PREDEFINED_KEYS)[number];

/* ───────── flat-field scalar values ───────── */

export type FlatFieldValue = string | number | boolean | null;

/** Re-exported from lib/infer-type so it's part of the public surface (referenced by event types). */
export type { FlatFieldType } from "./lib/infer-type";

/* ───────── predefined-key payload shapes ───────── */

export type CodeAreaValue = { format: string; content: string };
export type ImageValue = { src: string; alt?: string };
export type TableValue = { headers: string[]; rows: FlatFieldValue[][] };
export type QuoteValue = string;
export type ListValue = FlatFieldValue[];

/* ───────── input node shape ───────── */

export type CardTreeJsonNode = {
  __rcid?: string;
  __rcorder?: number;
  __rcmeta?: Record<string, FlatFieldValue>;
  [key: string]: unknown;
};

/* ───────── styling ───────── */

export type LevelStyle = {
  containerClassName?: string;
  headerClassName?: string;
  fieldsClassName?: string;
  childrenClassName?: string;
};

/* ───────── v0.2 event types ───────── */

import type { FlatFieldType as _FlatFieldType } from "./lib/infer-type";

export type FieldEditedEvent = {
  cardId: string;
  key: string;
  oldValue: FlatFieldValue;
  oldType: _FlatFieldType;
  newValue: FlatFieldValue;
  newType: _FlatFieldType;
};

export type FieldAddedEvent = {
  cardId: string;
  key: string;
  value: FlatFieldValue;
  type: _FlatFieldType;
};

export type FieldRemovedEvent = {
  cardId: string;
  key: string;
  oldValue: FlatFieldValue;
  oldType: _FlatFieldType;
};

export type CardAddedEvent = {
  parentId: string;
  card: CardTreeJsonNode;
};

export type CardRemovedEvent = {
  cardId: string;
  removed: CardTreeJsonNode;
  parentId: string | null;
};

export type CardRenamedEvent = {
  cardId: string;
  oldKey: string | undefined;
  newKey: string;
};

export type PredefinedAddedEvent = {
  cardId: string;
  key: PredefinedKey | string;
  value: unknown;
};

export type PredefinedEditedEvent = {
  cardId: string;
  key: PredefinedKey | string;
  oldValue: unknown;
  newValue: unknown;
};

export type PredefinedRemovedEvent = {
  cardId: string;
  key: PredefinedKey | string;
  oldValue: unknown;
};

/* ───────── v0.3 event types ───────── */

export type CardMovedEvent = {
  cardId: string;
  oldParentId: string;
  newParentId: string;
  oldOrder: number;
  newOrder: number;
};

export type CardDuplicatedEvent = {
  sourceCardId: string;
  newCardId: string;
  parentId: string;
};

export type MetaChangedEvent = {
  cardId: string;
  key: string;
  oldValue: FlatFieldValue;
  newValue: FlatFieldValue;
};

export type MetaAddedEvent = {
  cardId: string;
  key: string;
  value: FlatFieldValue;
};

export type MetaRemovedEvent = {
  cardId: string;
  key: string;
  oldValue: FlatFieldValue;
};

/* ───────── v0.3 search ───────── */

export type SearchOptions = {
  query: string;
  caseSensitive?: boolean;
  matchTitles?: boolean;
  matchKeys?: boolean;
  matchValues?: boolean;
  matchPredefined?: boolean;
  matchMeta?: boolean;
};

export type SearchMatchType =
  | "title"
  | "field-key"
  | "field-value"
  | "predefined"
  | "meta-key"
  | "meta-value";

export type SearchMatch = {
  cardId: string;
  matchType: SearchMatchType;
  fieldKey?: string;
  excerpt: string;
  start: number;
  length: number;
};

export type SearchResult = {
  matches: SearchMatch[];
  matchedCardIds: ReadonlySet<string>;
  activeIndex: number | null;
};

/* ───────── v0.3 permissions ───────── */

export type PermissionRule = {
  edit?: boolean;
  add?: boolean;
  remove?: boolean;
  reorder?: boolean;
  reparent?: boolean;
};

export type CardTreePermissions = {
  default?: PermissionRule;
  byLevel?: Record<number, PermissionRule>;
  byCard?: Record<string, PermissionRule>;
  /**
   * v0.6: accepts registered custom-key names too, not just the five built-ins.
   * The runtime lookup in `lib/permissions.ts` always resolved this by string;
   * the narrower type was the only thing preventing a custom block from being
   * permission-controlled. `string & {}` keeps built-in autocomplete alive.
   */
  byPredefinedKey?: Partial<Record<PredefinedKey | (string & {}), PermissionRule>>;
  byFieldType?: Partial<Record<_FlatFieldType, PermissionRule>>;
  inherit?: boolean;
};

export type PermissionDenialReason =
  | "global-editable-false"
  | "meta-locked"
  | "by-card"
  | "by-predefined-key"
  | "by-field-type"
  | "by-level"
  | "default"
  | "predicate";

export type EffectivePermissions = PermissionRule & {
  reason?: PermissionDenialReason;
};

/* ───────── v0.3 DnD ───────── */

export type DndScopes = {
  sameLevel?: boolean;
  crossLevel?: boolean;
};

/* ───────── v0.3 custom predefined keys ───────── */

export type CustomKeyContext = {
  cardId: string;
  level: number;
  isEditing: boolean;
  className?: string;
};

export type CustomPredefinedKey = {
  key: string;
  description?: string;
  icon?: ReactNode;
  category?: string;
  validate: (value: unknown) => { ok: boolean; errors?: { code: string; message: string }[] };
  render: (value: unknown, ctx: CustomKeyContext) => ReactNode;
  edit?: (
    value: unknown,
    onSave: (next: unknown) => void,
    onCancel: () => void,
  ) => ReactNode;
  defaultValue: () => unknown;
  /**
   * Makes this block searchable by the native data-model search. Optional —
   * omit it and the block is simply skipped by search (never an error).
   *
   * Q-P15 deferred custom-key search out of v0.3; `lib/search.ts` shipped the
   * consumption early and it is live as of v0.6.
   */
  searchableText?: (value: unknown) => string[];
};

/* ───────── v0.3 meta renderers ───────── */

export type MetaRendererContext = {
  cardId: string;
  metaKey: string;
  className?: string;
};

export type MetaRenderer = (
  value: FlatFieldValue,
  ctx: MetaRendererContext,
) => ReactNode;

/* ───────── v0.3 audit trail ───────── */

export type AuditTrailConfig = {
  /** Author identifier stamped on `_lastEditor` meta entry. */
  editor?: string;
  /** Reserved meta key names for the audit trail. */
  lastEditedKey?: string;     // default '_lastEdited'
  lastEditorKey?: string;     // default '_lastEditor'
};

/* ───────── v0.4 validation hooks ───────── */

export type CardTreeValidationError = { code: string; message: string };

export type CardTreeValidationResponse =
  | { ok: true }
  | { ok: false; errors: CardTreeValidationError[] };

export type CardTreeValidators = {
  fieldEdit?: (
    event: FieldEditedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  fieldAdd?: (
    event: FieldAddedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  fieldRemove?: (
    event: FieldRemovedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  cardAdd?: (
    event: CardAddedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  cardRemove?: (
    event: CardRemovedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  cardRename?: (
    event: CardRenamedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  cardMove?: (
    event: CardMovedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  cardDuplicate?: (
    event: CardDuplicatedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  predefinedAdd?: (
    event: PredefinedAddedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  predefinedEdit?: (
    event: PredefinedEditedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  predefinedRemove?: (
    event: PredefinedRemovedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  metaEdit?: (
    event: MetaChangedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  metaAdd?: (
    event: MetaAddedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
  metaRemove?: (
    event: MetaRemovedEvent,
    tree: CardTreeJsonNode,
  ) => CardTreeValidationResponse;
};

export type CardTreeMasterValidator = (
  action: { type: string; cardId?: string },
  tree: CardTreeJsonNode,
) => CardTreeValidationResponse;

export type ValidationFailedEvent = {
  action: string;
  cardId?: string;
  errors: CardTreeValidationError[];
  layer: "per-action" | "master";
};

/* ───────── component props ───────── */

export type CardTreeProps = {
  defaultValue: CardTreeJsonNode;

  // styling (v0.1)
  levelStyles?: LevelStyle[];
  getLevelStyle?: (level: number) => LevelStyle;
  predefinedKeyStyles?: Partial<Record<PredefinedKey | string, string | LevelStyle>>;

  // viewer behavior (v0.1)
  defaultCollapsed?: "all" | "none" | ((level: number) => boolean);
  metaPresentation?: "hidden" | "inline" | "popover";
  disabledPredefinedKeys?: PredefinedKey[];
  dateDetection?: "auto" | "never" | ((value: string) => boolean);

  // editor behavior (v0.2)
  editable?: boolean;
  onChange?: (tree: CardTreeJsonNode) => void;
  onFieldEdited?: (event: FieldEditedEvent) => void;
  onFieldAdded?: (event: FieldAddedEvent) => void;
  onFieldRemoved?: (event: FieldRemovedEvent) => void;
  onCardAdded?: (event: CardAddedEvent) => void;
  onCardRemoved?: (event: CardRemovedEvent) => void;
  onCardRenamed?: (event: CardRenamedEvent) => void;
  onPredefinedAdded?: (event: PredefinedAddedEvent) => void;
  onPredefinedEdited?: (event: PredefinedEditedEvent) => void;
  onPredefinedRemoved?: (event: PredefinedRemovedEvent) => void;

  // v0.3 — DnD
  dndScopes?: DndScopes;
  onCardMoved?: (event: CardMovedEvent) => void;
  onCardDuplicated?: (event: CardDuplicatedEvent) => void;

  // v0.3 — permissions (declarative + predicate escape hatches)
  permissions?: CardTreePermissions;
  canEditField?: (cardId: string, key: string) => boolean;
  canAddField?: (cardId: string) => boolean;
  canRemoveField?: (cardId: string, key: string) => boolean;
  canEditCard?: (cardId: string) => boolean;
  canAddCard?: (parentId: string) => boolean;
  canRemoveCard?: (cardId: string) => boolean;
  canEditPredefined?: (cardId: string, key: string) => boolean;
  canAddPredefined?: (cardId: string, key: string) => boolean;
  canRemovePredefined?: (cardId: string, key: string) => boolean;
  canDragCard?: (cardId: string) => boolean;
  canDropCard?: (cardId: string, targetParentId: string) => boolean;
  /** Fired (analytics-only) when an action would have been blocked. */
  onPermissionDenied?: (
    action: keyof PermissionRule,
    cardId: string,
    target: string | undefined,
    reason: PermissionDenialReason,
  ) => void;

  /**
   * Register additional content blocks at mount (v0.3 API — **functional since v0.6**).
   *
   * Precedence: reserved (`__rc*`) → built-in predefined → **custom** → scalar
   * field → child card. Matching happens on the key NAME before the value is
   * inspected, so a custom block may hold any JSON shape, arrays included —
   * unlike ordinary children, which still reject arrays per Q-P4.
   *
   * Mount-only. A name that collides with a built-in or reserved key, or that is
   * registered twice, is dropped with a `console.error`.
   *
   * ⚠️ Registering a name **claims that name everywhere in the tree**. If existing
   * documents already use it for something else — a child card, say — those
   * values are now routed to your `validate`, and a rejection drops the entry
   * (same rule the five built-ins have always followed: a malformed `quote` is
   * dropped too). Pick names you own, and treat adding a registration to a
   * populated corpus as a migration. Parse diagnostics are emitted via
   * `console.warn`.
   */
  customPredefinedKeys?: CustomPredefinedKey[];

  // v0.3 — root-removal + delete policy
  allowRootRemoval?: boolean;
  onRootRemoved?: (current: CardTreeJsonNode) => CardTreeJsonNode | null;
  defaultDeletePolicy?: "cascade" | "promote";
  promoteCollisionStrategy?: "suffix" | "qualify" | "reject";
  emptyTreeRenderer?: () => ReactNode;

  // v0.3 — meta editing + audit
  metaRenderers?: Record<string, MetaRenderer>;
  auditTrail?: AuditTrailConfig;
  onMetaChanged?: (event: MetaChangedEvent) => void;
  onMetaAdded?: (event: MetaAddedEvent) => void;
  onMetaRemoved?: (event: MetaRemovedEvent) => void;

  // v0.3 — search (controlled by host)
  search?: SearchOptions;
  onSearchResults?: (result: SearchResult) => void;

  // v0.3 — multi-select handler (BREAKING change from v0.2's `id: string | null`)
  onSelectionChange?: (ids: readonly string[]) => void;

  // v0.4 — validation hooks
  validators?: CardTreeValidators;
  validate?: CardTreeMasterValidator;
  onValidationFailed?: (event: ValidationFailedEvent) => void;

  // v0.4 — undo/redo
  maxUndoDepth?: number;
  disableUndoShortcuts?: boolean;
  onUndo?: () => void;
  onRedo?: () => void;

  // container
  className?: string;
  "aria-label"?: string;
};

/* ───────── imperative ref handle ───────── */

export type CardTreeHandle = {
  // v0.1
  /** Canonical pretty-printed JSON string with auto-attached IDs. */
  getValue(): string;
  /** Object form of the parsed tree, with auto-attached IDs preserved. */
  getTree(): CardTreeJsonNode;
  // v0.2
  isDirty(): boolean;
  markClean(): void;
  /** Returns the first selected card id, or null. v0.3 retains for backward compat with single-select consumers. */
  getSelectedId(): string | null;
  // v0.3
  getSelectedIds(): readonly string[];
  setSelection(ids: readonly string[] | string | null): void;
  focusCard(id: string): void;
  addCardAt(parentId: string, position?: number): string;
  removeCard(id: string): void;
  /** Programmatic root replacement when allowRootRemoval is enabled. */
  replaceRoot(newRoot: CardTreeJsonNode | null): void;
  /** Returns effective permission resolution for a card (debug / host-side gating). */
  getEffectivePermissions(
    cardId: string,
    target?: { kind: "field" | "predefined" | "card"; key?: string },
  ): EffectivePermissions;
  // v0.3 — search
  findNext(): SearchMatch | null;
  findPrevious(): SearchMatch | null;
  scrollToMatch(match: SearchMatch): void;
  clearSearch(): void;
  // v0.4 — undo/redo
  undo(): boolean;
  redo(): boolean;
  canUndo(): boolean;
  canRedo(): boolean;
  clearHistory(): void;
};
src/registry/components/data/card-tree/hooks/use-bulk-selection.ts
import { useCallback, type Dispatch } from "react";
import type {
  CardTreeAction,
  CardTreeState,
} from "../lib/reducer";

/**
 * v0.3 multi-select helpers. Click semantics:
 *   - plain click       → replace selection with single id
 *   - shift-click       → range select from anchor to id
 *   - cmd/ctrl-click    → toggle id in/out of selection
 */
export function useBulkSelection(
  state: CardTreeState,
  dispatch: Dispatch<CardTreeAction>,
) {
  const select = useCallback(
    (id: string) => {
      dispatch({ type: "set-multi-selection", ids: [id], anchor: id });
    },
    [dispatch],
  );

  const toggle = useCallback(
    (id: string) => {
      dispatch({ type: "toggle-selection", id });
    },
    [dispatch],
  );

  const extend = useCallback(
    (id: string) => {
      dispatch({ type: "extend-selection-to", id });
    },
    [dispatch],
  );

  const clear = useCallback(() => {
    dispatch({ type: "clear-selection" });
  }, [dispatch]);

  const isSelected = useCallback(
    (id: string) => state.selectedIds.has(id),
    [state.selectedIds],
  );

  const handleClick = useCallback(
    (id: string, event: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => {
      if (event.shiftKey) extend(id);
      else if (event.metaKey || event.ctrlKey) toggle(id);
      else select(id);
    },
    [extend, toggle, select],
  );

  return {
    selectedIds: state.selectedIds,
    anchorId: state.selectionAnchorId,
    isSelected,
    select,
    toggle,
    extend,
    clear,
    handleClick,
  };
}
src/registry/components/data/card-tree/hooks/use-collapse-state.ts
import { useCallback, useMemo, type Dispatch } from "react";
import type { CardTreeAction, CardTreeState } from "../lib/reducer";

/**
 * Memoized selector + dispatcher for collapse state.
 * The Card component reads state.collapsed directly for render; this hook is
 * for callers that need a stable closure over `isCollapsed` / `toggle`.
 */
export function useCollapseState(
  state: CardTreeState,
  dispatch: Dispatch<CardTreeAction>,
) {
  const isCollapsed = useCallback(
    (id: string) => state.collapsed.has(id),
    [state.collapsed],
  );

  const toggle = useCallback(
    (id: string) => dispatch({ type: "toggle-collapse", id }),
    [dispatch],
  );

  return useMemo(() => ({ isCollapsed, toggle }), [isCollapsed, toggle]);
}
src/registry/components/data/card-tree/hooks/use-dirty.ts
import type { CardTreeState } from "../lib/reducer";

/**
 * Pure selector for dirty state. Counter-based (every commit-action increments
 * `version`; mark-clean snapshots `cleanVersion`). Cheap and predictable.
 *
 * v0.4 will upgrade to structural-diff dirty tracking once undo/redo lands.
 */
export function isDirty(state: CardTreeState): boolean {
  return state.version !== state.cleanVersion;
}
src/registry/components/data/card-tree/hooks/use-dnd-config.ts
"use client";

import {
  KeyboardSensor,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
  type CollisionDetection,
} from "@dnd-kit/core";

const DRAG_DISTANCE_THRESHOLD = 6; // px before drag activates

/**
 * Configures @dnd-kit sensors with a small drag-distance threshold so that
 * single-clicks (used for entering edit mode) don't accidentally trigger drag.
 */
export function useDndSensors() {
  return useSensors(
    useSensor(PointerSensor, {
      activationConstraint: { distance: DRAG_DISTANCE_THRESHOLD },
    }),
    useSensor(KeyboardSensor),
  );
}

/**
 * Default collision detection: closest-center against all draggable rectangles.
 * Refinement (between-siblings vs into-card) happens at the drop-event handler.
 */
export const collisionStrategy: CollisionDetection = closestCenter;
src/registry/components/data/card-tree/hooks/use-edit-mode.ts
import { useCallback, useState } from "react";
import type { PredefinedKey } from "../types";

/**
 * Transient UI state: what's currently being edited.
 *
 * Lives outside the reducer so undo/redo (v0.4) won't replay edit-mode
 * focus. Each card / field / block owns its own input state internally
 * via local useState; this hook just flags WHICH thing is in edit mode.
 */

export type EditMode =
  | { kind: "field-value"; cardId: string; key: string }
  | { kind: "field-key"; cardId: string; key: string }
  | { kind: "card-title"; cardId: string }
  // `key` is widened from PredefinedKey in v0.6: a custom block's key is an
  // arbitrary registered name. It was already being written here via an
  // `as PredefinedKey` assertion, which compiled but was a lie the moment
  // custom keys became real.
  | { kind: "predefined"; cardId: string; key: PredefinedKey | string }
  | { kind: "field-add"; cardId: string }
  | { kind: "predefined-add"; cardId: string };

export function useEditMode() {
  const [mode, setMode] = useState<EditMode | null>(null);

  const clear = useCallback(() => setMode(null), []);

  const isEditing = useCallback(
    (target: EditMode): boolean => {
      if (!mode) return false;
      if (mode.kind !== target.kind) return false;
      if (mode.cardId !== target.cardId) return false;
      if ("key" in mode && "key" in target && mode.key !== target.key)
        return false;
      return true;
    },
    [mode],
  );

  return { mode, setMode, clear, isEditing };
}
src/registry/components/data/card-tree/hooks/use-permissions.ts
import { useCallback, useMemo } from "react";
import type {
  EffectivePermissions,
  PermissionDenialReason,
  PermissionRule,
  CardTreePermissions,
} from "../types";
import {
  computeAncestorMap,
  computeLockedIds,
  resolveEffectivePermissions,
  type PermissionAction,
  type PermissionContext,
  type PermissionTarget,
} from "../lib/permissions";
import type { ParsedCardTree } from "../lib/parse";

export type PermissionPredicates = {
  canEditField?: (cardId: string, key: string) => boolean;
  canAddField?: (cardId: string) => boolean;
  canRemoveField?: (cardId: string, key: string) => boolean;
  canEditCard?: (cardId: string) => boolean;
  canAddCard?: (parentId: string) => boolean;
  canRemoveCard?: (cardId: string) => boolean;
  canEditPredefined?: (cardId: string, key: string) => boolean;
  canAddPredefined?: (cardId: string, key: string) => boolean;
  canRemovePredefined?: (cardId: string, key: string) => boolean;
  canDragCard?: (cardId: string) => boolean;
  canDropCard?: (cardId: string, targetParentId: string) => boolean;
};

/**
 * Returns memoized helpers that resolve effective permissions for any (action, target).
 * Predicate predicates take precedence over declarative permissions.
 */
export function usePermissions(
  tree: ParsedCardTree,
  editable: boolean,
  permissions: CardTreePermissions | undefined,
  predicates: PermissionPredicates,
  onDenied:
    | ((
        action: keyof PermissionRule,
        cardId: string,
        target: string | undefined,
        reason: PermissionDenialReason,
      ) => void)
    | undefined,
) {
  const metaLockedIds = useMemo(() => computeLockedIds(tree), [tree]);
  const ancestorMap = useMemo(() => computeAncestorMap(tree), [tree]);

  const ctx: PermissionContext = useMemo(
    () => ({ editable, permissions, metaLockedIds, ancestorMap }),
    [editable, permissions, metaLockedIds, ancestorMap],
  );

  const getEffective = useCallback(
    (target: PermissionTarget): EffectivePermissions => {
      return resolveEffectivePermissions(ctx, target);
    },
    [ctx],
  );

  const check = useCallback(
    (action: PermissionAction, target: PermissionTarget): boolean => {
      const eff = resolveEffectivePermissions(ctx, target);
      const allowed = eff[action] !== false;
      if (!allowed && onDenied) {
        onDenied(action, target.cardId, target.key, eff.reason ?? "default");
      }
      return allowed;
    },
    [ctx, onDenied],
  );

  // Public convenience wrappers
  const canEditField = useCallback(
    (cardId: string, key: string) => {
      if (predicates.canEditField) {
        const r = predicates.canEditField(cardId, key);
        if (!r && onDenied) onDenied("edit", cardId, key, "predicate");
        return r;
      }
      return check("edit", { cardId, level: 0, kind: "field", key });
    },
    [check, predicates, onDenied],
  );

  const canAddField = useCallback(
    (cardId: string) => {
      if (predicates.canAddField) {
        const r = predicates.canAddField(cardId);
        if (!r && onDenied) onDenied("add", cardId, undefined, "predicate");
        return r;
      }
      return check("add", { cardId, level: 0, kind: "field" });
    },
    [check, predicates, onDenied],
  );

  const canRemoveField = useCallback(
    (cardId: string, key: string) => {
      if (predicates.canRemoveField) {
        const r = predicates.canRemoveField(cardId, key);
        if (!r && onDenied) onDenied("remove", cardId, key, "predicate");
        return r;
      }
      return check("remove", { cardId, level: 0, kind: "field", key });
    },
    [check, predicates, onDenied],
  );

  const canEditCard = useCallback(
    (cardId: string) => {
      if (predicates.canEditCard) {
        const r = predicates.canEditCard(cardId);
        if (!r && onDenied) onDenied("edit", cardId, undefined, "predicate");
        return r;
      }
      return check("edit", { cardId, level: 0, kind: "card" });
    },
    [check, predicates, onDenied],
  );

  const canAddCard = useCallback(
    (parentId: string) => {
      if (predicates.canAddCard) {
        const r = predicates.canAddCard(parentId);
        if (!r && onDenied) onDenied("add", parentId, undefined, "predicate");
        return r;
      }
      return check("add", { cardId: parentId, level: 0, kind: "card" });
    },
    [check, predicates, onDenied],
  );

  const canRemoveCard = useCallback(
    (cardId: string) => {
      if (predicates.canRemoveCard) {
        const r = predicates.canRemoveCard(cardId);
        if (!r && onDenied) onDenied("remove", cardId, undefined, "predicate");
        return r;
      }
      return check("remove", { cardId, level: 0, kind: "card" });
    },
    [check, predicates, onDenied],
  );

  const canEditPredefined = useCallback(
    (cardId: string, key: string) => {
      if (predicates.canEditPredefined) {
        const r = predicates.canEditPredefined(cardId, key);
        if (!r && onDenied) onDenied("edit", cardId, key, "predicate");
        return r;
      }
      return check("edit", { cardId, level: 0, kind: "predefined", key });
    },
    [check, predicates, onDenied],
  );

  const canAddPredefined = useCallback(
    (cardId: string, key: string) => {
      if (predicates.canAddPredefined) return predicates.canAddPredefined(cardId, key);
      return check("add", { cardId, level: 0, kind: "predefined", key });
    },
    [check, predicates],
  );

  const canRemovePredefined = useCallback(
    (cardId: string, key: string) => {
      if (predicates.canRemovePredefined) return predicates.canRemovePredefined(cardId, key);
      return check("remove", { cardId, level: 0, kind: "predefined", key });
    },
    [check, predicates],
  );

  const canDragCard = useCallback(
    (cardId: string) => {
      if (predicates.canDragCard) return predicates.canDragCard(cardId);
      return check("reorder", { cardId, level: 0, kind: "card" });
    },
    [check, predicates],
  );

  const canDropCard = useCallback(
    (cardId: string, targetParentId: string) => {
      if (predicates.canDropCard) return predicates.canDropCard(cardId, targetParentId);
      return check("reparent", { cardId, level: 0, kind: "card" });
    },
    [check, predicates],
  );

  const isLocked = useCallback(
    (cardId: string) => metaLockedIds.has(cardId),
    [metaLockedIds],
  );

  return {
    canEditField,
    canAddField,
    canRemoveField,
    canEditCard,
    canAddCard,
    canRemoveCard,
    canEditPredefined,
    canAddPredefined,
    canRemovePredefined,
    canDragCard,
    canDropCard,
    isLocked,
    getEffective,
  };
}

export type UsePermissionsReturn = ReturnType<typeof usePermissions>;
src/registry/components/data/card-tree/hooks/use-search.ts
import { useEffect, useMemo, useRef } from "react";
import type { Dispatch } from "react";
import type {
  CustomPredefinedKey,
  SearchOptions,
  SearchResult,
} from "../types";
import { searchTree } from "../lib/search";
import { findAncestorIds, type CardTreeAction, type CardTreeState } from "../lib/reducer";

/**
 * Wires the controlled `search` prop through the reducer:
 *   - On query change → search the tree, dispatch expand-path-to-matches
 *   - Returns the SearchResult for downstream consumers
 *   - Also tracks the active match index in reducer state
 */
export function useSearch(
  state: CardTreeState,
  dispatch: Dispatch<CardTreeAction>,
  options: SearchOptions | undefined,
  customKeys: readonly CustomPredefinedKey[] | undefined,
  onSearchResults: ((result: SearchResult) => void) | undefined,
): SearchResult {
  const result = useMemo(() => {
    if (!options || !options.query) {
      return {
        matches: [],
        matchedCardIds: new Set<string>(),
        activeIndex: null,
      };
    }
    return searchTree(state.tree, options, {
      customKeys: customKeys as CustomPredefinedKey[] | undefined,
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    state.tree,
    options?.query,
    options?.caseSensitive,
    options?.matchTitles,
    options?.matchKeys,
    options?.matchValues,
    options?.matchPredefined,
    options?.matchMeta,
    customKeys,
  ]);

  // Sync query into reducer (so collapse-state preservation works)
  const lastQueryRef = useRef("");
  useEffect(() => {
    const query = options?.query ?? "";
    if (query !== lastQueryRef.current) {
      lastQueryRef.current = query;
      dispatch({ type: "set-search-query", query });
    }
  }, [options?.query, dispatch]);

  // Sync expand-path-to-matches when results change
  useEffect(() => {
    if (result.matchedCardIds.size === 0) return;
    const ancestorIds = new Set<string>();
    for (const cardId of result.matchedCardIds) {
      const ancestors = findAncestorIds(state.tree, cardId);
      for (const a of ancestors) ancestorIds.add(a);
    }
    if (ancestorIds.size > 0) {
      dispatch({
        type: "expand-path-to-matches",
        ancestorIds: Array.from(ancestorIds),
      });
    }
  }, [result.matchedCardIds, state.tree, dispatch]);

  // Active index lives in reducer state (so it survives unrelated re-renders)
  // Sync the result.activeIndex with state.searchActiveIndex
  const enrichedResult = useMemo(
    () => ({
      ...result,
      activeIndex:
        state.searchActiveIndex !== null && state.searchActiveIndex < result.matches.length
          ? state.searchActiveIndex
          : result.matches.length > 0
            ? 0
            : null,
    }),
    [result, state.searchActiveIndex],
  );

  // Fire onSearchResults callback when the result identity changes
  const lastResultRef = useRef<SearchResult | null>(null);
  useEffect(() => {
    if (lastResultRef.current !== enrichedResult && onSearchResults) {
      onSearchResults(enrichedResult);
    }
    lastResultRef.current = enrichedResult;
  }, [enrichedResult, onSearchResults]);

  return enrichedResult;
}
src/registry/components/data/card-tree/hooks/use-selection.ts
import { useCallback, type Dispatch } from "react";
import type {
  CardTreeAction,
  CardTreeState,
} from "../lib/reducer";

/**
 * Stable selector + dispatcher for selection state.
 *
 * Selection is click-driven, distinct from focus (which is keyboard-driven).
 * Single-select only in v0.2. Multi-select arrives in v0.3.
 */
export function useSelection(
  state: CardTreeState,
  dispatch: Dispatch<CardTreeAction>,
) {
  const select = useCallback(
    (id: string | null) => {
      dispatch({ type: "set-selection", id });
    },
    [dispatch],
  );

  const isSelected = useCallback(
    (id: string) => state.selectedId === id,
    [state.selectedId],
  );

  return {
    selectedId: state.selectedId,
    select,
    isSelected,
  };
}
src/registry/components/data/card-tree/hooks/use-tree-focus.ts
import { useMemo } from "react";
import type { CardTreeState } from "../lib/reducer";
import { visibleIdsInOrder } from "../lib/reducer";

/**
 * Returns the ordered list of card ids currently visible in the tree, plus
 * helpers for prev/next/parent traversal. Recomputes only when the tree or
 * collapse set changes.
 */
export function useTreeFocus(state: CardTreeState) {
  return useMemo(() => {
    const visible = visibleIdsInOrder(state.tree, state.collapsed);
    const indexById = new Map<string, number>();
    visible.forEach((id, i) => indexById.set(id, i));

    return {
      visible,
      first: visible[0] ?? null,
      last: visible[visible.length - 1] ?? null,
      next: (id: string | null): string | null => {
        if (id === null) return visible[0] ?? null;
        const i = indexById.get(id);
        if (i === undefined) return visible[0] ?? null;
        return visible[i + 1] ?? null;
      },
      prev: (id: string | null): string | null => {
        if (id === null) return visible[visible.length - 1] ?? null;
        const i = indexById.get(id);
        if (i === undefined) return visible[0] ?? null;
        return visible[i - 1] ?? null;
      },
    };
  }, [state.tree, state.collapsed]);
}
src/registry/components/data/card-tree/hooks/use-tree-keyboard.ts
import { useCallback, type Dispatch, type KeyboardEvent } from "react";
import type {
  CardTreeAction,
  CardTreeState,
} from "../lib/reducer";
import { findCard, findParentId } from "../lib/reducer";
import { useTreeFocus } from "./use-tree-focus";
import type { EditMode } from "./use-edit-mode";

/**
 * Returns an `onKeyDown` handler for the tree root.
 *
 * Keyboard contract (per plan §11):
 *   - ↑ / ↓        prev / next visible card
 *   - →            collapsed → expand; expanded with kids → focus first child
 *   - ←            expanded → collapse; collapsed/leaf → focus parent
 *   - Home / End   first / last visible
 *   - Enter / Space toggle collapse on focused card
 */
export function useTreeKeyboard(
  state: CardTreeState,
  dispatch: Dispatch<CardTreeAction>,
  editMode: EditMode | null,
) {
  const focus = useTreeFocus(state);

  const focusId = useCallback(
    (id: string | null, root: HTMLElement | null) => {
      if (!id) return;
      dispatch({ type: "set-focus", id });
      // After state commit, move DOM focus.
      requestAnimationFrame(() => {
        const el = root?.querySelector<HTMLElement>(`[data-rcid="${id}"]`);
        el?.focus();
      });
    },
    [dispatch],
  );

  return useCallback(
    (event: KeyboardEvent<HTMLElement>) => {
      // When an editor is active, don't process tree-keyboard nav — keystrokes
      // belong to the active input.
      if (editMode) return;
      const root = event.currentTarget;
      const focused = state.focusedId;

      switch (event.key) {
        case "ArrowDown": {
          event.preventDefault();
          focusId(focus.next(focused), root);
          return;
        }
        case "ArrowUp": {
          event.preventDefault();
          focusId(focus.prev(focused), root);
          return;
        }
        case "Home": {
          event.preventDefault();
          focusId(focus.first, root);
          return;
        }
        case "End": {
          event.preventDefault();
          focusId(focus.last, root);
          return;
        }
        case "ArrowRight": {
          if (!focused) return;
          event.preventDefault();
          const card = findCard(state.tree, focused);
          if (!card) return;
          const isCollapsed = state.collapsed.has(focused);
          const hasChildren = card.children.length > 0;
          const hasBody =
            card.fields.length > 0 || card.predefined.length > 0;
          const canCollapse = hasBody || hasChildren;
          if (canCollapse && isCollapsed) {
            // Expand body and/or children.
            dispatch({ type: "toggle-collapse", id: focused });
          } else if (hasChildren && !isCollapsed) {
            // Already expanded with children — descend.
            focusId(card.children[0]?.id ?? null, root);
          }
          return;
        }
        case "ArrowLeft": {
          if (!focused) return;
          event.preventDefault();
          const card = findCard(state.tree, focused);
          if (!card) return;
          const isCollapsed = state.collapsed.has(focused);
          const hasChildren = card.children.length > 0;
          const hasBody =
            card.fields.length > 0 || card.predefined.length > 0;
          const canCollapse = hasBody || hasChildren;
          if (canCollapse && !isCollapsed) {
            // Expanded — collapse it (body + children).
            dispatch({ type: "toggle-collapse", id: focused });
          } else {
            // Collapsed or empty — ascend.
            const parentId = findParentId(state.tree, focused);
            if (parentId) focusId(parentId, root);
          }
          return;
        }
        case "Enter":
        case " ": {
          if (!focused) return;
          const card = findCard(state.tree, focused);
          if (!card) return;
          const canCollapse =
            card.fields.length > 0 ||
            card.predefined.length > 0 ||
            card.children.length > 0;
          if (canCollapse) {
            event.preventDefault();
            dispatch({ type: "toggle-collapse", id: focused });
          }
          return;
        }
      }
    },
    [state.focusedId, state.tree, state.collapsed, dispatch, focus, focusId, editMode],
  );
}
src/registry/components/data/card-tree/hooks/use-undo.ts
"use client";

import { useCallback, useEffect, type Dispatch } from "react";
import type { CardTreeAction, CardTreeState } from "../lib/reducer";

export function useUndo(
  state: CardTreeState,
  dispatch: Dispatch<CardTreeAction>,
  options: {
    enableShortcuts?: boolean;
    rootRef?: { current: HTMLElement | null };
    onUndo?: () => void;
    onRedo?: () => void;
  } = {},
) {
  const canUndo = state.undoStack.length > 0;
  const canRedo = state.redoStack.length > 0;

  const undo = useCallback((): boolean => {
    if (state.undoStack.length === 0) return false;
    dispatch({ type: "undo" });
    options.onUndo?.();
    return true;
  }, [state.undoStack.length, dispatch, options]);

  const redo = useCallback((): boolean => {
    if (state.redoStack.length === 0) return false;
    dispatch({ type: "redo" });
    options.onRedo?.();
    return true;
  }, [state.redoStack.length, dispatch, options]);

  const clearHistory = useCallback(() => {
    dispatch({ type: "clear-history" });
  }, [dispatch]);

  // Keyboard shortcuts: Cmd/Ctrl+Z (undo), Cmd/Ctrl+Shift+Z (redo), Cmd/Ctrl+Y (redo)
  useEffect(() => {
    if (options.enableShortcuts === false) return;
    const root = options.rootRef?.current;
    if (!root) return;
    const handler = (e: KeyboardEvent) => {
      // Don't intercept if focus is in an input / textarea / contenteditable
      const t = e.target as HTMLElement | null;
      if (
        t &&
        (t.tagName === "INPUT" ||
          t.tagName === "TEXTAREA" ||
          t.isContentEditable)
      ) {
        return;
      }
      const isMod = e.metaKey || e.ctrlKey;
      if (!isMod) return;
      const key = e.key.toLowerCase();
      if (key === "z" && !e.shiftKey) {
        e.preventDefault();
        undo();
      } else if ((key === "z" && e.shiftKey) || key === "y") {
        e.preventDefault();
        redo();
      }
    };
    root.addEventListener("keydown", handler);
    return () => root.removeEventListener("keydown", handler);
  }, [options.enableShortcuts, options.rootRef, undo, redo]);

  return { canUndo, canRedo, undo, redo, clearHistory };
}
src/registry/components/data/card-tree/lib/bulk-actions.ts
/**
 * Pure helpers for bulk operations: deep-clone with fresh ids, deepest-first
 * sort for safe bulk-removal, parent-key generation for moved cards.
 */

import type { FlatFieldValue } from "../types";
import type { ParsedCardTree } from "./parse";

/**
 * Returns a depth map (id → traversal-order index) so callers can sort
 * card-ids deepest-first before bulk-removal.
 */
export function buildDepthMap(tree: ParsedCardTree): Map<string, number> {
  const out = new Map<string, number>();
  walk(tree, 0, out);
  return out;
}

function walk(node: ParsedCardTree, depth: number, out: Map<string, number>): void {
  out.set(node.id, depth);
  for (const c of node.children) walk(c, depth + 1, out);
}

/**
 * Returns true if any of `ids` is an ancestor of any other id in the same set.
 * Used to deduplicate selection: when an ancestor is selected, descendants
 * shouldn't be acted on independently.
 */
export function deduplicateSelection(
  tree: ParsedCardTree,
  ids: readonly string[],
): string[] {
  const set = new Set(ids);
  const out: string[] = [];
  for (const id of ids) {
    let hasAncestorSelected = false;
    const ancestorIds = collectAncestors(tree, id);
    for (const a of ancestorIds) {
      if (set.has(a)) {
        hasAncestorSelected = true;
        break;
      }
    }
    if (!hasAncestorSelected) out.push(id);
  }
  return out;
}

function collectAncestors(tree: ParsedCardTree, id: string): string[] {
  const out: string[] = [];
  walkAncestors(tree, id, [], out);
  return out;
}

function walkAncestors(
  node: ParsedCardTree,
  targetId: string,
  path: string[],
  out: string[],
): boolean {
  if (node.id === targetId) {
    out.push(...path);
    return true;
  }
  for (const c of node.children) {
    if (walkAncestors(c, targetId, [...path, node.id], out)) return true;
  }
  return false;
}

/* ───────── selection stats (for bulk-toolbar display) ───────── */

export type SelectionStats = {
  count: number;
  levelMin: number;
  levelMax: number;
  totalFields: number;
  totalPredefined: number;
  totalChildren: number;
  lockedCount: number;
};

export function computeSelectionStats(
  tree: ParsedCardTree,
  ids: ReadonlySet<string>,
): SelectionStats {
  const stats: SelectionStats = {
    count: 0,
    levelMin: Infinity,
    levelMax: 0,
    totalFields: 0,
    totalPredefined: 0,
    totalChildren: 0,
    lockedCount: 0,
  };
  walkStats(tree, ids, stats);
  if (stats.levelMin === Infinity) stats.levelMin = 0;
  return stats;
}

function walkStats(
  node: ParsedCardTree,
  ids: ReadonlySet<string>,
  stats: SelectionStats,
): void {
  if (ids.has(node.id)) {
    stats.count += 1;
    stats.levelMin = Math.min(stats.levelMin, node.level);
    stats.levelMax = Math.max(stats.levelMax, node.level);
    stats.totalFields += node.fields.length;
    stats.totalPredefined += node.predefined.length;
    stats.totalChildren += node.children.length;
    if (node.meta?.locked === true) stats.lockedCount += 1;
  }
  for (const c of node.children) walkStats(c, ids, stats);
}

/* ───────── bulk-edit field application ───────── */

export type BulkEditFieldArgs = {
  cardIds: readonly string[];
  key: string;
  value: FlatFieldValue;
  /**
   * 'set'   — set the field on every card (creates if missing)
   * 'unset' — remove the field from every card if present
   */
  mode: "set" | "unset";
};
src/registry/components/data/card-tree/lib/classify-key.ts
/**
 * Pure key router. Decides which bucket a JSON property belongs in:
 *   - 'reserved'    — a __rc* key handled out-of-band (skipped before this is called)
 *   - 'predefined'  — a known predefined-key (codearea/image/table/quote/list) not opted-out
 *   - 'custom'      — a host-registered custom predefined-key (v0.6, `customPredefinedKeys`)
 *   - 'field'       — a flat scalar field (or a predefined-key the developer opted out of)
 *   - 'child'       — a child card (parser will reject arrays here, per Q-P4)
 *
 * PRECEDENCE (v0.6 — load-bearing, see plan-v0.3 §9 + the 0.5.0 "custom keys are
 * inert" report): reserved → built-in → **custom** → scalar → object/array.
 *
 * Custom keys are matched by NAME, before the value is inspected at all. That is
 * what makes an array-valued custom block possible: the `child` route rejects
 * arrays (Q-P4, correct for ordinary children), so a value-shape-first router
 * could never reach a registered array key. Q-P4 is untouched for non-registered
 * keys.
 *
 * Built-ins win over custom registrations; `resolveCustomKeys` drops colliding
 * names at mount, so by the time a name reaches here the two sets are disjoint.
 */

import {
  PREDEFINED_KEYS,
  RESERVED_KEYS,
  type PredefinedKey,
} from "../types";

export type KeyClassification =
  | "reserved"
  | "predefined"
  | "custom"
  | "field"
  | "child";

export function classifyKey(
  key: string,
  value: unknown,
  disabledPredefinedKeys: readonly PredefinedKey[],
  /** Resolved custom-key names (see `resolveCustomKeys`). Empty = v0.5 behavior. */
  customKeyNames: readonly string[] = [],
): KeyClassification {
  if ((RESERVED_KEYS as readonly string[]).includes(key)) return "reserved";

  if ((PREDEFINED_KEYS as readonly string[]).includes(key)) {
    const predefined = key as PredefinedKey;
    if (disabledPredefinedKeys.includes(predefined)) return "field";
    return "predefined";
  }

  // Registered custom blocks are matched by NAME, before any value inspection —
  // this is the branch that unblocks array-valued blocks (Plate Value, editor.js).
  if (customKeyNames.includes(key)) return "custom";

  // Scalars are flat fields.
  if (
    value === null ||
    typeof value === "string" ||
    typeof value === "number" ||
    typeof value === "boolean"
  ) {
    return "field";
  }

  // Objects + arrays are children. The parser rejects arrays
  // (per Q-P4: arrays of objects break round-trip; use object-keyed children).
  if (typeof value === "object") return "child";

  // undefined, function, symbol, bigint — treat as field; type-inference will null out.
  return "field";
}
src/registry/components/data/card-tree/lib/custom-keys.ts
/**
 * Mount-time resolution of `customPredefinedKeys` (v0.6).
 *
 * Registration is mount-only and last-write-loses is NOT the policy: a name that
 * collides with a built-in or reserved key is rejected outright, and duplicate
 * registrations keep the first. Both cases emit a diagnostic — the 0.5.0 failure
 * mode this closes was silence, so every rejection must be *loud*.
 *
 * Pure: returns diagnostics rather than logging, so parse/classify stay
 * side-effect free and the caller (card-tree.tsx) owns the single console.error.
 */

import {
  PREDEFINED_KEYS,
  RESERVED_KEYS,
  type CustomPredefinedKey,
} from "../types";

export type CustomKeyDiagnostic = {
  key: string;
  code: "collides-with-predefined" | "collides-with-reserved" | "duplicate" | "empty-key";
  message: string;
};

export type ResolvedCustomKeys = {
  /** Accepted registrations, in registration order. */
  keys: readonly CustomPredefinedKey[];
  /** Accepted key names — the cheap lookup `classifyKey` takes. */
  names: readonly string[];
  diagnostics: readonly CustomKeyDiagnostic[];
};

/**
 * Shared no-registrations result. Frozen (arrays included) because it is handed
 * out by reference to every caller — an in-place push by future code would leak
 * one tree's registrations into every other CardTree on the page.
 */
const EMPTY: ResolvedCustomKeys = Object.freeze({
  keys: Object.freeze([]),
  names: Object.freeze([]),
  diagnostics: Object.freeze([]),
});

export function resolveCustomKeys(
  registered: readonly CustomPredefinedKey[] | undefined,
): ResolvedCustomKeys {
  if (!registered || registered.length === 0) return EMPTY;

  const keys: CustomPredefinedKey[] = [];
  const names: string[] = [];
  const diagnostics: CustomKeyDiagnostic[] = [];
  const seen = new Set<string>();

  for (const entry of registered) {
    const key = entry?.key;

    if (typeof key !== "string" || key.trim().length === 0) {
      diagnostics.push({
        key: String(key),
        code: "empty-key",
        message: "custom predefined-key registered with an empty name; dropped",
      });
      continue;
    }

    if ((RESERVED_KEYS as readonly string[]).includes(key)) {
      diagnostics.push({
        key,
        code: "collides-with-reserved",
        message: `custom predefined-key "${key}" collides with a reserved key (${RESERVED_KEYS.join(", ")}); dropped`,
      });
      continue;
    }

    if ((PREDEFINED_KEYS as readonly string[]).includes(key)) {
      diagnostics.push({
        key,
        code: "collides-with-predefined",
        message: `custom predefined-key "${key}" collides with a built-in predefined key (${PREDEFINED_KEYS.join(", ")}); dropped — the built-in wins`,
      });
      continue;
    }

    if (seen.has(key)) {
      diagnostics.push({
        key,
        code: "duplicate",
        message: `custom predefined-key "${key}" registered more than once; keeping the first registration`,
      });
      continue;
    }

    seen.add(key);
    keys.push(entry);
    names.push(key);
  }

  return { keys, names, diagnostics };
}

/** Find a resolved registration by name. Linear scan — registries are tiny. */
export function findCustomKey(
  keys: readonly CustomPredefinedKey[],
  name: string,
): CustomPredefinedKey | undefined {
  return keys.find((k) => k.key === name);
}
src/registry/components/data/card-tree/lib/dnd-helpers.ts
/**
 * Pure helpers for drag-drop math.
 * Auto-scroll utilities also live here.
 */

import { findAncestorIds, type ParsedCardTree } from "./reducer";

export type DropPosition = {
  parentId: string;
  order: number;
  /** Whether the drop is "into" the card (last child) or "between" siblings (specific order). */
  kind: "into" | "between";
};

/**
 * Determine if dragging `sourceId` onto `targetId` would create a cycle.
 * (Target is the source itself OR a descendant of source.)
 */
export function wouldCreateCycle(
  tree: ParsedCardTree,
  sourceId: string,
  targetId: string,
): boolean {
  if (sourceId === targetId) return true;
  const ancestorsOfTarget = findAncestorIds(tree, targetId);
  // If source is an ancestor of target, dropping target into source's subtree (or onto source) is fine;
  // but dropping source INTO target's subtree (where target is source's descendant) creates a cycle.
  // So we need to check: is target a descendant of source?
  return ancestorsOfTarget.includes(sourceId);
}

/**
 * Resolve drop intent based on pointer position within a target card's bounding rectangle.
 * Same-level reorder when in upper/lower 25%; cross-level reparent when in middle 50%.
 */
export function resolveDropIntent(args: {
  pointerY: number;
  rectTop: number;
  rectHeight: number;
  upperZoneRatio?: number;
  lowerZoneRatio?: number;
}): "before" | "after" | "into" {
  const upper = args.upperZoneRatio ?? 0.25;
  const lower = args.lowerZoneRatio ?? 0.75;
  const relative = (args.pointerY - args.rectTop) / args.rectHeight;
  if (relative < upper) return "before";
  if (relative > lower) return "after";
  return "into";
}

/**
 * Returns the auto-scroll velocity for a pointer near the edges of a scrollable container.
 * Returns 0 when not in the auto-scroll zone.
 *
 * Velocity scales linearly within the trigger zone (top/bottom 60px by default).
 */
export function autoScrollVelocity(args: {
  pointerY: number;
  containerTop: number;
  containerBottom: number;
  triggerZone?: number;
  maxVelocity?: number;
}): number {
  const zone = args.triggerZone ?? 60;
  const maxV = args.maxVelocity ?? 12;

  const distFromTop = args.pointerY - args.containerTop;
  const distFromBottom = args.containerBottom - args.pointerY;

  if (distFromTop < zone && distFromTop >= 0) {
    // Negative velocity = scroll up
    return -maxV * (1 - distFromTop / zone);
  }
  if (distFromBottom < zone && distFromBottom >= 0) {
    return maxV * (1 - distFromBottom / zone);
  }
  return 0;
}

/**
 * Animation-frame-driven scroll loop. Call to start; call the returned cleanup to stop.
 */
export function startAutoScroll(
  el: HTMLElement,
  getVelocity: () => number,
): () => void {
  let raf = 0;
  const tick = () => {
    const v = getVelocity();
    if (v !== 0) {
      el.scrollBy({ top: v });
    }
    raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);
}
src/registry/components/data/card-tree/lib/infer-type.ts
/**
 * Pure scalar type inference for flat fields.
 *
 * v0.1 supports five field types:
 *   string | number | boolean | date | null
 *
 * `date` is a string-subtype: an ISO-8601 string that ALSO parses to a real Date.
 * The dual check (regex + Date.parse) rules out false positives like "2026-12-99".
 */

export type FlatFieldType = "string" | "number" | "boolean" | "date" | "null";

export type DateDetection =
  | "auto"
  | "never"
  | ((value: string) => boolean);

const ISO_8601 =
  /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/;

/**
 * Returns the resolved `FlatFieldType` for a JSON-scalar value, or `null`
 * if the value isn't a supported scalar (caller should error).
 */
export function inferFlatFieldType(
  value: unknown,
  dateDetection: DateDetection,
): FlatFieldType | null {
  if (value === null) return "null";
  if (typeof value === "boolean") return "boolean";
  if (typeof value === "number") return Number.isFinite(value) ? "number" : "string";
  if (typeof value === "string") return inferStringType(value, dateDetection);
  return null;
}

function inferStringType(
  value: string,
  dateDetection: DateDetection,
): FlatFieldType {
  if (dateDetection === "never") return "string";
  if (typeof dateDetection === "function") {
    return dateDetection(value) ? "date" : "string";
  }
  // 'auto': pattern match + real-date validation
  if (ISO_8601.test(value) && !Number.isNaN(Date.parse(value))) return "date";
  return "string";
}
src/registry/components/data/card-tree/lib/parse.ts
/**
 * Pure parser: input `CardTreeJsonNode` → normalized internal `ParsedCardTree`.
 *
 * Errors are collected into an array (not thrown), so the caller can render
 * a best-effort tree alongside diagnostics.
 *
 * See plan §5.1 for the full algorithm.
 */

import type {
  CodeAreaValue,
  CustomPredefinedKey,
  FlatFieldValue,
  ImageValue,
  ListValue,
  PredefinedKey,
  QuoteValue,
  CardTreeJsonNode,
  TableValue,
} from "../types";
import { classifyKey } from "./classify-key";
import { findCustomKey } from "./custom-keys";
import {
  inferFlatFieldType,
  type DateDetection,
  type FlatFieldType,
} from "./infer-type";

/* ───────── internal tree types ───────── */

export type CardTreeField = {
  key: string;
  value: FlatFieldValue;
  type: FlatFieldType;
};

/**
 * A host-registered custom block (v0.6). `value` is deliberately `unknown` and is
 * written back by `serialize` VERBATIM — a custom block's shape is the
 * integrator's contract, not card-tree's.
 *
 * NARROWING CONTRACT: `key` is an open `string`, so a bare `switch (entry.key)`
 * would keep this variant alive in every `case`. Always test `isCustomEntry(entry)`
 * (or `"custom" in entry`) FIRST; the remaining union is then the five built-ins
 * and `switch` narrows as it always did.
 */
export type CardTreeCustomEntry = {
  key: string;
  custom: true;
  value: unknown;
};

export type CardTreePredefinedEntry =
  | { key: "codearea"; value: CodeAreaValue }
  | { key: "image"; value: ImageValue }
  | { key: "table"; value: TableValue }
  | { key: "quote"; value: QuoteValue }
  | { key: "list"; value: ListValue }
  | CardTreeCustomEntry;

/** Narrowing guard for {@link CardTreeCustomEntry} — see its narrowing contract. */
export function isCustomEntry(
  entry: CardTreePredefinedEntry,
): entry is CardTreeCustomEntry {
  return "custom" in entry && entry.custom === true;
}

export type ParsedCardTree = {
  id: string;
  order: number;
  level: number;
  /** Property name on the parent that pointed to this card. Undefined only on root. */
  parentKey?: string;
  meta?: Record<string, FlatFieldValue>;
  fields: CardTreeField[];
  predefined: CardTreePredefinedEntry[];
  children: ParsedCardTree[];
};

export type ParseError = {
  message: string;
  path: string;
};

export type ParseOptions = {
  disabledPredefinedKeys: readonly PredefinedKey[];
  dateDetection: DateDetection;
  /**
   * Resolved custom-key registrations (v0.6) — pass the `keys` from
   * `resolveCustomKeys`, never the raw prop: colliding + duplicate names must
   * already be dropped. Omit for v0.5 behavior.
   */
  customKeys?: readonly CustomPredefinedKey[];
};

export type ParseResult = {
  tree: ParsedCardTree | null;
  errors: ParseError[];
};

/* ───────── helpers ───────── */

/**
 * Deterministic auto-id from a card's tree path, for cards that don't supply an
 * `__rcid`. MUST be deterministic (not a random UUID): parse runs at render-time
 * state-init, so an SSR pass and the client hydration pass both parse the same
 * input tree in the same order — a random id would differ between them and
 * trip a hydration mismatch (`data-rcid` / `aria-labelledby`). Path is unique
 * per node, so these ids are collision-free within a tree. Runtime edits mint
 * random ids elsewhere (client-only, post-hydration — safe).
 */
function autoId(path: string): string {
  const safe = path.replace(/[^a-zA-Z0-9_-]/g, "-");
  return `rc-auto-${safe || "root"}`;
}

function isPlainObject(v: unknown): v is Record<string, unknown> {
  return typeof v === "object" && v !== null && !Array.isArray(v);
}

function pathJoin(parent: string, segment: string | number): string {
  return parent === "" ? String(segment) : `${parent}.${segment}`;
}

/* ───────── meta parsing ───────── */

function parseMeta(
  raw: unknown,
  errors: ParseError[],
  path: string,
): Record<string, FlatFieldValue> | undefined {
  if (raw === undefined) return undefined;
  if (!isPlainObject(raw)) {
    errors.push({
      message: "__rcmeta must be an object of scalar values; ignored",
      path,
    });
    return undefined;
  }
  const out: Record<string, FlatFieldValue> = {};
  for (const k of Object.keys(raw)) {
    const v = raw[k];
    if (
      v === null ||
      typeof v === "string" ||
      typeof v === "number" ||
      typeof v === "boolean"
    ) {
      out[k] = v as FlatFieldValue;
    } else {
      errors.push({
        message: `__rcmeta.${k} must be a scalar; dropped`,
        path,
      });
    }
  }
  return out;
}

/* ───────── predefined-key shape validation ───────── */

function validatePredefinedShape(
  key: PredefinedKey,
  value: unknown,
  errors: ParseError[],
  path: string,
): CardTreePredefinedEntry | null {
  switch (key) {
    case "codearea": {
      if (
        isPlainObject(value) &&
        typeof value.format === "string" &&
        typeof value.content === "string"
      ) {
        return {
          key: "codearea",
          value: { format: value.format, content: value.content },
        };
      }
      errors.push({
        message:
          "codearea must be { format: string, content: string }; entry dropped",
        path,
      });
      return null;
    }
    case "image": {
      if (
        isPlainObject(value) &&
        typeof value.src === "string" &&
        (value.alt === undefined || typeof value.alt === "string")
      ) {
        return {
          key: "image",
          value: { src: value.src, alt: value.alt as string | undefined },
        };
      }
      errors.push({
        message: "image must be { src: string, alt?: string }; entry dropped",
        path,
      });
      return null;
    }
    case "table": {
      if (
        isPlainObject(value) &&
        Array.isArray(value.headers) &&
        value.headers.every((h) => typeof h === "string") &&
        Array.isArray(value.rows) &&
        value.rows.every(
          (row) =>
            Array.isArray(row) &&
            row.length === (value.headers as string[]).length &&
            row.every(
              (c) =>
                c === null ||
                typeof c === "string" ||
                typeof c === "number" ||
                typeof c === "boolean",
            ),
        )
      ) {
        return {
          key: "table",
          value: {
            headers: value.headers as string[],
            rows: value.rows as FlatFieldValue[][],
          },
        };
      }
      errors.push({
        message:
          "table must be { headers: string[], rows: FlatFieldValue[][] } with consistent row widths; entry dropped",
        path,
      });
      return null;
    }
    case "quote": {
      if (typeof value === "string") return { key: "quote", value };
      errors.push({ message: "quote must be a string; entry dropped", path });
      return null;
    }
    case "list": {
      if (
        Array.isArray(value) &&
        value.every(
          (v) =>
            v === null ||
            typeof v === "string" ||
            typeof v === "number" ||
            typeof v === "boolean",
        )
      ) {
        return { key: "list", value: value as FlatFieldValue[] };
      }
      errors.push({
        message: "list must be an array of scalar values; entry dropped",
        path,
      });
      return null;
    }
  }
}

/* ───────── core recursion ───────── */

/**
 * `ParseOptions` plus the custom-key NAME list, derived once per `parseInput`
 * rather than per node — `classifyKey` runs on every property of every card.
 */
type InternalParseOptions = ParseOptions & {
  customNames: readonly string[];
};

function parseNode(
  node: unknown,
  parentKey: string | undefined,
  level: number,
  opts: InternalParseOptions,
  errors: ParseError[],
  path: string,
  seenIds: Set<string>,
): ParsedCardTree | null {
  if (!isPlainObject(node)) {
    errors.push({
      message: "expected an object; found " + (Array.isArray(node) ? "array" : typeof node),
      path,
    });
    return null;
  }

  // identity
  let id =
    typeof node.__rcid === "string" && node.__rcid.length > 0
      ? node.__rcid
      : autoId(path);
  if (seenIds.has(id)) {
    errors.push({
      message: `duplicate __rcid "${id}"; regenerated`,
      path,
    });
    // Path is unique, so the path-derived id deterministically resolves the
    // collision (and stays SSR-stable).
    id = autoId(path);
  }
  seenIds.add(id);

  let order = 0;
  if (node.__rcorder !== undefined) {
    if (typeof node.__rcorder === "number" && Number.isFinite(node.__rcorder)) {
      order = node.__rcorder;
    } else {
      errors.push({
        message: `non-numeric __rcorder coerced to 0`,
        path,
      });
    }
  }

  const meta = parseMeta(node.__rcmeta, errors, path);

  const fields: CardTreeField[] = [];
  const predefined: CardTreePredefinedEntry[] = [];
  const children: ParsedCardTree[] = [];
  const seenSiblingKeys = new Set<string>();

  for (const key of Object.keys(node)) {
    if ((["__rcid", "__rcorder", "__rcmeta"] as const).includes(key as never))
      continue;
    if (seenSiblingKeys.has(key)) {
      errors.push({ message: `duplicate sibling key "${key}"; ignored`, path });
      continue;
    }
    seenSiblingKeys.add(key);

    const value = node[key];
    const childPath = pathJoin(path, key);
    const classification = classifyKey(
      key,
      value,
      opts.disabledPredefinedKeys,
      opts.customNames,
    );

    switch (classification) {
      case "reserved":
        // unreachable in this loop (reserved keys filtered above)
        break;

      case "predefined": {
        const validated = validatePredefinedShape(
          key as PredefinedKey,
          value,
          errors,
          childPath,
        );
        if (validated) predefined.push(validated);
        break;
      }

      case "custom": {
        const registration = opts.customKeys
          ? findCustomKey(opts.customKeys, key)
          : undefined;
        if (!registration) {
          // classifyKey said "custom" but the registration is gone — only
          // reachable if customNames and customKeys were built from different
          // sources. Treat as a field rather than losing the value silently.
          errors.push({
            message: `custom predefined-key "${key}" is classified but has no registration; entry dropped`,
            path: childPath,
          });
          break;
        }

        // Host code runs here. plan-v0.3 §L881: a throwing validator must
        // degrade (drop + warn), never take the tree down.
        let result: { ok: boolean; errors?: { code: string; message: string }[] };
        try {
          result = registration.validate(value);
        } catch {
          errors.push({
            message: `custom predefined-key "${key}" validator threw; entry dropped`,
            path: childPath,
          });
          break;
        }

        if (!result || result.ok !== true) {
          const detail = (result?.errors ?? [])
            .map((e) => `${e.code}: ${e.message}`)
            .join("; ");
          errors.push({
            message: `custom predefined-key "${key}" failed validation${detail ? ` (${detail})` : ""}; entry dropped`,
            path: childPath,
          });
          break;
        }

        // Stored verbatim — serialize writes `entry.value` straight back out,
        // so the round-trip is byte-identical for any JSON shape (I2).
        predefined.push({ key, custom: true, value });
        break;
      }

      case "field": {
        const type = inferFlatFieldType(value, opts.dateDetection);
        if (type === null) {
          errors.push({
            message: `unsupported flat-field value type for key "${key}" (got ${typeof value}); ignored`,
            path: childPath,
          });
        } else {
          fields.push({ key, value: value as FlatFieldValue, type });
        }
        break;
      }

      case "child": {
        if (Array.isArray(value)) {
          errors.push({
            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.`,
            path: childPath,
          });
          break;
        }
        const child = parseNode(
          value,
          key,
          level + 1,
          opts,
          errors,
          childPath,
          seenIds,
        );
        if (child) children.push(child);
        break;
      }
    }
  }

  // Stable sort children by order; tie-break by input order (Array.prototype.sort
  // is stable in all modern engines, including V8 since 7.0).
  children.sort((a, b) => a.order - b.order);

  return {
    id,
    order,
    level,
    parentKey,
    meta,
    fields,
    predefined,
    children,
  };
}

/* ───────── public entry point ───────── */

export function parseInput(
  input: CardTreeJsonNode,
  opts: ParseOptions,
): ParseResult {
  const errors: ParseError[] = [];
  const seenIds = new Set<string>();
  const internal: InternalParseOptions = {
    ...opts,
    customNames: opts.customKeys?.map((k) => k.key) ?? [],
  };
  const tree = parseNode(input, undefined, 1, internal, errors, "", seenIds);
  return { tree, errors };
}
src/registry/components/data/card-tree/lib/permissions.ts
/**
 * Pure permission resolver.
 *
 * Resolution order (most → least specific):
 *   1. Predicate (canEditField, canAddCard, etc.) — overrides everything
 *   2. Per-card meta lock (`__rcmeta.locked === true`) — read-only cascade
 *   3. Per-card override (permissions.byCard[id])
 *   4. Per-predefined-key override (permissions.byPredefinedKey[key])
 *   5. Per-field-type override (permissions.byFieldType[type])
 *   6. Per-level override (permissions.byLevel[level])
 *   7. Default (permissions.default)
 *   8. Global `editable` prop — false denies everything
 */

import type {
  EffectivePermissions,
  PermissionDenialReason,
  PermissionRule,
  CardTreePermissions,
} from "../types";
import type { FlatFieldType } from "./infer-type";
import type { ParsedCardTree } from "./reducer";

export type PermissionAction = "edit" | "add" | "remove" | "reorder" | "reparent";

export type PermissionTarget = {
  cardId: string;
  level: number;
  /** "card" → the card itself; "field" → flat field with optional `key`; "predefined" → predefined entry with `key`. */
  kind: "card" | "field" | "predefined";
  key?: string;
  /** For field-targeted actions, the field's inferred type. */
  fieldType?: FlatFieldType;
};

export type PermissionContext = {
  editable: boolean;
  permissions?: CardTreePermissions;
  metaLockedIds: ReadonlySet<string>;
  ancestorMap: Map<string, readonly string[]>;
};

/* ───────── locked-cards computation (cascading) ───────── */

export function computeLockedIds(tree: ParsedCardTree): Set<string> {
  const out = new Set<string>();
  walkLocked(tree, false, out);
  return out;
}

function walkLocked(node: ParsedCardTree, ancestorLocked: boolean, out: Set<string>): void {
  const selfLocked = node.meta?.locked === true;
  const isLocked = ancestorLocked || selfLocked;
  if (isLocked) out.add(node.id);
  for (const child of node.children) walkLocked(child, isLocked, out);
}

/* ───────── ancestor map (for permission inheritance) ───────── */

export function computeAncestorMap(tree: ParsedCardTree): Map<string, readonly string[]> {
  const out = new Map<string, readonly string[]>();
  walkAncestor(tree, [], out);
  return out;
}

function walkAncestor(
  node: ParsedCardTree,
  path: string[],
  out: Map<string, readonly string[]>,
): void {
  out.set(node.id, path);
  for (const c of node.children) walkAncestor(c, [...path, node.id], out);
}

/* ───────── effective permissions resolver ───────── */

const ALL_DENIED: EffectivePermissions = {
  edit: false,
  add: false,
  remove: false,
  reorder: false,
  reparent: false,
};

const ALL_ALLOWED_DEFAULTS: PermissionRule = {
  edit: true,
  add: true,
  remove: true,
  reorder: true,
  reparent: true,
};

export function resolveEffectivePermissions(
  ctx: PermissionContext,
  target: PermissionTarget,
): EffectivePermissions {
  // 1. Global editable=false → deny all
  if (!ctx.editable) {
    return { ...ALL_DENIED, reason: "global-editable-false" };
  }

  // 2. Meta-lock cascade
  if (ctx.metaLockedIds.has(target.cardId)) {
    return { ...ALL_DENIED, reason: "meta-locked" };
  }

  // Build resolved rule by layering, most-specific overrides win
  let rule: PermissionRule = { ...ALL_ALLOWED_DEFAULTS };
  let reason: PermissionDenialReason | undefined;

  // 7. Default
  if (ctx.permissions?.default) {
    rule = { ...rule, ...ctx.permissions.default };
    reason = "default";
  }

  // 6. Per-level
  if (ctx.permissions?.byLevel) {
    const levelRule = ctx.permissions.byLevel[target.level];
    if (levelRule) {
      rule = { ...rule, ...levelRule };
      reason = "by-level";
    }
  }

  // 6.5 Inheritance — when permissions.inherit is true, walk ancestors and OR-combine "denied" flags
  if (ctx.permissions?.inherit && ctx.permissions.byCard) {
    const ancestors = ctx.ancestorMap.get(target.cardId) ?? [];
    for (const ancestorId of ancestors) {
      const ancestorRule = ctx.permissions.byCard[ancestorId];
      if (ancestorRule) {
        rule = { ...rule, ...maskDenials(rule, ancestorRule) };
      }
    }
  }

  // 5. Per-field-type (only applicable for field-targeted actions)
  if (
    ctx.permissions?.byFieldType &&
    target.kind === "field" &&
    target.fieldType
  ) {
    const fieldTypeRule = ctx.permissions.byFieldType[target.fieldType];
    if (fieldTypeRule) {
      rule = { ...rule, ...fieldTypeRule };
      reason = "by-field-type";
    }
  }

  // 4. Per-predefined-key
  if (
    ctx.permissions?.byPredefinedKey &&
    target.kind === "predefined" &&
    target.key
  ) {
    const keyRule = (
      ctx.permissions.byPredefinedKey as Record<string, PermissionRule>
    )[target.key];
    if (keyRule) {
      rule = { ...rule, ...keyRule };
      reason = "by-predefined-key";
    }
  }

  // 3. Per-card
  if (ctx.permissions?.byCard) {
    const cardRule = ctx.permissions.byCard[target.cardId];
    if (cardRule) {
      rule = { ...rule, ...cardRule };
      reason = "by-card";
    }
  }

  return { ...rule, reason };
}

/** OR-combine denials: if either rule denies, denied. Used for inheritance. */
function maskDenials(base: PermissionRule, ancestor: PermissionRule): PermissionRule {
  const out: PermissionRule = {};
  for (const key of Object.keys(ancestor) as (keyof PermissionRule)[]) {
    if (ancestor[key] === false) out[key] = false;
    else if (base[key] !== undefined) out[key] = base[key];
  }
  return out;
}

/* ───────── per-action helpers (used by predicates) ───────── */

export function canDoAction(
  ctx: PermissionContext,
  action: PermissionAction,
  target: PermissionTarget,
): { allowed: boolean; reason?: PermissionDenialReason } {
  const eff = resolveEffectivePermissions(ctx, target);
  const allowed = eff[action] !== false;
  return { allowed, reason: allowed ? undefined : eff.reason };
}
src/registry/components/data/card-tree/lib/reducer.ts
/**
 * Pure reducer for card-tree state.
 *
 * v0.1 actions: toggle-collapse, set-focus, replace-tree
 * v0.2 actions: 12 (selection, mark-clean, field/card/predefined edit/add/remove)
 * v0.3 actions: 12 (multi-select, drag-transient, card move/duplicate, meta edit/add/remove,
 *                   search query/active-match/clear, expand-path-to-matches)
 *
 * Validation runs OUTSIDE the reducer; the dispatch wrapper validates before dispatch.
 */

import type { FlatFieldValue, PredefinedKey } from "../types";
import type { FlatFieldType } from "./infer-type";
import type { CardTreePredefinedEntry, ParsedCardTree } from "./parse";

export type { CardTreeField, CardTreePredefinedEntry, ParsedCardTree } from "./parse";

/* ───────── state ───────── */

/** v0.4 — undo snapshot. Captures tree + UI state that "undo" should restore. */
export type UndoSnapshot = {
  tree: ParsedCardTree;
  collapsed: ReadonlySet<string>;
  selectedIds: ReadonlySet<string>;
  focusedId: string | null;
};

export type CardTreeState = {
  tree: ParsedCardTree;
  collapsed: ReadonlySet<string>;
  /** v0.3 — preserves user-driven collapse separately from search-auto-expand. */
  userCollapsed: ReadonlySet<string>;
  focusedId: string | null;
  /** v0.2 — single id retained for backward compat; in v0.3 derived from selectedIds[0]. */
  selectedId: string | null;
  /** v0.3 — multi-select set. */
  selectedIds: ReadonlySet<string>;
  /** v0.3 — anchor for shift-click range selection. */
  selectionAnchorId: string | null;
  /** v0.3 — id being dragged (transient). */
  draggingId: string | null;
  /** v0.3 — current search query (empty string = inactive). */
  searchQuery: string;
  /** v0.3 — index into search-result.matches for the currently active match. */
  searchActiveIndex: number | null;
  /** v0.4 — undo / redo stacks (most recent at end). */
  undoStack: ReadonlyArray<UndoSnapshot>;
  redoStack: ReadonlyArray<UndoSnapshot>;
  /** v0.4 — configurable history depth. */
  maxUndoDepth: number;
  version: number;
  cleanVersion: number;
};

/* ───────── actions ───────── */

export type CardTreeAction =
  // v0.1
  | { type: "toggle-collapse"; id: string }
  | { type: "set-focus"; id: string | null }
  | { type: "replace-tree"; tree: ParsedCardTree }
  // v0.2 selection + dirty
  | { type: "set-selection"; id: string | null }
  | { type: "mark-clean" }
  // v0.2 flat fields
  | {
      type: "field-edit-value";
      cardId: string;
      key: string;
      value: FlatFieldValue;
      valueType: FlatFieldType;
    }
  | { type: "field-edit-key"; cardId: string; oldKey: string; newKey: string }
  | {
      type: "field-add";
      cardId: string;
      key: string;
      value: FlatFieldValue;
      valueType: FlatFieldType;
    }
  | { type: "field-remove"; cardId: string; key: string }
  // v0.2 cards
  | { type: "card-add"; parentId: string; card: ParsedCardTree }
  | {
      type: "card-remove";
      cardId: string;
      policy?: "cascade" | "promote";
      collisionStrategy?: "suffix" | "qualify" | "reject";
    }
  | { type: "card-rename"; cardId: string; newParentKey: string }
  // v0.2 predefined
  | { type: "predefined-add"; cardId: string; entry: CardTreePredefinedEntry }
  | {
      type: "predefined-edit";
      cardId: string;
      key: PredefinedKey | string;
      entry: CardTreePredefinedEntry;
    }
  | { type: "predefined-remove"; cardId: string; key: PredefinedKey | string }
  // v0.3 — multi-select
  | { type: "set-multi-selection"; ids: readonly string[]; anchor?: string | null }
  | { type: "toggle-selection"; id: string }
  | { type: "extend-selection-to"; id: string }
  | { type: "clear-selection" }
  // v0.3 — drag-transient
  | { type: "drag-start"; cardId: string }
  | { type: "drag-end" }
  // v0.3 — card movement + duplicate
  | {
      type: "card-move";
      cardId: string;
      newParentId: string;
      newOrder: number;
    }
  | { type: "card-duplicate"; cardId: string; newCardId: string }
  // v0.3 — meta
  | {
      type: "meta-edit";
      cardId: string;
      key: string;
      value: FlatFieldValue;
    }
  | {
      type: "meta-add";
      cardId: string;
      key: string;
      value: FlatFieldValue;
    }
  | { type: "meta-remove"; cardId: string; key: string }
  // v0.3 — bulk
  | { type: "bulk-remove"; cardIds: readonly string[] }
  // v0.3 — search
  | { type: "set-search-query"; query: string }
  | { type: "set-active-match-index"; index: number | null }
  | { type: "clear-search" }
  | { type: "expand-path-to-matches"; ancestorIds: readonly string[] }
  // v0.4 — undo/redo
  | { type: "undo" }
  | { type: "redo" }
  | { type: "clear-history" }
  | { type: "set-max-undo-depth"; depth: number };

/* ───────── initial-state factory ───────── */

export type DefaultCollapsed = "all" | "none" | ((level: number) => boolean);

function collectCollapsedIds(
  tree: ParsedCardTree,
  predicate: DefaultCollapsed,
  out: Set<string>,
): void {
  const shouldCollapse =
    predicate === "all"
      ? true
      : predicate === "none"
        ? false
        : predicate(tree.level);
  if (shouldCollapse) out.add(tree.id);
  for (const child of tree.children) {
    collectCollapsedIds(child, predicate, out);
  }
}

export function createInitialState(
  tree: ParsedCardTree,
  defaultCollapsed: DefaultCollapsed,
  maxUndoDepth = 50,
): CardTreeState {
  const collapsed = new Set<string>();
  collectCollapsedIds(tree, defaultCollapsed, collapsed);
  return {
    tree,
    collapsed,
    userCollapsed: collapsed,
    focusedId: null,
    selectedId: null,
    selectedIds: new Set<string>(),
    selectionAnchorId: null,
    draggingId: null,
    searchQuery: "",
    searchActiveIndex: null,
    undoStack: [],
    redoStack: [],
    maxUndoDepth,
    version: 0,
    cleanVersion: 0,
  };
}

/* ───────── immutable tree helpers ───────── */

function updateNode(
  tree: ParsedCardTree,
  id: string,
  fn: (node: ParsedCardTree) => ParsedCardTree,
): ParsedCardTree {
  if (tree.id === id) return fn(tree);
  if (tree.children.length === 0) return tree;
  let changed = false;
  const next = tree.children.map((c) => {
    const n = updateNode(c, id, fn);
    if (n !== c) changed = true;
    return n;
  });
  return changed ? { ...tree, children: next } : tree;
}

function removeNodeById(
  tree: ParsedCardTree,
  id: string,
): { tree: ParsedCardTree; removed: ParsedCardTree | null; parentId: string | null } {
  const directIdx = tree.children.findIndex((c) => c.id === id);
  if (directIdx >= 0) {
    const removed = tree.children[directIdx];
    return {
      tree: {
        ...tree,
        children: tree.children.filter((_, i) => i !== directIdx),
      },
      removed,
      parentId: tree.id,
    };
  }
  for (let i = 0; i < tree.children.length; i++) {
    const sub = removeNodeById(tree.children[i], id);
    if (sub.removed) {
      const newChildren = [...tree.children];
      newChildren[i] = sub.tree;
      return {
        tree: { ...tree, children: newChildren },
        removed: sub.removed,
        parentId: sub.parentId,
      };
    }
  }
  return { tree, removed: null, parentId: null };
}

function setLevelDeep(node: ParsedCardTree, level: number): ParsedCardTree {
  const next: ParsedCardTree = { ...node, level };
  if (node.children.length === 0) return next;
  next.children = node.children.map((c) => setLevelDeep(c, level + 1));
  return next;
}

/**
 * Duplicating a card must not leave the copy sharing a mutable value with the
 * original. A spread only copies the entry wrapper, so `value` stayed shared by
 * reference — latent for `table.rows`, and much likelier from v0.6 on, where a
 * custom block's value is arbitrary host-owned JSON (a Plate Value array, say)
 * and some editors mutate in place before calling `onSave`. That would corrupt
 * the source card while editing its duplicate.
 *
 * Values are JSON by contract, so a structural clone is valid; anything
 * non-cloneable (a stray function on a host value) falls back to the old
 * shallow behavior rather than throwing.
 */
function cloneEntryValue<T>(value: T): T {
  try {
    return typeof structuredClone === "function"
      ? structuredClone(value)
      : (JSON.parse(JSON.stringify(value)) as T);
  } catch {
    return value;
  }
}

function deepCloneWithFreshIds(node: ParsedCardTree, freshId: () => string): ParsedCardTree {
  return {
    ...node,
    id: freshId(),
    fields: node.fields.map((f) => ({ ...f })),
    predefined: node.predefined.map((p) => ({
      ...p,
      value: cloneEntryValue(p.value),
    })) as ParsedCardTree["predefined"],
    meta: node.meta ? { ...node.meta } : undefined,
    children: node.children.map((c) => deepCloneWithFreshIds(c, freshId)),
  };
}

function freshIdGen(): () => string {
  if (
    typeof globalThis.crypto !== "undefined" &&
    typeof globalThis.crypto.randomUUID === "function"
  ) {
    return () => globalThis.crypto.randomUUID();
  }
  let i = 0;
  return () => `rc-${Date.now().toString(36)}-${(i++).toString(36)}`;
}

/* ───────── promote-on-delete ───────── */

function resolveCollision(
  candidateKey: string,
  existingKeys: Set<string>,
  strategy: "suffix" | "qualify" | "reject",
  qualifyPrefix: string,
): string | null {
  if (!existingKeys.has(candidateKey)) return candidateKey;
  if (strategy === "reject") return null;
  if (strategy === "qualify") {
    let qualified = `${qualifyPrefix}_${candidateKey}`;
    let i = 2;
    while (existingKeys.has(qualified)) {
      qualified = `${qualifyPrefix}_${candidateKey}_${i++}`;
    }
    return qualified;
  }
  // suffix _2 / _3 / ...
  let i = 2;
  while (existingKeys.has(`${candidateKey}_${i}`)) i++;
  return `${candidateKey}_${i}`;
}

/* ───────── reducer ───────── */

/** Take a snapshot of state slots that undo restores. */
function snapshot(state: CardTreeState): UndoSnapshot {
  return {
    tree: state.tree,
    collapsed: state.collapsed,
    selectedIds: state.selectedIds,
    focusedId: state.focusedId,
  };
}

/** After a commit-action, push undo snapshot + clear redo + trim. */
function withUndoBookkeeping(
  prev: CardTreeState,
  next: CardTreeState,
): CardTreeState {
  if (next.version === prev.version) return next; // not a commit
  const undoStack = [...prev.undoStack, snapshot(prev)];
  while (undoStack.length > prev.maxUndoDepth) {
    undoStack.shift();
  }
  return { ...next, undoStack, redoStack: [] };
}

export function reducer(
  state: CardTreeState,
  action: CardTreeAction,
): CardTreeState {
  // v0.4 actions handled before delegating to innerReducer
  if (action.type === "undo") {
    if (state.undoStack.length === 0) return state;
    const snap = state.undoStack[state.undoStack.length - 1];
    const newUndoStack = state.undoStack.slice(0, -1);
    const newRedoStack = [...state.redoStack, snapshot(state)];
    return {
      ...state,
      tree: snap.tree,
      collapsed: snap.collapsed,
      selectedIds: snap.selectedIds,
      selectedId:
        snap.selectedIds.size > 0
          ? Array.from(snap.selectedIds)[0] ?? null
          : null,
      focusedId: snap.focusedId,
      undoStack: newUndoStack,
      redoStack: newRedoStack,
    };
  }
  if (action.type === "redo") {
    if (state.redoStack.length === 0) return state;
    const snap = state.redoStack[state.redoStack.length - 1];
    const newRedoStack = state.redoStack.slice(0, -1);
    const newUndoStack = [...state.undoStack, snapshot(state)];
    return {
      ...state,
      tree: snap.tree,
      collapsed: snap.collapsed,
      selectedIds: snap.selectedIds,
      selectedId:
        snap.selectedIds.size > 0
          ? Array.from(snap.selectedIds)[0] ?? null
          : null,
      focusedId: snap.focusedId,
      undoStack: newUndoStack,
      redoStack: newRedoStack,
    };
  }
  if (action.type === "clear-history") {
    if (state.undoStack.length === 0 && state.redoStack.length === 0) return state;
    return { ...state, undoStack: [], redoStack: [] };
  }
  if (action.type === "set-max-undo-depth") {
    if (state.maxUndoDepth === action.depth) return state;
    const undoStack =
      state.undoStack.length > action.depth
        ? state.undoStack.slice(state.undoStack.length - action.depth)
        : state.undoStack;
    return { ...state, maxUndoDepth: action.depth, undoStack };
  }

  // All other actions → run through innerReducer + bookkeeping
  const next = innerReducer(state, action);
  return withUndoBookkeeping(state, next);
}

/** v0.1–v0.3 reducer logic, unchanged. */
function innerReducer(
  state: CardTreeState,
  action: CardTreeAction,
): CardTreeState {
  switch (action.type) {
    /* v0.1 */
    case "toggle-collapse": {
      const next = new Set(state.collapsed);
      if (next.has(action.id)) next.delete(action.id);
      else next.add(action.id);
      // Mirror to userCollapsed when search is inactive (so user-driven collapse persists across search clear)
      const userNext = state.searchQuery === "" ? next : state.userCollapsed;
      return { ...state, collapsed: next, userCollapsed: userNext };
    }
    case "set-focus": {
      if (state.focusedId === action.id) return state;
      return { ...state, focusedId: action.id };
    }
    case "replace-tree": {
      return createInitialState(action.tree, "none");
    }

    /* v0.2 selection + dirty */
    case "set-selection": {
      if (state.selectedId === action.id) return state;
      const ids = action.id ? new Set<string>([action.id]) : new Set<string>();
      return {
        ...state,
        selectedId: action.id,
        selectedIds: ids,
        selectionAnchorId: action.id,
      };
    }
    case "mark-clean": {
      if (state.cleanVersion === state.version) return state;
      return { ...state, cleanVersion: state.version };
    }

    /* v0.2 flat-field actions */
    case "field-edit-value": {
      const tree = updateNode(state.tree, action.cardId, (card) => ({
        ...card,
        fields: card.fields.map((f) =>
          f.key === action.key
            ? { ...f, value: action.value, type: action.valueType }
            : f,
        ),
      }));
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }
    case "field-edit-key": {
      const tree = updateNode(state.tree, action.cardId, (card) => ({
        ...card,
        fields: card.fields.map((f) =>
          f.key === action.oldKey ? { ...f, key: action.newKey } : f,
        ),
      }));
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }
    case "field-add": {
      const tree = updateNode(state.tree, action.cardId, (card) => ({
        ...card,
        fields: [
          ...card.fields,
          { key: action.key, value: action.value, type: action.valueType },
        ],
      }));
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }
    case "field-remove": {
      const tree = updateNode(state.tree, action.cardId, (card) => ({
        ...card,
        fields: card.fields.filter((f) => f.key !== action.key),
      }));
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }

    /* v0.2 card actions */
    case "card-add": {
      const tree = updateNode(state.tree, action.parentId, (parent) => {
        const child = setLevelDeep(action.card, parent.level + 1);
        const next = [...parent.children, child].sort(
          (a, b) => a.order - b.order,
        );
        return { ...parent, children: next };
      });
      if (tree === state.tree) return state;
      const collapsed = new Set(state.collapsed);
      collapsed.delete(action.parentId);
      return { ...state, tree, collapsed, version: state.version + 1 };
    }
    case "card-remove": {
      const policy = action.policy ?? "cascade";
      const collisionStrategy = action.collisionStrategy ?? "suffix";

      // Find the card and its parent BEFORE removal
      const target = findCard(state.tree, action.cardId);
      if (!target) return state;

      // Promote first if requested
      let workingTree = state.tree;
      if (policy === "promote" && target.children.length > 0) {
        const parentId = findParentId(state.tree, action.cardId);
        if (parentId === null) {
          // Root; can't promote — fall back to cascade
        } else {
          // Compute promoted children with collision resolution
          const parent = findCard(state.tree, parentId)!;
          const existingKeys = new Set<string>([
            ...parent.children
              .filter((c) => c.id !== action.cardId)
              .map((c) => c.parentKey ?? "")
              .filter((k) => k.length > 0),
            ...parent.fields.map((f) => f.key),
          ]);

          const promoted: ParsedCardTree[] = [];
          for (const child of target.children) {
            const candidate = child.parentKey ?? `child_${child.id.slice(0, 8)}`;
            const resolved = resolveCollision(
              candidate,
              existingKeys,
              collisionStrategy,
              target.parentKey ?? "promoted",
            );
            if (resolved === null) {
              // reject mode: refuse this promotion
              return state;
            }
            existingKeys.add(resolved);
            promoted.push({ ...child, parentKey: resolved, level: parent.level + 1 });
          }

          workingTree = updateNode(state.tree, parentId, (p) => ({
            ...p,
            children: [
              ...p.children.filter((c) => c.id !== action.cardId),
              ...promoted.map((c) => setLevelDeep(c, p.level + 1)),
            ].sort((a, b) => a.order - b.order),
          }));

          const removedIds = new Set<string>([action.cardId]);
          // Note: descendants' IDs are preserved; only the removed card's id is dirty.
          const collapsed = new Set(state.collapsed);
          removedIds.forEach((id) => collapsed.delete(id));
          const focusedId = removedIds.has(state.focusedId ?? "")
            ? null
            : state.focusedId;
          const selectedIds = new Set(
            [...state.selectedIds].filter((id) => !removedIds.has(id)),
          );
          return {
            ...state,
            tree: workingTree,
            collapsed,
            focusedId,
            selectedId: selectedIds.size > 0 ? [...selectedIds][0] ?? null : null,
            selectedIds,
            version: state.version + 1,
          };
        }
      }

      // Cascade
      const result = removeNodeById(state.tree, action.cardId);
      if (!result.removed) return state;
      const removedIds = new Set<string>();
      collectIds(result.removed, removedIds);
      const collapsed = new Set(state.collapsed);
      removedIds.forEach((id) => collapsed.delete(id));
      const focusedId = removedIds.has(state.focusedId ?? "")
        ? null
        : state.focusedId;
      const selectedIds = new Set(
        [...state.selectedIds].filter((id) => !removedIds.has(id)),
      );
      return {
        ...state,
        tree: result.tree,
        collapsed,
        focusedId,
        selectedId: selectedIds.size > 0 ? [...selectedIds][0] ?? null : null,
        selectedIds,
        version: state.version + 1,
      };
    }
    case "card-rename": {
      const tree = updateNode(state.tree, action.cardId, (card) => ({
        ...card,
        parentKey: action.newParentKey,
      }));
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }

    /* v0.2 predefined */
    case "predefined-add": {
      const tree = updateNode(state.tree, action.cardId, (card) => ({
        ...card,
        predefined: [...card.predefined, action.entry],
      }));
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }
    case "predefined-edit": {
      const tree = updateNode(state.tree, action.cardId, (card) => ({
        ...card,
        predefined: card.predefined.map((p) =>
          p.key === action.key ? action.entry : p,
        ),
      }));
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }
    case "predefined-remove": {
      const tree = updateNode(state.tree, action.cardId, (card) => ({
        ...card,
        predefined: card.predefined.filter((p) => p.key !== action.key),
      }));
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }

    /* v0.3 — multi-select */
    case "set-multi-selection": {
      const ids = new Set(action.ids);
      const anchor =
        action.anchor !== undefined
          ? action.anchor
          : action.ids.length > 0
            ? action.ids[action.ids.length - 1]
            : null;
      return {
        ...state,
        selectedIds: ids,
        selectedId: ids.size > 0 ? [...ids][0] ?? null : null,
        selectionAnchorId: anchor,
      };
    }
    case "toggle-selection": {
      const next = new Set(state.selectedIds);
      if (next.has(action.id)) next.delete(action.id);
      else next.add(action.id);
      return {
        ...state,
        selectedIds: next,
        selectedId: next.size > 0 ? [...next][0] ?? null : null,
        selectionAnchorId: action.id,
      };
    }
    case "extend-selection-to": {
      // Range-select from anchor to action.id along visible-traversal order.
      const anchor = state.selectionAnchorId ?? action.id;
      const order = visibleIdsInOrder(state.tree, state.collapsed);
      const a = order.indexOf(anchor);
      const b = order.indexOf(action.id);
      if (a < 0 || b < 0) return state;
      const [lo, hi] = a <= b ? [a, b] : [b, a];
      const range = order.slice(lo, hi + 1);
      const next = new Set([...state.selectedIds, ...range]);
      return {
        ...state,
        selectedIds: next,
        selectedId: next.size > 0 ? [...next][0] ?? null : null,
      };
    }
    case "clear-selection": {
      if (state.selectedIds.size === 0) return state;
      return {
        ...state,
        selectedIds: new Set<string>(),
        selectedId: null,
        selectionAnchorId: null,
      };
    }

    /* v0.3 — drag transient */
    case "drag-start": {
      return { ...state, draggingId: action.cardId };
    }
    case "drag-end": {
      if (state.draggingId === null) return state;
      return { ...state, draggingId: null };
    }

    /* v0.3 — card movement + duplicate */
    case "card-move": {
      // Remove the card from its current parent, then insert under newParentId at newOrder.
      const result = removeNodeById(state.tree, action.cardId);
      if (!result.removed) return state;
      const detached = result.removed;
      const inserted = updateNode(result.tree, action.newParentId, (parent) => ({
        ...parent,
        children: [
          ...parent.children,
          setLevelDeep({ ...detached, order: action.newOrder }, parent.level + 1),
        ].sort((a, b) => a.order - b.order),
      }));
      return { ...state, tree: inserted, version: state.version + 1 };
    }
    case "card-duplicate": {
      const source = findCard(state.tree, action.cardId);
      if (!source) return state;
      const parentId = findParentId(state.tree, action.cardId);
      if (parentId === null) return state; // can't duplicate root
      const fresh = freshIdGen();
      const clone = deepCloneWithFreshIds(source, fresh);
      // Override the top-level clone id with the supplied newCardId
      const cloneWithId: ParsedCardTree = {
        ...clone,
        id: action.newCardId,
        order: source.order + 0.5,
        parentKey: source.parentKey ? `${source.parentKey}_copy` : "copy",
      };
      const tree = updateNode(state.tree, parentId, (parent) => ({
        ...parent,
        children: [
          ...parent.children,
          setLevelDeep(cloneWithId, parent.level + 1),
        ].sort((a, b) => a.order - b.order),
      }));
      return { ...state, tree, version: state.version + 1 };
    }

    /* v0.3 — meta */
    case "meta-edit": {
      const tree = updateNode(state.tree, action.cardId, (card) => ({
        ...card,
        meta: { ...(card.meta ?? {}), [action.key]: action.value },
      }));
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }
    case "meta-add": {
      const tree = updateNode(state.tree, action.cardId, (card) => {
        if (card.meta && action.key in card.meta) return card;
        return {
          ...card,
          meta: { ...(card.meta ?? {}), [action.key]: action.value },
        };
      });
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }
    case "meta-remove": {
      const tree = updateNode(state.tree, action.cardId, (card) => {
        if (!card.meta || !(action.key in card.meta)) return card;
        const next = { ...card.meta };
        delete next[action.key];
        return { ...card, meta: Object.keys(next).length > 0 ? next : undefined };
      });
      if (tree === state.tree) return state;
      return { ...state, tree, version: state.version + 1 };
    }

    /* v0.3 — bulk */
    case "bulk-remove": {
      // Order ids deepest-first to avoid removing an ancestor before its descendants.
      const order = depthFirstDepthMap(state.tree);
      const sorted = [...action.cardIds]
        .filter((id) => id !== state.tree.id) // root protected
        .sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0));
      let workingTree = state.tree;
      const allRemoved = new Set<string>();
      for (const id of sorted) {
        const result = removeNodeById(workingTree, id);
        if (result.removed) {
          collectIds(result.removed, allRemoved);
          workingTree = result.tree;
        }
      }
      if (allRemoved.size === 0) return state;
      const collapsed = new Set(state.collapsed);
      allRemoved.forEach((id) => collapsed.delete(id));
      const focusedId = allRemoved.has(state.focusedId ?? "") ? null : state.focusedId;
      const selectedIds = new Set(
        [...state.selectedIds].filter((id) => !allRemoved.has(id)),
      );
      return {
        ...state,
        tree: workingTree,
        collapsed,
        focusedId,
        selectedIds,
        selectedId: selectedIds.size > 0 ? [...selectedIds][0] ?? null : null,
        version: state.version + 1,
      };
    }

    /* v0.3 — search */
    case "set-search-query": {
      if (state.searchQuery === action.query) return state;
      const wasInactive = state.searchQuery === "";
      const isInactive = action.query === "";
      // When transitioning to active, snapshot user-collapsed state (if not already snapshotted).
      // When transitioning to inactive, restore user-collapsed state.
      if (wasInactive && !isInactive) {
        return {
          ...state,
          searchQuery: action.query,
          searchActiveIndex: 0,
          userCollapsed: state.collapsed,
        };
      }
      if (!wasInactive && isInactive) {
        return {
          ...state,
          searchQuery: "",
          searchActiveIndex: null,
          collapsed: state.userCollapsed,
        };
      }
      return { ...state, searchQuery: action.query };
    }
    case "set-active-match-index": {
      if (state.searchActiveIndex === action.index) return state;
      return { ...state, searchActiveIndex: action.index };
    }
    case "clear-search": {
      if (state.searchQuery === "") return state;
      return {
        ...state,
        searchQuery: "",
        searchActiveIndex: null,
        collapsed: state.userCollapsed,
      };
    }
    case "expand-path-to-matches": {
      // Remove these ancestor ids from collapsed (additively).
      if (action.ancestorIds.length === 0) return state;
      const next = new Set(state.collapsed);
      let changed = false;
      for (const id of action.ancestorIds) {
        if (next.delete(id)) changed = true;
      }
      return changed ? { ...state, collapsed: next } : state;
    }
    // v0.4 actions are handled by the outer reducer wrapper, so they should
    // never reach innerReducer. Listed here for exhaustiveness.
    case "undo":
    case "redo":
    case "clear-history":
    case "set-max-undo-depth":
      return state;
  }
}

/* ───────── traversal helpers ───────── */

function collectIds(node: ParsedCardTree, out: Set<string>): void {
  out.add(node.id);
  for (const c of node.children) collectIds(c, out);
}

/** Depth-first-traversal order map (id → traversal index). Used for deepest-first sort. */
function depthFirstDepthMap(tree: ParsedCardTree): Map<string, number> {
  const out = new Map<string, number>();
  walkDepth(tree, 0, out);
  return out;
}

function walkDepth(node: ParsedCardTree, depth: number, out: Map<string, number>): void {
  out.set(node.id, depth);
  for (const c of node.children) walkDepth(c, depth + 1, out);
}

export function visibleIdsInOrder(
  tree: ParsedCardTree,
  collapsed: ReadonlySet<string>,
): string[] {
  const out: string[] = [];
  walkVisible(tree, collapsed, out);
  return out;
}

function walkVisible(
  tree: ParsedCardTree,
  collapsed: ReadonlySet<string>,
  out: string[],
): void {
  out.push(tree.id);
  if (collapsed.has(tree.id)) return;
  for (const child of tree.children) walkVisible(child, collapsed, out);
}

export function findParentId(tree: ParsedCardTree, id: string): string | null {
  if (tree.id === id) return null;
  for (const child of tree.children) {
    if (child.id === id) return tree.id;
    const inSubtree = findParentId(child, id);
    if (inSubtree) return inSubtree;
  }
  return null;
}

export function findCard(tree: ParsedCardTree, id: string): ParsedCardTree | null {
  if (tree.id === id) return tree;
  for (const child of tree.children) {
    const found = findCard(child, id);
    if (found) return found;
  }
  return null;
}

/** Returns the chain of ancestor ids from root → parent (excluding the card itself). */
export function findAncestorIds(tree: ParsedCardTree, id: string): string[] {
  const out: string[] = [];
  walkAncestors(tree, id, [], out);
  return out;
}

function walkAncestors(
  node: ParsedCardTree,
  targetId: string,
  path: string[],
  out: string[],
): boolean {
  if (node.id === targetId) {
    out.push(...path);
    return true;
  }
  for (const c of node.children) {
    if (walkAncestors(c, targetId, [...path, node.id], out)) return true;
  }
  return false;
}
src/registry/components/data/card-tree/lib/search.ts
/**
 * Pure data-model search.
 *
 * Walks `ParsedCardTree` directly — finds matches in collapsed subtrees and
 * meta entries. No DOM dependence.
 */

import type {
  CustomPredefinedKey,
  FlatFieldValue,
  SearchMatch,
  SearchOptions,
  SearchResult,
} from "../types";
import { isCustomEntry } from "./parse";
import type { CardTreePredefinedEntry, ParsedCardTree } from "./parse";

const EXCERPT_RADIUS = 32; // chars on each side of the match

export type SearchContext = {
  customKeys?: CustomPredefinedKey[];
};

export function searchTree(
  tree: ParsedCardTree,
  options: SearchOptions,
  ctx: SearchContext = {},
): SearchResult {
  const matches: SearchMatch[] = [];
  if (!options.query || options.query.length === 0) {
    return { matches, matchedCardIds: new Set(), activeIndex: null };
  }

  const flags = {
    matchTitles: options.matchTitles ?? true,
    matchKeys: options.matchKeys ?? true,
    matchValues: options.matchValues ?? true,
    matchPredefined: options.matchPredefined ?? true,
    matchMeta: options.matchMeta ?? true,
  };

  walk(tree, options.query, options.caseSensitive ?? false, flags, ctx, matches);

  const matchedCardIds = new Set<string>();
  for (const m of matches) matchedCardIds.add(m.cardId);

  return {
    matches,
    matchedCardIds,
    activeIndex: matches.length > 0 ? 0 : null,
  };
}

type Flags = {
  matchTitles: boolean;
  matchKeys: boolean;
  matchValues: boolean;
  matchPredefined: boolean;
  matchMeta: boolean;
};

function walk(
  node: ParsedCardTree,
  query: string,
  caseSensitive: boolean,
  flags: Flags,
  ctx: SearchContext,
  out: SearchMatch[],
): void {
  if (flags.matchTitles && node.parentKey) {
    addOccurrences(out, node.id, "title", undefined, node.parentKey, query, caseSensitive);
  }

  for (const field of node.fields) {
    if (flags.matchKeys) {
      addOccurrences(out, node.id, "field-key", field.key, field.key, query, caseSensitive);
    }
    if (flags.matchValues) {
      addOccurrences(
        out,
        node.id,
        "field-value",
        field.key,
        stringifyScalar(field.value),
        query,
        caseSensitive,
      );
    }
  }

  if (flags.matchPredefined) {
    for (const entry of node.predefined) {
      const text = stringifyPredefined(entry, ctx);
      if (text) {
        addOccurrences(out, node.id, "predefined", String(entry.key), text, query, caseSensitive);
      }
    }
  }

  if (flags.matchMeta && node.meta) {
    for (const [key, value] of Object.entries(node.meta)) {
      addOccurrences(out, node.id, "meta-key", key, key, query, caseSensitive);
      addOccurrences(
        out,
        node.id,
        "meta-value",
        key,
        stringifyScalar(value),
        query,
        caseSensitive,
      );
    }
  }

  for (const child of node.children) {
    walk(child, query, caseSensitive, flags, ctx, out);
  }
}

function stringifyScalar(v: FlatFieldValue): string {
  if (v === null) return "null";
  return String(v);
}

function stringifyPredefined(
  entry: CardTreePredefinedEntry,
  ctx: SearchContext,
): string {
  // Custom entries carry an open `string` key, so they must be peeled off BEFORE
  // the switch — otherwise they stay a candidate in every `case` and break the
  // built-ins' narrowing (see CardTreeCustomEntry's narrowing contract).
  if (isCustomEntry(entry)) {
    const customKey = ctx.customKeys?.find((k) => k.key === entry.key);
    if (!customKey?.searchableText) return "";
    try {
      return customKey.searchableText(entry.value).join(" ");
    } catch {
      // Host code — a throwing searchableText must not take the search down.
      return "";
    }
  }

  switch (entry.key) {
    case "codearea":
      return `${entry.value.format} ${entry.value.content}`;
    case "image":
      return `${entry.value.src} ${entry.value.alt ?? ""}`;
    case "table": {
      const t = entry.value;
      return [
        ...t.headers,
        ...t.rows.flat().map(stringifyScalar),
      ].join(" ");
    }
    case "quote":
      return entry.value;
    case "list":
      return entry.value.map(stringifyScalar).join(" ");
  }
}

function addOccurrences(
  out: SearchMatch[],
  cardId: string,
  matchType: SearchMatch["matchType"],
  fieldKey: string | undefined,
  haystack: string,
  needle: string,
  caseSensitive: boolean,
): void {
  if (!haystack || !needle) return;
  const h = caseSensitive ? haystack : haystack.toLowerCase();
  const n = caseSensitive ? needle : needle.toLowerCase();
  let from = 0;
  while (from <= h.length) {
    const idx = h.indexOf(n, from);
    if (idx < 0) break;
    const excerptStart = Math.max(0, idx - EXCERPT_RADIUS);
    const excerptEnd = Math.min(haystack.length, idx + needle.length + EXCERPT_RADIUS);
    const excerpt =
      (excerptStart > 0 ? "…" : "") +
      haystack.slice(excerptStart, excerptEnd) +
      (excerptEnd < haystack.length ? "…" : "");
    out.push({
      cardId,
      matchType,
      fieldKey,
      excerpt,
      start: idx - excerptStart + (excerptStart > 0 ? 1 : 0),
      length: needle.length,
    });
    from = idx + needle.length;
  }
}

/**
 * Returns the ranges within a given string where matches should be highlighted,
 * filtered to a specific (cardId, matchType, fieldKey) context.
 *
 * Used by <MatchHighlight> to wrap matched text in <mark> elements.
 */
export function rangesFor(
  matches: readonly SearchMatch[],
  haystack: string,
  cardId: string,
  matchType: SearchMatch["matchType"],
  fieldKey: string | undefined,
  query: string,
  caseSensitive: boolean,
): Array<{ start: number; length: number }> {
  if (!query) return [];
  const relevant = matches.filter(
    (m) =>
      m.cardId === cardId &&
      m.matchType === matchType &&
      m.fieldKey === fieldKey,
  );
  if (relevant.length === 0) return [];
  // Re-run the indexOf locally to get exact positions in the haystack
  // (excerpt-based positions are relative to excerpt, not haystack).
  const ranges: Array<{ start: number; length: number }> = [];
  const h = caseSensitive ? haystack : haystack.toLowerCase();
  const n = caseSensitive ? query : query.toLowerCase();
  let from = 0;
  while (from <= h.length) {
    const idx = h.indexOf(n, from);
    if (idx < 0) break;
    ranges.push({ start: idx, length: query.length });
    from = idx + query.length;
  }
  return ranges;
}
src/registry/components/data/card-tree/lib/serialize.ts
/**
 * Pure serializer: `ParsedCardTree` → canonical `CardTreeJsonNode` and JSON string.
 *
 * Property emission order per plan §3.1:
 *   1. __rcid
 *   2. __rcorder
 *   3. __rcmeta
 *   4. flat fields (insertion order from parse)
 *   5. predefined-key elements (insertion order)
 *   6. children — emitted under their stored `parentKey` in `order` ascending
 *
 * Round-trip property: parse(serialize(parse(x))) === parse(x). Fixed-point
 * on the second round trip.
 */

import type { CardTreeJsonNode } from "../types";
import type { ParsedCardTree } from "./parse";

export function treeToJsonNode(tree: ParsedCardTree): CardTreeJsonNode {
  const out: CardTreeJsonNode = {
    __rcid: tree.id,
    __rcorder: tree.order,
  };

  if (tree.meta && Object.keys(tree.meta).length > 0) {
    out.__rcmeta = { ...tree.meta };
  }

  for (const field of tree.fields) {
    out[field.key] = field.value;
  }

  for (const entry of tree.predefined) {
    out[entry.key] = entry.value as unknown;
  }

  for (const child of tree.children) {
    if (!child.parentKey) {
      // Root has no parentKey; non-root with no parentKey is a parser bug — skip.
      continue;
    }
    out[child.parentKey] = treeToJsonNode(child);
  }

  return out;
}

export function serializeTree(tree: ParsedCardTree): string {
  return JSON.stringify(treeToJsonNode(tree), null, 2);
}
src/registry/components/data/card-tree/lib/validate-edit.ts
/**
 * Pure synchronous validators for v0.2 edit operations.
 *
 * Each validator receives the current state + action params, returns either
 * { ok: true } or { ok: false, errors: ValidationError[] }. The reducer calls
 * these before any commit; on failure, state is unchanged and the calling
 * editor surfaces errors via <InlineError>.
 *
 * No async hooks here — those land in v0.4.
 */

import {
  PREDEFINED_KEYS,
  RESERVED_KEYS,
  type FlatFieldValue,
  type PredefinedKey,
} from "../types";
import {
  findAncestorIds,
  findCard,
  findParentId,
  type CardTreeState,
} from "./reducer";
import type { FlatFieldType } from "./infer-type";

/* ───────── result + error types ───────── */

export type ValidationError = { code: string; message: string };

export type ValidationResult =
  | { ok: true }
  | { ok: false; errors: ValidationError[] };

const ok: ValidationResult = { ok: true };
const fail = (...errors: ValidationError[]): ValidationResult => ({
  ok: false,
  errors,
});

/* ───────── shared key checks ───────── */

function isReservedKey(key: string): boolean {
  return (RESERVED_KEYS as readonly string[]).includes(key);
}

function isPredefinedKey(key: string, disabled: readonly PredefinedKey[]): boolean {
  return (
    (PREDEFINED_KEYS as readonly string[]).includes(key) &&
    !(disabled as readonly string[]).includes(key)
  );
}

/**
 * v0.6 — a registered custom-key name is no more available as a flat-field or
 * card name than a built-in is. Without this check the edit commits, but the
 * next parse re-classifies the key as `custom`, runs the host validator against
 * a scalar, and drops the value: silent data loss across one round-trip.
 */
function isCustomKeyName(key: string, customKeyNames: readonly string[]): boolean {
  return customKeyNames.includes(key);
}

function checkKeyAvailable(
  newKey: string,
  cardId: string,
  state: CardTreeState,
  disabled: readonly PredefinedKey[],
  excludeKey?: string,
  customKeyNames: readonly string[] = [],
): ValidationError[] {
  const errors: ValidationError[] = [];
  if (!newKey || newKey.trim().length === 0) {
    errors.push({ code: "empty-key", message: "Key cannot be empty." });
    return errors;
  }
  if (isReservedKey(newKey)) {
    errors.push({
      code: "reserved-key",
      message: `"${newKey}" is reserved (${RESERVED_KEYS.join(", ")}).`,
    });
  }
  if (isPredefinedKey(newKey, disabled)) {
    errors.push({
      code: "predefined-key",
      message: `"${newKey}" is a predefined-key name. Add it to disabledPredefinedKeys to use as a flat field.`,
    });
  }
  if (isCustomKeyName(newKey, customKeyNames)) {
    errors.push({
      code: "custom-key",
      message: `"${newKey}" is a registered custom predefined-key name; pick a different key.`,
    });
  }
  const card = findCard(state.tree, cardId);
  if (card) {
    const fieldCollision = card.fields.some(
      (f) => f.key === newKey && f.key !== excludeKey,
    );
    if (fieldCollision) {
      errors.push({
        code: "sibling-key-collision",
        message: `Field "${newKey}" already exists on this card.`,
      });
    }
  }
  return errors;
}

/* ───────── flat-field validators ───────── */

export function validateFieldEditValue(
  state: CardTreeState,
  cardId: string,
  key: string,
  value: FlatFieldValue,
  expectedType: FlatFieldType,
): ValidationResult {
  const card = findCard(state.tree, cardId);
  if (!card) return fail({ code: "no-card", message: "Card not found." });
  const field = card.fields.find((f) => f.key === key);
  if (!field) return fail({ code: "no-field", message: `Field "${key}" not found.` });

  // Type preservation: v0.2 doesn't allow type-changing on edit (Q-P2).
  // The editor passes through the inferred type; we just sanity-check it.
  if (expectedType === "number" && typeof value !== "number") {
    return fail({
      code: "type-mismatch",
      message: "Value must be a number.",
    });
  }
  if (expectedType === "boolean" && typeof value !== "boolean") {
    return fail({
      code: "type-mismatch",
      message: "Value must be a boolean.",
    });
  }
  if (
    (expectedType === "string" || expectedType === "date") &&
    typeof value !== "string"
  ) {
    return fail({
      code: "type-mismatch",
      message: "Value must be a string.",
    });
  }
  if (expectedType === "date" && typeof value === "string") {
    if (Number.isNaN(Date.parse(value))) {
      return fail({
        code: "invalid-date",
        message: "Must be a valid ISO-8601 date string.",
      });
    }
  }
  if (expectedType === "null" && value !== null) {
    return fail({
      code: "type-mismatch",
      message: "Null fields are read-only in v0.2.",
    });
  }
  return ok;
}

export function validateFieldEditKey(
  state: CardTreeState,
  cardId: string,
  oldKey: string,
  newKey: string,
  disabledPredefinedKeys: readonly PredefinedKey[],
  customKeyNames: readonly string[] = [],
): ValidationResult {
  if (oldKey === newKey) return ok;
  const errors = checkKeyAvailable(
    newKey,
    cardId,
    state,
    disabledPredefinedKeys,
    oldKey,
    customKeyNames,
  );
  return errors.length === 0 ? ok : fail(...errors);
}

export function validateFieldAdd(
  state: CardTreeState,
  cardId: string,
  key: string,
  value: FlatFieldValue,
  type: FlatFieldType,
  disabledPredefinedKeys: readonly PredefinedKey[],
  customKeyNames: readonly string[] = [],
): ValidationResult {
  const keyErrors = checkKeyAvailable(
    key,
    cardId,
    state,
    disabledPredefinedKeys,
    undefined,
    customKeyNames,
  );
  if (keyErrors.length > 0) return fail(...keyErrors);

  if (type === "null") {
    return fail({
      code: "no-null-add",
      message: "Adding null fields is not supported in v0.2.",
    });
  }
  // delegate the value/type sanity check to validateFieldEditValue logic minus card/field lookup
  if (type === "number" && typeof value !== "number")
    return fail({ code: "type-mismatch", message: "Value must be a number." });
  if (type === "boolean" && typeof value !== "boolean")
    return fail({ code: "type-mismatch", message: "Value must be a boolean." });
  if ((type === "string" || type === "date") && typeof value !== "string")
    return fail({ code: "type-mismatch", message: "Value must be a string." });
  if (type === "date" && typeof value === "string" && Number.isNaN(Date.parse(value)))
    return fail({
      code: "invalid-date",
      message: "Must be a valid ISO-8601 date string.",
    });
  return ok;
}

/* ───────── card validators ───────── */

export function validateCardRename(
  state: CardTreeState,
  cardId: string,
  newKey: string,
  disabledPredefinedKeys: readonly PredefinedKey[],
  customKeyNames: readonly string[] = [],
): ValidationResult {
  if (!newKey || newKey.trim().length === 0) {
    return fail({ code: "empty-key", message: "Card name cannot be empty." });
  }
  if (isReservedKey(newKey)) {
    return fail({
      code: "reserved-key",
      message: `"${newKey}" is a reserved key.`,
    });
  }
  if (isPredefinedKey(newKey, disabledPredefinedKeys)) {
    return fail({
      code: "predefined-key",
      message: `"${newKey}" is a predefined-key name; pick a different card name.`,
    });
  }
  if (isCustomKeyName(newKey, customKeyNames)) {
    return fail({
      code: "custom-key",
      message: `"${newKey}" is a registered custom predefined-key name; pick a different card name.`,
    });
  }
  // Sibling-key collision: find this card's parent + check siblings (excluding self)
  const parentId = findParentIdLocal(state.tree, cardId);
  if (parentId === null) return ok; // root has no siblings
  const parent = findCard(state.tree, parentId);
  if (!parent) return ok;
  const collision = parent.children.some(
    (c) => c.id !== cardId && c.parentKey === newKey,
  );
  if (collision) {
    return fail({
      code: "sibling-collision",
      message: `Sibling "${newKey}" already exists on the parent.`,
    });
  }
  // Also collide with any flat field on the parent (since on serialize they share namespace)
  const fieldCollision = parent.fields.some((f) => f.key === newKey);
  if (fieldCollision) {
    return fail({
      code: "sibling-collision",
      message: `Parent already has a field named "${newKey}".`,
    });
  }
  return ok;
}

function findParentIdLocal(
  tree: { id: string; children: { id: string }[] } & {
    [key: string]: unknown;
  },
  childId: string,
): string | null {
  for (const c of tree.children) {
    if (c.id === childId) return tree.id;
  }
  for (const c of tree.children) {
    // @ts-expect-error recurse into ParsedCardTree shape
    const inner = findParentIdLocal(c, childId);
    if (inner) return inner;
  }
  return null;
}

export function validateCardRemove(
  state: CardTreeState,
  cardId: string,
): ValidationResult {
  if (state.tree.id === cardId) {
    return fail({
      code: "no-root-remove",
      message: "Removing the root card is not supported in v0.2.",
    });
  }
  if (!findCard(state.tree, cardId)) {
    return fail({ code: "no-card", message: "Card not found." });
  }
  return ok;
}

/* ───────── predefined-key validators ───────── */

export function validatePredefinedShape(
  key: PredefinedKey,
  value: unknown,
): ValidationResult {
  switch (key) {
    case "codearea": {
      if (
        value &&
        typeof value === "object" &&
        !Array.isArray(value) &&
        typeof (value as Record<string, unknown>).format === "string" &&
        typeof (value as Record<string, unknown>).content === "string"
      )
        return ok;
      return fail({
        code: "shape-mismatch",
        message: "codearea must be { format: string, content: string }.",
      });
    }
    case "image": {
      if (
        value &&
        typeof value === "object" &&
        !Array.isArray(value) &&
        typeof (value as Record<string, unknown>).src === "string"
      ) {
        const alt = (value as Record<string, unknown>).alt;
        if (alt === undefined || typeof alt === "string") return ok;
      }
      return fail({
        code: "shape-mismatch",
        message: "image must be { src: string, alt?: string }.",
      });
    }
    case "table": {
      if (
        value &&
        typeof value === "object" &&
        !Array.isArray(value)
      ) {
        const v = value as Record<string, unknown>;
        if (
          Array.isArray(v.headers) &&
          v.headers.every((h) => typeof h === "string") &&
          Array.isArray(v.rows) &&
          v.rows.every(
            (row) =>
              Array.isArray(row) &&
              row.length === (v.headers as string[]).length &&
              row.every(
                (c) =>
                  c === null ||
                  typeof c === "string" ||
                  typeof c === "number" ||
                  typeof c === "boolean",
              ),
          )
        )
          return ok;
      }
      return fail({
        code: "shape-mismatch",
        message:
          "table must be { headers: string[], rows: scalar[][] } with consistent row widths.",
      });
    }
    case "quote": {
      if (typeof value === "string") return ok;
      return fail({
        code: "shape-mismatch",
        message: "quote must be a string.",
      });
    }
    case "list": {
      if (
        Array.isArray(value) &&
        value.every(
          (v) =>
            v === null ||
            typeof v === "string" ||
            typeof v === "number" ||
            typeof v === "boolean",
        )
      )
        return ok;
      return fail({
        code: "shape-mismatch",
        message: "list must be an array of scalar values.",
      });
    }
  }
}

/* ───────── v0.3: meta validators ───────── */

export function validateMetaEdit(
  state: CardTreeState,
  cardId: string,
  key: string,
  value: FlatFieldValue,
): ValidationResult {
  const card = findCard(state.tree, cardId);
  if (!card) return fail({ code: "no-card", message: "Card not found." });
  if (!card.meta || !(key in card.meta)) {
    return fail({ code: "no-meta-key", message: `Meta key "${key}" not found.` });
  }
  if (
    value !== null &&
    typeof value !== "string" &&
    typeof value !== "number" &&
    typeof value !== "boolean"
  ) {
    return fail({
      code: "invalid-meta-value",
      message: "Meta values must be scalars (string / number / boolean / null).",
    });
  }
  return ok;
}

export function validateMetaAdd(
  state: CardTreeState,
  cardId: string,
  key: string,
  value: FlatFieldValue,
): ValidationResult {
  if (!key || key.trim().length === 0) {
    return fail({ code: "empty-key", message: "Meta key cannot be empty." });
  }
  if (isReservedKey(key)) {
    return fail({
      code: "reserved-key",
      message: `"${key}" is a reserved key.`,
    });
  }
  const card = findCard(state.tree, cardId);
  if (!card) return fail({ code: "no-card", message: "Card not found." });
  if (card.meta && key in card.meta) {
    return fail({
      code: "meta-key-collision",
      message: `Meta key "${key}" already exists on this card.`,
    });
  }
  if (
    value !== null &&
    typeof value !== "string" &&
    typeof value !== "number" &&
    typeof value !== "boolean"
  ) {
    return fail({
      code: "invalid-meta-value",
      message: "Meta values must be scalars.",
    });
  }
  return ok;
}

/* ───────── v0.3: card-move validator ───────── */

export function validateCardMove(
  state: CardTreeState,
  cardId: string,
  newParentId: string,
): ValidationResult {
  if (cardId === newParentId) {
    return fail({
      code: "self-parent",
      message: "Cannot drop a card onto itself.",
    });
  }
  if (!findCard(state.tree, cardId)) {
    return fail({ code: "no-card", message: "Card not found." });
  }
  if (!findCard(state.tree, newParentId)) {
    return fail({ code: "no-parent", message: "Target parent not found." });
  }
  // Cycle detection: target cannot be a descendant of source
  const targetAncestors = findAncestorIds(state.tree, newParentId);
  if (targetAncestors.includes(cardId)) {
    return fail({
      code: "cycle",
      message: "Cannot drop a card into its own descendant (would create a cycle).",
    });
  }
  // Prevent moving root
  if (state.tree.id === cardId) {
    return fail({
      code: "no-root-move",
      message: "Cannot move the root card.",
    });
  }
  // Suppress unused parent-id helper warning
  void findParentId;
  return ok;
}
src/registry/components/data/card-tree/lib/validators.ts
/**
 * Pure validation pipeline runner (v0.4).
 *
 * Three layers, each must pass before commit:
 *   1. Built-in validators — already run by individual dispatchers (v0.2)
 *   2. Per-action validators — typed payloads, host-supplied
 *   3. Master validator — catch-all, host-supplied
 *
 * Async hooks are deferred indefinitely (Q14).
 */

import type {
  CardAddedEvent,
  CardDuplicatedEvent,
  CardMovedEvent,
  CardRemovedEvent,
  CardRenamedEvent,
  FieldAddedEvent,
  FieldEditedEvent,
  FieldRemovedEvent,
  MetaAddedEvent,
  MetaChangedEvent,
  MetaRemovedEvent,
  PredefinedAddedEvent,
  PredefinedEditedEvent,
  PredefinedRemovedEvent,
  CardTreeJsonNode,
  CardTreeMasterValidator,
  CardTreeValidationError,
  CardTreeValidationResponse,
  CardTreeValidators,
  ValidationFailedEvent,
} from "../types";

/** All event payloads passed to per-action validators. */
export type AnyChangeEvent =
  | FieldEditedEvent
  | FieldAddedEvent
  | FieldRemovedEvent
  | CardAddedEvent
  | CardRemovedEvent
  | CardRenamedEvent
  | CardMovedEvent
  | CardDuplicatedEvent
  | PredefinedAddedEvent
  | PredefinedEditedEvent
  | PredefinedRemovedEvent
  | MetaChangedEvent
  | MetaAddedEvent
  | MetaRemovedEvent;

export type ValidatorKey = keyof CardTreeValidators;

/**
 * Run host-supplied validators (per-action + master) against the proposed event.
 * Returns ok or the first failing layer's errors.
 */
export function runHostValidators(
  validatorKey: ValidatorKey,
  event: AnyChangeEvent,
  actionType: string,
  cardId: string | undefined,
  tree: CardTreeJsonNode,
  validators: CardTreeValidators | undefined,
  master: CardTreeMasterValidator | undefined,
):
  | { ok: true }
  | {
      ok: false;
      errors: CardTreeValidationError[];
      layer: "per-action" | "master";
    } {
  // Per-action layer
  if (validators) {
    const fn = validators[validatorKey];
    if (fn) {
      const result = safeCall(() =>
        (fn as (e: AnyChangeEvent, t: CardTreeJsonNode) => CardTreeValidationResponse)(
          event,
          tree,
        ),
      );
      if (!result.ok) {
        return { ok: false, errors: result.errors, layer: "per-action" };
      }
    }
  }
  // Master layer
  if (master) {
    const result = safeCall(() => master({ type: actionType, cardId }, tree));
    if (!result.ok) {
      return { ok: false, errors: result.errors, layer: "master" };
    }
  }
  return { ok: true };
}

function safeCall(
  fn: () => CardTreeValidationResponse,
): CardTreeValidationResponse {
  try {
    const r = fn();
    if (!r || (r.ok !== true && r.ok !== false)) {
      return {
        ok: false,
        errors: [
          {
            code: "validator-bad-return",
            message: "Validator must return { ok: true } or { ok: false, errors: [...] }.",
          },
        ],
      };
    }
    return r;
  } catch (err) {
    return {
      ok: false,
      errors: [
        {
          code: "validator-error",
          message: err instanceof Error ? err.message : String(err),
        },
      ],
    };
  }
}

/** Build the typed event for `onValidationFailed` consumers. */
export function buildValidationFailedEvent(
  actionType: string,
  cardId: string | undefined,
  errors: CardTreeValidationError[],
  layer: "per-action" | "master",
): ValidationFailedEvent {
  return { action: actionType, cardId, errors, layer };
}
src/registry/components/data/card-tree/parts/bulk-toolbar.tsx
"use client";

import { useState } from "react";
import { Copy, Edit3, Lock, Trash2, X } from "lucide-react";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { FlatFieldValue } from "../types";
import type { FlatFieldType } from "../lib/infer-type";

/**
 * Floating toolbar visible when ≥2 cards are selected. Shows count + bulk actions.
 */
export function BulkToolbar({
  count,
  levelMin,
  levelMax,
  totalFields,
  lockedCount,
  onDelete,
  onDuplicate,
  onClear,
  onBulkSetField,
  onToggleLock,
  className,
}: {
  count: number;
  levelMin: number;
  levelMax: number;
  totalFields: number;
  lockedCount: number;
  onDelete: () => void;
  onDuplicate: () => void;
  onClear: () => void;
  onBulkSetField: (key: string, value: FlatFieldValue, type: FlatFieldType) => void;
  onToggleLock: () => void;
  className?: string;
}) {
  if (count < 2) return null;
  return (
    <div
      role="toolbar"
      aria-label="Bulk actions"
      className={cn(
        "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",
        className,
      )}
    >
      <span className="font-mono text-xs text-foreground/80">
        <strong>{count}</strong> cards selected
      </span>
      <span className="font-mono text-[10px] text-muted-foreground">
        · L{levelMin}–L{levelMax} · {totalFields} fields{lockedCount > 0 ? ` · ${lockedCount} locked` : ""}
      </span>
      <div className="ml-auto flex flex-wrap items-center gap-1">
        <BulkSetFieldPopover onCommit={onBulkSetField} />
        <button
          type="button"
          onClick={onToggleLock}
          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"
          aria-label="Toggle lock on selected cards"
        >
          <Lock className="size-3" aria-hidden="true" />
          toggle lock
        </button>
        <button
          type="button"
          onClick={onDuplicate}
          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"
        >
          <Copy className="size-3" aria-hidden="true" />
          duplicate
        </button>
        <button
          type="button"
          onClick={onDelete}
          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"
        >
          <Trash2 className="size-3" aria-hidden="true" />
          delete
        </button>
        <button
          type="button"
          onClick={onClear}
          aria-label="Clear selection"
          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"
        >
          <X className="size-3.5" aria-hidden="true" />
        </button>
      </div>
    </div>
  );
}

function BulkSetFieldPopover({
  onCommit,
}: {
  onCommit: (key: string, value: FlatFieldValue, type: FlatFieldType) => void;
}) {
  const [open, setOpen] = useState(false);
  const [key, setKey] = useState("");
  const [type, setType] = useState<FlatFieldType>("string");
  const [value, setValue] = useState<string>("");

  const submit = () => {
    if (!key.trim()) return;
    let parsedValue: FlatFieldValue;
    switch (type) {
      case "number":
        parsedValue = Number(value) || 0;
        break;
      case "boolean":
        parsedValue = value === "true";
        break;
      case "null":
        parsedValue = null;
        break;
      case "string":
      case "date":
      default:
        parsedValue = value;
    }
    onCommit(key, parsedValue, type);
    setKey("");
    setValue("");
    setOpen(false);
  };

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger
        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"
        aria-label="Bulk set field across selection"
      >
        <Edit3 className="size-3" aria-hidden="true" />
        set field
      </PopoverTrigger>
      <PopoverContent align="end" sideOffset={4} className="w-72 p-3 space-y-2">
        <p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
          Set field on all selected
        </p>
        <input
          type="text"
          value={key}
          onChange={(e) => setKey(e.target.value)}
          placeholder="key"
          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"
        />
        <select
          value={type}
          onChange={(e) => setType(e.target.value as FlatFieldType)}
          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"
        >
          <option value="string">string</option>
          <option value="number">number</option>
          <option value="boolean">boolean</option>
          <option value="date">date</option>
        </select>
        <input
          type={type === "boolean" ? "text" : type === "number" ? "number" : "text"}
          value={value}
          onChange={(e) => setValue(e.target.value)}
          placeholder={type === "boolean" ? "true / false" : "value"}
          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"
        />
        <button
          type="button"
          onClick={submit}
          disabled={!key.trim()}
          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"
        >
          apply to selected
        </button>
      </PopoverContent>
    </Popover>
  );
}
src/registry/components/data/card-tree/parts/card-actions.tsx
"use client";

import { Copy, MoreHorizontal, Plus, Trash2, TreePine } from "lucide-react";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";

/**
 * Compact card-level actions menu for edit mode (... button in header).
 * v0.3 actions: remove (cascade or promote), duplicate. Root-only when allowed.
 */
export function CardActionsMenu({
  onRemoveCascade,
  onRemovePromote,
  onDuplicate,
  canRemove,
  canDuplicate,
  isRoot,
  allowRootRemoval,
  hasChildren,
  className,
}: {
  onRemoveCascade: () => void;
  onRemovePromote?: () => void;
  onDuplicate?: () => void;
  canRemove: boolean;
  canDuplicate: boolean;
  isRoot: boolean;
  allowRootRemoval: boolean;
  hasChildren: boolean;
  className?: string;
}) {
  const removeAllowed = canRemove && (!isRoot || allowRootRemoval);
  return (
    <Popover>
      <PopoverTrigger
        className={cn(
          "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",
          className,
        )}
        aria-label="Card actions"
      >
        <MoreHorizontal className="size-3.5" />
      </PopoverTrigger>
      <PopoverContent align="end" sideOffset={4} className="w-52 p-1">
        {onDuplicate ? (
          <button
            type="button"
            onClick={onDuplicate}
            disabled={!canDuplicate || isRoot}
            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"
          >
            <Copy className="size-3.5" aria-hidden="true" />
            Duplicate
          </button>
        ) : null}
        {hasChildren && onRemovePromote ? (
          <button
            type="button"
            onClick={onRemovePromote}
            disabled={!removeAllowed}
            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"
          >
            <TreePine className="size-3.5" aria-hidden="true" />
            Remove (promote children)
          </button>
        ) : null}
        <button
          type="button"
          onClick={onRemoveCascade}
          disabled={!removeAllowed}
          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"
        >
          <Trash2 className="size-3.5" aria-hidden="true" />
          Remove{hasChildren ? " (cascade)" : ""}
        </button>
        {!removeAllowed ? (
          <p className="px-2 py-1 text-[11px] text-muted-foreground">
            {isRoot && !allowRootRemoval
              ? "Root removal disabled (allowRootRemoval=false)."
              : "Removal denied by permissions."}
          </p>
        ) : null}
      </PopoverContent>
    </Popover>
  );
}

export function AddChildButton({
  onClick,
  disabled,
}: {
  onClick: () => void;
  disabled?: boolean;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      disabled={disabled}
      className={cn(
        "inline-flex items-center gap-1 rounded-md border border-dashed border-border/70 bg-transparent px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
        disabled && "cursor-not-allowed opacity-40",
      )}
    >
      <Plus className="size-3" aria-hidden="true" />
      child
    </button>
  );
}
src/registry/components/data/card-tree/parts/card-header.tsx
import { Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FlatFieldValue, MetaRenderer, SearchMatch } from "../types";
import type { ParsedCardTree } from "../lib/parse";
import type { EditMode } from "../hooks/use-edit-mode";
import { rangesFor } from "../lib/search";
import { ChevronRight } from "lucide-react";
import { MetaInline } from "./meta-inline";
import { MetaPopover } from "./meta-popover";
import { CardTitleEdit } from "./card-title-edit";
import { CardActionsMenu } from "./card-actions";
import { DragHandle } from "./drag-handle";
import { MatchHighlight } from "./match-highlight";
import type { EditDispatchers, EditValidators } from "./card";

export function CardHeader({
  tree,
  title,
  titleId,
  collapsed,
  canCollapse,
  meta,
  metaPresentation,
  metaRenderers,
  onToggleCollapse,
  onSelect,
  editable,
  canEdit,
  canRemove,
  canDuplicate,
  canDrag,
  isLocked,
  isRoot,
  allowRootRemoval,
  hasChildren,
  defaultDeletePolicy,
  editMode,
  setEditMode,
  validators,
  dispatchers,
  tentativeCardId,
  dragHandleListeners,
  dragHandleAttributes,
  searchMatches,
  searchQuery,
  searchCaseSensitive,
  className,
}: {
  tree: ParsedCardTree;
  title: string | undefined;
  titleId: string;
  collapsed: boolean;
  canCollapse: boolean;
  meta?: Record<string, FlatFieldValue>;
  metaPresentation: "hidden" | "inline" | "popover";
  metaRenderers?: Record<string, MetaRenderer>;
  onToggleCollapse: () => void;
  onSelect: (event: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => void;
  editable: boolean;
  canEdit: boolean;
  canRemove: boolean;
  canDuplicate: boolean;
  canDrag: boolean;
  isLocked: boolean;
  isRoot: boolean;
  allowRootRemoval: boolean;
  hasChildren: boolean;
  defaultDeletePolicy: "cascade" | "promote";
  editMode: EditMode | null;
  setEditMode: (m: EditMode | null) => void;
  validators: EditValidators;
  dispatchers: EditDispatchers;
  tentativeCardId: string | null;
  dragHandleListeners?: Record<string, (event: unknown) => void>;
  dragHandleAttributes?: Record<string, unknown>;
  searchMatches: readonly SearchMatch[];
  searchQuery: string;
  searchCaseSensitive: boolean;
  className?: string;
}) {
  const showInlineMeta =
    metaPresentation === "inline" && meta && Object.keys(meta).length > 0;
  const hasMeta = meta && Object.keys(meta).length > 0;
  const isEditingTitle =
    editable &&
    canEdit &&
    editMode?.kind === "card-title" &&
    editMode.cardId === tree.id;

  const canRenameTitle = editable && canEdit && !isRoot;
  const titleRanges = title
    ? rangesFor(searchMatches, title, tree.id, "title", undefined, searchQuery, searchCaseSensitive)
    : [];

  return (
    <div
      className={cn("flex items-center gap-1.5", className)}
      onClick={(e) => {
        if (e.target === e.currentTarget) {
          onSelect({ shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey });
        }
      }}
    >
      {/* Drag handle (edit mode only) */}
      {editable && canDrag ? (
        <DragHandle
          listeners={dragHandleListeners}
          attributes={dragHandleAttributes}
          disabled={!canDrag}
        />
      ) : null}

      {canCollapse ? (
        <button
          type="button"
          onClick={(e) => {
            e.stopPropagation();
            onToggleCollapse();
          }}
          aria-label={collapsed ? "Expand card" : "Collapse card"}
          aria-expanded={!collapsed}
          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"
        >
          <ChevronRight
            className={cn(
              "size-3.5 transition-transform duration-150",
              !collapsed && "rotate-90",
            )}
          />
        </button>
      ) : (
        <span aria-hidden="true" className="inline-block size-5 shrink-0" />
      )}

      {isLocked ? (
        <Lock
          className="size-3 shrink-0 text-muted-foreground/70"
          aria-label="Locked"
        />
      ) : null}

      {isEditingTitle ? (
        <CardTitleEdit
          initialTitle={title ?? ""}
          isTentative={tentativeCardId === tree.id}
          validate={(newKey) => validators.cardRename(tree.id, newKey)}
          onCommit={(newKey) => dispatchers.cardRename(tree.id, newKey)}
          onCancel={() => setEditMode(null)}
          onCancelTentative={() => dispatchers.cardCancelTentative(tree.id)}
        />
      ) : (
        <h3
          id={titleId}
          className={cn(
            "min-w-0 flex-1 truncate text-sm font-semibold tracking-tight",
            canRenameTitle && "cursor-text rounded-sm hover:bg-muted px-1 -mx-1",
          )}
          onClick={(e) => {
            e.stopPropagation();
            onSelect({ shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey });
            if (canRenameTitle) {
              setEditMode({ kind: "card-title", cardId: tree.id });
            }
          }}
        >
          {title ? (
            <MatchHighlight text={title} ranges={titleRanges} />
          ) : (
            <span className="text-muted-foreground italic font-normal">
              Untitled
            </span>
          )}
        </h3>
      )}

      {showInlineMeta && meta ? (
        <MetaInline meta={meta} metaRenderers={metaRenderers} />
      ) : null}

      {metaPresentation === "popover" && hasMeta && meta ? (
        <MetaPopover
          meta={meta}
          cardId={tree.id}
          editable={editable && canEdit}
          metaRenderers={metaRenderers}
          dispatchers={dispatchers}
          validators={validators}
        />
      ) : null}

      {editable ? (
        <CardActionsMenu
          onRemoveCascade={() => dispatchers.cardRemove(tree.id, "cascade")}
          onRemovePromote={
            defaultDeletePolicy === "promote" || hasChildren
              ? () => dispatchers.cardRemove(tree.id, "promote")
              : undefined
          }
          onDuplicate={
            !isRoot ? () => dispatchers.cardDuplicate(tree.id) : undefined
          }
          canRemove={canRemove}
          canDuplicate={canDuplicate}
          isRoot={isRoot}
          allowRootRemoval={allowRootRemoval}
          hasChildren={hasChildren}
        />
      ) : null}
    </div>
  );
}
src/registry/components/data/card-tree/parts/card-title-edit.tsx
import { useEffect, useRef, useState } from "react";
import type { ValidationResult } from "../lib/validate-edit";
import { InlineError } from "./inline-error";

/**
 * Inline editor for a card's parentKey (its visible title).
 * Replaces the <h3> while in edit mode.
 */
export function CardTitleEdit({
  initialTitle,
  validate,
  onCommit,
  onCancel,
  onCancelTentative,
  isTentative,
}: {
  initialTitle: string;
  validate: (newTitle: string) => ValidationResult;
  onCommit: (newTitle: string) => void;
  /** Called on Escape when the card is "real" (not tentative). */
  onCancel: () => void;
  /** Called on Escape when the card was just-added and should be removed. */
  onCancelTentative: () => void;
  isTentative: boolean;
}) {
  const [title, setTitle] = useState(initialTitle);
  const validation = validate(title);
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => {
    ref.current?.focus();
    ref.current?.select();
  }, []);

  const submit = () => {
    if (validation.ok && title !== initialTitle) onCommit(title);
    else if (title === initialTitle && !isTentative) onCancel();
  };

  const cancel = () => {
    if (isTentative) onCancelTentative();
    else onCancel();
  };

  return (
    <span className="inline-flex flex-1 flex-wrap items-center gap-1">
      <input
        ref={ref}
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        onBlur={submit}
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            e.preventDefault();
            submit();
          } else if (e.key === "Escape") {
            e.preventDefault();
            cancel();
          }
        }}
        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"
        aria-label="Edit card title"
      />
      {!validation.ok ? <InlineError errors={validation.errors} /> : null}
    </span>
  );
}
src/registry/components/data/card-tree/parts/card.tsx
"use client";

import { type Dispatch } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import type {
  CustomPredefinedKey,
  FlatFieldValue,
  LevelStyle,
  MetaRenderer,
  PredefinedKey,
  SearchMatch,
} from "../types";
import {
  isCustomEntry,
  type CardTreePredefinedEntry,
  type ParsedCardTree,
} from "../lib/parse";
import { findCustomKey } from "../lib/custom-keys";
import type {
  CardTreeAction,
  CardTreeState,
} from "../lib/reducer";
import type { FlatFieldType } from "../lib/infer-type";
import type { ValidationResult } from "../lib/validate-edit";
import type { EditMode } from "../hooks/use-edit-mode";
import type { UsePermissionsReturn } from "../hooks/use-permissions";
import { CardHeader } from "./card-header";
import { FieldRow } from "./field-row";
import { PredefinedCodeArea } from "./predefined-codearea";
import { PredefinedImage } from "./predefined-image";
import { PredefinedTable } from "./predefined-table";
import { PredefinedQuote } from "./predefined-quote";
import { PredefinedList } from "./predefined-list";
import { PredefinedCustom } from "./predefined-custom";
import { PredefinedEdit } from "./predefined-edit";
import { FieldAddButton, FieldAddForm } from "./field-add";
import { PredefinedAddMenu } from "./predefined-add-menu";
import { AddChildButton } from "./card-actions";

/* ───────── public config (passed from card-tree.tsx) ───────── */

export type EditDispatchers = {
  fieldEditValue: (
    cardId: string,
    key: string,
    value: FlatFieldValue,
    type: FlatFieldType,
  ) => void;
  fieldEditKey: (cardId: string, oldKey: string, newKey: string) => void;
  fieldAdd: (
    cardId: string,
    key: string,
    value: FlatFieldValue,
    type: FlatFieldType,
  ) => void;
  fieldRemove: (cardId: string, key: string) => void;
  cardAdd: (parentId: string) => void;
  cardRemove: (cardId: string, policy?: "cascade" | "promote") => void;
  cardRename: (cardId: string, newKey: string) => void;
  cardCancelTentative: (cardId: string) => void;
  cardDuplicate: (cardId: string) => void;
  predefinedAdd: (cardId: string, entry: CardTreePredefinedEntry) => void;
  predefinedEdit: (
    cardId: string,
    key: PredefinedKey | string,
    entry: CardTreePredefinedEntry,
  ) => void;
  predefinedRemove: (cardId: string, key: PredefinedKey | string) => void;
  metaEdit: (cardId: string, key: string, value: FlatFieldValue) => void;
  metaAdd: (cardId: string, key: string, value: FlatFieldValue) => void;
  metaRemove: (cardId: string, key: string) => void;
  // Selection
  selectCard: (
    cardId: string,
    event: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean },
  ) => void;
};

export type EditValidators = {
  fieldEditValue: (
    cardId: string,
    key: string,
    value: FlatFieldValue,
    type: FlatFieldType,
  ) => ValidationResult;
  fieldEditKey: (cardId: string, oldKey: string, newKey: string) => ValidationResult;
  fieldAdd: (
    cardId: string,
    key: string,
    value: FlatFieldValue,
    type: FlatFieldType,
  ) => ValidationResult;
  cardRename: (cardId: string, newKey: string) => ValidationResult;
  metaEdit: (cardId: string, key: string, value: FlatFieldValue) => ValidationResult;
  metaAdd: (cardId: string, key: string, value: FlatFieldValue) => ValidationResult;
};

export type CardConfig = {
  resolveLevelStyle: (level: number) => LevelStyle;
  predefinedKeyStyles?: Partial<Record<PredefinedKey | string, string | LevelStyle>>;
  metaPresentation: "hidden" | "inline" | "popover";
  metaRenderers?: Record<string, MetaRenderer>;
  rootTitle?: string;
  disabledPredefinedKeys: readonly PredefinedKey[];
  // v0.2 editor surface
  editable: boolean;
  editMode: EditMode | null;
  setEditMode: (mode: EditMode | null) => void;
  clearEditMode: () => void;
  tentativeCardId: string | null;
  dispatchers: EditDispatchers;
  validators: EditValidators;
  // v0.3
  perms: UsePermissionsReturn;
  allowRootRemoval: boolean;
  defaultDeletePolicy: "cascade" | "promote";
  searchMatches: readonly SearchMatch[];
  searchQuery: string;
  searchCaseSensitive: boolean;
  dndEnabled: boolean;
  // v0.6 — resolved custom predefined-key registrations (see `resolveCustomKeys`).
  // Always present; `[]` when the host registered none.
  customKeys: readonly CustomPredefinedKey[];
};

/* ───────── helpers ───────── */

function resolvePredefinedClass(
  style: string | LevelStyle | undefined,
): string | undefined {
  if (!style) return undefined;
  if (typeof style === "string") return style;
  return style.containerClassName;
}

function PredefinedRenderer({
  entry,
  styleProp,
  editable,
  isEditing,
  cardId,
  level,
  customKeys,
  onEnterEdit,
  onCommit,
  onCancel,
  onRemove,
}: {
  entry: CardTreePredefinedEntry;
  styleProp: string | LevelStyle | undefined;
  editable: boolean;
  isEditing: boolean;
  cardId: string;
  level: number;
  customKeys: readonly CustomPredefinedKey[];
  onEnterEdit: () => void;
  onCommit: (next: CardTreePredefinedEntry) => void;
  onCancel: () => void;
  onRemove: () => void;
}) {
  if (isEditing) {
    return (
      <PredefinedEdit
        entry={entry}
        customKeys={customKeys}
        onCommit={onCommit}
        onCancel={onCancel}
      />
    );
  }
  const className = resolvePredefinedClass(styleProp);
  // NARROWING CONTRACT: peel the custom variant off first — `entry.key` is an
  // open `string` on `CardTreeCustomEntry`, so a bare `switch (entry.key)`
  // would keep it a live candidate in every case and collapse `entry.value`
  // to `unknown`. See lib/parse.ts `isCustomEntry`.
  const block = isCustomEntry(entry) ? (
    <PredefinedCustom
      entry={entry}
      registration={findCustomKey(customKeys, entry.key)}
      ctx={{ cardId, level, isEditing: false, className }}
    />
  ) : (
    (() => {
      switch (entry.key) {
        case "codearea":
          return <PredefinedCodeArea value={entry.value} className={className} />;
        case "image":
          return <PredefinedImage value={entry.value} className={className} />;
        case "table":
          return <PredefinedTable value={entry.value} className={className} />;
        case "quote":
          return <PredefinedQuote value={entry.value} className={className} />;
        case "list":
          return <PredefinedList value={entry.value} className={className} />;
      }
    })()
  );
  if (!editable) return block;
  return (
    <div className="group relative">
      <button
        type="button"
        onClick={onEnterEdit}
        className="block w-full cursor-text text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md"
        aria-label={`Edit ${entry.key}`}
      >
        {block}
      </button>
      <button
        type="button"
        onClick={(e) => {
          e.stopPropagation();
          onRemove();
        }}
        aria-label={`Remove ${entry.key}`}
        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"
      >
        ×
      </button>
    </div>
  );
}

/* ───────── Card (recursive, sortable) ───────── */

export function Card({
  tree,
  config,
  state,
  dispatch,
}: {
  tree: ParsedCardTree;
  config: CardConfig;
  state: CardTreeState;
  dispatch: Dispatch<CardTreeAction>;
}) {
  const { perms } = config;
  const collapsed = state.collapsed.has(tree.id);
  const isFocused = state.focusedId === tree.id;
  const isSelected = state.selectedIds.has(tree.id);
  const hasChildren = tree.children.length > 0;
  const hasFields = tree.fields.length > 0;
  const hasPredefined = tree.predefined.length > 0;
  const hasBody = hasFields || hasPredefined;
  const canCollapse = hasBody || hasChildren;
  const isLocked = perms.isLocked(tree.id);

  const levelStyle = config.resolveLevelStyle(tree.level);
  const titleId = `rcc-title-${tree.id}`;
  const title =
    tree.parentKey ?? (tree.level === 1 ? config.rootTitle : undefined);

  const isAddingField =
    config.editable &&
    perms.canAddField(tree.id) &&
    config.editMode?.kind === "field-add" &&
    config.editMode.cardId === tree.id;

  const presentPredefinedKeys = tree.predefined.map((p) => p.key);
  const isRoot = tree.level === 1;

  // v0.3: sortable integration
  const {
    attributes: sortableAttributes,
    listeners: sortableListeners,
    setNodeRef: sortableSetNodeRef,
    transform: sortableTransform,
    transition: sortableTransition,
    isDragging: sortableIsDragging,
  } = useSortable({
    id: tree.id,
    disabled:
      !config.dndEnabled || !config.editable || isRoot || !perms.canDragCard(tree.id),
  });
  const sortableStyle = {
    transform: CSS.Translate.toString(sortableTransform),
    transition: sortableTransition,
    opacity: sortableIsDragging ? 0.5 : 1,
  };

  const canEdit = perms.canEditCard(tree.id);
  const canAddField = perms.canAddField(tree.id);
  const canRemoveCard = perms.canRemoveCard(tree.id);
  const canAddCard = perms.canAddCard(tree.id);
  const canDuplicateCard = !isRoot && perms.canEditCard(tree.id);
  const canDrag = !isRoot && perms.canDragCard(tree.id);

  return (
    <li
      ref={sortableSetNodeRef}
      role="treeitem"
      aria-level={tree.level}
      aria-expanded={canCollapse ? !collapsed : undefined}
      aria-selected={isSelected}
      aria-labelledby={titleId}
      data-readonly={isLocked || undefined}
      tabIndex={isFocused ? 0 : -1}
      data-rcid={tree.id}
      style={sortableStyle}
      onFocus={(e) => {
        if (e.target === e.currentTarget) {
          dispatch({ type: "set-focus", id: tree.id });
        }
      }}
      className={cn(
        "block focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        levelStyle.containerClassName,
        isSelected &&
          "ring-2 ring-primary/40 ring-offset-1 ring-offset-background",
        isLocked && "opacity-90",
      )}
    >
      <CardHeader
        tree={tree}
        title={title}
        titleId={titleId}
        collapsed={collapsed}
        canCollapse={canCollapse}
        meta={tree.meta}
        metaPresentation={config.metaPresentation}
        metaRenderers={config.metaRenderers}
        onToggleCollapse={() =>
          dispatch({ type: "toggle-collapse", id: tree.id })
        }
        onSelect={(event) => config.dispatchers.selectCard(tree.id, event)}
        editable={config.editable}
        canEdit={canEdit}
        canRemove={canRemoveCard}
        canDuplicate={canDuplicateCard}
        canDrag={canDrag}
        isLocked={isLocked}
        isRoot={isRoot}
        allowRootRemoval={config.allowRootRemoval}
        hasChildren={hasChildren}
        defaultDeletePolicy={config.defaultDeletePolicy}
        editMode={config.editMode}
        setEditMode={config.setEditMode}
        validators={config.validators}
        dispatchers={config.dispatchers}
        tentativeCardId={config.tentativeCardId}
        dragHandleListeners={
          sortableListeners as
            | Record<string, (event: unknown) => void>
            | undefined
        }
        dragHandleAttributes={
          sortableAttributes as unknown as Record<string, unknown> | undefined
        }
        searchMatches={config.searchMatches}
        searchQuery={config.searchQuery}
        searchCaseSensitive={config.searchCaseSensitive}
        className={levelStyle.headerClassName}
      />

      {hasBody && !collapsed ? (
        <div className={cn("mt-2 space-y-2", levelStyle.fieldsClassName)}>
          {hasFields ? (
            <dl className="grid gap-1">
              {tree.fields.map((f) => (
                <FieldRow
                  key={f.key}
                  cardId={tree.id}
                  fieldKey={f.key}
                  value={f.value}
                  type={f.type}
                  editable={config.editable}
                  canEdit={perms.canEditField(tree.id, f.key)}
                  canEditKey={perms.canEditField(tree.id, f.key)}
                  canRemove={perms.canRemoveField(tree.id, f.key)}
                  editMode={config.editMode}
                  setEditMode={config.setEditMode}
                  validators={config.validators}
                  dispatchers={config.dispatchers}
                  searchMatches={config.searchMatches}
                  searchQuery={config.searchQuery}
                  searchCaseSensitive={config.searchCaseSensitive}
                />
              ))}
            </dl>
          ) : null}

          {hasPredefined ? (
            <div className="space-y-2">
              {tree.predefined.map((entry, i) => {
                const isEditing =
                  config.editable &&
                  perms.canEditPredefined(tree.id, String(entry.key)) &&
                  config.editMode?.kind === "predefined" &&
                  config.editMode.cardId === tree.id &&
                  config.editMode.key === entry.key;
                return (
                  <PredefinedRenderer
                    key={`${entry.key}-${i}`}
                    entry={entry}
                    styleProp={config.predefinedKeyStyles?.[entry.key]}
                    editable={
                      config.editable &&
                      perms.canEditPredefined(tree.id, String(entry.key))
                    }
                    isEditing={isEditing}
                    cardId={tree.id}
                    level={tree.level}
                    customKeys={config.customKeys}
                    onEnterEdit={() =>
                      config.setEditMode({
                        kind: "predefined",
                        cardId: tree.id,
                        // No cast: EditMode.key is `PredefinedKey | string` as of
                        // v0.6 precisely so a custom key's name fits honestly.
                        key: entry.key,
                      })
                    }
                    onCommit={(next) =>
                      config.dispatchers.predefinedEdit(
                        tree.id,
                        entry.key,
                        next,
                      )
                    }
                    onCancel={() => config.clearEditMode()}
                    onRemove={() =>
                      config.dispatchers.predefinedRemove(tree.id, entry.key)
                    }
                  />
                );
              })}
            </div>
          ) : null}
        </div>
      ) : null}

      {/* v0.4.2 — add affordances rendered independently of `hasBody` so an
          empty card (zero fields, zero predefined) can still be populated.
          Pre-v0.4.2 this block sat inside the body wrapper above, making the
          "+ FIELD" / "+ BLOCK" buttons unreachable for a freshly-added child
          card (cardAdd creates fields:[]/predefined:[], so hasBody=false). */}
      {config.editable && !collapsed ? (
        <div className={cn("mt-2 flex flex-wrap items-center gap-1.5", levelStyle.fieldsClassName)}>
          {isAddingField ? (
            <FieldAddForm
              validateAll={(key, value, type) =>
                config.validators.fieldAdd(tree.id, key, value, type)
              }
              onCommit={(key, value, type) =>
                config.dispatchers.fieldAdd(tree.id, key, value, type)
              }
              onCancel={() => config.clearEditMode()}
            />
          ) : canAddField ? (
            <FieldAddButton
              onClick={() =>
                config.setEditMode({
                  kind: "field-add",
                  cardId: tree.id,
                })
              }
            />
          ) : null}
          <PredefinedAddMenu
            presentKeys={presentPredefinedKeys}
            disabledKeys={config.disabledPredefinedKeys}
            customKeys={config.customKeys}
            onAdd={(entry) =>
              config.dispatchers.predefinedAdd(tree.id, entry)
            }
          />
        </div>
      ) : null}

      {hasChildren && !collapsed ? (
        <ul
          role="group"
          className={cn(
            "mt-2 space-y-1.5 ps-2 border-s border-border/40",
            levelStyle.childrenClassName,
          )}
        >
          {tree.children.map((child) => (
            <Card
              key={child.id}
              tree={child}
              config={config}
              state={state}
              dispatch={dispatch}
            />
          ))}
        </ul>
      ) : null}

      {/* "+ child" button in edit mode */}
      {config.editable && canAddCard && !collapsed ? (
        <div className={cn("mt-2", hasChildren && "ps-2")}>
          <AddChildButton
            onClick={() => config.dispatchers.cardAdd(tree.id)}
            disabled={!canAddCard}
          />
        </div>
      ) : null}
    </li>
  );
}
src/registry/components/data/card-tree/parts/drag-handle.tsx
import { GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";

/**
 * Visible drag affordance — small grip icon. Lives in card header (edit mode).
 * Connects to @dnd-kit via the `dragListeners` and `dragAttributes` from useSortable
 * applied at the parent (Card).
 */
export function DragHandle({
  listeners,
  attributes,
  disabled,
  className,
}: {
  listeners?: Record<string, (event: unknown) => void>;
  attributes?: Record<string, unknown>;
  disabled?: boolean;
  className?: string;
}) {
  return (
    <button
      type="button"
      {...attributes}
      {...listeners}
      disabled={disabled}
      aria-label="Drag to reorder"
      className={cn(
        "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",
        disabled && "cursor-not-allowed opacity-30",
        className,
      )}
    >
      <GripVertical className="size-3.5" aria-hidden="true" />
    </button>
  );
}
src/registry/components/data/card-tree/parts/drop-indicator.tsx
import { cn } from "@/lib/utils";

/**
 * Same-level (between-siblings) drop indicator: thin horizontal line at the drop position.
 */
export function BetweenDropIndicator({
  active,
  className,
}: {
  active: boolean;
  className?: string;
}) {
  return (
    <div
      aria-hidden="true"
      className={cn(
        "h-0.5 -my-px rounded-full transition-colors",
        active ? "bg-primary" : "bg-transparent",
        className,
      )}
    />
  );
}

/**
 * Cross-level (into-card) drop indicator: outline highlight on the target card.
 * Renders as a wrapper. The Card component conditionally applies it.
 */
export function IntoDropOutline({
  active,
  rejected,
  className,
}: {
  active: boolean;
  rejected?: boolean;
  className?: string;
}) {
  return (
    <div
      aria-hidden="true"
      className={cn(
        "pointer-events-none absolute inset-0 rounded-md ring-2 ring-offset-1 ring-offset-background transition-opacity",
        active ? "opacity-100" : "opacity-0",
        rejected ? "ring-destructive" : "ring-primary",
        className,
      )}
    />
  );
}
src/registry/components/data/card-tree/parts/empty-tree.tsx
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";

/**
 * Default empty-tree placeholder shown when the root has been removed
 * (allowRootRemoval={true} + onRootRemoved returned null).
 *
 * Hosts can override this entirely via the emptyTreeRenderer prop.
 */
export function EmptyTreePlaceholder({
  onAddRoot,
  className,
}: {
  onAddRoot?: () => void;
  className?: string;
}) {
  return (
    <div
      role="status"
      className={cn(
        "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",
        className,
      )}
    >
      <p className="text-sm text-muted-foreground">Tree is empty.</p>
      {onAddRoot ? (
        <button
          type="button"
          onClick={onAddRoot}
          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"
        >
          <Plus className="size-3" aria-hidden="true" />
          add root card
        </button>
      ) : null}
    </div>
  );
}
src/registry/components/data/card-tree/parts/field-add.tsx
import { useEffect, useRef, useState } from "react";
import { Check, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FlatFieldValue } from "../types";
import type { FlatFieldType } from "../lib/infer-type";
import type { ValidationResult } from "../lib/validate-edit";
import { InlineError } from "./inline-error";

const ADDABLE_TYPES: FlatFieldType[] = [
  "string",
  "number",
  "boolean",
  "date",
];

function defaultValueFor(type: FlatFieldType): FlatFieldValue {
  switch (type) {
    case "number":
      return 0;
    case "boolean":
      return false;
    case "date":
      return new Date().toISOString().slice(0, 10);
    case "null":
      return null;
    case "string":
    default:
      return "";
  }
}

/**
 * Inline form for adding a flat field. Replaces the "+ field" button
 * location while open. Press Enter to add, Escape to cancel.
 */
export function FieldAddForm({
  validateAll,
  onCommit,
  onCancel,
}: {
  validateAll: (
    key: string,
    value: FlatFieldValue,
    type: FlatFieldType,
  ) => ValidationResult;
  onCommit: (
    key: string,
    value: FlatFieldValue,
    type: FlatFieldType,
  ) => void;
  onCancel: () => void;
}) {
  const [key, setKey] = useState("");
  const [type, setType] = useState<FlatFieldType>("string");
  const [value, setValue] = useState<FlatFieldValue>(defaultValueFor("string"));
  const keyRef = useRef<HTMLInputElement>(null);
  useEffect(() => {
    keyRef.current?.focus();
  }, []);

  const validation = validateAll(key, value, type);

  const submit = () => {
    if (validation.ok) {
      onCommit(key, value, type);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Enter") {
      e.preventDefault();
      submit();
    } else if (e.key === "Escape") {
      e.preventDefault();
      onCancel();
    }
  };

  return (
    <div
      className="space-y-1.5 rounded-md border border-dashed border-border/70 bg-muted/30 p-2"
      onKeyDown={handleKeyDown}
    >
      <div className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)_minmax(0,auto)] items-center gap-1.5">
        <span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
          key
        </span>
        <input
          ref={keyRef}
          type="text"
          value={key}
          onChange={(e) => setKey(e.target.value)}
          placeholder="field name"
          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"
          aria-label="New field key"
        />
        <select
          value={type}
          onChange={(e) => {
            const t = e.target.value as FlatFieldType;
            setType(t);
            setValue(defaultValueFor(t));
          }}
          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"
          aria-label="New field type"
        >
          {ADDABLE_TYPES.map((t) => (
            <option key={t} value={t}>
              {t}
            </option>
          ))}
        </select>
      </div>

      <div className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] items-center gap-1.5">
        <span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
          value
        </span>
        <ValueInputForType type={type} value={value} onChange={setValue} />
      </div>

      <div className="flex items-center justify-between gap-2">
        {!validation.ok && key.length > 0 ? (
          <InlineError errors={validation.errors} />
        ) : (
          <span />
        )}
        <div className="flex items-center gap-1">
          <button
            type="button"
            onClick={onCancel}
            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"
            aria-label="Cancel add field"
          >
            <X className="size-3.5" aria-hidden="true" />
          </button>
          <button
            type="button"
            onClick={submit}
            disabled={!validation.ok}
            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"
          >
            <Check className="size-3" aria-hidden="true" />
            add
          </button>
        </div>
      </div>
    </div>
  );
}

function ValueInputForType({
  type,
  value,
  onChange,
}: {
  type: FlatFieldType;
  value: FlatFieldValue;
  onChange: (v: FlatFieldValue) => void;
}) {
  const baseClass =
    "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";
  switch (type) {
    case "boolean":
      return (
        <label className="inline-flex items-center gap-1.5 text-sm">
          <input
            type="checkbox"
            checked={value === true}
            onChange={(e) => onChange(e.target.checked)}
            className="size-4 rounded-sm"
          />
          <span className="font-mono text-[11px] text-muted-foreground">
            {value === true ? "true" : "false"}
          </span>
        </label>
      );
    case "number":
      return (
        <input
          type="number"
          value={typeof value === "number" ? String(value) : ""}
          onChange={(e) => {
            const n = Number(e.target.value);
            onChange(Number.isFinite(n) ? n : 0);
          }}
          className={cn(baseClass, "w-32 text-right font-mono tabular-nums")}
          aria-label="New field number value"
        />
      );
    case "date":
      return (
        <input
          type="date"
          value={typeof value === "string" ? value.slice(0, 10) : ""}
          onChange={(e) => onChange(e.target.value)}
          className={cn(baseClass, "min-w-32")}
          aria-label="New field date value"
        />
      );
    case "null":
      return null;
    case "string":
    default:
      return (
        <input
          type="text"
          value={typeof value === "string" ? value : ""}
          onChange={(e) => onChange(e.target.value)}
          placeholder="value"
          className={cn(baseClass, "min-w-32")}
          aria-label="New field string value"
        />
      );
  }
}

/**
 * Lightweight "+ field" button that triggers FieldAddForm.
 */
export function FieldAddButton({
  onClick,
}: {
  onClick: () => void;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      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"
    >
      <Plus className="size-3" aria-hidden="true" />
      field
    </button>
  );
}
src/registry/components/data/card-tree/parts/field-edit.tsx
import {
  type ChangeEvent,
  type KeyboardEvent,
  useEffect,
  useRef,
  useState,
} from "react";
import { Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FlatFieldValue } from "../types";
import type { FlatFieldType } from "../lib/infer-type";
import type { ValidationResult } from "../lib/validate-edit";
import { InlineError } from "./inline-error";

/* ───────── value editor (type-aware) ───────── */

export function FieldValueEdit({
  initialValue,
  type,
  validate,
  onCommit,
  onCancel,
}: {
  initialValue: FlatFieldValue;
  type: FlatFieldType;
  validate: (value: FlatFieldValue) => ValidationResult;
  onCommit: (value: FlatFieldValue) => void;
  onCancel: () => void;
}) {
  const [value, setValue] = useState<FlatFieldValue>(initialValue);
  const validation = validate(value);

  const submit = () => {
    if (validation.ok) onCommit(value);
  };

  const handleKey = (e: KeyboardEvent<HTMLElement>) => {
    if (e.key === "Enter" && type !== "boolean") {
      e.preventDefault();
      submit();
    } else if (e.key === "Escape") {
      e.preventDefault();
      onCancel();
    }
  };

  return (
    <span
      className="inline-flex flex-wrap items-center gap-1"
      onKeyDown={handleKey}
    >
      <ValueInput
        type={type}
        value={value}
        onChange={(v) => {
          setValue(v);
          // For booleans, commit immediately on toggle (Q-P9).
          if (type === "boolean" && validation.ok) {
            onCommit(v);
          }
        }}
        onBlur={() => {
          // Don't commit on blur from boolean — already committed on toggle.
          if (type === "boolean") return;
          submit();
        }}
      />
      {type !== "boolean" ? (
        <span className="inline-flex items-center gap-0.5">
          <button
            type="button"
            onClick={submit}
            disabled={!validation.ok}
            aria-label="Save"
            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"
          >
            <Check className="size-3.5" aria-hidden="true" />
          </button>
          <button
            type="button"
            onClick={onCancel}
            aria-label="Cancel"
            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"
          >
            <X className="size-3.5" aria-hidden="true" />
          </button>
        </span>
      ) : null}
      {!validation.ok ? <InlineError errors={validation.errors} /> : null}
    </span>
  );
}

function ValueInput({
  type,
  value,
  onChange,
  onBlur,
}: {
  type: FlatFieldType;
  value: FlatFieldValue;
  onChange: (v: FlatFieldValue) => void;
  onBlur: () => void;
}) {
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => {
    ref.current?.focus();
    if (ref.current && typeof ref.current.select === "function") {
      try {
        ref.current.select();
      } catch {
        /* some input types don't support select() */
      }
    }
  }, []);

  const baseClass =
    "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";

  switch (type) {
    case "boolean":
      return (
        <input
          ref={ref}
          type="checkbox"
          checked={value === true}
          onChange={(e) => onChange(e.target.checked)}
          onBlur={onBlur}
          className="size-4 rounded-sm border border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          aria-label="Edit boolean value"
        />
      );
    case "number":
      return (
        <input
          ref={ref}
          type="number"
          value={value === null || typeof value !== "number" ? "" : String(value)}
          onChange={(e: ChangeEvent<HTMLInputElement>) => {
            const raw = e.target.value;
            if (raw === "") {
              onChange(0);
              return;
            }
            const n = Number(raw);
            onChange(Number.isFinite(n) ? n : 0);
          }}
          onBlur={onBlur}
          className={cn(baseClass, "w-24 text-right font-mono tabular-nums")}
          aria-label="Edit number value"
        />
      );
    case "date": {
      const isoString = typeof value === "string" ? value : "";
      const hasTime = /T\d{2}:\d{2}/.test(isoString);
      const inputType = hasTime ? "datetime-local" : "date";
      // datetime-local doesn't accept Z-suffix; strip for display
      const inputValue = hasTime
        ? isoString.replace(/Z$/, "").slice(0, 16)
        : isoString.slice(0, 10);
      return (
        <input
          ref={ref}
          type={inputType}
          value={inputValue}
          onChange={(e) => onChange(e.target.value)}
          onBlur={onBlur}
          className={cn(baseClass, "min-w-32")}
          aria-label="Edit date value"
        />
      );
    }
    case "null":
      return (
        <span className="text-muted-foreground italic">
          (read-only — null fields cannot be edited)
        </span>
      );
    case "string":
    default:
      return (
        <input
          ref={ref}
          type="text"
          value={typeof value === "string" ? value : ""}
          onChange={(e) => onChange(e.target.value)}
          onBlur={onBlur}
          className={cn(baseClass, "min-w-32")}
          aria-label="Edit string value"
        />
      );
  }
}

/* ───────── key editor (rename) ───────── */

export function FieldKeyEdit({
  initialKey,
  validate,
  onCommit,
  onCancel,
}: {
  initialKey: string;
  validate: (newKey: string) => ValidationResult;
  onCommit: (newKey: string) => void;
  onCancel: () => void;
}) {
  const [key, setKey] = useState(initialKey);
  const validation = validate(key);
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => {
    ref.current?.focus();
    ref.current?.select();
  }, []);

  const submit = () => {
    if (validation.ok && key !== initialKey) onCommit(key);
    else if (key === initialKey) onCancel();
  };

  return (
    <span className="inline-flex flex-wrap items-center gap-1">
      <input
        ref={ref}
        type="text"
        value={key}
        onChange={(e) => setKey(e.target.value)}
        onBlur={submit}
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            e.preventDefault();
            submit();
          } else if (e.key === "Escape") {
            e.preventDefault();
            onCancel();
          }
        }}
        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"
        aria-label="Rename field key"
      />
      {!validation.ok ? <InlineError errors={validation.errors} /> : null}
    </span>
  );
}
src/registry/components/data/card-tree/parts/field-row.tsx
import { Check, Minus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FlatFieldValue, SearchMatch } from "../types";
import type { FlatFieldType } from "../lib/infer-type";
import type { EditMode } from "../hooks/use-edit-mode";
import type { EditDispatchers, EditValidators } from "./card";
import { FieldKeyEdit, FieldValueEdit } from "./field-edit";
import { MatchHighlight } from "./match-highlight";
import { rangesFor } from "../lib/search";

function formatDate(iso: string): string {
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return iso;
  const hasTime = /T\d{2}:\d{2}/.test(iso);
  return new Intl.DateTimeFormat(undefined, {
    dateStyle: "medium",
    ...(hasTime ? { timeStyle: "short" } : {}),
  }).format(d);
}

function renderTypedValue(value: FlatFieldValue, type: FlatFieldType, ranges: Array<{ start: number; length: number }>) {
  switch (type) {
    case "null":
      return <span className="text-muted-foreground">—</span>;
    case "boolean":
      return (
        <span className="inline-flex items-center gap-1.5">
          {value === true ? (
            <Check className="size-3.5 text-primary" aria-hidden="true" />
          ) : (
            <Minus className="size-3.5 text-muted-foreground" aria-hidden="true" />
          )}
          <span className="sr-only">{String(value)}</span>
        </span>
      );
    case "number":
      return (
        <span className="font-mono tabular-nums text-foreground/90">
          <MatchHighlight text={String(value)} ranges={ranges} />
        </span>
      );
    case "date":
      return (
        <span title={String(value)} className="text-foreground/90">
          <MatchHighlight text={formatDate(String(value))} ranges={[]} />
        </span>
      );
    case "string":
    default:
      return (
        <span>
          <MatchHighlight text={String(value)} ranges={ranges} />
        </span>
      );
  }
}

export function FieldRow({
  cardId,
  fieldKey,
  value,
  type,
  editable,
  canEdit,
  canEditKey,
  canRemove,
  editMode,
  setEditMode,
  validators,
  dispatchers,
  searchMatches,
  searchQuery,
  searchCaseSensitive,
}: {
  cardId: string;
  fieldKey: string;
  value: FlatFieldValue;
  type: FlatFieldType;
  editable: boolean;
  canEdit: boolean;
  canEditKey: boolean;
  canRemove: boolean;
  editMode: EditMode | null;
  setEditMode: (m: EditMode | null) => void;
  validators: EditValidators;
  dispatchers: EditDispatchers;
  searchMatches: readonly SearchMatch[];
  searchQuery: string;
  searchCaseSensitive: boolean;
}) {
  const isEditingValue =
    editable &&
    canEdit &&
    editMode?.kind === "field-value" &&
    editMode.cardId === cardId &&
    editMode.key === fieldKey;
  const isEditingKey =
    editable &&
    canEditKey &&
    editMode?.kind === "field-key" &&
    editMode.cardId === cardId &&
    editMode.key === fieldKey;

  const canEditValue = editable && canEdit && type !== "null";

  const keyRanges = rangesFor(searchMatches, fieldKey, cardId, "field-key", fieldKey, searchQuery, searchCaseSensitive);
  const valueText = value === null ? "" : String(value);
  const valueRanges = rangesFor(searchMatches, valueText, cardId, "field-value", fieldKey, searchQuery, searchCaseSensitive);

  return (
    <div className="group grid grid-cols-[minmax(0,auto)_minmax(0,1fr)_auto] items-baseline gap-x-3 gap-y-0.5">
      <dt
        className={cn(
          "truncate font-mono text-[11px] text-muted-foreground",
          editable && canEditKey && "cursor-text rounded-sm hover:bg-muted px-1 -mx-1",
        )}
        onClick={() => {
          if (!editable || !canEditKey) return;
          if (isEditingKey) return;
          setEditMode({ kind: "field-key", cardId, key: fieldKey });
        }}
      >
        {isEditingKey ? (
          <FieldKeyEdit
            initialKey={fieldKey}
            validate={(newKey) => validators.fieldEditKey(cardId, fieldKey, newKey)}
            onCommit={(newKey) => dispatchers.fieldEditKey(cardId, fieldKey, newKey)}
            onCancel={() => setEditMode(null)}
          />
        ) : (
          <MatchHighlight text={fieldKey} ranges={keyRanges} />
        )}
      </dt>
      <dd
        className="min-w-0 wrap-break-word text-sm text-foreground"
        onClick={() => {
          if (!canEditValue) return;
          if (isEditingValue) return;
          setEditMode({ kind: "field-value", cardId, key: fieldKey });
        }}
      >
        {isEditingValue ? (
          <FieldValueEdit
            initialValue={value}
            type={type}
            validate={(newValue) =>
              validators.fieldEditValue(cardId, fieldKey, newValue, type)
            }
            onCommit={(newValue) =>
              dispatchers.fieldEditValue(cardId, fieldKey, newValue, type)
            }
            onCancel={() => setEditMode(null)}
          />
        ) : (
          <span
            className={cn(
              canEditValue && "cursor-text rounded-sm hover:bg-muted px-1 -mx-1",
            )}
          >
            {renderTypedValue(value, type, valueRanges)}
          </span>
        )}
      </dd>
      {editable && canRemove ? (
        <button
          type="button"
          onClick={(e) => {
            e.stopPropagation();
            dispatchers.fieldRemove(cardId, fieldKey);
          }}
          aria-label={`Remove field ${fieldKey}`}
          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"
        >
          <X className="size-3" aria-hidden="true" />
        </button>
      ) : (
        <span aria-hidden="true" />
      )}
    </div>
  );
}
src/registry/components/data/card-tree/parts/inline-error.tsx
import { AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ValidationError } from "../lib/validate-edit";

export function InlineError({
  errors,
  className,
}: {
  errors: ValidationError[];
  className?: string;
}) {
  if (errors.length === 0) return null;
  return (
    <div
      role="alert"
      aria-live="polite"
      className={cn(
        "mt-1 inline-flex items-start gap-1.5 rounded-sm bg-destructive/10 px-1.5 py-0.5 text-[11px] text-destructive",
        className,
      )}
    >
      <AlertCircle className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
      <span>
        {errors.length === 1
          ? errors[0]?.message
          : errors.map((e) => e.message).join(" · ")}
      </span>
    </div>
  );
}
src/registry/components/data/card-tree/parts/match-highlight.tsx
import { Fragment } from "react";
import { cn } from "@/lib/utils";

export type HighlightRange = { start: number; length: number };

/**
 * Wraps matched substrings in <mark>. Active ranges get a distinct style.
 * Non-matched portions render as plain text.
 *
 * Used by every text-rendering location in the tree to display search hits.
 * When `ranges` is empty, this is a transparent passthrough.
 */
export function MatchHighlight({
  text,
  ranges,
  activeRange,
  className,
}: {
  text: string;
  ranges: readonly HighlightRange[];
  activeRange?: HighlightRange;
  className?: string;
}) {
  if (ranges.length === 0) {
    return className ? <span className={className}>{text}</span> : <>{text}</>;
  }
  // Sort and merge ranges
  const sorted = [...ranges].sort((a, b) => a.start - b.start);
  const segments: Array<{
    start: number;
    end: number;
    highlight: boolean;
    isActive: boolean;
  }> = [];
  let cursor = 0;
  for (const r of sorted) {
    if (r.start < cursor) continue; // skip overlapping
    if (r.start > cursor) {
      segments.push({ start: cursor, end: r.start, highlight: false, isActive: false });
    }
    const isActive =
      activeRange !== undefined &&
      activeRange.start === r.start &&
      activeRange.length === r.length;
    segments.push({
      start: r.start,
      end: r.start + r.length,
      highlight: true,
      isActive,
    });
    cursor = r.start + r.length;
  }
  if (cursor < text.length) {
    segments.push({ start: cursor, end: text.length, highlight: false, isActive: false });
  }
  return (
    <span className={className}>
      {segments.map((s, i) => {
        const slice = text.slice(s.start, s.end);
        if (!s.highlight) return <Fragment key={i}>{slice}</Fragment>;
        return (
          <mark
            key={i}
            className={cn(
              "rounded-sm bg-amber-200/70 text-foreground dark:bg-amber-500/30",
              s.isActive && "bg-amber-400 text-foreground dark:bg-amber-400/80",
            )}
          >
            {slice}
          </mark>
        );
      })}
    </span>
  );
}
src/registry/components/data/card-tree/parts/meta-edit.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FlatFieldValue, MetaRenderer } from "../types";
import type { ValidationResult } from "../lib/validate-edit";
import { InlineError } from "./inline-error";

/**
 * Inline meta editor used inside the popover (and inline-strip when desired).
 * Renders meta entries with edit/remove affordances + an "+ meta" button.
 */
export function MetaEditList({
  meta,
  cardId,
  metaRenderers,
  onEdit,
  onAdd,
  onRemove,
  validateAdd,
  validateEdit,
  className,
}: {
  meta: Record<string, FlatFieldValue> | undefined;
  cardId: string;
  metaRenderers?: Record<string, MetaRenderer>;
  onEdit: (key: string, value: FlatFieldValue) => void;
  onAdd: (key: string, value: FlatFieldValue) => void;
  onRemove: (key: string) => void;
  validateAdd: (key: string, value: FlatFieldValue) => ValidationResult;
  validateEdit: (key: string, value: FlatFieldValue) => ValidationResult;
  className?: string;
}) {
  const entries = meta ? Object.entries(meta) : [];
  const [adding, setAdding] = useState(false);
  const [editingKey, setEditingKey] = useState<string | null>(null);

  return (
    <div className={cn("space-y-1.5", className)}>
      <div className="flex items-center justify-between">
        <p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
          Meta
        </p>
        {!adding ? (
          <button
            type="button"
            onClick={() => setAdding(true)}
            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"
            aria-label="Add meta entry"
          >
            <Plus className="size-2.5" aria-hidden="true" />
            add
          </button>
        ) : null}
      </div>

      <dl className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)_auto] gap-x-2 gap-y-1 text-sm">
        {entries.map(([k, v]) => (
          <MetaRow
            key={k}
            metaKey={k}
            value={v}
            cardId={cardId}
            isEditing={editingKey === k}
            metaRenderers={metaRenderers}
            onEnterEdit={() => setEditingKey(k)}
            onCommitEdit={(newValue) => {
              onEdit(k, newValue);
              setEditingKey(null);
            }}
            onCancelEdit={() => setEditingKey(null)}
            onRemove={() => onRemove(k)}
            validate={(newValue) => validateEdit(k, newValue)}
          />
        ))}
      </dl>

      {adding ? (
        <MetaAddRow
          validate={validateAdd}
          onCommit={(k, v) => {
            onAdd(k, v);
            setAdding(false);
          }}
          onCancel={() => setAdding(false)}
        />
      ) : null}
    </div>
  );
}

function MetaRow({
  metaKey,
  value,
  cardId,
  isEditing,
  metaRenderers,
  onEnterEdit,
  onCommitEdit,
  onCancelEdit,
  onRemove,
  validate,
}: {
  metaKey: string;
  value: FlatFieldValue;
  cardId: string;
  isEditing: boolean;
  metaRenderers?: Record<string, MetaRenderer>;
  onEnterEdit: () => void;
  onCommitEdit: (newValue: FlatFieldValue) => void;
  onCancelEdit: () => void;
  onRemove: () => void;
  validate: (newValue: FlatFieldValue) => ValidationResult;
}) {
  const renderer = metaRenderers?.[metaKey];
  return (
    <div className="contents">
      <dt className="truncate font-mono text-[11px] text-muted-foreground">
        {metaKey}
      </dt>
      <dd className="min-w-0">
        {isEditing ? (
          <MetaValueInput
            initial={value}
            validate={validate}
            onCommit={onCommitEdit}
            onCancel={onCancelEdit}
          />
        ) : renderer ? (
          renderer(value, { cardId, metaKey })
        ) : (
          <button
            type="button"
            onClick={onEnterEdit}
            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"
          >
            {value === null ? "—" : String(value)}
          </button>
        )}
      </dd>
      <button
        type="button"
        onClick={onRemove}
        aria-label={`Remove meta ${metaKey}`}
        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"
      >
        <X className="size-3" aria-hidden="true" />
      </button>
    </div>
  );
}

function MetaValueInput({
  initial,
  validate,
  onCommit,
  onCancel,
}: {
  initial: FlatFieldValue;
  validate: (v: FlatFieldValue) => ValidationResult;
  onCommit: (v: FlatFieldValue) => void;
  onCancel: () => void;
}) {
  const [text, setText] = useState(initial === null ? "" : String(initial));
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => {
    ref.current?.focus();
    ref.current?.select();
  }, []);
  const value = parseValue(initial, text);
  const validation = validate(value);

  return (
    <span className="inline-flex flex-wrap items-center gap-1">
      <input
        ref={ref}
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        onBlur={() => {
          if (validation.ok) onCommit(value);
        }}
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            e.preventDefault();
            if (validation.ok) onCommit(value);
          } else if (e.key === "Escape") {
            e.preventDefault();
            onCancel();
          }
        }}
        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"
        aria-label="Edit meta value"
      />
      {!validation.ok ? <InlineError errors={validation.errors} /> : null}
    </span>
  );
}

function parseValue(initial: FlatFieldValue, text: string): FlatFieldValue {
  // Preserve type of original
  if (typeof initial === "number") {
    const n = Number(text);
    return Number.isFinite(n) ? n : 0;
  }
  if (typeof initial === "boolean") {
    return text === "true";
  }
  if (initial === null) {
    return text === "" ? null : text;
  }
  return text;
}

function MetaAddRow({
  validate,
  onCommit,
  onCancel,
}: {
  validate: (key: string, value: FlatFieldValue) => ValidationResult;
  onCommit: (key: string, value: FlatFieldValue) => void;
  onCancel: () => void;
}) {
  const [key, setKey] = useState("");
  const [value, setValue] = useState("");
  const validation = validate(key, value);
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => {
    ref.current?.focus();
  }, []);

  return (
    <div className="space-y-1 rounded-sm border border-dashed border-border/70 p-1.5">
      <div className="flex gap-1">
        <input
          ref={ref}
          type="text"
          value={key}
          onChange={(e) => setKey(e.target.value)}
          placeholder="key"
          onKeyDown={(e) => {
            if (e.key === "Escape") {
              e.preventDefault();
              onCancel();
            }
          }}
          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"
        />
        <input
          type="text"
          value={value}
          onChange={(e) => setValue(e.target.value)}
          placeholder="value"
          onKeyDown={(e) => {
            if (e.key === "Enter" && validation.ok) {
              e.preventDefault();
              onCommit(key, value);
            } else if (e.key === "Escape") {
              e.preventDefault();
              onCancel();
            }
          }}
          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"
        />
      </div>
      {!validation.ok && key.length > 0 ? (
        <InlineError errors={validation.errors} />
      ) : null}
    </div>
  );
}
src/registry/components/data/card-tree/parts/meta-inline.tsx
import { cn } from "@/lib/utils";
import type { FlatFieldValue, MetaRenderer } from "../types";

function format(v: FlatFieldValue) {
  if (v === null) return "—";
  return String(v);
}

export function MetaInline({
  meta,
  metaRenderers,
  className,
}: {
  meta: Record<string, FlatFieldValue>;
  metaRenderers?: Record<string, MetaRenderer>;
  className?: string;
}) {
  const entries = Object.entries(meta);
  if (entries.length === 0) return null;

  return (
    <div
      className={cn(
        "flex flex-wrap items-center gap-x-1.5 gap-y-0.5 truncate font-mono text-[10px] text-muted-foreground",
        className,
      )}
    >
      {entries.map(([k, v], i) => {
        const renderer = metaRenderers?.[k];
        return (
          <span key={k} className="inline-flex items-center gap-1">
            {i > 0 ? (
              <span aria-hidden="true" className="opacity-60">
                ·
              </span>
            ) : null}
            <span className="text-foreground/50">{k}</span>
            {renderer ? renderer(v, { cardId: "", metaKey: k }) : <span>{format(v)}</span>}
          </span>
        );
      })}
    </div>
  );
}
src/registry/components/data/card-tree/parts/meta-popover.tsx
"use client";

import { Info } from "lucide-react";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { FlatFieldValue, MetaRenderer } from "../types";
import { MetaEditList } from "./meta-edit";
import type { EditDispatchers, EditValidators } from "./card";

function format(v: FlatFieldValue) {
  if (v === null) return "—";
  return String(v);
}

export function MetaPopover({
  meta,
  cardId,
  editable,
  metaRenderers,
  dispatchers,
  validators,
  className,
}: {
  meta: Record<string, FlatFieldValue>;
  cardId: string;
  editable: boolean;
  metaRenderers?: Record<string, MetaRenderer>;
  dispatchers?: EditDispatchers;
  validators?: EditValidators;
  className?: string;
}) {
  const entries = Object.entries(meta);
  if (entries.length === 0 && !editable) return null;

  return (
    <Popover>
      <PopoverTrigger
        className={cn(
          "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",
          className,
        )}
        aria-label="Show meta information"
      >
        <Info className="size-3.5" />
      </PopoverTrigger>
      <PopoverContent
        align="end"
        sideOffset={6}
        className="w-72 p-3"
      >
        {editable && dispatchers && validators ? (
          <MetaEditList
            meta={meta}
            cardId={cardId}
            metaRenderers={metaRenderers}
            onEdit={(k, v) => dispatchers.metaEdit(cardId, k, v)}
            onAdd={(k, v) => dispatchers.metaAdd(cardId, k, v)}
            onRemove={(k) => dispatchers.metaRemove(cardId, k)}
            validateAdd={(k, v) => validators.metaAdd(cardId, k, v)}
            validateEdit={(k, v) => validators.metaEdit(cardId, k, v)}
          />
        ) : (
          <>
            <p className="mb-2 text-xs font-mono uppercase tracking-wide text-muted-foreground">
              Meta
            </p>
            <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
              {entries.map(([k, v]) => {
                const renderer = metaRenderers?.[k];
                return (
                  <div key={k} className="contents">
                    <dt className="truncate font-mono text-xs text-muted-foreground">
                      {k}
                    </dt>
                    <dd className="min-w-0 wrap-break-word">
                      {renderer ? renderer(v, { cardId, metaKey: k }) : format(v)}
                    </dd>
                  </div>
                );
              })}
            </dl>
          </>
        )}
      </PopoverContent>
    </Popover>
  );
}
src/registry/components/data/card-tree/parts/permission-tooltip.tsx
import type { PermissionDenialReason } from "../types";
import { cn } from "@/lib/utils";

const REASON_MESSAGES: Record<PermissionDenialReason, string> = {
  "global-editable-false": "Editing is disabled (editable={false}).",
  "meta-locked": "Card is locked via meta (__rcmeta.locked=true).",
  "by-card": "Permissions denied for this card specifically.",
  "by-predefined-key": "Permissions denied for this content type.",
  "by-field-type": "Permissions denied for this field type.",
  "by-level": "Permissions denied at this depth.",
  default: "Permissions denied (default rule).",
  predicate: "Permissions denied (host predicate).",
};

export function PermissionDenialTooltip({
  reason,
  className,
}: {
  reason: PermissionDenialReason;
  className?: string;
}) {
  return (
    <span
      role="status"
      className={cn(
        "inline-block rounded-sm bg-muted/80 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground",
        className,
      )}
    >
      {REASON_MESSAGES[reason]}
    </span>
  );
}

export function permissionReasonText(reason: PermissionDenialReason): string {
  return REASON_MESSAGES[reason];
}
src/registry/components/data/card-tree/parts/predefined-add-menu.tsx
import { Plus } from "lucide-react";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import {
  PREDEFINED_KEYS,
  type CustomPredefinedKey,
  type PredefinedKey,
} from "../types";
import type { CardTreeCustomEntry, CardTreePredefinedEntry } from "../lib/parse";
import { HostRenderBoundary } from "./predefined-custom";

/**
 * Default-shape value for a freshly-added predefined-key entry.
 * The user is auto-entered into the predefined editor immediately after add
 * so they can fill in real content.
 */
function defaultEntry(key: PredefinedKey): CardTreePredefinedEntry {
  switch (key) {
    case "codearea":
      return { key: "codearea", value: { format: "text", content: "" } };
    case "image":
      return { key: "image", value: { src: "", alt: "" } };
    case "table":
      return {
        key: "table",
        value: { headers: ["col"], rows: [[""]] },
      };
    case "quote":
      return { key: "quote", value: "" };
    case "list":
      return { key: "list", value: [""] };
  }
}

/**
 * Default-shape value for a freshly-added CUSTOM predefined-key entry (v0.6).
 * `defaultValue()` is host code and may throw — degrade to `null` rather
 * than blocking the add.
 */
function defaultCustomEntry(registration: CustomPredefinedKey): CardTreeCustomEntry {
  let value: unknown;
  try {
    value = registration.defaultValue();
  } catch {
    value = null;
  }
  return { key: registration.key, custom: true, value };
}

/**
 * Hover-revealed "+ block" button → menu listing predefined keys (built-in +
 * registered custom, v0.6) not yet present on this card and not in
 * `disabledPredefinedKeys` (built-ins only — custom keys have no disable
 * switch).
 */
export function PredefinedAddMenu({
  presentKeys,
  disabledKeys,
  customKeys,
  onAdd,
}: {
  presentKeys: readonly (PredefinedKey | string)[];
  disabledKeys: readonly PredefinedKey[];
  customKeys: readonly CustomPredefinedKey[];
  onAdd: (entry: CardTreePredefinedEntry) => void;
}) {
  const available = PREDEFINED_KEYS.filter(
    (k) => !presentKeys.includes(k) && !disabledKeys.includes(k),
  );
  const availableCustom = customKeys.filter(
    (reg) => !presentKeys.includes(reg.key),
  );
  if (available.length === 0 && availableCustom.length === 0) return null;

  return (
    <Popover>
      <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">
        <Plus className="size-3" aria-hidden="true" />
        block
      </PopoverTrigger>
      <PopoverContent align="start" sideOffset={4} className="w-44 p-1">
        {available.length > 0 ? (
          <>
            <p className="px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
              Add block
            </p>
            {available.map((k) => (
              <button
                key={k}
                type="button"
                onClick={() => onAdd(defaultEntry(k))}
                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"
              >
                {k}
              </button>
            ))}
          </>
        ) : null}
        {availableCustom.length > 0 ? (
          <>
            <p className="px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
              Custom
            </p>
            {availableCustom.map((reg) => (
              <button
                key={reg.key}
                type="button"
                onClick={() => onAdd(defaultCustomEntry(reg))}
                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"
              >
                {reg.icon ? (
                  <span className="shrink-0 text-muted-foreground" aria-hidden="true">
                    {/* Host-supplied node — a throwing icon must not take the
                        add-menu (or the tree) down with it. */}
                    <HostRenderBoundary fallback={null}>
                      {reg.icon}
                    </HostRenderBoundary>
                  </span>
                ) : null}
                <span className="flex-1 truncate">{reg.description ?? reg.key}</span>
                {reg.category ? (
                  <span className="shrink-0 rounded-sm bg-muted px-1 py-0.5 font-mono text-[9px] uppercase tracking-wider text-muted-foreground">
                    {reg.category}
                  </span>
                ) : null}
              </button>
            ))}
          </>
        ) : null}
      </PopoverContent>
    </Popover>
  );
}
src/registry/components/data/card-tree/parts/predefined-codearea.tsx
import { cn } from "@/lib/utils";
import type { CodeAreaValue } from "../types";

export function PredefinedCodeArea({
  value,
  className,
}: {
  value: CodeAreaValue;
  className?: string;
}) {
  return (
    <figure
      className={cn(
        "overflow-hidden rounded-md border border-border/70 bg-muted/50",
        className,
      )}
    >
      <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">
        <span>{value.format || "code"}</span>
      </figcaption>
      <pre className="overflow-x-auto px-2.5 py-2 font-mono text-[12.5px] leading-relaxed">
        <code>{value.content}</code>
      </pre>
    </figure>
  );
}
src/registry/components/data/card-tree/parts/predefined-custom.tsx
"use client";

import { Component, useEffect, useRef, useState, type ReactNode } from "react";
import { Check, TriangleAlert, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CustomKeyContext, CustomPredefinedKey } from "../types";
import type { CardTreeCustomEntry } from "../lib/parse";
import { InlineError } from "./inline-error";

/**
 * Render + edit surface for host-registered custom predefined-keys (v0.6).
 * Mirrors the structure of the built-in blocks (predefined-quote.tsx,
 * predefined-codearea.tsx): a dumb, prop-driven view component + an editor
 * that either delegates to the host's `edit` or falls back to a JSON
 * textarea (guide §7.1, plan-v0.3 §9 L881/L846).
 *
 * Host code (`render` / `edit` / `defaultValue`) is untrusted: it may throw
 * synchronously (call-site try/catch) or during its own render (error
 * boundary). Either path degrades to a generic JSON `<pre>` fallback rather
 * than blanking the tree (I-neg).
 */

function safeStringify(value: unknown): string {
  try {
    const out = JSON.stringify(value, null, 2);
    return out ?? String(value);
  } catch {
    return String(value);
  }
}

function GenericJsonFallback({
  value,
  className,
}: {
  value: unknown;
  className?: string;
}) {
  return (
    <figure
      className={cn(
        "overflow-hidden rounded-md border border-destructive/40 bg-destructive/5",
        className,
      )}
    >
      <figcaption className="flex items-center gap-1.5 border-b border-destructive/30 bg-destructive/10 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wider text-destructive">
        <TriangleAlert className="size-3" aria-hidden="true" />
        custom renderer error
      </figcaption>
      <pre className="overflow-x-auto px-2.5 py-2 font-mono text-[12px] leading-relaxed text-foreground/80">
        {safeStringify(value)}
      </pre>
    </figure>
  );
}

/**
 * Catches throws raised during the host node's own render pass.
 *
 * Exported because EVERY surface that renders host-supplied ReactNode needs it,
 * not just this one — `registration.icon` is rendered by the add-menu and is
 * equally hostile. A try/catch cannot substitute here: the throw happens inside
 * React's render of the returned element, not at the call site.
 */
export class HostRenderBoundary extends Component<
  { fallback: ReactNode; children: ReactNode },
  { hasError: boolean }
> {
  override state = { hasError: false };
  static getDerivedStateFromError() {
    return { hasError: true };
  }
  override componentDidCatch() {
    // Swallow — degrading to the generic JSON fallback is the recovery.
  }
  override render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

/* ───────── view ───────── */

export function PredefinedCustom({
  entry,
  registration,
  ctx,
}: {
  entry: CardTreeCustomEntry;
  registration: CustomPredefinedKey | undefined;
  ctx: CustomKeyContext;
}) {
  const fallback = (
    <GenericJsonFallback value={entry.value} className={ctx.className} />
  );

  if (!registration) return fallback;

  let node: ReactNode;
  try {
    node = registration.render(entry.value, ctx);
  } catch {
    return fallback;
  }

  return <HostRenderBoundary fallback={fallback}>{node}</HostRenderBoundary>;
}

/* ───────── edit: JSON-textarea fallback (guide §7.1) ───────── */

// Mirrors predefined-edit.tsx's `inputBase` (the table/JSON-fallback precedent) —
// kept file-local like its sibling rather than sharing an export.
const inputBase =
  "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";

function CustomJsonFallbackEdit({
  entry,
  onCommit,
  onCancel,
}: {
  entry: CardTreeCustomEntry;
  onCommit: (next: CardTreeCustomEntry) => void;
  onCancel: () => void;
}) {
  const [text, setText] = useState(() => safeStringify(entry.value));
  const ref = useRef<HTMLTextAreaElement>(null);
  useEffect(() => {
    ref.current?.focus();
  }, []);

  let parsed: unknown;
  let parseError: string | null = null;
  try {
    parsed = JSON.parse(text);
  } catch (e) {
    parseError = e instanceof Error ? e.message : "invalid JSON";
  }

  return (
    <div
      className="space-y-1.5 rounded-md border border-dashed border-primary/40 bg-muted/40 p-2"
      onKeyDown={(e) => {
        if (e.key === "Escape") {
          e.preventDefault();
          onCancel();
        }
      }}
    >
      <p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
        custom block &quot;{entry.key}&quot; · no `edit` registered · JSON fallback
      </p>
      <textarea
        ref={ref}
        value={text}
        onChange={(e) => setText(e.target.value)}
        rows={8}
        className={cn(inputBase, "block w-full resize-y font-mono text-[12px]")}
        aria-label={`Edit ${entry.key} (JSON)`}
        spellCheck={false}
      />
      <div className="flex items-center justify-between gap-2 pt-1">
        {parseError ? (
          <InlineError errors={[{ code: "json-parse", message: parseError }]} />
        ) : (
          <span />
        )}
        <div className="flex items-center gap-1">
          <button
            type="button"
            onClick={onCancel}
            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"
            aria-label="Cancel edit"
          >
            <X className="size-3.5" aria-hidden="true" />
          </button>
          <button
            type="button"
            onClick={() => {
              if (!parseError) {
                onCommit({ key: entry.key, custom: true, value: parsed });
              }
            }}
            disabled={!!parseError}
            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"
          >
            <Check className="size-3" aria-hidden="true" />
            save
          </button>
        </div>
      </div>
    </div>
  );
}

/* ───────── edit: dispatcher ───────── */

export function PredefinedCustomEdit({
  entry,
  registration,
  onCommit,
  onCancel,
}: {
  entry: CardTreeCustomEntry;
  registration: CustomPredefinedKey | undefined;
  onCommit: (next: CardTreeCustomEntry) => void;
  onCancel: () => void;
}) {
  const fallback = (
    <CustomJsonFallbackEdit entry={entry} onCommit={onCommit} onCancel={onCancel} />
  );

  if (!registration?.edit) return fallback;

  // Host `edit` runs here. It may throw synchronously (caught below) or
  // throw during its own render (caught by the boundary, which then swaps
  // in the same JSON fallback so editing is never a dead end).
  let node: ReactNode;
  try {
    node = registration.edit(
      entry.value,
      (next) => onCommit({ key: entry.key, custom: true, value: next }),
      onCancel,
    );
  } catch {
    return fallback;
  }

  return <HostRenderBoundary fallback={fallback}>{node}</HostRenderBoundary>;
}
src/registry/components/data/card-tree/parts/predefined-edit.tsx
import { useEffect, useRef, useState } from "react";
import { Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
  CodeAreaValue,
  CustomPredefinedKey,
  FlatFieldValue,
  ImageValue,
  ListValue,
  QuoteValue,
  TableValue,
} from "../types";
import { isCustomEntry, type CardTreePredefinedEntry } from "../lib/parse";
import { findCustomKey } from "../lib/custom-keys";
import {
  validatePredefinedShape,
  type ValidationResult,
} from "../lib/validate-edit";
import { InlineError } from "./inline-error";
import { PredefinedCustomEdit } from "./predefined-custom";

/* ───────── shared editor frame ───────── */

function EditorFrame({
  children,
  validation,
  onSave,
  onCancel,
}: {
  children: React.ReactNode;
  validation: ValidationResult;
  onSave: () => void;
  onCancel: () => void;
}) {
  return (
    <div
      className="space-y-1.5 rounded-md border border-dashed border-primary/40 bg-muted/40 p-2"
      onKeyDown={(e) => {
        if (e.key === "Escape") {
          e.preventDefault();
          onCancel();
        }
      }}
    >
      {children}
      <div className="flex items-center justify-between gap-2 pt-1">
        {!validation.ok ? (
          <InlineError errors={validation.errors} />
        ) : (
          <span />
        )}
        <div className="flex items-center gap-1">
          <button
            type="button"
            onClick={onCancel}
            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"
            aria-label="Cancel edit"
          >
            <X className="size-3.5" aria-hidden="true" />
          </button>
          <button
            type="button"
            onClick={onSave}
            disabled={!validation.ok}
            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"
          >
            <Check className="size-3" aria-hidden="true" />
            save
          </button>
        </div>
      </div>
    </div>
  );
}

const inputBase =
  "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";

/* ───────── codearea ───────── */

function CodeAreaEdit({
  initial,
  onSave,
  onCancel,
}: {
  initial: CodeAreaValue;
  onSave: (v: CodeAreaValue) => void;
  onCancel: () => void;
}) {
  const [format, setFormat] = useState(initial.format);
  const [content, setContent] = useState(initial.content);
  const ref = useRef<HTMLTextAreaElement>(null);
  useEffect(() => {
    ref.current?.focus();
  }, []);
  const value: CodeAreaValue = { format, content };
  const validation = validatePredefinedShape("codearea", value);
  return (
    <EditorFrame
      validation={validation}
      onSave={() => validation.ok && onSave(value)}
      onCancel={onCancel}
    >
      <div className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] items-center gap-1.5">
        <span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
          format
        </span>
        <input
          type="text"
          value={format}
          onChange={(e) => setFormat(e.target.value)}
          className={cn(inputBase, "w-32 font-mono text-[12px]")}
          aria-label="Code format"
        />
      </div>
      <textarea
        ref={ref}
        value={content}
        onChange={(e) => setContent(e.target.value)}
        rows={6}
        className={cn(
          inputBase,
          "block w-full resize-y font-mono text-[12.5px] leading-relaxed",
        )}
        aria-label="Code content"
      />
    </EditorFrame>
  );
}

/* ───────── image ───────── */

function ImageEdit({
  initial,
  onSave,
  onCancel,
}: {
  initial: ImageValue;
  onSave: (v: ImageValue) => void;
  onCancel: () => void;
}) {
  const [src, setSrc] = useState(initial.src);
  const [alt, setAlt] = useState(initial.alt ?? "");
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => {
    ref.current?.focus();
  }, []);
  const value: ImageValue = { src, ...(alt ? { alt } : {}) };
  const validation = validatePredefinedShape("image", value);
  return (
    <EditorFrame
      validation={validation}
      onSave={() => validation.ok && onSave(value)}
      onCancel={onCancel}
    >
      <div className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] items-center gap-1.5">
        <span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
          src
        </span>
        <input
          ref={ref}
          type="url"
          value={src}
          onChange={(e) => setSrc(e.target.value)}
          placeholder="https://..."
          className={cn(inputBase, "w-full")}
          aria-label="Image src"
        />
        <span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
          alt
        </span>
        <input
          type="text"
          value={alt}
          onChange={(e) => setAlt(e.target.value)}
          placeholder="description"
          className={cn(inputBase, "w-full")}
          aria-label="Image alt text"
        />
      </div>
      {src ? (
        <img
          src={src}
          alt={alt}
          className="max-h-32 w-auto rounded-sm border border-border/70"
        />
      ) : null}
    </EditorFrame>
  );
}

/* ───────── quote ───────── */

function QuoteEdit({
  initial,
  onSave,
  onCancel,
}: {
  initial: QuoteValue;
  onSave: (v: QuoteValue) => void;
  onCancel: () => void;
}) {
  const [value, setValue] = useState(initial);
  const ref = useRef<HTMLTextAreaElement>(null);
  useEffect(() => {
    ref.current?.focus();
    ref.current?.select();
  }, []);
  const validation = validatePredefinedShape("quote", value);
  return (
    <EditorFrame
      validation={validation}
      onSave={() => validation.ok && onSave(value)}
      onCancel={onCancel}
    >
      <textarea
        ref={ref}
        value={value}
        onChange={(e) => setValue(e.target.value)}
        rows={3}
        className={cn(
          inputBase,
          "block w-full resize-y text-sm italic leading-relaxed",
        )}
        aria-label="Quote text"
      />
    </EditorFrame>
  );
}

/* ───────── list (one item per line) ───────── */

function ListEdit({
  initial,
  onSave,
  onCancel,
}: {
  initial: ListValue;
  onSave: (v: ListValue) => void;
  onCancel: () => void;
}) {
  const [text, setText] = useState(
    initial.map((v) => stringifyScalar(v)).join("\n"),
  );
  const ref = useRef<HTMLTextAreaElement>(null);
  useEffect(() => {
    ref.current?.focus();
  }, []);
  const value: ListValue = text
    .split("\n")
    .filter((line, i, a) => line.length > 0 || i < a.length - 1)
    .map((line) => parseScalar(line));
  const validation = validatePredefinedShape("list", value);
  return (
    <EditorFrame
      validation={validation}
      onSave={() => validation.ok && onSave(value)}
      onCancel={onCancel}
    >
      <p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
        one item per line · empty lines dropped · `true` / `false` / `null` /
        numbers inferred
      </p>
      <textarea
        ref={ref}
        value={text}
        onChange={(e) => setText(e.target.value)}
        rows={5}
        className={cn(inputBase, "block w-full resize-y font-mono text-[12px]")}
        aria-label="List items"
      />
    </EditorFrame>
  );
}

function stringifyScalar(v: FlatFieldValue): string {
  if (v === null) return "null";
  return String(v);
}

function parseScalar(line: string): FlatFieldValue {
  const trimmed = line.trim();
  if (trimmed === "") return "";
  if (trimmed === "null") return null;
  if (trimmed === "true") return true;
  if (trimmed === "false") return false;
  if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
  return line;
}

/* ───────── table (JSON-textarea fallback per Q-P4) ───────── */

function TableEdit({
  initial,
  onSave,
  onCancel,
}: {
  initial: TableValue;
  onSave: (v: TableValue) => void;
  onCancel: () => void;
}) {
  const [text, setText] = useState(JSON.stringify(initial, null, 2));
  const ref = useRef<HTMLTextAreaElement>(null);
  useEffect(() => {
    ref.current?.focus();
  }, []);

  let parsed: TableValue | null = null;
  let parseError: string | null = null;
  try {
    parsed = JSON.parse(text) as TableValue;
  } catch (e) {
    parseError = e instanceof Error ? e.message : "invalid JSON";
  }

  const validation: ValidationResult = parsed
    ? validatePredefinedShape("table", parsed)
    : {
        ok: false,
        errors: [{ code: "json-parse", message: parseError ?? "invalid JSON" }],
      };

  return (
    <EditorFrame
      validation={validation}
      onSave={() => validation.ok && parsed && onSave(parsed)}
      onCancel={onCancel}
    >
      <p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
        table edit (JSON · cell-editor in v0.3)
      </p>
      <textarea
        ref={ref}
        value={text}
        onChange={(e) => setText(e.target.value)}
        rows={8}
        className={cn(inputBase, "block w-full resize-y font-mono text-[12px]")}
        aria-label="Table JSON"
        spellCheck={false}
      />
    </EditorFrame>
  );
}

/* ───────── dispatcher ───────── */

export function PredefinedEdit({
  entry,
  customKeys,
  onCommit,
  onCancel,
}: {
  entry: CardTreePredefinedEntry;
  /** Resolved custom-key registrations (v0.6) — see `resolveCustomKeys`. */
  customKeys: readonly CustomPredefinedKey[];
  onCommit: (next: CardTreePredefinedEntry) => void;
  onCancel: () => void;
}) {
  if (isCustomEntry(entry)) {
    return (
      <PredefinedCustomEdit
        entry={entry}
        registration={findCustomKey(customKeys, entry.key)}
        onCommit={onCommit}
        onCancel={onCancel}
      />
    );
  }
  switch (entry.key) {
    case "codearea":
      return (
        <CodeAreaEdit
          initial={entry.value}
          onSave={(v) => onCommit({ key: "codearea", value: v })}
          onCancel={onCancel}
        />
      );
    case "image":
      return (
        <ImageEdit
          initial={entry.value}
          onSave={(v) => onCommit({ key: "image", value: v })}
          onCancel={onCancel}
        />
      );
    case "quote":
      return (
        <QuoteEdit
          initial={entry.value}
          onSave={(v) => onCommit({ key: "quote", value: v })}
          onCancel={onCancel}
        />
      );
    case "list":
      return (
        <ListEdit
          initial={entry.value}
          onSave={(v) => onCommit({ key: "list", value: v })}
          onCancel={onCancel}
        />
      );
    case "table":
      return (
        <TableEdit
          initial={entry.value}
          onSave={(v) => onCommit({ key: "table", value: v })}
          onCancel={onCancel}
        />
      );
  }
}

src/registry/components/data/card-tree/parts/predefined-image.tsx
import { cn } from "@/lib/utils";
import type { ImageValue } from "../types";

export function PredefinedImage({
  value,
  className,
}: {
  value: ImageValue;
  className?: string;
}) {
  return (
    <figure
      className={cn(
        "overflow-hidden rounded-md border border-border/70 bg-muted/30",
        className,
      )}
    >
      <img
        src={value.src}
        alt={value.alt ?? ""}
        className="block h-auto w-full max-w-full object-cover"
        loading="lazy"
      />
      {value.alt ? (
        <figcaption className="border-t border-border/70 bg-muted/40 px-2.5 py-1 text-[11px] text-muted-foreground">
          {value.alt}
        </figcaption>
      ) : null}
    </figure>
  );
}
src/registry/components/data/card-tree/parts/predefined-list.tsx
import { cn } from "@/lib/utils";
import type { FlatFieldValue, ListValue } from "../types";

function renderItem(v: FlatFieldValue) {
  if (v === null) return <span className="text-muted-foreground">—</span>;
  if (typeof v === "boolean")
    return (
      <span className={v ? "text-primary" : "text-muted-foreground"}>
        {v ? "true" : "false"}
      </span>
    );
  if (typeof v === "number")
    return <span className="font-mono tabular-nums">{v}</span>;
  return String(v);
}

export function PredefinedList({
  value,
  className,
}: {
  value: ListValue;
  className?: string;
}) {
  return (
    <ul
      className={cn(
        "list-disc space-y-0.5 ps-4 text-[13px] marker:text-muted-foreground/60",
        className,
      )}
    >
      {value.map((item, i) => (
        <li key={i}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}
src/registry/components/data/card-tree/parts/predefined-quote.tsx
import { cn } from "@/lib/utils";
import type { QuoteValue } from "../types";

export function PredefinedQuote({
  value,
  className,
}: {
  value: QuoteValue;
  className?: string;
}) {
  return (
    <blockquote
      className={cn(
        "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",
        className,
      )}
    >
      {value}
    </blockquote>
  );
}
src/registry/components/data/card-tree/parts/predefined-table.tsx
import { cn } from "@/lib/utils";
import type { FlatFieldValue, TableValue } from "../types";

function renderCell(v: FlatFieldValue) {
  if (v === null) return <span className="text-muted-foreground">—</span>;
  if (typeof v === "boolean")
    return (
      <span
        className={
          v ? "text-primary" : "text-muted-foreground"
        }
      >
        {v ? "✓" : "—"}
      </span>
    );
  return String(v);
}

export function PredefinedTable({
  value,
  className,
}: {
  value: TableValue;
  className?: string;
}) {
  return (
    <div
      className={cn(
        "overflow-x-auto rounded-md border border-border/70 bg-card",
        className,
      )}
    >
      <table className="w-full text-[13px]">
        <thead className="border-b border-border/70 bg-muted/40">
          <tr>
            {value.headers.map((h, i) => (
              <th
                key={`${h}-${i}`}
                className="px-2.5 py-1.5 text-left font-mono text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
              >
                {h}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {value.rows.map((row, i) => (
            <tr
              key={i}
              className="border-b border-border/40 last:border-0 hover:bg-muted/20"
            >
              {row.map((cell, j) => (
                <td
                  key={j}
                  className={cn(
                    "px-2.5 py-1.5 align-top",
                    typeof cell === "number" &&
                      "text-right font-mono tabular-nums",
                  )}
                >
                  {renderCell(cell)}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
src/registry/components/data/card-tree/parts/search-bar.tsx
"use client";

import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";

/**
 * Optional default search bar — sibling export. Hosts can skip and wire their own input
 * directly into the `search` prop of CardTree.
 *
 * Bindings:
 *   F3 / Shift+F3 → next / previous match (handled by host or via the imperative handle)
 */
export function CardTreeSearchBar({
  value,
  onChange,
  matchCount,
  activeIndex,
  onNext,
  onPrevious,
  onClear,
  placeholder = "Search cards…",
  className,
}: {
  value: string;
  onChange: (next: string) => void;
  matchCount: number;
  activeIndex: number | null;
  onNext: () => void;
  onPrevious: () => void;
  onClear: () => void;
  placeholder?: string;
  className?: string;
}) {
  const hasMatches = matchCount > 0;
  const noMatches = value.length > 0 && matchCount === 0;
  const display =
    activeIndex !== null && hasMatches
      ? `${activeIndex + 1} / ${matchCount}`
      : hasMatches
        ? `${matchCount} matches`
        : noMatches
          ? "no matches"
          : null;

  return (
    <div
      role="search"
      className={cn(
        "inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 shadow-sm",
        className,
      )}
    >
      <Search className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
      <input
        type="search"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
        aria-label="Search cards"
        onKeyDown={(e) => {
          if (e.key === "F3" || (e.key === "g" && (e.metaKey || e.ctrlKey))) {
            e.preventDefault();
            if (e.shiftKey) onPrevious();
            else onNext();
          } else if (e.key === "Escape") {
            e.preventDefault();
            onClear();
          }
        }}
        className="min-w-32 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60"
      />
      {display ? (
        <span
          aria-live="polite"
          className={cn(
            "shrink-0 font-mono text-[10px]",
            noMatches ? "text-destructive" : "text-muted-foreground",
          )}
        >
          {display}
        </span>
      ) : null}
      {hasMatches ? (
        <span className="inline-flex shrink-0 items-center gap-0.5 border-l border-border pl-1">
          <button
            type="button"
            onClick={onPrevious}
            aria-label="Previous match"
            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"
          >
            <ChevronUp className="size-3" aria-hidden="true" />
          </button>
          <button
            type="button"
            onClick={onNext}
            aria-label="Next match"
            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"
          >
            <ChevronDown className="size-3" aria-hidden="true" />
          </button>
        </span>
      ) : null}
      {value.length > 0 ? (
        <button
          type="button"
          onClick={onClear}
          aria-label="Clear search"
          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"
        >
          <X className="size-3" aria-hidden="true" />
        </button>
      ) : null}
    </div>
  );
}
src/registry/components/data/card-tree/parts/undo-toolbar.tsx
"use client";

import { Redo2, Undo2 } from "lucide-react";
import { cn } from "@/lib/utils";

/**
 * Optional sibling export — drop in next to <CardTree /> for default
 * undo / redo affordances. Hosts can wire their own buttons via the
 * imperative ref handle.
 */
export function CardTreeUndoToolbar({
  canUndo,
  canRedo,
  onUndo,
  onRedo,
  className,
}: {
  canUndo: boolean;
  canRedo: boolean;
  onUndo: () => void;
  onRedo: () => void;
  className?: string;
}) {
  return (
    <div
      role="toolbar"
      aria-label="Undo / redo"
      className={cn(
        "inline-flex items-center gap-0.5 rounded-md border border-border bg-card p-0.5 shadow-sm",
        className,
      )}
    >
      <button
        type="button"
        onClick={onUndo}
        disabled={!canUndo}
        aria-label="Undo"
        title="Undo (Cmd/Ctrl+Z)"
        aria-disabled={!canUndo}
        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"
      >
        <Undo2 className="size-3.5" aria-hidden="true" />
      </button>
      <button
        type="button"
        onClick={onRedo}
        disabled={!canRedo}
        aria-label="Redo"
        title="Redo (Cmd/Ctrl+Shift+Z)"
        aria-disabled={!canRedo}
        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"
      >
        <Redo2 className="size-3.5" aria-hidden="true" />
      </button>
    </div>
  );
}

More from ilinxa

Browse all components in this library →