{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "task-card",
  "title": "Task Card",
  "author": "ilinxa",
  "description": "Schema-driven task card with time-aware color coding, popup and inline editing, clipboard and drag-drop payloads, and permission predicates.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "avatar",
    "badge",
    "button",
    "dialog",
    "dropdown-menu",
    "input",
    "label",
    "scroll-area",
    "select",
    "separator",
    "switch",
    "textarea",
    "tooltip"
  ],
  "files": [
    {
      "path": "src/registry/components/data/task-card/task-card.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useReducer,\n  useRef,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useColorEngine } from \"./hooks/use-color-engine\";\nimport { useKeyboard } from \"./hooks/use-keyboard\";\nimport { TaskCardContext } from \"./hooks/use-card-context\";\nimport { Card } from \"./parts/card\";\nimport {\n  copyTasksToClipboard,\n  readTasksFromClipboard,\n} from \"./lib/clipboard\";\nimport { denormalize, findNode, normalize } from \"./lib/normalize\";\nimport { buildResolver } from \"./lib/permissions\";\nimport { resolveRamp } from \"./lib/ramp\";\nimport { resolveNowFactory } from \"./lib/time\";\nimport { createInitialState, reducer } from \"./lib/reducer\";\nimport type {\n  TaskCardContextValue,\n  TaskEventMap,\n  TaskPermissionReason,\n  TaskPermissionRule,\n  TaskCardHandle,\n  TaskCardProps,\n} from \"./types\";\n\nexport const TaskCard = forwardRef<TaskCardHandle, TaskCardProps>(\n  function TaskCard(props, ref) {\n    const {\n      defaultValue,\n      value,\n      colorRamp,\n      colorRefreshIntervalMs = 60_000,\n      now: nowProp,\n      editable = false,\n      showEditButton = true,\n      statusOptions,\n      statusEditable = true,\n      priorityOptions,\n      labelOptions,\n      permissions,\n      className,\n      \"aria-label\": ariaLabel,\n    } = props;\n\n    const [state, dispatch] = useReducer(\n      reducer,\n      value ?? defaultValue,\n      createInitialState,\n    );\n\n    /* ───────── derive context inputs ───────── */\n\n    const nowFactory = useMemo(() => resolveNowFactory(nowProp), [nowProp]);\n    const ramp = useMemo(() => resolveRamp(colorRamp), [colorRamp]);\n    const tick = useColorEngine(colorRefreshIntervalMs);\n\n    const resolvePermissions = useMemo(\n      () => buildResolver(state.root, props),\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n      [\n        state.root,\n        permissions,\n        props.canEditItem,\n        props.canRemoveItem,\n        props.canAddChildren,\n        props.canDragItem,\n        props.canToggleActive,\n        props.canOverrideColor,\n      ],\n    );\n\n    /* ───────── event firing ───────── */\n\n    const propsRef = useRef(props);\n    useEffect(() => {\n      propsRef.current = props;\n    });\n\n    const reportPermissionDenied = useCallback(\n      (\n        action: keyof TaskPermissionRule,\n        itemId: string,\n        reason: TaskPermissionReason,\n      ) => {\n        propsRef.current.onPermissionDenied?.(action, itemId, reason);\n      },\n      [],\n    );\n\n    const fireEvent = useCallback(\n      <K extends keyof TaskEventMap>(name: K, event: TaskEventMap[K]) => {\n        const p = propsRef.current;\n        switch (name) {\n          case \"change\":\n            p.onChange?.(event as TaskEventMap[\"change\"]);\n            break;\n          case \"fieldEdited\":\n            p.onFieldEdited?.(event as TaskEventMap[\"fieldEdited\"]);\n            break;\n          case \"statusChanged\":\n            p.onStatusChanged?.(event as TaskEventMap[\"statusChanged\"]);\n            break;\n          case \"itemAdded\":\n            p.onItemAdded?.(event as TaskEventMap[\"itemAdded\"]);\n            break;\n          case \"itemRemoved\":\n            p.onItemRemoved?.(event as TaskEventMap[\"itemRemoved\"]);\n            break;\n          case \"itemMoved\":\n            p.onItemMoved?.(event as TaskEventMap[\"itemMoved\"]);\n            break;\n          case \"colorOverridden\":\n            p.onColorOverridden?.(event as TaskEventMap[\"colorOverridden\"]);\n            break;\n          case \"activeToggled\":\n            p.onActiveToggled?.(event as TaskEventMap[\"activeToggled\"]);\n            break;\n          case \"lockedToggled\":\n            p.onLockedToggled?.(event as TaskEventMap[\"lockedToggled\"]);\n            break;\n          case \"copy\":\n            p.onCopy?.(event as TaskEventMap[\"copy\"]);\n            break;\n          case \"paste\":\n            p.onPaste?.(event as TaskEventMap[\"paste\"]);\n            break;\n        }\n      },\n      [],\n    );\n\n    /* ───────── edit veto (single entry point for header/keyboard/handle) ───────── */\n\n    const requestEdit = useCallback(\n      (itemId: string, mode: \"popup\" | \"inline\") => {\n        // Consult the veto BEFORE dispatching, so a vetoed edit never flash-opens.\n        const veto = propsRef.current.onEditRequest?.({ itemId, mode });\n        if (veto === false) return;\n        dispatch({ type: \"open-edit\", itemId, mode });\n      },\n      [],\n    );\n\n    /* ───────── fire onChange after structural mutations ───────── */\n\n    // A root change from reconciling an external `value` sets this flag so the\n    // controlled consumer isn't notified of its own update (avoids double-fire).\n    const suppressChangeRef = useRef(false);\n    const initialMount = useRef(true);\n    useEffect(() => {\n      if (initialMount.current) {\n        initialMount.current = false;\n        return;\n      }\n      if (suppressChangeRef.current) {\n        suppressChangeRef.current = false;\n        return;\n      }\n      fireEvent(\"change\", denormalize(state.root));\n    }, [state.root, fireEvent]);\n\n    /* ───────── controlled-mode reconcile ─────────\n     * When `value` is provided the card is controlled. Reconcile ONLY on a\n     * genuine external `value` change (deps = [value]); the echo guard skips\n     * no-op syncs. `state.root` is read for comparison but deliberately NOT a\n     * dep, so internal optimistic mutations don't revert themselves.\n     */\n    useEffect(() => {\n      if (value === undefined) return;\n      const incoming = JSON.stringify(denormalize(normalize(value).root));\n      const current = JSON.stringify(denormalize(state.root));\n      if (incoming === current) return;\n      suppressChangeRef.current = true;\n      dispatch({ type: \"sync-tree\", tree: value });\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [value]);\n\n    /* ───────── context value ───────── */\n\n    const ctxValue: TaskCardContextValue = useMemo(\n      () => ({\n        now: nowFactory,\n        tick,\n        ramp,\n        dispatch,\n        editState: state.edit,\n        focusedId: state.focusedId,\n        dirty: state.dirty,\n        isCollapsed: (id: string) => state.collapsedIds.has(id),\n        resolvePermissions,\n        statusOptions,\n        statusEditable,\n        priorityOptions,\n        labelOptions,\n        editable,\n        showEditButton,\n        fireEvent,\n        requestEdit,\n        reportPermissionDenied,\n      }),\n      [\n        nowFactory,\n        tick,\n        ramp,\n        state.edit,\n        state.focusedId,\n        state.dirty,\n        state.collapsedIds,\n        resolvePermissions,\n        statusOptions,\n        statusEditable,\n        priorityOptions,\n        labelOptions,\n        editable,\n        showEditButton,\n        fireEvent,\n        requestEdit,\n        reportPermissionDenied,\n      ],\n    );\n\n    /* ───────── imperative handle ───────── */\n\n    useImperativeHandle(\n      ref,\n      (): TaskCardHandle => ({\n        getValue: () => JSON.stringify(denormalize(state.root), null, 2),\n        getTree: () => denormalize(state.root),\n        isDirty: () => state.dirty,\n        markClean: () => dispatch({ type: \"mark-clean\" }),\n        focusItem: (id) => dispatch({ type: \"set-focus\", itemId: id }),\n        copy: async (itemId) => {\n          const target = itemId ? findNode(state.root, itemId) : state.root;\n          if (!target) return;\n          // Denormalize so the copied subtree's `children` is current (item.children\n          // can be stale after in-session edits; childNodes is the source of truth).\n          const item = denormalize(target);\n          await copyTasksToClipboard([item], \"task-card\");\n          fireEvent(\"copy\", { itemId: item.id, payload: item });\n        },\n        paste: async (parentId) => {\n          const target = parentId\n            ? findNode(state.root, parentId)\n            : state.root;\n          if (!target) return;\n          const items = await readTasksFromClipboard();\n          if (!items) return;\n          // The shared envelope may carry N items (multi-select copy elsewhere);\n          // the reducer re-ids each grafted subtree.\n          for (const payload of items) {\n            dispatch({\n              type: \"add-child\",\n              parentId: target.item.id,\n              item: payload,\n            });\n            fireEvent(\"paste\", { parentId: target.item.id, payload });\n            fireEvent(\"itemAdded\", { parentId: target.item.id, item: payload });\n          }\n        },\n        setBorderColor: (itemId, color) => {\n          const target = findNode(state.root, itemId);\n          if (!target) return;\n          const oldColor = target.item.borderColor;\n          dispatch({ type: \"set-border-color\", itemId, color });\n          fireEvent(\"colorOverridden\", {\n            itemId,\n            oldColor,\n            newColor: color ?? undefined,\n          });\n        },\n        toggleActive: (itemId) => {\n          const target = findNode(state.root, itemId);\n          if (!target) return;\n          const oldActive = target.item.active;\n          dispatch({ type: \"toggle-active\", itemId });\n          fireEvent(\"activeToggled\", {\n            itemId,\n            oldActive,\n            newActive: !oldActive,\n          });\n        },\n        setLocked: (itemId, locked) => {\n          const target = findNode(state.root, itemId);\n          if (!target) return;\n          const oldLocked = Boolean(target.item.locked);\n          dispatch({ type: \"set-locked\", itemId, locked });\n          fireEvent(\"lockedToggled\", {\n            itemId,\n            oldLocked,\n            newLocked: locked,\n          });\n        },\n        openEdit: (itemId, mode = \"popup\") => requestEdit(itemId, mode),\n        closeEdit: () => dispatch({ type: \"close-edit\" }),\n      }),\n      [state.root, state.dirty, fireEvent, requestEdit],\n    );\n\n    /* ───────── root keyboard ───────── */\n\n    const onKeyDown = useKeyboard({\n      rootNode: state.root,\n      focusedId: state.focusedId,\n      editState: state.edit,\n      editable,\n      dispatch,\n      resolvePermissions,\n      fireCopy: (p) => fireEvent(\"copy\", p),\n      firePaste: (p) => fireEvent(\"paste\", p),\n      fireItemAdded: (p) => fireEvent(\"itemAdded\", p),\n      requestEdit,\n      reportPermissionDenied,\n    });\n\n    return (\n      <TaskCardContext.Provider value={ctxValue}>\n        <section\n          role=\"region\"\n          aria-label={ariaLabel ?? state.root.item.name}\n          onKeyDown={onKeyDown}\n          className={cn(\"w-full\", className)}\n        >\n          <Card node={state.root} />\n        </section>\n      </TaskCardContext.Provider>\n    );\n  },\n);\n\nTaskCard.displayName = \"TaskCard\";\n",
      "type": "registry:component",
      "target": "components/task-card/task-card.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/index.ts",
      "content": "// Main component\nexport { TaskCard } from \"./task-card\";\n\n// Companion: kanban-board card renderer (Q-P6)\nexport { taskCardKanbanRenderer } from \"./parts/kanban-adapter\";\n\n// Constants\nexport { RAMPS } from \"./lib/ramp\";\nexport { TASK_RAMPS, TASK_CLIPBOARD_MIME } from \"./types\";\n\n// Unified cross-surface task clipboard (the canonical family envelope —\n// gantt / calendar / tree import from \"./lib/clipboard\" directly)\nexport {\n  TASK_CLIPBOARD_KIND,\n  TASK_CLIPBOARD_VERSION,\n  serializeTasks,\n  parseTasks,\n  reassignTaskIds,\n  writeTasksToClipboardEvent,\n  readTasksFromClipboardEvent,\n  copyTasksToClipboard,\n  readTasksFromClipboard,\n} from \"./lib/clipboard\";\nexport type { TaskClipboardEnvelope } from \"./lib/clipboard\";\n\n// Data types\nexport type {\n  TaskItem,\n  TaskPerson,\n  TaskImage,\n  TaskLink,\n  TaskColorRamp,\n  TaskColorRampPreset,\n  TaskStatusOption,\n  TaskPriorityOption,\n  TaskLabelOption,\n  TaskEditableField,\n} from \"./types\";\n\n// Permission types\nexport type {\n  TaskPermissions,\n  TaskPermissionRule,\n  TaskPermissionReason,\n} from \"./types\";\n\n// Event types\nexport type {\n  TaskFieldEditedEvent,\n  TaskStatusChangedEvent,\n  TaskItemAddedEvent,\n  TaskItemRemovedEvent,\n  TaskItemMovedEvent,\n  TaskColorOverriddenEvent,\n  TaskActiveToggledEvent,\n  TaskLockedToggledEvent,\n  TaskCopyEvent,\n  TaskPasteEvent,\n  TaskEditRequestEvent,\n} from \"./types\";\n\n// Component surface\nexport type { TaskCardProps, TaskCardHandle } from \"./types\";\n",
      "type": "registry:component",
      "target": "components/task-card/index.ts"
    },
    {
      "path": "src/registry/components/data/task-card/types.ts",
      "content": "/**\n * Public + internal types for the task-card pro-component.\n *\n * v0.1.0 surface — fixed-schema task card with time-driven auto-color, dual\n * edit modes (popup default + inline-toggle), JSON I/O, clipboard, DnD payload,\n * granular events, permission predicates.\n *\n * Architectural lineage: mirrors card-tree's patterns (uncontrolled +\n * imperative handle, granular events, permission predicates, JSON I/O) without\n * sharing code. See docs/procomps/task-card-procomp/.\n */\n\nimport type { Dispatch, ReactNode } from \"react\";\n\n/* ───────── ramp presets + clipboard MIME ───────── */\n\nexport const TASK_RAMPS = [\"default\", \"muted\", \"vivid\", \"monochrome\"] as const;\nexport type TaskColorRampPreset = (typeof TASK_RAMPS)[number];\n\n/** MIME for clipboard + DnD payloads — lets us fast-path verified todo JSON. */\nexport const TASK_CLIPBOARD_MIME = \"application/x-ilinxa-task+json\" as const;\n\n/* ───────── data shape ───────── */\n\nexport type TaskPerson = {\n  id: string;\n  name: string;\n  avatar?: string;\n};\n\nexport type TaskImage = {\n  src: string;\n  alt?: string;\n  caption?: string;\n};\n\nexport type TaskLink = {\n  url: string;\n  label?: string;\n  icon?: string;\n};\n\nexport type TaskItem = {\n  id: string;\n  name: string;\n  description?: string;\n  status: string;\n  active: boolean;\n  /** ISO-8601 timestamp; required. */\n  setAt: string;\n  /** ISO-8601; defaults to setAt at render time if absent. */\n  startAt?: string;\n  /** ISO-8601; wins over duration when both are set. */\n  expireAt?: string;\n  /**\n   * Milliseconds; used only if expireAt is absent. NOTE: stored in ms, but the\n   * popup editor displays + edits this in WHOLE MINUTES (rounded), so sub-minute\n   * values are lossy on edit round-trips.\n   */\n  duration?: number;\n  targetPerson?: TaskPerson;\n  creatorPerson?: TaskPerson;\n  images?: TaskImage[];\n  links?: TaskLink[];\n  /** Per-item override; skips the time engine for this node. Any CSS color string. */\n  borderColor?: string;\n  /** When true, edit + drag are blocked for this item. */\n  locked?: boolean;\n  /** Priority/urgency bucket key. Single-valued; consumer supplies display metadata via `priorityOptions`. */\n  priority?: string;\n  /** Label/tag keys (many-to-many). Consumer supplies display metadata via `labelOptions`. */\n  labels?: string[];\n  /** Infinite recursive nesting. */\n  children?: TaskItem[];\n};\n\n/* ───────── color ramp ───────── */\n\nexport type TaskColorRamp =\n  | TaskColorRampPreset\n  | ((elapsed: number) => string);\n\n/* ───────── status rendering ───────── */\n\n/** Plan refinement (Q-P4): enables `<Select>` in edit modes + variant-colored badges. */\nexport type TaskStatusOption = {\n  value: string;\n  label: string;\n  variant?: \"default\" | \"secondary\" | \"destructive\" | \"outline\";\n  /**\n   * Semantic treatment of a card in this status (v0.3):\n   *  - `active` (default): the time-driven border color applies; normal card.\n   *  - `done`: gray border + a dimmed green overlay; the card auto-collapses.\n   *  - `blocked`: gray border + a reddish overlay; the card auto-collapses.\n   * Decouples the *status* signal from the time-driven *urgency* border.\n   */\n  tone?: \"active\" | \"done\" | \"blocked\";\n  /** Emoji/glyph shown above the tag in the done/blocked overlay. */\n  icon?: string;\n};\n\n/** Display metadata for a priority bucket; pair with `TaskItem.priority` (v0.3). */\nexport type TaskPriorityOption = {\n  value: string;\n  label: string;\n  /** Any CSS color string — applied to border + text only (never a filled background). */\n  color?: string;\n};\n\n/** Display metadata for a label/tag; pair with `TaskItem.labels` (v0.3). */\nexport type TaskLabelOption = {\n  value: string;\n  label: string;\n  /** Any CSS color string — applied to border + text only (never a filled background). */\n  color?: string;\n};\n\n/* ───────── editable-field union ─────────\n *\n * Field union for the GENERIC `onFieldEdited` event — limited to the scalar\n * fields the built-in editors actually edit. Excludes fields with dedicated\n * events:\n *   - borderColor → fires `onColorOverridden`\n *   - locked      → fires `onLockedToggled`\n *   - active      → ALSO fires `onActiveToggled` in addition to `onFieldEdited`\n *\n * Structural keys (id, children) are excluded entirely — those are tree\n * operations covered by onItemAdded / onItemRemoved / onItemMoved.\n *\n * `targetPerson` / `creatorPerson` / `images` / `links` are intentionally NOT\n * here: the schema carries them but no built-in editor edits them, so they are\n * render-only (a future `renderPersonEditor` slot would add them back).\n */\nexport type TaskEditableField =\n  | \"name\"\n  | \"description\"\n  | \"status\"\n  | \"active\"\n  | \"setAt\"\n  | \"startAt\"\n  | \"expireAt\"\n  | \"duration\";\n\n/* ───────── permissions ───────── */\n\nexport type TaskPermissionRule = {\n  edit?: boolean;\n  remove?: boolean;\n  addChildren?: boolean;\n  drag?: boolean;\n  toggleActive?: boolean;\n  overrideColor?: boolean;\n};\n\nexport type TaskPermissionReason =\n  | \"locked\"\n  | \"by-item\"\n  | \"by-level\"\n  | \"default\"\n  | \"predicate\";\n\nexport type TaskPermissions = {\n  default?: TaskPermissionRule;\n  byLevel?: Record<number, TaskPermissionRule>;\n  byItem?: Record<string, TaskPermissionRule>;\n  /** Default true — child rules cascade from parent's effective rule. */\n  inherit?: boolean;\n};\n\n/* ───────── event types ───────── */\n\nexport type TaskFieldEditedEvent = {\n  itemId: string;\n  key: TaskEditableField;\n  oldValue: unknown;\n  newValue: unknown;\n};\n\nexport type TaskStatusChangedEvent = {\n  itemId: string;\n  oldStatus: string;\n  newStatus: string;\n};\n\nexport type TaskItemAddedEvent = {\n  parentId: string;\n  item: TaskItem;\n};\n\nexport type TaskItemRemovedEvent = {\n  itemId: string;\n  removed: TaskItem;\n  parentId: string;\n};\n\nexport type TaskItemMovedEvent = {\n  itemId: string;\n  oldParentId: string;\n  newParentId: string;\n  oldIndex: number;\n  newIndex: number;\n};\n\nexport type TaskColorOverriddenEvent = {\n  itemId: string;\n  oldColor: string | undefined;\n  newColor: string | undefined;\n};\n\nexport type TaskActiveToggledEvent = {\n  itemId: string;\n  oldActive: boolean;\n  newActive: boolean;\n};\n\nexport type TaskLockedToggledEvent = {\n  itemId: string;\n  oldLocked: boolean;\n  newLocked: boolean;\n};\n\nexport type TaskCopyEvent = {\n  itemId: string;\n  payload: TaskItem;\n};\n\nexport type TaskPasteEvent = {\n  parentId: string;\n  payload: TaskItem;\n};\n\nexport type TaskEditRequestEvent = {\n  itemId: string;\n  mode: \"popup\" | \"inline\";\n};\n\n/* ───────── component props ───────── */\n\nexport type TaskCardProps = {\n  /**\n   * Initial tree for UNCONTROLLED mode (seeds once; later changes ignored).\n   * Provide either `defaultValue` (uncontrolled) or `value` (controlled).\n   */\n  defaultValue?: TaskItem;\n  /**\n   * Controlled tree. When provided, `value` is the source of truth: the card\n   * reconciles to it and `onChange` reports edits for you to echo back. Falls\n   * back to `defaultValue` when omitted.\n   */\n  value?: TaskItem;\n\n  // Auto-color\n  colorRamp?: TaskColorRamp;\n  colorRefreshIntervalMs?: number;\n  /** Frozen clock for testing or SSR determinism. */\n  now?: Date | (() => Date);\n\n  // Edit modes\n  editable?: boolean;\n  showEditButton?: boolean;\n  statusOptions?: TaskStatusOption[];\n  /**\n   * Render the status badge as a dropdown that changes the item's status\n   * (default true). The dropdown only appears when `editable`, the item's\n   * `edit` permission is granted, and `statusOptions` is non-empty; otherwise\n   * the badge stays read-only. Set false where status is changed another way —\n   * e.g. inside a kanban column, where moving the card between columns sets the\n   * status.\n   */\n  statusEditable?: boolean;\n  /** Display metadata for `TaskItem.priority`; enables the priority badge in the meta row. */\n  priorityOptions?: TaskPriorityOption[];\n  /** Display metadata for `TaskItem.labels`; chips fall back to the raw key when unmatched. */\n  labelOptions?: TaskLabelOption[];\n\n  // Permissions\n  permissions?: TaskPermissions;\n  canEditItem?: (id: string) => boolean;\n  canRemoveItem?: (id: string) => boolean;\n  canAddChildren?: (id: string) => boolean;\n  canDragItem?: (id: string) => boolean;\n  canToggleActive?: (id: string) => boolean;\n  canOverrideColor?: (id: string) => boolean;\n  onPermissionDenied?: (\n    action: keyof TaskPermissionRule,\n    itemId: string,\n    reason: TaskPermissionReason,\n  ) => void;\n\n  // Events\n  onChange?: (tree: TaskItem) => void;\n  /** Fires before edit opens; return false to veto. */\n  onEditRequest?: (event: TaskEditRequestEvent) => boolean | void;\n  onFieldEdited?: (event: TaskFieldEditedEvent) => void;\n  onStatusChanged?: (event: TaskStatusChangedEvent) => void;\n  onItemAdded?: (event: TaskItemAddedEvent) => void;\n  onItemRemoved?: (event: TaskItemRemovedEvent) => void;\n  onItemMoved?: (event: TaskItemMovedEvent) => void;\n  onColorOverridden?: (event: TaskColorOverriddenEvent) => void;\n  onActiveToggled?: (event: TaskActiveToggledEvent) => void;\n  onLockedToggled?: (event: TaskLockedToggledEvent) => void;\n  onCopy?: (event: TaskCopyEvent) => void;\n  onPaste?: (event: TaskPasteEvent) => void;\n\n  // Container\n  className?: string;\n  \"aria-label\"?: string;\n};\n\n/* ───────── imperative ref handle ───────── */\n\nexport type TaskCardHandle = {\n  /** Canonical pretty-printed JSON string. */\n  getValue(): string;\n  /** Object form of the current tree. */\n  getTree(): TaskItem;\n  isDirty(): boolean;\n  markClean(): void;\n  focusItem(id: string): void;\n  /** Copies item (or root if id omitted) to clipboard with our MIME + text/plain. */\n  copy(itemId?: string): Promise<void>;\n  /** Pastes from clipboard as child of parent (or root if id omitted). */\n  paste(parentId?: string): Promise<void>;\n  setBorderColor(itemId: string, color: string | null): void;\n  toggleActive(itemId: string): void;\n  setLocked(itemId: string, locked: boolean): void;\n  openEdit(itemId: string, mode?: \"popup\" | \"inline\"): void;\n  closeEdit(): void;\n};\n\n/* ─────────────────────────────────────────────────────────────────────────\n * Internal types — NOT exported from index.ts. Kept here so the reducer +\n * context implementation can share them.\n * ───────────────────────────────────────────────────────────────────────── */\n\n/** Normalized internal tree with derived data. */\nexport type TaskNode = {\n  item: TaskItem;\n  /** 1 = root. */\n  level: number;\n  parentId: string | null;\n  /** Sibling position (0-based). */\n  index: number;\n  childNodes: TaskNode[];\n};\n\nexport type EditState =\n  | { kind: \"view\" }\n  | { kind: \"popup\"; itemId: string }\n  | { kind: \"inline\"; itemId: string };\n\nexport type ResolvedPermissions = {\n  edit: boolean;\n  remove: boolean;\n  addChildren: boolean;\n  drag: boolean;\n  toggleActive: boolean;\n  overrideColor: boolean;\n  reason: TaskPermissionReason;\n};\n\nexport type State = {\n  root: TaskNode;\n  edit: EditState;\n  focusedId: string | null;\n  dirty: boolean;\n  /** UI-only collapse state — not part of the TaskItem schema. */\n  collapsedIds: ReadonlySet<string>;\n};\n\nexport type Action =\n  | { type: \"replace-tree\"; tree: TaskItem }\n  | { type: \"sync-tree\"; tree: TaskItem }\n  | { type: \"open-edit\"; itemId: string; mode: \"popup\" | \"inline\" }\n  | { type: \"close-edit\" }\n  | {\n      type: \"edit-field\";\n      itemId: string;\n      key: TaskEditableField;\n      value: unknown;\n    }\n  | { type: \"add-child\"; parentId: string; item: TaskItem; index?: number }\n  | { type: \"remove-item\"; itemId: string }\n  | {\n      type: \"move-item\";\n      itemId: string;\n      newParentId: string;\n      newIndex: number;\n    }\n  | { type: \"set-border-color\"; itemId: string; color: string | null }\n  | { type: \"toggle-active\"; itemId: string }\n  | { type: \"set-locked\"; itemId: string; locked: boolean }\n  | { type: \"set-focus\"; itemId: string | null }\n  | { type: \"toggle-collapse\"; itemId: string }\n  | { type: \"mark-clean\" };\n\n/* ───────── context value ───────── */\n\n/** Internal — constructed in task-card.tsx, consumed by parts/*. */\nexport type TaskCardContextValue = {\n  // Time + color\n  now: () => Date;\n  /** Bumped by useColorEngine on each interval; included in context memo dep. */\n  tick: number;\n  ramp: (elapsed: number) => string;\n\n  // State machine\n  dispatch: Dispatch<Action>;\n  editState: EditState;\n  focusedId: string | null;\n  dirty: boolean;\n  /** Check whether a given item is collapsed (UI-only state). */\n  isCollapsed: (itemId: string) => boolean;\n\n  /** Memoized closure over props.permissions + per-action predicates. */\n  resolvePermissions: (node: TaskNode) => ResolvedPermissions;\n\n  // Display config\n  statusOptions?: TaskStatusOption[];\n  /** Whether the status badge becomes a status-change dropdown (B3). */\n  statusEditable: boolean;\n  priorityOptions?: TaskPriorityOption[];\n  labelOptions?: TaskLabelOption[];\n  editable: boolean;\n  showEditButton: boolean;\n\n  /** Closure that dispatches AND fires the matching on* callback. */\n  fireEvent: <K extends keyof TaskEventMap>(name: K, event: TaskEventMap[K]) => void;\n\n  /**\n   * Consult `onEditRequest` (veto) BEFORE opening the editor, then dispatch\n   * `open-edit` only if not vetoed. The single edit entry point shared by the\n   * header, keyboard, and imperative handle — prevents the flash-open-then-close\n   * a retroactive veto caused.\n   */\n  requestEdit: (itemId: string, mode: \"popup\" | \"inline\") => void;\n\n  /** For permission predicates that can't be cleanly closed-over. */\n  reportPermissionDenied: (\n    action: keyof TaskPermissionRule,\n    itemId: string,\n    reason: TaskPermissionReason,\n  ) => void;\n};\n\n/** Event-name → event-payload map; powers fireEvent's typed dispatch. */\nexport type TaskEventMap = {\n  change: TaskItem;\n  editRequest: TaskEditRequestEvent;\n  fieldEdited: TaskFieldEditedEvent;\n  statusChanged: TaskStatusChangedEvent;\n  itemAdded: TaskItemAddedEvent;\n  itemRemoved: TaskItemRemovedEvent;\n  itemMoved: TaskItemMovedEvent;\n  colorOverridden: TaskColorOverriddenEvent;\n  activeToggled: TaskActiveToggledEvent;\n  lockedToggled: TaskLockedToggledEvent;\n  copy: TaskCopyEvent;\n  paste: TaskPasteEvent;\n};\n\n/** Slot prop for the (private) error-card fallback. */\nexport type TaskErrorCardProps = {\n  message: string;\n  children?: ReactNode;\n};\n",
      "type": "registry:component",
      "target": "components/task-card/types.ts"
    },
    {
      "path": "src/registry/components/data/task-card/lib/time.ts",
      "content": "/**\n * Pure date helpers. No React. No locale-specific deps — uses Intl.* APIs only.\n */\n\nconst ISO_8601_RE =\n  /^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})?)?$/;\n\nconst DATE_ONLY_RE = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/**\n * True for a bare `YYYY-MM-DD` calendar date (no time component). Date-only\n * values are the family's all-day form and must round-trip VERBATIM — never\n * re-serialized via `toISOString()`, which would rewrite them to a UTC\n * timestamp and destroy all-day classification downstream (calendar).\n */\nexport function isDateOnly(value: string | undefined | null): boolean {\n  return typeof value === \"string\" && DATE_ONLY_RE.test(value);\n}\n\n/** Returns Date or null. Validates format AND parseability (rejects \"2026-12-99\"). */\nexport function parseIso(value: string | undefined | null): Date | null {\n  if (!value || typeof value !== \"string\") return null;\n  if (!ISO_8601_RE.test(value)) return null;\n  const d = new Date(value);\n  return Number.isFinite(d.getTime()) ? d : null;\n}\n\n/** ISO-8601 serializer; mirrors toISOString but null-safe. */\nexport function toIso(date: Date | null | undefined): string | undefined {\n  if (!date || !Number.isFinite(date.getTime())) return undefined;\n  return date.toISOString();\n}\n\n/**\n * Validating normalizer for a stored ISO field. Invalid input → undefined\n * (unchanged recovery behavior); a date-only `YYYY-MM-DD` passes through\n * VERBATIM (the all-day form is part of the value, not a formatting detail);\n * full timestamps canonicalize via `toISOString()` as before.\n */\nexport function normalizeIsoField(value: string): string | undefined {\n  const d = parseIso(value);\n  if (!d) return undefined;\n  if (isDateOnly(value)) return value;\n  return d.toISOString();\n}\n\n/**\n * Human-relative formatting via Intl.RelativeTimeFormat — \"in 2 days\", \"3h ago\".\n * Picks the largest unit that fits.\n */\nexport function formatRelative(target: Date, now: Date): string {\n  const diffMs = target.getTime() - now.getTime();\n  const absMs = Math.abs(diffMs);\n\n  const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [\n    [\"year\", 1000 * 60 * 60 * 24 * 365],\n    [\"month\", 1000 * 60 * 60 * 24 * 30],\n    [\"week\", 1000 * 60 * 60 * 24 * 7],\n    [\"day\", 1000 * 60 * 60 * 24],\n    [\"hour\", 1000 * 60 * 60],\n    [\"minute\", 1000 * 60],\n    [\"second\", 1000],\n  ];\n\n  const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: \"auto\" });\n\n  for (const [unit, msPerUnit] of units) {\n    if (absMs >= msPerUnit || unit === \"second\") {\n      const value = Math.round(diffMs / msPerUnit);\n      return rtf.format(value, unit);\n    }\n  }\n  return rtf.format(0, \"second\");\n}\n\n/** Absolute formatting: \"May 20, 2026, 09:00\". */\nexport function formatAbsolute(date: Date): string {\n  const hasTime =\n    date.getUTCHours() !== 0 ||\n    date.getUTCMinutes() !== 0 ||\n    date.getUTCSeconds() !== 0;\n  return new Intl.DateTimeFormat(undefined, {\n    dateStyle: \"medium\",\n    timeStyle: hasTime ? \"short\" : undefined,\n  }).format(date);\n}\n\n/**\n * Human-readable duration: \"30 min\", \"2 h\", \"1 d 4 h\".\n * Used when duration is set without expireAt.\n */\nexport function formatDuration(ms: number): string {\n  if (!Number.isFinite(ms) || ms <= 0) return \"—\";\n  const sec = Math.floor(ms / 1000);\n  const min = Math.floor(sec / 60);\n  const hr = Math.floor(min / 60);\n  const day = Math.floor(hr / 24);\n\n  if (day > 0) {\n    const remHr = hr - day * 24;\n    return remHr > 0 ? `${day}d ${remHr}h` : `${day}d`;\n  }\n  if (hr > 0) {\n    const remMin = min - hr * 60;\n    return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`;\n  }\n  if (min > 0) return `${min}m`;\n  return `${sec}s`;\n}\n\nexport function clamp01(t: number): number {\n  if (!Number.isFinite(t)) return 0;\n  if (t < 0) return 0;\n  if (t > 1) return 1;\n  return t;\n}\n\n/** Resolve the `now` prop into a callable clock factory. */\nexport function resolveNowFactory(\n  now: Date | (() => Date) | undefined,\n): () => Date {\n  if (now == null) return () => new Date();\n  if (typeof now === \"function\") return now;\n  // Frozen Date instance: copy on each call so consumers can't accidentally mutate.\n  const frozen = now.getTime();\n  return () => new Date(frozen);\n}\n",
      "type": "registry:component",
      "target": "components/task-card/lib/time.ts"
    },
    {
      "path": "src/registry/components/data/task-card/lib/normalize.ts",
      "content": "/**\n * Input ↔ internal tree.\n *\n * `normalize(item)` walks a public TaskItem, validates the closed schema,\n * recovers from common authoring mistakes (missing id, bad ISO date), and\n * produces a TaskNode tree with derived data (level, parentId, sibling index,\n * idIndex map).\n *\n * `denormalize(node)` rebuilds the public TaskItem shape — drops level/parentId/\n * index but otherwise hands back the same data consumers passed in.\n */\n\nimport type { TaskItem, TaskNode } from \"../types\";\nimport { isDateOnly, normalizeIsoField, parseIso } from \"./time\";\n\nexport type NormalizationError = {\n  path: string;\n  message: string;\n};\n\nexport type NormalizationResult = {\n  root: TaskNode;\n  errors: NormalizationError[];\n  idIndex: Map<string, TaskNode>;\n};\n\n/** Returns true if value is a non-null, non-array object. */\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n  return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction validatePerson(\n  raw: unknown,\n  path: string,\n  errors: NormalizationError[],\n): TaskItem[\"targetPerson\"] {\n  if (raw == null) return undefined;\n  if (!isPlainObject(raw)) {\n    errors.push({ path, message: \"person must be an object\" });\n    return undefined;\n  }\n  const id = typeof raw.id === \"string\" ? raw.id : undefined;\n  const name = typeof raw.name === \"string\" ? raw.name : undefined;\n  if (!name) {\n    errors.push({ path: `${path}.name`, message: \"person.name is required\" });\n    return undefined;\n  }\n  return {\n    id: id ?? `person-${Math.random().toString(36).slice(2, 10)}`,\n    name,\n    avatar: typeof raw.avatar === \"string\" ? raw.avatar : undefined,\n  };\n}\n\nfunction validateImages(\n  raw: unknown,\n  path: string,\n  errors: NormalizationError[],\n): TaskItem[\"images\"] {\n  if (raw == null) return undefined;\n  if (!Array.isArray(raw)) {\n    errors.push({ path, message: \"images must be an array\" });\n    return undefined;\n  }\n  const out: NonNullable<TaskItem[\"images\"]> = [];\n  raw.forEach((entry, i) => {\n    if (!isPlainObject(entry)) {\n      errors.push({ path: `${path}[${i}]`, message: \"image must be an object\" });\n      return;\n    }\n    if (typeof entry.src !== \"string\" || entry.src.length === 0) {\n      errors.push({\n        path: `${path}[${i}].src`,\n        message: \"image.src is required\",\n      });\n      return;\n    }\n    out.push({\n      src: entry.src,\n      alt: typeof entry.alt === \"string\" ? entry.alt : undefined,\n      caption: typeof entry.caption === \"string\" ? entry.caption : undefined,\n    });\n  });\n  return out;\n}\n\nfunction validateLinks(\n  raw: unknown,\n  path: string,\n  errors: NormalizationError[],\n): TaskItem[\"links\"] {\n  if (raw == null) return undefined;\n  if (!Array.isArray(raw)) {\n    errors.push({ path, message: \"links must be an array\" });\n    return undefined;\n  }\n  const out: NonNullable<TaskItem[\"links\"]> = [];\n  raw.forEach((entry, i) => {\n    if (!isPlainObject(entry)) {\n      errors.push({ path: `${path}[${i}]`, message: \"link must be an object\" });\n      return;\n    }\n    if (typeof entry.url !== \"string\" || entry.url.length === 0) {\n      errors.push({\n        path: `${path}[${i}].url`,\n        message: \"link.url is required\",\n      });\n      return;\n    }\n    out.push({\n      url: entry.url,\n      label: typeof entry.label === \"string\" ? entry.label : undefined,\n      icon: typeof entry.icon === \"string\" ? entry.icon : undefined,\n    });\n  });\n  return out;\n}\n\nfunction validateLabels(\n  raw: unknown,\n  path: string,\n  errors: NormalizationError[],\n): TaskItem[\"labels\"] {\n  if (raw == null) return undefined;\n  if (!Array.isArray(raw)) {\n    errors.push({ path, message: \"labels must be an array of strings\" });\n    return undefined;\n  }\n  const out: string[] = [];\n  raw.forEach((entry, i) => {\n    if (typeof entry !== \"string\" || entry.length === 0) {\n      errors.push({ path: `${path}[${i}]`, message: \"label must be a non-empty string\" });\n      return;\n    }\n    out.push(entry);\n  });\n  return out.length > 0 ? out : undefined;\n}\n\n/** Generate a fresh id for items that arrived without one. */\nfunction freshId(): string {\n  if (typeof globalThis.crypto !== \"undefined\" && globalThis.crypto.randomUUID) {\n    return globalThis.crypto.randomUUID();\n  }\n  return `todo-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;\n}\n\ntype NormalizeCtx = {\n  errors: NormalizationError[];\n  idIndex: Map<string, TaskNode>;\n  seenIds: Set<string>;\n};\n\nfunction normalizeNode(\n  raw: unknown,\n  ctx: NormalizeCtx,\n  path: string,\n  level: number,\n  parentId: string | null,\n  index: number,\n): TaskNode {\n  // Structural fallback if the input isn't even an object.\n  if (!isPlainObject(raw)) {\n    ctx.errors.push({ path, message: \"item must be an object\" });\n    return makeErrorNode(level, parentId, index);\n  }\n\n  const idRaw = typeof raw.id === \"string\" && raw.id.length > 0 ? raw.id : null;\n  let id = idRaw ?? freshId();\n  if (!idRaw) {\n    ctx.errors.push({\n      path: `${path}.id`,\n      message: \"id is required — synthesized a fresh UUID\",\n    });\n  }\n  if (ctx.seenIds.has(id)) {\n    const original = id;\n    id = freshId();\n    ctx.errors.push({\n      path: `${path}.id`,\n      message: `duplicate id \"${original}\" — regenerated as \"${id}\"`,\n    });\n  }\n  ctx.seenIds.add(id);\n\n  const name =\n    typeof raw.name === \"string\" && raw.name.length > 0 ? raw.name : \"(Untitled)\";\n  if (typeof raw.name !== \"string\" || raw.name.length === 0) {\n    ctx.errors.push({\n      path: `${path}.name`,\n      message: \"name is missing or empty\",\n    });\n  }\n\n  const status = typeof raw.status === \"string\" ? raw.status : \"\";\n  const active = typeof raw.active === \"boolean\" ? raw.active : true;\n\n  // setAt is required. Recover to now() ISO if missing/invalid. A date-only\n  // `YYYY-MM-DD` round-trips VERBATIM (all-day form; see normalizeIsoField).\n  const setAtRaw = typeof raw.setAt === \"string\" ? raw.setAt : \"\";\n  const setAtDate = parseIso(setAtRaw);\n  const setAt = setAtDate\n    ? isDateOnly(setAtRaw)\n      ? setAtRaw\n      : setAtDate.toISOString()\n    : new Date().toISOString();\n  if (!setAtDate) {\n    ctx.errors.push({\n      path: `${path}.setAt`,\n      message: `setAt missing or invalid ISO-8601 — coerced to now()`,\n    });\n  }\n\n  const startAt =\n    typeof raw.startAt === \"string\"\n      ? normalizeIsoField(raw.startAt)\n      : undefined;\n  const expireAt =\n    typeof raw.expireAt === \"string\"\n      ? normalizeIsoField(raw.expireAt)\n      : undefined;\n  const duration =\n    typeof raw.duration === \"number\" && Number.isFinite(raw.duration) && raw.duration > 0\n      ? raw.duration\n      : undefined;\n\n  // expireAt < startAt sanity check.\n  if (expireAt && startAt) {\n    const sd = parseIso(startAt);\n    const ed = parseIso(expireAt);\n    if (sd && ed && ed.getTime() <= sd.getTime()) {\n      ctx.errors.push({\n        path: `${path}.expireAt`,\n        message: \"expireAt <= startAt; time engine will treat as inactive\",\n      });\n    }\n  }\n\n  // AUTHORITATIVE ALLOW-LIST: this literal is the canonical-schema guard — any\n  // key NOT constructed here is silently dropped on the way in (and, in\n  // controlled mode, on every `sync-tree` reconcile). When adding a field to\n  // the TaskItem type you MUST add it here too, and to denormalize()'s\n  // optionalKeys below, or the field never reaches the card.\n  const item: TaskItem = {\n    id,\n    name,\n    description:\n      typeof raw.description === \"string\" ? raw.description : undefined,\n    status,\n    active,\n    setAt,\n    startAt,\n    expireAt,\n    duration,\n    targetPerson: validatePerson(raw.targetPerson, `${path}.targetPerson`, ctx.errors),\n    creatorPerson: validatePerson(raw.creatorPerson, `${path}.creatorPerson`, ctx.errors),\n    images: validateImages(raw.images, `${path}.images`, ctx.errors),\n    links: validateLinks(raw.links, `${path}.links`, ctx.errors),\n    borderColor:\n      typeof raw.borderColor === \"string\" ? raw.borderColor : undefined,\n    locked: typeof raw.locked === \"boolean\" ? raw.locked : undefined,\n    priority: typeof raw.priority === \"string\" ? raw.priority : undefined,\n    labels: validateLabels(raw.labels, `${path}.labels`, ctx.errors),\n  };\n\n  // Children — recurse.\n  const childNodes: TaskNode[] = [];\n  if (raw.children !== undefined) {\n    if (!Array.isArray(raw.children)) {\n      ctx.errors.push({\n        path: `${path}.children`,\n        message: \"children must be an array\",\n      });\n    } else {\n      raw.children.forEach((child, i) => {\n        childNodes.push(\n          normalizeNode(child, ctx, `${path}.children[${i}]`, level + 1, id, i),\n        );\n      });\n    }\n  }\n  item.children = childNodes.length > 0 ? childNodes.map((n) => n.item) : undefined;\n\n  const node: TaskNode = {\n    item,\n    level,\n    parentId,\n    index,\n    childNodes,\n  };\n  ctx.idIndex.set(id, node);\n  return node;\n}\n\nfunction makeErrorNode(\n  level: number,\n  parentId: string | null,\n  index: number,\n): TaskNode {\n  const id = freshId();\n  return {\n    item: {\n      id,\n      name: \"(Invalid item)\",\n      status: \"\",\n      active: false,\n      setAt: new Date().toISOString(),\n    },\n    level,\n    parentId,\n    index,\n    childNodes: [],\n  };\n}\n\nexport function normalize(input: TaskItem | unknown): NormalizationResult {\n  const ctx: NormalizeCtx = {\n    errors: [],\n    idIndex: new Map(),\n    seenIds: new Set(),\n  };\n  const root = normalizeNode(input, ctx, \"$\", 1, null, 0);\n  return { root, errors: ctx.errors, idIndex: ctx.idIndex };\n}\n\n/** TaskNode → public TaskItem (drops level/parentId/index). */\nexport function denormalize(node: TaskNode): TaskItem {\n  const { item, childNodes } = node;\n  // Reconstruct children array from current childNodes (item.children may be stale after edits).\n  const out: TaskItem = { ...item };\n  if (childNodes.length > 0) {\n    out.children = childNodes.map(denormalize);\n  } else {\n    delete out.children;\n  }\n  // Strip undefined optional fields so the output is canonical.\n  const optionalKeys: Array<keyof TaskItem> = [\n    \"description\",\n    \"startAt\",\n    \"expireAt\",\n    \"duration\",\n    \"targetPerson\",\n    \"creatorPerson\",\n    \"images\",\n    \"links\",\n    \"borderColor\",\n    \"locked\",\n    \"priority\",\n    \"labels\",\n    \"children\",\n  ];\n  optionalKeys.forEach((k) => {\n    if (out[k] === undefined) delete out[k];\n  });\n  return out;\n}\n\n/**\n * Read-only lookup: walks the tree to find a node by id (no mutation).\n * Returns the node, or null if not found.\n */\nexport function findNode(root: TaskNode, id: string): TaskNode | null {\n  if (root.item.id === id) return root;\n  for (const child of root.childNodes) {\n    const found = findNode(child, id);\n    if (found) return found;\n  }\n  return null;\n}\n\n/**\n * Return a structural copy of `item` with a fresh id on every node in the\n * subtree. Used when an external payload (paste / drop) is grafted into a tree\n * that may already contain its ids — re-iding prevents duplicate-id corruption\n * of the id-keyed lookup / focus / permission maps and keeps `getValue()` JSON\n * canonical.\n */\nexport function reassignIds(item: TaskItem): TaskItem {\n  return {\n    ...item,\n    id: freshId(),\n    children: item.children?.map(reassignIds),\n  };\n}\n\n/** Recompute level/parentId/index from a freshly-restructured tree. */\nexport function reindex(root: TaskNode): TaskNode {\n  function walk(node: TaskNode, level: number, parentId: string | null, index: number): TaskNode {\n    return {\n      item: node.item,\n      level,\n      parentId,\n      index,\n      childNodes: node.childNodes.map((child, i) =>\n        walk(child, level + 1, node.item.id, i),\n      ),\n    };\n  }\n  return walk(root, 1, null, 0);\n}\n",
      "type": "registry:component",
      "target": "components/task-card/lib/normalize.ts"
    },
    {
      "path": "src/registry/components/data/task-card/lib/ramp.ts",
      "content": "/**\n * OKLCH ramp engine.\n *\n * Endpoints picked against design-system tokens: green at t=0 (calm/fresh),\n * red at t=1 (alarming/overdue). Hue interpolation takes the short path\n * (so green→red goes through yellow/orange, not blue/purple).\n *\n * Browser support: `oklch()` syntax is universal in our target browsers\n * (the rest of the design system uses OKLCH throughout — see globals.css).\n */\n\nimport type { TaskColorRamp, TaskColorRampPreset } from \"../types\";\nimport { clamp01 } from \"./time\";\n\ntype Oklch = { l: number; c: number; h: number };\n\nconst STOPS: Record<TaskColorRampPreset, { from: Oklch; to: Oklch }> = {\n  default: {\n    from: { l: 0.78, c: 0.18, h: 142 },\n    to: { l: 0.62, c: 0.22, h: 25 },\n  },\n  muted: {\n    from: { l: 0.82, c: 0.08, h: 142 },\n    to: { l: 0.68, c: 0.12, h: 25 },\n  },\n  vivid: {\n    from: { l: 0.75, c: 0.22, h: 145 },\n    to: { l: 0.58, c: 0.26, h: 22 },\n  },\n  monochrome: {\n    from: { l: 0.85, c: 0.02, h: 250 },\n    to: { l: 0.45, c: 0.02, h: 250 },\n  },\n};\n\n/** Short-path hue interpolation; handles the 360° wrap. */\nexport function interpolateOklch(a: Oklch, b: Oklch, t: number): Oklch {\n  const tt = clamp01(t);\n  const hueDelta = ((b.h - a.h + 540) % 360) - 180;\n  return {\n    l: a.l + (b.l - a.l) * tt,\n    c: a.c + (b.c - a.c) * tt,\n    h: a.h + hueDelta * tt,\n  };\n}\n\nfunction formatOklch({ l, c, h }: Oklch): string {\n  // Three decimals is enough for visually-distinct stops at our budget; keeps strings short.\n  return `oklch(${l.toFixed(3)} ${c.toFixed(3)} ${((h % 360) + 360) % 360})`;\n}\n\nfunction rampFromPreset(preset: TaskColorRampPreset): (t: number) => string {\n  const { from, to } = STOPS[preset];\n  return (t) => formatOklch(interpolateOklch(from, to, t));\n}\n\n/** Public — exported from index.ts. Mirrors card-tree's PREDEFINED_KEYS pattern. */\nexport const RAMPS: Record<TaskColorRampPreset, (t: number) => string> = {\n  default: rampFromPreset(\"default\"),\n  muted: rampFromPreset(\"muted\"),\n  vivid: rampFromPreset(\"vivid\"),\n  monochrome: rampFromPreset(\"monochrome\"),\n};\n\n/** Resolve a TaskColorRamp (preset name | custom fn) into a callable. */\nexport function resolveRamp(\n  ramp: TaskColorRamp | undefined,\n): (elapsed: number) => string {\n  if (ramp == null) return RAMPS.default;\n  if (typeof ramp === \"function\") return ramp;\n  return RAMPS[ramp];\n}\n",
      "type": "registry:component",
      "target": "components/task-card/lib/ramp.ts"
    },
    {
      "path": "src/registry/components/data/task-card/lib/color-engine.ts",
      "content": "/**\n * Time → 0-1 elapsed value + border color resolution.\n *\n * Pure functions. No React.\n *\n * Rules (from plan §5.1):\n *   - Both expireAt + duration set → expireAt wins\n *   - Only expireAt → normalize over (expireAt - startAt)\n *   - Only duration → normalize over duration from startAt\n *   - Neither → returns null (engine inactive; default border)\n *   - Past expireAt → clamped to 1.0 (full red)\n *   - Before startAt → clamped to 0.0 (full green)\n *   - Per-item borderColor wins over engine output\n */\n\nimport type { TaskItem } from \"../types\";\nimport { clamp01, parseIso } from \"./time\";\n\n/** Returns elapsed in [0, 1] or null when the engine is inactive for this item. */\nexport function computeElapsed(item: TaskItem, now: Date): number | null {\n  const start = parseIso(item.startAt ?? item.setAt);\n  if (!start) return null;\n\n  if (item.expireAt) {\n    const end = parseIso(item.expireAt);\n    if (!end) return null;\n    if (end.getTime() <= start.getTime()) return null;\n    return clamp01((now.getTime() - start.getTime()) / (end.getTime() - start.getTime()));\n  }\n\n  if (item.duration && item.duration > 0) {\n    return clamp01((now.getTime() - start.getTime()) / item.duration);\n  }\n\n  return null;\n}\n\n/**\n * Border color resolution.\n * Returns null when the renderer should fall back to the semantic-token\n * className (border-border) — i.e., no inline style.borderColor.\n */\nexport function resolveBorderColor(\n  item: TaskItem,\n  elapsed: number | null,\n  ramp: (t: number) => string,\n): string | null {\n  if (item.borderColor != null && item.borderColor.length > 0) {\n    return item.borderColor;\n  }\n  if (elapsed == null) return null;\n  return ramp(elapsed);\n}\n",
      "type": "registry:component",
      "target": "components/task-card/lib/color-engine.ts"
    },
    {
      "path": "src/registry/components/data/task-card/lib/permissions.ts",
      "content": "/**\n * Permission resolver. Pure function.\n *\n * Resolution order (plan §8.1):\n *   1. Hard lock (item.locked) → all false, reason 'locked'\n *   2. Per-action predicate (canEditItem? / etc.) → false, reason 'predicate'\n *   3. permissions.byItem[id] rule\n *   4. permissions.byLevel[level] rule\n *   5. permissions.default rule\n *   6. Fallback: all true\n *\n * `permissions.inherit` (default true) cascades a parent's effective rule\n * onto its descendants before per-item / per-level rules apply.\n */\n\nimport type {\n  ResolvedPermissions,\n  TaskNode,\n  TaskPermissionReason,\n  TaskPermissionRule,\n  TaskPermissions,\n  TaskCardProps,\n} from \"../types\";\n\nconst ACTIONS: Array<keyof TaskPermissionRule> = [\n  \"edit\",\n  \"remove\",\n  \"addChildren\",\n  \"drag\",\n  \"toggleActive\",\n  \"overrideColor\",\n];\n\nfunction allTrue(): TaskPermissionRule {\n  return {\n    edit: true,\n    remove: true,\n    addChildren: true,\n    drag: true,\n    toggleActive: true,\n    overrideColor: true,\n  };\n}\n\nfunction mergeRule(\n  base: TaskPermissionRule,\n  overlay: TaskPermissionRule | undefined,\n): TaskPermissionRule {\n  if (!overlay) return base;\n  const out: TaskPermissionRule = { ...base };\n  for (const key of ACTIONS) {\n    if (overlay[key] !== undefined) out[key] = overlay[key];\n  }\n  return out;\n}\n\n/** Returns the effective rule for a node, walking declarative permissions only. */\nfunction declarativeRule(\n  level: number,\n  itemId: string,\n  ancestorRule: TaskPermissionRule,\n  permissions: TaskPermissions | undefined,\n): { rule: TaskPermissionRule; reason: TaskPermissionReason } {\n  const inherit = permissions?.inherit !== false; // default true\n\n  let rule = allTrue();\n  let reason: TaskPermissionReason = \"default\";\n\n  if (inherit) {\n    rule = ancestorRule;\n  }\n\n  if (permissions?.default) {\n    rule = mergeRule(rule, permissions.default);\n    reason = \"default\";\n  }\n  if (permissions?.byLevel?.[level]) {\n    rule = mergeRule(rule, permissions.byLevel[level]);\n    reason = \"by-level\";\n  }\n  if (permissions?.byItem?.[itemId]) {\n    rule = mergeRule(rule, permissions.byItem[itemId]);\n    reason = \"by-item\";\n  }\n\n  return { rule, reason };\n}\n\n/**\n * Compute effective permissions for a single node, given an ancestor's\n * pre-resolved rule (for cascade). Pure.\n */\nexport function resolveForNode(\n  node: TaskNode,\n  ancestorRule: TaskPermissionRule,\n  props: TaskCardProps,\n): ResolvedPermissions {\n  const id = node.item.id;\n\n  // 1. Hard lock.\n  if (node.item.locked) {\n    return {\n      edit: false,\n      remove: false,\n      addChildren: false,\n      drag: false,\n      toggleActive: false,\n      overrideColor: false,\n      reason: \"locked\",\n    };\n  }\n\n  // 3-5. Declarative resolution.\n  const { rule, reason: declarativeReason } = declarativeRule(\n    node.level,\n    id,\n    ancestorRule,\n    props.permissions,\n  );\n\n  // 2. Per-action predicate overrides. A predicate returning false wins;\n  //    returning true falls back to the declarative rule.\n  const predicateChecks: Array<[keyof TaskPermissionRule, ((id: string) => boolean) | undefined]> = [\n    [\"edit\", props.canEditItem],\n    [\"remove\", props.canRemoveItem],\n    [\"addChildren\", props.canAddChildren],\n    [\"drag\", props.canDragItem],\n    [\"toggleActive\", props.canToggleActive],\n    [\"overrideColor\", props.canOverrideColor],\n  ];\n\n  let predicateDeniedAny = false;\n  const resolved: TaskPermissionRule = { ...rule };\n  for (const [action, predicate] of predicateChecks) {\n    if (predicate && !predicate(id)) {\n      resolved[action] = false;\n      predicateDeniedAny = true;\n    }\n  }\n\n  return {\n    edit: resolved.edit ?? true,\n    remove: resolved.remove ?? true,\n    addChildren: resolved.addChildren ?? true,\n    drag: resolved.drag ?? true,\n    toggleActive: resolved.toggleActive ?? true,\n    overrideColor: resolved.overrideColor ?? true,\n    reason: predicateDeniedAny ? \"predicate\" : declarativeReason,\n  };\n}\n\n/**\n * Build a memoizable resolver closure for a tree.\n * The resolver walks ancestors lazily via the idIndex; in practice it's called\n * per-render-per-card so we precompute a flat map.\n */\nexport function buildResolver(\n  root: TaskNode,\n  props: TaskCardProps,\n): (node: TaskNode) => ResolvedPermissions {\n  // Pre-walk the tree depth-first, threading the ancestor rule.\n  const cache = new Map<string, ResolvedPermissions>();\n\n  function walk(node: TaskNode, ancestorRule: TaskPermissionRule): void {\n    const resolved = resolveForNode(node, ancestorRule, props);\n    cache.set(node.item.id, resolved);\n    // For cascade: convert resolved (effective) back into a \"carry\" rule for descendants.\n    const carry: TaskPermissionRule = {\n      edit: resolved.edit,\n      remove: resolved.remove,\n      addChildren: resolved.addChildren,\n      drag: resolved.drag,\n      toggleActive: resolved.toggleActive,\n      overrideColor: resolved.overrideColor,\n    };\n    for (const child of node.childNodes) walk(child, carry);\n  }\n\n  walk(root, allTrue());\n\n  return (node: TaskNode) => {\n    const cached = cache.get(node.item.id);\n    if (cached) return cached;\n    // Cache miss (node added between memo rebuilds, e.g. a fresh paste/drop):\n    // resolve against the PARENT's cached effective rule so inherited rules\n    // still cascade — instead of assuming an all-true ancestry.\n    const parentCached = node.parentId ? cache.get(node.parentId) : undefined;\n    const ancestorRule: TaskPermissionRule = parentCached\n      ? {\n          edit: parentCached.edit,\n          remove: parentCached.remove,\n          addChildren: parentCached.addChildren,\n          drag: parentCached.drag,\n          toggleActive: parentCached.toggleActive,\n          overrideColor: parentCached.overrideColor,\n        }\n      : allTrue();\n    return resolveForNode(node, ancestorRule, props);\n  };\n}\n",
      "type": "registry:component",
      "target": "components/task-card/lib/permissions.ts"
    },
    {
      "path": "src/registry/components/data/task-card/lib/json-io.ts",
      "content": "/**\n * JSON I/O + clipboard helpers. Pure modules (no React).\n *\n * Mirrors card-tree v0.3 patterns: pretty-printed canonical JSON, structural\n * validation distinct from full normalization (which lives in normalize.ts).\n */\n\nimport type { TaskItem } from \"../types\";\nimport { TASK_CLIPBOARD_MIME } from \"../types\";\n\nexport class TaskValidationError extends Error {\n  readonly path: string;\n  readonly expected: string;\n  readonly actual: string;\n  constructor(path: string, expected: string, actual: string) {\n    super(`${path}: expected ${expected}, got ${actual}`);\n    this.name = \"TaskValidationError\";\n    this.path = path;\n    this.expected = expected;\n    this.actual = actual;\n  }\n}\n\n/** Pretty-printed canonical JSON. */\nexport function serialize(item: TaskItem): string {\n  return JSON.stringify(item, null, 2);\n}\n\n/** Cheap structural fence — throws TaskValidationError on the first miss. */\nexport function validate(value: unknown, path = \"$\"): asserts value is TaskItem {\n  if (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n    throw new TaskValidationError(path, \"object\", typeof value);\n  }\n  const v = value as Record<string, unknown>;\n  if (typeof v.id !== \"string\" || v.id.length === 0) {\n    throw new TaskValidationError(`${path}.id`, \"non-empty string\", String(v.id));\n  }\n  if (typeof v.name !== \"string\") {\n    throw new TaskValidationError(`${path}.name`, \"string\", String(v.name));\n  }\n  if (typeof v.status !== \"string\") {\n    throw new TaskValidationError(`${path}.status`, \"string\", String(v.status));\n  }\n  if (typeof v.active !== \"boolean\") {\n    throw new TaskValidationError(`${path}.active`, \"boolean\", String(v.active));\n  }\n  if (typeof v.setAt !== \"string\") {\n    throw new TaskValidationError(`${path}.setAt`, \"ISO-8601 string\", String(v.setAt));\n  }\n  if (v.children !== undefined) {\n    if (!Array.isArray(v.children)) {\n      throw new TaskValidationError(\n        `${path}.children`,\n        \"array | undefined\",\n        typeof v.children,\n      );\n    }\n    v.children.forEach((child, i) => validate(child, `${path}.children[${i}]`));\n  }\n}\n\nexport function parse(json: string): TaskItem {\n  const obj: unknown = JSON.parse(json);\n  validate(obj);\n  return obj;\n}\n\n/* ───────── DnD ───────── */\n\n/**\n * Read a TaskItem from a DnD DataTransfer synchronously. Returns null when the\n * payload isn't a valid Todo (no throw — silent no-op). Clipboard copy/paste now\n * lives in the shared `clipboard.ts` envelope; this stays for drag payloads.\n */\nexport function fromDataTransfer(dt: DataTransfer): TaskItem | null {\n  const ourPayload = dt.getData(TASK_CLIPBOARD_MIME);\n  if (ourPayload) {\n    try {\n      return parse(ourPayload);\n    } catch {\n      // fall through\n    }\n  }\n  const text = dt.getData(\"text/plain\");\n  if (text) {\n    try {\n      return parse(text);\n    } catch {\n      // not a Todo\n    }\n  }\n  return null;\n}\n",
      "type": "registry:component",
      "target": "components/task-card/lib/json-io.ts"
    },
    {
      "path": "src/registry/components/data/task-card/lib/clipboard.ts",
      "content": "/**\n * Unified cross-surface task clipboard (the canonical family module). Every\n * task-family surface — task-card / task-tree / gantt-timeline / calendar —\n * copies & pastes `TaskItem`s through the OS clipboard using ONE envelope, so a\n * copy on any surface pastes on any other.\n *\n * Wire format — a versioned envelope:\n *   { kind: \"ilinxa/task\", version, source?, items: TaskItem[] }\n *\n * Transports:\n *   - `text/plain` carries the envelope (universally portable; the guaranteed path).\n *   - The custom MIME `application/x-ilinxa-task+json` also carries the envelope on\n *     the async `navigator.clipboard.write` path (typed fast-path).\n * Read is omnivorous: it accepts the envelope from either transport AND a legacy\n * bare `TaskItem` (the pre-unify card-tree payload), wrapping it as `items:[item]`.\n * Foreign clipboard text parses to `null` so a normal paste falls through.\n *\n * Pure + framework-free (Vitest-ready): `serialize`/`parse`/`reassignTaskIds` are\n * deterministic; the event + async adapters are the only DOM touch-points and are\n * called from client clipboard handlers.\n */\n\nimport type { TaskItem } from \"../types\";\nimport { TASK_CLIPBOARD_MIME } from \"../types\";\n\n/** Envelope sentinel — a foreign payload (plain text, other app's JSON) lacks it. */\nexport const TASK_CLIPBOARD_KIND = \"ilinxa/task\";\nexport const TASK_CLIPBOARD_VERSION = 1;\n\nexport interface TaskClipboardEnvelope {\n  kind: typeof TASK_CLIPBOARD_KIND;\n  version: number;\n  /** Provenance hint (e.g. \"event-calendar\"); informational only. */\n  source?: string;\n  items: TaskItem[];\n}\n\n/** Fresh id (crypto-guarded fallback). Neutral prefix — this module is shared. */\nfunction freshId(): string {\n  if (typeof crypto !== \"undefined\" && \"randomUUID\" in crypto) {\n    return `task-${crypto.randomUUID()}`;\n  }\n  return `task-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;\n}\n\n/**\n * Deep-clone a `TaskItem` subtree with fresh ids so a paste can never collide\n * with the source (or with an item pasted twice). Canonical home for the family —\n * supersedes calendar's local mirror and `normalize.ts#reassignIds` (which isn't\n * barrel-exported).\n */\nexport function reassignTaskIds(item: TaskItem): TaskItem {\n  return {\n    ...item,\n    id: freshId(),\n    children: item.children?.map(reassignTaskIds),\n  };\n}\n\n/** Serialize tasks to the clipboard text payload (JSON envelope). */\nexport function serializeTasks(items: TaskItem[], source?: string): string {\n  const envelope: TaskClipboardEnvelope = {\n    kind: TASK_CLIPBOARD_KIND,\n    version: TASK_CLIPBOARD_VERSION,\n    source,\n    items,\n  };\n  return JSON.stringify(envelope);\n}\n\n/**\n * Structural fence for the legacy bare-item back-compat branch. Matches the\n * required `TaskItem` core (id / name / status / active / setAt) so a foreign\n * `{id, name}` JSON can't false-positive as a task and get pasted as junk.\n */\nfunction looksLikeTaskItem(v: unknown): v is TaskItem {\n  if (typeof v !== \"object\" || v === null || Array.isArray(v)) return false;\n  const o = v as Record<string, unknown>;\n  return (\n    typeof o.id === \"string\" &&\n    o.id.length > 0 &&\n    typeof o.name === \"string\" &&\n    typeof o.status === \"string\" &&\n    typeof o.active === \"boolean\" &&\n    typeof o.setAt === \"string\"\n  );\n}\n\n/**\n * Parse a clipboard text payload back to tasks; `null` if it isn't ours. Accepts\n * the envelope OR a legacy bare `TaskItem` (pre-unify card-tree copy) → `[item]`.\n */\nexport function parseTasks(text: string | null | undefined): TaskItem[] | null {\n  if (!text) return null;\n  try {\n    const parsed = JSON.parse(text) as unknown;\n    if (\n      parsed &&\n      typeof parsed === \"object\" &&\n      (parsed as Partial<TaskClipboardEnvelope>).kind === TASK_CLIPBOARD_KIND &&\n      // Version gate: only accept the envelope version this reader knows. A\n      // future v2 envelope must not be half-parsed by v1 code.\n      (parsed as Partial<TaskClipboardEnvelope>).version ===\n        TASK_CLIPBOARD_VERSION &&\n      Array.isArray((parsed as TaskClipboardEnvelope).items) &&\n      (parsed as TaskClipboardEnvelope).items.length > 0 &&\n      // Structural fence over EVERY item — a right-kind envelope with junk\n      // items (hand-crafted / corrupted JSON) must not paste garbage into a\n      // tree. Any invalid item ⇒ the whole payload is foreign.\n      (parsed as TaskClipboardEnvelope).items.every(looksLikeTaskItem)\n    ) {\n      return (parsed as TaskClipboardEnvelope).items;\n    }\n    // Back-compat: a legacy bare TaskItem (pre-unify card-tree) → wrap as one.\n    if (looksLikeTaskItem(parsed)) return [parsed];\n  } catch {\n    // Not JSON / not ours — let the native paste proceed.\n  }\n  return null;\n}\n\n/* ───────── ClipboardEvent adapters (copy / cut / paste handlers) ───────── */\n\n/**\n * Write tasks onto a `copy`/`cut` ClipboardEvent. text/plain only — the\n * guaranteed path on synthetic events (custom MIME types are unreliable here).\n */\nexport function writeTasksToClipboardEvent(\n  e: ClipboardEvent,\n  items: TaskItem[],\n  source?: string,\n): void {\n  e.clipboardData?.setData(\"text/plain\", serializeTasks(items, source));\n}\n\n/** Read tasks from a `paste` ClipboardEvent; `null` if foreign. */\nexport function readTasksFromClipboardEvent(\n  e: ClipboardEvent,\n): TaskItem[] | null {\n  const dt = e.clipboardData;\n  if (!dt) return null;\n  return parseTasks(dt.getData(TASK_CLIPBOARD_MIME) || dt.getData(\"text/plain\"));\n}\n\n/* ───────── async navigator.clipboard variants (handle / keyboard) ───────── */\n\n/**\n * Programmatic copy: writes the envelope to the OS clipboard via the typed\n * ClipboardItem (custom MIME + text/plain), falling back to `writeText`.\n */\nexport async function copyTasksToClipboard(\n  items: TaskItem[],\n  source?: string,\n): Promise<void> {\n  if (typeof navigator === \"undefined\" || !navigator.clipboard) {\n    throw new Error(\"Clipboard API not available\");\n  }\n  const payload = serializeTasks(items, source);\n  if (navigator.clipboard.write && typeof ClipboardItem !== \"undefined\") {\n    try {\n      await navigator.clipboard.write([\n        new ClipboardItem({\n          [TASK_CLIPBOARD_MIME]: new Blob([payload], { type: TASK_CLIPBOARD_MIME }),\n          \"text/plain\": new Blob([payload], { type: \"text/plain\" }),\n        }),\n      ]);\n      return;\n    } catch {\n      // Typed write blocked — fall through to text/plain.\n    }\n  }\n  if (navigator.clipboard.writeText) {\n    await navigator.clipboard.writeText(payload);\n    return;\n  }\n  throw new Error(\"Clipboard API not available\");\n}\n\n/**\n * Programmatic paste: reads the OS clipboard (typed MIME first, then text/plain),\n * returning the tasks or `null`. Omnivorous via `parseTasks`.\n */\nexport async function readTasksFromClipboard(): Promise<TaskItem[] | null> {\n  if (typeof navigator === \"undefined\" || !navigator.clipboard) return null;\n  if (navigator.clipboard.read) {\n    try {\n      const items = await navigator.clipboard.read();\n      for (const it of items) {\n        if (it.types.includes(TASK_CLIPBOARD_MIME)) {\n          const text = await (await it.getType(TASK_CLIPBOARD_MIME)).text();\n          const parsed = parseTasks(text);\n          if (parsed) return parsed;\n        }\n        if (it.types.includes(\"text/plain\")) {\n          const text = await (await it.getType(\"text/plain\")).text();\n          const parsed = parseTasks(text);\n          if (parsed) return parsed;\n        }\n      }\n      return null;\n    } catch {\n      // Typed read blocked — fall through to readText.\n    }\n  }\n  if (navigator.clipboard.readText) {\n    try {\n      return parseTasks(await navigator.clipboard.readText());\n    } catch {\n      return null;\n    }\n  }\n  return null;\n}\n",
      "type": "registry:component",
      "target": "components/task-card/lib/clipboard.ts"
    },
    {
      "path": "src/registry/components/data/task-card/lib/reducer.ts",
      "content": "/**\n * State machine. Pure reducer.\n *\n * The reducer only mutates `state` — it does NOT fire events. Event firing\n * happens at the dispatch site (in hooks/use-card-state) which has access to\n * the props callbacks. Keeping the reducer side-effect-free preserves\n * testability and makes time-travel debugging trivial when added later.\n */\n\nimport type {\n  Action,\n  EditState,\n  State,\n  TaskItem,\n  TaskNode,\n} from \"../types\";\nimport { findNode, normalize, reassignIds, reindex } from \"./normalize\";\n\nexport function createInitialState(input: TaskItem | undefined): State {\n  const { root } = normalize(input);\n  return {\n    root,\n    edit: { kind: \"view\" },\n    focusedId: null,\n    dirty: false,\n    collapsedIds: new Set<string>(),\n  };\n}\n\n/** Deep-clone a node so the reducer can mutate without aliasing. */\nfunction cloneNode(node: TaskNode): TaskNode {\n  return {\n    item: { ...node.item },\n    level: node.level,\n    parentId: node.parentId,\n    index: node.index,\n    childNodes: node.childNodes.map(cloneNode),\n  };\n}\n\n/** Walk + map: returns a new tree with `transform` applied to a single node. */\nfunction withNode(\n  root: TaskNode,\n  id: string,\n  transform: (node: TaskNode) => TaskNode,\n): TaskNode {\n  if (root.item.id === id) return transform(cloneNode(root));\n  return {\n    ...root,\n    childNodes: root.childNodes.map((child) => withNode(child, id, transform)),\n  };\n}\n\n/** Locate and remove a node; returns { newRoot, removed, oldParentId, oldIndex }. */\nfunction removeNode(\n  root: TaskNode,\n  id: string,\n): {\n  newRoot: TaskNode;\n  removed: TaskNode | null;\n  oldParentId: string | null;\n  oldIndex: number;\n} {\n  if (root.item.id === id) {\n    // Root removal is out of scope in v0.1; caller should reject.\n    return { newRoot: root, removed: null, oldParentId: null, oldIndex: 0 };\n  }\n\n  function walk(node: TaskNode): {\n    node: TaskNode;\n    removed: TaskNode | null;\n    parentId: string | null;\n    index: number;\n  } {\n    const directIndex = node.childNodes.findIndex((c) => c.item.id === id);\n    if (directIndex !== -1) {\n      const removed = node.childNodes[directIndex];\n      const nextChildren = node.childNodes.filter((_, i) => i !== directIndex);\n      return {\n        node: { ...node, childNodes: nextChildren },\n        removed,\n        parentId: node.item.id,\n        index: directIndex,\n      };\n    }\n    let foundRemoved: TaskNode | null = null;\n    let foundParent: string | null = null;\n    let foundIndex = 0;\n    const nextChildren = node.childNodes.map((child) => {\n      if (foundRemoved) return child;\n      const r = walk(child);\n      if (r.removed) {\n        foundRemoved = r.removed;\n        foundParent = r.parentId;\n        foundIndex = r.index;\n        return r.node;\n      }\n      return child;\n    });\n    return {\n      node: { ...node, childNodes: nextChildren },\n      removed: foundRemoved,\n      parentId: foundParent,\n      index: foundIndex,\n    };\n  }\n\n  const result = walk(root);\n  return {\n    newRoot: result.node,\n    removed: result.removed,\n    oldParentId: result.parentId,\n    oldIndex: result.index,\n  };\n}\n\n/** Insert a node under parentId at the given index (append if undefined). */\nfunction insertChild(\n  root: TaskNode,\n  parentId: string,\n  newChild: TaskNode,\n  index?: number,\n): TaskNode {\n  return withNode(root, parentId, (parent) => {\n    const next = [...parent.childNodes];\n    const i = index == null || index < 0 || index > next.length ? next.length : index;\n    next.splice(i, 0, newChild);\n    return { ...parent, childNodes: next };\n  });\n}\n\nfunction taskItemToNode(\n  item: TaskItem,\n  level: number,\n  parentId: string,\n  index: number,\n): TaskNode {\n  return {\n    item: { ...item },\n    level,\n    parentId,\n    index,\n    childNodes: (item.children ?? []).map((child, i) =>\n      taskItemToNode(child, level + 1, item.id, i),\n    ),\n  };\n}\n\nfunction applyFieldEdit(\n  root: TaskNode,\n  id: string,\n  key: string,\n  value: unknown,\n): TaskNode {\n  return withNode(root, id, (node) => {\n    return {\n      ...node,\n      // eslint-disable-next-line @typescript-eslint/no-explicit-any\n      item: { ...node.item, [key]: value } as any,\n    };\n  });\n}\n\nexport function reducer(state: State, action: Action): State {\n  switch (action.type) {\n    case \"replace-tree\": {\n      const { root } = normalize(action.tree);\n      return {\n        root,\n        edit: { kind: \"view\" },\n        focusedId: null,\n        dirty: false,\n        collapsedIds: new Set<string>(),\n      };\n    }\n\n    case \"open-edit\": {\n      // Caller is responsible for the permissions check.\n      const editing: EditState = { kind: action.mode, itemId: action.itemId };\n      return { ...state, edit: editing };\n    }\n\n    case \"close-edit\": {\n      return { ...state, edit: { kind: \"view\" } };\n    }\n\n    case \"edit-field\": {\n      const root = applyFieldEdit(state.root, action.itemId, action.key, action.value);\n      return { ...state, root, dirty: true };\n    }\n\n    case \"add-child\": {\n      // Re-id the incoming subtree: paste/drop payloads may carry ids that\n      // already exist in this tree, which would corrupt id-keyed lookup/focus/\n      // permissions. Re-iding here covers all add-child entry points at once.\n      const newNode = taskItemToNode(reassignIds(action.item), 0, action.parentId, 0);\n      const rootWithInsert = insertChild(state.root, action.parentId, newNode, action.index);\n      return { ...state, root: reindex(rootWithInsert), dirty: true };\n    }\n\n    case \"sync-tree\": {\n      // Controlled-mode reconcile: replace the tree from an external `value`\n      // while preserving UI-only state (edit / focus / collapse / dirty).\n      const { root } = normalize(action.tree);\n      return { ...state, root };\n    }\n\n    case \"remove-item\": {\n      const { newRoot, removed } = removeNode(state.root, action.itemId);\n      if (!removed) return state; // not found or attempted root-remove\n      // Close edit if the removed item (or any descendant) was being edited.\n      const newEdit: EditState =\n        state.edit.kind !== \"view\" && state.edit.itemId === action.itemId\n          ? { kind: \"view\" }\n          : state.edit;\n      return {\n        ...state,\n        root: reindex(newRoot),\n        edit: newEdit,\n        dirty: true,\n      };\n    }\n\n    case \"move-item\": {\n      const { newRoot, removed } = removeNode(state.root, action.itemId);\n      if (!removed) return state;\n      const inserted = insertChild(newRoot, action.newParentId, removed, action.newIndex);\n      return { ...state, root: reindex(inserted), dirty: true };\n    }\n\n    case \"set-border-color\": {\n      const next = withNode(state.root, action.itemId, (node) => ({\n        ...node,\n        item: {\n          ...node.item,\n          borderColor: action.color ?? undefined,\n        },\n      }));\n      return { ...state, root: next, dirty: true };\n    }\n\n    case \"toggle-active\": {\n      const next = withNode(state.root, action.itemId, (node) => ({\n        ...node,\n        item: { ...node.item, active: !node.item.active },\n      }));\n      return { ...state, root: next, dirty: true };\n    }\n\n    case \"set-locked\": {\n      const next = withNode(state.root, action.itemId, (node) => ({\n        ...node,\n        item: { ...node.item, locked: action.locked },\n      }));\n      // Close any edit on this item if locking.\n      const newEdit: EditState =\n        action.locked && state.edit.kind !== \"view\" && state.edit.itemId === action.itemId\n          ? { kind: \"view\" }\n          : state.edit;\n      return { ...state, root: next, edit: newEdit, dirty: true };\n    }\n\n    case \"set-focus\": {\n      return { ...state, focusedId: action.itemId };\n    }\n\n    case \"toggle-collapse\": {\n      const next = new Set(state.collapsedIds);\n      if (next.has(action.itemId)) {\n        next.delete(action.itemId);\n      } else {\n        next.add(action.itemId);\n      }\n      return { ...state, collapsedIds: next };\n    }\n\n    case \"mark-clean\": {\n      return { ...state, dirty: false };\n    }\n\n    default: {\n      return state;\n    }\n  }\n}\n\n/** Read helper — exported so hooks can locate nodes without rummaging. */\nexport function lookup(state: State, id: string): TaskNode | null {\n  return findNode(state.root, id);\n}\n",
      "type": "registry:component",
      "target": "components/task-card/lib/reducer.ts"
    },
    {
      "path": "src/registry/components/data/task-card/hooks/use-card-context.ts",
      "content": "\"use client\";\n\n/**\n * React context for tree-wide config consumed by parts/*.\n * Provider is constructed in task-card.tsx; this file owns the symbol +\n * the typed consumer hook.\n */\n\nimport { createContext, useContext } from \"react\";\nimport type { TaskCardContextValue } from \"../types\";\n\nexport const TaskCardContext = createContext<TaskCardContextValue | null>(null);\n\nexport function useCardContext(): TaskCardContextValue {\n  const ctx = useContext(TaskCardContext);\n  if (!ctx) {\n    throw new Error(\n      \"useCardContext must be used inside <TaskCard>; missing provider.\",\n    );\n  }\n  return ctx;\n}\n",
      "type": "registry:component",
      "target": "components/task-card/hooks/use-card-context.ts"
    },
    {
      "path": "src/registry/components/data/task-card/hooks/use-color-engine.ts",
      "content": "\"use client\";\n\n/**\n * Single setInterval at the root that bumps `tick`, forcing the card subtree\n * to re-render and recompute `elapsed` from `now()`.\n *\n * NOT per-card — would be N intervals for N nodes. One interval, tree-wide.\n *\n * `intervalMs = 0` disables the timer (render-time computation still works at\n * mount, just doesn't refresh). Useful for consumer-driven external clocks.\n */\n\nimport { useEffect, useState } from \"react\";\n\nexport function useColorEngine(intervalMs: number): number {\n  const [tick, setTick] = useState(0);\n\n  useEffect(() => {\n    if (intervalMs <= 0) return;\n    const id = setInterval(() => setTick((t) => t + 1), intervalMs);\n    return () => clearInterval(id);\n  }, [intervalMs]);\n\n  return tick;\n}\n",
      "type": "registry:component",
      "target": "components/task-card/hooks/use-color-engine.ts"
    },
    {
      "path": "src/registry/components/data/task-card/hooks/use-keyboard.ts",
      "content": "\"use client\";\n\n/**\n * Root-level keyboard bindings:\n *   - Cmd/Ctrl + C  → copy focused card\n *   - Cmd/Ctrl + V  → paste from clipboard as child of focused card\n *   - Enter / Space → open default edit affordance on focused card\n *   - Escape        → close edit\n *\n * Attached to the root section via onKeyDown.\n */\n\nimport { useCallback } from \"react\";\nimport { copyTasksToClipboard, readTasksFromClipboard } from \"../lib/clipboard\";\nimport { denormalize, findNode } from \"../lib/normalize\";\nimport type {\n  Action,\n  EditState,\n  ResolvedPermissions,\n  TaskItem,\n  TaskNode,\n  TaskPermissionReason,\n  TaskPermissionRule,\n} from \"../types\";\n\ntype Args = {\n  rootNode: TaskNode;\n  focusedId: string | null;\n  editState: EditState;\n  editable: boolean;\n  dispatch: (action: Action) => void;\n  resolvePermissions: (node: TaskNode) => ResolvedPermissions;\n  fireCopy: (payload: { itemId: string; payload: TaskItem }) => void;\n  firePaste: (payload: { parentId: string; payload: TaskItem }) => void;\n  fireItemAdded: (payload: { parentId: string; item: TaskItem }) => void;\n  requestEdit: (itemId: string, mode: \"popup\" | \"inline\") => void;\n  reportPermissionDenied: (\n    action: keyof TaskPermissionRule,\n    itemId: string,\n    reason: TaskPermissionReason,\n  ) => void;\n};\n\nexport function useKeyboard(args: Args) {\n  const handler = useCallback(\n    async (e: React.KeyboardEvent) => {\n      const targetTag = (e.target as HTMLElement)?.tagName;\n      const editingInForm =\n        targetTag === \"INPUT\" ||\n        targetTag === \"TEXTAREA\" ||\n        (e.target as HTMLElement)?.isContentEditable;\n\n      // Escape always works to close edit, even from within a form field.\n      if (e.key === \"Escape\" && args.editState.kind !== \"view\") {\n        args.dispatch({ type: \"close-edit\" });\n        e.stopPropagation();\n        return;\n      }\n\n      // Don't intercept other shortcuts when typing in a form.\n      if (editingInForm) return;\n\n      const focusedNode = args.focusedId\n        ? findNode(args.rootNode, args.focusedId)\n        : null;\n      if (!focusedNode) return;\n\n      const mod = e.ctrlKey || e.metaKey;\n\n      // Cmd/Ctrl + C\n      if (mod && (e.key === \"c\" || e.key === \"C\")) {\n        try {\n          // Denormalize so the copied subtree's `children` is current.\n          const item = denormalize(focusedNode);\n          await copyTasksToClipboard([item], \"task-card\");\n          args.fireCopy({ itemId: item.id, payload: item });\n        } catch {\n          // browser blocked or unavailable — swallow\n        }\n        return;\n      }\n\n      // Cmd/Ctrl + V\n      if (mod && (e.key === \"v\" || e.key === \"V\")) {\n        const perms = args.resolvePermissions(focusedNode);\n        if (!perms.addChildren) {\n          args.reportPermissionDenied(\"addChildren\", focusedNode.item.id, perms.reason);\n          return;\n        }\n        const items = await readTasksFromClipboard();\n        if (!items) return;\n        for (const payload of items) {\n          args.dispatch({\n            type: \"add-child\",\n            parentId: focusedNode.item.id,\n            item: payload,\n          });\n          args.firePaste({ parentId: focusedNode.item.id, payload });\n          args.fireItemAdded({ parentId: focusedNode.item.id, item: payload });\n        }\n        return;\n      }\n\n      // Enter / Space → open edit\n      if (e.key === \"Enter\" || e.key === \" \") {\n        const perms = args.resolvePermissions(focusedNode);\n        if (!perms.edit) {\n          args.reportPermissionDenied(\"edit\", focusedNode.item.id, perms.reason);\n          return;\n        }\n        e.preventDefault();\n        const mode: \"popup\" | \"inline\" = args.editable ? \"inline\" : \"popup\";\n        args.requestEdit(focusedNode.item.id, mode);\n      }\n    },\n    [args],\n  );\n\n  return handler;\n}\n",
      "type": "registry:component",
      "target": "components/task-card/hooks/use-keyboard.ts"
    },
    {
      "path": "src/registry/components/data/task-card/parts/card.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type CSSProperties } from \"react\"; // useEffect for focus + state hooks\nimport { Palette, Sparkles } from \"lucide-react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\nimport { useCardContext } from \"../hooks/use-card-context\";\nimport { computeElapsed, resolveBorderColor } from \"../lib/color-engine\";\nimport { fromDataTransfer, serialize } from \"../lib/json-io\";\nimport { TASK_CLIPBOARD_MIME, type TaskNode } from \"../types\";\nimport { CardBody } from \"./card-body\";\nimport { CardHeader } from \"./card-header\";\nimport { EditInline } from \"./edit-inline\";\nimport { EditPopup } from \"./edit-popup\";\nimport { StatusBadge } from \"./status-badge\";\n\nconst PALETTE_SWATCHES = [\n  \"oklch(0.78 0.18 142)\", // green\n  \"oklch(0.78 0.18 90)\",  // yellow-green\n  \"oklch(0.78 0.18 50)\",  // amber\n  \"oklch(0.70 0.20 25)\",  // red\n  \"oklch(0.78 0.18 250)\", // blue\n  \"oklch(0.78 0.18 290)\", // violet\n  \"oklch(0.78 0.18 310)\", // pink\n  \"oklch(0.45 0.02 250)\", // graphite\n];\n\nexport function Card({ node }: { node: TaskNode }) {\n  const ctx = useCardContext();\n  const perms = ctx.resolvePermissions(node);\n  const isInlineActive =\n    ctx.editState.kind === \"inline\" && ctx.editState.itemId === node.item.id;\n\n  // Subscribe to context tick (referenced for memo dep — drives re-render on interval).\n  void ctx.tick;\n  // Recompute per render — cheap. Border color is rendered immediately; if\n  // SSR + non-frozen `now` produces a hydration warning, the consumer can\n  // either pass a frozen `now` (recommended) or accept the warning. The\n  // article element below sets `suppressHydrationWarning` to mute it\n  // gracefully (plan §5.5 / §11 trade-off — avoids the cascading-render lint\n  // hit from a mount-state pattern).\n  const elapsed = computeElapsed(node.item, ctx.now());\n  const computedBorder = resolveBorderColor(node.item, elapsed, ctx.ramp);\n\n  // Status tone (v0.3) — decouples the *status* signal from the time-driven\n  // *urgency* border. Terminal statuses (done/blocked) drop the time color for a\n  // neutral gray border, auto-collapse the card, and show a dimmed overlay.\n  const matchedStatus = (ctx.statusOptions ?? []).find(\n    (o) => o.value === node.item.status,\n  );\n  const tone = matchedStatus?.tone ?? \"active\";\n  const isTerminal = tone === \"done\" || tone === \"blocked\";\n  const statusIcon = matchedStatus?.icon ?? (tone === \"done\" ? \"✅\" : \"🚫\");\n\n  // Time color drives the border ONLY for active statuses; terminal → gray\n  // (the default `border-border` class on the article wins when style is empty).\n  const style: CSSProperties =\n    !isTerminal && computedBorder\n      ? { borderColor: computedBorder, transition: \"border-color 0.4s ease\" }\n      : {};\n\n  // Terminal cards stay compact under the overlay regardless of manual collapse.\n  const collapsed = isTerminal || ctx.isCollapsed(node.item.id);\n\n  /* ───────── focus management ───────── */\n\n  const articleRef = useRef<HTMLElement | null>(null);\n  useEffect(() => {\n    if (ctx.focusedId === node.item.id) {\n      articleRef.current?.focus();\n    }\n  }, [ctx.focusedId, node.item.id]);\n\n  /* ───────── DnD ───────── */\n\n  const [isDragging, setIsDragging] = useState(false);\n\n  function handleDragStart(e: React.DragEvent) {\n    if (!perms.drag) {\n      e.preventDefault();\n      ctx.reportPermissionDenied(\"drag\", node.item.id, perms.reason);\n      return;\n    }\n    const payload = serialize(node.item);\n    e.dataTransfer.setData(TASK_CLIPBOARD_MIME, payload);\n    e.dataTransfer.setData(\"text/plain\", payload);\n    e.dataTransfer.effectAllowed = \"copy\";\n    setIsDragging(true);\n    ctx.fireEvent(\"copy\", { itemId: node.item.id, payload: node.item });\n  }\n\n  function handleDragEnd() {\n    setIsDragging(false);\n  }\n\n  /* ───────── color override dialog ───────── */\n\n  const [colorPickerOpen, setColorPickerOpen] = useState(false);\n  const [colorInput, setColorInput] = useState(node.item.borderColor ?? \"\");\n\n  function applyColor(next: string | null) {\n    if (!perms.overrideColor) {\n      ctx.reportPermissionDenied(\"overrideColor\", node.item.id, perms.reason);\n      return;\n    }\n    const oldColor = node.item.borderColor;\n    ctx.dispatch({ type: \"set-border-color\", itemId: node.item.id, color: next });\n    ctx.fireEvent(\"colorOverridden\", {\n      itemId: node.item.id,\n      oldColor,\n      newColor: next ?? undefined,\n    });\n    setColorPickerOpen(false);\n  }\n\n  /* ───────── children DnD drop ───────── */\n\n  const [isDropTarget, setIsDropTarget] = useState(false);\n  function handleDragOver(e: React.DragEvent) {\n    if (!perms.addChildren) return;\n    if (\n      e.dataTransfer.types.includes(TASK_CLIPBOARD_MIME) ||\n      e.dataTransfer.types.includes(\"text/plain\")\n    ) {\n      e.preventDefault();\n      e.dataTransfer.dropEffect = \"copy\";\n      setIsDropTarget(true);\n    }\n  }\n  function handleDragLeave() {\n    setIsDropTarget(false);\n  }\n  function handleDrop(e: React.DragEvent) {\n    setIsDropTarget(false);\n    if (!perms.addChildren) {\n      ctx.reportPermissionDenied(\"addChildren\", node.item.id, perms.reason);\n      return;\n    }\n    e.preventDefault();\n    const payload = fromDataTransfer(e.dataTransfer);\n    if (!payload) return;\n    ctx.dispatch({ type: \"add-child\", parentId: node.item.id, item: payload });\n    ctx.fireEvent(\"paste\", { parentId: node.item.id, payload });\n    ctx.fireEvent(\"itemAdded\", { parentId: node.item.id, item: payload });\n  }\n\n  return (\n    <article\n      ref={articleRef}\n      role=\"article\"\n      tabIndex={0}\n      aria-label={node.item.name}\n      data-locked={node.item.locked ? \"true\" : undefined}\n      data-inactive={!node.item.active ? \"true\" : undefined}\n      draggable={perms.drag}\n      onDragStart={handleDragStart}\n      onDragEnd={handleDragEnd}\n      onFocus={() => ctx.dispatch({ type: \"set-focus\", itemId: node.item.id })}\n      style={style}\n      className={cn(\n        \"relative rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-sm\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n        isTerminal && \"min-h-28\",\n        node.item.locked && \"opacity-70\",\n        !node.item.active && \"opacity-50 saturate-50\",\n        isDragging && \"opacity-40\",\n      )}\n      data-todo-card-id={node.item.id}\n      suppressHydrationWarning\n    >\n      <CardHeader node={node} onOverrideColor={() => setColorPickerOpen(true)} />\n      {!collapsed ? (\n        <>\n          <CardBody item={node.item} />\n          {isInlineActive ? (\n            <div className=\"mt-3\">\n              <EditInline node={node} />\n            </div>\n          ) : null}\n        </>\n      ) : null}\n\n      {/* Terminal-status overlay (done/blocked). The same StatusBadge dropdown\n          lets an editable consumer change the status, which clears the overlay. */}\n      {isTerminal ? (\n        <div\n          className={cn(\n            \"absolute inset-0 z-20 flex flex-col items-center justify-center gap-1.5 rounded-2xl px-3 text-center backdrop-blur-sm\",\n            tone === \"done\"\n              ? \"bg-emerald-500/15 dark:bg-emerald-950/45\"\n              : \"bg-rose-500/15 dark:bg-rose-950/70\",\n          )}\n        >\n          <p className=\"line-clamp-2 text-sm font-medium text-foreground\" aria-hidden>\n            {node.item.name}\n          </p>\n          <span className=\"text-2xl leading-none\" aria-hidden>\n            {statusIcon}\n          </span>\n          <StatusBadge node={node} />\n        </div>\n      ) : null}\n\n      <EditPopup node={node} />\n\n      {/* Color override dialog — opened programmatically from action menu / header.\n          Using Dialog instead of Popover avoids the outside-click race with the\n          DropdownMenu's lingering click event after the menu dismisses. */}\n      <Dialog open={colorPickerOpen} onOpenChange={setColorPickerOpen}>\n        <DialogContent className=\"max-w-sm\">\n          <DialogHeader>\n            <DialogTitle className=\"flex items-center gap-2\">\n              <Palette className=\"size-4\" />\n              Border color\n            </DialogTitle>\n            <DialogDescription>\n              Override the time-engine color for this card. Pick a swatch, paste a\n              CSS color, or reset to let the engine decide.\n            </DialogDescription>\n          </DialogHeader>\n          <div className=\"space-y-3\">\n            <div className=\"grid grid-cols-8 gap-1.5\">\n              {PALETTE_SWATCHES.map((swatch) => (\n                <button\n                  key={swatch}\n                  type=\"button\"\n                  onClick={() => applyColor(swatch)}\n                  className=\"aspect-square rounded border border-border transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                  style={{ backgroundColor: swatch }}\n                  aria-label={`Use color ${swatch}`}\n                />\n              ))}\n            </div>\n            <div className=\"flex items-center gap-2\">\n              <Input\n                placeholder=\"oklch(...) or #hex\"\n                value={colorInput}\n                onChange={(e) => setColorInput(e.target.value)}\n                className=\"font-mono text-xs\"\n              />\n              <Button size=\"sm\" onClick={() => applyColor(colorInput || null)}>\n                Apply\n              </Button>\n            </div>\n          </div>\n          <DialogFooter className=\"sm:justify-between\">\n            <Button\n              size=\"sm\"\n              variant=\"secondary\"\n              onClick={() => applyColor(null)}\n              className=\"gap-1.5\"\n              title=\"Hand control back to the time-driven auto-color engine\"\n            >\n              <Sparkles className=\"size-3.5\" />\n              Auto\n            </Button>\n            <Button\n              size=\"sm\"\n              variant=\"outline\"\n              onClick={() => setColorPickerOpen(false)}\n            >\n              Close\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      </Dialog>\n\n      {node.childNodes.length > 0 && !collapsed ? (\n        <div\n          role=\"group\"\n          aria-label={`Children of ${node.item.name}`}\n          onDragOver={handleDragOver}\n          onDragLeave={handleDragLeave}\n          onDrop={handleDrop}\n          className={cn(\n            \"mt-3 space-y-2 border-s border-border/40 ps-4\",\n            isDropTarget && \"bg-muted/30 rounded-r-md\",\n          )}\n        >\n          {node.childNodes.map((child) => (\n            <Card key={child.item.id} node={child} />\n          ))}\n        </div>\n      ) : !collapsed ? (\n        <div\n          role=\"group\"\n          aria-label={`Drop zone for ${node.item.name}`}\n          onDragOver={handleDragOver}\n          onDragLeave={handleDragLeave}\n          onDrop={handleDrop}\n          className={cn(\n            \"mt-2 h-0\",\n            isDropTarget && \"mt-2 h-12 rounded-md border-2 border-dashed border-border bg-muted/30 transition-all\",\n          )}\n        />\n      ) : null}\n    </article>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/card.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/card-header.tsx",
      "content": "\"use client\";\n\nimport { ChevronDown, ChevronRight, PanelTop, Pencil } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { cn } from \"@/lib/utils\";\nimport { useCardContext } from \"../hooks/use-card-context\";\nimport type { TaskNode } from \"../types\";\nimport { ActionMenu } from \"./action-menu\";\nimport { CardMeta } from \"./card-meta\";\nimport { StatusBadge } from \"./status-badge\";\n\nexport function CardHeader({\n  node,\n  onOverrideColor,\n}: {\n  node: TaskNode;\n  onOverrideColor: () => void;\n}) {\n  const ctx = useCardContext();\n  const perms = ctx.resolvePermissions(node);\n  const id = node.item.id;\n  const isInlineActive = ctx.editState.kind === \"inline\" && ctx.editState.itemId === id;\n\n  function tryOpenEdit(mode: \"popup\" | \"inline\") {\n    if (!perms.edit) {\n      ctx.reportPermissionDenied(\"edit\", id, perms.reason);\n      return;\n    }\n    // requestEdit consults the veto BEFORE opening (no flash-open-then-close).\n    ctx.requestEdit(id, mode);\n  }\n\n  function handlePrimaryEdit() {\n    if (ctx.editable) {\n      // Toggle inline mode.\n      if (isInlineActive) {\n        ctx.dispatch({ type: \"close-edit\" });\n      } else {\n        tryOpenEdit(\"inline\");\n      }\n    } else {\n      tryOpenEdit(\"popup\");\n    }\n  }\n\n  function handleToggleActive(next: boolean) {\n    if (!perms.toggleActive) {\n      ctx.reportPermissionDenied(\"toggleActive\", id, perms.reason);\n      return;\n    }\n    if (next === node.item.active) return;\n    const oldActive = node.item.active;\n    ctx.dispatch({ type: \"toggle-active\", itemId: id });\n    ctx.fireEvent(\"activeToggled\", { itemId: id, oldActive, newActive: next });\n    ctx.fireEvent(\"fieldEdited\", {\n      itemId: id,\n      key: \"active\",\n      oldValue: oldActive,\n      newValue: next,\n    });\n  }\n\n  const collapsed = ctx.isCollapsed(node.item.id);\n\n  function toggleCollapse() {\n    ctx.dispatch({ type: \"toggle-collapse\", itemId: node.item.id });\n  }\n\n  return (\n    <header className=\"flex flex-col gap-2\">\n      {/* row 1 — controls (status + toggles); kept on its own line so the title\n          never has to share horizontal space and truncate to nothing. */}\n      <div className=\"flex items-center justify-between gap-2\">\n        <div className=\"flex min-w-0 items-center gap-1.5\">\n          <Button\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"-ms-1 size-6 shrink-0 text-muted-foreground hover:text-foreground\"\n            aria-label={collapsed ? \"Expand card\" : \"Collapse card\"}\n            aria-expanded={!collapsed}\n            onClick={toggleCollapse}\n          >\n            {collapsed ? (\n              <ChevronRight className=\"size-3.5\" />\n            ) : (\n              <ChevronDown className=\"size-3.5\" />\n            )}\n          </Button>\n          <StatusBadge node={node} className=\"shrink-0\" />\n        </div>\n        <div className=\"flex shrink-0 items-center gap-0.5\">\n          <Switch\n            size=\"sm\"\n            checked={node.item.active}\n            onCheckedChange={handleToggleActive}\n            aria-label=\"Active\"\n            disabled={!perms.toggleActive}\n          />\n          {ctx.showEditButton ? (\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className={cn(\n                \"size-6\",\n                isInlineActive && \"bg-muted text-foreground\",\n              )}\n              aria-label={ctx.editable ? \"Toggle inline edit\" : \"Edit\"}\n              aria-haspopup={ctx.editable ? undefined : \"dialog\"}\n              aria-pressed={ctx.editable ? isInlineActive : undefined}\n              disabled={!perms.edit}\n              onClick={handlePrimaryEdit}\n            >\n              <Pencil className=\"size-3.5\" />\n            </Button>\n          ) : null}\n          {ctx.showEditButton && ctx.editable ? (\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              className=\"size-6\"\n              aria-label=\"Edit in dialog\"\n              disabled={!perms.edit}\n              onClick={() => tryOpenEdit(\"popup\")}\n            >\n              <PanelTop className=\"size-3.5\" />\n            </Button>\n          ) : null}\n          <ActionMenu node={node} onOverrideColor={onOverrideColor} />\n        </div>\n      </div>\n      {/* row 2 — title, always readable (wraps up to 3 lines, never truncates to 0). */}\n      <h3 className=\"line-clamp-3 text-sm font-medium leading-snug text-foreground\">\n        {node.item.name}\n      </h3>\n      {/* row 3 — priority + labels (renders nothing when the item has neither). */}\n      <CardMeta item={node.item} />\n    </header>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/card-header.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/card-body.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { TaskItem } from \"../types\";\nimport { ImageStrip } from \"./image-strip\";\nimport { LinkChip } from \"./link-chip\";\nimport { PersonChip } from \"./person-chip\";\nimport { TimeInfo } from \"./time-info\";\n\nexport function CardBody({ item }: { item: TaskItem }) {\n  const hasPeople = item.targetPerson || item.creatorPerson;\n  const hasLinks = item.links && item.links.length > 0;\n  const hasImages = item.images && item.images.length > 0;\n\n  return (\n    <div className=\"mt-3 space-y-2 text-sm\">\n      {item.description ? (\n        <p className=\"text-muted-foreground whitespace-pre-wrap\">{item.description}</p>\n      ) : null}\n      <TimeInfo item={item} />\n      {hasPeople ? (\n        <div className=\"flex flex-wrap items-center gap-2\">\n          {item.targetPerson ? (\n            <PersonChip person={item.targetPerson} variant=\"target\" />\n          ) : null}\n          {item.creatorPerson ? (\n            <PersonChip person={item.creatorPerson} variant=\"creator\" />\n          ) : null}\n        </div>\n      ) : null}\n      {hasLinks ? (\n        <div className={cn(\"flex flex-wrap gap-1.5\")}>\n          {item.links!.map((link, i) => (\n            <LinkChip key={i} link={link} />\n          ))}\n        </div>\n      ) : null}\n      {hasImages ? <ImageStrip images={item.images!} /> : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/card-body.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/card-meta.tsx",
      "content": "\"use client\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { useCardContext } from \"../hooks/use-card-context\";\nimport type { TaskItem } from \"../types\";\n\n/**\n * Display-only meta row — priority badge + label chips (v0.3). Reads the item's\n * `priority` / `labels` and their display metadata from context\n * (`priorityOptions` / `labelOptions`). Renders nothing when the item has\n * neither, so cards without these fields are visually unchanged.\n *\n * Colors are applied to border + text only (never a filled background) via\n * inline `style`, so any CSS color string works and an option without a `color`\n * falls back to the themeable `outline` Badge variant. Painting only the\n * dynamic color inline (not the surface) sidesteps the B6 trap where an inline\n * background would beat consumer / theme classes.\n */\nexport function CardMeta({ item }: { item: TaskItem }) {\n  const ctx = useCardContext();\n  const priority = item.priority;\n  const labels = item.labels ?? [];\n  if (!priority && labels.length === 0) return null;\n\n  const priorityOpt = priority\n    ? ctx.priorityOptions?.find((o) => o.value === priority)\n    : undefined;\n\n  return (\n    <div className=\"mt-2 flex flex-wrap items-center gap-1.5\">\n      {priority ? (\n        <Badge\n          variant=\"outline\"\n          className=\"font-medium\"\n          style={\n            priorityOpt?.color\n              ? { borderColor: priorityOpt.color, color: priorityOpt.color }\n              : undefined\n          }\n          role=\"status\"\n        >\n          {priorityOpt?.label ?? priority}\n        </Badge>\n      ) : null}\n      {labels.map((key) => {\n        const opt = ctx.labelOptions?.find((o) => o.value === key);\n        return (\n          <Badge\n            key={key}\n            variant=\"outline\"\n            className=\"font-normal\"\n            style={opt?.color ? { borderColor: opt.color, color: opt.color } : undefined}\n          >\n            {opt?.label ?? key}\n          </Badge>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/card-meta.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/status-badge.tsx",
      "content": "\"use client\";\n\nimport { Check, ChevronDown } from \"lucide-react\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\nimport { useCardContext } from \"../hooks/use-card-context\";\nimport type { TaskNode } from \"../types\";\n\n/**\n * Status indicator. Read-only `<Badge>` by default; becomes a status-change\n * dropdown when the card is editable, the item's `edit` permission is granted,\n * `statusEditable` is on, and `statusOptions` is non-empty. The dropdown reuses\n * the exact `edit-field` dispatch + events as the inline editor, so a status\n * change here is identical to one made there.\n */\nexport function StatusBadge({\n  node,\n  className,\n}: {\n  node: TaskNode;\n  className?: string;\n}) {\n  const ctx = useCardContext();\n  const status = node.item.status;\n  if (!status) return null;\n\n  const options = ctx.statusOptions ?? [];\n  const match = options.find((o) => o.value === status);\n  const label = match?.label ?? status;\n  const variant = match?.variant ?? \"secondary\";\n\n  // Terminal statuses get a fixed green/red tag regardless of `variant`, so the\n  // status reads at a glance (pairs with the card.tsx done/blocked overlay).\n  const toneClass =\n    match?.tone === \"done\"\n      ? \"border-transparent bg-emerald-600 text-white hover:bg-emerald-600/90\"\n      : match?.tone === \"blocked\"\n        ? \"border-transparent bg-rose-600 text-white hover:bg-rose-600/90\"\n        : \"\";\n\n  const perms = ctx.resolvePermissions(node);\n  const canSelect =\n    ctx.statusEditable && ctx.editable && perms.edit && options.length > 0;\n\n  if (!canSelect) {\n    return (\n      <Badge\n        variant={variant}\n        className={cn(\"font-medium\", toneClass, className)}\n        role=\"status\"\n      >\n        {label}\n      </Badge>\n    );\n  }\n\n  function selectStatus(next: string) {\n    if (next === status) return;\n    const id = node.item.id;\n    ctx.dispatch({ type: \"edit-field\", itemId: id, key: \"status\", value: next });\n    ctx.fireEvent(\"fieldEdited\", {\n      itemId: id,\n      key: \"status\",\n      oldValue: status,\n      newValue: next,\n    });\n    ctx.fireEvent(\"statusChanged\", { itemId: id, oldStatus: status, newStatus: next });\n  }\n\n  return (\n    <DropdownMenu>\n      {/* F-cross-13 path-b: trigger IS the button (native <button> in both\n          backends) — `asChild` is Radix-only. stop-propagation keeps a click on\n          the badge from starting an HTML5/kanban drag or bubbling to the card;\n          data-stop-click is honored by the kanban ItemShell's click guard.\n          (v0.4.2) */}\n      <DropdownMenuTrigger\n        type=\"button\"\n        data-stop-click\n        onPointerDown={(e) => e.stopPropagation()}\n        aria-label={`Status: ${label}. Click to change`}\n        className=\"rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      >\n        <Badge\n          variant={variant}\n          className={cn(\"cursor-pointer gap-1 pe-1.5 font-medium\", toneClass, className)}\n          role=\"status\"\n        >\n          {label}\n          <ChevronDown className=\"size-3 opacity-70\" />\n        </Badge>\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"start\" className=\"min-w-36\">\n        {options.map((opt) => (\n          <DropdownMenuItem\n            key={opt.value}\n            onSelect={() => selectStatus(opt.value)}\n            className=\"gap-2\"\n          >\n            <Check\n              className={cn(\"size-3.5\", opt.value === status ? \"opacity-100\" : \"opacity-0\")}\n            />\n            {opt.label}\n          </DropdownMenuItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/status-badge.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/person-chip.tsx",
      "content": "\"use client\";\n\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { cn } from \"@/lib/utils\";\nimport type { TaskPerson } from \"../types\";\n\nfunction initials(name: string): string {\n  // filter(Boolean) drops empty segments so a whitespace-only name yields []\n  // (→ \"?\") and every remaining part is a non-empty string — safe to index.\n  const parts = name.trim().split(/\\s+/).filter(Boolean);\n  if (parts.length === 0) return \"?\";\n  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();\n  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();\n}\n\nexport function PersonChip({\n  person,\n  variant = \"target\",\n  className,\n}: {\n  person: TaskPerson;\n  variant?: \"target\" | \"creator\";\n  className?: string;\n}) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 text-xs\",\n        className,\n      )}\n    >\n      <Avatar className=\"size-5\">\n        {person.avatar ? <AvatarImage src={person.avatar} alt={person.name} /> : null}\n        <AvatarFallback className=\"text-[10px]\">{initials(person.name)}</AvatarFallback>\n      </Avatar>\n      <span className=\"font-medium\">{person.name}</span>\n      {variant === \"creator\" ? (\n        <span className=\"text-muted-foreground\">(creator)</span>\n      ) : null}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/person-chip.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/link-chip.tsx",
      "content": "\"use client\";\n\nimport { ExternalLink, Link as LinkIcon } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { TaskLink } from \"../types\";\n\nfunction safeHostname(url: string): string {\n  try {\n    return new URL(url).hostname;\n  } catch {\n    return url;\n  }\n}\n\nexport function LinkChip({\n  link,\n  className,\n}: {\n  link: TaskLink;\n  className?: string;\n}) {\n  const label = link.label ?? safeHostname(link.url);\n  let validUrl = true;\n  try {\n    new URL(link.url);\n  } catch {\n    validUrl = false;\n  }\n\n  if (!validUrl) {\n    return (\n      <span\n        className={cn(\n          \"inline-flex items-center gap-1 rounded border border-dashed border-destructive/40 px-1.5 py-0.5 text-xs text-muted-foreground\",\n          className,\n        )}\n        title=\"Invalid URL\"\n      >\n        <LinkIcon className=\"size-3\" />\n        {label}\n      </span>\n    );\n  }\n\n  return (\n    <a\n      href={link.url}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className={cn(\n        \"inline-flex items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-xs hover:bg-muted\",\n        className,\n      )}\n    >\n      {link.icon ? (\n        <img src={link.icon} alt=\"\" className=\"size-3\" />\n      ) : (\n        <LinkIcon className=\"size-3\" />\n      )}\n      <span className=\"font-medium\">{label}</span>\n      <ExternalLink className=\"size-3 text-muted-foreground\" />\n    </a>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/link-chip.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/image-strip.tsx",
      "content": "\"use client\";\n\nimport { ScrollArea, ScrollBar } from \"@/components/ui/scroll-area\";\nimport { cn } from \"@/lib/utils\";\nimport type { TaskImage } from \"../types\";\n\nexport function ImageStrip({\n  images,\n  className,\n}: {\n  images: TaskImage[];\n  className?: string;\n}) {\n  if (images.length === 0) return null;\n\n  return (\n    <ScrollArea className={cn(\"w-full whitespace-nowrap\", className)}>\n      <div className=\"flex gap-2 pb-2\">\n        {images.map((img, i) => (\n          <a\n            key={i}\n            href={img.src}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"block shrink-0\"\n            title={img.caption}\n          >\n            <figure className=\"flex w-32 flex-col gap-1\">\n              <img\n                src={img.src}\n                alt={img.alt ?? \"\"}\n                className=\"h-20 w-32 rounded-md border border-border object-cover\"\n              />\n              {img.caption ? (\n                <figcaption className=\"truncate text-xs text-muted-foreground\">\n                  {img.caption}\n                </figcaption>\n              ) : null}\n            </figure>\n          </a>\n        ))}\n      </div>\n      <ScrollBar orientation=\"horizontal\" />\n    </ScrollArea>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/image-strip.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/time-info.tsx",
      "content": "\"use client\";\n\nimport { Info } from \"lucide-react\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { useCardContext } from \"../hooks/use-card-context\";\nimport type { TaskItem } from \"../types\";\nimport { formatAbsolute, formatDuration, formatRelative, parseIso } from \"../lib/time\";\n\nexport function TimeInfo({ item }: { item: TaskItem }) {\n  const { now: nowFactory } = useCardContext();\n  const now = nowFactory();\n\n  const setAt = parseIso(item.setAt);\n  const startAt = parseIso(item.startAt ?? item.setAt);\n  const expireAt = parseIso(item.expireAt);\n\n  if (!setAt) return null;\n\n  const primary = (() => {\n    if (expireAt) return `Due ${formatRelative(expireAt, now)}`;\n    if (item.duration && item.duration > 0)\n      return `Duration: ${formatDuration(item.duration)}`;\n    return null;\n  })();\n\n  return (\n    <TooltipProvider>\n      <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n        {primary ? <span className=\"font-medium\">{primary}</span> : null}\n        <Tooltip>\n          {/* F-cross-13 path-b: trigger IS the button (native <button> in both\n              backends) — `asChild` is Radix-only. (v0.4.2) */}\n          <TooltipTrigger\n            type=\"button\"\n            className=\"inline-flex items-center text-muted-foreground/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded\"\n            aria-label=\"Show timing details\"\n          >\n            <Info className=\"size-3\" />\n          </TooltipTrigger>\n          <TooltipContent side=\"top\" className=\"max-w-xs\">\n            <dl className=\"grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs\">\n              <dt className=\"text-muted-foreground\">Created</dt>\n              <dd>{formatAbsolute(setAt)}</dd>\n              <dt className=\"text-muted-foreground\">Starts</dt>\n              <dd>{startAt ? formatAbsolute(startAt) : formatAbsolute(setAt)}</dd>\n              <dt className=\"text-muted-foreground\">Expires</dt>\n              <dd>{expireAt ? formatAbsolute(expireAt) : \"—\"}</dd>\n              {item.duration && item.duration > 0 ? (\n                <>\n                  <dt className=\"text-muted-foreground\">Duration</dt>\n                  <dd>{formatDuration(item.duration)}</dd>\n                </>\n              ) : null}\n            </dl>\n          </TooltipContent>\n        </Tooltip>\n      </div>\n    </TooltipProvider>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/time-info.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/action-menu.tsx",
      "content": "\"use client\";\n\nimport {\n  ClipboardPaste,\n  Copy,\n  Edit3,\n  Lock,\n  MoreHorizontal,\n  Palette,\n  PanelTop,\n  PowerOff,\n  Trash2,\n  Unlock,\n  Zap,\n} from \"lucide-react\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\nimport { useCardContext } from \"../hooks/use-card-context\";\nimport { copyTasksToClipboard, readTasksFromClipboard } from \"../lib/clipboard\";\nimport { denormalize } from \"../lib/normalize\";\nimport type { TaskNode } from \"../types\";\n\nexport function ActionMenu({\n  node,\n  onOverrideColor,\n}: {\n  node: TaskNode;\n  onOverrideColor: () => void;\n}) {\n  const ctx = useCardContext();\n  const perms = ctx.resolvePermissions(node);\n  const id = node.item.id;\n  const isInlineActive = ctx.editState.kind === \"inline\" && ctx.editState.itemId === id;\n\n  function tryOpenEdit(mode: \"popup\" | \"inline\") {\n    if (!perms.edit) {\n      ctx.reportPermissionDenied(\"edit\", id, perms.reason);\n      return;\n    }\n    ctx.dispatch({ type: \"open-edit\", itemId: id, mode });\n    ctx.fireEvent(\"editRequest\", { itemId: id, mode });\n  }\n\n  async function handleCopy() {\n    // Denormalize so the copied subtree's `children` is current; the shared\n    // clipboard writes the typed MIME and falls back to text/plain.\n    try {\n      const item = denormalize(node);\n      await copyTasksToClipboard([item], \"task-card\");\n      ctx.fireEvent(\"copy\", { itemId: id, payload: item });\n    } catch (err) {\n      console.warn(\"[task-card] copy failed\", err);\n    }\n  }\n\n  async function handlePaste() {\n    if (!perms.addChildren) {\n      ctx.reportPermissionDenied(\"addChildren\", id, perms.reason);\n      return;\n    }\n    const items = await readTasksFromClipboard();\n    if (!items) return;\n    for (const payload of items) {\n      ctx.dispatch({ type: \"add-child\", parentId: id, item: payload });\n      ctx.fireEvent(\"paste\", { parentId: id, payload });\n      ctx.fireEvent(\"itemAdded\", { parentId: id, item: payload });\n    }\n  }\n\n  function handleToggleActive() {\n    if (!perms.toggleActive) {\n      ctx.reportPermissionDenied(\"toggleActive\", id, perms.reason);\n      return;\n    }\n    const oldActive = node.item.active;\n    ctx.dispatch({ type: \"toggle-active\", itemId: id });\n    ctx.fireEvent(\"activeToggled\", {\n      itemId: id,\n      oldActive,\n      newActive: !oldActive,\n    });\n    ctx.fireEvent(\"fieldEdited\", {\n      itemId: id,\n      key: \"active\",\n      oldValue: oldActive,\n      newValue: !oldActive,\n    });\n  }\n\n  function handleToggleLocked() {\n    const oldLocked = Boolean(node.item.locked);\n    ctx.dispatch({ type: \"set-locked\", itemId: id, locked: !oldLocked });\n    ctx.fireEvent(\"lockedToggled\", {\n      itemId: id,\n      oldLocked,\n      newLocked: !oldLocked,\n    });\n  }\n\n  function handleRemove() {\n    if (!perms.remove) {\n      ctx.reportPermissionDenied(\"remove\", id, perms.reason);\n      return;\n    }\n    ctx.dispatch({ type: \"remove-item\", itemId: id });\n    ctx.fireEvent(\"itemRemoved\", {\n      itemId: id,\n      removed: node.item,\n      parentId: node.parentId ?? id,\n    });\n  }\n\n  function handleOverrideColor() {\n    if (!perms.overrideColor) {\n      ctx.reportPermissionDenied(\"overrideColor\", id, perms.reason);\n      return;\n    }\n    onOverrideColor();\n  }\n\n  return (\n    <DropdownMenu>\n      {/* F-cross-13 path-b: trigger IS the button (native <button> in both\n          backends) — `asChild` is Radix-only; `buttonVariants` reproduces the\n          ghost/icon look. (v0.4.2) */}\n      <DropdownMenuTrigger\n        className={cn(buttonVariants({ variant: \"ghost\", size: \"icon\" }), \"size-6\")}\n        aria-label=\"Card actions\"\n      >\n        <MoreHorizontal className=\"size-3.5\" />\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\" className=\"w-52\">\n        <DropdownMenuItem\n          disabled={!perms.edit}\n          onClick={() => tryOpenEdit(ctx.editable ? \"inline\" : \"popup\")}\n        >\n          <Edit3 className=\"mr-2 size-4\" />\n          {ctx.editable && isInlineActive ? \"Close inline edit\" : \"Edit\"}\n        </DropdownMenuItem>\n        {ctx.editable ? (\n          <DropdownMenuItem\n            disabled={!perms.edit}\n            onClick={() => tryOpenEdit(\"popup\")}\n          >\n            <PanelTop className=\"mr-2 size-4\" />\n            Edit in dialog…\n          </DropdownMenuItem>\n        ) : null}\n        <DropdownMenuSeparator />\n        <DropdownMenuItem onClick={handleCopy}>\n          <Copy className=\"mr-2 size-4\" />\n          Copy\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          disabled={!perms.addChildren}\n          onClick={handlePaste}\n        >\n          <ClipboardPaste className=\"mr-2 size-4\" />\n          Paste as child\n        </DropdownMenuItem>\n        <DropdownMenuSeparator />\n        <DropdownMenuItem\n          disabled={!perms.toggleActive}\n          onClick={handleToggleActive}\n        >\n          {node.item.active ? (\n            <>\n              <PowerOff className=\"mr-2 size-4\" />\n              Deactivate\n            </>\n          ) : (\n            <>\n              <Zap className=\"mr-2 size-4\" />\n              Activate\n            </>\n          )}\n        </DropdownMenuItem>\n        <DropdownMenuItem onClick={handleToggleLocked}>\n          {node.item.locked ? (\n            <>\n              <Unlock className=\"mr-2 size-4\" />\n              Unlock\n            </>\n          ) : (\n            <>\n              <Lock className=\"mr-2 size-4\" />\n              Lock\n            </>\n          )}\n        </DropdownMenuItem>\n        <DropdownMenuItem\n          disabled={!perms.overrideColor}\n          onClick={handleOverrideColor}\n        >\n          <Palette className=\"mr-2 size-4\" />\n          Override color…\n        </DropdownMenuItem>\n        <DropdownMenuSeparator />\n        <DropdownMenuItem\n          disabled={!perms.remove}\n          onClick={handleRemove}\n          className=\"text-destructive focus:text-destructive\"\n        >\n          <Trash2 className=\"mr-2 size-4\" />\n          Remove\n        </DropdownMenuItem>\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/action-menu.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/edit-popup.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogDescription,\n  DialogFooter,\n  DialogHeader,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Switch } from \"@/components/ui/switch\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { useCardContext } from \"../hooks/use-card-context\";\nimport { isDateOnly } from \"../lib/time\";\nimport type { TaskEditableField, TaskItem, TaskNode } from \"../types\";\n\nfunction isoToDatetimeLocal(iso: string | undefined): string {\n  if (!iso) return \"\";\n  // Date-only (all-day) value: seed the input at LOCAL midnight of that\n  // calendar day. `new Date(\"YYYY-MM-DD\")` parses as UTC midnight, which\n  // renders on the previous local day in negative-UTC offsets.\n  if (isDateOnly(iso)) return `${iso}T00:00`;\n  const d = new Date(iso);\n  if (!Number.isFinite(d.getTime())) return \"\";\n  // datetime-local expects \"YYYY-MM-DDTHH:mm\" in local time.\n  const pad = (n: number) => String(n).padStart(2, \"0\");\n  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;\n}\n\n/**\n * Serialize an EDITED datetime-local input back to the stored form. When the\n * field's original value was date-only (all-day) and the edited value still\n * sits at a local midnight, the date-only form is representable — keep it\n * (`YYYY-MM-DD`, never `toISOString()` for date-only). Anything else\n * serializes as a full ISO timestamp, as before.\n */\nfunction datetimeLocalToIso(\n  local: string,\n  original?: string,\n): string | undefined {\n  if (!local) return undefined;\n  const d = new Date(local);\n  if (!Number.isFinite(d.getTime())) return undefined;\n  if (original !== undefined && isDateOnly(original) && /T00:00(:00)?$/.test(local)) {\n    return local.slice(0, 10);\n  }\n  return d.toISOString();\n}\n\ntype Draft = {\n  name: string;\n  description: string;\n  status: string;\n  active: boolean;\n  setAt: string;\n  startAt: string;\n  expireAt: string;\n  duration: string;\n};\n\nfunction itemToDraft(item: TaskItem): Draft {\n  return {\n    name: item.name,\n    description: item.description ?? \"\",\n    status: item.status,\n    active: item.active,\n    setAt: isoToDatetimeLocal(item.setAt),\n    startAt: isoToDatetimeLocal(item.startAt),\n    expireAt: isoToDatetimeLocal(item.expireAt),\n    duration:\n      item.duration != null && Number.isFinite(item.duration)\n        ? String(Math.round(item.duration / 1000 / 60))\n        : \"\",\n  };\n}\n\nexport function EditPopup({ node }: { node: TaskNode }) {\n  const ctx = useCardContext();\n  const isOpen =\n    ctx.editState.kind === \"popup\" && ctx.editState.itemId === node.item.id;\n  const [draft, setDraft] = useState<Draft>(() => itemToDraft(node.item));\n  const [originalId, setOriginalId] = useState<string>(node.item.id);\n\n  // Reset draft when the dialog reopens for a (possibly different) item.\n  if (isOpen && originalId !== node.item.id) {\n    setOriginalId(node.item.id);\n    setDraft(itemToDraft(node.item));\n  }\n\n  function setField<K extends keyof Draft>(key: K, value: Draft[K]) {\n    setDraft((prev) => ({ ...prev, [key]: value }));\n  }\n\n  function applyDraft() {\n    const id = node.item.id;\n    const item = node.item;\n\n    function emit<K extends TaskEditableField>(\n      key: K,\n      oldValue: unknown,\n      newValue: unknown,\n    ) {\n      if (Object.is(oldValue, newValue)) return;\n      ctx.dispatch({ type: \"edit-field\", itemId: id, key, value: newValue });\n      ctx.fireEvent(\"fieldEdited\", { itemId: id, key, oldValue, newValue });\n    }\n\n    emit(\"name\", item.name, draft.name);\n    emit(\"description\", item.description ?? \"\", draft.description);\n\n    if (draft.status !== item.status) {\n      const oldStatus = item.status;\n      emit(\"status\", oldStatus, draft.status);\n      ctx.fireEvent(\"statusChanged\", {\n        itemId: id,\n        oldStatus,\n        newStatus: draft.status,\n      });\n    }\n\n    if (draft.active !== item.active) {\n      emit(\"active\", item.active, draft.active);\n      ctx.fireEvent(\"activeToggled\", {\n        itemId: id,\n        oldActive: item.active,\n        newActive: draft.active,\n      });\n    }\n\n    // Date fields: only emit when the INPUT actually changed. The stored form\n    // (date-only / offset timestamp / seconds) doesn't survive the\n    // datetime-local round-trip, so re-serializing an untouched field would\n    // rewrite the stored value and fire a phantom fieldEdited on a name-only\n    // save. Untouched ⇒ skipped ⇒ the stored string stays byte-identical.\n    const baseline = itemToDraft(item);\n    if (draft.setAt !== baseline.setAt) {\n      emit(\"setAt\", item.setAt, datetimeLocalToIso(draft.setAt, item.setAt) ?? item.setAt);\n    }\n    if (draft.startAt !== baseline.startAt) {\n      emit(\"startAt\", item.startAt, datetimeLocalToIso(draft.startAt, item.startAt));\n    }\n    if (draft.expireAt !== baseline.expireAt) {\n      emit(\"expireAt\", item.expireAt, datetimeLocalToIso(draft.expireAt, item.expireAt));\n    }\n\n    const durMinutes = draft.duration === \"\" ? null : Number(draft.duration);\n    const durMs =\n      durMinutes != null && Number.isFinite(durMinutes) && durMinutes > 0\n        ? durMinutes * 60 * 1000\n        : undefined;\n    emit(\"duration\", item.duration, durMs);\n\n    ctx.dispatch({ type: \"close-edit\" });\n  }\n\n  function handleOpenChange(open: boolean) {\n    if (!open) ctx.dispatch({ type: \"close-edit\" });\n  }\n\n  return (\n    <Dialog open={isOpen} onOpenChange={handleOpenChange}>\n      <DialogContent className=\"max-w-lg\">\n        <DialogHeader>\n          <DialogTitle>Edit task</DialogTitle>\n          <DialogDescription>\n            Changes apply when you click Save. Press Escape to discard.\n          </DialogDescription>\n        </DialogHeader>\n\n        <div className=\"grid gap-4 py-2\">\n          <div className=\"grid gap-1.5\">\n            <Label htmlFor=\"todo-name\">Name</Label>\n            <Input\n              id=\"todo-name\"\n              value={draft.name}\n              onChange={(e) => setField(\"name\", e.target.value)}\n            />\n          </div>\n\n          <div className=\"grid gap-1.5\">\n            <Label htmlFor=\"todo-description\">Description</Label>\n            <Textarea\n              id=\"todo-description\"\n              rows={3}\n              value={draft.description}\n              onChange={(e) => setField(\"description\", e.target.value)}\n            />\n          </div>\n\n          <div className=\"grid grid-cols-2 gap-3\">\n            <div className=\"grid gap-1.5\">\n              <Label htmlFor=\"todo-status\">Status</Label>\n              {ctx.statusOptions && ctx.statusOptions.length > 0 ? (\n                <Select\n                  value={draft.status}\n                  // Cross-backend: Radix passes `(string)`, Base UI passes\n                  // `(string | null, eventDetails)` — keep the nullable guard.\n                  onValueChange={(v: string | null) => setField(\"status\", v ?? \"\")}\n                >\n                  {/* w-full override per F-cross-13 / shadcn v4 Select w-fit lock */}\n                  <SelectTrigger id=\"todo-status\" className=\"w-full\">\n                    <SelectValue placeholder=\"Select status\" />\n                  </SelectTrigger>\n                  <SelectContent>\n                    {ctx.statusOptions.map((opt) => (\n                      <SelectItem key={opt.value} value={opt.value}>\n                        {opt.label}\n                      </SelectItem>\n                    ))}\n                  </SelectContent>\n                </Select>\n              ) : (\n                <Input\n                  id=\"todo-status\"\n                  value={draft.status}\n                  onChange={(e) => setField(\"status\", e.target.value)}\n                />\n              )}\n            </div>\n            <div className=\"grid gap-1.5\">\n              <Label htmlFor=\"todo-active\" className=\"flex items-center justify-between\">\n                <span>Active</span>\n                <Switch\n                  id=\"todo-active\"\n                  checked={draft.active}\n                  onCheckedChange={(v) => setField(\"active\", v)}\n                />\n              </Label>\n            </div>\n          </div>\n\n          <Separator />\n\n          <div className=\"grid grid-cols-2 gap-3\">\n            <div className=\"grid gap-1.5\">\n              <Label htmlFor=\"todo-setAt\">Set at</Label>\n              <Input\n                id=\"todo-setAt\"\n                type=\"datetime-local\"\n                value={draft.setAt}\n                onChange={(e) => setField(\"setAt\", e.target.value)}\n              />\n            </div>\n            <div className=\"grid gap-1.5\">\n              <Label htmlFor=\"todo-startAt\">Starts at</Label>\n              <Input\n                id=\"todo-startAt\"\n                type=\"datetime-local\"\n                value={draft.startAt}\n                onChange={(e) => setField(\"startAt\", e.target.value)}\n              />\n            </div>\n            <div className=\"grid gap-1.5\">\n              <Label htmlFor=\"todo-expireAt\">Expires at</Label>\n              <Input\n                id=\"todo-expireAt\"\n                type=\"datetime-local\"\n                value={draft.expireAt}\n                onChange={(e) => setField(\"expireAt\", e.target.value)}\n              />\n            </div>\n            <div className=\"grid gap-1.5\">\n              <Label htmlFor=\"todo-duration\">Duration (min)</Label>\n              <Input\n                id=\"todo-duration\"\n                type=\"number\"\n                min={0}\n                placeholder=\"e.g. 30\"\n                value={draft.duration}\n                onChange={(e) => setField(\"duration\", e.target.value)}\n              />\n            </div>\n          </div>\n        </div>\n\n        <DialogFooter>\n          <Button\n            variant=\"ghost\"\n            onClick={() => ctx.dispatch({ type: \"close-edit\" })}\n          >\n            Cancel\n          </Button>\n          <Button onClick={applyDraft}>Save</Button>\n        </DialogFooter>\n      </DialogContent>\n    </Dialog>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/edit-popup.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/edit-inline.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Check, X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useCardContext } from \"../hooks/use-card-context\";\nimport type { TaskEditableField, TaskItem, TaskNode } from \"../types\";\n\n/**\n * Inline editor. Each field commits on blur (or Enter for text fields) via\n * edit-field dispatch — no Save button. Escape (handled at the card level)\n * closes inline mode without rollback.\n *\n * Images / links are read-only in inline mode (per plan §6.3); consumers\n * needing to edit those use the popup.\n */\nexport function EditInline({ node }: { node: TaskNode }) {\n  const ctx = useCardContext();\n  const id = node.item.id;\n\n  function commit<K extends TaskEditableField>(\n    key: K,\n    oldValue: unknown,\n    newValue: unknown,\n  ) {\n    if (Object.is(oldValue, newValue)) return;\n    ctx.dispatch({ type: \"edit-field\", itemId: id, key, value: newValue });\n    ctx.fireEvent(\"fieldEdited\", { itemId: id, key, oldValue, newValue });\n    if (key === \"status\") {\n      ctx.fireEvent(\"statusChanged\", {\n        itemId: id,\n        oldStatus: oldValue as string,\n        newStatus: newValue as string,\n      });\n    }\n  }\n\n  return (\n    // Stop pointer propagation so dragging to select text inside a field doesn't\n    // start a kanban/HTML5 card drag when this card is embedded with shell-drag.\n    <div\n      className=\"space-y-2 rounded-md border border-dashed border-border bg-muted/30 p-3\"\n      onPointerDown={(e) => e.stopPropagation()}\n    >\n      <NameField item={node.item} commit={commit} />\n      <DescriptionField item={node.item} commit={commit} />\n      <StatusField item={node.item} commit={commit} />\n      <div className=\"flex justify-end\">\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"gap-1\"\n          onClick={() => ctx.dispatch({ type: \"close-edit\" })}\n        >\n          <X className=\"size-3.5\" />\n          Done editing\n        </Button>\n      </div>\n    </div>\n  );\n}\n\nfunction NameField({\n  item,\n  commit,\n}: {\n  item: TaskItem;\n  commit: <K extends TaskEditableField>(key: K, oldValue: unknown, newValue: unknown) => void;\n}) {\n  const [value, setValue] = useState(item.name);\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      <Input\n        autoFocus\n        value={value}\n        onChange={(e) => setValue(e.target.value)}\n        onBlur={() => commit(\"name\", item.name, value)}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            (e.currentTarget as HTMLInputElement).blur();\n          }\n        }}\n        className=\"font-medium\"\n        aria-label=\"Name\"\n      />\n    </div>\n  );\n}\n\nfunction DescriptionField({\n  item,\n  commit,\n}: {\n  item: TaskItem;\n  commit: <K extends TaskEditableField>(key: K, oldValue: unknown, newValue: unknown) => void;\n}) {\n  const [value, setValue] = useState(item.description ?? \"\");\n\n  return (\n    <Textarea\n      rows={2}\n      placeholder=\"Description\"\n      value={value}\n      onChange={(e) => setValue(e.target.value)}\n      onBlur={() => commit(\"description\", item.description ?? \"\", value)}\n      className=\"resize-none\"\n      aria-label=\"Description\"\n    />\n  );\n}\n\nfunction StatusField({\n  item,\n  commit,\n}: {\n  item: TaskItem;\n  commit: <K extends TaskEditableField>(key: K, oldValue: unknown, newValue: unknown) => void;\n}) {\n  const ctx = useCardContext();\n  const [value, setValue] = useState(item.status);\n  const opts = ctx.statusOptions;\n\n  if (opts && opts.length > 0) {\n    return (\n      <Select\n        value={value}\n        // Cross-backend signature: the producer's Radix Select passes `(string)`,\n        // but a Base UI (base-nova) consumer's Select passes\n        // `(string | null, eventDetails)`. Keep the nullable param + `?? \"\"` guard\n        // so this handler stays assignable to BOTH backends (smoke-proven).\n        onValueChange={(v: string | null) => {\n          const next = v ?? \"\";\n          setValue(next);\n          commit(\"status\", item.status, next);\n        }}\n      >\n        {/* w-full override per F-cross-13 / shadcn v4 Select w-fit lock */}\n        <SelectTrigger className=\"w-full\" aria-label=\"Status\">\n          <SelectValue placeholder=\"Status\" />\n        </SelectTrigger>\n        <SelectContent>\n          {opts.map((opt) => (\n            <SelectItem key={opt.value} value={opt.value}>\n              {opt.label}\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n    );\n  }\n\n  return (\n    <div className=\"flex items-center gap-1\">\n      <Input\n        placeholder=\"Status\"\n        value={value}\n        onChange={(e) => setValue(e.target.value)}\n        onBlur={() => commit(\"status\", item.status, value)}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            (e.currentTarget as HTMLInputElement).blur();\n          }\n        }}\n        aria-label=\"Status\"\n      />\n      <Check className=\"size-3.5 text-muted-foreground\" />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-card/parts/edit-inline.tsx"
    },
    {
      "path": "src/registry/components/data/task-card/parts/kanban-adapter.tsx",
      "content": "\"use client\";\n\n/**\n * Kanban-board renderer adapter (plan Q-P6).\n *\n * Exposes `taskCardKanbanRenderer`: a `KanbanCardRenderer<TaskItem>` that\n * `kanban-board` can register and use as the body for each card slot.\n *\n * `dragHandle: \"shell\"` (v0.3): the whole card is the drag activator. The rich\n * card normally marks its root `draggable` (HTML5 DnD, for copy/move payload +\n * nested drop), which would hijack the kanban PointerSensor gesture — so the\n * adapter passes `canDragItem={() => false}` to disable the root `draggable`\n * while embedded, letting the two DnD systems coexist. The board's\n * `activationConstraint: { distance: 5 }` keeps a click on an inner control\n * (status, edit, switch) from becoming a drag, and the inline editor stops\n * pointer propagation so text selection in a field doesn't drag the card.\n *\n * Trade-off: with the root drag disabled, subtasks can't be reordered by native\n * DnD *inside a kanban column* — acceptable since the kanban owns movement\n * there; the standalone (list) card keeps its internal DnD.\n *\n * NOTE: this adapter renders a SINGLE TaskCard per kanban item. The item's\n * own `children` still recurse internally — kanban \"items\" are not the same\n * as todo \"children.\" Consumers wiring deep nesting into kanban columns should\n * flatten to one level at the kanban level and keep the depth inside each\n * card.\n */\n\nimport { TaskCard } from \"../task-card\";\nimport type { TaskItem } from \"../types\";\n\n/**\n * Minimal structural mirror of `kanban-board`'s `KanbanCardRenderer<TData>`.\n * Declared inline to avoid a hard cross-procomp import (consumers wiring this\n * adapter into kanban-board will get full type checking through that\n * procomp's own re-exported type).\n */\ntype KanbanRenderContext = {\n  itemId: string;\n  columnId: string;\n  swimlaneId?: string;\n  isDragging: boolean;\n  isLocked: boolean;\n  /** Persist an in-place edit back to the board (kanban-board v0.4+). */\n  onDataChange?: (nextData: unknown) => void;\n};\n\ntype KanbanCardRenderer<TData> = {\n  id: string;\n  label: string;\n  render: (data: TData, ctx: KanbanRenderContext) => React.ReactNode;\n  newItem?: () => TData;\n  editForm?: (\n    data: TData,\n    onSave: (next: TData) => void,\n    onCancel: () => void,\n  ) => React.ReactNode;\n  dragHandle?: \"shell\" | \"header\";\n};\n\nexport const taskCardKanbanRenderer: KanbanCardRenderer<TaskItem> = {\n  id: \"task-card\",\n  label: \"Task card\",\n  dragHandle: \"shell\",\n  // Controlled: the board's item data is the source of truth. Edits flow back\n  // via onChange → ctx.onDataChange so they persist; a locked item is read-only;\n  // `key` resets card state when the board swaps the item in this slot.\n  // `canDragItem={() => false}` disables the card's own root `draggable` so it\n  // can't hijack the board's pointer drag (see file header).\n  render: (data, ctx) => (\n    <TaskCard\n      key={ctx.itemId}\n      value={data}\n      editable={!ctx.isLocked}\n      canDragItem={() => false}\n      onChange={(next) => ctx.onDataChange?.(next)}\n    />\n  ),\n  newItem: () => ({\n    id: `todo-${Date.now().toString(36)}`,\n    name: \"New task\",\n    status: \"todo\",\n    active: true,\n    setAt: new Date().toISOString(),\n  }),\n};\n",
      "type": "registry:component",
      "target": "components/task-card/parts/kanban-adapter.tsx"
    }
  ],
  "categories": [
    "data",
    "task-management"
  ],
  "type": "registry:block"
}