{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "task-tree",
  "title": "Task Tree",
  "author": "ilinxa",
  "description": "Hierarchical task outline with multi-select, bulk operations, search and filter toolbar, dual drag-and-drop, and virtualization.",
  "dependencies": [
    "lucide-react",
    "@dnd-kit/core",
    "@tanstack/react-virtual"
  ],
  "registryDependencies": [
    "avatar",
    "badge",
    "button",
    "checkbox",
    "dialog",
    "input",
    "popover",
    "select",
    "separator",
    "@ilinxa/task-card"
  ],
  "files": [
    {
      "path": "src/registry/components/data/task-tree/task-tree.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n} from \"react\";\nimport {\n  DndContext,\n  DragOverlay,\n  type DndContextProps,\n} from \"@dnd-kit/core\";\nimport { cn } from \"@/lib/utils\";\nimport type { TaskItem, TaskStatusOption } from \"../task-card/types\";\nimport {\n  readTasksFromClipboardEvent,\n  writeTasksToClipboardEvent,\n  reassignTaskIds,\n} from \"../task-card/lib/clipboard\";\nimport type {\n  TaskTreeHandle,\n  TaskTreePermissionDeniedEvent,\n  TaskTreeProps,\n} from \"./types\";\nimport { useTaskTreeState } from \"./hooks/use-task-tree-state\";\nimport {\n  TaskTreeDndContext,\n  TaskTreeRenderContext,\n  TaskTreeStateContext,\n  type TaskTreeDndContextValue,\n  type TaskTreeRenderContextValue,\n} from \"./hooks/use-task-tree-context\";\nimport { useTreeDndInternal } from \"./hooks/use-tree-dnd-internal\";\nimport { useTreeDndHtml5 } from \"./hooks/use-tree-dnd-html5\";\nimport { evalPermission } from \"./lib/permissions\";\nimport {\n  findItemWithLevel,\n  forEachItem,\n  pruneNestedIds,\n} from \"./lib/tree-walker\";\nimport { TaskTreeList } from \"./parts/task-tree-list\";\nimport { TaskTreeDragOverlay } from \"./parts/task-tree-drag-overlay\";\nimport { TaskTreeToolbar } from \"./parts/task-tree-toolbar\";\nimport { isValidElement, type ReactNode } from \"react\";\n\n/**\n * Tree-row renderer for TaskItem outlines. Sibling to `<TaskCard>` —\n * same `TaskItem` schema, lightweight two-line row instead of the rich\n * card chrome.\n *\n * Status across the C1–C11 commit chain:\n *   C1–C6 ✓  — scaffold, lib, hooks, row primitives, list, DnD\n *   C7   ◌  — toolbar\n *   C8   ◌  — keyboard + a11y + empty state\n *   C9+  ◌  — wrapper + demo + usage + meta sync + ship\n *\n * GATE 1: docs/procomps/task-tree-procomp/task-tree-procomp-description.md\n * GATE 2: docs/procomps/task-tree-procomp/task-tree-procomp-plan.md\n */\nexport const TaskTree = forwardRef<TaskTreeHandle, TaskTreeProps>(\n  function TaskTree(props, ref) {\n    const {\n      defaultValue,\n      value,\n      onChange,\n      state: externalState,\n      statusOptions,\n      defaultCollapsedIds,\n      defaultSelectedIds,\n      indentSize = 20,\n      filterMode = \"fade\",\n      statusIndicator = \"dot\",\n      virtualize,\n      toolbar = \"default\",\n      dndContext = \"internal\",\n      readOnly,\n      editable = false,\n      createItem,\n      onCreateRequest,\n      renderRow,\n      renderName,\n      renderDescription,\n      renderPerson,\n      renderStatusIndicator,\n      renderToolbar,\n      renderEmptyState,\n      renderDragOverlay,\n      onItemClick,\n      onItemContextMenu,\n      onActiveToggled,\n      onCollapseToggled,\n      onItemMoved,\n      onItemDropped,\n      onItemAdded,\n      onItemRemoved,\n      onBulkToggleActive,\n      onBulkRemove,\n      onBulkEdit,\n      onSelectionChanged,\n      onSearchChanged,\n      onSortChanged,\n      onFilterChanged,\n      permissions,\n      onPermissionDenied,\n      className,\n      \"aria-label\": ariaLabel,\n    } = props;\n\n    // Drag flags shared between useControlledMode (defense 3) and the DnD\n    // hooks (set/unset on dragStart/dragEnd). isInternalDragRef is the\n    // mutual-exclusion gate between @dnd-kit grip and native HTML5 drag.\n    const isDraggingRef = useRef(false);\n    const isInternalDragRef = useRef(false);\n\n    // Stable id for <DndContext>. Without an explicit id, @dnd-kit allocates\n    // `DndDescribedBy-N` from a module-level counter that diverges between\n    // SSR (single pass) and CSR (StrictMode double-pass) — that triggers a\n    // hydration mismatch on every grip button's `aria-describedby`. React's\n    // useId is stable across SSR + CSR so we use it as the deterministic seed.\n    const dndContextId = useId();\n\n    const internalState = useTaskTreeState({\n      defaultValue,\n      value,\n      onChange,\n      defaultCollapsedIds,\n      defaultSelectedIds,\n      filterMode,\n      isDraggingRef,\n      onItemClick,\n      onItemContextMenu,\n      onActiveToggled,\n      onCollapseToggled,\n      onItemMoved,\n      onItemDropped,\n      onItemAdded,\n      onItemRemoved,\n      onBulkToggleActive,\n      onBulkRemove,\n      onBulkEdit,\n      onSelectionChanged,\n      onSearchChanged,\n      onSortChanged,\n      onFilterChanged,\n      onPermissionDenied,\n      permissions,\n    });\n\n    const stateValue = externalState ?? internalState;\n\n    useImperativeHandle(ref, () => stateValue, [stateValue]);\n\n    // Latest state for the document clipboard listeners (kept in a ref so the\n    // listeners attach once, not on every tree mutation).\n    const treeRef = useRef<HTMLDivElement | null>(null);\n    const stateValueRef = useRef(stateValue);\n    useEffect(() => {\n      stateValueRef.current = stateValue;\n    }, [stateValue]);\n    // Permission matrix + denial callback mirrored to refs so the document\n    // clipboard listeners (attach-once) always gate against the live values.\n    const permissionsRef = useRef(permissions);\n    const onPermissionDeniedRef = useRef(onPermissionDenied);\n    useEffect(() => {\n      permissionsRef.current = permissions;\n      onPermissionDeniedRef.current = onPermissionDenied;\n    }, [permissions, onPermissionDenied]);\n\n    /* ── cross-surface clipboard (v0.3.0) — copy/cut/paste TaskItems through the\n          OS clipboard (shared ilinxa/task envelope). Document-level, gated on the\n          tree containing focus + skipped over text inputs; mirrors gantt/calendar.\n          Operates on the current selection (or the focused row). ── */\n    useEffect(() => {\n      if (!editable || readOnly) return;\n      const owns = () => {\n        const active = document.activeElement;\n        return !!treeRef.current && !!active && treeRef.current.contains(active);\n      };\n      const overText = () => {\n        const el = document.activeElement as HTMLElement | null;\n        return (\n          el?.tagName === \"INPUT\" ||\n          el?.tagName === \"TEXTAREA\" ||\n          el?.tagName === \"SELECT\" ||\n          el?.isContentEditable === true\n        );\n      };\n      const targetIds = (): string[] => {\n        const s = stateValueRef.current;\n        const sel = s.getSelectedIds();\n        // Prune ids whose ancestor is also selected — a parent's payload\n        // already carries the subtree; keeping both duplicates it on paste.\n        if (sel.size > 0) return pruneNestedIds(s.items, [...sel]);\n        return s.focusedItemId ? [s.focusedItemId] : [];\n      };\n      const collect = (ids: string[]): TaskItem[] =>\n        ids\n          .map((id) => stateValueRef.current.getItemById(id))\n          .filter((it): it is TaskItem => !!it);\n      const onCopy = (e: ClipboardEvent) => {\n        if (!owns() || overText()) return;\n        const items = collect(targetIds());\n        if (items.length === 0) return;\n        writeTasksToClipboardEvent(e, items, \"task-tree\");\n        e.preventDefault();\n      };\n      const onCut = (e: ClipboardEvent) => {\n        if (!owns() || overText()) return;\n        const ids = targetIds();\n        if (ids.length === 0) return;\n        // Gate each id on the remove rule + `locked`, mirroring keyboard\n        // Delete: blocked ids are reported + skipped, allowed ids are cut.\n        const allowed: string[] = [];\n        for (const id of ids) {\n          const found = findItemWithLevel(stateValueRef.current.items, id);\n          if (!found) continue;\n          if (\n            evalPermission(permissionsRef.current, \"remove\", found.item, found.level)\n          ) {\n            allowed.push(id);\n          } else {\n            onPermissionDeniedRef.current?.({\n              action: \"remove\",\n              itemId: id,\n              reason:\n                found.item.locked === true ? \"denied-by-lock\" : \"denied-by-rule\",\n            });\n          }\n        }\n        const items = collect(allowed);\n        if (items.length === 0) return;\n        writeTasksToClipboardEvent(e, items, \"task-tree\");\n        stateValueRef.current.removeItems(allowed);\n        e.preventDefault();\n      };\n      const onPaste = (e: ClipboardEvent) => {\n        if (!owns() || overText()) return;\n        const items = readTasksFromClipboardEvent(e);\n        if (!items) return;\n        const s = stateValueRef.current;\n        const parentId = s.focusedItemId;\n        if (parentId) {\n          // Gate the graft target on its dropIntoChildren (addChildren) rule +\n          // `locked` — same rule the DnD drop path enforces (TT1 parity).\n          const target = findItemWithLevel(s.items, parentId);\n          if (\n            target &&\n            !evalPermission(\n              permissionsRef.current,\n              \"dropIntoChildren\",\n              target.item,\n              target.level,\n            )\n          ) {\n            onPermissionDeniedRef.current?.({\n              action: \"dropIntoChildren\",\n              itemId: parentId,\n              reason:\n                target.item.locked === true ? \"denied-by-lock\" : \"denied-by-rule\",\n            });\n            // The payload was ours — consume the event, deny the graft.\n            e.preventDefault();\n            return;\n          }\n        }\n        for (const raw of items) {\n          const fresh = reassignTaskIds(raw);\n          if (parentId) s.addChild(parentId, fresh);\n          else s.addItem(fresh);\n        }\n        e.preventDefault();\n      };\n      document.addEventListener(\"copy\", onCopy);\n      document.addEventListener(\"cut\", onCut);\n      document.addEventListener(\"paste\", onPaste);\n      return () => {\n        document.removeEventListener(\"copy\", onCopy);\n        document.removeEventListener(\"cut\", onCut);\n        document.removeEventListener(\"paste\", onPaste);\n      };\n    }, [editable, readOnly]);\n\n    const statusOptionMap = useMemo(() => {\n      const map = new Map<string, TaskStatusOption>();\n      if (statusOptions) {\n        for (const o of statusOptions) map.set(o.value, o);\n      }\n      return map;\n    }, [statusOptions]);\n\n    const renderContextValue = useMemo<TaskTreeRenderContextValue>(\n      () => ({\n        statusIndicator,\n        statusOptionMap,\n        indentSize,\n        renderRow,\n        renderName,\n        renderDescription,\n        renderPerson,\n        renderStatusIndicator,\n        renderEmptyState,\n      }),\n      [\n        statusIndicator,\n        statusOptionMap,\n        indentSize,\n        renderRow,\n        renderName,\n        renderDescription,\n        renderPerson,\n        renderStatusIndicator,\n        renderEmptyState,\n      ],\n    );\n\n    // Resolve a drop-target's permission against the live tree, mirroring the\n    // keyboard path's evalPermission gate. Predicates are target-keyed to match\n    // the DnD hook's enforcement signature (TT1 — previously left undefined so\n    // the matrix only applied on the keyboard path).\n    const resolveDropPermission = useCallback(\n      (targetId: string, action: \"dropIntoChildren\" | \"dropAsSibling\"): boolean => {\n        let allowed = true;\n        forEachItem(stateValue.items, (item, level) => {\n          if (item.id === targetId) {\n            allowed = evalPermission(permissions, action, item, level);\n            return false;\n          }\n          return true;\n        });\n        return allowed;\n      },\n      [permissions, stateValue.items],\n    );\n    const canDropIntoChildren = useCallback(\n      (targetId: string) => resolveDropPermission(targetId, \"dropIntoChildren\"),\n      [resolveDropPermission],\n    );\n    const canDropAsSibling = useCallback(\n      (targetId: string) => resolveDropPermission(targetId, \"dropAsSibling\"),\n      [resolveDropPermission],\n    );\n\n    // DnD wiring.\n    const dndInternal = useTreeDndInternal({\n      items: stateValue.items,\n      dispatch: stateValue.dispatch,\n      fireMoved: (args) => onItemMoved?.(args),\n      firePermissionDenied: (args: TaskTreePermissionDeniedEvent) =>\n        onPermissionDenied?.(args),\n      isDraggingRef,\n      isInternalDragRef,\n      canDropIntoChildren,\n      canDropAsSibling,\n    });\n\n    const dndHtml5 = useTreeDndHtml5({\n      items: stateValue.items,\n      dispatch: stateValue.dispatch,\n      fireAdded: (args) => onItemAdded?.(args),\n      fireDropped: (args) => onItemDropped?.(args),\n      isInternalDragRef,\n    });\n\n    const dndContextValue = useMemo<TaskTreeDndContextValue>(\n      () => ({\n        activeItemId: dndInternal.activeItem?.id ?? null,\n        overId: dndInternal.over?.overId ?? null,\n        overZone: dndInternal.over?.zone ?? null,\n        overCircular: dndInternal.over?.circular ?? false,\n        handleRowClick: stateValue.handleRowClick,\n        getRowHandlers: dndHtml5.getRowHandlers,\n      }),\n      [\n        dndInternal.activeItem,\n        dndInternal.over,\n        stateValue.handleRowClick,\n        dndHtml5.getRowHandlers,\n      ],\n    );\n\n    // Virtualization config from prop variants.\n    const virtualizeMode: \"auto\" | \"always\" | \"never\" =\n      virtualize === true\n        ? \"always\"\n        : virtualize === false\n          ? \"never\"\n          : \"auto\";\n    const virtualizeThreshold =\n      typeof virtualize === \"object\" && virtualize !== null\n        ? (virtualize.threshold ?? 200)\n        : 200;\n\n    const defaultToolbar: ReactNode =\n      toolbar === \"default\" ? (\n        <TaskTreeToolbar\n          statusOptions={statusOptions}\n          onBulkEdit={onBulkEdit}\n          createItem={createItem}\n          onCreateRequest={onCreateRequest}\n          readOnly={readOnly}\n          editable={editable}\n          permissions={permissions}\n        />\n      ) : null;\n    const customNodeToolbar: ReactNode =\n      toolbar !== \"default\" && toolbar !== \"none\" && isValidElement(toolbar)\n        ? (toolbar as ReactNode)\n        : null;\n    const toolbarRendered: ReactNode = renderToolbar\n      ? renderToolbar({ defaultToolbar, state: stateValue })\n      : (customNodeToolbar ?? (toolbar === \"none\" ? null : defaultToolbar));\n\n    const treeBody = (\n      <TaskTreeStateContext.Provider value={stateValue}>\n        <TaskTreeRenderContext.Provider value={renderContextValue}>\n          <TaskTreeDndContext.Provider value={dndContextValue}>\n            <div\n              ref={treeRef}\n              aria-label={ariaLabel ?? \"Task tree\"}\n              className={cn(\"flex h-full flex-col\", className)}\n            >\n              {toolbarRendered}\n              <TaskTreeList\n                virtualize={virtualizeMode}\n                virtualizeThreshold={virtualizeThreshold}\n                suspended={dndInternal.activeItem !== null}\n                permissions={permissions}\n                onPermissionDenied={onPermissionDenied}\n                readOnly={readOnly}\n              />\n            </div>\n          </TaskTreeDndContext.Provider>\n        </TaskTreeRenderContext.Provider>\n      </TaskTreeStateContext.Provider>\n    );\n\n    const dndProps: DndContextProps = {\n      id: dndContextId,\n      sensors: dndInternal.sensors,\n      onDragStart: dndInternal.onDragStart,\n      onDragOver: dndInternal.onDragOver,\n      onDragEnd: dndInternal.onDragEnd,\n      onDragCancel: dndInternal.onDragCancel,\n    };\n\n    if (dndContext === \"external\") {\n      return treeBody;\n    }\n\n    return (\n      <DndContext {...dndProps}>\n        {treeBody}\n        <DragOverlay>\n          {dndInternal.activeItem &&\n            (renderDragOverlay\n              ? renderDragOverlay({\n                  item: dndInternal.activeItem,\n                  level: dndInternal.activeLevel,\n                })\n              : (\n                  <TaskTreeDragOverlay\n                    item={dndInternal.activeItem}\n                    level={dndInternal.activeLevel}\n                  />\n                ))}\n        </DragOverlay>\n      </DndContext>\n    );\n  },\n);\n\n",
      "type": "registry:component",
      "target": "components/task-tree/task-tree.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/task-tree-with-editor.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useRef,\n  useState,\n  type Ref,\n} from \"react\";\nimport {\n  Dialog,\n  DialogContent,\n  DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Button } from \"@/components/ui/button\";\nimport { TaskCard } from \"../task-card/task-card\";\nimport type { TaskItem } from \"../task-card/types\";\nimport { TaskTree } from \"./task-tree\";\nimport type {\n  TaskTreeHandle,\n  TaskTreeItemEvent,\n  TaskTreeProps,\n} from \"./types\";\nimport { updateById } from \"./lib/tree-mutators\";\n\n/**\n * Convenience wrapper that pairs `<TaskTree>` with a `<TaskCard>` edit\n * dialog. Two modes the dialog enters:\n *\n *   - EDIT existing item — fired by row click. Live-edit semantics: each\n *     keystroke / field change propagates straight back into the tree via\n *     `handle.setValue`. Closing the dialog (X, overlay, Escape) just\n *     dismisses; changes are already saved.\n *\n *   - CREATE new item — fired by the toolbar \"+ New\" button (via the\n *     deferred-commit `onCreateRequest` hook). The dialog opens on a\n *     PENDING item that is NOT yet in the tree. Edits inside the dialog\n *     mutate only the local pending state. Submit commits via\n *     `handle.addItem` and closes; Cancel / Escape / overlay discards the\n *     pending item entirely.\n *\n * Architecture:\n *   - Wrapper is STATE-TRANSPARENT. Consumer's value / defaultValue /\n *     onChange flow through to TaskTree unmodified. The wrapper owns only\n *     the dialog's open + pending state.\n *   - The two dialog modes are distinguished by `pending` state. When\n *     `pending` is true, onChange does NOT touch the tree — it only\n *     updates the local `editTarget`. The footer's Submit button is the\n *     single commit point.\n *   - Outer ref is forwarded via a callback ref so the consumer's\n *     ref.current stays in sync as the inner handle identity changes\n *     across renders (it changes whenever state.items mutates because\n *     TaskTreeStateValue is rebuilt). useImperativeHandle would freeze\n *     to the initial handle.\n *\n * Consumers that want a stricter integration (confirm dialog before edit,\n * custom editor surface) should compose <TaskTree> + their own dialog\n * using the onItemClick / onCreateRequest callbacks.\n */\nexport const TaskTreeWithEditor = forwardRef<TaskTreeHandle, TaskTreeProps>(\n  function TaskTreeWithEditor(props, ref) {\n    const { onItemClick: onItemClickProp, ...rest } = props;\n\n    const treeRef = useRef<TaskTreeHandle | null>(null);\n    const [editTarget, setEditTarget] = useState<TaskItem | null>(null);\n    // True when editTarget represents a not-yet-committed new row.\n    const [pending, setPending] = useState(false);\n\n    // Callback ref — fires whenever the inner TaskTree's handle reference\n    // changes. Forwards to both internal treeRef and the outer consumer ref.\n    const setTreeRef = useCallback(\n      (handle: TaskTreeHandle | null) => {\n        treeRef.current = handle;\n        forwardRefValue(ref, handle);\n      },\n      [ref],\n    );\n\n    // Radix Dialog flips `aria-hidden=\"true\"` on the surrounding tree\n     // wrapper the instant `open` becomes truthy. If a focused element\n     // (the clicked row, the \"+ New\" button, etc.) is still inside that\n     // wrapper at that moment, the browser logs an a11y warning because\n     // a focused descendant of an aria-hidden ancestor is illegal. Blur\n     // the active element before flipping our open state; Radix's\n     // dialog auto-focus then takes over on the next tick.\n    const blurActiveElement = useCallback(() => {\n      if (typeof document === \"undefined\") return;\n      const el = document.activeElement;\n      if (el instanceof HTMLElement) el.blur();\n    }, []);\n\n    const handleRowClick = useCallback(\n      (args: TaskTreeItemEvent) => {\n        blurActiveElement();\n        setEditTarget(args.item);\n        setPending(false);\n        onItemClickProp?.(args);\n      },\n      [onItemClickProp, blurActiveElement],\n    );\n\n    const handleCreateRequest = useCallback(\n      (draft: TaskItem) => {\n        blurActiveElement();\n        // Open the dialog on the pending item; do NOT add to the tree yet —\n        // commit happens on Submit.\n        setEditTarget(draft);\n        setPending(true);\n      },\n      [blurActiveElement],\n    );\n\n    const handleEditedItemChange = useCallback(\n      (updated: TaskItem) => {\n        // Always track the latest in-dialog state so the title + Submit\n        // payload reflect user edits.\n        setEditTarget(updated);\n        if (pending) {\n          // Pending mode — defer tree mutation until Submit.\n          return;\n        }\n        // Edit-existing mode — propagate every change to the tree.\n        const handle = treeRef.current;\n        if (!handle) return;\n        const current = handle.getValue();\n        const next = updateById(current, updated.id, () => updated);\n        handle.setValue(next);\n      },\n      [pending],\n    );\n\n    const closeDialog = useCallback(() => {\n      setEditTarget(null);\n      setPending(false);\n    }, []);\n\n    const handleSubmitPending = useCallback(() => {\n      if (!pending || !editTarget) return;\n      const handle = treeRef.current;\n      if (!handle) {\n        closeDialog();\n        return;\n      }\n      handle.addItem(editTarget);\n      // Focus the newly-committed row so arrow-keys continue from it.\n      handle.focusItem(editTarget.id);\n      closeDialog();\n    }, [pending, editTarget, closeDialog]);\n\n    return (\n      <>\n        <TaskTree\n          editable\n          {...rest}\n          ref={setTreeRef}\n          onItemClick={handleRowClick}\n          onCreateRequest={handleCreateRequest}\n        />\n        <Dialog\n          open={!!editTarget}\n          onOpenChange={(open) => {\n            // Any close path (X, Escape, overlay) in pending mode = discard.\n            if (!open) closeDialog();\n          }}\n        >\n          <DialogContent\n            aria-describedby={undefined}\n            className=\"flex max-h-[calc(100dvh-2rem)] max-w-252 flex-col overflow-hidden\"\n          >\n            <DialogTitle className=\"sr-only\">\n              {pending\n                ? \"New task\"\n                : editTarget\n                  ? `Edit ${editTarget.name}`\n                  : \"Edit task\"}\n            </DialogTitle>\n            {editTarget && (\n              <>\n                <div className=\"-mx-4 -mt-4 min-h-0 flex-1 overflow-y-auto p-4\">\n                  <TaskCard\n                    // Remount when the target id changes so TaskCard's\n                    // uncontrolled defaultValue picks up the new payload.\n                    key={editTarget.id}\n                    editable\n                    defaultValue={editTarget}\n                    onChange={handleEditedItemChange}\n                    statusOptions={props.statusOptions}\n                    priorityOptions={props.priorityOptions}\n                  />\n                </div>\n                {pending && (\n                  <div className=\"-mx-4 -mb-4 flex items-center justify-end gap-2 border-t border-border bg-muted/40 px-4 py-3\">\n                    <Button\n                      type=\"button\"\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={closeDialog}\n                    >\n                      Cancel\n                    </Button>\n                    <Button\n                      type=\"button\"\n                      size=\"sm\"\n                      onClick={handleSubmitPending}\n                    >\n                      Add task\n                    </Button>\n                  </div>\n                )}\n              </>\n            )}\n          </DialogContent>\n        </Dialog>\n      </>\n    );\n  },\n);\n\nfunction forwardRefValue<T>(ref: Ref<T> | undefined, value: T | null): void {\n  if (!ref) return;\n  if (typeof ref === \"function\") {\n    ref(value);\n    return;\n  }\n  // RefObject — assign current.\n  (ref as { current: T | null }).current = value;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/task-tree-with-editor.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/index.ts",
      "content": "export { TaskTree } from \"./task-tree\";\nexport { TaskTreeWithEditor } from \"./task-tree-with-editor\";\n\n// Headless state hook\nexport { useTaskTreeState } from \"./hooks/use-task-tree-state\";\n\nexport type {\n  TaskTreeProps,\n  TaskTreeHandle,\n  TaskTreeStateValue,\n  TaskTreeChangeReason,\n  TaskTreeChangeArgs,\n  TaskTreeSort,\n  TaskTreeFilter,\n  TaskTreePermissionAction,\n  TaskTreePermissionDenialReason,\n  TaskTreeAction,\n  TreeLocation,\n  TaskTreeVisibleRow,\n  TaskTreeItemEvent,\n  TaskTreeMoveEvent,\n  TaskTreeDropEvent,\n  TaskTreeAddEvent,\n  TaskTreeRemoveEvent,\n  TaskTreePermissionDeniedEvent,\n  TaskTreeRowRenderArgs,\n  TaskTreeFieldRenderArgs,\n  TaskTreeStatusRenderArgs,\n  TaskTreeToolbarRenderArgs,\n  TaskTreeEmptyRenderArgs,\n  TaskTreeDragOverlayArgs,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/task-tree/index.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/types.ts",
      "content": "import type { ReactNode, MouseEvent, KeyboardEvent } from \"react\";\nimport type {\n  TaskItem,\n  TaskStatusOption,\n  TaskPriorityOption,\n  TaskPermissions,\n} from \"../task-card/types\";\n\n/**\n * Tree-position pointer used by onItemMoved.from / .to.\n * parentId === null indicates top-level (sibling of root items).\n */\nexport interface TreeLocation {\n  parentId: string | null;\n  index: number;\n}\n\n/**\n * Mutation reason tag — fires with every onChange. One code per\n * public mutation pathway so consumers can route post-change effects.\n */\nexport type TaskTreeChangeReason =\n  | \"reorder\"\n  | \"reparent\"\n  | \"toggle-active\"\n  | \"imperative-set\"\n  | \"drop-from-external\"\n  | \"add-item\"\n  | \"remove-item\"\n  | \"add-child\"\n  | \"bulk-toggle-active\"\n  | \"bulk-remove\";\n\n/** Sort specification. */\nexport type TaskTreeSort =\n  | { kind: \"name\" | \"setAt\" | \"expireAt\" | \"status\"; direction: \"asc\" | \"desc\" }\n  | { kind: \"custom\"; compare: (a: TaskItem, b: TaskItem) => number };\n\n/** Filter specification (combined with the search query). */\nexport interface TaskTreeFilter {\n  statuses?: ReadonlyArray<string>;\n  personIds?: ReadonlyArray<string>;\n  active?: \"all\" | \"active\" | \"inactive\";\n}\n\n/** Permission action codes (tree-specific; distinct from task-card's set). */\nexport type TaskTreePermissionAction =\n  | \"edit\"\n  | \"toggleActive\"\n  | \"drag\"\n  | \"dropAsSibling\"\n  | \"dropIntoChildren\"\n  | \"remove\";\n\n/** Denial reasons fired with onPermissionDenied. */\nexport type TaskTreePermissionDenialReason =\n  | \"denied-by-rule\"\n  | \"denied-by-readOnly\"\n  | \"denied-by-lock\"\n  | \"circular-drop\";\n\n/**\n * Reducer action union. Private engine; surfaced via the `dispatch`\n * escape hatch on TaskTreeStateValue for advanced consumers.\n */\nexport type TaskTreeAction =\n  | { type: \"SET_ITEMS\"; items: TaskItem[]; reason: TaskTreeChangeReason }\n  | {\n      type: \"ADD_ITEM\";\n      item: TaskItem;\n      parentId: string | null;\n      index?: number;\n      via: \"imperative\" | \"drop-from-external\";\n    }\n  | { type: \"REMOVE_ITEM\"; id: string; via: \"imperative\" | \"keyboard\" | \"bulk\" }\n  | { type: \"REMOVE_ITEMS\"; ids: ReadonlyArray<string> }\n  | { type: \"ADD_CHILD\"; parentId: string; item: TaskItem; index?: number }\n  | {\n      type: \"MOVE_ITEM\";\n      itemId: string;\n      to: TreeLocation;\n      reason: \"reorder\" | \"reparent\";\n    }\n  | { type: \"TOGGLE_ACTIVE\"; id: string; nextActive: boolean }\n  | {\n      type: \"TOGGLE_ACTIVE_BULK\";\n      ids: ReadonlyArray<string>;\n      nextActive: boolean;\n    }\n  | { type: \"TOGGLE_COLLAPSE\"; id: string }\n  | { type: \"EXPAND_ALL\" }\n  | { type: \"COLLAPSE_ALL\" }\n  | { type: \"SET_COLLAPSED\"; ids: ReadonlyArray<string> }\n  | {\n      type: \"SELECT_ONE\";\n      id: string;\n      mode: \"replace\" | \"toggle\" | \"range\";\n    }\n  | { type: \"SELECT_ALL\" }\n  | {\n      type: \"SELECT_REPLACE\";\n      ids: ReadonlyArray<string>;\n      anchorId?: string | null;\n    }\n  | { type: \"CLEAR_SELECTION\" }\n  | { type: \"SET_FOCUS\"; id: string | null }\n  | { type: \"SET_QUERY\"; query: string }\n  | { type: \"SET_SORT\"; sort: TaskTreeSort }\n  | { type: \"SET_FILTER\"; filter: TaskTreeFilter }\n  | { type: \"CLEAR_FILTERS\" };\n\n/* ---------------------------------- Event arg shapes ---------------------------------- */\n\nexport interface TaskTreeChangeArgs {\n  items: TaskItem[];\n  reason: TaskTreeChangeReason;\n}\n\nexport interface TaskTreeItemEvent {\n  item: TaskItem;\n  level: number;\n  /**\n   * The originating event. MouseEvent for click + context-menu, KeyboardEvent\n   * when the row is activated via Enter from the keyboard handler. Consumers\n   * narrow with `\"clientX\" in event` or instanceof checks when they need\n   * mouse-specific properties.\n   */\n  event: MouseEvent | KeyboardEvent;\n}\n\nexport interface TaskTreeMoveEvent {\n  item: TaskItem;\n  from: TreeLocation;\n  to: TreeLocation;\n  via: \"drag\" | \"imperative\";\n}\n\nexport interface TaskTreeDropEvent {\n  item: TaskItem;\n  from: \"internal\" | \"external\";\n  targetParentId: string | null;\n  targetIndex: number;\n}\n\nexport interface TaskTreeAddEvent {\n  item: TaskItem;\n  parentId: string | null;\n  index: number;\n  via: \"imperative\" | \"drop-from-external\";\n}\n\nexport interface TaskTreeRemoveEvent {\n  item: TaskItem;\n  via: \"imperative\" | \"keyboard\" | \"bulk\";\n}\n\nexport interface TaskTreePermissionDeniedEvent {\n  action: TaskTreePermissionAction;\n  itemId: string;\n  reason: TaskTreePermissionDenialReason;\n}\n\n/* ---------------------------------- Visible item snapshot ---------------------------------- */\n\n/**\n * Renderer-ready flat row produced by the filter → sort → flatten pipeline.\n * level + parentId + index are needed for indent, ARIA, and reparent target\n * detection without re-walking the tree per render.\n */\nexport interface TaskTreeVisibleRow {\n  item: TaskItem;\n  level: number;\n  parentId: string | null;\n  index: number;\n  /**\n   * Set by the filter pipeline in `filterMode: \"fade\"` when this row's item\n   * doesn't match the active query/filter. Renderers apply reduced opacity\n   * while keeping interactive affordances reachable. Absent in \"hide\" mode\n   * because non-matching rows aren't emitted at all.\n   */\n  dimmed?: boolean;\n}\n\n/* ---------------------------------- Imperative handle ---------------------------------- */\n\n/**\n * The full imperative handle exposed via ref. 29 methods (26 base + 3 v0.3\n * clipboard) covering tree state, item ops, single + bulk active-toggle +\n * remove, focus, collapse, selection, query/sort/filter, and copy/cut/paste.\n */\nexport interface TaskTreeHandle {\n  // Tree state (2)\n  getValue(): TaskItem[];\n  setValue(next: TaskItem[]): void;\n\n  // Item ops (6)\n  addItem(item: TaskItem, opts?: { parentId?: string; index?: number }): void;\n  removeItem(id: string): void;\n  addChild(parentId: string, item: TaskItem, index?: number): void;\n  removeItems(ids: ReadonlyArray<string>): void;\n  toggleActive(id: string, nextActive: boolean): void;\n  toggleActiveBulk(ids: ReadonlyArray<string>, nextActive: boolean): void;\n\n  // Focus / lookup (2)\n  focusItem(id: string): void;\n  getItemById(id: string): TaskItem | undefined;\n\n  // Collapse (6)\n  expandItem(id: string): void;\n  collapseItem(id: string): void;\n  toggleCollapse(id: string): void;\n  expandAll(): void;\n  collapseAll(): void;\n  isCollapsed(id: string): boolean;\n\n  // Selection (6)\n  selectItem(id: string): void;\n  deselectItem(id: string): void;\n  selectRange(idA: string, idB: string): void;\n  selectAll(): void;\n  clearSelection(): void;\n  getSelectedIds(): ReadonlySet<string>;\n\n  // Query / sort / filter (4)\n  setQuery(query: string): void;\n  setSort(sort: TaskTreeSort): void;\n  setFilter(filter: TaskTreeFilter): void;\n  clearAllFilters(): void;\n\n  // Cross-surface clipboard (v0.3.0; shared ilinxa/task envelope) (3)\n  /** Copy items to the OS clipboard. Defaults to the current selection (or focus). */\n  copyItems(ids?: ReadonlyArray<string>): void;\n  /** Copy then remove. Defaults to the current selection (or focus). */\n  cutItems(ids?: ReadonlyArray<string>): void;\n  /** Paste clipboard tasks (re-id'd) under `parentId` (null/omitted = the focused row; root if none focused). */\n  pasteItems(parentId?: string | null): void;\n}\n\n/**\n * Headless state value returned by useTaskTreeState. Superset of\n * TaskTreeHandle plus the live state values + reducer escape hatch.\n * Consumers can pass this to <TaskTree state={state} /> to host their\n * own toolbar / row layout / virtualization.\n */\nexport interface TaskTreeStateValue extends TaskTreeHandle {\n  items: TaskItem[];\n  visibleItems: ReadonlyArray<TaskTreeVisibleRow>;\n  collapsedIds: ReadonlySet<string>;\n  selectedIds: ReadonlySet<string>;\n  query: string;\n  sort: TaskTreeSort;\n  filter: TaskTreeFilter;\n  dispatch: (action: TaskTreeAction) => void;\n  /**\n   * Click handler that owns selection-mode resolution (plain / cmd / shift),\n   * range resolution over `visibleItems`, and `onItemClick` firing. The\n   * default `<TaskTreeRow>` wires it; custom rows / slot consumers call it\n   * from their own row click target.\n   */\n  handleRowClick: (\n    item: TaskItem,\n    level: number,\n    event: MouseEvent | KeyboardEvent,\n  ) => void;\n  /**\n   * Currently focused row id — drives the row's tabindex and arrow-key\n   * navigation. Null until the user interacts; first visible row becomes\n   * tabbable by default.\n   */\n  focusedItemId: string | null;\n}\n\n/* ---------------------------------- Slot render-prop args ---------------------------------- */\n\nexport interface TaskTreeRowRenderArgs {\n  item: TaskItem;\n  level: number;\n  isSelected: boolean;\n  isCollapsed: boolean;\n  isExpanded: boolean;\n  defaultRender: ReactNode;\n}\n\nexport interface TaskTreeFieldRenderArgs {\n  item: TaskItem;\n  level: number;\n}\n\nexport interface TaskTreeStatusRenderArgs extends TaskTreeFieldRenderArgs {\n  statusOption?: TaskStatusOption;\n}\n\nexport interface TaskTreeToolbarRenderArgs {\n  defaultToolbar: ReactNode;\n  state: TaskTreeStateValue;\n}\n\nexport interface TaskTreeEmptyRenderArgs {\n  hasFilter: boolean;\n}\n\nexport interface TaskTreeDragOverlayArgs {\n  item: TaskItem;\n  level: number;\n}\n\n/* ---------------------------------- Public props ---------------------------------- */\n\nexport interface TaskTreeProps {\n  // Data\n  defaultValue?: TaskItem[];\n  value?: TaskItem[];\n  onChange?: (args: TaskTreeChangeArgs) => void;\n  /** Lifted state from useTaskTreeState(); supersedes value/defaultValue when provided. */\n  state?: TaskTreeStateValue;\n\n  // Status enum\n  statusOptions?: TaskStatusOption[];\n  /** Priority enum — threaded to the edit surface (TaskTreeWithEditor's card). */\n  priorityOptions?: TaskPriorityOption[];\n\n  // Permissions\n  permissions?: TaskPermissions;\n  onPermissionDenied?: (args: TaskTreePermissionDeniedEvent) => void;\n\n  // Behavior\n  readOnly?: boolean;\n  /**\n   * Signals that the tree has an editing affordance (an edit panel,\n   * inline editor, or similar consumer-provided surface). Default\n   * `false`. When false, the default toolbar's \"+ New\" button and the\n   * bulk action bar's \"Delete\" button are hidden — without an editor a\n   * newly-added row is just an unrenameable \"Untitled\" stub and a delete\n   * affordance is pointing at a row the user never got to interact with.\n   * `<TaskTreeWithEditor>` sets this to `true` automatically. Consumers\n   * wiring their own editor (via `onItemClick` opening a dialog, an\n   * inline rename, etc.) should set `editable` themselves.\n   */\n  editable?: boolean;\n  /**\n   * Factory for the default toolbar's \"+ New\" button. When provided, the\n   * toolbar renders the button and calls this on click; the returned item\n   * is appended at root via `state.addItem`. When absent, the toolbar\n   * falls back to a built-in factory IF `statusOptions[0]` is available\n   * (needed to satisfy the required `status` field). When neither path\n   * yields a factory (no `createItem` + no `statusOptions`), the button\n   * is hidden. Also hidden when `readOnly`, `editable === false`, or\n   * `toolbar` isn't the built-in default. Consumers wanting fancier\n   * behaviour (add-as-child, add-as-sibling, custom inline editor) should\n   * replace the toolbar via `renderToolbar` and call `state.addItem` /\n   * `state.addChild` themselves.\n   */\n  createItem?: () => TaskItem;\n  /**\n   * Intercepts the default toolbar's \"+ New\" button click. When provided,\n   * the toolbar calls this with a freshly-built item (via `createItem` or\n   * the built-in fallback) and DOES NOT add it to the tree itself —\n   * commit is the consumer's responsibility (call `state.addItem` /\n   * `handle.addItem` when ready). Used by `<TaskTreeWithEditor>` to open\n   * the edit dialog on a pending item and commit only after the user\n   * submits, so a new row never appears in the tree until its values are\n   * filled in. When omitted, the toolbar adds the item synchronously and\n   * focuses it (current behaviour).\n   */\n  onCreateRequest?: (item: TaskItem) => void;\n  defaultCollapsedIds?: ReadonlyArray<string>;\n  defaultSelectedIds?: ReadonlyArray<string>;\n  /** Pixels per nesting level (default 20). */\n  indentSize?: number;\n  /** Default \"fade\" (non-matching rows shown dimmed); \"hide\" omits non-matching + non-ancestor rows. */\n  filterMode?: \"fade\" | \"hide\";\n  /** Default \"dot\"; \"strip\" renders a left-edge color strip; \"none\" hides the indicator. */\n  statusIndicator?: \"dot\" | \"strip\" | \"none\";\n  /** Default auto-enable when total tree size ≥ 200 rows. */\n  virtualize?: boolean | { threshold?: number };\n  /** Default \"default\" mounts the built-in toolbar; \"none\" hides; ReactNode replaces. */\n  toolbar?: \"default\" | \"none\" | ReactNode;\n  /**\n   * \"internal\" (default) mounts a <DndContext> wrapping the tree.\n   * \"external\" skips internal mounting — use when the consumer already\n   * provides a <DndContext> higher up (e.g., nested inside kanban-board).\n   * Note: when external, the consumer is responsible for matching the\n   * MouseSensor (distance:5) + TouchSensor (delay:300) sensor config or\n   * accepting whatever activation rules their outer context provides.\n   */\n  dndContext?: \"internal\" | \"external\";\n\n  // Slot props (priority rule: slot wins over prop variant)\n  renderRow?: (args: TaskTreeRowRenderArgs) => ReactNode;\n  renderName?: (args: TaskTreeFieldRenderArgs) => ReactNode;\n  renderDescription?: (args: TaskTreeFieldRenderArgs) => ReactNode;\n  renderPerson?: (args: TaskTreeFieldRenderArgs) => ReactNode;\n  renderStatusIndicator?: (args: TaskTreeStatusRenderArgs) => ReactNode;\n  renderToolbar?: (args: TaskTreeToolbarRenderArgs) => ReactNode;\n  renderEmptyState?: (args: TaskTreeEmptyRenderArgs) => ReactNode;\n  renderDragOverlay?: (args: TaskTreeDragOverlayArgs) => ReactNode;\n\n  // Events\n  onItemClick?: (args: TaskTreeItemEvent) => void;\n  onItemContextMenu?: (args: TaskTreeItemEvent) => void;\n  onActiveToggled?: (args: { item: TaskItem; nextActive: boolean }) => void;\n  onCollapseToggled?: (args: { item: TaskItem; collapsed: boolean }) => void;\n  onItemMoved?: (args: TaskTreeMoveEvent) => void;\n  onItemDropped?: (args: TaskTreeDropEvent) => void;\n  onItemAdded?: (args: TaskTreeAddEvent) => void;\n  onItemRemoved?: (args: TaskTreeRemoveEvent) => void;\n  onBulkToggleActive?: (args: {\n    ids: ReadonlyArray<string>;\n    nextActive: boolean;\n  }) => void;\n  onBulkRemove?: (args: { ids: ReadonlyArray<string> }) => void;\n  onBulkEdit?: (args: { ids: ReadonlyArray<string> }) => void;\n  onSelectionChanged?: (args: { selectedIds: ReadonlySet<string> }) => void;\n  onSearchChanged?: (args: { query: string }) => void;\n  onSortChanged?: (args: { sort: TaskTreeSort }) => void;\n  onFilterChanged?: (args: { filter: TaskTreeFilter }) => void;\n\n  // Standard\n  className?: string;\n  \"aria-label\"?: string;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/types.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/reducer.ts",
      "content": "import type { TaskItem } from \"../../task-card/types\";\nimport type {\n  TaskTreeAction,\n  TaskTreeChangeReason,\n  TaskTreeFilter,\n  TaskTreeSort,\n} from \"../types\";\nimport {\n  insertAt,\n  moveItem,\n  removeById,\n  updateById,\n} from \"./tree-mutators\";\nimport { forEachItem } from \"./tree-walker\";\n\n/**\n * Internal reducer state. Not exported from the package barrel — only\n * `useTaskTreeState` (C3) consumes it. Mutations are reference-equal on\n * no-ops so downstream selectors can memoize off identity.\n */\nexport interface State {\n  items: TaskItem[];\n  collapsedIds: ReadonlySet<string>;\n  selectedIds: ReadonlySet<string>;\n  selectionAnchorId: string | null;\n  query: string;\n  sort: TaskTreeSort;\n  filter: TaskTreeFilter;\n  focusedItemId: string | null;\n  /**\n   * Set by the reducer on every items-changing action; consumed by the\n   * hook's items-change effect to route the correct reason to onChange.\n   * Lives in state (vs an external ref) so raw-dispatch consumers route\n   * correctly too. Non-items actions leave it untouched.\n   */\n  lastChangeReason: TaskTreeChangeReason | null;\n}\n\nexport interface CreateInitialStateInput {\n  items: TaskItem[];\n  defaultCollapsedIds?: ReadonlyArray<string>;\n  defaultSelectedIds?: ReadonlyArray<string>;\n  defaultSort?: TaskTreeSort;\n  defaultFilter?: TaskTreeFilter;\n}\n\nconst DEFAULT_SORT: TaskTreeSort = { kind: \"name\", direction: \"asc\" };\nconst DEFAULT_FILTER: TaskTreeFilter = {\n  statuses: [],\n  personIds: [],\n  active: \"all\",\n};\n\nexport function createInitialState(input: CreateInitialStateInput): State {\n  return {\n    items: input.items,\n    collapsedIds: new Set(input.defaultCollapsedIds ?? []),\n    selectedIds: new Set(input.defaultSelectedIds ?? []),\n    selectionAnchorId: null,\n    query: \"\",\n    sort: input.defaultSort ?? DEFAULT_SORT,\n    filter: input.defaultFilter ?? DEFAULT_FILTER,\n    focusedItemId: null,\n    lastChangeReason: null,\n  };\n}\n\n/**\n * Derive the public change reason from any items-changing action. Returns\n * null for non-items actions so the hook's effect can skip firing onChange.\n */\nfunction reasonFromAction(\n  action: TaskTreeAction,\n): TaskTreeChangeReason | null {\n  switch (action.type) {\n    case \"SET_ITEMS\":\n      return action.reason;\n    case \"ADD_ITEM\":\n      return action.via === \"drop-from-external\"\n        ? \"drop-from-external\"\n        : \"add-item\";\n    case \"REMOVE_ITEM\":\n      return \"remove-item\";\n    case \"REMOVE_ITEMS\":\n      return \"bulk-remove\";\n    case \"ADD_CHILD\":\n      return \"add-child\";\n    case \"MOVE_ITEM\":\n      return action.reason;\n    case \"TOGGLE_ACTIVE\":\n      return \"toggle-active\";\n    case \"TOGGLE_ACTIVE_BULK\":\n      return \"bulk-toggle-active\";\n    default:\n      return null;\n  }\n}\n\nexport function reducer(state: State, action: TaskTreeAction): State {\n  const next = reducerInner(state, action);\n  if (next === state) return state;\n  if (next.items !== state.items) {\n    const reason = reasonFromAction(action);\n    return { ...next, lastChangeReason: reason };\n  }\n  return next;\n}\n\nfunction reducerInner(state: State, action: TaskTreeAction): State {\n  switch (action.type) {\n    case \"SET_ITEMS\": {\n      if (action.items === state.items) return state;\n      return pruneOrphanedIds({ ...state, items: action.items });\n    }\n\n    case \"ADD_ITEM\": {\n      const items = insertAt(\n        state.items,\n        action.parentId,\n        action.index,\n        action.item,\n      );\n      if (items === state.items) return state;\n      return { ...state, items };\n    }\n\n    case \"REMOVE_ITEM\": {\n      const items = removeById(state.items, action.id);\n      if (items === state.items) return state;\n      return pruneOrphanedIds({ ...state, items });\n    }\n\n    case \"REMOVE_ITEMS\": {\n      if (action.ids.length === 0) return state;\n      let items = state.items;\n      for (const id of action.ids) {\n        items = removeById(items, id);\n      }\n      if (items === state.items) return state;\n      return pruneOrphanedIds({ ...state, items });\n    }\n\n    case \"ADD_CHILD\": {\n      const items = insertAt(\n        state.items,\n        action.parentId,\n        action.index,\n        action.item,\n      );\n      if (items === state.items) return state;\n      const collapsedIds = withoutId(state.collapsedIds, action.parentId);\n      return { ...state, items, collapsedIds };\n    }\n\n    case \"MOVE_ITEM\": {\n      const items = moveItem(state.items, action.itemId, action.to);\n      if (items === state.items) return state;\n      const collapsedIds =\n        action.to.parentId !== null\n          ? withoutId(state.collapsedIds, action.to.parentId)\n          : state.collapsedIds;\n      return { ...state, items, collapsedIds };\n    }\n\n    case \"TOGGLE_ACTIVE\": {\n      const items = updateById(state.items, action.id, (item) =>\n        item.active === action.nextActive\n          ? item\n          : { ...item, active: action.nextActive },\n      );\n      if (items === state.items) return state;\n      return { ...state, items };\n    }\n\n    case \"TOGGLE_ACTIVE_BULK\": {\n      if (action.ids.length === 0) return state;\n      const target = new Set(action.ids);\n      let items = state.items;\n      for (const id of target) {\n        items = updateById(items, id, (item) =>\n          item.active === action.nextActive\n            ? item\n            : { ...item, active: action.nextActive },\n        );\n      }\n      if (items === state.items) return state;\n      return { ...state, items };\n    }\n\n    case \"TOGGLE_COLLAPSE\": {\n      const next = new Set(state.collapsedIds);\n      if (next.has(action.id)) next.delete(action.id);\n      else next.add(action.id);\n      return { ...state, collapsedIds: next };\n    }\n\n    case \"EXPAND_ALL\": {\n      if (state.collapsedIds.size === 0) return state;\n      return { ...state, collapsedIds: new Set<string>() };\n    }\n\n    case \"COLLAPSE_ALL\": {\n      const next = new Set<string>();\n      forEachItem(state.items, (item) => {\n        if (item.children && item.children.length > 0) next.add(item.id);\n      });\n      if (sameSet(next, state.collapsedIds)) return state;\n      return { ...state, collapsedIds: next };\n    }\n\n    case \"SET_COLLAPSED\": {\n      const next = new Set(action.ids);\n      if (sameSet(next, state.collapsedIds)) return state;\n      return { ...state, collapsedIds: next };\n    }\n\n    case \"SELECT_ONE\": {\n      const { id, mode } = action;\n      if (mode === \"replace\") {\n        if (state.selectedIds.size === 1 && state.selectedIds.has(id)) {\n          if (state.selectionAnchorId === id) return state;\n          return { ...state, selectionAnchorId: id };\n        }\n        return {\n          ...state,\n          selectedIds: new Set([id]),\n          selectionAnchorId: id,\n        };\n      }\n      if (mode === \"toggle\") {\n        const next = new Set(state.selectedIds);\n        if (next.has(id)) next.delete(id);\n        else next.add(id);\n        return { ...state, selectedIds: next, selectionAnchorId: id };\n      }\n      // mode === \"range\" — DFS over the (unfiltered, uncollapsed) tree.\n      // Visibility-aware range select is the hook's job: the hook computes the\n      // visible-row id list using the consumer's filterMode and dispatches\n      // SELECT_REPLACE with the resolved ids. This branch is the fallback for\n      // raw `dispatch(SELECT_ONE/range)` users and works correctly when the\n      // filter is inactive and nothing is collapsed.\n      const anchor = state.selectionAnchorId;\n      const rangeIds = computeUnfilteredRangeIds(state.items, anchor, id);\n      if (rangeIds.length === 0) return state;\n      const next = new Set(state.selectedIds);\n      for (const r of rangeIds) next.add(r);\n      return { ...state, selectedIds: next };\n    }\n\n    case \"SELECT_ALL\": {\n      // Unfiltered: select every id in the tree. Visibility-aware select-all\n      // is the hook's job; it uses SELECT_REPLACE with the visible-id list.\n      const next = new Set<string>();\n      forEachItem(state.items, (item) => {\n        next.add(item.id);\n      });\n      if (sameSet(next, state.selectedIds)) return state;\n      return { ...state, selectedIds: next };\n    }\n\n    case \"SELECT_REPLACE\": {\n      const next = new Set(action.ids);\n      const anchor =\n        action.anchorId !== undefined\n          ? action.anchorId\n          : state.selectionAnchorId;\n      if (\n        sameSet(next, state.selectedIds) &&\n        anchor === state.selectionAnchorId\n      ) {\n        return state;\n      }\n      return { ...state, selectedIds: next, selectionAnchorId: anchor };\n    }\n\n    case \"CLEAR_SELECTION\": {\n      if (state.selectedIds.size === 0 && state.selectionAnchorId === null) {\n        return state;\n      }\n      return {\n        ...state,\n        selectedIds: new Set<string>(),\n        selectionAnchorId: null,\n      };\n    }\n\n    case \"SET_FOCUS\": {\n      if (state.focusedItemId === action.id) return state;\n      return { ...state, focusedItemId: action.id };\n    }\n\n    case \"SET_QUERY\": {\n      if (state.query === action.query) return state;\n      return { ...state, query: action.query };\n    }\n\n    case \"SET_SORT\": {\n      if (sortsEqual(state.sort, action.sort)) return state;\n      return { ...state, sort: action.sort };\n    }\n\n    case \"SET_FILTER\": {\n      if (filtersEqual(state.filter, action.filter)) return state;\n      return { ...state, filter: action.filter };\n    }\n\n    case \"CLEAR_FILTERS\": {\n      if (\n        state.query === \"\" &&\n        filtersEqual(state.filter, DEFAULT_FILTER)\n      ) {\n        return state;\n      }\n      return { ...state, query: \"\", filter: DEFAULT_FILTER };\n    }\n  }\n}\n\n/**\n * After a tree mutation, drop dangling ids from collapsedIds + selectedIds +\n * the selection anchor. Cheap (DFS over remaining items) and keeps Q7's \"drop\n * silently\" lock honored for selection.\n */\nfunction pruneOrphanedIds(state: State): State {\n  const live = new Set<string>();\n  forEachItem(state.items, (item) => {\n    live.add(item.id);\n  });\n\n  let collapsedIds = state.collapsedIds;\n  let selectedIds = state.selectedIds;\n  let anchor = state.selectionAnchorId;\n  let focus = state.focusedItemId;\n\n  if (hasOrphan(collapsedIds, live)) {\n    const next = new Set<string>();\n    for (const id of collapsedIds) if (live.has(id)) next.add(id);\n    collapsedIds = next;\n  }\n  if (hasOrphan(selectedIds, live)) {\n    const next = new Set<string>();\n    for (const id of selectedIds) if (live.has(id)) next.add(id);\n    selectedIds = next;\n  }\n  if (anchor !== null && !live.has(anchor)) anchor = null;\n  if (focus !== null && !live.has(focus)) focus = null;\n\n  if (\n    collapsedIds === state.collapsedIds &&\n    selectedIds === state.selectedIds &&\n    anchor === state.selectionAnchorId &&\n    focus === state.focusedItemId\n  ) {\n    return state;\n  }\n  return {\n    ...state,\n    collapsedIds,\n    selectedIds,\n    selectionAnchorId: anchor,\n    focusedItemId: focus,\n  };\n}\n\nfunction hasOrphan(ids: ReadonlySet<string>, live: ReadonlySet<string>): boolean {\n  for (const id of ids) if (!live.has(id)) return true;\n  return false;\n}\n\nfunction withoutId(\n  ids: ReadonlySet<string>,\n  id: string,\n): ReadonlySet<string> {\n  if (!ids.has(id)) return ids;\n  const next = new Set(ids);\n  next.delete(id);\n  return next;\n}\n\nfunction sameSet<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): boolean {\n  if (a === b) return true;\n  if (a.size !== b.size) return false;\n  for (const v of a) if (!b.has(v)) return false;\n  return true;\n}\n\nfunction sortsEqual(a: TaskTreeSort, b: TaskTreeSort): boolean {\n  if (a === b) return true;\n  if (a.kind !== b.kind) return false;\n  if (a.kind === \"custom\" && b.kind === \"custom\") return a.compare === b.compare;\n  if (a.kind !== \"custom\" && b.kind !== \"custom\") {\n    return a.direction === b.direction;\n  }\n  return false;\n}\n\nfunction filtersEqual(a: TaskTreeFilter, b: TaskTreeFilter): boolean {\n  if (a === b) return true;\n  if ((a.active ?? \"all\") !== (b.active ?? \"all\")) return false;\n  if (!arrEq(a.statuses ?? [], b.statuses ?? [])) return false;\n  if (!arrEq(a.personIds ?? [], b.personIds ?? [])) return false;\n  return true;\n}\n\nfunction arrEq<T>(a: ReadonlyArray<T>, b: ReadonlyArray<T>): boolean {\n  if (a === b) return true;\n  if (a.length !== b.length) return false;\n  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;\n  return true;\n}\n\n/**\n * Range select fallback over the UNFILTERED tree in DFS order. The hook\n * supersedes this for visibility-aware ranges by dispatching SELECT_REPLACE\n * with pre-resolved ids; this only fires for raw `dispatch(SELECT_ONE/range)`.\n */\nfunction computeUnfilteredRangeIds(\n  items: ReadonlyArray<TaskItem>,\n  anchor: string | null,\n  target: string,\n): string[] {\n  const order: string[] = [];\n  forEachItem(items, (item) => {\n    order.push(item.id);\n  });\n  const targetIdx = order.indexOf(target);\n  if (targetIdx === -1) return [];\n  const anchorIdx = anchor === null ? 0 : order.indexOf(anchor);\n  if (anchorIdx === -1) return [target];\n  const [lo, hi] =\n    anchorIdx <= targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx];\n  return order.slice(lo, hi + 1);\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/reducer.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/tree-walker.ts",
      "content": "import type { TaskItem } from \"../../task-card/types\";\n\n/** Depth-first search for an item by id. Returns undefined when not found. */\nexport function findItemById(\n  items: ReadonlyArray<TaskItem>,\n  id: string,\n): TaskItem | undefined {\n  for (const item of items) {\n    if (item.id === id) return item;\n    if (item.children && item.children.length > 0) {\n      const hit = findItemById(item.children, id);\n      if (hit) return hit;\n    }\n  }\n  return undefined;\n}\n\n/**\n * Find the parent id of `id`. Returns null when `id` is a top-level item OR\n * when `id` is not in the tree. Use `findItemById` first if you need to\n * disambiguate those two cases.\n */\nexport function findParentId(\n  items: ReadonlyArray<TaskItem>,\n  id: string,\n): string | null {\n  for (const item of items) {\n    if (item.children) {\n      for (const child of item.children) {\n        if (child.id === id) return item.id;\n      }\n      const deeper = findParentId(item.children, id);\n      if (deeper !== null) return deeper;\n    }\n  }\n  return null;\n}\n\n/**\n * Return the ancestor chain (root-first) leading to `id`, excluding `id`\n * itself. Empty when `id` is top-level or missing.\n */\nexport function findAncestors(\n  items: ReadonlyArray<TaskItem>,\n  id: string,\n): TaskItem[] {\n  const path: TaskItem[] = [];\n  if (walkPath(items, id, path)) return path;\n  return [];\n}\n\nfunction walkPath(\n  items: ReadonlyArray<TaskItem>,\n  id: string,\n  acc: TaskItem[],\n): boolean {\n  for (const item of items) {\n    if (item.id === id) return true;\n    if (item.children && item.children.length > 0) {\n      acc.push(item);\n      if (walkPath(item.children, id, acc)) return true;\n      acc.pop();\n    }\n  }\n  return false;\n}\n\n/** Sibling index of `id` under `parentId`. Returns -1 when not found. */\nexport function findIndexUnder(\n  items: ReadonlyArray<TaskItem>,\n  parentId: string | null,\n  id: string,\n): number {\n  if (parentId === null) {\n    return items.findIndex((i) => i.id === id);\n  }\n  const parent = findItemById(items, parentId);\n  if (!parent || !parent.children) return -1;\n  return parent.children.findIndex((i) => i.id === id);\n}\n\n/**\n * Locate an item AND its depth (0-based, matching the visible-row `level`\n * convention). Returns null when `id` is not in the tree. Used by the\n * clipboard paths to evaluate level-keyed permission rules.\n */\nexport function findItemWithLevel(\n  items: ReadonlyArray<TaskItem>,\n  id: string,\n): { item: TaskItem; level: number } | null {\n  let out: { item: TaskItem; level: number } | null = null;\n  forEachItem(items, (item, level) => {\n    if (item.id === id) {\n      out = { item, level };\n      return false;\n    }\n    return true;\n  });\n  return out;\n}\n\n/**\n * Prune ids whose ANCESTOR is also in the set. A copy/cut of a parent already\n * carries its whole subtree, so serializing a selected descendant a second\n * time would duplicate it on paste.\n */\nexport function pruneNestedIds(\n  items: ReadonlyArray<TaskItem>,\n  ids: ReadonlyArray<string>,\n): string[] {\n  if (ids.length <= 1) return [...ids];\n  const idSet = new Set(ids);\n  return ids.filter(\n    (id) => !findAncestors(items, id).some((a) => idSet.has(a.id)),\n  );\n}\n\n/** Visit every item depth-first. Stops when visitor returns false. */\nexport function forEachItem(\n  items: ReadonlyArray<TaskItem>,\n  visit: (item: TaskItem, level: number, parentId: string | null) => boolean | void,\n  level = 0,\n  parentId: string | null = null,\n): boolean {\n  for (const item of items) {\n    if (visit(item, level, parentId) === false) return false;\n    if (item.children && item.children.length > 0) {\n      if (!forEachItem(item.children, visit, level + 1, item.id)) return false;\n    }\n  }\n  return true;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/tree-walker.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/tree-mutators.ts",
      "content": "import type { TaskItem } from \"../../task-card/types\";\nimport type { TreeLocation } from \"../types\";\nimport { findItemById, findParentId } from \"./tree-walker\";\nimport { isAncestor } from \"./circular-drop\";\n\n/**\n * Insert `item` as a child of `parentId` at `index`. parentId=null inserts at\n * the top level. index=undefined appends. Returns a new top-level array; the\n * spine from root to mutation point is copied; untouched subtrees are reused.\n */\nexport function insertAt(\n  items: ReadonlyArray<TaskItem>,\n  parentId: string | null,\n  index: number | undefined,\n  item: TaskItem,\n): TaskItem[] {\n  if (parentId === null) {\n    const next = items.slice();\n    const at = clampIndex(index, next.length);\n    next.splice(at, 0, item);\n    return next;\n  }\n  let mutated = false;\n  const next = items.map((node) => {\n    if (node.id === parentId) {\n      mutated = true;\n      const children = node.children ? node.children.slice() : [];\n      const at = clampIndex(index, children.length);\n      children.splice(at, 0, item);\n      return { ...node, children };\n    }\n    if (node.children && node.children.length > 0) {\n      const childUpdated = insertAt(node.children, parentId, index, item);\n      if (childUpdated !== node.children) {\n        mutated = true;\n        return { ...node, children: childUpdated };\n      }\n    }\n    return node;\n  });\n  return mutated ? next : (items as TaskItem[]);\n}\n\n/**\n * Remove the item with `id` and return the new top-level array. Returns the\n * input by reference when the id is not in the tree.\n */\nexport function removeById(\n  items: ReadonlyArray<TaskItem>,\n  id: string,\n): TaskItem[] {\n  let mutated = false;\n  const next = items\n    .map((node) => {\n      if (node.id === id) {\n        mutated = true;\n        return null;\n      }\n      if (node.children && node.children.length > 0) {\n        const childUpdated = removeById(node.children, id);\n        if (childUpdated !== node.children) {\n          mutated = true;\n          return { ...node, children: childUpdated };\n        }\n      }\n      return node;\n    })\n    .filter((n): n is TaskItem => n !== null);\n  return mutated ? next : (items as TaskItem[]);\n}\n\n/**\n * Apply an updater to the item with `id`. The updater receives the current\n * item and returns the next one (it may return the same reference to signal\n * no-op). Returns the input by reference when nothing changed.\n */\nexport function updateById(\n  items: ReadonlyArray<TaskItem>,\n  id: string,\n  updater: (item: TaskItem) => TaskItem,\n): TaskItem[] {\n  let mutated = false;\n  const next = items.map((node) => {\n    if (node.id === id) {\n      const replaced = updater(node);\n      if (replaced !== node) mutated = true;\n      return replaced;\n    }\n    if (node.children && node.children.length > 0) {\n      const childUpdated = updateById(node.children, id, updater);\n      if (childUpdated !== node.children) {\n        mutated = true;\n        return { ...node, children: childUpdated };\n      }\n    }\n    return node;\n  });\n  return mutated ? next : (items as TaskItem[]);\n}\n\n/**\n * Atomic remove + insert. Bans circular drops (target inside source's subtree)\n * by returning the input unchanged. Also a no-op when the source can't be\n * found. Auto-expands behavior is handled in the reducer (collapsedIds set),\n * not here — this stays a pure tree transform.\n */\nexport function moveItem(\n  items: ReadonlyArray<TaskItem>,\n  sourceId: string,\n  to: TreeLocation,\n): TaskItem[] {\n  const source = findItemById(items, sourceId);\n  if (!source) return items as TaskItem[];\n  if (to.parentId && isAncestor(items, sourceId, to.parentId)) {\n    return items as TaskItem[];\n  }\n\n  const oldParentId = findParentId(items, sourceId);\n  const sameParent = oldParentId === to.parentId;\n\n  // Compute the destination index BEFORE removal when the source and target\n  // share a parent — the removal shifts later siblings up by one.\n  let targetIndex = to.index;\n  if (sameParent) {\n    const oldIndex = siblingIndex(items, oldParentId, sourceId);\n    if (oldIndex !== -1 && oldIndex < targetIndex) {\n      targetIndex = targetIndex - 1;\n    }\n  }\n\n  const withoutSource = removeById(items, sourceId);\n  return insertAt(withoutSource, to.parentId, targetIndex, source);\n}\n\nfunction siblingIndex(\n  items: ReadonlyArray<TaskItem>,\n  parentId: string | null,\n  id: string,\n): number {\n  if (parentId === null) {\n    return items.findIndex((i) => i.id === id);\n  }\n  const parent = findItemById(items, parentId);\n  if (!parent || !parent.children) return -1;\n  return parent.children.findIndex((i) => i.id === id);\n}\n\nfunction clampIndex(index: number | undefined, len: number): number {\n  if (index === undefined) return len;\n  if (index < 0) return 0;\n  if (index > len) return len;\n  return index;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/tree-mutators.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/filter-items.ts",
      "content": "import type { TaskItem } from \"../../task-card/types\";\nimport type { TaskTreeFilter } from \"../types\";\nimport { forEachItem } from \"./tree-walker\";\nimport { itemMatchesQuery } from \"./search-items\";\n\n/**\n * Build a match-flag map: `true` when the item matches BOTH the filter spec\n * AND the search query, after normalization. The flatten pass uses this to\n * decide what to dim (fade mode) or hide (hide mode, with ancestor-of-match\n * still rendered).\n *\n * Filter spec interpretation:\n *   - statuses: empty array OR undefined ⇒ all statuses pass\n *   - personIds: empty array OR undefined ⇒ all persons pass; matches against\n *     both targetPerson.id AND creatorPerson.id (either hits qualifies)\n *   - active: \"all\" (default) | \"active\" | \"inactive\"\n */\nexport function buildMatchMap(\n  items: ReadonlyArray<TaskItem>,\n  filter: TaskTreeFilter,\n  query: string,\n): ReadonlyMap<string, boolean> {\n  const map = new Map<string, boolean>();\n  const statuses = filter.statuses;\n  const personIds = filter.personIds;\n  const active = filter.active ?? \"all\";\n  const hasStatusFilter = statuses && statuses.length > 0;\n  const hasPersonFilter = personIds && personIds.length > 0;\n  const trimmed = query.trim();\n  const hasQuery = trimmed.length > 0;\n\n  forEachItem(items, (item) => {\n    let pass = true;\n    if (hasStatusFilter && !statuses.includes(item.status)) pass = false;\n    if (pass && hasPersonFilter) {\n      const targetId = item.targetPerson?.id;\n      const creatorId = item.creatorPerson?.id;\n      const ok =\n        (targetId !== undefined && personIds.includes(targetId)) ||\n        (creatorId !== undefined && personIds.includes(creatorId));\n      if (!ok) pass = false;\n    }\n    if (pass && active === \"active\" && !item.active) pass = false;\n    if (pass && active === \"inactive\" && item.active) pass = false;\n    if (pass && hasQuery && !itemMatchesQuery(item, trimmed)) pass = false;\n    map.set(item.id, pass);\n  });\n\n  return map;\n}\n\n/**\n * Build the ancestor-of-match set used by `filterMode: \"hide\"` to ensure that\n * even non-matching ancestors render so the matched descendants stay visible\n * in tree context (VSCode-style — Q6 lock).\n */\nexport function buildAncestorOfMatchSet(\n  items: ReadonlyArray<TaskItem>,\n  matchMap: ReadonlyMap<string, boolean>,\n): ReadonlySet<string> {\n  const out = new Set<string>();\n  function walk(nodes: ReadonlyArray<TaskItem>): boolean {\n    let any = false;\n    for (const node of nodes) {\n      const selfMatch = matchMap.get(node.id) === true;\n      let descendantMatch = false;\n      if (node.children && node.children.length > 0) {\n        descendantMatch = walk(node.children);\n      }\n      if (descendantMatch && !selfMatch) {\n        out.add(node.id);\n      }\n      if (selfMatch || descendantMatch) any = true;\n    }\n    return any;\n  }\n  walk(items);\n  return out;\n}\n\n/**\n * Convenience: true when ANY entry in the filter spec or query is non-empty.\n * Used by the empty state (\"no results for filter X\") and toolbar\n * clear-filters affordance gating.\n */\nexport function isFilterActive(filter: TaskTreeFilter, query: string): boolean {\n  if (query.trim().length > 0) return true;\n  if (filter.statuses && filter.statuses.length > 0) return true;\n  if (filter.personIds && filter.personIds.length > 0) return true;\n  if (filter.active && filter.active !== \"all\") return true;\n  return false;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/filter-items.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/search-items.ts",
      "content": "import type { TaskItem } from \"../../task-card/types\";\n\n/**\n * Plain case-insensitive `.includes()` over `name` + `description` per\n * Q5 lock. Empty/whitespace-only queries match everything (returns true for\n * every item; callers don't need to special-case).\n */\nexport function itemMatchesQuery(item: TaskItem, query: string): boolean {\n  const q = query.trim().toLowerCase();\n  if (!q) return true;\n  if (item.name.toLowerCase().includes(q)) return true;\n  if (item.description && item.description.toLowerCase().includes(q)) return true;\n  return false;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/search-items.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/sort-items.ts",
      "content": "import type { TaskItem } from \"../../task-card/types\";\nimport type { TaskTreeSort } from \"../types\";\n\n/**\n * Apply `sort` to the tree recursively. Children sort independently under\n * their parent. Pure: returns a new top-level array; subtrees with empty or\n * single-element children are returned by reference when stable. Sibling\n * order is stable (Array.prototype.sort is stable as of ES2019).\n */\nexport function applySort(\n  items: ReadonlyArray<TaskItem>,\n  sort: TaskTreeSort,\n): TaskItem[] {\n  const compare = makeComparator(sort);\n  return sortRecursive(items, compare);\n}\n\nfunction sortRecursive(\n  items: ReadonlyArray<TaskItem>,\n  compare: (a: TaskItem, b: TaskItem) => number,\n): TaskItem[] {\n  const next = items\n    .map((item) => {\n      if (item.children && item.children.length > 0) {\n        const sortedChildren = sortRecursive(item.children, compare);\n        if (sortedChildren !== item.children) {\n          return { ...item, children: sortedChildren };\n        }\n      }\n      return item;\n    })\n    .slice();\n  next.sort(compare);\n  return next;\n}\n\nfunction makeComparator(\n  sort: TaskTreeSort,\n): (a: TaskItem, b: TaskItem) => number {\n  if (sort.kind === \"custom\") return sort.compare;\n  const dir = sort.direction === \"desc\" ? -1 : 1;\n  switch (sort.kind) {\n    case \"name\":\n      return (a, b) => dir * a.name.localeCompare(b.name);\n    case \"setAt\":\n      return (a, b) => dir * compareIsoStrings(a.setAt, b.setAt);\n    case \"expireAt\":\n      return (a, b) => dir * compareIsoStrings(a.expireAt, b.expireAt);\n    case \"status\":\n      return (a, b) => dir * a.status.localeCompare(b.status);\n  }\n}\n\nfunction compareIsoStrings(a: string | undefined, b: string | undefined): number {\n  // Items without the timestamp sort last regardless of direction so the\n  // direction flip only affects rows that actually have the field.\n  if (a === undefined && b === undefined) return 0;\n  if (a === undefined) return 1;\n  if (b === undefined) return -1;\n  if (a < b) return -1;\n  if (a > b) return 1;\n  return 0;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/sort-items.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/flatten-tree.ts",
      "content": "import type { TaskItem } from \"../../task-card/types\";\nimport type { TaskTreeVisibleRow } from \"../types\";\n\n/**\n * Renderer-ready row variant. The optional `dimmed` flag is set by the\n * filter-fade path; the default renderer applies opacity-30 when true.\n */\nexport interface FlattenedRow extends TaskTreeVisibleRow {\n  dimmed?: boolean;\n}\n\nexport interface FlattenOptions {\n  collapsedIds: ReadonlySet<string>;\n  /** \"fade\" emits all rows with dimmed flag; \"hide\" omits non-matching + non-ancestor rows. */\n  mode: \"fade\" | \"hide\";\n  /** When mode === \"hide\" OR mode === \"fade\", drives the dim / hide decision. */\n  matchMap?: ReadonlyMap<string, boolean>;\n  /** When mode === \"hide\", ancestors-of-match still render. */\n  ancestorOfMatch?: ReadonlySet<string>;\n}\n\n/**\n * DFS flatten respecting `collapsedIds`. Emits one row per visible item.\n *\n * When a row is collapsed, its descendants are skipped entirely (no rows\n * emitted for them) regardless of mode.\n *\n * Mode \"fade\":\n *   - every non-collapsed row emits\n *   - matchMap === false ⇒ row.dimmed = true\n *\n * Mode \"hide\":\n *   - rows where matchMap === true emit normally\n *   - rows in ancestorOfMatch emit normally (so context survives)\n *   - everything else is skipped\n *\n * When no matchMap is supplied (filter inactive), every non-collapsed row\n * emits without the dimmed flag in either mode.\n */\nexport function flattenTree(\n  items: ReadonlyArray<TaskItem>,\n  options: FlattenOptions,\n): FlattenedRow[] {\n  const out: FlattenedRow[] = [];\n  walk(items, 0, null, options, out);\n  return out;\n}\n\nfunction walk(\n  items: ReadonlyArray<TaskItem>,\n  level: number,\n  parentId: string | null,\n  options: FlattenOptions,\n  out: FlattenedRow[],\n): void {\n  const { collapsedIds, mode, matchMap, ancestorOfMatch } = options;\n  for (let i = 0; i < items.length; i++) {\n    const item = items[i];\n    const matches = matchMap ? matchMap.get(item.id) === true : true;\n    const isAncestor = ancestorOfMatch ? ancestorOfMatch.has(item.id) : false;\n\n    let emit = true;\n    let dimmed = false;\n\n    if (matchMap) {\n      if (mode === \"hide\") {\n        emit = matches || isAncestor;\n      } else {\n        // mode === \"fade\"\n        emit = true;\n        dimmed = !matches;\n      }\n    }\n\n    if (emit) {\n      out.push(\n        dimmed\n          ? { item, level, parentId, index: i, dimmed: true }\n          : { item, level, parentId, index: i },\n      );\n    }\n\n    const hasChildren = item.children && item.children.length > 0;\n    const collapsed = collapsedIds.has(item.id);\n    if (hasChildren && !collapsed) {\n      walk(item.children!, level + 1, item.id, options, out);\n    }\n  }\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/flatten-tree.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/visible-items.ts",
      "content": "import type { TaskItem } from \"../../task-card/types\";\nimport type { TaskTreeFilter, TaskTreeSort } from \"../types\";\nimport {\n  buildAncestorOfMatchSet,\n  buildMatchMap,\n  isFilterActive,\n} from \"./filter-items\";\nimport { applySort } from \"./sort-items\";\nimport { flattenTree, type FlattenedRow } from \"./flatten-tree\";\n\nexport interface VisibleItemsInput {\n  items: ReadonlyArray<TaskItem>;\n  query: string;\n  sort: TaskTreeSort;\n  filter: TaskTreeFilter;\n  collapsedIds: ReadonlySet<string>;\n  /** Default \"fade\" per description doc; \"hide\" omits non-matching + non-ancestor rows. */\n  filterMode: \"fade\" | \"hide\";\n}\n\n/**\n * Top-level pipeline orchestrator: items → filter → sort → flatten →\n * TaskTreeVisibleRow[] (with optional `dimmed` flag in fade mode).\n *\n * Order locked at Q-P5 (filter then sort then flatten). Memoize-friendly:\n * caller passes stable refs and the pipeline returns a new array only when\n * inputs actually change.\n */\nexport function computeVisibleItems(input: VisibleItemsInput): FlattenedRow[] {\n  const { items, query, sort, filter, collapsedIds, filterMode } = input;\n  const filterOn = isFilterActive(filter, query);\n  const matchMap = filterOn ? buildMatchMap(items, filter, query) : undefined;\n  const ancestorSet =\n    filterOn && filterMode === \"hide\" && matchMap\n      ? buildAncestorOfMatchSet(items, matchMap)\n      : undefined;\n  const sorted = applySort(items, sort);\n  return flattenTree(sorted, {\n    collapsedIds,\n    mode: filterMode,\n    matchMap,\n    ancestorOfMatch: ancestorSet,\n  });\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/visible-items.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/circular-drop.ts",
      "content": "import type { TaskItem } from \"../../task-card/types\";\nimport { findItemById } from \"./tree-walker\";\n\n/**\n * True when `targetId` lies within the subtree rooted at `sourceId` —\n * i.e., reparenting `sourceId` under `targetId` would form a cycle.\n * Also true when source === target (item dropped into itself).\n */\nexport function isAncestor(\n  items: ReadonlyArray<TaskItem>,\n  sourceId: string,\n  targetId: string,\n): boolean {\n  if (sourceId === targetId) return true;\n  const source = findItemById(items, sourceId);\n  if (!source || !source.children || source.children.length === 0) return false;\n  return findItemById(source.children, targetId) !== undefined;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/circular-drop.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/dnd-payload.ts",
      "content": "import type { TaskItem } from \"../../task-card/types\";\n\n/**\n * Cross-procomp MIME — identical to task-card's TASK_CLIPBOARD_MIME so\n * payloads round-trip between tree rows, rich-cards, and the clipboard.\n */\nexport const TASK_TREE_MIME = \"application/x-ilinxa-task+json\" as const;\n\nexport function serializeForDataTransfer(item: TaskItem): string {\n  return JSON.stringify(item);\n}\n\n/**\n * Parse a payload off a DataTransfer object. Returns null when the payload is\n * missing, malformed, or doesn't look like a TaskItem. Callers decide whether\n * to fall back to text/plain (which carries the same JSON body in our setter).\n */\nexport function parseFromDataTransfer(dt: DataTransfer): TaskItem | null {\n  const raw =\n    dt.getData(TASK_TREE_MIME) || dt.getData(\"text/plain\") || \"\";\n  if (!raw) return null;\n  try {\n    const parsed = JSON.parse(raw) as unknown;\n    if (!isTaskItemShape(parsed)) return null;\n    return parsed as TaskItem;\n  } catch {\n    return null;\n  }\n}\n\nfunction isTaskItemShape(value: unknown): boolean {\n  if (typeof value !== \"object\" || value === null) return false;\n  const v = value as Record<string, unknown>;\n  return (\n    typeof v.id === \"string\" &&\n    typeof v.name === \"string\" &&\n    typeof v.status === \"string\" &&\n    typeof v.active === \"boolean\" &&\n    typeof v.setAt === \"string\"\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/dnd-payload.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/permissions.ts",
      "content": "import type {\n  TaskItem,\n  TaskPermissionRule,\n  TaskPermissions,\n} from \"../../task-card/types\";\nimport type { TaskTreePermissionAction } from \"../types\";\n\n/**\n * Map tree-specific actions onto task-card's TaskPermissionRule keys.\n * The TaskPermissions prop type comes from task-card to keep the matrix\n * shape consistent across sibling procomps, so the two tree-only actions\n * piggy-back on the most-natural existing rule key:\n *   - dropAsSibling  → \"drag\"        (the source item must be draggable)\n *   - dropIntoChildren → \"addChildren\" (the target must accept new children)\n */\nconst ACTION_TO_RULE_KEY: Record<\n  TaskTreePermissionAction,\n  keyof TaskPermissionRule\n> = {\n  edit: \"edit\",\n  toggleActive: \"toggleActive\",\n  drag: \"drag\",\n  dropAsSibling: \"drag\",\n  dropIntoChildren: \"addChildren\",\n  remove: \"remove\",\n};\n\n/**\n * Evaluate the effective allow/deny for a single action against an item at\n * `level`. Hard locks (`item.locked === true`) deny everything. The lookup\n * order is byItem → byLevel → default, then a true fallback when nothing\n * matches.\n *\n * `permissions.inherit` is honored by ascending the chain on undefined keys;\n * when inherit is false the first match wins outright (no fallthrough).\n */\nexport function evalPermission(\n  permissions: TaskPermissions | undefined,\n  action: TaskTreePermissionAction,\n  item: TaskItem,\n  level: number,\n): boolean {\n  if (item.locked) return false;\n  if (!permissions) return true;\n\n  const ruleKey = ACTION_TO_RULE_KEY[action];\n  const inherit = permissions.inherit !== false;\n\n  const byItemValue = permissions.byItem?.[item.id]?.[ruleKey];\n  if (byItemValue !== undefined) return byItemValue;\n  if (!inherit && permissions.byItem?.[item.id]) return true;\n\n  const byLevelValue = permissions.byLevel?.[level]?.[ruleKey];\n  if (byLevelValue !== undefined) return byLevelValue;\n  if (!inherit && permissions.byLevel?.[level]) return true;\n\n  const defaultValue = permissions.default?.[ruleKey];\n  if (defaultValue !== undefined) return defaultValue;\n\n  return true;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/permissions.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/edge-zone.ts",
      "content": "/**\n * Compute the drop zone for a pointer over a row's bounding box.\n *\n * Top / bottom 8px → sibling drop indicator (reorder).\n * Middle → reparent drop indicator (drop into children).\n *\n * Cap from description doc L6: top + bottom bands are capped at 8px so the\n * \"middle\" zone always wins for typical 52px rows, even at coarse coordinates.\n */\n\nexport type EdgeZone = \"top\" | \"middle\" | \"bottom\";\n\nexport const EDGE_BAND_PX = 8;\n\nexport function computeEdgeZone(\n  pointerY: number,\n  rowTop: number,\n  rowHeight: number,\n): EdgeZone {\n  const relative = pointerY - rowTop;\n  if (relative < EDGE_BAND_PX) return \"top\";\n  if (relative > rowHeight - EDGE_BAND_PX) return \"bottom\";\n  return \"middle\";\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/edge-zone.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/shallow.ts",
      "content": "/**\n * Shallow-equality helpers used by the controlled-mode structural resync guard\n * (lands in C3 `use-controlled-mode.ts`). Kept here as a sealed-folder utility\n * to avoid leaking a third-party shallow dep to consumers.\n */\n\nexport function shallowEqualArray<T>(\n  a: ReadonlyArray<T>,\n  b: ReadonlyArray<T>,\n): boolean {\n  if (a === b) return true;\n  if (a.length !== b.length) return false;\n  for (let i = 0; i < a.length; i++) {\n    if (a[i] !== b[i]) return false;\n  }\n  return true;\n}\n\nexport function shallowEqualSet<T>(\n  a: ReadonlySet<T>,\n  b: ReadonlySet<T>,\n): boolean {\n  if (a === b) return true;\n  if (a.size !== b.size) return false;\n  for (const v of a) {\n    if (!b.has(v)) return false;\n  }\n  return true;\n}\n\nexport function shallowEqualObject<T extends Record<string, unknown>>(\n  a: T,\n  b: T,\n): boolean {\n  if (a === b) return true;\n  const ak = Object.keys(a);\n  const bk = Object.keys(b);\n  if (ak.length !== bk.length) return false;\n  for (const k of ak) {\n    if (a[k] !== b[k]) return false;\n  }\n  return true;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/shallow.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/lib/default-status-options.ts",
      "content": "import type { TaskStatusOption } from \"../../task-card/types\";\n\n/**\n * Fallback status enum when the consumer passes no `statusOptions` prop.\n * Mirrors the demo set in dummy-data.ts so out-of-the-box rendering is\n * predictable for both demos and ad-hoc usage.\n */\nexport const DEFAULT_STATUS_OPTIONS: ReadonlyArray<TaskStatusOption> = [\n  { value: \"todo\", label: \"To do\", variant: \"outline\" },\n  { value: \"in-progress\", label: \"In progress\", variant: \"secondary\" },\n  { value: \"done\", label: \"Done\", variant: \"default\" },\n  { value: \"blocked\", label: \"Blocked\", variant: \"destructive\" },\n];\n",
      "type": "registry:component",
      "target": "components/task-tree/lib/default-status-options.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-task-tree-state.ts",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useReducer,\n  useRef,\n} from \"react\";\nimport type { TaskItem, TaskPermissions } from \"../../task-card/types\";\nimport type {\n  TaskTreeChangeArgs,\n  TaskTreeFilter,\n  TaskTreeHandle,\n  TaskTreeSort,\n  TaskTreeStateValue,\n  TaskTreeVisibleRow,\n} from \"../types\";\nimport {\n  createInitialState,\n  reducer,\n  type State,\n} from \"../lib/reducer\";\nimport { computeVisibleItems } from \"../lib/visible-items\";\nimport { evalPermission } from \"../lib/permissions\";\nimport {\n  findItemById,\n  findItemWithLevel,\n  pruneNestedIds,\n} from \"../lib/tree-walker\";\nimport {\n  copyTasksToClipboard,\n  readTasksFromClipboard,\n  reassignTaskIds,\n} from \"../../task-card/lib/clipboard\";\nimport { useControlledMode } from \"./use-controlled-mode\";\nimport { useTreeEvents, type TreeEventCallbacks } from \"./use-tree-events\";\nimport { useSelection } from \"./use-selection\";\n\nexport interface UseTaskTreeStateArgs extends TreeEventCallbacks {\n  defaultValue?: TaskItem[];\n  value?: TaskItem[];\n  onChange?: (args: TaskTreeChangeArgs) => void;\n  defaultCollapsedIds?: ReadonlyArray<string>;\n  defaultSelectedIds?: ReadonlyArray<string>;\n  defaultSort?: TaskTreeSort;\n  defaultFilter?: TaskTreeFilter;\n  /** Default \"fade\"; \"hide\" omits non-matching + non-ancestor-of-match rows. */\n  filterMode?: \"fade\" | \"hide\";\n  /**\n   * Optional drag-flag ref. Forwarded to useControlledMode so defense 3\n   * (suppress mid-drag onChange) reads from the same ref the host's DnD\n   * hooks mutate. When omitted, controlled-mode allocates its own inert ref.\n   */\n  isDraggingRef?: React.MutableRefObject<boolean>;\n  /**\n   * Declarative permission matrix (defaults to allow-all when omitted).\n   * Gates the imperative clipboard ops — `cutItems` on the remove rule +\n   * `locked`, `pasteItems` on the target's addChildren rule — mirroring the\n   * keyboard / DnD paths. Denials fire `onPermissionDenied`.\n   */\n  permissions?: TaskPermissions;\n}\n\n/**\n * Headless state hook. Owns the reducer + visible-item pipeline + event\n * dispatch + controlled-mode wiring. Returns a TaskTreeStateValue that lifts\n * cleanly into custom toolbars / virtualization layers / external state\n * managers, OR can be fed back into `<TaskTree state={state} />` so the\n * default tree shell drives the same engine.\n *\n * Visibility-aware ops (select-all, range-select via the handle) are\n * resolved here against the current visibleItems snapshot using the\n * consumer's filterMode, then dispatched as SELECT_REPLACE.\n */\nexport function useTaskTreeState(\n  args: UseTaskTreeStateArgs,\n): TaskTreeStateValue {\n  const {\n    defaultValue,\n    value,\n    onChange,\n    defaultCollapsedIds,\n    defaultSelectedIds,\n    defaultSort,\n    defaultFilter,\n    filterMode = \"fade\",\n    isDraggingRef,\n    permissions,\n    ...eventCallbacks\n  } = args;\n\n  // Initializer fires once at mount; controlled `value` wins over defaultValue.\n  type InitArg = {\n    initial: TaskItem[];\n    collapsed: ReadonlyArray<string> | undefined;\n    selected: ReadonlyArray<string> | undefined;\n    sort: TaskTreeSort | undefined;\n    filter: TaskTreeFilter | undefined;\n  };\n  const initArg: InitArg = {\n    initial: value ?? defaultValue ?? [],\n    collapsed: defaultCollapsedIds,\n    selected: defaultSelectedIds,\n    sort: defaultSort,\n    filter: defaultFilter,\n  };\n\n  const [state, dispatch] = useReducer(\n    reducer,\n    initArg,\n    (init: InitArg): State =>\n      createInitialState({\n        items: init.initial,\n        defaultCollapsedIds: init.collapsed,\n        defaultSelectedIds: init.selected,\n        defaultSort: init.sort,\n        defaultFilter: init.filter,\n      }),\n  );\n\n  const visibleItems = useMemo<ReadonlyArray<TaskTreeVisibleRow>>(\n    () =>\n      computeVisibleItems({\n        items: state.items,\n        query: state.query,\n        sort: state.sort,\n        filter: state.filter,\n        collapsedIds: state.collapsedIds,\n        filterMode,\n      }),\n    [\n      state.items,\n      state.query,\n      state.sort,\n      state.filter,\n      state.collapsedIds,\n      filterMode,\n    ],\n  );\n\n  // Skip-onChange flag flipped before applyExternalItems so the resulting\n  // SET_ITEMS dispatch doesn't echo the value-prop change back to the consumer.\n  const skipNextOnChangeRef = useRef(false);\n\n  const applyExternalItems = useCallback(\n    (next: TaskItem[]) => {\n      skipNextOnChangeRef.current = true;\n      dispatch({ type: \"SET_ITEMS\", items: next, reason: \"imperative-set\" });\n    },\n    [dispatch],\n  );\n\n  const { fireOnChange } = useControlledMode({\n    value,\n    internalItems: state.items,\n    onChange,\n    applyExternalItems,\n    isDraggingRef,\n  });\n\n  const { fire } = useTreeEvents(eventCallbacks);\n  const fireRef = useRef(fire);\n  useEffect(() => {\n    fireRef.current = fire;\n  }, [fire]);\n\n  // Track previous state to wire selection / search / sort / filter events.\n  const prevSelectedIdsRef = useRef(state.selectedIds);\n  const prevQueryRef = useRef(state.query);\n  const prevSortRef = useRef(state.sort);\n  const prevFilterRef = useRef(state.filter);\n  const prevItemsRef = useRef(state.items);\n  const isFirstRunRef = useRef(true);\n\n  useEffect(() => {\n    if (isFirstRunRef.current) {\n      isFirstRunRef.current = false;\n      prevSelectedIdsRef.current = state.selectedIds;\n      prevQueryRef.current = state.query;\n      prevSortRef.current = state.sort;\n      prevFilterRef.current = state.filter;\n      prevItemsRef.current = state.items;\n      return;\n    }\n    if (state.selectedIds !== prevSelectedIdsRef.current) {\n      prevSelectedIdsRef.current = state.selectedIds;\n      fireRef.current(\"selectionChanged\", { selectedIds: state.selectedIds });\n    }\n    if (state.query !== prevQueryRef.current) {\n      prevQueryRef.current = state.query;\n      fireRef.current(\"searchChanged\", { query: state.query });\n    }\n    if (state.sort !== prevSortRef.current) {\n      prevSortRef.current = state.sort;\n      fireRef.current(\"sortChanged\", { sort: state.sort });\n    }\n    if (state.filter !== prevFilterRef.current) {\n      prevFilterRef.current = state.filter;\n      fireRef.current(\"filterChanged\", { filter: state.filter });\n    }\n    if (state.items !== prevItemsRef.current) {\n      prevItemsRef.current = state.items;\n      const skip = skipNextOnChangeRef.current;\n      const reason = state.lastChangeReason ?? \"imperative-set\";\n      skipNextOnChangeRef.current = false;\n      // Controlled-mode echo: items came from the value prop's sync, so the\n      // consumer already knows. Don't fire onChange back at them.\n      if (skip) return;\n      fireOnChange(state.items, reason);\n    }\n    // `state.lastChangeReason` is intentionally omitted — it changes lockstep\n    // with `state.items` and is read only in the items branch. Adding it as\n    // a dep would fire onChange a second time when the reducer cleared the\n    // reason without items having changed.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [\n    state.selectedIds,\n    state.query,\n    state.sort,\n    state.filter,\n    state.items,\n    fireOnChange,\n  ]);\n\n  const selection = useSelection({\n    visibleItems,\n    selectionAnchorId: state.selectionAnchorId,\n    selectedIds: state.selectedIds,\n    dispatch,\n    fire,\n  });\n\n  // Build the imperative handle (26 base methods + 3 clipboard ops).\n  const handle = useMemo<TaskTreeHandle>(() => {\n    // Resolve the operand id set: explicit ids → current selection → focused row.\n    const resolveTargetIds = (ids?: ReadonlyArray<string>): string[] => {\n      if (ids) return [...ids];\n      if (state.selectedIds.size > 0) return [...state.selectedIds];\n      return state.focusedItemId ? [state.focusedItemId] : [];\n    };\n    const collectItems = (ids: ReadonlyArray<string>): TaskItem[] =>\n      ids\n        .map((id) => findItemById(state.items, id))\n        .filter((it): it is TaskItem => !!it);\n    return {\n      getValue: () => state.items,\n      setValue: (next) => {\n        dispatch({ type: \"SET_ITEMS\", items: next, reason: \"imperative-set\" });\n      },\n      addItem: (item, opts) => {\n        const parentId = opts?.parentId ?? null;\n        dispatch({\n          type: \"ADD_ITEM\",\n          item,\n          parentId,\n          index: opts?.index,\n          via: \"imperative\",\n        });\n        fireRef.current(\"itemAdded\", {\n          item,\n          parentId,\n          index: opts?.index ?? -1,\n          via: \"imperative\",\n        });\n      },\n      removeItem: (id) => {\n        const item = findItemById(state.items, id);\n        if (!item) return;\n        dispatch({ type: \"REMOVE_ITEM\", id, via: \"imperative\" });\n        fireRef.current(\"itemRemoved\", { item, via: \"imperative\" });\n      },\n      addChild: (parentId, item, index) => {\n        dispatch({ type: \"ADD_CHILD\", parentId, item, index });\n        fireRef.current(\"itemAdded\", {\n          item,\n          parentId,\n          index: index ?? -1,\n          via: \"imperative\",\n        });\n      },\n      removeItems: (ids) => {\n        if (ids.length === 0) return;\n        dispatch({ type: \"REMOVE_ITEMS\", ids });\n        fireRef.current(\"bulkRemove\", { ids });\n      },\n      toggleActive: (id, nextActive) => {\n        const item = findItemById(state.items, id);\n        if (!item) return;\n        dispatch({ type: \"TOGGLE_ACTIVE\", id, nextActive });\n        fireRef.current(\"activeToggled\", { item, nextActive });\n      },\n      toggleActiveBulk: (ids, nextActive) => {\n        if (ids.length === 0) return;\n        dispatch({ type: \"TOGGLE_ACTIVE_BULK\", ids, nextActive });\n        fireRef.current(\"bulkToggleActive\", { ids, nextActive });\n      },\n      focusItem: (id) => {\n        dispatch({ type: \"SET_FOCUS\", id });\n      },\n      getItemById: (id) => findItemById(state.items, id),\n      expandItem: (id) => {\n        if (!state.collapsedIds.has(id)) return;\n        dispatch({ type: \"TOGGLE_COLLAPSE\", id });\n        const item = findItemById(state.items, id);\n        if (item) {\n          fireRef.current(\"collapseToggled\", { item, collapsed: false });\n        }\n      },\n      collapseItem: (id) => {\n        if (state.collapsedIds.has(id)) return;\n        dispatch({ type: \"TOGGLE_COLLAPSE\", id });\n        const item = findItemById(state.items, id);\n        if (item) {\n          fireRef.current(\"collapseToggled\", { item, collapsed: true });\n        }\n      },\n      toggleCollapse: (id) => {\n        const wasCollapsed = state.collapsedIds.has(id);\n        dispatch({ type: \"TOGGLE_COLLAPSE\", id });\n        const item = findItemById(state.items, id);\n        if (item) {\n          fireRef.current(\"collapseToggled\", {\n            item,\n            collapsed: !wasCollapsed,\n          });\n        }\n      },\n      expandAll: () => {\n        dispatch({ type: \"EXPAND_ALL\" });\n      },\n      collapseAll: () => {\n        dispatch({ type: \"COLLAPSE_ALL\" });\n      },\n      isCollapsed: (id) => state.collapsedIds.has(id),\n      selectItem: (id) => {\n        // Idempotent ADD to selection. Toggle-mode would deselect when id is\n        // already selected — wrong semantics for an explicit \"select\".\n        if (state.selectedIds.has(id)) return;\n        const next: string[] = [];\n        for (const v of state.selectedIds) next.push(v);\n        next.push(id);\n        dispatch({ type: \"SELECT_REPLACE\", ids: next, anchorId: id });\n      },\n      deselectItem: (id) => {\n        if (!state.selectedIds.has(id)) return;\n        const next: string[] = [];\n        for (const v of state.selectedIds) if (v !== id) next.push(v);\n        dispatch({ type: \"SELECT_REPLACE\", ids: next });\n      },\n      selectRange: selection.selectRange,\n      selectAll: selection.selectAllVisible,\n      clearSelection: () => {\n        dispatch({ type: \"CLEAR_SELECTION\" });\n      },\n      getSelectedIds: () => state.selectedIds,\n      setQuery: (query) => {\n        dispatch({ type: \"SET_QUERY\", query });\n      },\n      setSort: (sort) => {\n        dispatch({ type: \"SET_SORT\", sort });\n      },\n      setFilter: (filter) => {\n        dispatch({ type: \"SET_FILTER\", filter });\n      },\n      clearAllFilters: () => {\n        dispatch({ type: \"CLEAR_FILTERS\" });\n      },\n      copyItems: (ids) => {\n        // Prune ids whose ancestor is also targeted — the ancestor's payload\n        // already carries the subtree (a duplicate would re-paste it twice).\n        const items = collectItems(\n          pruneNestedIds(state.items, resolveTargetIds(ids)),\n        );\n        if (items.length === 0) return;\n        copyTasksToClipboard(items, \"task-tree\").catch(() => {\n          // Clipboard unavailable / write denied — nothing to roll back.\n          if (process.env.NODE_ENV !== \"production\") {\n            console.warn(\"[task-tree] copyItems: clipboard write failed\");\n          }\n        });\n      },\n      cutItems: (ids) => {\n        const targetIds = pruneNestedIds(state.items, resolveTargetIds(ids));\n        // Gate each id on the remove rule + `locked`, mirroring keyboard\n        // Delete: blocked ids are reported + skipped, allowed ids are cut.\n        const allowed: string[] = [];\n        for (const id of targetIds) {\n          const found = findItemWithLevel(state.items, id);\n          if (!found) continue;\n          if (evalPermission(permissions, \"remove\", found.item, found.level)) {\n            allowed.push(id);\n          } else {\n            fireRef.current(\"permissionDenied\", {\n              action: \"remove\",\n              itemId: id,\n              reason:\n                found.item.locked === true ? \"denied-by-lock\" : \"denied-by-rule\",\n            });\n          }\n        }\n        const items = collectItems(allowed);\n        if (items.length === 0) return;\n        // Await the async clipboard write; remove ONLY on fulfillment so a\n        // denied/unavailable clipboard can't destroy items with nothing copied.\n        copyTasksToClipboard(items, \"task-tree\").then(\n          () => {\n            dispatch({ type: \"REMOVE_ITEMS\", ids: allowed });\n            fireRef.current(\"bulkRemove\", { ids: allowed });\n          },\n          () => {\n            // Report, don't throw — the cut is aborted, items preserved.\n            if (process.env.NODE_ENV !== \"production\") {\n              console.warn(\n                \"[task-tree] cutItems: clipboard write failed — cut aborted, items were NOT removed\",\n              );\n            }\n          },\n        );\n      },\n      pasteItems: (parentId) => {\n        // Match the keyboard path: omitted → paste under the focused row (root if none).\n        const target = parentId ?? state.focusedItemId ?? null;\n        if (target) {\n          // Gate the graft target on its addChildren (dropIntoChildren) rule +\n          // `locked` — same rule the DnD drop path enforces (TT1 parity).\n          const found = findItemWithLevel(state.items, target);\n          if (\n            found &&\n            !evalPermission(permissions, \"dropIntoChildren\", found.item, found.level)\n          ) {\n            fireRef.current(\"permissionDenied\", {\n              action: \"dropIntoChildren\",\n              itemId: target,\n              reason:\n                found.item.locked === true ? \"denied-by-lock\" : \"denied-by-rule\",\n            });\n            return;\n          }\n        }\n        void readTasksFromClipboard().then((items) => {\n          if (!items || items.length === 0) return;\n          // Re-id every grafted subtree so a paste can't collide.\n          for (const raw of items) {\n            const fresh = reassignTaskIds(raw);\n            if (target) {\n              dispatch({ type: \"ADD_CHILD\", parentId: target, item: fresh });\n            } else {\n              dispatch({\n                type: \"ADD_ITEM\",\n                item: fresh,\n                parentId: null,\n                via: \"imperative\",\n              });\n            }\n            fireRef.current(\"itemAdded\", {\n              item: fresh,\n              parentId: target,\n              index: -1,\n              via: \"imperative\",\n            });\n          }\n        });\n      },\n    };\n  }, [\n    state.items,\n    state.collapsedIds,\n    state.selectedIds,\n    state.focusedItemId,\n    dispatch,\n    selection.selectRange,\n    selection.selectAllVisible,\n    permissions,\n  ]);\n\n  // Compose the public state value. Spread the handle so all 26 methods land\n  // on the result. State fields layer on top.\n  return useMemo<TaskTreeStateValue>(\n    () => ({\n      ...handle,\n      items: state.items,\n      visibleItems,\n      collapsedIds: state.collapsedIds,\n      selectedIds: state.selectedIds,\n      query: state.query,\n      sort: state.sort,\n      filter: state.filter,\n      focusedItemId: state.focusedItemId,\n      dispatch,\n      handleRowClick: selection.handleRowClick,\n    }),\n    [\n      handle,\n      state.items,\n      visibleItems,\n      state.collapsedIds,\n      state.selectedIds,\n      state.query,\n      state.sort,\n      state.filter,\n      state.focusedItemId,\n      dispatch,\n      selection.handleRowClick,\n    ],\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-task-tree-state.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-task-tree-context.ts",
      "content": "\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type {\n  DragEvent as ReactDragEvent,\n  MouseEvent as ReactMouseEvent,\n  ReactNode,\n} from \"react\";\nimport type { TaskItem, TaskStatusOption } from \"../../task-card/types\";\nimport type {\n  TaskTreeEmptyRenderArgs,\n  TaskTreeFieldRenderArgs,\n  TaskTreeRowRenderArgs,\n  TaskTreeStateValue,\n  TaskTreeStatusRenderArgs,\n} from \"../types\";\nimport type { EdgeZone } from \"../lib/edge-zone\";\n\n/**\n * State context — populated by the host `<TaskTree>` so parts/ can read the\n * live state value (visibleItems, selectedIds, etc.) and dispatch actions\n * without re-walking the reducer's effective state. Separate from the\n * render-config context (slot props, indentSize, statusOptions) that lives\n * alongside parts/ in C4 — keeping state pure here lets the headless\n * useTaskTreeState() consumer pass a state ref to other surfaces.\n */\nexport const TaskTreeStateContext = createContext<TaskTreeStateValue | null>(\n  null,\n);\n\n/**\n * Read the state context. Throws when called outside `<TaskTree>` because\n * parts/ relying on this context cannot function without it — a silent null\n * would mask the bug.\n */\nexport function useTaskTreeStateContext(): TaskTreeStateValue {\n  const ctx = useContext(TaskTreeStateContext);\n  if (ctx === null) {\n    throw new Error(\n      \"useTaskTreeStateContext must be called inside <TaskTree>. \" +\n        \"If composing parts/* manually, wrap them in <TaskTreeStateContext.Provider value={state}>.\",\n    );\n  }\n  return ctx;\n}\n\n/**\n * Render-config context — display config + slot overrides shared by parts/.\n * Kept separate from the state context so headless consumers can drive their\n * own render layer without inheriting the default chrome's config.\n *\n * Interactive callbacks (onToggleActive, onRowClick, etc.) are passed as\n * props from `<TaskTreeRow>` down to the leaves, NOT through this context,\n * to keep the leaves trivially testable.\n */\nexport interface TaskTreeRenderContextValue {\n  /** Default \"dot\"; \"strip\" renders a left-edge color strip; \"none\" hides. */\n  statusIndicator: \"dot\" | \"strip\" | \"none\";\n  /** Memoized lookup map; empty when the consumer passes no statusOptions. */\n  statusOptionMap: ReadonlyMap<string, TaskStatusOption>;\n  /** Pixels per nesting level. */\n  indentSize: number;\n  // Slot overrides — undefined => use the default leaf paint.\n  renderRow?: (args: TaskTreeRowRenderArgs) => ReactNode;\n  renderName?: (args: TaskTreeFieldRenderArgs) => ReactNode;\n  renderDescription?: (args: TaskTreeFieldRenderArgs) => ReactNode;\n  renderPerson?: (args: TaskTreeFieldRenderArgs) => ReactNode;\n  renderStatusIndicator?: (args: TaskTreeStatusRenderArgs) => ReactNode;\n  renderEmptyState?: (args: TaskTreeEmptyRenderArgs) => ReactNode;\n}\n\nexport const TaskTreeRenderContext =\n  createContext<TaskTreeRenderContextValue | null>(null);\n\nexport function useTaskTreeRenderContext(): TaskTreeRenderContextValue {\n  const ctx = useContext(TaskTreeRenderContext);\n  if (ctx === null) {\n    throw new Error(\n      \"useTaskTreeRenderContext must be called inside <TaskTree>. \" +\n        \"If composing parts/* manually, wrap them in <TaskTreeRenderContext.Provider value={config}>.\",\n    );\n  }\n  return ctx;\n}\n\n/**\n * DnD context — drag-time state surfaced to rows so they can decorate\n * themselves (drop indicator visual + dragging-source dimming) without\n * needing to subscribe to the @dnd-kit store directly.\n */\nexport interface TaskTreeDndContextValue {\n  activeItemId: string | null;\n  overId: string | null;\n  overZone: EdgeZone | null;\n  /** True when the over zone would form a cycle; row should suppress the indicator. */\n  overCircular: boolean;\n  /** Click handler for plain / cmd / shift row click. */\n  handleRowClick: (\n    item: TaskItem,\n    level: number,\n    event: ReactMouseEvent,\n  ) => void;\n  /** Native HTML5 drag handlers; spread onto the row's outer div. */\n  getRowHandlers: (item: TaskItem) => {\n    draggable: boolean;\n    onDragStart: (e: ReactDragEvent<HTMLDivElement>) => void;\n    onDragOver: (e: ReactDragEvent<HTMLDivElement>) => void;\n    onDrop: (e: ReactDragEvent<HTMLDivElement>) => void;\n  };\n}\n\n/**\n * `null` value indicates DnD is not mounted in this tree (e.g.,\n * `dndContext=\"external\"` is set AND the consumer's outer DndContext\n * doesn't expose its over state to us). Rows degrade gracefully — no\n * drop indicator, no row-click handler.\n */\nexport const TaskTreeDndContext =\n  createContext<TaskTreeDndContextValue | null>(null);\n\nexport function useTaskTreeDndContext(): TaskTreeDndContextValue | null {\n  return useContext(TaskTreeDndContext);\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-task-tree-context.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-controlled-mode.ts",
      "content": "import { useCallback, useEffect, useRef } from \"react\";\nimport type { TaskItem } from \"../../task-card/types\";\nimport type {\n  TaskTreeChangeArgs,\n  TaskTreeChangeReason,\n} from \"../types\";\n\n/**\n * Three-defenses controlled-mode wiring. Mirrors flow-canvas v0.2.4's\n * deferred-notify + structural resync + mid-flow suppress pattern. Without\n * all three the round-trip cycle between `value` prop and `onChange`\n * notification cascades into reducer-mid-render warnings + spurious wholesale\n * state replacement during drag.\n *\n * Defense 1 — microtask-defer consumer notify: fireOnChange queues a\n * microtask before invoking the consumer callback. Lets the host's reducer\n * commit AND React flush before the consumer's setState fires.\n *\n * Defense 2 — structural resync guard: when `value` updates, compare against\n * the current internal state by structure. If equal (round-trip echo), skip\n * the dispatch. Avoids the infinite resync cascade.\n *\n * Defense 3 — drop notifications fired during a continuous flow (drag).\n * Drag tick callbacks queue microtasks that fire AFTER the drag started,\n * leaving the snapshot stale. Bail at fire time when isDraggingRef is set.\n */\nexport interface UseControlledModeArgs {\n  value: TaskItem[] | undefined;\n  internalItems: TaskItem[];\n  onChange: ((args: TaskTreeChangeArgs) => void) | undefined;\n  applyExternalItems: (items: TaskItem[]) => void;\n  /**\n   * Optional external drag-flag ref. When provided, the host (task-tree.tsx)\n   * controls drag state from C6's DnD hooks; defense 3 reads from it at\n   * microtask-fire time. When omitted, an internal ref is used (no caller\n   * ever flips it, so drag suppression is inert — fine for pure controlled-\n   * mode users with no drag).\n   */\n  isDraggingRef?: React.MutableRefObject<boolean>;\n}\n\nexport interface UseControlledModeResult {\n  isControlled: boolean;\n  isDraggingRef: React.MutableRefObject<boolean>;\n  fireOnChange: (next: TaskItem[], reason: TaskTreeChangeReason) => void;\n}\n\nexport function useControlledMode(\n  args: UseControlledModeArgs,\n): UseControlledModeResult {\n  const { value, internalItems, onChange, applyExternalItems } = args;\n  const isControlled = value !== undefined;\n  const internalDraggingRef = useRef(false);\n  const isDraggingRef = args.isDraggingRef ?? internalDraggingRef;\n  const onChangeRef = useRef(onChange);\n\n  useEffect(() => {\n    onChangeRef.current = onChange;\n  }, [onChange]);\n\n  // Defense 1 + 3. `isDraggingRef` is intentionally omitted from the dep\n  // array — refs have stable identity and the callback reads `.current` at\n  // microtask-fire time. Including it would force fireOnChange's identity to\n  // change whenever the host swapped the ref (it never should).\n  const fireOnChange = useCallback(\n    (next: TaskItem[], reason: TaskTreeChangeReason) => {\n      const cb = onChangeRef.current;\n      if (!cb) return;\n      queueMicrotask(() => {\n        if (isDraggingRef.current) return;\n        const live = onChangeRef.current;\n        live?.({ items: next, reason });\n      });\n    },\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [],\n  );\n\n  // Defense 2 — full-field echo guard (TT2/TT11). Serialize the incoming value\n  // once and compare to the last applied snapshot + the current internal state.\n  // Replaces the partial 5-field structural walk (id/name/status/active/children\n  // only), which silently DROPPED external changes to assignee / description /\n  // dates, and removes the unbounded recursive compare on every value change.\n  const lastAppliedRef = useRef<string | null>(null);\n  useEffect(() => {\n    if (!isControlled) return;\n    if (value === internalItems) return;\n    const incoming = JSON.stringify(value);\n    if (incoming === lastAppliedRef.current) return;\n    if (incoming === JSON.stringify(internalItems)) {\n      lastAppliedRef.current = incoming;\n      return;\n    }\n    lastAppliedRef.current = incoming;\n    applyExternalItems(value!);\n    // applyExternalItems' identity is stable; `internalItems` is read for the\n    // echo compare but intentionally NOT a dep (only react to `value` changes).\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [value, isControlled]);\n\n  return { isControlled, isDraggingRef, fireOnChange };\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-controlled-mode.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-tree-events.ts",
      "content": "import { useCallback, useEffect, useRef } from \"react\";\nimport type { TaskItem } from \"../../task-card/types\";\nimport type {\n  TaskTreeAddEvent,\n  TaskTreeDropEvent,\n  TaskTreeFilter,\n  TaskTreeItemEvent,\n  TaskTreeMoveEvent,\n  TaskTreePermissionDeniedEvent,\n  TaskTreeRemoveEvent,\n  TaskTreeSort,\n} from \"../types\";\n\n/**\n * Map of event names → payload shapes. Lets `fire` be typed without resorting\n * to a giant prop-name union switch.\n */\nexport interface TreeEventMap {\n  itemMoved: TaskTreeMoveEvent;\n  itemDropped: TaskTreeDropEvent;\n  itemAdded: TaskTreeAddEvent;\n  itemRemoved: TaskTreeRemoveEvent;\n  activeToggled: { item: TaskItem; nextActive: boolean };\n  collapseToggled: { item: TaskItem; collapsed: boolean };\n  bulkToggleActive: { ids: ReadonlyArray<string>; nextActive: boolean };\n  bulkRemove: { ids: ReadonlyArray<string> };\n  bulkEdit: { ids: ReadonlyArray<string> };\n  selectionChanged: { selectedIds: ReadonlySet<string> };\n  searchChanged: { query: string };\n  sortChanged: { sort: TaskTreeSort };\n  filterChanged: { filter: TaskTreeFilter };\n  itemClick: TaskTreeItemEvent;\n  itemContextMenu: TaskTreeItemEvent;\n  permissionDenied: TaskTreePermissionDeniedEvent;\n}\n\n/**\n * Consumer callbacks. Names mirror TreeEventMap; each is `on<Name>` with the\n * first letter upper-cased. Keeping callbacks in a single bag lets us\n * ref-mirror them to one stable identity for the lifetime of the host.\n */\nexport interface TreeEventCallbacks {\n  onItemMoved?: (args: TaskTreeMoveEvent) => void;\n  onItemDropped?: (args: TaskTreeDropEvent) => void;\n  onItemAdded?: (args: TaskTreeAddEvent) => void;\n  onItemRemoved?: (args: TaskTreeRemoveEvent) => void;\n  onActiveToggled?: (args: { item: TaskItem; nextActive: boolean }) => void;\n  onCollapseToggled?: (args: { item: TaskItem; collapsed: boolean }) => void;\n  onBulkToggleActive?: (args: {\n    ids: ReadonlyArray<string>;\n    nextActive: boolean;\n  }) => void;\n  onBulkRemove?: (args: { ids: ReadonlyArray<string> }) => void;\n  onBulkEdit?: (args: { ids: ReadonlyArray<string> }) => void;\n  onSelectionChanged?: (args: { selectedIds: ReadonlySet<string> }) => void;\n  onSearchChanged?: (args: { query: string }) => void;\n  onSortChanged?: (args: { sort: TaskTreeSort }) => void;\n  onFilterChanged?: (args: { filter: TaskTreeFilter }) => void;\n  onItemClick?: (args: TaskTreeItemEvent) => void;\n  onItemContextMenu?: (args: TaskTreeItemEvent) => void;\n  onPermissionDenied?: (args: TaskTreePermissionDeniedEvent) => void;\n}\n\nconst NAME_TO_PROP: { [K in keyof TreeEventMap]: keyof TreeEventCallbacks } = {\n  itemMoved: \"onItemMoved\",\n  itemDropped: \"onItemDropped\",\n  itemAdded: \"onItemAdded\",\n  itemRemoved: \"onItemRemoved\",\n  activeToggled: \"onActiveToggled\",\n  collapseToggled: \"onCollapseToggled\",\n  bulkToggleActive: \"onBulkToggleActive\",\n  bulkRemove: \"onBulkRemove\",\n  bulkEdit: \"onBulkEdit\",\n  selectionChanged: \"onSelectionChanged\",\n  searchChanged: \"onSearchChanged\",\n  sortChanged: \"onSortChanged\",\n  filterChanged: \"onFilterChanged\",\n  itemClick: \"onItemClick\",\n  itemContextMenu: \"onItemContextMenu\",\n  permissionDenied: \"onPermissionDenied\",\n};\n\nexport interface TreeEventDispatcher {\n  fire: <K extends keyof TreeEventMap>(name: K, args: TreeEventMap[K]) => void;\n}\n\n/**\n * Stable-identity event dispatcher. Microtask-defers each invocation to avoid\n * setState-during-render — same defense pattern as flow-canvas v0.2.4's\n * fireOnChange. Callbacks are read from a ref at fire-time, so consumers can\n * pass inline event handlers without re-rendering the tree.\n *\n * `onItemClick` / `onItemContextMenu` fire SYNCHRONOUSLY (no microtask defer):\n * these are user-initiated and consumers commonly call `event.preventDefault()`\n * or navigate from within the handler. Deferring one tick would push focus /\n * scroll / navigation out of the same task as the original click event and\n * break the synchronous expectation. (React 17+ disabled synthetic-event\n * pooling, so deferring is technically safe — this is a UX choice, not a\n * correctness one.)\n */\nexport function useTreeEvents(\n  callbacks: TreeEventCallbacks,\n): TreeEventDispatcher {\n  const callbacksRef = useRef(callbacks);\n  useEffect(() => {\n    callbacksRef.current = callbacks;\n  }, [callbacks]);\n\n  const fire = useCallback(\n    <K extends keyof TreeEventMap>(name: K, args: TreeEventMap[K]) => {\n      const propName = NAME_TO_PROP[name];\n      const cb = callbacksRef.current[propName] as\n        | ((args: TreeEventMap[K]) => void)\n        | undefined;\n      if (!cb) return;\n      if (name === \"itemClick\" || name === \"itemContextMenu\") {\n        cb(args);\n        return;\n      }\n      queueMicrotask(() => {\n        // Re-read the callback so a consumer mid-flight swap still routes.\n        const live = callbacksRef.current[propName] as\n          | ((args: TreeEventMap[K]) => void)\n          | undefined;\n        live?.(args);\n      });\n    },\n    [],\n  );\n\n  return { fire };\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-tree-events.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-selection.ts",
      "content": "import { useCallback } from \"react\";\nimport type {\n  KeyboardEvent as ReactKeyboardEvent,\n  MouseEvent as ReactMouseEvent,\n} from \"react\";\nimport type { TaskItem } from \"../../task-card/types\";\nimport type { TaskTreeAction, TaskTreeVisibleRow } from \"../types\";\nimport type { TreeEventDispatcher } from \"./use-tree-events\";\n\ntype RowEvent = ReactMouseEvent | ReactKeyboardEvent;\n\nexport interface UseSelectionArgs {\n  visibleItems: ReadonlyArray<TaskTreeVisibleRow>;\n  selectionAnchorId: string | null;\n  selectedIds: ReadonlySet<string>;\n  dispatch: (action: TaskTreeAction) => void;\n  /** Event dispatcher; the click handler fires itemClick through here. */\n  fire: TreeEventDispatcher[\"fire\"];\n}\n\nexport interface UseSelectionResult {\n  /** Plain / cmd-click / shift-click handler. Visibility-aware. */\n  handleRowClick: (item: TaskItem, level: number, event: RowEvent) => void;\n  /** Imperative range — replaces selection with the visible-row sequence between idA and idB. */\n  selectRange: (idA: string, idB: string) => void;\n  /** Imperative — replaces selection with every currently-visible row id. */\n  selectAllVisible: () => void;\n}\n\n/**\n * Multi-select math. Resolves ranges + \"all\" over the live `visibleItems`\n * snapshot so the consumer's filterMode + collapse state are respected\n * (per plan §8.3 — Cmd+A \"selects all VISIBLE items only\").\n *\n * Shift-click ADDS the resolved range to existing selection (matches the\n * reducer's SELECT_ONE/range additive semantics — Shift+Cmd-click parity).\n * Imperative selectRange replaces the selection (set-selection-to-range\n * semantics for direct caller use).\n */\nexport function useSelection(args: UseSelectionArgs): UseSelectionResult {\n  const { visibleItems, selectionAnchorId, selectedIds, dispatch, fire } = args;\n\n  const handleRowClick = useCallback(\n    (item: TaskItem, level: number, event: RowEvent) => {\n      const isShift = event.shiftKey;\n      const isCmd = event.metaKey || event.ctrlKey;\n\n      if (isShift) {\n        const rangeIds = resolveRangeOverVisible(\n          visibleItems,\n          selectionAnchorId,\n          item.id,\n        );\n        if (rangeIds.length === 0) {\n          // No anchor / target not in visible — fall back to replace.\n          dispatch({ type: \"SELECT_ONE\", id: item.id, mode: \"replace\" });\n          return;\n        }\n        const merged = new Set(selectedIds);\n        for (const r of rangeIds) merged.add(r);\n        dispatch({\n          type: \"SELECT_REPLACE\",\n          ids: Array.from(merged),\n          anchorId: selectionAnchorId ?? item.id,\n        });\n        return;\n      }\n\n      if (isCmd) {\n        dispatch({ type: \"SELECT_ONE\", id: item.id, mode: \"toggle\" });\n        return;\n      }\n\n      // Plain click: replace selection AND fire itemClick. Modifier-click\n      // does not fire itemClick (matches plan §8.1).\n      dispatch({ type: \"SELECT_ONE\", id: item.id, mode: \"replace\" });\n      fire(\"itemClick\", { item, level, event });\n    },\n    [visibleItems, selectionAnchorId, selectedIds, dispatch, fire],\n  );\n\n  const selectRange = useCallback(\n    (idA: string, idB: string) => {\n      const rangeIds = resolveRangeOverVisible(visibleItems, idA, idB);\n      if (rangeIds.length === 0) return;\n      dispatch({\n        type: \"SELECT_REPLACE\",\n        ids: rangeIds,\n        anchorId: idA,\n      });\n    },\n    [visibleItems, dispatch],\n  );\n\n  const selectAllVisible = useCallback(() => {\n    const ids: string[] = [];\n    for (const row of visibleItems) ids.push(row.item.id);\n    dispatch({ type: \"SELECT_REPLACE\", ids });\n  }, [visibleItems, dispatch]);\n\n  return { handleRowClick, selectRange, selectAllVisible };\n}\n\nfunction resolveRangeOverVisible(\n  visibleItems: ReadonlyArray<TaskTreeVisibleRow>,\n  anchorId: string | null,\n  targetId: string,\n): string[] {\n  if (visibleItems.length === 0) return [];\n  const targetIdx = visibleItems.findIndex((r) => r.item.id === targetId);\n  if (targetIdx === -1) return [];\n  const anchorIdx =\n    anchorId === null\n      ? 0\n      : visibleItems.findIndex((r) => r.item.id === anchorId);\n  if (anchorIdx === -1) return [visibleItems[targetIdx].item.id];\n  const [lo, hi] =\n    anchorIdx <= targetIdx ? [anchorIdx, targetIdx] : [targetIdx, anchorIdx];\n  const out: string[] = [];\n  for (let i = lo; i <= hi; i++) out.push(visibleItems[i].item.id);\n  return out;\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-selection.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-debounced-callback.ts",
      "content": "import { useCallback, useEffect, useRef } from \"react\";\n\n/**\n * Returns a stable-identity debounced wrapper around `fn`. The wrapper\n * collapses bursts of calls into one trailing-edge invocation after `ms`\n * milliseconds of silence. Cancels any pending invocation on unmount.\n *\n * Used by the search input (200ms per Q-P4).\n */\nexport function useDebouncedCallback<TArgs extends unknown[]>(\n  fn: (...args: TArgs) => void,\n  ms: number,\n): (...args: TArgs) => void {\n  const fnRef = useRef(fn);\n  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(() => {\n    fnRef.current = fn;\n  }, [fn]);\n\n  useEffect(() => {\n    return () => {\n      if (timerRef.current !== null) clearTimeout(timerRef.current);\n    };\n  }, []);\n\n  return useCallback(\n    (...args: TArgs) => {\n      if (timerRef.current !== null) clearTimeout(timerRef.current);\n      timerRef.current = setTimeout(() => {\n        timerRef.current = null;\n        fnRef.current(...args);\n      }, ms);\n    },\n    [ms],\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-debounced-callback.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-status-option-by-value.ts",
      "content": "import { useMemo } from \"react\";\nimport type { TaskStatusOption } from \"../../task-card/types\";\n\n/**\n * Memoized lookup of a status option by `value`. Returns undefined when no\n * match is found. Used by the row's status indicator + sort dropdown labels.\n */\nexport function useStatusOptionByValue(\n  statusOptions: ReadonlyArray<TaskStatusOption> | undefined,\n  value: string,\n): TaskStatusOption | undefined {\n  return useMemo(() => {\n    if (!statusOptions || statusOptions.length === 0) return undefined;\n    return statusOptions.find((o) => o.value === value);\n  }, [statusOptions, value]);\n}\n\n/**\n * Memoized id → option map. Cheap O(N) once per statusOptions change; lookups\n * are O(1) thereafter. The hook is useful when many rows render concurrently.\n */\nexport function useStatusOptionMap(\n  statusOptions: ReadonlyArray<TaskStatusOption> | undefined,\n): ReadonlyMap<string, TaskStatusOption> {\n  return useMemo(() => {\n    const map = new Map<string, TaskStatusOption>();\n    if (!statusOptions) return map;\n    for (const o of statusOptions) map.set(o.value, o);\n    return map;\n  }, [statusOptions]);\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-status-option-by-value.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-tree-virtual.ts",
      "content": "\"use client\";\n\nimport { useVirtualizer } from \"@tanstack/react-virtual\";\nimport type { RefObject } from \"react\";\nimport type { TaskTreeVisibleRow } from \"../types\";\n\nexport type TaskTreeVirtualizeMode = \"auto\" | \"always\" | \"never\";\n\ninterface UseTreeVirtualArgs {\n  rows: ReadonlyArray<TaskTreeVisibleRow>;\n  scrollRef: RefObject<HTMLDivElement | null>;\n  mode: TaskTreeVirtualizeMode;\n  /** Threshold for \"auto\" mode — count of TOTAL rows (not visible) per R9. */\n  threshold: number;\n  /** Fixed row height. Default 52px for the 2-line layout (Q-P2). */\n  rowHeight: number;\n  /**\n   * When true, virtualization auto-suspends regardless of mode — drag is the\n   * canonical use case (R7 mitigation: virtualizer + DnD scroll snap fights).\n   */\n  suspended?: boolean;\n}\n\nexport interface UseTreeVirtualResult {\n  /** True when virtualization is active for this render. */\n  active: boolean;\n  virtualizer: ReturnType<typeof useVirtualizer<HTMLDivElement, HTMLDivElement>>;\n}\n\n/**\n * Virtualizer wrapper around `@tanstack/react-virtual`. Threshold check uses\n * TOTAL items count (not visible) per R9 so the filter doesn't defeat the\n * optimization — a filter that hides 90% of rows on a 1000-item tree still\n * benefits from virtualization on the unfiltered sibling.\n */\nexport function useTreeVirtual(args: UseTreeVirtualArgs): UseTreeVirtualResult {\n  const { rows, scrollRef, mode, threshold, rowHeight, suspended } = args;\n\n  const active =\n    !suspended &&\n    (mode === \"always\" || (mode === \"auto\" && rows.length >= threshold));\n\n  const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({\n    count: rows.length,\n    getScrollElement: () => scrollRef.current,\n    estimateSize: () => rowHeight,\n    overscan: 8,\n    enabled: active,\n  });\n\n  return { active, virtualizer };\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-tree-virtual.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-tree-dnd-internal.ts",
      "content": "\"use client\";\n\nimport {\n  KeyboardSensor,\n  MouseSensor,\n  TouchSensor,\n  useSensor,\n  useSensors,\n  type DragCancelEvent,\n  type DragEndEvent,\n  type DragOverEvent,\n  type DragStartEvent,\n} from \"@dnd-kit/core\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { TaskItem } from \"../../task-card/types\";\nimport type {\n  TaskTreeAction,\n  TaskTreePermissionDeniedEvent,\n  TreeLocation,\n} from \"../types\";\nimport { findItemById, findParentId } from \"../lib/tree-walker\";\nimport { isAncestor } from \"../lib/circular-drop\";\nimport { computeEdgeZone, type EdgeZone } from \"../lib/edge-zone\";\n\nexport interface UseTreeDndInternalArgs {\n  items: TaskItem[];\n  dispatch: (action: TaskTreeAction) => void;\n  fireMoved: (args: {\n    item: TaskItem;\n    from: TreeLocation;\n    to: TreeLocation;\n    via: \"drag\" | \"imperative\";\n  }) => void;\n  firePermissionDenied?: (args: TaskTreePermissionDeniedEvent) => void;\n  isDraggingRef: React.MutableRefObject<boolean>;\n  /** Mutual-exclusion gate; set by the grip's onDragStart, cleared on end. */\n  isInternalDragRef: React.MutableRefObject<boolean>;\n  /** Permission predicate for reparent target. Default allow. */\n  canDropIntoChildren?: (targetId: string) => boolean;\n  /** Permission predicate for sibling adjacency. Default allow. */\n  canDropAsSibling?: (targetId: string) => boolean;\n}\n\nexport interface TreeDndOverInfo {\n  overId: string;\n  zone: EdgeZone;\n  /** True when the current pointer over the row would form a cycle. */\n  circular: boolean;\n}\n\nexport interface UseTreeDndInternalResult {\n  sensors: ReturnType<typeof useSensors>;\n  activeItem: TaskItem | null;\n  activeLevel: number;\n  over: TreeDndOverInfo | null;\n  onDragStart: (event: DragStartEvent) => void;\n  onDragOver: (event: DragOverEvent) => void;\n  onDragEnd: (event: DragEndEvent) => void;\n  onDragCancel: (event: DragCancelEvent) => void;\n}\n\n/**\n * @dnd-kit drag wiring for internal (within-tree) drags. The host mounts a\n * `<DndContext>` with these handlers + sensors; rows register as droppables\n * with id = item.id. Edge zone is computed from a globally tracked pointer\n * Y + the over rect's bounding box (the dragOver event doesn't carry the\n * current pointer, only the active's delta).\n *\n * Sensor split (Mouse + Touch separate, not unified Pointer) per L17 +\n * plan §6.1: mouse activates at 5px, touch needs 300ms long-press. Unified\n * PointerSensor can't deliver divergent activation timings.\n */\nexport function useTreeDndInternal(\n  args: UseTreeDndInternalArgs,\n): UseTreeDndInternalResult {\n  const {\n    items,\n    dispatch,\n    fireMoved,\n    firePermissionDenied,\n    isDraggingRef,\n    isInternalDragRef,\n    canDropIntoChildren,\n    canDropAsSibling,\n  } = args;\n\n  const sensors = useSensors(\n    useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),\n    useSensor(TouchSensor, {\n      activationConstraint: { delay: 300, tolerance: 5 },\n    }),\n    useSensor(KeyboardSensor),\n  );\n\n  const [activeItem, setActiveItem] = useState<TaskItem | null>(null);\n  const [activeLevel, setActiveLevel] = useState<number>(0);\n  const [over, setOver] = useState<TreeDndOverInfo | null>(null);\n\n  // Pointer tracker — @dnd-kit's onDragOver provides over.rect but not the\n  // current pointer; install a global mousemove/touchmove listener while drag\n  // is active to read the pointer position (viewport coords).\n  const pointerYRef = useRef<number>(0);\n  const pointerXRef = useRef<number>(0);\n\n  // Keep activeItem + items reachable from the scroll/wheel listener without\n  // re-subscribing on every change. Synced in an effect (refs must not be\n  // written during render).\n  const activeItemRef = useRef<TaskItem | null>(null);\n  const itemsRef = useRef<TaskItem[]>(items);\n  useEffect(() => {\n    activeItemRef.current = activeItem;\n    itemsRef.current = items;\n  });\n\n  // Recompute the over-target + zone from a pointer position, using\n  // getBoundingClientRect (viewport coords, consistent with clientY). Shared by\n  // the scroll/wheel handler so the indicator stays correct when the list\n  // scrolls under a stationary pointer (TT7).\n  const applyOverAt = useCallback((clientX: number, clientY: number) => {\n    const active = activeItemRef.current;\n    if (!active) return;\n    const el = document.elementFromPoint(clientX, clientY);\n    const rowEl = el?.closest<HTMLElement>(\"[data-task-tree-row]\");\n    const targetId = rowEl?.getAttribute(\"data-task-tree-row\");\n    if (!rowEl || !targetId || targetId === active.id) {\n      setOver(null);\n      return;\n    }\n    const rect = rowEl.getBoundingClientRect();\n    const zone = computeEdgeZone(clientY, rect.top, rect.height);\n    const circular =\n      zone === \"middle\" && isAncestor(itemsRef.current, active.id, targetId);\n    setOver({ overId: targetId, zone, circular });\n  }, []);\n\n  useEffect(() => {\n    if (!activeItem) return;\n    const onMove = (e: MouseEvent | TouchEvent) => {\n      if (\"clientY\" in e) {\n        pointerYRef.current = e.clientY;\n        pointerXRef.current = e.clientX;\n      } else if (e.touches && e.touches.length > 0) {\n        pointerYRef.current = e.touches[0].clientY;\n        pointerXRef.current = e.touches[0].clientX;\n      }\n    };\n    // On scroll/wheel without pointer movement, a different row sits under the\n    // stationary pointer — recompute from the cached pointer so the zone +\n    // indicator don't go stale (TT7).\n    const onScroll = () => applyOverAt(pointerXRef.current, pointerYRef.current);\n    window.addEventListener(\"mousemove\", onMove);\n    window.addEventListener(\"touchmove\", onMove, { passive: true });\n    window.addEventListener(\"scroll\", onScroll, { capture: true, passive: true });\n    window.addEventListener(\"wheel\", onScroll, { passive: true });\n    return () => {\n      window.removeEventListener(\"mousemove\", onMove);\n      window.removeEventListener(\"touchmove\", onMove);\n      window.removeEventListener(\"scroll\", onScroll, { capture: true });\n      window.removeEventListener(\"wheel\", onScroll);\n    };\n  }, [activeItem, applyOverAt]);\n\n  const onDragStart = useCallback(\n    (event: DragStartEvent) => {\n      const id = String(event.active.id);\n      const item = findItemById(items, id);\n      if (!item) return;\n      const level =\n        typeof event.active.data.current?.level === \"number\"\n          ? event.active.data.current.level\n          : 0;\n      setActiveItem(item);\n      setActiveLevel(level);\n      isDraggingRef.current = true;\n      isInternalDragRef.current = true;\n    },\n    [items, isDraggingRef, isInternalDragRef],\n  );\n\n  const onDragOver = useCallback(\n    (event: DragOverEvent) => {\n      const overObj = event.over;\n      if (!overObj || !activeItem) {\n        setOver(null);\n        return;\n      }\n      const targetId = String(overObj.id);\n      if (targetId === activeItem.id) {\n        setOver(null);\n        return;\n      }\n      const rect = overObj.rect;\n      const zone = computeEdgeZone(\n        pointerYRef.current,\n        rect.top,\n        rect.height,\n      );\n      const circular =\n        zone === \"middle\" && isAncestor(items, activeItem.id, targetId);\n      setOver({ overId: targetId, zone, circular });\n    },\n    [activeItem, items],\n  );\n\n  const finishDrag = useCallback(() => {\n    setActiveItem(null);\n    setOver(null);\n    isDraggingRef.current = false;\n    isInternalDragRef.current = false;\n  }, [isDraggingRef, isInternalDragRef]);\n\n  const onDragEnd = useCallback(\n    (event: DragEndEvent) => {\n      const src = activeItem;\n      const overInfo = over;\n      try {\n        if (!src || !overInfo) return;\n        const targetId = overInfo.overId;\n        if (targetId === src.id) return;\n\n        // Circular ban for reparent.\n        if (overInfo.zone === \"middle\" && overInfo.circular) {\n          firePermissionDenied?.({\n            action: \"dropIntoChildren\",\n            itemId: targetId,\n            reason: \"circular-drop\",\n          });\n          return;\n        }\n\n        // Permission gates.\n        if (\n          overInfo.zone === \"middle\" &&\n          canDropIntoChildren &&\n          !canDropIntoChildren(targetId)\n        ) {\n          firePermissionDenied?.({\n            action: \"dropIntoChildren\",\n            itemId: targetId,\n            reason: \"denied-by-rule\",\n          });\n          return;\n        }\n        if (\n          overInfo.zone !== \"middle\" &&\n          canDropAsSibling &&\n          !canDropAsSibling(targetId)\n        ) {\n          firePermissionDenied?.({\n            action: \"dropAsSibling\",\n            itemId: targetId,\n            reason: \"denied-by-rule\",\n          });\n          return;\n        }\n\n        // Resolve destination from edge zone.\n        const fromParentId = findParentId(items, src.id);\n        const fromIndex = siblingIndex(items, fromParentId, src.id);\n        const toLoc = resolveDropLocation(items, targetId, overInfo.zone);\n        if (!toLoc) return;\n\n        dispatch({\n          type: \"MOVE_ITEM\",\n          itemId: src.id,\n          to: toLoc,\n          reason: overInfo.zone === \"middle\" ? \"reparent\" : \"reorder\",\n        });\n        fireMoved({\n          item: src,\n          from: { parentId: fromParentId, index: fromIndex },\n          to: toLoc,\n          via: \"drag\",\n        });\n      } finally {\n        finishDrag();\n      }\n      // event is read above; silence unused-var lint via reference.\n      void event;\n    },\n    [\n      activeItem,\n      over,\n      items,\n      dispatch,\n      fireMoved,\n      firePermissionDenied,\n      canDropIntoChildren,\n      canDropAsSibling,\n      finishDrag,\n    ],\n  );\n\n  const onDragCancel = useCallback(() => {\n    finishDrag();\n  }, [finishDrag]);\n\n  return {\n    sensors,\n    activeItem,\n    activeLevel,\n    over,\n    onDragStart,\n    onDragOver,\n    onDragEnd,\n    onDragCancel,\n  };\n}\n\nfunction siblingIndex(\n  items: ReadonlyArray<TaskItem>,\n  parentId: string | null,\n  id: string,\n): number {\n  if (parentId === null) return items.findIndex((i) => i.id === id);\n  const parent = findItemById(items, parentId);\n  if (!parent || !parent.children) return -1;\n  return parent.children.findIndex((i) => i.id === id);\n}\n\n/**\n * Resolve the drop's TreeLocation from the over row + edge zone. Middle =\n * append-as-last-child of the target (auto-expand handled in the reducer).\n * Top/bottom = sibling adjacency relative to the target's parent.\n */\nfunction resolveDropLocation(\n  items: ReadonlyArray<TaskItem>,\n  targetId: string,\n  zone: EdgeZone,\n): TreeLocation | null {\n  if (zone === \"middle\") {\n    const target = findItemById(items, targetId);\n    if (!target) return null;\n    const childCount = target.children?.length ?? 0;\n    return { parentId: targetId, index: childCount };\n  }\n  const parentId = findParentId(items, targetId);\n  const sibIdx = siblingIndex(items, parentId, targetId);\n  if (sibIdx === -1) return null;\n  return {\n    parentId,\n    index: zone === \"top\" ? sibIdx : sibIdx + 1,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-tree-dnd-internal.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-tree-dnd-html5.ts",
      "content": "\"use client\";\n\nimport { useCallback } from \"react\";\nimport type { DragEvent as ReactDragEvent } from \"react\";\nimport type { TaskItem } from \"../../task-card/types\";\nimport type { TaskTreeAction } from \"../types\";\nimport { findItemById } from \"./../lib/tree-walker\";\nimport {\n  TASK_TREE_MIME,\n  parseFromDataTransfer,\n  serializeForDataTransfer,\n} from \"../lib/dnd-payload\";\n\nexport interface UseTreeDndHtml5Args {\n  /** Live items snapshot — needed to detect same-tree drops (which become noops). */\n  items: TaskItem[];\n  dispatch: (action: TaskTreeAction) => void;\n  fireDropped?: (args: {\n    item: TaskItem;\n    from: \"internal\" | \"external\";\n    targetParentId: string | null;\n    targetIndex: number;\n  }) => void;\n  fireAdded?: (args: {\n    item: TaskItem;\n    parentId: string | null;\n    index: number;\n    via: \"imperative\" | \"drop-from-external\";\n  }) => void;\n  isInternalDragRef: React.MutableRefObject<boolean>;\n}\n\nexport interface RowDragHandlers {\n  draggable: boolean;\n  onDragStart: (e: ReactDragEvent<HTMLDivElement>) => void;\n  onDragOver: (e: ReactDragEvent<HTMLDivElement>) => void;\n  onDrop: (e: ReactDragEvent<HTMLDivElement>) => void;\n}\n\nexport interface UseTreeDndHtml5Result {\n  /** Factory: per-row HTML5 drag handler set. */\n  getRowHandlers: (item: TaskItem) => RowDragHandlers;\n}\n\n/**\n * Native HTML5 dataTransfer drag wiring for cross-procomp drags between\n * task-tree and task-card (or any procomp that speaks the shared\n * `application/x-ilinxa-task+json` MIME).\n *\n * Each row is BOTH source AND target:\n *   - As source: dragstart serializes the item; bails if the grip already\n *     activated @dnd-kit drag (`isInternalDragRef.current === true`).\n *   - As target: dragover preventDefault when the MIME matches so the\n *     browser allows the drop; drop parses the payload and dispatches\n *     ADD_ITEM as a child of the over row.\n *\n * For v0.1, external HTML5 drops always land as the LAST child of the over\n * row (middle-zone semantics). Edge-zone HTML5 drops (top/bottom sibling)\n * are a future refinement — the v0.1 contract is \"internal drag = full\n * top/middle/bottom zones; external drag = always reparent.\"\n */\nexport function useTreeDndHtml5(\n  args: UseTreeDndHtml5Args,\n): UseTreeDndHtml5Result {\n  const { items, dispatch, fireDropped, fireAdded, isInternalDragRef } = args;\n\n  const getRowHandlers = useCallback(\n    (item: TaskItem): RowDragHandlers => ({\n      draggable: true,\n      onDragStart: (e) => {\n        if (isInternalDragRef.current) {\n          // @dnd-kit drag took over via the grip; suppress native drag so\n          // the browser doesn't fire a ghost-image alongside the overlay.\n          e.preventDefault();\n          return;\n        }\n        try {\n          const payload = serializeForDataTransfer(item);\n          e.dataTransfer.setData(TASK_TREE_MIME, payload);\n          e.dataTransfer.setData(\"text/plain\", payload);\n          e.dataTransfer.effectAllowed = \"copy\";\n        } catch {\n          // setData can throw in restricted contexts (sandboxed iframes);\n          // swallow — the drag will simply fail to populate the payload.\n        }\n      },\n      onDragOver: (e) => {\n        if (!e.dataTransfer.types.includes(TASK_TREE_MIME)) return;\n        if (isInternalDragRef.current) return;\n        e.preventDefault();\n        e.dataTransfer.dropEffect = \"copy\";\n      },\n      onDrop: (e) => {\n        if (isInternalDragRef.current) return;\n        const parsed = parseFromDataTransfer(e.dataTransfer);\n        if (!parsed) return;\n        if (parsed.id === item.id) return; // self-drop noop\n        // Same-tree native drop (rare — typically the grip routes through\n        // @dnd-kit, not native HTML5). Skip silently to avoid creating a\n        // duplicate row; internal moves come through MOVE_ITEM, not\n        // ADD_ITEM. External drops are detected by parsed.id NOT existing\n        // in our items.\n        if (findItemById(items, parsed.id)) return;\n        e.preventDefault();\n        const targetParentId = item.id;\n        const targetIndex = item.children?.length ?? 0;\n        dispatch({\n          type: \"ADD_ITEM\",\n          item: parsed,\n          parentId: targetParentId,\n          index: targetIndex,\n          via: \"drop-from-external\",\n        });\n        fireAdded?.({\n          item: parsed,\n          parentId: targetParentId,\n          index: targetIndex,\n          via: \"drop-from-external\",\n        });\n        fireDropped?.({\n          item: parsed,\n          from: \"external\",\n          targetParentId,\n          targetIndex,\n        });\n      },\n    }),\n    [items, dispatch, fireAdded, fireDropped, isInternalDragRef],\n  );\n\n  return { getRowHandlers };\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-tree-dnd-html5.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/hooks/use-tree-keyboard.ts",
      "content": "\"use client\";\n\nimport { useCallback } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent } from \"react\";\nimport type { TaskPermissions } from \"../../task-card/types\";\nimport type {\n  TaskTreePermissionAction,\n  TaskTreePermissionDeniedEvent,\n  TaskTreeStateValue,\n} from \"../types\";\nimport { evalPermission } from \"../lib/permissions\";\n\nexport interface UseTreeKeyboardArgs {\n  state: TaskTreeStateValue;\n  /** Declarative permission matrix (defaults to allow-all when omitted). */\n  permissions?: TaskPermissions;\n  /**\n   * Fired when a Space (toggleActive) or Delete/Backspace (remove)\n   * shortcut is denied by `permissions` or `item.locked`. Receives the\n   * resolved action code + the offending item id + a reason tag.\n   */\n  onPermissionDenied?: (args: TaskTreePermissionDeniedEvent) => void;\n  /**\n   * When true, suppresses Space + Delete/Backspace shortcuts entirely\n   * and fires a `denied-by-readOnly` event. Navigation + selection\n   * shortcuts continue to work.\n   */\n  readOnly?: boolean;\n}\n\nexport interface UseTreeKeyboardResult {\n  onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void;\n}\n\n/**\n * Keyboard navigation per plan §16.3. Reads visibleItems + focusedItemId\n * from state; routes to the appropriate handle method.\n *\n * Implemented map:\n *   ArrowDown / ArrowUp   — next / prev visible row\n *   ArrowRight            — expand collapsed; else move to first visible child\n *   ArrowLeft             — collapse expanded; else move to parent\n *   Home / End            — first / last visible row\n *   Space                 — toggle the focused row's active state\n *   Enter                 — select + fire onItemClick on the focused row\n *   Delete / Backspace    — remove the focused row\n *   Cmd/Ctrl + A          — select all visible\n *   Escape                — clear selection\n *\n * Bail-out rule: the handler is attached at the tree root and events bubble\n * from interactive descendants (chevron buttons, checkboxes, grip, slot\n * inputs). When the target is a form input or button, we bail so the\n * native handler runs without double-handling (e.g., Space toggling the\n * checkbox AND firing toggleActive a second time; Backspace deleting a\n * character in a slot input AND removing the row).\n */\nexport function useTreeKeyboard(\n  args: UseTreeKeyboardArgs,\n): UseTreeKeyboardResult {\n  const { state, permissions, onPermissionDenied, readOnly } = args;\n\n  const denyAndFire = useCallback(\n    (action: TaskTreePermissionAction, itemId: string, locked: boolean) => {\n      onPermissionDenied?.({\n        action,\n        itemId,\n        reason: readOnly\n          ? \"denied-by-readOnly\"\n          : locked\n            ? \"denied-by-lock\"\n            : \"denied-by-rule\",\n      });\n    },\n    [onPermissionDenied, readOnly],\n  );\n\n  const onKeyDown = useCallback(\n    (event: ReactKeyboardEvent<HTMLDivElement>) => {\n      const target = event.target as HTMLElement | null;\n      if (\n        target?.matches(\n          \"input, textarea, select, button, [role='button'], [contenteditable='true']\",\n        )\n      ) {\n        return;\n      }\n\n      const visible = state.visibleItems;\n      if (visible.length === 0) return;\n\n      // Resolve the active row from focusedItemId; fall back to first\n      // visible when nothing is focused yet.\n      const focusIdx = state.focusedItemId\n        ? visible.findIndex((r) => r.item.id === state.focusedItemId)\n        : -1;\n      const idx = focusIdx === -1 ? 0 : focusIdx;\n      const current = visible[idx];\n\n      const moveTo = (nextIdx: number) => {\n        const clamped = Math.max(0, Math.min(visible.length - 1, nextIdx));\n        const next = visible[clamped];\n        if (!next) return;\n        state.focusItem(next.item.id);\n      };\n\n      const meta = event.metaKey || event.ctrlKey;\n      const key = event.key;\n\n      // Cmd/Ctrl-A — select all visible.\n      if (meta && (key === \"a\" || key === \"A\")) {\n        event.preventDefault();\n        state.selectAll();\n        return;\n      }\n\n      switch (key) {\n        case \"ArrowDown\":\n          event.preventDefault();\n          moveTo(idx + 1);\n          return;\n        case \"ArrowUp\":\n          event.preventDefault();\n          moveTo(idx - 1);\n          return;\n        case \"Home\":\n          event.preventDefault();\n          moveTo(0);\n          return;\n        case \"End\":\n          event.preventDefault();\n          moveTo(visible.length - 1);\n          return;\n        case \"ArrowRight\": {\n          event.preventDefault();\n          if (!current) return;\n          const hasChildren =\n            !!current.item.children && current.item.children.length > 0;\n          if (!hasChildren) return;\n          if (state.collapsedIds.has(current.item.id)) {\n            state.expandItem(current.item.id);\n            return;\n          }\n          // Move to first visible child (the next row whose parentId matches).\n          const child = visible[idx + 1];\n          if (child && child.parentId === current.item.id) {\n            state.focusItem(child.item.id);\n          }\n          return;\n        }\n        case \"ArrowLeft\": {\n          event.preventDefault();\n          if (!current) return;\n          const hasChildren =\n            !!current.item.children && current.item.children.length > 0;\n          if (hasChildren && !state.collapsedIds.has(current.item.id)) {\n            state.collapseItem(current.item.id);\n            return;\n          }\n          // Move to parent if any.\n          if (current.parentId !== null) {\n            state.focusItem(current.parentId);\n          }\n          return;\n        }\n        case \" \":\n        case \"Spacebar\": {\n          event.preventDefault();\n          if (!current) return;\n          if (readOnly) {\n            denyAndFire(\"toggleActive\", current.item.id, false);\n            return;\n          }\n          const allowed = evalPermission(\n            permissions,\n            \"toggleActive\",\n            current.item,\n            current.level,\n          );\n          if (!allowed) {\n            denyAndFire(\n              \"toggleActive\",\n              current.item.id,\n              current.item.locked === true,\n            );\n            return;\n          }\n          state.toggleActive(current.item.id, !current.item.active);\n          return;\n        }\n        case \"Enter\": {\n          event.preventDefault();\n          if (!current) return;\n          // Route through handleRowClick so selection-mode resolution +\n          // onItemClick firing match the click path. TaskTreeItemEvent.event\n          // accepts MouseEvent | KeyboardEvent, so the KeyboardEvent passes\n          // through to the consumer's onItemClick handler.\n          state.handleRowClick(current.item, current.level, event);\n          return;\n        }\n        case \"Delete\":\n        case \"Backspace\": {\n          event.preventDefault();\n          if (!current) return;\n          if (readOnly) {\n            denyAndFire(\"remove\", current.item.id, false);\n            return;\n          }\n          const allowed = evalPermission(\n            permissions,\n            \"remove\",\n            current.item,\n            current.level,\n          );\n          if (!allowed) {\n            denyAndFire(\n              \"remove\",\n              current.item.id,\n              current.item.locked === true,\n            );\n            return;\n          }\n          // Move focus to the next row BEFORE removal so the user keeps a\n          // sensible focus target. Fall back to previous if at the end.\n          const nextFocus =\n            visible[idx + 1]?.item.id ?? visible[idx - 1]?.item.id ?? null;\n          state.removeItem(current.item.id);\n          if (nextFocus) state.focusItem(nextFocus);\n          return;\n        }\n        case \"Escape\": {\n          event.preventDefault();\n          state.clearSelection();\n          return;\n        }\n      }\n    },\n    [state, permissions, readOnly, denyAndFire],\n  );\n\n  return { onKeyDown };\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/hooks/use-tree-keyboard.ts"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-chevron.tsx",
      "content": "\"use client\";\n\nimport { ChevronRight } from \"lucide-react\";\nimport type { MouseEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeChevronProps {\n  collapsed: boolean;\n  hasChildren: boolean;\n  onToggle?: (event: MouseEvent<HTMLButtonElement>) => void;\n  className?: string;\n}\n\n/**\n * Disclosure triangle for the row. Renders as a hidden 16px slot when the\n * item has no children so the row alignment stays consistent across rows\n * with and without children.\n */\nexport function TaskTreeChevron({\n  collapsed,\n  hasChildren,\n  onToggle,\n  className,\n}: TaskTreeChevronProps) {\n  if (!hasChildren) {\n    return (\n      <span\n        aria-hidden\n        className={cn(\"inline-block size-4 shrink-0\", className)}\n      />\n    );\n  }\n  return (\n    <button\n      type=\"button\"\n      onClick={(e) => {\n        // Chevron is a leaf interaction — don't bubble to the row's click /\n        // select handler.\n        e.stopPropagation();\n        onToggle?.(e);\n      }}\n      aria-label={collapsed ? \"Expand\" : \"Collapse\"}\n      aria-expanded={!collapsed}\n      className={cn(\n        \"inline-flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n        className,\n      )}\n    >\n      <ChevronRight\n        className={cn(\n          \"size-3 transition-transform duration-150\",\n          !collapsed && \"rotate-90\",\n        )}\n      />\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-chevron.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-status-indicator.tsx",
      "content": "\"use client\";\n\nimport type { TaskStatusOption } from \"../../task-card/types\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeStatusIndicatorProps {\n  variant: \"dot\" | \"strip\" | \"none\";\n  statusOption?: TaskStatusOption;\n  className?: string;\n}\n\n/**\n * Status badge. Three layouts:\n *  - \"dot\"   — 8px coloured circle, fits inline with the name row.\n *  - \"strip\" — full-height left-edge bar; absolute-positioned by the row.\n *  - \"none\"  — renders nothing.\n *\n * Colour comes from the matched statusOption's `variant` field via the\n * shadcn token palette. When no option matches, falls back to a muted grey\n * so the indicator still emits visual presence (dropping the indicator on a\n * fallback case would silently shift row spacing).\n */\nexport function TaskTreeStatusIndicator({\n  variant,\n  statusOption,\n  className,\n}: TaskTreeStatusIndicatorProps) {\n  if (variant === \"none\") return null;\n  const palette = paletteFor(statusOption?.variant);\n  if (variant === \"strip\") {\n    return (\n      <span\n        aria-hidden\n        data-status={statusOption?.value ?? \"unknown\"}\n        className={cn(\n          \"pointer-events-none absolute inset-y-0 left-0 w-1 rounded-l\",\n          palette.strip,\n          className,\n        )}\n      />\n    );\n  }\n  // variant === \"dot\"\n  return (\n    <span\n      aria-hidden\n      data-status={statusOption?.value ?? \"unknown\"}\n      title={statusOption?.label}\n      className={cn(\n        \"inline-block size-2 shrink-0 rounded-full\",\n        palette.dot,\n        className,\n      )}\n    />\n  );\n}\n\nfunction paletteFor(\n  variant: TaskStatusOption[\"variant\"] | undefined,\n): { dot: string; strip: string } {\n  switch (variant) {\n    case \"default\":\n      return { dot: \"bg-primary\", strip: \"bg-primary\" };\n    case \"secondary\":\n      return { dot: \"bg-secondary-foreground/60\", strip: \"bg-secondary-foreground/60\" };\n    case \"destructive\":\n      return { dot: \"bg-destructive\", strip: \"bg-destructive\" };\n    case \"outline\":\n      return {\n        dot: \"border border-border bg-background\",\n        strip: \"bg-border\",\n      };\n    default:\n      return {\n        dot: \"bg-muted-foreground/40\",\n        strip: \"bg-muted-foreground/40\",\n      };\n  }\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-status-indicator.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-checkbox.tsx",
      "content": "\"use client\";\n\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeCheckboxProps {\n  checked: boolean;\n  /** Disabled state from permission gate; the click is a no-op when true. */\n  disabled?: boolean;\n  /** Next active state — already inverted from `checked`. */\n  onChange?: (nextActive: boolean) => void;\n  ariaLabel?: string;\n  className?: string;\n}\n\n/**\n * Wrapper around the shadcn `<Checkbox>` primitive that bridges the boolean\n * `checked` + `onChange` API the row expects.\n *\n * This is a PER-ROW boolean toggle bound to a single item's `active` — never a\n * tri-state group selector, so `checked` is always a real boolean and the box\n * never renders an \"indeterminate\"/mixed state. The `typeof next === \"boolean\"`\n * guard below exists ONLY to bridge the two checkbox backends (producer's Radix\n * passes `boolean | \"indeterminate\"`; consumer-installed Base UI passes only\n * `boolean`); the `: true` fallback is therefore unreachable in practice.\n */\nexport function TaskTreeCheckbox({\n  checked,\n  disabled,\n  onChange,\n  ariaLabel,\n  className,\n}: TaskTreeCheckboxProps) {\n  return (\n    <Checkbox\n      checked={checked}\n      disabled={disabled}\n      aria-label={ariaLabel ?? (checked ? \"Mark inactive\" : \"Mark active\")}\n      aria-disabled={disabled || undefined}\n      onCheckedChange={(next) => {\n        if (disabled) return;\n        // Cross-backend type guard (F-cross-13): Radix passes\n        // `boolean | \"indeterminate\"`, Base UI passes only `boolean`. Since\n        // `checked` is always a real boolean here, `next` is always a boolean\n        // and the `: true` branch is unreachable — kept only for type-narrowing.\n        const value: boolean = typeof next === \"boolean\" ? next : true;\n        onChange?.(value);\n      }}\n      onClick={(e) => {\n        // Stop row click from also firing — checkbox is a leaf interaction.\n        e.stopPropagation();\n      }}\n      className={cn(\"size-4 shrink-0\", className)}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-checkbox.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-name.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeNameProps {\n  name: string;\n  active?: boolean;\n  className?: string;\n}\n\n/**\n * Bold name span with single-line truncation. Inactive items render dimmed +\n * strikethrough as a visual affordance — the active state is also reflected\n * via the row checkbox; the dimming pairs the two so an inactive item reads\n * \"done\" from the row glance alone.\n */\nexport function TaskTreeName({ name, active, className }: TaskTreeNameProps) {\n  return (\n    <span\n      className={cn(\n        \"flex-1 truncate font-semibold\",\n        active === false && \"text-muted-foreground line-through opacity-70\",\n        className,\n      )}\n    >\n      {name}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-name.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-description.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeDescriptionProps {\n  description: string;\n  className?: string;\n}\n\n/**\n * Second-line description preview, single-line truncated. Rendered with the\n * row's calculated left padding so the description aligns with the name,\n * minus the chevron + checkbox affordances' widths.\n */\nexport function TaskTreeDescription({\n  description,\n  className,\n}: TaskTreeDescriptionProps) {\n  return (\n    <div\n      className={cn(\n        \"truncate text-xs text-muted-foreground leading-tight\",\n        className,\n      )}\n    >\n      {description}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-description.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-person-label.tsx",
      "content": "\"use client\";\n\nimport type { TaskPerson } from \"../../task-card/types\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreePersonLabelProps {\n  person: TaskPerson;\n  /** Default \"right\" puts the label on the row's right edge with avatar trailing. */\n  layout?: \"right\" | \"compact\";\n  className?: string;\n}\n\n/**\n * Avatar + name. `layout=\"compact\"` drops the name text (avatar-only) for\n * narrow rows; the full name is preserved as a `title` so hover reveals it.\n */\nexport function TaskTreePersonLabel({\n  person,\n  layout = \"right\",\n  className,\n}: TaskTreePersonLabelProps) {\n  const initials = computeInitials(person.name);\n  return (\n    <span\n      title={person.name}\n      className={cn(\n        \"inline-flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground\",\n        className,\n      )}\n    >\n      {layout === \"right\" && (\n        <span className=\"truncate max-w-[120px]\">{person.name}</span>\n      )}\n      <Avatar className=\"size-5\">\n        {person.avatar && (\n          <AvatarImage src={person.avatar} alt={person.name} />\n        )}\n        <AvatarFallback className=\"text-[10px] font-medium\">\n          {initials}\n        </AvatarFallback>\n      </Avatar>\n    </span>\n  );\n}\n\nfunction computeInitials(name: string): string {\n  const trimmed = name.trim();\n  if (!trimmed) return \"?\";\n  const parts = trimmed.split(/\\s+/);\n  if (parts.length === 1) {\n    return parts[0].slice(0, 2).toUpperCase();\n  }\n  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-person-label.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-row-content.tsx",
      "content": "\"use client\";\n\nimport type { MouseEvent } from \"react\";\nimport type { TaskItem } from \"../../task-card/types\";\nimport { useTaskTreeRenderContext } from \"../hooks/use-task-tree-context\";\nimport { TaskTreeChevron } from \"./task-tree-chevron\";\nimport { TaskTreeStatusIndicator } from \"./task-tree-status-indicator\";\nimport { TaskTreeCheckbox } from \"./task-tree-checkbox\";\nimport { TaskTreeName } from \"./task-tree-name\";\nimport { TaskTreeDescription } from \"./task-tree-description\";\nimport { TaskTreePersonLabel } from \"./task-tree-person-label\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeRowContentProps {\n  item: TaskItem;\n  level: number;\n  isSelected: boolean;\n  isCollapsed: boolean;\n  /**\n   * Visual dimming flag from the filter-fade path. The container also fades;\n   * the inner controls stay full opacity so checkbox / chevron remain\n   * affordant. Set by the host based on visibleItems[i].dimmed.\n   */\n  dimmed?: boolean;\n  /** Read-only when both the row-level + item-level toggle gates deny. */\n  canToggleActive?: boolean;\n  onToggleCollapse?: (event: MouseEvent<HTMLButtonElement>) => void;\n  onToggleActive?: (nextActive: boolean) => void;\n  className?: string;\n}\n\n/**\n * Default `renderRow` paint. Composes the leaf primitives with the consumer's\n * slot overrides (name / description / person / statusIndicator) pulled from\n * the render context. Interactive callbacks pass through from `<TaskTreeRow>`\n * (lands in C6) — keeping the content itself props-only makes it testable\n * without a full DnD host.\n */\nexport function TaskTreeRowContent({\n  item,\n  level,\n  isSelected,\n  isCollapsed,\n  dimmed,\n  canToggleActive = true,\n  onToggleCollapse,\n  onToggleActive,\n  className,\n}: TaskTreeRowContentProps) {\n  const {\n    statusIndicator,\n    statusOptionMap,\n    indentSize,\n    renderName,\n    renderDescription,\n    renderPerson,\n    renderStatusIndicator,\n  } = useTaskTreeRenderContext();\n\n  const statusOption = statusOptionMap.get(item.status);\n  const hasChildren = !!item.children && item.children.length > 0;\n  const fieldArgs = { item, level } as const;\n  const stripIndicator = statusIndicator === \"strip\";\n\n  return (\n    <div\n      data-level={level}\n      data-selected={isSelected || undefined}\n      data-collapsed={isCollapsed || undefined}\n      data-dimmed={dimmed || undefined}\n      data-active={item.active}\n      className={cn(\n        \"relative flex flex-col justify-center gap-0.5 py-1.5 pr-2\",\n        dimmed && \"opacity-50\",\n        className,\n      )}\n      style={{\n        // Indent + 8px gutter for chevron alignment.\n        paddingInlineStart:\n          (stripIndicator ? 12 : 8) + level * indentSize,\n      }}\n    >\n      {stripIndicator && (\n        <TaskTreeStatusIndicator variant=\"strip\" statusOption={statusOption} />\n      )}\n\n      <div className=\"flex min-w-0 items-center gap-1.5\">\n        <TaskTreeChevron\n          collapsed={isCollapsed}\n          hasChildren={hasChildren}\n          onToggle={onToggleCollapse}\n        />\n\n        {renderStatusIndicator\n          ? renderStatusIndicator({ ...fieldArgs, statusOption })\n          : statusIndicator === \"dot\" && (\n              <TaskTreeStatusIndicator\n                variant=\"dot\"\n                statusOption={statusOption}\n              />\n            )}\n\n        <TaskTreeCheckbox\n          checked={item.active}\n          disabled={!canToggleActive}\n          onChange={onToggleActive}\n          ariaLabel={item.active ? `Mark ${item.name} inactive` : `Mark ${item.name} active`}\n        />\n\n        {renderName ? (\n          <span className=\"min-w-0 flex-1 truncate\">\n            {renderName(fieldArgs)}\n          </span>\n        ) : (\n          <TaskTreeName name={item.name} active={item.active} />\n        )}\n\n        {item.targetPerson &&\n          (renderPerson ? (\n            <span className=\"shrink-0\">{renderPerson(fieldArgs)}</span>\n          ) : (\n            <TaskTreePersonLabel person={item.targetPerson} />\n          ))}\n      </div>\n\n      {item.description &&\n        (renderDescription ? (\n          <div\n            className=\"min-w-0\"\n            style={{\n              paddingInlineStart: 16,\n            }}\n          >\n            {renderDescription(fieldArgs)}\n          </div>\n        ) : (\n          <div\n            className=\"min-w-0\"\n            style={{\n              paddingInlineStart: 16,\n            }}\n          >\n            <TaskTreeDescription description={item.description} />\n          </div>\n        ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-row-content.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-list.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { TaskPermissions } from \"../../task-card/types\";\nimport type { TaskTreePermissionDeniedEvent } from \"../types\";\nimport {\n  useTaskTreeRenderContext,\n  useTaskTreeStateContext,\n} from \"../hooks/use-task-tree-context\";\nimport {\n  useTreeVirtual,\n  type TaskTreeVirtualizeMode,\n} from \"../hooks/use-tree-virtual\";\nimport { useTreeKeyboard } from \"../hooks/use-tree-keyboard\";\nimport { TaskTreeRow } from \"./task-tree-row\";\nimport { TaskTreeEmptyState } from \"./task-tree-empty-state\";\nimport { isFilterActive } from \"../lib/filter-items\";\nimport { evalPermission } from \"../lib/permissions\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeListProps {\n  /** Default \"auto\" — virtualize when total rows ≥ threshold (R9 lock). */\n  virtualize?: TaskTreeVirtualizeMode;\n  /** Default 200 (plan §10.2). */\n  virtualizeThreshold?: number;\n  /** Default 52 (Q-P2 fixed row height). */\n  rowHeight?: number;\n  /**\n   * When true, virtualization auto-suspends (R7 mitigation). Wired by C6's\n   * DnD hooks so virtualizer + drag don't fight over scroll position.\n   */\n  suspended?: boolean;\n  /** Forwarded into the keyboard hook so Space + Delete honor permissions. */\n  permissions?: TaskPermissions;\n  /** Forwarded into the keyboard hook for permission-denied events. */\n  onPermissionDenied?: (args: TaskTreePermissionDeniedEvent) => void;\n  /** When true, keyboard Space + Delete fire `denied-by-readOnly`. */\n  readOnly?: boolean;\n  className?: string;\n}\n\n/**\n * List view over `visibleItems`. Optionally virtualizes via\n * `@tanstack/react-virtual` when the total tree exceeds the threshold OR\n * when the consumer forces \"always\". Static list paint otherwise so DnD\n * indicators + slot rows with variable heights work without virtualizer\n * measurement overrides.\n *\n * Owns the tree's keydown handler + focus management + empty-state slot.\n * One row at a time has tabIndex=0 (the focused row or the first visible\n * when nothing is focused); a useEffect programmatically focuses the\n * matching DOM element when state.focusedItemId changes.\n */\nexport function TaskTreeList({\n  virtualize = \"auto\",\n  virtualizeThreshold = 200,\n  rowHeight = 52,\n  suspended = false,\n  permissions,\n  onPermissionDenied,\n  readOnly,\n  className,\n}: TaskTreeListProps) {\n  const state = useTaskTreeStateContext();\n  const { renderEmptyState } = useTaskTreeRenderContext();\n  const scrollRef = useRef<HTMLDivElement | null>(null);\n  const { onKeyDown } = useTreeKeyboard({\n    state,\n    permissions,\n    onPermissionDenied,\n    readOnly,\n  });\n\n  const { active, virtualizer } = useTreeVirtual({\n    rows: state.visibleItems,\n    scrollRef,\n    mode: virtualize,\n    threshold: virtualizeThreshold,\n    rowHeight,\n    suspended,\n  });\n\n  const rows = state.visibleItems;\n\n  // Resolve the tabbable row id: the focused one if it's still visible, else\n  // the first visible row. Lets Tab into the tree always land on something\n  // meaningful and keeps the WAI-ARIA \"exactly one tabindex=0 treeitem\" rule.\n  const tabbableId =\n    state.focusedItemId &&\n    rows.some((r) => r.item.id === state.focusedItemId)\n      ? state.focusedItemId\n      : (rows[0]?.item.id ?? null);\n\n  // Sync DOM focus to focusedItemId. Only fires when the id changes — clicks\n  // that don't change focus don't re-trigger focus() (which would clobber a\n  // text input the consumer might've focused from a slot row).\n  useEffect(() => {\n    if (!state.focusedItemId || !scrollRef.current) return;\n    const el = scrollRef.current.querySelector<HTMLElement>(\n      `[data-treeitem-id=\"${CSS.escape(state.focusedItemId)}\"]`,\n    );\n    if (el && document.activeElement !== el) {\n      el.focus({ preventScroll: false });\n    }\n  }, [state.focusedItemId]);\n\n  const hasFilter = isFilterActive(state.filter, state.query);\n\n  if (rows.length === 0) {\n    const emptyArgs = { hasFilter };\n    const emptyContent = renderEmptyState\n      ? renderEmptyState(emptyArgs)\n      : <TaskTreeEmptyState hasFilter={hasFilter} />;\n    return (\n      <div\n        ref={scrollRef}\n        className={cn(\"h-full overflow-auto\", className)}\n        role=\"tree\"\n        aria-label=\"Task tree\"\n        aria-multiselectable=\"true\"\n        onKeyDown={onKeyDown}\n      >\n        {emptyContent}\n      </div>\n    );\n  }\n\n  if (active) {\n    const virtualItems = virtualizer.getVirtualItems();\n    return (\n      <div\n        ref={scrollRef}\n        className={cn(\"h-full overflow-auto\", className)}\n        role=\"tree\"\n        aria-label=\"Task tree\"\n        aria-multiselectable=\"true\"\n        onKeyDown={onKeyDown}\n      >\n        <div\n          style={{\n            height: virtualizer.getTotalSize(),\n            position: \"relative\",\n            width: \"100%\",\n          }}\n        >\n          {virtualItems.map((vRow) => {\n            const row = rows[vRow.index];\n            return (\n              <div\n                key={row.item.id}\n                data-treeitem-id={row.item.id}\n                style={{\n                  position: \"absolute\",\n                  top: 0,\n                  left: 0,\n                  width: \"100%\",\n                  transform: `translateY(${vRow.start}px)`,\n                  height: vRow.size,\n                }}\n                role=\"treeitem\"\n                tabIndex={tabbableId === row.item.id ? 0 : -1}\n                aria-level={row.level + 1}\n                aria-expanded={\n                  row.item.children && row.item.children.length > 0\n                    ? !state.collapsedIds.has(row.item.id)\n                    : undefined\n                }\n                aria-selected={state.selectedIds.has(row.item.id) || undefined}\n                onFocus={() => {\n                  if (state.focusedItemId !== row.item.id) {\n                    state.focusItem(row.item.id);\n                  }\n                }}\n              >\n                <TaskTreeRow\n                  item={row.item}\n                  level={row.level}\n                  isSelected={state.selectedIds.has(row.item.id)}\n                  isCollapsed={state.collapsedIds.has(row.item.id)}\n                  dimmed={row.dimmed}\n                  canDrag={!readOnly && evalPermission(permissions, \"drag\", row.item, row.level)}\n                  canToggleActive={\n                    !readOnly &&\n                    evalPermission(permissions, \"toggleActive\", row.item, row.level)\n                  }\n                />\n              </div>\n            );\n          })}\n        </div>\n      </div>\n    );\n  }\n\n  // Static list paint (small trees + dragging).\n  return (\n    <div\n      ref={scrollRef}\n      className={cn(\"h-full overflow-auto\", className)}\n      role=\"tree\"\n      aria-label=\"Task tree\"\n      aria-multiselectable=\"true\"\n      onKeyDown={onKeyDown}\n    >\n      <div className=\"flex flex-col\">\n        {rows.map((row) => (\n          <div\n            key={row.item.id}\n            data-treeitem-id={row.item.id}\n            role=\"treeitem\"\n            tabIndex={tabbableId === row.item.id ? 0 : -1}\n            aria-level={row.level + 1}\n            aria-expanded={\n              row.item.children && row.item.children.length > 0\n                ? !state.collapsedIds.has(row.item.id)\n                : undefined\n            }\n            aria-selected={state.selectedIds.has(row.item.id) || undefined}\n            onFocus={() => {\n              if (state.focusedItemId !== row.item.id) {\n                state.focusItem(row.item.id);\n              }\n            }}\n          >\n            <TaskTreeRow\n              item={row.item}\n              level={row.level}\n              isSelected={state.selectedIds.has(row.item.id)}\n              isCollapsed={state.collapsedIds.has(row.item.id)}\n              dimmed={row.dimmed}\n              canDrag={!readOnly && evalPermission(permissions, \"drag\", row.item, row.level)}\n              canToggleActive={\n                !readOnly &&\n                evalPermission(permissions, \"toggleActive\", row.item, row.level)\n              }\n            />\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-list.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-row.tsx",
      "content": "\"use client\";\n\nimport { useDroppable } from \"@dnd-kit/core\";\nimport type { MouseEvent } from \"react\";\nimport type { TaskItem } from \"../../task-card/types\";\nimport { TaskTreeRowContent } from \"./task-tree-row-content\";\nimport { TaskTreeGrip } from \"./task-tree-grip\";\nimport { TaskTreeDropIndicator } from \"./task-tree-drop-indicator\";\nimport { useTaskTreeStateContext } from \"../hooks/use-task-tree-context\";\nimport { useTaskTreeRenderContext } from \"../hooks/use-task-tree-context\";\nimport { useTaskTreeDndContext } from \"../hooks/use-task-tree-context\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeRowProps {\n  item: TaskItem;\n  level: number;\n  isSelected: boolean;\n  isCollapsed: boolean;\n  dimmed?: boolean;\n  /** Permission for the drag-handle render gate. Default true. */\n  canDrag?: boolean;\n  /** Permission for the active-checkbox. Default true. */\n  canToggleActive?: boolean;\n}\n\n/**\n * Full row: composes grip + row-content + drop-indicator + click/DnD\n * wiring. The row body is a single `<div>` that serves as\n *   - @dnd-kit droppable (id = item.id) — internal drag drop target.\n *   - HTML5 drop target (via getRowHandlers from DnD context).\n *   - HTML5 drag source (the row body itself; grip uses @dnd-kit).\n *   - click target — row-select via fire(\"itemClick\").\n *\n * Drop indicator paints when the row is the active over-target AND the\n * computed zone isn't circular.\n */\nexport function TaskTreeRow({\n  item,\n  level,\n  isSelected,\n  isCollapsed,\n  dimmed,\n  canDrag = true,\n  canToggleActive = true,\n}: TaskTreeRowProps) {\n  const state = useTaskTreeStateContext();\n  const { indentSize, statusIndicator, renderRow } = useTaskTreeRenderContext();\n  const dnd = useTaskTreeDndContext();\n\n  const { setNodeRef, isOver } = useDroppable({ id: item.id });\n\n  const isActiveSource = dnd?.activeItemId === item.id;\n  const showIndicator =\n    !!dnd &&\n    isOver &&\n    dnd.overId === item.id &&\n    dnd.overZone !== null &&\n    !dnd.overCircular;\n\n  const rowHandlers = dnd?.getRowHandlers(item);\n\n  const indicatorIndentPx =\n    (statusIndicator === \"strip\" ? 12 : 8) + level * indentSize;\n\n  const hasChildren = !!item.children && item.children.length > 0;\n  const defaultRender = (\n    <TaskTreeRowContent\n      item={item}\n      level={level}\n      isSelected={isSelected}\n      isCollapsed={isCollapsed}\n      dimmed={dimmed}\n      canToggleActive={canToggleActive}\n      onToggleCollapse={() => state.toggleCollapse(item.id)}\n      onToggleActive={(next) => state.toggleActive(item.id, next)}\n    />\n  );\n\n  const body = renderRow\n    ? renderRow({\n        item,\n        level,\n        isSelected,\n        isCollapsed,\n        isExpanded: hasChildren && !isCollapsed,\n        defaultRender,\n      })\n    : defaultRender;\n\n  return (\n    <div\n      ref={setNodeRef}\n      data-task-tree-row={item.id}\n      onClick={(e: MouseEvent<HTMLDivElement>) => {\n        // Defensive: synthetic events from nested buttons should already\n        // stopPropagation, but a slot row replacement might not.\n        if (\n          (e.target as HTMLElement | null)?.closest(\"button, input, [role='button']\")\n        ) {\n          return;\n        }\n        // Move keyboard focus to the clicked row so arrow keys continue\n        // from here. The treeitem div's onFocus also calls focusItem, but\n        // a programmatic click from a slot may skip onFocus.\n        state.focusItem(item.id);\n        dnd?.handleRowClick(item, level, e);\n      }}\n      draggable={rowHandlers?.draggable && canDrag}\n      onDragStart={rowHandlers?.onDragStart}\n      onDragOver={rowHandlers?.onDragOver}\n      onDrop={rowHandlers?.onDrop}\n      className={cn(\n        // `group` enables the grip's hover-reveal.\n        \"group relative flex w-full items-stretch\",\n        isSelected && \"bg-accent/30\",\n        isActiveSource && \"opacity-50\",\n        // Hover lift — applies when not dimmed by filter-fade.\n        !dimmed && \"hover:bg-accent/20\",\n      )}\n    >\n      {/* Grip column — leading flex item so it sits inside the row's\n          bounding box (a previous absolute `-left-4` placement landed\n          outside the row and was clipped by ancestor overflow:hidden /\n          overflow:auto, making DnD unreachable). Always rendered\n          (no `sm:` gate) because @dnd-kit's TouchSensor listeners live on\n          the grip's button — hiding it on mobile would make long-press\n          drag unreachable. Desktop UX still hides at rest via the\n          `group-hover` class inside <TaskTreeGrip>. */}\n      <span className=\"flex w-4 shrink-0 items-center justify-center\">\n        <TaskTreeGrip id={item.id} enabled={canDrag} level={level} />\n      </span>\n\n      <div className=\"min-w-0 flex-1\">{body}</div>\n\n      {showIndicator && dnd?.overZone && (\n        <TaskTreeDropIndicator\n          zone={dnd.overZone}\n          indentPx={indicatorIndentPx}\n        />\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-row.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-grip.tsx",
      "content": "\"use client\";\n\nimport { useDraggable } from \"@dnd-kit/core\";\nimport { GripVertical } from \"lucide-react\";\nimport { forwardRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeGripProps {\n  /** Stable id used as the @dnd-kit draggable id (= item.id). */\n  id: string;\n  /**\n   * Permission gate. When false the grip renders empty (no drag affordance)\n   * so the cursor doesn't suggest interaction; @dnd-kit's listeners are\n   * never wired.\n   */\n  enabled?: boolean;\n  /** Marker the @dnd-kit handlers can read off `activeId.data.current.level`. */\n  level?: number;\n  className?: string;\n}\n\n/**\n * Hover-revealed drag handle. The grip is invisible at rest and fades in\n * on row hover (the row sets `group` so this can use `group-hover`).\n *\n * Wires `useDraggable` from @dnd-kit/core as the activator — drag begins\n * here, NOT on the row body. That's what enables the activator mutual-\n * exclusion pattern: row-level native dragstart checks\n * `isInternalDragRef.current` and bails when the grip has already taken\n * over.\n */\nexport const TaskTreeGrip = forwardRef<HTMLButtonElement, TaskTreeGripProps>(\n  function TaskTreeGrip({ id, enabled = true, level, className }, ref) {\n    const { attributes, listeners, setNodeRef, isDragging } = useDraggable({\n      id,\n      data: { level },\n      disabled: !enabled,\n    });\n\n    if (!enabled) return null;\n\n    return (\n      <button\n        ref={(node) => {\n          setNodeRef(node);\n          if (typeof ref === \"function\") ref(node);\n          else if (ref) ref.current = node;\n        }}\n        type=\"button\"\n        aria-label=\"Drag to move\"\n        // Stop click bubble — the grip click should not also select the row.\n        onClick={(e) => e.stopPropagation()}\n        {...attributes}\n        {...listeners}\n        className={cn(\n          \"inline-flex size-4 shrink-0 cursor-grab items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\",\n          isDragging && \"cursor-grabbing opacity-100\",\n          className,\n        )}\n      >\n        <GripVertical className=\"size-3\" />\n      </button>\n    );\n  },\n);\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-grip.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-drop-indicator.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { EdgeZone } from \"../lib/edge-zone\";\n\nexport interface TaskTreeDropIndicatorProps {\n  /** Which slot of the over-row the indicator paints in. */\n  zone: EdgeZone;\n  /** Indent of the dragged-over row so the line aligns with row content. */\n  indentPx?: number;\n  className?: string;\n}\n\n/**\n * Visual feedback for the active drop target.\n *\n * - \"top\"    — 2px horizontal line at the row's top edge → sibling-before.\n * - \"bottom\" — 2px horizontal line at the row's bottom edge → sibling-after.\n * - \"middle\" — inner-glow ring around the row → reparent as last child.\n *\n * The component is absolutely positioned relative to the row's bounding box.\n * The row sets `position: relative` (already true on `TaskTreeRowContent`)\n * for this to land in the right place. Pointer events are disabled so the\n * drop event still hits the row, not this overlay.\n */\nexport function TaskTreeDropIndicator({\n  zone,\n  indentPx = 0,\n  className,\n}: TaskTreeDropIndicatorProps) {\n  if (zone === \"top\" || zone === \"bottom\") {\n    return (\n      <span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute right-0 z-10 h-0.5 bg-primary\",\n          zone === \"top\" ? \"top-0\" : \"bottom-0\",\n          className,\n        )}\n        style={{ left: indentPx }}\n      />\n    );\n  }\n  // zone === \"middle\"\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"pointer-events-none absolute inset-0 z-10 rounded ring-2 ring-inset ring-primary/70\",\n        className,\n      )}\n    />\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-drop-indicator.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-drag-overlay.tsx",
      "content": "\"use client\";\n\nimport type { TaskItem } from \"../../task-card/types\";\nimport { TaskTreeName } from \"./task-tree-name\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeDragOverlayProps {\n  item: TaskItem;\n  level: number;\n  className?: string;\n}\n\n/**\n * Cursor-follow visual mounted inside @dnd-kit's `<DragOverlay>`. Kept\n * deliberately spare — name only, with a slight elevation — so it reads as\n * a \"ghost\" and doesn't fight the real row underneath.\n */\nexport function TaskTreeDragOverlay({\n  item,\n  level,\n  className,\n}: TaskTreeDragOverlayProps) {\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-sm shadow-md\",\n        \"max-w-sm cursor-grabbing\",\n        className,\n      )}\n      data-level={level}\n    >\n      <TaskTreeName name={item.name} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-drag-overlay.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-toolbar.tsx",
      "content": "\"use client\";\n\nimport { Plus } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { useTaskTreeStateContext } from \"../hooks/use-task-tree-context\";\nimport { TaskTreeSearchInput } from \"./task-tree-search-input\";\nimport { TaskTreeSortDropdown } from \"./task-tree-sort-dropdown\";\nimport { TaskTreeFilterDropdown } from \"./task-tree-filter-dropdown\";\nimport { TaskTreeFilterActiveToggle } from \"./task-tree-filter-active-toggle\";\nimport { TaskTreeBulkActionBar } from \"./task-tree-bulk-action-bar\";\nimport type {\n  TaskItem,\n  TaskPermissions,\n  TaskStatusOption,\n} from \"../../task-card/types\";\nimport { evalPermission } from \"../lib/permissions\";\nimport { cn } from \"@/lib/utils\";\n\n// Synthetic level-0 item used only to evaluate the root-create gate against\n// `permissions.default` / `byLevel[0]` (there's no real root node to attach a\n// rule to). Its empty id never matches a `byItem` rule.\nconst ROOT_CREATE_SENTINEL: TaskItem = {\n  id: \"\",\n  name: \"\",\n  status: \"\",\n  active: true,\n  setAt: \"\",\n};\n\nexport interface TaskTreeToolbarProps {\n  statusOptions?: ReadonlyArray<TaskStatusOption>;\n  /**\n   * Bulk-edit has no matching handle method (Q4 lock — v0.1 does not bake\n   * an edit dialog). When provided, the Edit button renders and fires this\n   * with the current selection; consumer wires their own dialog. When\n   * omitted, the Edit button is hidden.\n   */\n  onBulkEdit?: (args: { ids: ReadonlyArray<string> }) => void;\n  /**\n   * Optional factory for the \"+ New\" button. Falls back to a built-in\n   * default item (`name: \"Untitled\"`, first statusOption, `active: true`,\n   * `setAt: now`) when omitted but `statusOptions` is non-empty. Button\n   * hides entirely if neither path can produce a valid TaskItem or if\n   * `readOnly` is set.\n   */\n  createItem?: () => TaskItem;\n  /**\n   * Intercepts the \"+ New\" click. When provided, the toolbar calls this\n   * with the factory's output instead of committing to the tree itself —\n   * the consumer (e.g., `<TaskTreeWithEditor>`) takes over to open an\n   * editor on a pending item and commit only on user submit.\n   */\n  onCreateRequest?: (item: TaskItem) => void;\n  /** Suppresses the \"+ New\" button when true. */\n  readOnly?: boolean;\n  /**\n   * Gates the \"+ New\" button AND the bulk action bar's Delete button.\n   * Without an editing affordance there's nothing meaningful to do with\n   * a freshly-added \"Untitled\" row or with a row the user can't otherwise\n   * interact with — so we hide both.\n   */\n  editable?: boolean;\n  /**\n   * Permission matrix. The \"+ New\" (root-create) button also honors\n   * `default` / `byLevel[0]` `addChildren` so root creation can be denied via\n   * the matrix (there's no root node to attach a `byItem` rule to).\n   */\n  permissions?: TaskPermissions;\n  className?: string;\n}\n\n/**\n * Default toolbar. Reads state from context; the host passes statusOptions +\n * onBulkEdit (the only bulk action with no handle method — Q4 lock means\n * consumers wire their own edit dialog).\n *\n * Activate / Deactivate / Delete go through the handle methods\n * (state.toggleActiveBulk, state.removeItems) so they actually mutate\n * the tree. The matching event (onBulkToggleActive / onBulkRemove) fires\n * automatically via the handle's internal event dispatcher.\n *\n * Layout per plan §7.1:\n *   [Search input (grows)] [Active toggle] [Sort dropdown] [Filter dropdown]\n *   [Bulk action bar — when selectedIds.size > 0]\n */\nexport function TaskTreeToolbar({\n  statusOptions,\n  onBulkEdit,\n  createItem,\n  onCreateRequest,\n  readOnly,\n  editable = false,\n  permissions,\n  className,\n}: TaskTreeToolbarProps) {\n  const state = useTaskTreeStateContext();\n\n  const selectedCount = state.selectedIds.size;\n  const selectedIdsArray = (): string[] => Array.from(state.selectedIds);\n\n  // Resolve the new-item factory. Explicit prop wins; otherwise build a\n  // minimal default that satisfies TaskItem's required fields — needs\n  // statusOptions[0] for `status`. When neither path can produce an item\n  // we hide the button rather than insert invalid data.\n  const factory: (() => TaskItem) | null =\n    createItem ??\n    (statusOptions && statusOptions.length > 0\n      ? () => ({\n          id:\n            typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n              ? crypto.randomUUID()\n              : `todo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,\n          name: \"Untitled\",\n          status: statusOptions[0].value,\n          active: true,\n          setAt: new Date().toISOString(),\n        })\n      : null);\n\n  // Root-create gate: honor the matrix's level-0 `addChildren` rule (mapped via\n  // the dropIntoChildren action) so consumers can deny root creation.\n  const canCreateRoot = evalPermission(\n    permissions,\n    \"dropIntoChildren\",\n    ROOT_CREATE_SENTINEL,\n    0,\n  );\n  const canAdd = editable && !readOnly && factory !== null && canCreateRoot;\n  const canBulkRemove = editable && !readOnly;\n\n  return (\n    <div\n      role=\"toolbar\"\n      aria-label=\"Tree controls\"\n      className={cn(\n        \"flex flex-wrap items-center gap-2 border-b border-border bg-background/50 px-2 py-1.5\",\n        className,\n      )}\n    >\n      <TaskTreeSearchInput value={state.query} onChange={state.setQuery} />\n      <TaskTreeFilterActiveToggle\n        value={state.filter.active}\n        onChange={(active) =>\n          state.setFilter({ ...state.filter, active })\n        }\n      />\n      <TaskTreeSortDropdown value={state.sort} onChange={state.setSort} />\n      <TaskTreeFilterDropdown\n        value={state.filter}\n        onChange={state.setFilter}\n        items={state.items}\n        statusOptions={statusOptions}\n      />\n      {canAdd && (\n        <Button\n          size=\"sm\"\n          variant=\"default\"\n          className=\"ml-auto\"\n          onClick={(e) => {\n            const next = factory!();\n            if (onCreateRequest) {\n              // Blur the trigger before the consumer mounts a Radix Dialog\n              // — Radix flips aria-hidden on the surrounding tree wrapper\n              // synchronously on open, and a still-focused button inside\n              // an aria-hidden ancestor trips the browser's a11y warning.\n              // Blurring here lets the dialog's auto-focus take over\n              // cleanly.\n              (e.currentTarget as HTMLButtonElement).blur();\n              // Deferred-commit path — consumer opens an editor and commits\n              // on submit. Toolbar stays out of the tree mutation entirely.\n              onCreateRequest(next);\n              return;\n            }\n            // Default path — commit immediately + focus.\n            state.addItem(next);\n            state.focusItem(next.id);\n          }}\n        >\n          <Plus className=\"size-3.5\" />\n          New\n        </Button>\n      )}\n      {selectedCount > 0 && (\n        <TaskTreeBulkActionBar\n          count={selectedCount}\n          onToggleActive={(next) =>\n            state.toggleActiveBulk(selectedIdsArray(), next)\n          }\n          onRemove={\n            canBulkRemove\n              ? () => state.removeItems(selectedIdsArray())\n              : undefined\n          }\n          onEdit={\n            onBulkEdit\n              ? () => onBulkEdit({ ids: selectedIdsArray() })\n              : undefined\n          }\n          onClear={state.clearSelection}\n        />\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-toolbar.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-search-input.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Search, X } from \"lucide-react\";\nimport { Input } from \"@/components/ui/input\";\nimport { Button } from \"@/components/ui/button\";\nimport { useDebouncedCallback } from \"../hooks/use-debounced-callback\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeSearchInputProps {\n  /** Current debounced query — used as the canonical source on external changes. */\n  value: string;\n  onChange: (next: string) => void;\n  /** Default 200ms per Q-P4. */\n  delayMs?: number;\n  placeholder?: string;\n  className?: string;\n}\n\n/**\n * Search input. Raw text lives in a local input ref so each keystroke\n * doesn't churn the reducer; a debounced (default 200ms) wrapper publishes\n * the trimmed value via `onChange`. External resets (clear-all-filters)\n * sync the local input by re-seeding on prop change.\n */\nexport function TaskTreeSearchInput({\n  value,\n  onChange,\n  delayMs = 200,\n  placeholder = \"Search items...\",\n  className,\n}: TaskTreeSearchInputProps) {\n  const [local, setLocal] = useState(value);\n  const [lastSeenValue, setLastSeenValue] = useState(value);\n\n  // Adjust local-on-prop-change without an effect — React's recommended\n  // pattern for \"derived state that syncs to a prop\" (avoids the cascading-\n  // render warning that fires for setState-in-useEffect on a value dep).\n  if (value !== lastSeenValue) {\n    setLastSeenValue(value);\n    setLocal(value);\n  }\n\n  const debounced = useDebouncedCallback(onChange, delayMs);\n\n  return (\n    <div className={cn(\"relative flex-1 min-w-0\", className)}>\n      <Search className=\"pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground\" />\n      <Input\n        type=\"search\"\n        value={local}\n        placeholder={placeholder}\n        onChange={(e) => {\n          setLocal(e.target.value);\n          debounced(e.target.value);\n        }}\n        className=\"h-8 pl-7 pr-7\"\n        aria-label=\"Search todo tree\"\n      />\n      {local.length > 0 && (\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          aria-label=\"Clear search\"\n          onClick={() => {\n            setLocal(\"\");\n            onChange(\"\");\n          }}\n          className=\"absolute right-0.5 top-1/2 size-7 -translate-y-1/2 p-0\"\n        >\n          <X className=\"size-3\" />\n        </Button>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-search-input.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-sort-dropdown.tsx",
      "content": "\"use client\";\n\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { ArrowUpDown } from \"lucide-react\";\nimport type { TaskTreeSort } from \"../types\";\nimport { cn } from \"@/lib/utils\";\n\ninterface SortOption {\n  value: string;\n  label: string;\n  spec: Exclude<TaskTreeSort, { kind: \"custom\" }>;\n}\n\nconst SORT_OPTIONS: ReadonlyArray<SortOption> = [\n  { value: \"name-asc\", label: \"Name ↑\", spec: { kind: \"name\", direction: \"asc\" } },\n  { value: \"name-desc\", label: \"Name ↓\", spec: { kind: \"name\", direction: \"desc\" } },\n  { value: \"setAt-asc\", label: \"Created ↑\", spec: { kind: \"setAt\", direction: \"asc\" } },\n  { value: \"setAt-desc\", label: \"Created ↓\", spec: { kind: \"setAt\", direction: \"desc\" } },\n  { value: \"expireAt-asc\", label: \"Due ↑\", spec: { kind: \"expireAt\", direction: \"asc\" } },\n  { value: \"expireAt-desc\", label: \"Due ↓\", spec: { kind: \"expireAt\", direction: \"desc\" } },\n  { value: \"status-asc\", label: \"Status ↑\", spec: { kind: \"status\", direction: \"asc\" } },\n  { value: \"status-desc\", label: \"Status ↓\", spec: { kind: \"status\", direction: \"desc\" } },\n];\n\nfunction sortToValue(sort: TaskTreeSort): string | undefined {\n  if (sort.kind === \"custom\") return undefined;\n  return `${sort.kind}-${sort.direction}`;\n}\n\nexport interface TaskTreeSortDropdownProps {\n  value: TaskTreeSort;\n  onChange: (next: TaskTreeSort) => void;\n  className?: string;\n}\n\n/**\n * Sort selector. 4 sort kinds × 2 directions = 8 options + a \"Custom\" display\n * label when the consumer has set a custom comparator (the dropdown reads\n * read-only in that case until the consumer flips back to a named kind).\n *\n * F-cross-13 pre-emption (L11): onValueChange widened to accept `string | null`\n * (Base UI's Select signature). Null coalesces to \"name-asc\".\n */\nexport function TaskTreeSortDropdown({\n  value,\n  onChange,\n  className,\n}: TaskTreeSortDropdownProps) {\n  const isCustom = value.kind === \"custom\";\n  const current = sortToValue(value);\n\n  return (\n    <Select\n      value={current ?? \"\"}\n      disabled={isCustom}\n      onValueChange={(v: string | null) => {\n        const choice = v ?? \"name-asc\";\n        const match = SORT_OPTIONS.find((o) => o.value === choice);\n        if (!match) return;\n        onChange(match.spec);\n      }}\n    >\n      <SelectTrigger\n        aria-label=\"Sort\"\n        className={cn(\"h-8 w-[140px]\", className)}\n      >\n        <ArrowUpDown className=\"mr-1 size-3.5 text-muted-foreground\" />\n        <SelectValue placeholder={isCustom ? \"Custom\" : \"Sort\"} />\n      </SelectTrigger>\n      <SelectContent>\n        {SORT_OPTIONS.map((o) => (\n          <SelectItem key={o.value} value={o.value}>\n            {o.label}\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-sort-dropdown.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-filter-active-toggle.tsx",
      "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ntype ActiveMode = \"all\" | \"active\" | \"inactive\";\n\nexport interface TaskTreeFilterActiveToggleProps {\n  value: ActiveMode | undefined;\n  onChange: (next: ActiveMode) => void;\n  className?: string;\n}\n\nconst MODES: ReadonlyArray<{ value: ActiveMode; label: string }> = [\n  { value: \"all\", label: \"All\" },\n  { value: \"active\", label: \"Active\" },\n  { value: \"inactive\", label: \"Done\" },\n];\n\n/**\n * Segmented control for the active/inactive filter. Three values; uses\n * native `<button>` array with `aria-pressed` rather than a Tabs primitive\n * to keep the trigger trivially compact.\n */\nexport function TaskTreeFilterActiveToggle({\n  value = \"all\",\n  onChange,\n  className,\n}: TaskTreeFilterActiveToggleProps) {\n  return (\n    <div\n      role=\"group\"\n      aria-label=\"Filter by active state\"\n      className={cn(\n        \"inline-flex h-8 items-center rounded-md border border-input bg-background p-0.5\",\n        className,\n      )}\n    >\n      {MODES.map((mode) => (\n        <Button\n          key={mode.value}\n          type=\"button\"\n          variant={value === mode.value ? \"secondary\" : \"ghost\"}\n          size=\"sm\"\n          aria-pressed={value === mode.value}\n          onClick={() => onChange(mode.value)}\n          className=\"h-7 px-2 text-xs\"\n        >\n          {mode.label}\n        </Button>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-filter-active-toggle.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-filter-dropdown.tsx",
      "content": "\"use client\";\n\nimport { useMemo, useState } from \"react\";\nimport { Filter, X } from \"lucide-react\";\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Badge } from \"@/components/ui/badge\";\nimport type {\n  TaskItem,\n  TaskPerson,\n  TaskStatusOption,\n} from \"../../task-card/types\";\nimport type { TaskTreeFilter } from \"../types\";\nimport { forEachItem } from \"../lib/tree-walker\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeFilterDropdownProps {\n  value: TaskTreeFilter;\n  onChange: (next: TaskTreeFilter) => void;\n  items: TaskItem[];\n  statusOptions?: ReadonlyArray<TaskStatusOption>;\n  className?: string;\n}\n\n/**\n * Multi-select Popover for status + person + clear. Status chips come from\n * the consumer's statusOptions; persons are gathered lazily from the live\n * items tree on first open (and recomputed when items change while the\n * popover is open). Empty arrays mean \"no filter applied\" semantically.\n */\nexport function TaskTreeFilterDropdown({\n  value,\n  onChange,\n  items,\n  statusOptions,\n  className,\n}: TaskTreeFilterDropdownProps) {\n  const [open, setOpen] = useState(false);\n\n  const persons = useMemo(() => collectPersons(items), [items]);\n  const activeStatusCount = value.statuses?.length ?? 0;\n  const activePersonCount = value.personIds?.length ?? 0;\n  const totalActive = activeStatusCount + activePersonCount;\n\n  const toggleStatus = (statusValue: string) => {\n    const current = value.statuses ?? [];\n    const next = current.includes(statusValue)\n      ? current.filter((s) => s !== statusValue)\n      : [...current, statusValue];\n    onChange({ ...value, statuses: next });\n  };\n\n  const togglePerson = (personId: string) => {\n    const current = value.personIds ?? [];\n    const next = current.includes(personId)\n      ? current.filter((p) => p !== personId)\n      : [...current, personId];\n    onChange({ ...value, personIds: next });\n  };\n\n  return (\n    <Popover open={open} onOpenChange={setOpen}>\n      {/* F-cross-13 path-b: trigger IS the button (native <button> in both\n          backends) — `asChild` is Radix-only; `buttonVariants` reproduces the\n          outline/sm look. (v0.3.2) */}\n      <PopoverTrigger\n        type=\"button\"\n        aria-label=\"Filter\"\n        className={cn(\n          buttonVariants({ variant: \"outline\", size: \"sm\" }),\n          \"h-8 gap-1\",\n          className,\n        )}\n      >\n        <Filter className=\"size-3.5\" />\n        <span>Filter</span>\n        {totalActive > 0 && (\n          <Badge variant=\"secondary\" className=\"ml-1 h-4 px-1 text-[10px]\">\n            {totalActive}\n          </Badge>\n        )}\n      </PopoverTrigger>\n      <PopoverContent\n        className=\"w-72 max-h-[60vh] overflow-y-auto p-3\"\n        align=\"end\"\n      >\n        <div className=\"space-y-3\">\n          {statusOptions && statusOptions.length > 0 && (\n            <div className=\"space-y-1.5\">\n              <div className=\"text-xs font-medium text-muted-foreground\">\n                Status\n              </div>\n              <div className=\"space-y-1\">\n                {statusOptions.map((opt) => {\n                  const checked = value.statuses?.includes(opt.value) ?? false;\n                  return (\n                    <label\n                      key={opt.value}\n                      className=\"flex items-center gap-2 text-sm cursor-pointer\"\n                    >\n                      <Checkbox\n                        checked={checked}\n                        onCheckedChange={() => toggleStatus(opt.value)}\n                      />\n                      <span>{opt.label}</span>\n                    </label>\n                  );\n                })}\n              </div>\n            </div>\n          )}\n\n          {persons.length > 0 && (\n            <div className=\"space-y-1.5\">\n              <div className=\"text-xs font-medium text-muted-foreground\">\n                Person\n              </div>\n              <div className=\"space-y-1\">\n                {persons.map((p) => {\n                  const checked = value.personIds?.includes(p.id) ?? false;\n                  return (\n                    <label\n                      key={p.id}\n                      className=\"flex items-center gap-2 text-sm cursor-pointer\"\n                    >\n                      <Checkbox\n                        checked={checked}\n                        onCheckedChange={() => togglePerson(p.id)}\n                      />\n                      <span>{p.name}</span>\n                    </label>\n                  );\n                })}\n              </div>\n            </div>\n          )}\n\n          {totalActive > 0 && (\n            <Button\n              type=\"button\"\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={() => onChange({ ...value, statuses: [], personIds: [] })}\n              className=\"w-full justify-start gap-1 px-1 h-7 text-xs\"\n            >\n              <X className=\"size-3\" /> Clear status + person\n            </Button>\n          )}\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n\n/**\n * Lazy person collection: walks the tree once collecting every unique\n * person id from both targetPerson and creatorPerson fields. Preserves\n * insertion order for stable rendering.\n */\nfunction collectPersons(items: ReadonlyArray<TaskItem>): TaskPerson[] {\n  const map = new Map<string, TaskPerson>();\n  forEachItem(items, (item) => {\n    if (item.targetPerson && !map.has(item.targetPerson.id)) {\n      map.set(item.targetPerson.id, item.targetPerson);\n    }\n    if (item.creatorPerson && !map.has(item.creatorPerson.id)) {\n      map.set(item.creatorPerson.id, item.creatorPerson);\n    }\n  });\n  return Array.from(map.values());\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-filter-dropdown.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-bulk-action-bar.tsx",
      "content": "\"use client\";\n\nimport { Trash2, Pencil, CheckCheck, X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeBulkActionBarProps {\n  count: number;\n  /** When omitted the matching button is hidden. */\n  onToggleActive?: (nextActive: boolean) => void;\n  onRemove?: () => void;\n  onEdit?: () => void;\n  onClear?: () => void;\n  className?: string;\n}\n\n/**\n * Bulk action surface — appears in the toolbar when any rows are selected.\n * Each action button is gated on the matching callback being provided;\n * `onEdit` in particular is consumer-only (bulk-edit popup isn't baked into\n * v0.1 per Q4 lock — consumers wire their own dialog).\n */\nexport function TaskTreeBulkActionBar({\n  count,\n  onToggleActive,\n  onRemove,\n  onEdit,\n  onClear,\n  className,\n}: TaskTreeBulkActionBarProps) {\n  if (count === 0) return null;\n  return (\n    <div\n      role=\"toolbar\"\n      aria-label={`${count} item${count === 1 ? \"\" : \"s\"} selected`}\n      className={cn(\n        \"inline-flex h-8 items-center gap-1 rounded-md border border-input bg-background px-1.5\",\n        className,\n      )}\n    >\n      <Badge variant=\"secondary\" className=\"h-5 px-1.5 text-xs\">\n        {count}\n      </Badge>\n      <Separator orientation=\"vertical\" className=\"h-4\" />\n      {onToggleActive && (\n        <>\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => onToggleActive(true)}\n            className=\"h-6 gap-1 px-2 text-xs\"\n          >\n            <CheckCheck className=\"size-3\" /> Activate\n          </Button>\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => onToggleActive(false)}\n            className=\"h-6 px-2 text-xs\"\n          >\n            Deactivate\n          </Button>\n        </>\n      )}\n      {onEdit && (\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={onEdit}\n          className=\"h-6 gap-1 px-2 text-xs\"\n        >\n          <Pencil className=\"size-3\" /> Edit\n        </Button>\n      )}\n      {onRemove && (\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={onRemove}\n          className=\"h-6 gap-1 px-2 text-xs text-destructive hover:text-destructive\"\n        >\n          <Trash2 className=\"size-3\" /> Delete\n        </Button>\n      )}\n      {onClear && (\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          aria-label=\"Clear selection\"\n          onClick={onClear}\n          className=\"size-6\"\n        >\n          <X className=\"size-3\" />\n        </Button>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-bulk-action-bar.tsx"
    },
    {
      "path": "src/registry/components/data/task-tree/parts/task-tree-empty-state.tsx",
      "content": "\"use client\";\n\nimport { Inbox, SearchX } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface TaskTreeEmptyStateProps {\n  /** True when the empty list is the result of a filter/search; false when the tree is genuinely empty. */\n  hasFilter: boolean;\n  className?: string;\n}\n\n/**\n * Default placeholder rendered when `visibleItems.length === 0`. Two\n * variants — \"no matches\" (filter active) vs \"no tasks\" (genuinely empty\n * tree). Replace via the `renderEmptyState` slot prop.\n */\nexport function TaskTreeEmptyState({\n  hasFilter,\n  className,\n}: TaskTreeEmptyStateProps) {\n  const Icon = hasFilter ? SearchX : Inbox;\n  return (\n    <div\n      role=\"status\"\n      aria-live=\"polite\"\n      className={cn(\n        \"flex h-full flex-col items-center justify-center gap-2 px-6 py-12 text-center\",\n        className,\n      )}\n    >\n      <Icon className=\"size-8 text-muted-foreground/60\" aria-hidden />\n      <div className=\"text-sm font-medium text-foreground\">\n        {hasFilter ? \"No matches\" : \"No tasks yet\"}\n      </div>\n      <div className=\"text-xs text-muted-foreground\">\n        {hasFilter\n          ? \"Adjust your search or filter to see more rows.\"\n          : \"Items added via the tree's imperative API or DnD will appear here.\"}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/task-tree/parts/task-tree-empty-state.tsx"
    }
  ],
  "categories": [
    "data",
    "task-management"
  ],
  "type": "registry:block"
}