{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban-board",
  "title": "Kanban Board",
  "author": "ilinxa",
  "description": "Drag-and-drop kanban board with swimlanes, tinted columns, per-column rules, and a renderer registry that hosts any card type.",
  "dependencies": [
    "@dnd-kit/core",
    "@dnd-kit/sortable",
    "@dnd-kit/utilities",
    "lucide-react"
  ],
  "registryDependencies": [
    "avatar",
    "badge",
    "button",
    "dropdown-menu",
    "input",
    "popover",
    "textarea"
  ],
  "files": [
    {
      "path": "src/registry/components/data/kanban-board/kanban-board.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo } from \"react\";\nimport {\n  DndContext,\n  KeyboardSensor,\n  PointerSensor,\n  closestCenter,\n  pointerWithin,\n  rectIntersection,\n  useSensor,\n  useSensors,\n  type CollisionDetection,\n} from \"@dnd-kit/core\";\nimport { sortableKeyboardCoordinates } from \"@dnd-kit/sortable\";\nimport { cn } from \"@/lib/utils\";\nimport { Board } from \"./parts/board\";\nimport { KanbanDragOverlay } from \"./parts/drag-overlay\";\nimport { useDragHandlers } from \"./hooks/use-drag-handlers\";\nimport { useKanbanState } from \"./hooks/use-kanban-state\";\nimport { useRendererMap } from \"./hooks/use-renderer-map\";\nimport { newColumnId, newItemId } from \"./lib/ids\";\nimport { DEFAULT_PALETTE } from \"./lib/palette\";\nimport { validateData } from \"./lib/data\";\nimport type {\n  AnyKanbanCardRenderer,\n  KanbanBoardProps,\n  KanbanColumn,\n  KanbanItem,\n} from \"./types\";\n\n// Pointer-based collision detection so an item drops wherever the cursor is —\n// onto a card (reorder) or anywhere in a column's empty space / the column\n// wrapper (append to end), not only when a droppable's *center* is nearest.\n// Column reorder branches to center-of-columns-only so a card inside the target\n// column can't swallow the column drop. `rectIntersection` is a fling fallback\n// when the pointer isn't within any droppable.\nconst kanbanCollisionDetection: CollisionDetection = (args) => {\n  if (args.active.data.current?.kind === \"column\") {\n    const columnsOnly = args.droppableContainers.filter(\n      (c) => c.data.current?.kind === \"column\",\n    );\n    return closestCenter({ ...args, droppableContainers: columnsOnly });\n  }\n  const pointer = pointerWithin(args);\n  return pointer.length > 0 ? pointer : rectIntersection(args);\n};\n\nexport function KanbanBoard({\n  renderers,\n  data,\n  defaultData,\n  onChange,\n  onItemCreate,\n  onItemUpdate,\n  onItemDelete,\n  onColumnCreate,\n  onColumnUpdate,\n  onColumnDelete,\n  onItemClick,\n  onItemMove,\n  palette,\n  readOnly = false,\n  \"aria-label\": ariaLabel = \"Kanban board\",\n  className,\n}: KanbanBoardProps) {\n  const [state, dispatch] = useKanbanState({ data, defaultData, onChange });\n  const rendererMap = useRendererMap(renderers);\n  const palettePinned = useMemo(() => palette ?? DEFAULT_PALETTE, [palette]);\n\n  // Internal drag handlers + create-handler still use positional shapes for\n  // their internal contract. Adapt the public object-shape callbacks at this\n  // boundary so dispatch / useDragHandlers / handleItemCreate stay untouched.\n  const handlePartItemMove = onItemMove\n    ? (\n        item: KanbanItem,\n        from: { columnId: string; swimlaneId?: string },\n        to: { columnId: string; swimlaneId?: string },\n      ) => onItemMove({ item, from, to })\n    : undefined;\n\n  // Mount-time validation (developer feedback only; doesn't block render).\n  useEffect(() => {\n    if (renderers.length === 0) {\n      console.error(\n        \"[kanban-board] `renderers` prop is required and must contain at least one entry.\",\n      );\n      return;\n    }\n    const result = validateData(state, renderers);\n    if (!result.valid) {\n      for (const err of result.errors) {\n        console.warn(`[kanban-board] ${err}`);\n      }\n    }\n    // Re-validate when renderers OR data change, so duplicate-id / unknown-renderer\n    // problems introduced by later (controlled) `data` updates are still surfaced.\n  }, [renderers, state]);\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),\n    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),\n  );\n\n  const {\n    onDragStart,\n    onDragOver,\n    onDragEnd,\n    onDragCancel,\n    activeItem,\n    activeRendererId,\n  } = useDragHandlers({\n    data: state,\n    readOnly,\n    dispatch,\n    onItemMove: handlePartItemMove,\n  });\n\n  // Internal handlers translating UI events into reducer actions + consumer callbacks.\n  function handleItemCreate(columnId: string, item: KanbanItem) {\n    dispatch({ type: \"create-item\", columnId, item });\n    onItemCreate?.({ columnId, item });\n  }\n\n  function handleItemUpdate(item: KanbanItem) {\n    dispatch({ type: \"update-item\", item });\n    onItemUpdate?.(item);\n  }\n\n  function handleItemDelete(itemId: string) {\n    dispatch({ type: \"delete-item\", itemId });\n    onItemDelete?.(itemId);\n  }\n\n  function handleColumnCreate(column: KanbanColumn) {\n    dispatch({ type: \"create-column\", column });\n    onColumnCreate?.(column);\n  }\n\n  function handleColumnUpdate(column: KanbanColumn) {\n    dispatch({ type: \"update-column\", column });\n    onColumnUpdate?.(column);\n  }\n\n  function handleColumnDelete(columnId: string) {\n    dispatch({ type: \"delete-column\", columnId });\n    onColumnDelete?.(columnId);\n  }\n\n  function handleSetColor(columnId: string, color: string | undefined) {\n    dispatch({ type: \"set-color\", columnId, color });\n  }\n\n  function handleToggleCollapse(columnId: string) {\n    dispatch({ type: \"toggle-collapse\", columnId });\n  }\n\n  if (renderers.length === 0) {\n    return (\n      <div\n        role=\"region\"\n        aria-label={ariaLabel}\n        className={cn(\n          \"flex h-full min-h-32 items-center justify-center rounded-md border border-destructive/40 bg-destructive/5 p-6 text-sm text-destructive\",\n          className,\n        )}\n      >\n        kanban-board requires at least one renderer\n      </div>\n    );\n  }\n\n  // In readOnly mode, skip DnD wiring entirely.\n  if (readOnly) {\n    return (\n      <div\n        role=\"region\"\n        aria-label={ariaLabel}\n        className={cn(\"relative flex h-full min-h-0 flex-col\", className)}\n      >\n        <Board\n          data={state}\n          swimlanes={state.swimlanes}\n          rendererMap={rendererMap}\n          renderers={renderers}\n          palette={palettePinned}\n          readOnly\n          activeRendererId={undefined}\n          onItemClick={onItemClick}\n          onSetColor={handleSetColor}\n          onToggleCollapse={handleToggleCollapse}\n        />\n      </div>\n    );\n  }\n\n  return (\n    <DndContext\n      sensors={sensors}\n      collisionDetection={kanbanCollisionDetection}\n      onDragStart={onDragStart}\n      onDragOver={onDragOver}\n      onDragEnd={onDragEnd}\n      onDragCancel={onDragCancel}\n    >\n      <div\n        role=\"region\"\n        aria-label={ariaLabel}\n        className={cn(\"relative flex h-full min-h-0 flex-col\", className)}\n      >\n        <Board\n          data={state}\n          swimlanes={state.swimlanes}\n          rendererMap={rendererMap}\n          renderers={renderers}\n          palette={palettePinned}\n          readOnly={false}\n          activeRendererId={activeRendererId}\n          onItemCreate={onItemCreate ? handleItemCreate : undefined}\n          onItemUpdate={onItemUpdate ? handleItemUpdate : undefined}\n          onItemDelete={onItemDelete ? handleItemDelete : undefined}\n          onItemClick={onItemClick}\n          // Ungated: a self-editing renderer (e.g. the card-tree adapter) must persist\n          // its in-place edits to board state even when the consumer wires no onItemUpdate.\n          onItemDataChange={handleItemUpdate}\n          onColumnCreate={onColumnCreate ? handleColumnCreate : undefined}\n          onColumnUpdate={onColumnUpdate ? handleColumnUpdate : undefined}\n          onColumnDelete={onColumnDelete ? handleColumnDelete : undefined}\n          onSetColor={handleSetColor}\n          onToggleCollapse={handleToggleCollapse}\n        />\n      </div>\n      <KanbanDragOverlay activeItem={activeItem} rendererMap={rendererMap} />\n    </DndContext>\n  );\n}\n\n// Public helper exported for advanced consumers writing custom inline editors.\nexport function findRenderer(\n  renderers: AnyKanbanCardRenderer[],\n  rendererId: string,\n): AnyKanbanCardRenderer | undefined {\n  return renderers.find((r) => r.id === rendererId);\n}\n\n// Re-export ID helpers for consumers building items / columns programmatically.\nexport { newItemId, newColumnId };\n",
      "type": "registry:component",
      "target": "components/kanban-board/kanban-board.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/index.ts",
      "content": "export { KanbanBoard, findRenderer, newColumnId, newItemId } from \"./kanban-board\";\nexport { kanbanCardRenderer, KanbanCardView } from \"./parts/kanban-card\";\nexport { kanbanNoteRenderer, KanbanNoteView } from \"./parts/kanban-note\";\nexport { DEFAULT_PALETTE } from \"./lib/palette\";\nexport { validateData } from \"./lib/data\";\nexport type {\n  // Type-erased renderer. Consumers normally build typed\n  // `KanbanCardRenderer<TData>` values, but this is the declared element type\n  // of `KanbanBoardProps[\"renderers\"]` and of the exported `findRenderer`'s\n  // first parameter, so it has to be nameable\n  // (validate:barrel-exports, 2026-08-17).\n  AnyKanbanCardRenderer,\n  KanbanBoardProps,\n  KanbanCardData,\n  KanbanCardRenderer,\n  KanbanColumn,\n  KanbanData,\n  KanbanItem,\n  KanbanNoteData,\n  KanbanPaletteSwatch,\n  KanbanRenderContext,\n  KanbanSwimlane,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/kanban-board/index.ts"
    },
    {
      "path": "src/registry/components/data/kanban-board/types.ts",
      "content": "import type { ReactNode } from \"react\";\n\nexport type KanbanRenderContext = {\n  itemId: string;\n  columnId: string;\n  swimlaneId?: string;\n  isDragging: boolean;\n  isLocked: boolean;\n  /**\n   * Persist an edit to this item's `data` back into the board (additive, v0.4).\n   * A renderer that owns its own edit UI (e.g. the card-tree adapter) calls this\n   * with the next data; the board dispatches `update-item` and notifies `onItemUpdate`.\n   * Undefined in read-only mode or when the item is locked. The renderer is\n   * responsible for passing data of its own `TData` shape.\n   */\n  onDataChange?: (nextData: unknown) => void;\n};\n\nexport type KanbanCardRenderer<TData = unknown> = {\n  id: string;\n  label: string;\n  render: (data: TData, ctx: KanbanRenderContext) => ReactNode;\n  newItem?: () => TData;\n  editForm?: (\n    data: TData,\n    onSave: (next: TData) => void,\n    onCancel: () => void,\n  ) => ReactNode;\n  /**\n   * Where the kanban-level drag listeners attach.\n   * - `\"shell\"` (default): the entire item is the drag activator (cursor-grab on the whole card).\n   * - `\"header\"`: only a thin grip strip rendered above the item activates dragging; the body\n   *    stays fully interactive. Use for renderers that own internal pointer interactions\n   *    (inline editors, nested DnD), e.g. the card-tree adapter.\n   */\n  dragHandle?: \"shell\" | \"header\";\n};\n\n/**\n * Type-erased renderer used inside the board's registry. The board never reads\n * the data shape — it just hands data to the renderer's render fn — so erasing\n * TData here is sound. Consumers always interact with the typed\n * `KanbanCardRenderer<TData>` surface.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type AnyKanbanCardRenderer = KanbanCardRenderer<any>;\n\nexport type KanbanItem = {\n  id: string;\n  rendererId: string;\n  data: unknown;\n  swimlaneId?: string;\n  locked?: boolean;\n};\n\nexport type KanbanColumn = {\n  id: string;\n  title: string;\n  description?: string;\n  color?: string;\n  collapsed?: boolean;\n  items: KanbanItem[];\n  allowReorder?: boolean;\n  allowIncoming?: boolean;\n  allowOutgoing?: boolean;\n  acceptsRendererIds?: string[];\n  maxItems?: number;\n};\n\nexport type KanbanSwimlane = {\n  id: string;\n  title: string;\n  description?: string;\n  color?: string;\n};\n\nexport type KanbanData = {\n  columns: KanbanColumn[];\n  swimlanes?: KanbanSwimlane[];\n};\n\nexport type KanbanPaletteSwatch = {\n  id: string;\n  label: string;\n  cssVar: string;\n};\n\nexport type KanbanBoardProps = {\n  renderers: AnyKanbanCardRenderer[];\n\n  data?: KanbanData;\n  defaultData?: KanbanData;\n  onChange?: (next: KanbanData) => void;\n\n  /** Item-create handler — object-shape. v0.2 cutover from `(columnId, item)`. */\n  onItemCreate?: (args: { columnId: string; item: KanbanItem }) => void;\n  onItemUpdate?: (item: KanbanItem) => void;\n  onItemDelete?: (itemId: string) => void;\n  onColumnCreate?: (column: KanbanColumn) => void;\n  onColumnUpdate?: (column: KanbanColumn) => void;\n  onColumnDelete?: (columnId: string) => void;\n\n  onItemClick?: (item: KanbanItem) => void;\n  /** Item-move handler — object-shape. v0.2 cutover from `(item, from, to)`. */\n  onItemMove?: (args: {\n    item: KanbanItem;\n    from: { columnId: string; swimlaneId?: string };\n    to: { columnId: string; swimlaneId?: string };\n  }) => void;\n\n  palette?: KanbanPaletteSwatch[];\n  readOnly?: boolean;\n\n  \"aria-label\"?: string;\n  className?: string;\n};\n\nexport type KanbanCardData = {\n  title: string;\n  description?: string;\n  tags?: { label: string; color?: string }[];\n  assignees?: { id: string; name: string; avatarUrl?: string }[];\n  /** Serializable cell value. Arbitrary nodes belong behind a renderer's `render` fn, not in JSON data. */\n  meta?: { key: string; label: string; value: string | number }[];\n};\n\nexport type KanbanNoteData = {\n  title: string;\n  body?: string;\n  color?: string;\n};\n\nexport type KanbanAction =\n  | { type: \"move-item\"; itemId: string; toColumnId: string; toIndex: number; toSwimlaneId?: string }\n  | { type: \"reorder-column\"; columnId: string; toIndex: number }\n  | { type: \"create-item\"; columnId: string; item: KanbanItem; index?: number }\n  | { type: \"update-item\"; item: KanbanItem }\n  | { type: \"delete-item\"; itemId: string }\n  | { type: \"create-column\"; column: KanbanColumn; index?: number }\n  | { type: \"update-column\"; column: KanbanColumn }\n  | { type: \"delete-column\"; columnId: string }\n  | { type: \"toggle-collapse\"; columnId: string }\n  | { type: \"set-color\"; columnId: string; color: string | undefined }\n  | { type: \"replace\"; data: KanbanData };\n",
      "type": "registry:component",
      "target": "components/kanban-board/types.ts"
    },
    {
      "path": "src/registry/components/data/kanban-board/lib/data.ts",
      "content": "import type {\n  AnyKanbanCardRenderer,\n  KanbanColumn,\n  KanbanData,\n  KanbanItem,\n} from \"../types\";\n\nexport function findItem(\n  data: KanbanData,\n  itemId: string,\n): { item: KanbanItem; columnId: string; index: number } | null {\n  for (const col of data.columns) {\n    const idx = col.items.findIndex((it) => it.id === itemId);\n    if (idx >= 0) return { item: col.items[idx], columnId: col.id, index: idx };\n  }\n  return null;\n}\n\nexport function findColumn(\n  data: KanbanData,\n  columnId: string,\n): { column: KanbanColumn; index: number } | null {\n  const idx = data.columns.findIndex((c) => c.id === columnId);\n  if (idx < 0) return null;\n  return { column: data.columns[idx], index: idx };\n}\n\nexport function moveItem(\n  data: KanbanData,\n  itemId: string,\n  toColumnId: string,\n  toIndex: number,\n  toSwimlaneId?: string,\n): KanbanData {\n  const found = findItem(data, itemId);\n  if (!found) return data;\n\n  const movingItem: KanbanItem = {\n    ...found.item,\n    swimlaneId: toSwimlaneId !== undefined ? toSwimlaneId : found.item.swimlaneId,\n  };\n\n  // Remove from source column.\n  const stripped: KanbanColumn[] = data.columns.map((c) => {\n    if (c.id !== found.columnId) return c;\n    return { ...c, items: c.items.filter((it) => it.id !== itemId) };\n  });\n\n  // Insert into target column. Auto-expand if target was collapsed\n  // (locked contract: \"drop on collapsed → expand\"; bundled into the move\n  // action so it's atomic in both controlled and uncontrolled modes).\n  const result = stripped.map((c) => {\n    if (c.id !== toColumnId) return c;\n    const next = c.items.slice();\n    const clamped = Math.max(0, Math.min(toIndex, next.length));\n    next.splice(clamped, 0, movingItem);\n    return { ...c, items: next, collapsed: c.collapsed ? false : c.collapsed };\n  });\n\n  return { ...data, columns: result };\n}\n\nexport function reorderColumn(\n  data: KanbanData,\n  columnId: string,\n  toIndex: number,\n): KanbanData {\n  const fromIdx = data.columns.findIndex((c) => c.id === columnId);\n  if (fromIdx < 0) return data;\n  const cols = data.columns.slice();\n  const [removed] = cols.splice(fromIdx, 1);\n  const clamped = Math.max(0, Math.min(toIndex, cols.length));\n  cols.splice(clamped, 0, removed);\n  return { ...data, columns: cols };\n}\n\nexport function addItem(\n  data: KanbanData,\n  columnId: string,\n  item: KanbanItem,\n  index?: number,\n): KanbanData {\n  return {\n    ...data,\n    columns: data.columns.map((c) => {\n      if (c.id !== columnId) return c;\n      const next = c.items.slice();\n      const at = index === undefined ? next.length : Math.max(0, Math.min(index, next.length));\n      next.splice(at, 0, item);\n      return { ...c, items: next };\n    }),\n  };\n}\n\nexport function updateItem(data: KanbanData, item: KanbanItem): KanbanData {\n  return {\n    ...data,\n    columns: data.columns.map((c) => ({\n      ...c,\n      items: c.items.map((it) => (it.id === item.id ? item : it)),\n    })),\n  };\n}\n\nexport function deleteItem(data: KanbanData, itemId: string): KanbanData {\n  return {\n    ...data,\n    columns: data.columns.map((c) => ({\n      ...c,\n      items: c.items.filter((it) => it.id !== itemId),\n    })),\n  };\n}\n\nexport function addColumn(\n  data: KanbanData,\n  column: KanbanColumn,\n  index?: number,\n): KanbanData {\n  const next = data.columns.slice();\n  const at = index === undefined ? next.length : Math.max(0, Math.min(index, next.length));\n  next.splice(at, 0, column);\n  return { ...data, columns: next };\n}\n\nexport function updateColumn(data: KanbanData, column: KanbanColumn): KanbanData {\n  return {\n    ...data,\n    columns: data.columns.map((c) => (c.id === column.id ? column : c)),\n  };\n}\n\nexport function deleteColumn(data: KanbanData, columnId: string): KanbanData {\n  return { ...data, columns: data.columns.filter((c) => c.id !== columnId) };\n}\n\nexport function toggleCollapse(data: KanbanData, columnId: string): KanbanData {\n  return {\n    ...data,\n    columns: data.columns.map((c) =>\n      c.id === columnId ? { ...c, collapsed: !c.collapsed } : c,\n    ),\n  };\n}\n\nexport function setColumnColor(\n  data: KanbanData,\n  columnId: string,\n  color: string | undefined,\n): KanbanData {\n  return {\n    ...data,\n    columns: data.columns.map((c) => (c.id === columnId ? { ...c, color } : c)),\n  };\n}\n\nexport type ValidationResult = { valid: boolean; errors: string[] };\n\nexport function validateData(\n  data: KanbanData,\n  renderers: AnyKanbanCardRenderer[],\n): ValidationResult {\n  const errors: string[] = [];\n  const rendererIds = new Set(renderers.map((r) => r.id));\n  const swimlaneIds = new Set((data.swimlanes ?? []).map((l) => l.id));\n  const colIds = new Set<string>();\n  const itemIds = new Set<string>();\n\n  for (const col of data.columns) {\n    if (colIds.has(col.id)) errors.push(`Duplicate column id: ${col.id}`);\n    colIds.add(col.id);\n    for (const item of col.items) {\n      if (itemIds.has(item.id)) errors.push(`Duplicate item id: ${item.id}`);\n      itemIds.add(item.id);\n      if (!rendererIds.has(item.rendererId)) {\n        errors.push(`Item ${item.id} references unknown renderer: ${item.rendererId}`);\n      }\n      if (item.swimlaneId && data.swimlanes && !swimlaneIds.has(item.swimlaneId)) {\n        errors.push(`Item ${item.id} references unknown swimlane: ${item.swimlaneId}`);\n      }\n    }\n  }\n\n  return { valid: errors.length === 0, errors };\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/lib/data.ts"
    },
    {
      "path": "src/registry/components/data/kanban-board/lib/reducer.ts",
      "content": "import type { KanbanAction, KanbanData } from \"../types\";\nimport {\n  addColumn,\n  addItem,\n  deleteColumn,\n  deleteItem,\n  moveItem,\n  reorderColumn,\n  setColumnColor,\n  toggleCollapse,\n  updateColumn,\n  updateItem,\n} from \"./data\";\n\nexport function kanbanReducer(state: KanbanData, action: KanbanAction): KanbanData {\n  switch (action.type) {\n    case \"move-item\":\n      return moveItem(state, action.itemId, action.toColumnId, action.toIndex, action.toSwimlaneId);\n    case \"reorder-column\":\n      return reorderColumn(state, action.columnId, action.toIndex);\n    case \"create-item\":\n      return addItem(state, action.columnId, action.item, action.index);\n    case \"update-item\":\n      return updateItem(state, action.item);\n    case \"delete-item\":\n      return deleteItem(state, action.itemId);\n    case \"create-column\":\n      return addColumn(state, action.column, action.index);\n    case \"update-column\":\n      return updateColumn(state, action.column);\n    case \"delete-column\":\n      return deleteColumn(state, action.columnId);\n    case \"toggle-collapse\":\n      return toggleCollapse(state, action.columnId);\n    case \"set-color\":\n      return setColumnColor(state, action.columnId, action.color);\n    case \"replace\":\n      return action.data;\n  }\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/lib/reducer.ts"
    },
    {
      "path": "src/registry/components/data/kanban-board/lib/permissions.ts",
      "content": "import type { KanbanData } from \"../types\";\nimport { findColumn, findItem } from \"./data\";\n\nexport type CanDropArgs = {\n  data: KanbanData;\n  itemId: string;\n  fromColumnId: string;\n  toColumnId: string;\n  fromSwimlaneId?: string;\n  toSwimlaneId?: string;\n  readOnly: boolean;\n};\n\nexport function canDrop(args: CanDropArgs): boolean {\n  if (args.readOnly) return false;\n\n  const found = findItem(args.data, args.itemId);\n  if (!found) return false;\n  if (found.item.locked) return false;\n\n  const fromCol = findColumn(args.data, args.fromColumnId)?.column;\n  const toCol = findColumn(args.data, args.toColumnId)?.column;\n  if (!fromCol || !toCol) return false;\n\n  const sameColumn = args.fromColumnId === args.toColumnId;\n\n  if (sameColumn) {\n    if (toCol.allowReorder === false) return false;\n  } else {\n    if (fromCol.allowOutgoing === false) return false;\n    if (toCol.allowIncoming === false) return false;\n  }\n\n  if (toCol.acceptsRendererIds && !toCol.acceptsRendererIds.includes(found.item.rendererId)) {\n    return false;\n  }\n\n  return true;\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/lib/permissions.ts"
    },
    {
      "path": "src/registry/components/data/kanban-board/lib/ids.ts",
      "content": "// Non-authoritative client-side draft ids — stable only within a session.\n// The consumer/server remains the source of truth for persistent ids.\nlet counter = 0;\n\nfunction rand(): string {\n  // Prefer crypto for collision resistance; fall back for older runtimes.\n  if (typeof crypto !== \"undefined\" && typeof crypto.randomUUID === \"function\") {\n    return crypto.randomUUID().slice(0, 8);\n  }\n  return Math.random().toString(36).slice(2, 8);\n}\n\nexport function newItemId(): string {\n  counter += 1;\n  return `item-${rand()}-${counter.toString(36)}`;\n}\n\nexport function newColumnId(): string {\n  counter += 1;\n  return `col-${rand()}-${counter.toString(36)}`;\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/lib/ids.ts"
    },
    {
      "path": "src/registry/components/data/kanban-board/lib/palette.ts",
      "content": "import type { KanbanPaletteSwatch } from \"../types\";\n\nexport const DEFAULT_PALETTE: KanbanPaletteSwatch[] = [\n  { id: \"slate\", label: \"Slate\", cssVar: \"--muted-foreground\" },\n  { id: \"lime\", label: \"Lime\", cssVar: \"--primary\" },\n  { id: \"amber\", label: \"Amber\", cssVar: \"--chart-1\" },\n  { id: \"emerald\", label: \"Emerald\", cssVar: \"--chart-2\" },\n  { id: \"sky\", label: \"Sky\", cssVar: \"--chart-3\" },\n  { id: \"rose\", label: \"Rose\", cssVar: \"--destructive\" },\n];\n\nexport function findSwatch(\n  palette: KanbanPaletteSwatch[],\n  id: string | undefined,\n): KanbanPaletteSwatch | undefined {\n  if (!id) return undefined;\n  return palette.find((s) => s.id === id);\n}\n\nexport function swatchCssColor(swatch: KanbanPaletteSwatch | undefined): string | undefined {\n  if (!swatch) return undefined;\n  return `var(${swatch.cssVar})`;\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/lib/palette.ts"
    },
    {
      "path": "src/registry/components/data/kanban-board/hooks/use-kanban-state.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useReducer, useRef } from \"react\";\nimport type { KanbanAction, KanbanData } from \"../types\";\nimport { kanbanReducer } from \"../lib/reducer\";\n\nconst EMPTY: KanbanData = { columns: [] };\n\nexport function useKanbanState({\n  data,\n  defaultData,\n  onChange,\n}: {\n  data?: KanbanData;\n  defaultData?: KanbanData;\n  onChange?: (next: KanbanData) => void;\n}): [KanbanData, (action: KanbanAction) => void] {\n  const [internal, internalDispatch] = useReducer(kanbanReducer, defaultData ?? EMPTY);\n\n  const isControlled = data !== undefined;\n  const state = isControlled ? data : internal;\n\n  // Latest-state ref: `dispatch` computes `next` from here (and syncs it in\n  // the same dispatch) instead of the render-captured `state`, so two\n  // dispatches in one tick COMPOSE — the second sees the first's result\n  // rather than silently dropping it (controlled mode's re-render, and\n  // therefore a fresh `state` closure, only lands after the parent echoes).\n  const latestRef = useRef(state);\n  const committedRef = useRef(state);\n  useEffect(() => {\n    // Renders remain authoritative: a controlled parent that rejects or\n    // rewrites an update wins back the ref on commit.\n    latestRef.current = state;\n    committedRef.current = state;\n  }, [state]);\n\n  const onChangeRef = useRef(onChange);\n  useEffect(() => {\n    onChangeRef.current = onChange;\n  }, [onChange]);\n\n  // The optimistic chain in latestRef only lives for the current tick. A\n  // controlled parent that silently vetoes (no state change → no commit)\n  // must not have the vetoed mutation re-emitted by the NEXT dispatch, so a\n  // microtask falls back to the last committed state; a commit (echo or\n  // rewrite) re-syncs both refs via the effect above and wins either way.\n  const resetQueuedRef = useRef(false);\n\n  const dispatch = useCallback(\n    (action: KanbanAction) => {\n      const next = kanbanReducer(latestRef.current, action);\n      latestRef.current = next;\n      if (!resetQueuedRef.current) {\n        resetQueuedRef.current = true;\n        queueMicrotask(() => {\n          resetQueuedRef.current = false;\n          latestRef.current = committedRef.current;\n        });\n      }\n      if (!isControlled) internalDispatch(action);\n      onChangeRef.current?.(next);\n    },\n    [isControlled],\n  );\n\n  return [state, dispatch];\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/hooks/use-kanban-state.ts"
    },
    {
      "path": "src/registry/components/data/kanban-board/hooks/use-renderer-map.ts",
      "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport type { AnyKanbanCardRenderer } from \"../types\";\n\nexport function useRendererMap(\n  renderers: AnyKanbanCardRenderer[],\n): Map<string, AnyKanbanCardRenderer> {\n  return useMemo(() => {\n    const m = new Map<string, AnyKanbanCardRenderer>();\n    for (const r of renderers) m.set(r.id, r);\n    return m;\n  }, [renderers]);\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/hooks/use-renderer-map.ts"
    },
    {
      "path": "src/registry/components/data/kanban-board/hooks/use-drag-handlers.ts",
      "content": "\"use client\";\n\nimport { useCallback, useState } from \"react\";\nimport type { DragEndEvent, DragStartEvent } from \"@dnd-kit/core\";\nimport { findItem } from \"../lib/data\";\nimport { canDrop } from \"../lib/permissions\";\nimport type { KanbanAction, KanbanData, KanbanItem } from \"../types\";\n\ntype Active =\n  | { kind: \"item\"; item: KanbanItem }\n  | { kind: \"column\"; columnId: string }\n  | null;\n\nexport function useDragHandlers({\n  data,\n  readOnly,\n  dispatch,\n  onItemMove,\n}: {\n  data: KanbanData;\n  readOnly: boolean;\n  dispatch: (action: KanbanAction) => void;\n  onItemMove?: (\n    item: KanbanItem,\n    from: { columnId: string; swimlaneId?: string },\n    to: { columnId: string; swimlaneId?: string },\n  ) => void;\n}) {\n  const [active, setActive] = useState<Active>(null);\n\n  const onDragStart = useCallback(\n    (event: DragStartEvent) => {\n      if (readOnly) return;\n      const activeData = event.active.data.current as\n        | { kind: \"item\"; columnId: string; swimlaneId?: string; rendererId: string }\n        | { kind: \"column\"; columnId: string }\n        | undefined;\n      if (!activeData) return;\n      if (activeData.kind === \"item\") {\n        const found = findItem(data, String(event.active.id));\n        if (found) setActive({ kind: \"item\", item: found.item });\n      } else if (activeData.kind === \"column\") {\n        setActive({ kind: \"column\", columnId: activeData.columnId });\n      }\n    },\n    [data, readOnly],\n  );\n\n  const onDragOver = useCallback(() => {\n    // Visual rejection is handled inside SwimlaneCell via column.acceptsRendererIds + activeRendererId.\n  }, []);\n\n  const onDragEnd = useCallback(\n    (event: DragEndEvent) => {\n      const { active: a, over } = event;\n      setActive(null);\n      if (!over || readOnly) return;\n\n      const activeData = a.data.current as\n        | { kind: \"item\"; columnId: string; swimlaneId?: string; rendererId: string }\n        | { kind: \"column\"; columnId: string }\n        | undefined;\n      const overData = over.data.current as\n        | { kind: \"item\"; columnId: string; swimlaneId?: string; rendererId: string }\n        | { kind: \"cell\"; columnId: string; swimlaneId?: string }\n        | { kind: \"collapsed-column\"; columnId: string }\n        | { kind: \"column\"; columnId: string }\n        | undefined;\n      if (!activeData || !overData) return;\n\n      // ─── Column reorder ───\n      if (activeData.kind === \"column\" && overData.kind === \"column\") {\n        if (activeData.columnId === overData.columnId) return;\n        const toIndex = data.columns.findIndex((c) => c.id === overData.columnId);\n        if (toIndex < 0) return;\n        dispatch({ type: \"reorder-column\", columnId: activeData.columnId, toIndex });\n        return;\n      }\n\n      // ─── Item move ───\n      if (activeData.kind !== \"item\") return;\n      const itemId = String(a.id);\n      const found = findItem(data, itemId);\n      if (!found) return;\n\n      let toColumnId: string | undefined;\n      let toSwimlaneId: string | undefined;\n      let toIndex = 0;\n\n      if (overData.kind === \"item\") {\n        const overFound = findItem(data, String(over.id));\n        if (!overFound) return;\n        toColumnId = overFound.columnId;\n        toSwimlaneId = overData.swimlaneId;\n        const targetCol = data.columns.find((c) => c.id === toColumnId);\n        if (!targetCol) return;\n        toIndex = targetCol.items.findIndex((it) => it.id === over.id);\n        if (toIndex < 0) toIndex = targetCol.items.length;\n      } else if (\n        overData.kind === \"cell\" ||\n        overData.kind === \"collapsed-column\" ||\n        // An item released over a column's empty space (or onto the column\n        // wrapper) lands at the end of that column. `kind: \"column\"` reaches\n        // here only for an item drag — column reorder is handled above and\n        // requires `activeData.kind === \"column\"`.\n        overData.kind === \"column\"\n      ) {\n        toColumnId = overData.columnId;\n        toSwimlaneId = \"swimlaneId\" in overData ? overData.swimlaneId : undefined;\n        const targetCol = data.columns.find((c) => c.id === toColumnId);\n        if (!targetCol) return;\n        toIndex = targetCol.items.length;\n      } else {\n        return;\n      }\n\n      const allowed = canDrop({\n        data,\n        itemId,\n        fromColumnId: found.columnId,\n        toColumnId,\n        fromSwimlaneId: found.item.swimlaneId,\n        toSwimlaneId,\n        readOnly,\n      });\n      if (!allowed) return;\n\n      // For same-column moves, adjust toIndex to handle the\n      // active-before-over case correctly. When the active item sits before\n      // the over item in the same column, removing the active first shifts\n      // the over item's index down by 1; inserting at the original over\n      // index would land the active *after* over, not at over's slot.\n      if (toColumnId === found.columnId && found.index < toIndex) {\n        toIndex = toIndex - 1;\n      }\n\n      // No-op when index doesn't actually change in the same column.\n      if (\n        toColumnId === found.columnId &&\n        toIndex === found.index &&\n        toSwimlaneId === found.item.swimlaneId\n      ) {\n        return;\n      }\n\n      // moveItem auto-expands collapsed target columns (locked contract baked\n      // into the action so both controlled and uncontrolled modes apply it\n      // atomically).\n      dispatch({\n        type: \"move-item\",\n        itemId,\n        toColumnId,\n        toIndex,\n        toSwimlaneId,\n      });\n\n      onItemMove?.(\n        found.item,\n        { columnId: found.columnId, swimlaneId: found.item.swimlaneId },\n        { columnId: toColumnId, swimlaneId: toSwimlaneId },\n      );\n    },\n    [data, dispatch, onItemMove, readOnly],\n  );\n\n  const onDragCancel = useCallback(() => {\n    setActive(null);\n  }, []);\n\n  const activeItem = active?.kind === \"item\" ? active.item : null;\n  const activeRendererId = active?.kind === \"item\" ? active.item.rendererId : undefined;\n\n  return { onDragStart, onDragOver, onDragEnd, onDragCancel, activeItem, activeRendererId };\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/hooks/use-drag-handlers.ts"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/board.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { SortableContext, horizontalListSortingStrategy } from \"@dnd-kit/sortable\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  AnyKanbanCardRenderer,\n  KanbanColumn,\n  KanbanData,\n  KanbanItem,\n  KanbanPaletteSwatch,\n  KanbanSwimlane,\n} from \"../types\";\nimport { AddColumnButton } from \"./add-column-button\";\nimport { Column } from \"./column\";\n\nexport function Board({\n  data,\n  swimlanes,\n  rendererMap,\n  renderers,\n  palette,\n  readOnly,\n  activeRendererId,\n  onItemCreate,\n  onItemUpdate,\n  onItemDelete,\n  onItemClick,\n  onItemDataChange,\n  onColumnCreate,\n  onColumnUpdate,\n  onColumnDelete,\n  onSetColor,\n  onToggleCollapse,\n  className,\n}: {\n  data: KanbanData;\n  swimlanes: KanbanSwimlane[] | undefined;\n  rendererMap: Map<string, AnyKanbanCardRenderer>;\n  renderers: AnyKanbanCardRenderer[];\n  palette: KanbanPaletteSwatch[];\n  readOnly: boolean;\n  activeRendererId: string | undefined;\n  onItemCreate?: (columnId: string, item: KanbanItem) => void;\n  onItemUpdate?: (item: KanbanItem) => void;\n  onItemDelete?: (itemId: string) => void;\n  onItemClick?: (item: KanbanItem) => void;\n  onItemDataChange?: (item: KanbanItem) => void;\n  onColumnCreate?: (column: KanbanColumn) => void;\n  onColumnUpdate?: (column: KanbanColumn) => void;\n  onColumnDelete?: (columnId: string) => void;\n  onSetColor: (columnId: string, color: string | undefined) => void;\n  onToggleCollapse: (columnId: string) => void;\n  className?: string;\n}) {\n  // Translate a vertical mouse-wheel delta into horizontal scroll so a plain\n  // mouse can reach every column (a horizontal-only scroller ignores deltaY).\n  // Yields to a column under the pointer that can still scroll vertically in\n  // that direction. Attached non-passive because React's onWheel is passive —\n  // a passive listener can't preventDefault to stop the page also scrolling.\n  const scrollRef = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n    const onWheel = (e: WheelEvent) => {\n      if (e.deltaY === 0 || e.shiftKey) return; // shift+wheel already scrolls X\n      if (el.scrollWidth <= el.clientWidth) return; // nothing to scroll horizontally\n      let node = e.target as HTMLElement | null;\n      while (node && node !== el) {\n        const oy = getComputedStyle(node).overflowY;\n        if ((oy === \"auto\" || oy === \"scroll\") && node.scrollHeight > node.clientHeight) {\n          const atTop = node.scrollTop <= 0;\n          const atBottom = Math.ceil(node.scrollTop + node.clientHeight) >= node.scrollHeight;\n          if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) return;\n        }\n        node = node.parentElement;\n      }\n      el.scrollLeft += e.deltaY;\n      e.preventDefault();\n    };\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => el.removeEventListener(\"wheel\", onWheel);\n  }, []);\n\n  return (\n    <div\n      ref={scrollRef}\n      className={cn(\n        \"flex h-full min-h-0 w-full gap-3 overflow-x-auto overflow-y-hidden p-3\",\n        className,\n      )}\n    >\n      <SortableContext\n        items={data.columns.map((c) => c.id)}\n        strategy={horizontalListSortingStrategy}\n      >\n        {data.columns.map((column) => (\n          <Column\n            key={column.id}\n            column={column}\n            swimlanes={swimlanes}\n            rendererMap={rendererMap}\n            renderers={renderers}\n            palette={palette}\n            readOnly={readOnly}\n            activeRendererId={activeRendererId}\n            onItemCreate={onItemCreate ? (item) => onItemCreate(column.id, item) : undefined}\n            onItemUpdate={onItemUpdate}\n            onItemDelete={onItemDelete}\n            onItemClick={onItemClick}\n            onItemDataChange={onItemDataChange}\n            onColumnUpdate={onColumnUpdate}\n            onColumnDelete={onColumnDelete}\n            onSetColor={(color) => onSetColor(column.id, color)}\n            onToggleCollapse={() => onToggleCollapse(column.id)}\n          />\n        ))}\n      </SortableContext>\n      {!readOnly && onColumnCreate ? <AddColumnButton onCreate={onColumnCreate} /> : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/board.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/column.tsx",
      "content": "\"use client\";\n\nimport { type CSSProperties, useState } from \"react\";\nimport { useSortable } from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport { cn } from \"@/lib/utils\";\nimport { findSwatch, swatchCssColor } from \"../lib/palette\";\nimport type {\n  AnyKanbanCardRenderer,\n  KanbanColumn,\n  KanbanItem,\n  KanbanPaletteSwatch,\n  KanbanSwimlane,\n} from \"../types\";\nimport { ColumnBody } from \"./column-body\";\nimport { ColumnCollapsed } from \"./column-collapsed\";\nimport { ColumnFooter } from \"./column-footer\";\nimport { ColumnHeader } from \"./column-header\";\nimport { InlineEditEditor } from \"./inline-edit-editor\";\n\nexport function Column({\n  column,\n  swimlanes,\n  rendererMap,\n  renderers,\n  palette,\n  readOnly,\n  activeRendererId,\n  onItemCreate,\n  onItemUpdate,\n  onItemDelete,\n  onItemClick,\n  onItemDataChange,\n  onColumnUpdate,\n  onColumnDelete,\n  onSetColor,\n  onToggleCollapse,\n}: {\n  column: KanbanColumn;\n  swimlanes: KanbanSwimlane[] | undefined;\n  rendererMap: Map<string, AnyKanbanCardRenderer>;\n  renderers: AnyKanbanCardRenderer[];\n  palette: KanbanPaletteSwatch[];\n  readOnly: boolean;\n  activeRendererId: string | undefined;\n  onItemCreate?: (item: KanbanItem) => void;\n  onItemUpdate?: (item: KanbanItem) => void;\n  onItemDelete?: (itemId: string) => void;\n  onItemClick?: (item: KanbanItem) => void;\n  onItemDataChange?: (item: KanbanItem) => void;\n  onColumnUpdate?: (column: KanbanColumn) => void;\n  onColumnDelete?: (columnId: string) => void;\n  onSetColor: (color: string | undefined) => void;\n  onToggleCollapse: () => void;\n}) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    transition,\n    isDragging,\n  } = useSortable({\n    id: column.id,\n    data: { kind: \"column\", columnId: column.id },\n    disabled: readOnly,\n  });\n\n  const [editingItem, setEditingItem] = useState<KanbanItem | null>(null);\n\n  const swatch = findSwatch(palette, column.color);\n  const accentColor = swatchCssColor(swatch);\n\n  const style: CSSProperties = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n    ...(accentColor ? { borderLeftColor: accentColor } : {}),\n  };\n\n  if (column.collapsed) {\n    return (\n      <div ref={setNodeRef} style={style}>\n        <ColumnCollapsed column={column} palette={palette} onExpand={onToggleCollapse} />\n      </div>\n    );\n  }\n\n  const handleEdit = onItemUpdate\n    ? (item: KanbanItem) => {\n        const renderer = rendererMap.get(item.rendererId);\n        if (!renderer?.editForm) {\n          // Renderer doesn't support inline edit; just fire the callback with the existing item.\n          onItemUpdate?.(item);\n          return;\n        }\n        setEditingItem(item);\n      }\n    : undefined;\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      data-dragging={isDragging || undefined}\n      className={cn(\n        \"flex h-full min-h-0 max-h-full w-80 shrink-0 flex-col rounded-md border-l-4 border-y border-r border-border bg-card/40\",\n        isDragging && \"opacity-60\",\n      )}\n    >\n      <ColumnHeader\n        column={column}\n        palette={palette}\n        readOnly={readOnly}\n        itemCount={column.items.length}\n        dragHandleProps={{ ...attributes, ...listeners }}\n        onColorChange={onSetColor}\n        onCollapse={onToggleCollapse}\n        onEdit={onColumnUpdate}\n        onDelete={onColumnDelete}\n      />\n\n      {editingItem ? (\n        <div className=\"px-1.5 pb-1\">\n          <InlineEditEditor\n            item={editingItem}\n            renderer={rendererMap.get(editingItem.rendererId)!}\n            onSave={(next) => {\n              onItemUpdate?.(next);\n              setEditingItem(null);\n            }}\n            onCancel={() => setEditingItem(null)}\n          />\n        </div>\n      ) : null}\n\n      <ColumnBody\n        column={column}\n        swimlanes={swimlanes}\n        rendererMap={rendererMap}\n        readOnly={readOnly}\n        activeRendererId={activeRendererId}\n        onItemClick={onItemClick}\n        onItemDelete={readOnly ? undefined : onItemDelete}\n        onItemEdit={readOnly ? undefined : handleEdit}\n        onItemDataChange={readOnly ? undefined : onItemDataChange}\n      />\n\n      {!readOnly && onItemCreate ? (\n        <ColumnFooter column={column} renderers={renderers} onCreate={onItemCreate} />\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/column.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/column-header.tsx",
      "content": "\"use client\";\n\nimport { ChevronsLeft, MoreHorizontal, Pencil, Trash2 } from \"lucide-react\";\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { cn } from \"@/lib/utils\";\nimport type { KanbanColumn, KanbanPaletteSwatch } from \"../types\";\nimport { ColumnColorPicker } from \"./color-picker\";\n\nexport function ColumnHeader({\n  column,\n  palette,\n  readOnly,\n  itemCount,\n  dragHandleProps,\n  onColorChange,\n  onCollapse,\n  onEdit,\n  onDelete,\n}: {\n  column: KanbanColumn;\n  palette: KanbanPaletteSwatch[];\n  readOnly: boolean;\n  itemCount: number;\n  dragHandleProps?: Record<string, unknown>;\n  onColorChange: (color: string | undefined) => void;\n  onCollapse: () => void;\n  onEdit?: (column: KanbanColumn) => void;\n  onDelete?: (columnId: string) => void;\n}) {\n  const showMenu = !readOnly && (onEdit || onDelete);\n  const overCap = column.maxItems !== undefined && itemCount > column.maxItems;\n\n  return (\n    <div className=\"flex items-center gap-1 px-2.5 py-2\">\n      <div\n        {...(readOnly ? {} : dragHandleProps)}\n        className={cn(\n          \"flex min-w-0 flex-1 items-center gap-2\",\n          !readOnly && \"cursor-grab active:cursor-grabbing\",\n        )}\n      >\n        <span className=\"truncate text-sm font-medium text-foreground\">{column.title}</span>\n        <span\n          className={cn(\n            \"shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums\",\n            overCap ? \"bg-destructive/15 text-destructive\" : \"text-muted-foreground\",\n          )}\n        >\n          {column.maxItems !== undefined ? `${itemCount} / ${column.maxItems}` : itemCount}\n        </span>\n      </div>\n      <div className=\"flex shrink-0 items-center\" data-stop-click onPointerDown={(e) => e.stopPropagation()}>\n        {!readOnly ? (\n          <ColumnColorPicker\n            palette={palette}\n            current={column.color}\n            onChange={onColorChange}\n          />\n        ) : null}\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"size-6\"\n          onClick={onCollapse}\n          aria-label={`Collapse column ${column.title}`}\n          data-stop-click\n          onPointerDown={(e) => e.stopPropagation()}\n        >\n          <ChevronsLeft className=\"size-3.5\" />\n        </Button>\n        {showMenu ? (\n          <DropdownMenu>\n            {/* F-cross-13 path-b: trigger IS the button (native <button> in\n                both backends) — `asChild` is Radix-only; `buttonVariants`\n                reproduces the ghost/icon look. (v0.4.3) */}\n            <DropdownMenuTrigger\n              type=\"button\"\n              className={cn(buttonVariants({ variant: \"ghost\", size: \"icon\" }), \"size-6\")}\n              aria-label=\"Column menu\"\n              data-stop-click\n              onPointerDown={(e) => e.stopPropagation()}\n            >\n              <MoreHorizontal className=\"size-3.5\" />\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\">\n              {onEdit ? (\n                <DropdownMenuItem onClick={() => onEdit(column)}>\n                  <Pencil className=\"size-3.5\" />\n                  Rename column\n                </DropdownMenuItem>\n              ) : null}\n              {onEdit && onDelete ? <DropdownMenuSeparator /> : null}\n              {onDelete ? (\n                <DropdownMenuItem\n                  onClick={() => onDelete(column.id)}\n                  variant=\"destructive\"\n                >\n                  <Trash2 className=\"size-3.5\" />\n                  Delete column\n                </DropdownMenuItem>\n              ) : null}\n            </DropdownMenuContent>\n          </DropdownMenu>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/column-header.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/column-body.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type {\n  AnyKanbanCardRenderer,\n  KanbanColumn,\n  KanbanItem,\n  KanbanSwimlane,\n} from \"../types\";\nimport { SwimlaneCell } from \"./swimlane-cell\";\n\nexport function ColumnBody({\n  column,\n  swimlanes,\n  rendererMap,\n  readOnly,\n  activeRendererId,\n  onItemClick,\n  onItemDelete,\n  onItemEdit,\n  onItemDataChange,\n}: {\n  column: KanbanColumn;\n  swimlanes: KanbanSwimlane[] | undefined;\n  rendererMap: Map<string, AnyKanbanCardRenderer>;\n  readOnly: boolean;\n  activeRendererId: string | undefined;\n  onItemClick?: (item: KanbanItem) => void;\n  onItemDelete?: (itemId: string) => void;\n  onItemEdit?: (item: KanbanItem) => void;\n  onItemDataChange?: (item: KanbanItem) => void;\n}) {\n  // A drop is rejected if there's an active drag and this column doesn't accept that renderer.\n  const rejectDrop =\n    activeRendererId !== undefined &&\n    column.acceptsRendererIds !== undefined &&\n    !column.acceptsRendererIds.includes(activeRendererId);\n\n  const hasSwimlanes = !!(swimlanes && swimlanes.length > 0);\n  const lanes: (KanbanSwimlane | undefined)[] =\n    swimlanes && swimlanes.length > 0 ? swimlanes : [undefined];\n\n  return (\n    <div className=\"flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-1.5\">\n      {/* In single-lane mode the lone cell grows to fill the column so the whole\n          empty area below the cards is a (highlighted) drop target. In swimlane\n          mode each lane cell stays content-sized so the lanes stack + scroll. */}\n      <div className={cn(\"flex flex-col gap-2 py-1.5\", !hasSwimlanes && \"min-h-full\")}>\n        {lanes.map((lane) => {\n          const items = column.items.filter((it) => {\n            if (!swimlanes || swimlanes.length === 0) return true;\n            // If item's swimlaneId is missing or unknown, it lands in the first lane.\n            const known = swimlanes.some((l) => l.id === it.swimlaneId);\n            if (!known) return lane?.id === swimlanes[0].id;\n            return it.swimlaneId === lane?.id;\n          });\n          return (\n            <div\n              key={lane?.id ?? \"_\"}\n              className={cn(\"flex flex-col gap-1\", !hasSwimlanes && \"flex-1\")}\n            >\n              {lane ? (\n                <span className=\"px-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70\">\n                  {lane.title}\n                </span>\n              ) : null}\n              <SwimlaneCell\n                column={column}\n                swimlaneId={lane?.id}\n                items={items}\n                rendererMap={rendererMap}\n                readOnly={readOnly}\n                rejectDrop={rejectDrop}\n                grow={!hasSwimlanes}\n                onItemClick={onItemClick}\n                onItemDelete={onItemDelete}\n                onItemEdit={onItemEdit}\n                onItemDataChange={onItemDataChange}\n              />\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/column-body.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/column-collapsed.tsx",
      "content": "\"use client\";\n\nimport { useDroppable } from \"@dnd-kit/core\";\nimport { ChevronsRight } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { swatchCssColor, findSwatch } from \"../lib/palette\";\nimport type { KanbanColumn, KanbanPaletteSwatch } from \"../types\";\n\nexport function ColumnCollapsed({\n  column,\n  palette,\n  onExpand,\n}: {\n  column: KanbanColumn;\n  palette: KanbanPaletteSwatch[];\n  onExpand: () => void;\n}) {\n  const { setNodeRef, isOver } = useDroppable({\n    id: `${column.id}::collapsed`,\n    data: { kind: \"collapsed-column\", columnId: column.id },\n  });\n\n  const swatch = findSwatch(palette, column.color);\n  const accentColor = swatchCssColor(swatch);\n\n  return (\n    <div\n      ref={setNodeRef}\n      className={cn(\n        \"flex h-full w-10 shrink-0 flex-col items-center justify-between border-l-2 bg-card/50 py-2\",\n        \"rounded-md border border-border\",\n        isOver && \"ring-1 ring-ring\",\n      )}\n      style={accentColor ? { borderLeftColor: accentColor } : undefined}\n    >\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        size=\"icon\"\n        className=\"size-6\"\n        onClick={onExpand}\n        aria-label={`Expand column ${column.title}`}\n      >\n        <ChevronsRight className=\"size-3.5\" />\n      </Button>\n      <div className=\"flex flex-1 flex-col items-center justify-center gap-2\">\n        <span\n          className=\"text-xs font-medium text-foreground\"\n          style={{ writingMode: \"vertical-rl\", transform: \"rotate(180deg)\" }}\n        >\n          {column.title}\n        </span>\n      </div>\n      <span className=\"text-[10px] tabular-nums text-muted-foreground\">{column.items.length}</span>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/column-collapsed.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/column-footer.tsx",
      "content": "\"use client\";\n\nimport type { AnyKanbanCardRenderer, KanbanColumn, KanbanItem } from \"../types\";\nimport { InlineCreateEditor } from \"./inline-create-editor\";\n\nexport function ColumnFooter({\n  column,\n  renderers,\n  onCreate,\n}: {\n  column: KanbanColumn;\n  renderers: AnyKanbanCardRenderer[];\n  onCreate: (item: KanbanItem) => void;\n}) {\n  // Filter renderers by column.acceptsRendererIds if set.\n  const allowed =\n    column.acceptsRendererIds === undefined\n      ? renderers\n      : renderers.filter((r) => column.acceptsRendererIds!.includes(r.id));\n\n  if (allowed.length === 0) return null;\n\n  return (\n    <div className=\"border-t border-border/60 px-1.5 py-1.5\">\n      <InlineCreateEditor renderers={allowed} onCreate={onCreate} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/column-footer.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/add-column-button.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Plus } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { newColumnId } from \"../lib/ids\";\nimport type { KanbanColumn } from \"../types\";\n\nexport function AddColumnButton({\n  onCreate,\n}: {\n  onCreate: (column: KanbanColumn) => void;\n}) {\n  const [editing, setEditing] = useState(false);\n  const [title, setTitle] = useState(\"\");\n\n  function handleSave() {\n    if (!title.trim()) return;\n    onCreate({ id: newColumnId(), title: title.trim(), items: [] });\n    setTitle(\"\");\n    setEditing(false);\n  }\n\n  if (editing) {\n    return (\n      <form\n        onSubmit={(e) => {\n          e.preventDefault();\n          handleSave();\n        }}\n        className=\"flex h-fit w-80 shrink-0 flex-col gap-2 rounded-md border border-border bg-card p-2.5\"\n      >\n        <Input\n          value={title}\n          onChange={(e) => setTitle(e.target.value)}\n          placeholder=\"Column title\"\n          autoFocus\n          className=\"h-8 text-sm\"\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\") {\n              setTitle(\"\");\n              setEditing(false);\n            }\n          }}\n        />\n        <div className=\"flex justify-end gap-1\">\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={() => {\n              setTitle(\"\");\n              setEditing(false);\n            }}\n          >\n            Cancel\n          </Button>\n          <Button type=\"submit\" size=\"sm\" disabled={!title.trim()}>\n            Add\n          </Button>\n        </div>\n      </form>\n    );\n  }\n\n  return (\n    <Button\n      type=\"button\"\n      variant=\"outline\"\n      size=\"sm\"\n      className=\"h-fit w-80 shrink-0 justify-start py-2.5 text-muted-foreground hover:text-foreground\"\n      onClick={() => setEditing(true)}\n    >\n      <Plus className=\"size-4\" />\n      Add column\n    </Button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/add-column-button.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/swimlane-row.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport type { KanbanSwimlane } from \"../types\";\n\n/**\n * Optional swimlane label band. Currently rendered inline within each column body.\n * This component is reserved for a future \"single global label band above all columns\" mode.\n */\nexport function SwimlaneRow({ swimlanes, className }: { swimlanes: KanbanSwimlane[]; className?: string }) {\n  return (\n    <div className={cn(\"flex flex-col gap-1\", className)} aria-hidden=\"true\">\n      {swimlanes.map((s) => (\n        <span key={s.id} className=\"text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70\">\n          {s.title}\n        </span>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/swimlane-row.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/swimlane-cell.tsx",
      "content": "\"use client\";\n\nimport { useDroppable } from \"@dnd-kit/core\";\nimport { SortableContext, verticalListSortingStrategy } from \"@dnd-kit/sortable\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  AnyKanbanCardRenderer,\n  KanbanColumn,\n  KanbanItem,\n} from \"../types\";\nimport { ItemShell } from \"./item-shell\";\n\nexport function makeCellId(columnId: string, swimlaneId: string | undefined): string {\n  return `${columnId}::${swimlaneId ?? \"_\"}`;\n}\n\nexport function SwimlaneCell({\n  column,\n  swimlaneId,\n  items,\n  rendererMap,\n  readOnly,\n  rejectDrop,\n  grow,\n  onItemClick,\n  onItemDelete,\n  onItemEdit,\n  onItemDataChange,\n}: {\n  column: KanbanColumn;\n  swimlaneId: string | undefined;\n  items: KanbanItem[];\n  rendererMap: Map<string, AnyKanbanCardRenderer>;\n  readOnly: boolean;\n  rejectDrop: boolean;\n  /** Fill the available column height so the whole empty area is a drop target (single-lane mode). */\n  grow?: boolean;\n  onItemClick?: (item: KanbanItem) => void;\n  onItemDelete?: (itemId: string) => void;\n  onItemEdit?: (item: KanbanItem) => void;\n  onItemDataChange?: (item: KanbanItem) => void;\n}) {\n  const cellId = makeCellId(column.id, swimlaneId);\n  const { setNodeRef, isOver } = useDroppable({\n    id: cellId,\n    data: { kind: \"cell\", columnId: column.id, swimlaneId },\n  });\n\n  return (\n    <SortableContext id={cellId} items={items.map((it) => it.id)} strategy={verticalListSortingStrategy}>\n      <div\n        ref={setNodeRef}\n        data-cell-id={cellId}\n        className={cn(\n          \"flex min-h-12 flex-col gap-2 rounded-sm p-1.5 transition-colors\",\n          grow && \"flex-1\",\n          isOver && !rejectDrop && \"bg-accent/40 ring-1 ring-ring/30\",\n          isOver && rejectDrop && \"bg-destructive/10 ring-1 ring-destructive/40\",\n        )}\n      >\n        {items.map((item) => {\n          const renderer = rendererMap.get(item.rendererId);\n          const label = renderer?.label ?? \"Item\";\n          return (\n            <ItemShell\n              key={item.id}\n              item={item}\n              columnId={column.id}\n              swimlaneId={swimlaneId}\n              rendererMap={rendererMap}\n              rendererLabel={label}\n              readOnly={readOnly}\n              onClick={onItemClick}\n              onDelete={onItemDelete}\n              onEdit={onItemEdit}\n              onItemDataChange={onItemDataChange}\n            />\n          );\n        })}\n        {items.length === 0 ? (\n          <div className=\"pointer-events-none flex h-12 items-center justify-center rounded border border-dashed border-border/60 text-[10px] uppercase tracking-wide text-muted-foreground/60\">\n            Empty\n          </div>\n        ) : null}\n      </div>\n    </SortableContext>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/swimlane-cell.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/item-shell.tsx",
      "content": "\"use client\";\n\nimport { type CSSProperties, type KeyboardEvent, type MouseEvent, type PointerEvent } from \"react\";\nimport { useSortable } from \"@dnd-kit/sortable\";\nimport { CSS } from \"@dnd-kit/utilities\";\nimport { GripVertical, Lock, X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  AnyKanbanCardRenderer,\n  KanbanItem,\n  KanbanRenderContext,\n} from \"../types\";\nimport { ItemRenderer } from \"./item-renderer\";\n\nexport function ItemShell({\n  item,\n  columnId,\n  swimlaneId,\n  rendererMap,\n  rendererLabel,\n  readOnly,\n  onClick,\n  onDelete,\n  onEdit,\n  onItemDataChange,\n}: {\n  item: KanbanItem;\n  columnId: string;\n  swimlaneId?: string;\n  rendererMap: Map<string, AnyKanbanCardRenderer>;\n  rendererLabel: string;\n  readOnly: boolean;\n  onClick?: (item: KanbanItem) => void;\n  onDelete?: (itemId: string) => void;\n  onEdit?: (item: KanbanItem) => void;\n  /** Persist an in-place data edit from a self-editing renderer back to the board. */\n  onItemDataChange?: (item: KanbanItem) => void;\n}) {\n  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({\n    id: item.id,\n    data: { kind: \"item\", columnId, swimlaneId, rendererId: item.rendererId },\n    disabled: readOnly || item.locked === true,\n  });\n\n  const style: CSSProperties = {\n    transform: CSS.Transform.toString(transform),\n    transition,\n  };\n\n  const renderer = rendererMap.get(item.rendererId);\n  const handleMode = renderer?.dragHandle ?? \"shell\";\n  const dragDisabled = readOnly || item.locked === true;\n\n  const ctx: KanbanRenderContext = {\n    itemId: item.id,\n    columnId,\n    swimlaneId,\n    isDragging,\n    isLocked: item.locked === true,\n    onDataChange:\n      onItemDataChange && !readOnly && item.locked !== true\n        ? (nextData: unknown) => onItemDataChange({ ...item, data: nextData })\n        : undefined,\n  };\n\n  function handleClick(e: MouseEvent) {\n    if (isDragging) return;\n    // Avoid handling clicks bubbling from interactive children (buttons, links, anything tagged data-stop-click).\n    const target = e.target as HTMLElement;\n    if (target.closest(\"button, a, [data-stop-click]\")) return;\n    onClick?.(item);\n  }\n\n  function handleKeyDown(e: KeyboardEvent) {\n    if (e.key === \"Enter\") {\n      e.preventDefault();\n      onClick?.(item);\n    }\n  }\n\n  // Stop pointer events on overlay buttons from triggering drag activation.\n  function stopPointer(e: PointerEvent) {\n    e.stopPropagation();\n  }\n\n  const ariaLabel = item.locked ? `${rendererLabel}, locked` : `${rendererLabel}, draggable`;\n\n  // In \"shell\" mode the whole card is the drag activator (existing behavior).\n  // In \"header\" mode only the top grip strip activates drag; body is fully interactive.\n  const shellListeners = handleMode === \"shell\" && !dragDisabled ? listeners : undefined;\n  const headerListeners = handleMode === \"header\" && !dragDisabled ? listeners : undefined;\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      data-dragging={isDragging || undefined}\n      // dnd-kit's `attributes` provide keyboard-DnD a11y wiring (role, tabIndex,\n      // aria-pressed, aria-describedby, aria-disabled). We override\n      // aria-roledescription per renderer label and role to \"article\" so we\n      // don't nest <button> inside button (Edit/Delete are inner buttons).\n      {...attributes}\n      {...shellListeners}\n      role=\"article\"\n      aria-roledescription={ariaLabel}\n      onClick={handleClick}\n      onKeyDown={handleKeyDown}\n      className={cn(\n        \"group relative outline-none\",\n        \"rounded-md focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n        handleMode === \"shell\" && !dragDisabled && \"cursor-grab active:cursor-grabbing\",\n        dragDisabled && \"cursor-default\",\n        isDragging && \"opacity-40\",\n      )}\n    >\n      {/* Header drag handle — appears above the item body in \"header\" mode.\n          Click/tap on the grip activates dnd-kit drag; rest of the body is unaffected. */}\n      {handleMode === \"header\" && !dragDisabled ? (\n        // The strip extends a little below the grip (`pb-2`) and the card body\n        // below is pulled up over that extension (`-mt-2 z-10`, see the\n        // ItemRenderer wrapper) so the card's rounded top + opaque surface cover\n        // the strip's square bottom corners — no \"notch\" beside the rounded card.\n        <div\n          {...headerListeners}\n          className=\"relative z-0 flex h-9 cursor-grab items-center justify-center gap-1 rounded-t-xl border border-b-0 border-border bg-muted/70 pb-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted active:cursor-grabbing\"\n          aria-label=\"Drag to move\"\n          title=\"Drag to move\"\n        >\n          <GripVertical className=\"size-3.5\" />\n          <span>drag</span>\n        </div>\n      ) : null}\n\n      {/* Action overlay — only visible on hover/focus. Pointer events stop propagation\n          so clicks on the buttons don't trigger drag activation or the parent click. */}\n      <div className=\"pointer-events-none absolute right-1 top-1 z-10 flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100\">\n        {item.locked ? (\n          <span\n            className=\"pointer-events-auto inline-flex size-5 items-center justify-center rounded-sm text-muted-foreground\"\n            aria-hidden=\"true\"\n            title=\"Locked\"\n          >\n            <Lock className=\"size-3\" />\n          </span>\n        ) : null}\n        {!readOnly && !item.locked && onEdit ? (\n          <Button\n            data-stop-click\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"pointer-events-auto size-5\"\n            onPointerDown={stopPointer}\n            onClick={(e) => {\n              e.stopPropagation();\n              onEdit(item);\n            }}\n            aria-label=\"Edit item\"\n          >\n            <span className=\"text-[10px]\">Edit</span>\n          </Button>\n        ) : null}\n        {!readOnly && !item.locked && onDelete ? (\n          <Button\n            data-stop-click\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"pointer-events-auto size-5\"\n            onPointerDown={stopPointer}\n            onClick={(e) => {\n              e.stopPropagation();\n              onDelete(item.id);\n            }}\n            aria-label=\"Delete item\"\n          >\n            <X className=\"size-3\" />\n          </Button>\n        ) : null}\n      </div>\n\n      {/* In header mode, overlap the strip's bottom extension so the card body\n          (with its own rounded top + surface) masks the strip's square corners. */}\n      <div className={cn(\"relative\", handleMode === \"header\" && !dragDisabled && \"z-10 -mt-2\")}>\n        <ItemRenderer item={item} rendererMap={rendererMap} ctx={ctx} />\n      </div>\n\n      {handleMode === \"shell\" && !readOnly && !item.locked ? (\n        <span\n          className=\"pointer-events-none absolute left-0.5 top-1/2 -translate-y-1/2 text-muted-foreground/40 opacity-0 transition-opacity group-hover:opacity-100\"\n          aria-hidden=\"true\"\n        >\n          <GripVertical className=\"size-3\" />\n        </span>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/item-shell.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/item-renderer.tsx",
      "content": "\"use client\";\n\nimport type { AnyKanbanCardRenderer, KanbanItem, KanbanRenderContext } from \"../types\";\nimport { MissingRendererFallback, warnMissingRenderer } from \"./missing-renderer\";\n\nexport function ItemRenderer({\n  item,\n  rendererMap,\n  ctx,\n}: {\n  item: KanbanItem;\n  rendererMap: Map<string, AnyKanbanCardRenderer>;\n  ctx: KanbanRenderContext;\n}) {\n  const renderer = rendererMap.get(item.rendererId);\n  if (!renderer) {\n    warnMissingRenderer(item.rendererId);\n    return <MissingRendererFallback rendererId={item.rendererId} />;\n  }\n  return <>{renderer.render(item.data, ctx)}</>;\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/item-renderer.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/missing-renderer.tsx",
      "content": "\"use client\";\n\nimport { TriangleAlert } from \"lucide-react\";\n\nexport function MissingRendererFallback({ rendererId }: { rendererId: string }) {\n  return (\n    <div className=\"flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-2.5 text-xs\">\n      <TriangleAlert className=\"mt-0.5 size-3.5 shrink-0 text-destructive\" />\n      <div className=\"flex flex-col gap-0.5\">\n        <span className=\"font-medium text-destructive\">Renderer not found</span>\n        <span className=\"font-mono text-[10px] text-muted-foreground\">{rendererId}</span>\n      </div>\n    </div>\n  );\n}\n\nconst warned = new Set<string>();\n\nexport function warnMissingRenderer(rendererId: string) {\n  if (warned.has(rendererId)) return;\n  warned.add(rendererId);\n  if (typeof console !== \"undefined\") {\n    console.warn(\n      `[kanban-board] No renderer found for rendererId=\"${rendererId}\". ` +\n        `Add a renderer with id=\"${rendererId}\" to the renderers prop.`,\n    );\n  }\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/missing-renderer.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/inline-create-editor.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { Plus } from \"lucide-react\";\nimport { newItemId } from \"../lib/ids\";\nimport type { AnyKanbanCardRenderer, KanbanItem } from \"../types\";\n\ntype Mode =\n  | { kind: \"idle\" }\n  | { kind: \"editing\"; rendererId: string; data: unknown };\n\nexport function InlineCreateEditor({\n  renderers,\n  onCreate,\n}: {\n  renderers: AnyKanbanCardRenderer[];\n  onCreate: (item: KanbanItem) => void;\n}) {\n  const [mode, setMode] = useState<Mode>({ kind: \"idle\" });\n\n  function startCreate(renderer: AnyKanbanCardRenderer) {\n    if (renderer.editForm && renderer.newItem) {\n      setMode({ kind: \"editing\", rendererId: renderer.id, data: renderer.newItem() });\n    } else {\n      // No inline editor; emit stub item and let consumer handle it.\n      const stub: KanbanItem = {\n        id: newItemId(),\n        rendererId: renderer.id,\n        data: renderer.newItem ? renderer.newItem() : {},\n      };\n      onCreate(stub);\n    }\n  }\n\n  function handleSave(next: unknown) {\n    if (mode.kind !== \"editing\") return;\n    onCreate({ id: newItemId(), rendererId: mode.rendererId, data: next });\n    setMode({ kind: \"idle\" });\n  }\n\n  function handleCancel() {\n    setMode({ kind: \"idle\" });\n  }\n\n  if (mode.kind === \"editing\") {\n    const renderer = renderers.find((r) => r.id === mode.rendererId);\n    if (renderer?.editForm) {\n      return <>{renderer.editForm(mode.data, handleSave, handleCancel)}</>;\n    }\n    setMode({ kind: \"idle\" });\n    return null;\n  }\n\n  // Idle: show the trigger button. If 1 renderer, click → start. If ≥2, open picker.\n  if (renderers.length === 1) {\n    return (\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        size=\"sm\"\n        className=\"h-8 w-full justify-start text-xs text-muted-foreground hover:text-foreground\"\n        onClick={() => startCreate(renderers[0])}\n      >\n        <Plus className=\"size-3.5\" />\n        Add {renderers[0].label.toLowerCase()}\n      </Button>\n    );\n  }\n\n  return (\n    <DropdownMenu>\n      {/* F-cross-13 path-b: trigger IS the button (native <button> in both\n          backends) — `asChild` is Radix-only; `buttonVariants` reproduces the\n          ghost/sm look. (v0.4.3) */}\n      <DropdownMenuTrigger\n        type=\"button\"\n        className={cn(\n          buttonVariants({ variant: \"ghost\", size: \"sm\" }),\n          \"h-8 w-full justify-start text-xs text-muted-foreground hover:text-foreground\",\n        )}\n        aria-label={`Add item to column`}\n        data-stop-click\n        onPointerDown={(e) => e.stopPropagation()}\n      >\n        <Plus className=\"size-3.5\" />\n        Add\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"start\">\n        {renderers.map((r) => (\n          <DropdownMenuItem\n            key={r.id}\n            onClick={() => startCreate(r)}\n          >\n            {r.label}\n          </DropdownMenuItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/inline-create-editor.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/inline-edit-editor.tsx",
      "content": "\"use client\";\n\nimport type { AnyKanbanCardRenderer, KanbanItem } from \"../types\";\n\nexport function InlineEditEditor({\n  item,\n  renderer,\n  onSave,\n  onCancel,\n}: {\n  item: KanbanItem;\n  renderer: AnyKanbanCardRenderer;\n  onSave: (item: KanbanItem) => void;\n  onCancel: () => void;\n}) {\n  if (!renderer.editForm) return null;\n  return (\n    <>\n      {renderer.editForm(\n        item.data,\n        (next) => onSave({ ...item, data: next }),\n        onCancel,\n      )}\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/inline-edit-editor.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/color-picker.tsx",
      "content": "\"use client\";\n\nimport { Check, Palette } from \"lucide-react\";\nimport { Button, buttonVariants } from \"@/components/ui/button\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"@/components/ui/popover\";\nimport { cn } from \"@/lib/utils\";\nimport { findSwatch, swatchCssColor } from \"../lib/palette\";\nimport type { KanbanPaletteSwatch } from \"../types\";\n\nexport function ColumnColorPicker({\n  palette,\n  current,\n  onChange,\n  disabled,\n}: {\n  palette: KanbanPaletteSwatch[];\n  current: string | undefined;\n  onChange: (id: string | undefined) => void;\n  disabled?: boolean;\n}) {\n  const currentSwatch = findSwatch(palette, current);\n  const currentColor = swatchCssColor(currentSwatch);\n\n  return (\n    <Popover>\n      {/* F-cross-13 path-b: trigger IS the button (native <button> in both\n          backends) — `asChild` is Radix-only; `buttonVariants` reproduces the\n          ghost/icon look. (v0.4.3) */}\n      <PopoverTrigger\n        type=\"button\"\n        className={cn(buttonVariants({ variant: \"ghost\", size: \"icon\" }), \"size-6\")}\n        disabled={disabled}\n        aria-label=\"Pick column color\"\n        data-stop-click\n        onPointerDown={(e) => e.stopPropagation()}\n        onClick={(e) => e.stopPropagation()}\n      >\n        {currentColor ? (\n          <span\n            className=\"size-3.5 rounded-full border border-border\"\n            style={{ backgroundColor: currentColor }}\n            aria-hidden=\"true\"\n          />\n        ) : (\n          <Palette className=\"size-3.5 text-muted-foreground\" aria-hidden=\"true\" />\n        )}\n      </PopoverTrigger>\n      <PopoverContent\n        className=\"w-auto p-2\"\n        align=\"start\"\n        onPointerDown={(e) => e.stopPropagation()}\n      >\n        <div className=\"flex flex-col gap-2\">\n          <div className=\"grid grid-cols-6 gap-1\">\n            {palette.map((s) => {\n              const color = swatchCssColor(s);\n              const active = current === s.id;\n              return (\n                <button\n                  key={s.id}\n                  type=\"button\"\n                  className={cn(\n                    \"relative flex size-6 items-center justify-center rounded-md border border-border transition\",\n                    \"hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                  )}\n                  style={{ backgroundColor: color }}\n                  aria-label={s.label}\n                  aria-pressed={active}\n                  onClick={() => onChange(s.id)}\n                >\n                  {active ? (\n                    <Check className=\"size-3 text-background drop-shadow\" aria-hidden=\"true\" />\n                  ) : null}\n                </button>\n              );\n            })}\n          </div>\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"h-7 text-xs\"\n            onClick={() => onChange(undefined)}\n          >\n            Clear color\n          </Button>\n        </div>\n      </PopoverContent>\n    </Popover>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/color-picker.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/drag-overlay.tsx",
      "content": "\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { DragOverlay } from \"@dnd-kit/core\";\nimport { cn } from \"@/lib/utils\";\nimport type { AnyKanbanCardRenderer, KanbanItem, KanbanRenderContext } from \"../types\";\nimport { ItemRenderer } from \"./item-renderer\";\n\nexport function KanbanDragOverlay({\n  activeItem,\n  rendererMap,\n}: {\n  activeItem: KanbanItem | null;\n  rendererMap: Map<string, AnyKanbanCardRenderer>;\n}) {\n  // Portal the overlay into <body> so a transformed ancestor above the board\n  // can't become the containing block for dnd-kit's `position: fixed` overlay\n  // and offset the drag ghost from the pointer. (`transform`, `filter`,\n  // `perspective`, `will-change: transform`, and `contain: paint` all create a\n  // containing block — entrance-animation wrappers are the common culprit.)\n  // React context flows through portals, so the overlay stays bound to\n  // DndContext. Gate on a client-mount flag so SSR / pre-hydration renders\n  // nothing (document is absent on the server). `useSyncExternalStore` with a\n  // `false` server snapshot is the repo's SSR-safe client-detect primitive —\n  // it avoids the React-19 set-state-in-effect antipattern.\n  const mounted = useSyncExternalStore(\n    () => () => {},\n    () => true,\n    () => false,\n  );\n\n  const overlay = (\n    <DragOverlay dropAnimation={null}>\n      {activeItem ? (\n        <div\n          className={cn(\n            \"w-80 rotate-2 cursor-grabbing rounded-md shadow-2xl\",\n            \"ring-2 ring-ring/40\",\n          )}\n        >\n          <ItemRenderer\n            item={activeItem}\n            rendererMap={rendererMap}\n            ctx={\n              {\n                itemId: activeItem.id,\n                columnId: \"\",\n                swimlaneId: activeItem.swimlaneId,\n                isDragging: true,\n                isLocked: activeItem.locked === true,\n              } satisfies KanbanRenderContext\n            }\n          />\n        </div>\n      ) : null}\n    </DragOverlay>\n  );\n\n  if (!mounted) return null;\n  return createPortal(overlay, document.body);\n}\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/drag-overlay.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/kanban-card.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport type { KanbanCardData, KanbanCardRenderer, KanbanRenderContext } from \"../types\";\n\nfunction initials(name: string): string {\n  return name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((p) => p[0]?.toUpperCase() ?? \"\")\n    .join(\"\");\n}\n\nexport function KanbanCardView({\n  data,\n  ctx,\n}: {\n  data: KanbanCardData;\n  ctx: KanbanRenderContext;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-2 rounded-md border border-border bg-card p-3 text-card-foreground shadow-xs\",\n        \"transition-shadow\",\n        ctx.isDragging && \"shadow-md\",\n        ctx.isLocked && \"opacity-90\",\n      )}\n    >\n      <div className=\"flex items-start justify-between gap-2\">\n        <span className=\"text-sm font-medium leading-tight text-foreground\">{data.title}</span>\n      </div>\n      {data.description ? (\n        <p className=\"line-clamp-3 text-xs leading-relaxed text-muted-foreground\">\n          {data.description}\n        </p>\n      ) : null}\n      {data.tags && data.tags.length > 0 ? (\n        <div className=\"flex flex-wrap gap-1\">\n          {data.tags.map((tag, i) => (\n            <Badge key={`${tag.label}-${i}`} variant=\"secondary\" className=\"text-[10px]\">\n              {tag.label}\n            </Badge>\n          ))}\n        </div>\n      ) : null}\n      {data.meta && data.meta.length > 0 ? (\n        <ul className=\"flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground\">\n          {data.meta.map((m) => (\n            <li key={m.key} className=\"flex items-center gap-1\">\n              <span className=\"font-mono text-muted-foreground/70\">{m.label}:</span>\n              <span className=\"text-foreground\">{m.value}</span>\n            </li>\n          ))}\n        </ul>\n      ) : null}\n      {data.assignees && data.assignees.length > 0 ? (\n        <div className=\"flex -space-x-1.5\">\n          {data.assignees.slice(0, 5).map((a) => (\n            <Avatar key={a.id} className=\"size-5 border border-card\">\n              {a.avatarUrl ? <AvatarImage src={a.avatarUrl} alt={a.name} /> : null}\n              <AvatarFallback className=\"text-[10px]\">{initials(a.name)}</AvatarFallback>\n            </Avatar>\n          ))}\n          {data.assignees.length > 5 ? (\n            <span className=\"ml-2 text-[10px] text-muted-foreground\">\n              +{data.assignees.length - 5}\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nfunction KanbanCardEditForm({\n  data,\n  onSave,\n  onCancel,\n}: {\n  data: KanbanCardData;\n  onSave: (next: KanbanCardData) => void;\n  onCancel: () => void;\n}) {\n  const [title, setTitle] = useState(data.title);\n  const [description, setDescription] = useState(data.description ?? \"\");\n\n  function handleSave() {\n    if (!title.trim()) return;\n    onSave({ ...data, title: title.trim(), description: description.trim() || undefined });\n  }\n\n  return (\n    <form\n      onSubmit={(e) => {\n        e.preventDefault();\n        handleSave();\n      }}\n      className=\"flex flex-col gap-2 rounded-md border border-border bg-card p-3\"\n    >\n      <Input\n        value={title}\n        onChange={(e) => setTitle(e.target.value)}\n        placeholder=\"Card title\"\n        autoFocus\n        className=\"h-8 text-sm\"\n      />\n      <Textarea\n        value={description}\n        onChange={(e) => setDescription(e.target.value)}\n        placeholder=\"Description (optional)\"\n        rows={2}\n        className=\"resize-none text-xs\"\n      />\n      <div className=\"flex justify-end gap-1\">\n        <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={onCancel}>\n          Cancel\n        </Button>\n        <Button type=\"submit\" size=\"sm\" disabled={!title.trim()}>\n          Save\n        </Button>\n      </div>\n    </form>\n  );\n}\n\nexport const kanbanCardRenderer: KanbanCardRenderer<KanbanCardData> = {\n  id: \"kanban-card\",\n  label: \"Card\",\n  render: (data, ctx) => <KanbanCardView data={data} ctx={ctx} />,\n  newItem: () => ({ title: \"\" }),\n  editForm: (data, onSave, onCancel) => (\n    <KanbanCardEditForm data={data} onSave={onSave} onCancel={onCancel} />\n  ),\n};\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/kanban-card.tsx"
    },
    {
      "path": "src/registry/components/data/kanban-board/parts/kanban-note.tsx",
      "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\nimport { DEFAULT_PALETTE, findSwatch, swatchCssColor } from \"../lib/palette\";\nimport type { KanbanCardRenderer, KanbanNoteData, KanbanRenderContext } from \"../types\";\n\n// `data.color` keys into the kanban palette (KanbanNoteData.color); default\n// \"amber\" preserves the v0.1 visual (amber-tinted note) but via the swatch's\n// --chart-1 token instead of hardcoded `amber-*` Tailwind classes — keeps the\n// component honest to the project's design-system mandate. Consumers using a\n// custom board-level `palette` prop see DEFAULT_PALETTE here in v0.2.1; full\n// palette propagation through `KanbanRenderContext` is a v0.3 follow-up\n// (would be additive — no breaking change).\nconst NOTE_FALLBACK_COLOR_ID = \"amber\";\n\nexport function KanbanNoteView({\n  data,\n  ctx,\n}: {\n  data: KanbanNoteData;\n  ctx: KanbanRenderContext;\n}) {\n  const swatch = findSwatch(DEFAULT_PALETTE, data.color ?? NOTE_FALLBACK_COLOR_ID);\n  const accentColor = swatchCssColor(swatch);\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-1 rounded-md border-l-2 border-y border-r border-border bg-muted/40 p-2.5\",\n        \"transition-shadow\",\n        ctx.isDragging && \"shadow-md\",\n        ctx.isLocked && \"opacity-90\",\n      )}\n      style={accentColor ? { borderLeftColor: accentColor } : undefined}\n    >\n      <span className=\"text-xs font-semibold tracking-wide uppercase text-foreground\">\n        {data.title}\n      </span>\n      {data.body ? (\n        <p className=\"text-xs leading-relaxed text-foreground/80\">{data.body}</p>\n      ) : null}\n    </div>\n  );\n}\n\nfunction KanbanNoteEditForm({\n  data,\n  onSave,\n  onCancel,\n}: {\n  data: KanbanNoteData;\n  onSave: (next: KanbanNoteData) => void;\n  onCancel: () => void;\n}) {\n  const [title, setTitle] = useState(data.title);\n  const [body, setBody] = useState(data.body ?? \"\");\n  const swatch = findSwatch(DEFAULT_PALETTE, data.color ?? NOTE_FALLBACK_COLOR_ID);\n  const accentColor = swatchCssColor(swatch);\n\n  function handleSave() {\n    if (!title.trim()) return;\n    onSave({ ...data, title: title.trim(), body: body.trim() || undefined });\n  }\n\n  return (\n    <form\n      onSubmit={(e) => {\n        e.preventDefault();\n        handleSave();\n      }}\n      className=\"flex flex-col gap-2 rounded-md border-l-2 border-y border-r border-border bg-muted/40 p-2.5\"\n      style={accentColor ? { borderLeftColor: accentColor } : undefined}\n    >\n      <Input\n        value={title}\n        onChange={(e) => setTitle(e.target.value)}\n        placeholder=\"Note title\"\n        autoFocus\n        className=\"h-7 text-xs\"\n      />\n      <Textarea\n        value={body}\n        onChange={(e) => setBody(e.target.value)}\n        placeholder=\"Body (optional)\"\n        rows={2}\n        className=\"resize-none text-xs\"\n      />\n      <div className=\"flex justify-end gap-1\">\n        <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={onCancel}>\n          Cancel\n        </Button>\n        <Button type=\"submit\" size=\"sm\" disabled={!title.trim()}>\n          Save\n        </Button>\n      </div>\n    </form>\n  );\n}\n\nexport const kanbanNoteRenderer: KanbanCardRenderer<KanbanNoteData> = {\n  id: \"kanban-note\",\n  label: \"Note\",\n  render: (data, ctx) => <KanbanNoteView data={data} ctx={ctx} />,\n  newItem: () => ({ title: \"\" }),\n  editForm: (data, onSave, onCancel) => (\n    <KanbanNoteEditForm data={data} onSave={onSave} onCancel={onCancel} />\n  ),\n};\n",
      "type": "registry:component",
      "target": "components/kanban-board/parts/kanban-note.tsx"
    }
  ],
  "categories": [
    "data",
    "kanban",
    "drag-and-drop"
  ],
  "type": "registry:block"
}