{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban-board",
  "type": "registry:ui",
  "title": "KanbanBoard",
  "description": "Data-driven kanban board with a Pointer Events drag engine (velocity tilt, lift, spring settle, edge auto-scroll), keyboard pick-up/move/drop and a Move menu for single-pointer users, live announcements, soft and hard WIP limits with header meters, collapsible columns and swimlanes, canMove vetoes, and optimistic async moves that roll back with retry.",
  "dependencies": [
    "lucide-react@^1.47.0",
    "motion@^12.43.0"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json",
    "https://components.dethink.co.uk/r/dropdown-menu.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/kanban-board/kanban-board-model.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/kanban-board-model.ts",
      "content": "import type { ReactNode } from \"react\";\n\nexport interface KanbanItem {\n  id: string;\n  columnId: string;\n  laneId?: string;\n}\n\nexport interface KanbanLocation {\n  columnId: string;\n  laneId?: string;\n  /** Zero-based position inside the column/lane cell. */\n  index: number;\n}\n\nexport interface KanbanMove {\n  itemId: string;\n  from: KanbanLocation;\n  to: KanbanLocation;\n}\n\nexport type KanbanWipMode = \"soft\" | \"hard\";\n\nexport type KanbanWipLimit =\n  number | { max: number; min?: number; mode?: KanbanWipMode };\n\nexport type KanbanColumnTone =\n  \"neutral\" | \"primary\" | \"info\" | \"success\" | \"warning\" | \"destructive\";\n\nexport interface KanbanColumn {\n  id: string;\n  title: string;\n  description?: string;\n  icon?: ReactNode;\n  tone?: KanbanColumnTone;\n  wipLimit?: KanbanWipLimit;\n  /** Refuses every drop into this column. Cards can still leave it. */\n  dropDisabled?: boolean;\n}\n\nexport interface KanbanLane {\n  id: string;\n  title: string;\n  description?: string;\n}\n\nexport type KanbanWipStatus = \"none\" | \"under\" | \"ok\" | \"at\" | \"over\";\n\nexport interface KanbanWipState {\n  count: number;\n  max: number | null;\n  min: number | null;\n  mode: KanbanWipMode;\n  status: KanbanWipStatus;\n}\n\nexport function resolveKanbanWipLimit(limit: KanbanWipLimit | undefined) {\n  if (limit === undefined) {\n    return null;\n  }\n\n  const raw = typeof limit === \"number\" ? { max: limit } : limit;\n\n  if (!Number.isFinite(raw.max) || raw.max < 0) {\n    return null;\n  }\n\n  const min =\n    raw.min !== undefined && Number.isFinite(raw.min) && raw.min >= 0\n      ? Math.min(Math.trunc(raw.min), Math.trunc(raw.max))\n      : null;\n\n  return {\n    max: Math.trunc(raw.max),\n    min,\n    mode: raw.mode ?? \"soft\",\n  } as const;\n}\n\nexport function getKanbanWipState(\n  count: number,\n  limit: KanbanWipLimit | undefined,\n): KanbanWipState {\n  const resolved = resolveKanbanWipLimit(limit);\n\n  if (!resolved) {\n    return { count, max: null, min: null, mode: \"soft\", status: \"none\" };\n  }\n\n  let status: KanbanWipStatus = \"ok\";\n\n  if (count > resolved.max) {\n    status = \"over\";\n  } else if (count === resolved.max) {\n    status = \"at\";\n  } else if (resolved.min !== null && count < resolved.min) {\n    status = \"under\";\n  }\n\n  return { count, ...resolved, status };\n}\n\n/** Lane an item renders in: its own lane when known, otherwise the first lane. */\nexport function resolveKanbanLaneId(\n  item: Pick<KanbanItem, \"laneId\">,\n  lanes: readonly KanbanLane[] | undefined,\n) {\n  if (!lanes || lanes.length === 0) {\n    return undefined;\n  }\n\n  if (item.laneId !== undefined && lanes.some((l) => l.id === item.laneId)) {\n    return item.laneId;\n  }\n\n  return lanes[0]?.id;\n}\n\nfunction isInCell<T extends KanbanItem>(\n  item: T,\n  columnId: string,\n  laneId: string | undefined,\n  lanes: readonly KanbanLane[] | undefined,\n) {\n  return (\n    item.columnId === columnId && resolveKanbanLaneId(item, lanes) === laneId\n  );\n}\n\n/** Items in one column (and lane, when lanes are used), in board order. */\nexport function getKanbanCell<T extends KanbanItem>(\n  items: readonly T[],\n  columnId: string,\n  laneId?: string,\n  lanes?: readonly KanbanLane[],\n): T[] {\n  return items.filter((item) => isInCell(item, columnId, laneId, lanes));\n}\n\nexport function getKanbanLocation<T extends KanbanItem>(\n  items: readonly T[],\n  itemId: string,\n  lanes?: readonly KanbanLane[],\n): KanbanLocation | null {\n  const item = items.find((candidate) => candidate.id === itemId);\n\n  if (!item) {\n    return null;\n  }\n\n  const laneId = resolveKanbanLaneId(item, lanes);\n  const index = getKanbanCell(items, item.columnId, laneId, lanes).findIndex(\n    (candidate) => candidate.id === itemId,\n  );\n\n  return { columnId: item.columnId, laneId, index };\n}\n\nexport function isSameKanbanLocation(\n  a: KanbanLocation | null | undefined,\n  b: KanbanLocation | null | undefined,\n) {\n  return (\n    !!a &&\n    !!b &&\n    a.columnId === b.columnId &&\n    a.laneId === b.laneId &&\n    a.index === b.index\n  );\n}\n\n/**\n * Returns a new array with the item placed at `move.to`. Pure and idempotent:\n * re-applying a move that already happened yields the same order, which keeps\n * optimistic overlays safe when the consumer's store catches up.\n */\nexport function applyKanbanMove<T extends KanbanItem>(\n  items: readonly T[],\n  move: Pick<KanbanMove, \"itemId\" | \"to\">,\n  lanes?: readonly KanbanLane[],\n): T[] {\n  const sourceIndex = items.findIndex((item) => item.id === move.itemId);\n\n  if (sourceIndex === -1) {\n    return [...items];\n  }\n\n  const source = items[sourceIndex] as T;\n  const hasLanes = !!lanes && lanes.length > 0;\n  const laneId = hasLanes\n    ? (move.to.laneId ?? resolveKanbanLaneId(source, lanes))\n    : undefined;\n  const moved = { ...source, columnId: move.to.columnId } as T;\n\n  if (hasLanes) {\n    moved.laneId = laneId;\n  }\n\n  const rest = items.filter((_, index) => index !== sourceIndex);\n  const cell = rest.filter((item) =>\n    isInCell(item, move.to.columnId, laneId, lanes),\n  );\n  const targetIndex = Math.min(\n    Math.max(Math.trunc(move.to.index), 0),\n    cell.length,\n  );\n  let insertAt: number;\n\n  if (cell.length === 0) {\n    insertAt = rest.length;\n  } else if (targetIndex < cell.length) {\n    insertAt = rest.indexOf(cell[targetIndex] as T);\n  } else {\n    insertAt = rest.indexOf(cell[cell.length - 1] as T) + 1;\n  }\n\n  rest.splice(insertAt, 0, moved);\n\n  return rest;\n}\n\nexport function countKanbanColumn<T extends KanbanItem>(\n  items: readonly T[],\n  columnId: string,\n) {\n  return items.reduce(\n    (total, item) => (item.columnId === columnId ? total + 1 : total),\n    0,\n  );\n}\n\n/**\n * Whether a hard WIP limit or `dropDisabled` refuses a card entering the\n * column. Reordering inside a column is always allowed.\n */\nexport function getKanbanDropRefusal<T extends KanbanItem>(\n  items: readonly T[],\n  itemId: string,\n  column: KanbanColumn | undefined,\n): \"disabled\" | \"limit\" | null {\n  if (!column) {\n    return \"disabled\";\n  }\n\n  const item = items.find((candidate) => candidate.id === itemId);\n\n  if (item?.columnId === column.id) {\n    return null;\n  }\n\n  if (column.dropDisabled) {\n    return \"disabled\";\n  }\n\n  const limit = resolveKanbanWipLimit(column.wipLimit);\n\n  if (\n    limit?.mode === \"hard\" &&\n    countKanbanColumn(items, column.id) >= limit.max\n  ) {\n    return \"limit\";\n  }\n\n  return null;\n}\n"
    },
    {
      "path": "packages/components/src/components/kanban-board/kanban-board-types.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/kanban-board-types.ts",
      "content": "import type { CSSProperties, HTMLAttributes, ReactNode } from \"react\";\nimport type {\n  KanbanColumn,\n  KanbanItem,\n  KanbanLane,\n  KanbanLocation,\n  KanbanMove,\n} from \"./kanban-board-model\";\nimport type { KanbanBoardLabels } from \"./kanban-board-parts\";\n\nexport type KanbanDragHandleMode = \"card\" | \"handle\";\n\nexport interface KanbanDragHandleProps {\n  \"data-kanban-drag-handle\": \"\";\n  style: CSSProperties;\n}\n\nexport interface KanbanCardRenderState {\n  location: KanbanLocation;\n  column: KanbanColumn;\n  lane?: KanbanLane;\n  /** The card is being moved: the in-board placeholder or the keyboard-lifted card. */\n  isDragging: boolean;\n  /** The floating copy that follows the pointer. */\n  isOverlay: boolean;\n  isPending: boolean;\n  error: string | null;\n  /** Spread on a custom grip when `dragHandle=\"handle\"`. */\n  dragHandleProps: KanbanDragHandleProps | undefined;\n}\n\nexport interface KanbanMoveContext<T extends KanbanItem> {\n  item: T;\n  /** Items with the move applied. */\n  items: T[];\n  previousItems: T[];\n}\n\nexport interface KanbanBoardProps<T extends KanbanItem> extends Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"defaultValue\" | \"onChange\"\n> {\n  columns: readonly KanbanColumn[];\n  /** Optional swimlanes. Cards without a known `laneId` render in the first lane. */\n  lanes?: readonly KanbanLane[];\n  items?: T[];\n  defaultItems?: T[];\n  /** Called when a move is committed, or when a pending move resolves. */\n  onItemsChange?: (items: T[], move: KanbanMove) => void;\n  /**\n   * Persist a move. Return a Promise to make it optimistic: the card shows in its\n   * new place while pending, and springs back with an error when it rejects.\n   */\n  onMove?: (\n    move: KanbanMove,\n    context: KanbanMoveContext<T>,\n  ) => void | Promise<unknown>;\n  /** Veto a move. Return a string to explain why; it is shown and announced. */\n  canMove?: (move: KanbanMove, item: T) => boolean | string;\n  renderCard: (item: T, state: KanbanCardRenderState) => ReactNode;\n  /** Plain-text card name for announcements and the move menu. */\n  getItemLabel?: (item: T) => string;\n  /** Turn a rejected move into a user-facing message. */\n  getErrorMessage?: (error: unknown, item: T) => string;\n  label?: string;\n  labels?: Partial<KanbanBoardLabels>;\n  /** Column width as a CSS length. Default `18rem`. */\n  columnWidth?: string;\n  /** Maximum tilt in degrees while dragging. `0` disables it. Default `6`. */\n  dragTilt?: number;\n  /** Start pointer drags from the whole card or only from `dragHandleProps`. */\n  dragHandle?: KanbanDragHandleMode;\n  disabled?: boolean;\n  showMoveMenu?: boolean;\n  collapsibleColumns?: boolean;\n  collapsedColumns?: string[];\n  defaultCollapsedColumns?: string[];\n  onCollapsedColumnsChange?: (columnIds: string[]) => void;\n  collapsedLanes?: string[];\n  defaultCollapsedLanes?: string[];\n  onCollapsedLanesChange?: (laneIds: string[]) => void;\n  renderColumnFooter?: (column: KanbanColumn, items: T[]) => ReactNode;\n  renderEmptyColumn?: (column: KanbanColumn, lane?: KanbanLane) => ReactNode;\n}\n\nexport type DragMode = \"keyboard\" | \"pointer\";\n\nexport interface DragState {\n  itemId: string;\n  from: KanbanLocation;\n  preview: KanbanLocation;\n  mode: DragMode;\n  phase: \"dragging\" | \"settling\";\n  blocked: { columnId: string; reason: string } | null;\n}\n\nexport interface PointerSession {\n  pointerId: number;\n  pointerType: string;\n  itemId: string;\n  startX: number;\n  startY: number;\n  lastX: number;\n  lastY: number;\n  lastTime: number;\n  offsetX: number;\n  offsetY: number;\n  active: boolean;\n  timer: ReturnType<typeof setTimeout> | undefined;\n  frame: number | undefined;\n  idle: ReturnType<typeof setTimeout> | undefined;\n}\n\nexport interface CellTarget {\n  key: string;\n  columnId: string;\n  laneId: string | undefined;\n  zone: HTMLElement;\n}\n\nexport interface PendingMove {\n  key: number;\n  move: KanbanMove;\n  /**\n   * `saving` while onMove is unresolved; `saved` once confirmed but still\n   * waiting behind an earlier unsettled move before it reaches the base items.\n   */\n  status: \"saving\" | \"saved\";\n}\n"
    },
    {
      "path": "packages/components/src/components/kanban-board/kanban-board-utils.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/kanban-board-utils.ts",
      "content": "import { useCallback, useEffect, useLayoutEffect, useState } from \"react\";\nimport { cn } from \"../../utils/cn\";\nimport type { KanbanItem, KanbanLocation } from \"./kanban-board-model\";\nimport type { CellTarget } from \"./kanban-board-types\";\n\nexport const MOUSE_ACTIVATION_DISTANCE = 5;\nexport const TOUCH_ACTIVATION_DELAY = 180;\nexport const TOUCH_TOLERANCE = 8;\nexport const AUTO_SCROLL_EDGE = 72;\nexport const AUTO_SCROLL_MAX_SPEED = 20;\nexport const SPRING = {\n  type: \"spring\",\n  stiffness: 520,\n  damping: 38,\n  mass: 0.9,\n} as const;\n\nexport const useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport const cellKey = (columnId: string, laneId: string | undefined) =>\n  `${columnId}\\u0000${laneId ?? \"\"}`;\n\nexport function isPromiseLike(value: unknown): value is PromiseLike<unknown> {\n  return (\n    !!value &&\n    (typeof value === \"object\" || typeof value === \"function\") &&\n    typeof (value as PromiseLike<unknown>).then === \"function\"\n  );\n}\n\nexport function isInteractiveTarget(target: EventTarget | null, card: Element) {\n  if (!(target instanceof Element)) {\n    return false;\n  }\n\n  const interactive = target.closest(\n    \"a[href],button,input,select,textarea,label,summary,[contenteditable=''],[contenteditable='true'],[role='button'],[role='link'],[role='menuitem'],[data-kanban-no-drag]\",\n  );\n\n  return !!interactive && interactive !== card && card.contains(interactive);\n}\n\nexport function defaultItemLabel(item: KanbanItem) {\n  const title = (item as { title?: unknown }).title;\n\n  return typeof title === \"string\" && title ? title : item.id;\n}\n\nexport function useListState(\n  value: string[] | undefined,\n  defaultValue: string[] | undefined,\n  onChange: ((next: string[]) => void) | undefined,\n) {\n  const [internal, setInternal] = useState<string[]>(defaultValue ?? []);\n  const current = value ?? internal;\n  const toggle = useCallback(\n    (id: string) => {\n      const next = current.includes(id)\n        ? current.filter((entry) => entry !== id)\n        : [...current, id];\n\n      if (value === undefined) {\n        setInternal(next);\n      }\n\n      onChange?.(next);\n    },\n    [current, onChange, value],\n  );\n\n  return [current, toggle] as const;\n}\n\nexport const boardRootClasses =\n  \"relative isolate flex min-h-0 min-w-0 flex-col text-foreground [--kanban-rail-width:3rem]\";\n\nexport const boardScrollerClasses =\n  \"min-h-0 flex-1 overflow-auto overscroll-contain [scrollbar-gutter:stable] focus-visible:outline-none\";\n\nexport const columnShellClasses =\n  \"group/kanban-column relative flex min-h-0 min-w-0 flex-col rounded-xl border border-border/70 bg-muted/45 motion-safe:transition-[background-color,border-color,box-shadow] motion-safe:duration-[var(--dt-motion-fast)] data-[drop-target=true]:border-primary/40 data-[drop-target=true]:bg-primary/[0.045] data-[drop-blocked=true]:border-destructive/50 data-[drop-blocked=true]:bg-destructive/[0.05]\";\n\nexport const listClasses =\n  \"relative flex min-h-12 list-none flex-col gap-[calc(var(--dt-density-gap)+var(--dt-space-0-5))] p-[calc(var(--dt-density-gap)*0.75+var(--dt-space-0-5))] pt-0.5\";\n\nexport const cardClasses =\n  \"group/kanban-card relative isolate cursor-grab touch-manipulation rounded-lg border border-border bg-background p-[calc(var(--dt-density-gap)+var(--dt-space-1))] text-sm shadow-[0_1px_2px_oklch(0_0_0/0.06)] outline-none select-none [-webkit-touch-callout:none] motion-safe:transition-[box-shadow,border-color,translate,scale,rotate,opacity] motion-safe:duration-[var(--dt-motion-fast)] motion-safe:ease-control hover:border-foreground/15 hover:shadow-[0_6px_16px_-8px_oklch(0_0_0/0.22)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background data-[lifted=true]:z-10 data-[lifted=true]:cursor-grabbing data-[lifted=true]:border-primary/50 data-[lifted=true]:shadow-[0_18px_36px_-14px_oklch(0_0_0/0.4)] motion-safe:data-[lifted=true]:scale-[1.025] motion-safe:data-[lifted=true]:-rotate-1 data-[placeholder=true]:border-transparent data-[placeholder=true]:bg-transparent data-[placeholder=true]:shadow-none data-[pending=true]:border-dashed data-[error=true]:border-destructive/60 data-[disabled=true]:cursor-default\";\n\nexport const overlayClasses =\n  \"pointer-events-none fixed left-0 top-0 z-[60] cursor-grabbing rounded-lg border border-primary/35 bg-background p-[calc(var(--dt-density-gap)+var(--dt-space-1))] text-sm text-foreground shadow-[0_28px_60px_-18px_oklch(0_0_0/0.45),0_10px_20px_-12px_oklch(0_0_0/0.25)] ring-1 ring-primary/15\";\n\nexport function kanbanBoardClassNames({\n  className,\n}: { className?: string } = {}) {\n  return cn(boardRootClasses, className);\n}\n\n/**\n * Finds the drop location under the pointer. `countCell` sizes cells whose list\n * is not mounted (collapsed or empty) so the card lands at their end.\n */\nexport function hitTestKanbanCells(\n  targets: Iterable<CellTarget>,\n  lists: ReadonlyMap<string, HTMLElement>,\n  x: number,\n  y: number,\n  itemId: string,\n  countCell: (columnId: string, laneId: string | undefined) => number,\n): KanbanLocation | null {\n  let best: CellTarget | null = null;\n\n  for (const target of targets) {\n    const rect = target.zone.getBoundingClientRect();\n\n    if (\n      x >= rect.left &&\n      x <= rect.right &&\n      y >= rect.top &&\n      y <= rect.bottom\n    ) {\n      best = target;\n      break;\n    }\n  }\n\n  if (!best) {\n    return null;\n  }\n\n  const list = lists.get(best.key) ?? null;\n  const cards = list\n    ? Array.from(\n        list.querySelectorAll<HTMLElement>(\":scope > [data-kanban-card]\"),\n      )\n    : [];\n\n  if (!list) {\n    return {\n      columnId: best.columnId,\n      laneId: best.laneId,\n      index: countCell(best.columnId, best.laneId),\n    };\n  }\n\n  // offsetTop ignores the transforms layout animations apply mid-flight,\n  // which keeps hit-testing stable while siblings glide.\n  const listRect = list.getBoundingClientRect();\n  const pointerY = y - listRect.top + list.scrollTop;\n  const placeholder = cards.find((card) => card.dataset.kanbanCard === itemId);\n  const gap = Number.parseFloat(getComputedStyle(list).rowGap) || 0;\n  const shift = placeholder ? placeholder.offsetHeight + gap : 0;\n  let passedPlaceholder = false;\n  let index = 0;\n\n  for (const card of cards) {\n    if (card === placeholder) {\n      passedPlaceholder = true;\n      continue;\n    }\n\n    const middle =\n      card.offsetTop - (passedPlaceholder ? shift : 0) + card.offsetHeight / 2;\n\n    if (pointerY > middle) {\n      index += 1;\n    }\n  }\n\n  return { columnId: best.columnId, laneId: best.laneId, index };\n}\n\n/** Scrolls the board and any overflowing list whose edge the pointer is near. */\nexport function autoScrollKanban(\n  scroller: HTMLElement | null,\n  lists: Iterable<HTMLElement>,\n  x: number,\n  y: number,\n) {\n  const scrollEdge = (element: HTMLElement | null, axis: \"x\" | \"y\") => {\n    if (!element) {\n      return false;\n    }\n\n    const rect = element.getBoundingClientRect();\n    const start = axis === \"x\" ? rect.left : rect.top;\n    const end = axis === \"x\" ? rect.right : rect.bottom;\n    const cross = axis === \"x\" ? y : x;\n    const crossStart = axis === \"x\" ? rect.top : rect.left;\n    const crossEnd = axis === \"x\" ? rect.bottom : rect.right;\n\n    if (cross < crossStart || cross > crossEnd) {\n      return false;\n    }\n\n    const point = axis === \"x\" ? x : y;\n    const edge = Math.min(AUTO_SCROLL_EDGE, (end - start) / 4);\n    let delta = 0;\n\n    if (point < start + edge) {\n      delta = -Math.min(1, (start + edge - point) / edge);\n    } else if (point > end - edge) {\n      delta = Math.min(1, (point - (end - edge)) / edge);\n    }\n\n    if (delta === 0) {\n      return false;\n    }\n\n    const speed = Math.sign(delta) * AUTO_SCROLL_MAX_SPEED * delta * delta;\n    const before = axis === \"x\" ? element.scrollLeft : element.scrollTop;\n\n    if (axis === \"x\") {\n      element.scrollLeft += speed;\n    } else {\n      element.scrollTop += speed;\n    }\n\n    return (axis === \"x\" ? element.scrollLeft : element.scrollTop) !== before;\n  };\n\n  let scrolled = scrollEdge(scroller, \"x\");\n\n  scrolled = scrollEdge(scroller, \"y\") || scrolled;\n\n  for (const list of lists) {\n    if (list.scrollHeight > list.clientHeight + 1) {\n      scrolled = scrollEdge(list, \"y\") || scrolled;\n    }\n  }\n\n  return scrolled;\n}\n"
    },
    {
      "path": "packages/components/src/components/kanban-board/kanban-board-parts.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/kanban-board-parts.tsx",
      "content": "import { useState, type ReactNode } from \"react\";\nimport {\n  ArrowDown,\n  ArrowDownToLine,\n  ArrowUp,\n  ArrowUpToLine,\n  Ban,\n  ChevronRight,\n  Columns3,\n  EllipsisVertical,\n  ChevronsLeftRight,\n  ChevronsRightLeft,\n  Rows3,\n  TriangleAlert,\n} from \"lucide-react\";\nimport { cn } from \"../../utils/cn\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuItemDescription,\n  DropdownMenuItemIcon,\n  DropdownMenuItemLabel,\n  DropdownMenuSection,\n  DropdownMenuLabel,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"../dropdown-menu\";\nimport type {\n  KanbanColumn,\n  KanbanColumnTone,\n  KanbanLane,\n  KanbanWipState,\n} from \"./kanban-board-model\";\n\nexport interface KanbanAnnouncementContext {\n  item: string;\n  column: string;\n  lane?: string;\n  /** One-based position. */\n  position: number;\n  total: number;\n  reason?: string;\n}\n\nexport interface KanbanBoardLabels {\n  board: string;\n  cardRoleDescription: string;\n  instructions: string;\n  moveMenu: (item: string) => string;\n  moveUp: string;\n  moveDown: string;\n  moveToTop: string;\n  moveToBottom: string;\n  moveToColumn: string;\n  moveToLane: string;\n  collapseColumn: (column: string) => string;\n  expandColumn: (column: string) => string;\n  collapseLane: (lane: string) => string;\n  expandLane: (lane: string) => string;\n  cardCount: (count: number) => string;\n  emptyColumn: string;\n  pending: string;\n  error: string;\n  retry: string;\n  dismiss: string;\n  wipLimit: string;\n  wipOver: string;\n  wipAt: string;\n  wipUnder: string;\n  wipValue: (count: number, max: number) => string;\n  refusedLimit: (column: string, count: number, max: number) => string;\n  refusedDisabled: (column: string) => string;\n  refusedDefault: string;\n  pickedUp: (context: KanbanAnnouncementContext) => string;\n  moved: (context: KanbanAnnouncementContext) => string;\n  dropped: (context: KanbanAnnouncementContext) => string;\n  cancelled: (context: KanbanAnnouncementContext) => string;\n  refused: (context: KanbanAnnouncementContext) => string;\n  failed: (context: KanbanAnnouncementContext) => string;\n}\n\nfunction describePosition({\n  column,\n  lane,\n  position,\n  total,\n}: KanbanAnnouncementContext) {\n  return `position ${position} of ${total} in ${column}${lane ? `, ${lane}` : \"\"}`;\n}\n\nexport const defaultKanbanBoardLabels: KanbanBoardLabels = {\n  board: \"Kanban board\",\n  cardRoleDescription: \"movable card\",\n  instructions:\n    \"Press Space or Enter to pick up this card. Use the arrow keys to move it, Space or Enter to drop it, and Escape to cancel.\",\n  moveMenu: (item) => `Move ${item}`,\n  moveUp: \"Move up\",\n  moveDown: \"Move down\",\n  moveToTop: \"Move to top\",\n  moveToBottom: \"Move to bottom\",\n  moveToColumn: \"Move to column\",\n  moveToLane: \"Move to lane\",\n  collapseColumn: (column) => `Collapse ${column}`,\n  expandColumn: (column) => `Expand ${column}`,\n  collapseLane: (lane) => `Collapse ${lane}`,\n  expandLane: (lane) => `Expand ${lane}`,\n  cardCount: (count) => `${count} ${count === 1 ? \"card\" : \"cards\"}`,\n  emptyColumn: \"No cards\",\n  pending: \"Saving\",\n  error: \"Couldn't save this move.\",\n  retry: \"Retry\",\n  dismiss: \"Dismiss\",\n  wipLimit: \"Work in progress\",\n  wipOver: \"Over limit\",\n  wipAt: \"At limit\",\n  wipUnder: \"Below minimum\",\n  wipValue: (count, max) => `${count} of ${max}`,\n  refusedLimit: (column, count, max) =>\n    `${column} is at its limit, ${count} of ${max}.`,\n  refusedDisabled: (column) => `${column} doesn't accept cards.`,\n  refusedDefault: \"That move isn't allowed.\",\n  pickedUp: (context) =>\n    `Picked up ${context.item}, ${describePosition(context)}.`,\n  moved: (context) => `${describePosition(context)}.`,\n  dropped: (context) =>\n    `Dropped ${context.item}, ${describePosition(context)}.`,\n  cancelled: (context) =>\n    `Move cancelled. ${context.item} returned to ${describePosition(context)}.`,\n  refused: (context) => context.reason ?? \"That move isn't allowed.\",\n  failed: (context) =>\n    `Couldn't move ${context.item}. It went back to ${describePosition(context)}.`,\n};\n\nexport const kanbanToneDotClasses: Record<KanbanColumnTone, string> = {\n  neutral: \"bg-muted-foreground/60\",\n  primary: \"bg-primary\",\n  info: \"bg-info\",\n  success: \"bg-success\",\n  warning: \"bg-warning\",\n  destructive: \"bg-destructive\",\n};\n\nconst kanbanToneAccentClasses: Record<KanbanColumnTone, string> = {\n  neutral: \"from-muted-foreground/40\",\n  primary: \"from-primary\",\n  info: \"from-info\",\n  success: \"from-success\",\n  warning: \"from-warning\",\n  destructive: \"from-destructive\",\n};\n\nconst wipFillClasses: Record<KanbanWipState[\"status\"], string> = {\n  none: \"bg-muted-foreground/40\",\n  ok: \"bg-foreground/55\",\n  under: \"bg-info\",\n  at: \"bg-warning\",\n  over: \"bg-destructive\",\n};\n\nconst collapseButtonClasses =\n  \"inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-safe:transition-colors motion-safe:duration-[var(--dt-motion-fast)] [&>svg]:size-4\";\n\nexport function KanbanWipMeter({\n  labels,\n  state,\n}: {\n  labels: KanbanBoardLabels;\n  state: KanbanWipState;\n}) {\n  if (state.max === null) {\n    return null;\n  }\n\n  const max = state.max;\n  const segmented = max > 0 && max <= 12;\n  const filled = Math.min(state.count, max);\n  const statusLabel =\n    state.status === \"over\"\n      ? labels.wipOver\n      : state.status === \"under\"\n        ? labels.wipUnder\n        : state.status === \"at\"\n          ? labels.wipAt\n          : null;\n  const valueText = `${labels.wipValue(state.count, max)}${statusLabel ? `, ${statusLabel}` : \"\"}`;\n\n  return (\n    <div\n      className=\"flex min-w-0 items-center gap-2\"\n      data-slot=\"kanban-wip\"\n      data-status={state.status}\n      data-mode={state.mode}\n    >\n      <div\n        role=\"meter\"\n        aria-label={labels.wipLimit}\n        aria-valuemin={0}\n        aria-valuemax={Math.max(max, 1)}\n        aria-valuenow={Math.min(state.count, Math.max(max, 1))}\n        aria-valuetext={valueText}\n        className=\"flex h-1.5 min-w-0 flex-1 items-stretch gap-0.5\"\n      >\n        {segmented ? (\n          Array.from({ length: max }, (_, index) => (\n            <span\n              key={index}\n              className={cn(\n                \"flex-1 rounded-full motion-safe:transition-colors motion-safe:duration-[var(--dt-motion-standard)]\",\n                index < filled\n                  ? wipFillClasses[state.status]\n                  : \"bg-foreground/10\",\n              )}\n            />\n          ))\n        ) : (\n          <span className=\"bg-foreground/10 relative flex-1 overflow-hidden rounded-full\">\n            <span\n              className={cn(\n                \"absolute inset-y-0 start-0 rounded-full motion-safe:transition-[width,background-color] motion-safe:duration-[var(--dt-motion-standard)]\",\n                wipFillClasses[state.status],\n              )}\n              style={{\n                width: `${max === 0 ? 100 : Math.min(100, (state.count / max) * 100)}%`,\n              }}\n            />\n          </span>\n        )}\n      </div>\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"shrink-0 text-[0.6875rem] font-medium tabular-nums\",\n          state.status === \"over\"\n            ? \"text-destructive\"\n            : state.status === \"at\"\n              ? \"text-warning\"\n              : state.status === \"under\"\n                ? \"text-info\"\n                : \"text-muted-foreground\",\n        )}\n      >\n        {state.count}/{max}\n      </span>\n      {state.status === \"over\" ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"bg-destructive/10 text-destructive inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-px text-[0.6875rem] font-medium\"\n        >\n          <TriangleAlert className=\"size-3\" />\n          {labels.wipOver}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n\nexport function KanbanColumnHeader({\n  collapsed,\n  collapsible,\n  column,\n  count,\n  headingId,\n  labels,\n  onToggle,\n  refusal,\n  wip,\n}: {\n  collapsed: boolean;\n  collapsible: boolean;\n  column: KanbanColumn;\n  count: number;\n  headingId: string;\n  labels: KanbanBoardLabels;\n  onToggle: () => void;\n  refusal: string | null;\n  wip: KanbanWipState;\n}) {\n  const tone = column.tone ?? \"neutral\";\n\n  if (collapsed) {\n    return (\n      <div\n        className=\"flex h-full flex-col items-center gap-2 py-2 select-none\"\n        data-slot=\"kanban-column-header\"\n      >\n        <button\n          type=\"button\"\n          className={collapseButtonClasses}\n          aria-label={labels.expandColumn(column.title)}\n          aria-expanded={false}\n          onClick={onToggle}\n        >\n          <ChevronsLeftRight aria-hidden=\"true\" />\n        </button>\n        <span\n          aria-hidden=\"true\"\n          className={cn(\"size-2 rounded-full\", kanbanToneDotClasses[tone])}\n        />\n        <span\n          id={headingId}\n          className=\"text-foreground text-sm font-medium [writing-mode:vertical-rl]\"\n        >\n          {column.title}\n        </span>\n        <span className=\"bg-background text-muted-foreground ring-border rounded-full px-1.5 text-xs tabular-nums ring-1\">\n          <span aria-hidden=\"true\">{count}</span>\n          <span className=\"sr-only\">{labels.cardCount(count)}</span>\n        </span>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className=\"relative flex flex-col gap-2 px-[calc(var(--dt-density-gap)+var(--dt-space-1))] pt-[calc(var(--dt-density-gap)+var(--dt-space-1))] pb-2 select-none\"\n      data-slot=\"kanban-column-header\"\n    >\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"absolute inset-x-3 top-0 h-0.5 rounded-b-full bg-gradient-to-r to-transparent\",\n          kanbanToneAccentClasses[tone],\n        )}\n      />\n      <div className=\"flex min-w-0 items-center gap-2\">\n        {column.icon ? (\n          <span\n            aria-hidden=\"true\"\n            className=\"text-muted-foreground inline-flex shrink-0 [&>svg]:size-4\"\n          >\n            {column.icon}\n          </span>\n        ) : (\n          <span\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-2 shrink-0 rounded-full\",\n              kanbanToneDotClasses[tone],\n            )}\n          />\n        )}\n        <h3\n          id={headingId}\n          className=\"text-foreground min-w-0 truncate text-sm font-semibold tracking-tight\"\n        >\n          {column.title}\n        </h3>\n        <span className=\"bg-background text-muted-foreground ring-border shrink-0 rounded-full px-1.5 text-xs tabular-nums ring-1\">\n          <span aria-hidden=\"true\">{count}</span>\n          <span className=\"sr-only\">{labels.cardCount(count)}</span>\n        </span>\n        <span className=\"flex-1\" />\n        {collapsible ? (\n          <button\n            type=\"button\"\n            className={collapseButtonClasses}\n            aria-label={labels.collapseColumn(column.title)}\n            aria-expanded={true}\n            onClick={onToggle}\n          >\n            <ChevronsRightLeft aria-hidden=\"true\" />\n          </button>\n        ) : null}\n      </div>\n      {column.description ? (\n        <p className=\"text-muted-foreground -mt-1 truncate text-xs\">\n          {column.description}\n        </p>\n      ) : null}\n      <KanbanWipMeter labels={labels} state={wip} />\n      {refusal ? (\n        <p\n          aria-hidden=\"true\"\n          className=\"bg-destructive/10 text-destructive flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium\"\n          data-slot=\"kanban-refusal\"\n        >\n          <Ban className=\"size-3.5 shrink-0\" />\n          <span className=\"min-w-0 truncate\">{refusal}</span>\n        </p>\n      ) : null}\n    </div>\n  );\n}\n\nexport function KanbanLaneHeader({\n  collapsed,\n  count,\n  headingId,\n  labels,\n  lane,\n  onToggle,\n}: {\n  collapsed: boolean;\n  count: number;\n  headingId: string;\n  labels: KanbanBoardLabels;\n  lane: KanbanLane;\n  onToggle: () => void;\n}) {\n  return (\n    <div\n      className=\"sticky start-0 flex w-fit max-w-[min(100%,40rem)] items-center gap-2 py-1.5\"\n      data-slot=\"kanban-lane-header\"\n    >\n      <button\n        type=\"button\"\n        className=\"text-foreground hover:bg-muted focus-visible:ring-ring inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-semibold focus-visible:ring-2 focus-visible:outline-none\"\n        aria-expanded={!collapsed}\n        aria-label={\n          collapsed\n            ? labels.expandLane(lane.title)\n            : labels.collapseLane(lane.title)\n        }\n        onClick={onToggle}\n      >\n        <ChevronRight\n          aria-hidden=\"true\"\n          className={cn(\n            \"text-muted-foreground size-4 shrink-0 motion-safe:transition-transform motion-safe:duration-[var(--dt-motion-fast)] rtl:-scale-x-100\",\n            !collapsed && \"rotate-90 rtl:-rotate-90\",\n          )}\n        />\n        <span id={headingId} className=\"truncate\">\n          {lane.title}\n        </span>\n      </button>\n      <span className=\"bg-muted text-muted-foreground rounded-full px-1.5 text-xs tabular-nums\">\n        <span aria-hidden=\"true\">{count}</span>\n        <span className=\"sr-only\">{labels.cardCount(count)}</span>\n      </span>\n      {lane.description ? (\n        <span className=\"text-muted-foreground truncate text-xs\">\n          {lane.description}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n\nexport interface KanbanMoveMenuOption {\n  key: string;\n  label: string;\n  description?: string;\n  icon: ReactNode;\n  disabled?: boolean;\n  section: \"order\" | \"column\" | \"lane\";\n  onAction: () => void;\n}\n\nexport function KanbanMoveMenu({\n  focusable,\n  getOptions,\n  label,\n  labels,\n}: {\n  focusable: boolean;\n  /** Called when the menu opens so limits and vetoes reflect the latest board. */\n  getOptions: () => KanbanMoveMenuOption[];\n  label: string;\n  labels: KanbanBoardLabels;\n}) {\n  const [open, setOpen] = useState(false);\n  const options = open ? getOptions() : [];\n  const order = options.filter((option) => option.section === \"order\");\n  const columns = options.filter((option) => option.section === \"column\");\n  const lanes = options.filter((option) => option.section === \"lane\");\n  const renderOption = (option: KanbanMoveMenuOption) => (\n    <DropdownMenuItem\n      key={option.key}\n      textValue={option.label}\n      disabled={option.disabled}\n      onAction={option.onAction}\n    >\n      <DropdownMenuItemIcon aria-hidden=\"true\">\n        {option.icon}\n      </DropdownMenuItemIcon>\n      <DropdownMenuItemLabel>{option.label}</DropdownMenuItemLabel>\n      {option.description ? (\n        <DropdownMenuItemDescription>\n          {option.description}\n        </DropdownMenuItemDescription>\n      ) : null}\n    </DropdownMenuItem>\n  );\n\n  return (\n    <DropdownMenu open={open} onOpenChange={setOpen}>\n      <DropdownMenuTrigger\n        size=\"icon\"\n        variant=\"ghost\"\n        aria-label={label}\n        excludeFromTabOrder={!focusable}\n        className=\"text-muted-foreground hover:text-foreground data-[pressed]:text-foreground -me-1 -mt-0.5 size-7 shrink-0 opacity-70 group-focus-within/kanban-card:opacity-100 group-hover/kanban-card:opacity-100 pointer-coarse:opacity-100 [&_svg]:size-4\"\n        data-kanban-no-drag=\"\"\n      >\n        <EllipsisVertical aria-hidden=\"true\" />\n      </DropdownMenuTrigger>\n      <DropdownMenuContent placement=\"bottom end\" className=\"min-w-52\">\n        {order.length > 0 ? (\n          <DropdownMenuSection>{order.map(renderOption)}</DropdownMenuSection>\n        ) : null}\n        {columns.length > 0 ? (\n          <>\n            {order.length > 0 ? <DropdownMenuSeparator /> : null}\n            <DropdownMenuSection>\n              <DropdownMenuLabel>{labels.moveToColumn}</DropdownMenuLabel>\n              {columns.map(renderOption)}\n            </DropdownMenuSection>\n          </>\n        ) : null}\n        {lanes.length > 0 ? (\n          <>\n            <DropdownMenuSeparator />\n            <DropdownMenuSection>\n              <DropdownMenuLabel>{labels.moveToLane}</DropdownMenuLabel>\n              {lanes.map(renderOption)}\n            </DropdownMenuSection>\n          </>\n        ) : null}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nexport const kanbanMenuIcons = {\n  up: <ArrowUp />,\n  down: <ArrowDown />,\n  top: <ArrowUpToLine />,\n  bottom: <ArrowDownToLine />,\n  column: <Columns3 />,\n  lane: <Rows3 />,\n};\n"
    },
    {
      "path": "packages/components/src/components/kanban-board/kanban-board-card.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/kanban-board-card.tsx",
      "content": "import type {\n  KeyboardEvent,\n  PointerEvent as ReactPointerEvent,\n  ReactNode,\n} from \"react\";\nimport { motion } from \"motion/react\";\nimport { LoaderCircle, RotateCcw, TriangleAlert, X } from \"lucide-react\";\nimport { cn } from \"../../utils/cn\";\nimport {\n  KanbanMoveMenu,\n  type KanbanBoardLabels,\n  type KanbanMoveMenuOption,\n} from \"./kanban-board-parts\";\nimport type { KanbanDragHandleMode } from \"./kanban-board-types\";\nimport { SPRING, cardClasses } from \"./kanban-board-utils\";\n\nexport interface KanbanCardShellProps {\n  itemId: string;\n  /** The consumer's rendered card. */\n  children: ReactNode;\n  itemLabel: string;\n  labels: KanbanBoardLabels;\n  instructionsId: string;\n  /** Shared layout id; `undefined` disables layout animation. */\n  layoutId: string | undefined;\n  cardRef: (node: HTMLLIElement | null) => void;\n  /** The card holds the board's single roving tab stop. */\n  focusable: boolean;\n  disabled: boolean;\n  dragHandle: KanbanDragHandleMode;\n  isLifted: boolean;\n  isPlaceholder: boolean;\n  isPending: boolean;\n  error: string | null;\n  showMoveMenu: boolean;\n  getMenuOptions: () => KanbanMoveMenuOption[];\n  onFocus: () => void;\n  onKeyDown: (event: KeyboardEvent<HTMLLIElement>) => void;\n  onPointerDown: (event: ReactPointerEvent<HTMLLIElement>) => void;\n  onRetry: () => void;\n  onDismiss: () => void;\n}\n\n/** A movable card in a kanban list, with its pending, error and drop states. */\nexport function KanbanCardShell({\n  cardRef,\n  children,\n  disabled,\n  dragHandle,\n  error,\n  focusable,\n  getMenuOptions,\n  instructionsId,\n  isLifted,\n  isPending,\n  isPlaceholder,\n  itemId,\n  itemLabel,\n  labels,\n  layoutId,\n  onDismiss,\n  onFocus,\n  onKeyDown,\n  onPointerDown,\n  onRetry,\n  showMoveMenu,\n}: KanbanCardShellProps) {\n  return (\n    <motion.li\n      layoutId={layoutId}\n      layout={layoutId ? \"position\" : false}\n      transition={SPRING}\n      ref={cardRef}\n      tabIndex={focusable ? 0 : -1}\n      aria-roledescription={labels.cardRoleDescription}\n      aria-describedby={disabled ? undefined : instructionsId}\n      aria-busy={isPending || undefined}\n      className={cn(cardClasses, dragHandle === \"handle\" && \"cursor-default\")}\n      data-kanban-card={itemId}\n      data-slot=\"kanban-card\"\n      data-lifted={isLifted || undefined}\n      data-placeholder={isPlaceholder || undefined}\n      data-pending={isPending || undefined}\n      data-error={error !== null ? true : undefined}\n      data-disabled={disabled || undefined}\n      onFocus={(event) => {\n        if (event.target === event.currentTarget) {\n          onFocus();\n        }\n      }}\n      onKeyDown={onKeyDown}\n      onPointerDown={onPointerDown}\n    >\n      <div\n        className={cn(\n          \"flex min-w-0 items-start gap-1\",\n          isPlaceholder && \"invisible\",\n        )}\n      >\n        <div className=\"min-w-0 flex-1\">{children}</div>\n        {isPending ? (\n          <span className=\"text-muted-foreground mt-0.5 inline-flex shrink-0\">\n            <LoaderCircle\n              aria-hidden=\"true\"\n              className=\"size-3.5 motion-safe:animate-spin\"\n            />\n            <span className=\"sr-only\">{labels.pending}</span>\n          </span>\n        ) : null}\n        {showMoveMenu ? (\n          <KanbanMoveMenu\n            focusable={focusable}\n            label={labels.moveMenu(itemLabel)}\n            labels={labels}\n            getOptions={getMenuOptions}\n          />\n        ) : null}\n      </div>\n      {error !== null && !isPlaceholder ? (\n        <div\n          className=\"border-destructive/25 text-destructive mt-2 flex items-center gap-1.5 border-t pt-2 text-xs\"\n          data-slot=\"kanban-card-error\"\n        >\n          <TriangleAlert aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n          <span className=\"min-w-0 flex-1\">{error}</span>\n          <button\n            type=\"button\"\n            className=\"hover:bg-destructive/10 focus-visible:ring-ring inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-medium focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50\"\n            disabled={disabled}\n            tabIndex={focusable ? 0 : -1}\n            onClick={onRetry}\n          >\n            <RotateCcw aria-hidden=\"true\" className=\"size-3\" />\n            {labels.retry}\n          </button>\n          <button\n            type=\"button\"\n            aria-label={labels.dismiss}\n            className=\"hover:bg-destructive/10 focus-visible:ring-ring inline-flex size-5 items-center justify-center rounded focus-visible:ring-2 focus-visible:outline-none\"\n            tabIndex={focusable ? 0 : -1}\n            onClick={onDismiss}\n          >\n            <X aria-hidden=\"true\" className=\"size-3\" />\n          </button>\n        </div>\n      ) : null}\n      {isPlaceholder ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"border-primary/45 bg-primary/[0.06] absolute -inset-px rounded-[inherit] border-2 border-dashed\"\n          data-slot=\"kanban-drop-indicator\"\n        />\n      ) : null}\n    </motion.li>\n  );\n}\n"
    },
    {
      "path": "packages/components/src/components/kanban-board/kanban-board-menu.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/kanban-board-menu.ts",
      "content": "import {\n  getKanbanCell,\n  type KanbanColumn,\n  type KanbanItem,\n  type KanbanLane,\n  type KanbanLocation,\n} from \"./kanban-board-model\";\nimport {\n  kanbanMenuIcons,\n  type KanbanBoardLabels,\n  type KanbanMoveMenuOption,\n} from \"./kanban-board-parts\";\n\nexport interface KanbanMoveMenuContext<T extends KanbanItem> {\n  item: T;\n  location: KanbanLocation;\n  cellCount: number;\n  columns: readonly KanbanColumn[];\n  lanes: readonly KanbanLane[] | undefined;\n  items: readonly T[];\n  labels: KanbanBoardLabels;\n  getRefusal: (\n    itemId: string,\n    to: KanbanLocation,\n    from: KanbanLocation,\n  ) => string | null;\n  onMove: (to: KanbanLocation) => void;\n}\n\n/** Options for a card's Move menu: reorder, then other columns, then lanes. */\nexport function buildKanbanMoveMenuOptions<T extends KanbanItem>({\n  cellCount,\n  columns,\n  getRefusal,\n  item,\n  items,\n  labels,\n  lanes,\n  location,\n  onMove,\n}: KanbanMoveMenuContext<T>): KanbanMoveMenuOption[] {\n  const move = (to: KanbanLocation) => () => onMove(to);\n  const options: KanbanMoveMenuOption[] = [\n    {\n      key: \"up\",\n      label: labels.moveUp,\n      icon: kanbanMenuIcons.up,\n      section: \"order\",\n      disabled: location.index === 0,\n      onAction: move({ ...location, index: location.index - 1 }),\n    },\n    {\n      key: \"down\",\n      label: labels.moveDown,\n      icon: kanbanMenuIcons.down,\n      section: \"order\",\n      disabled: location.index >= cellCount - 1,\n      onAction: move({ ...location, index: location.index + 1 }),\n    },\n    {\n      key: \"top\",\n      label: labels.moveToTop,\n      icon: kanbanMenuIcons.top,\n      section: \"order\",\n      disabled: location.index === 0,\n      onAction: move({ ...location, index: 0 }),\n    },\n    {\n      key: \"bottom\",\n      label: labels.moveToBottom,\n      icon: kanbanMenuIcons.bottom,\n      section: \"order\",\n      disabled: location.index >= cellCount - 1,\n      onAction: move({ ...location, index: cellCount - 1 }),\n    },\n  ];\n\n  for (const column of columns) {\n    if (column.id === location.columnId) {\n      continue;\n    }\n\n    const to: KanbanLocation = {\n      columnId: column.id,\n      laneId: location.laneId,\n      index: getKanbanCell(items, column.id, location.laneId, lanes).length,\n    };\n    const reason = getRefusal(item.id, to, location);\n\n    options.push({\n      key: `column-${column.id}`,\n      label: column.title,\n      description: reason ?? undefined,\n      icon: kanbanMenuIcons.column,\n      section: \"column\",\n      disabled: !!reason,\n      onAction: move(to),\n    });\n  }\n\n  for (const lane of lanes ?? []) {\n    if (lane.id === location.laneId) {\n      continue;\n    }\n\n    const to: KanbanLocation = {\n      columnId: location.columnId,\n      laneId: lane.id,\n      index: getKanbanCell(items, location.columnId, lane.id, lanes).length,\n    };\n    const reason = getRefusal(item.id, to, location);\n\n    options.push({\n      key: `lane-${lane.id}`,\n      label: lane.title,\n      description: reason ?? undefined,\n      icon: kanbanMenuIcons.lane,\n      section: \"lane\",\n      disabled: !!reason,\n      onAction: move(to),\n    });\n  }\n\n  return options;\n}\n"
    },
    {
      "path": "packages/components/src/components/kanban-board/use-kanban-board-state.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/use-kanban-board-state.ts",
      "content": "import {\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n  type ForwardedRef,\n} from \"react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useProviderPortalRoot } from \"../../utils/provider-portal\";\nimport {\n  applyKanbanMove,\n  getKanbanCell,\n  getKanbanDropRefusal,\n  getKanbanLocation,\n  isSameKanbanLocation,\n  resolveKanbanLaneId,\n  resolveKanbanWipLimit,\n  countKanbanColumn,\n  type KanbanItem,\n  type KanbanLocation,\n  type KanbanMove,\n} from \"./kanban-board-model\";\nimport {\n  defaultKanbanBoardLabels,\n  type KanbanAnnouncementContext,\n} from \"./kanban-board-parts\";\nimport type {\n  CellTarget,\n  DragState,\n  KanbanBoardProps,\n  PendingMove,\n} from \"./kanban-board-types\";\nimport {\n  cellKey,\n  isPromiseLike,\n  useIsomorphicLayoutEffect,\n  useListState,\n} from \"./kanban-board-utils\";\n\ntype KanbanBoardStateOptions<T extends KanbanItem> = Pick<\n  KanbanBoardProps<T>,\n  | \"canMove\"\n  | \"collapsedColumns\"\n  | \"collapsedLanes\"\n  | \"columns\"\n  | \"defaultCollapsedColumns\"\n  | \"defaultCollapsedLanes\"\n  | \"defaultItems\"\n  | \"disabled\"\n  | \"getErrorMessage\"\n  | \"items\"\n  | \"labels\"\n  | \"lanes\"\n  | \"onCollapsedColumnsChange\"\n  | \"onCollapsedLanesChange\"\n  | \"onItemsChange\"\n  | \"onMove\"\n> &\n  Required<Pick<KanbanBoardProps<T>, \"getItemLabel\">>;\n\n/**\n * Board state shared by the keyboard and pointer engines: committed and\n * optimistic items, the active drag, announcements, and focus requests.\n */\nexport function useKanbanBoardState<T extends KanbanItem>(\n  {\n    canMove,\n    collapsedColumns: collapsedColumnsProp,\n    collapsedLanes: collapsedLanesProp,\n    columns,\n    defaultCollapsedColumns,\n    defaultCollapsedLanes,\n    defaultItems,\n    disabled = false,\n    getErrorMessage,\n    getItemLabel,\n    items: itemsProp,\n    labels: labelsProp,\n    lanes: lanesProp,\n    onCollapsedColumnsChange,\n    onCollapsedLanesChange,\n    onItemsChange,\n    onMove,\n  }: KanbanBoardStateOptions<T>,\n  ref: ForwardedRef<HTMLDivElement>,\n) {\n  const labels = useMemo(\n    () => ({ ...defaultKanbanBoardLabels, ...labelsProp }),\n    [labelsProp],\n  );\n  const lanes = lanesProp && lanesProp.length > 0 ? lanesProp : undefined;\n  const prefersReducedMotion = useReducedMotion() === true;\n  const { portalContainer, rootRef } = useProviderPortalRoot<HTMLDivElement>({\n    forwardedRef: ref,\n    portalSlot: \"kanban-board\",\n  });\n  const rootElementRef = useRef<HTMLDivElement | null>(null);\n  const setRootRef = useCallback(\n    (node: HTMLDivElement | null) => {\n      rootElementRef.current = node;\n      rootRef(node);\n    },\n    [rootRef],\n  );\n\n  const [internalItems, setInternalItems] = useState<T[]>(\n    () => defaultItems ?? [],\n  );\n  const baseItems = itemsProp ?? internalItems;\n  const [pendingMoves, setPendingMoves] = useState<PendingMove[]>([]);\n  const [errors, setErrors] = useState<\n    Record<string, { key: number; move: KanbanMove; message: string }>\n  >({});\n  const [drag, setDragState] = useState<DragState | null>(null);\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [collapsedColumns, toggleColumn] = useListState(\n    collapsedColumnsProp,\n    defaultCollapsedColumns,\n    onCollapsedColumnsChange,\n  );\n  const [collapsedLanes, toggleLane] = useListState(\n    collapsedLanesProp,\n    defaultCollapsedLanes,\n    onCollapsedLanesChange,\n  );\n\n  const displayedItems = useMemo(\n    () =>\n      pendingMoves.reduce(\n        (current, pending) => applyKanbanMove(current, pending.move, lanes),\n        baseItems,\n      ),\n    [baseItems, lanes, pendingMoves],\n  );\n  const renderItems = useMemo(\n    () =>\n      drag\n        ? applyKanbanMove(\n            displayedItems,\n            { itemId: drag.itemId, to: drag.preview },\n            lanes,\n          )\n        : displayedItems,\n    [displayedItems, drag, lanes],\n  );\n  const pendingIds = useMemo(\n    () =>\n      new Set(\n        pendingMoves\n          .filter((pending) => pending.status === \"saving\")\n          .map((pending) => pending.move.itemId),\n      ),\n    [pendingMoves],\n  );\n  const columnById = useMemo(\n    () => new Map(columns.map((column) => [column.id, column])),\n    [columns],\n  );\n  const laneById = useMemo(\n    () => new Map((lanes ?? []).map((lane) => [lane.id, lane])),\n    [lanes],\n  );\n  const cells = useMemo(() => {\n    const map = new Map<string, T[]>();\n\n    for (const item of renderItems) {\n      const key = cellKey(item.columnId, resolveKanbanLaneId(item, lanes));\n      const list = map.get(key);\n\n      if (list) {\n        list.push(item);\n      } else {\n        map.set(key, [item]);\n      }\n    }\n\n    return map;\n  }, [lanes, renderItems]);\n\n  const scrollerRef = useRef<HTMLDivElement | null>(null);\n  const cardRefs = useRef(new Map<string, HTMLLIElement>());\n  const cellTargets = useRef(new Map<string, CellTarget>());\n  const cellLists = useRef(new Map<string, HTMLElement>());\n  const dragRef = useRef<DragState | null>(null);\n  const focusRequestRef = useRef<string | null>(null);\n  const pendingSeq = useRef(0);\n  // Every move that has not reached the base items, in submission order. Moves\n  // are positional, so they are applied to the base strictly in this order even\n  // when their saves settle out of order.\n  const moveQueue = useRef<PendingMove[]>([]);\n  const latest = useRef({\n    baseItems,\n    canMove,\n    collapsedColumns,\n    collapsedLanes,\n    columnById,\n    disabled,\n    displayedItems,\n    getErrorMessage,\n    getItemLabel,\n    labels,\n    laneById,\n    lanes,\n    onItemsChange,\n    onMove,\n    prefersReducedMotion,\n    isControlled: itemsProp !== undefined,\n  });\n\n  useIsomorphicLayoutEffect(() => {\n    latest.current = {\n      baseItems,\n      canMove,\n      collapsedColumns,\n      collapsedLanes,\n      columnById,\n      disabled,\n      displayedItems,\n      getErrorMessage,\n      getItemLabel,\n      labels,\n      laneById,\n      lanes,\n      onItemsChange,\n      onMove,\n      prefersReducedMotion,\n      isControlled: itemsProp !== undefined,\n    };\n  });\n\n  const setDrag = useCallback(\n    (\n      next:\n        DragState | null | ((current: DragState | null) => DragState | null),\n    ) => {\n      const resolved =\n        typeof next === \"function\" ? next(dragRef.current) : next;\n\n      dragRef.current = resolved;\n      setDragState(resolved);\n    },\n    [],\n  );\n\n  const announce = useCallback((message: string) => {\n    // A trailing no-break space forces repeats of the same text to re-announce.\n    setAnnouncement((current) =>\n      current === message ? `${message}\\u00a0` : message,\n    );\n  }, []);\n\n  const describe = useCallback(\n    (\n      itemId: string,\n      location: KanbanLocation,\n      items: readonly T[],\n      reason?: string,\n    ): KanbanAnnouncementContext => {\n      const {\n        columnById: columnsById,\n        getItemLabel: itemLabel,\n        laneById: lanesById,\n      } = latest.current;\n      const item = items.find((candidate) => candidate.id === itemId);\n      const total = getKanbanCell(\n        items,\n        location.columnId,\n        location.laneId,\n        latest.current.lanes,\n      ).filter((candidate) => candidate.id !== itemId).length;\n\n      return {\n        item: item ? itemLabel(item) : itemId,\n        column: columnsById.get(location.columnId)?.title ?? location.columnId,\n        lane:\n          location.laneId !== undefined\n            ? lanesById.get(location.laneId)?.title\n            : undefined,\n        position: location.index + 1,\n        total: total + 1,\n        reason,\n      };\n    },\n    [],\n  );\n\n  const getRefusal = useCallback(\n    (itemId: string, to: KanbanLocation, from: KanbanLocation) => {\n      const {\n        canMove: veto,\n        columnById: columnsById,\n        displayedItems: items,\n        labels: text,\n      } = latest.current;\n      const column = columnsById.get(to.columnId);\n      const refusal = getKanbanDropRefusal(items, itemId, column);\n\n      if (refusal === \"limit\" && column) {\n        const limit = resolveKanbanWipLimit(column.wipLimit);\n\n        return text.refusedLimit(\n          column.title,\n          countKanbanColumn(items, column.id),\n          limit?.max ?? 0,\n        );\n      }\n\n      if (refusal === \"disabled\") {\n        return text.refusedDisabled(column?.title ?? to.columnId);\n      }\n\n      const item = items.find((candidate) => candidate.id === itemId);\n\n      if (veto && item) {\n        const verdict = veto({ itemId, from, to }, item);\n\n        if (verdict === false) {\n          return text.refusedDefault;\n        }\n\n        if (typeof verdict === \"string\") {\n          return verdict;\n        }\n      }\n\n      return null;\n    },\n    [],\n  );\n\n  const commitToBase = useCallback((move: KanbanMove) => {\n    const {\n      baseItems: base,\n      isControlled,\n      lanes: laneList,\n      onItemsChange: notify,\n    } = latest.current;\n    const next = applyKanbanMove(base, move, laneList);\n\n    if (!isControlled) {\n      setInternalItems(next);\n    }\n\n    latest.current.baseItems = next;\n    notify?.(next, move);\n  }, []);\n\n  const clearError = useCallback((itemId: string, beforeKey?: number) => {\n    setErrors((current) => {\n      const error = current[itemId];\n\n      if (!error || (beforeKey !== undefined && error.key > beforeKey)) {\n        return current;\n      }\n\n      const rest = { ...current };\n      delete rest[itemId];\n\n      return rest;\n    });\n  }, []);\n\n  // Commits confirmed moves from the head of the queue until it reaches one\n  // that is still saving.\n  const flushQueue = useCallback(() => {\n    const queue = moveQueue.current;\n\n    while (queue[0]?.status === \"saved\") {\n      const { key, move } = queue.shift() as PendingMove;\n\n      commitToBase(move);\n      // A newer confirmed move makes an older failure for the card obsolete.\n      clearError(move.itemId, key);\n    }\n\n    setPendingMoves([...queue]);\n  }, [clearError, commitToBase]);\n\n  const commitMove = useCallback(\n    (move: KanbanMove, options: { skipValidation?: boolean } = {}) => {\n      const { displayedItems: items, lanes: laneList } = latest.current;\n      const item = items.find((candidate) => candidate.id === move.itemId);\n\n      // Every move path (drag, keyboard, menu, retry) funnels through here,\n      // so a disabled board refuses them in one place.\n      if (!item || latest.current.disabled) {\n        return false;\n      }\n\n      if (isSameKanbanLocation(move.from, move.to)) {\n        return false;\n      }\n\n      if (!options.skipValidation) {\n        const refusal = getRefusal(move.itemId, move.to, move.from);\n\n        if (refusal) {\n          announce(\n            latest.current.labels.refused(\n              describe(move.itemId, move.from, items, refusal),\n            ),\n          );\n\n          return false;\n        }\n      }\n\n      const next = applyKanbanMove(items, move, laneList);\n\n      clearError(move.itemId);\n\n      const result = latest.current.onMove?.(move, {\n        item,\n        items: next,\n        previousItems: items,\n      });\n      const key = ++pendingSeq.current;\n      const saving = isPromiseLike(result);\n\n      latest.current.displayedItems = next;\n      moveQueue.current.push({\n        key,\n        move,\n        status: saving ? \"saving\" : \"saved\",\n      });\n      // A synchronous move commits at once unless an earlier save is still\n      // pending, in which case it waits its turn so the earlier save cannot\n      // land on top of it.\n      flushQueue();\n\n      if (!saving) {\n        return true;\n      }\n\n      Promise.resolve(result).then(\n        () => {\n          const entry = moveQueue.current.find(\n            (pending) => pending.key === key,\n          );\n\n          if (entry) {\n            entry.status = \"saved\";\n          }\n\n          flushQueue();\n        },\n        (error: unknown) => {\n          const { getErrorMessage: toMessage, labels: text } = latest.current;\n          const queue = moveQueue.current;\n          const card = cardRefs.current.get(move.itemId);\n\n          // Rolling back remounts the card in its original list, so keep\n          // keyboard focus with it rather than letting it fall to the body.\n          if (card?.contains(card.ownerDocument.activeElement)) {\n            focusRequestRef.current = move.itemId;\n            setActiveId(move.itemId);\n          }\n\n          moveQueue.current = queue.filter((pending) => pending.key !== key);\n          flushQueue();\n\n          // A later move for the same card replaces this one, so its failure\n          // is no longer actionable.\n          if (\n            queue.some(\n              (pending) =>\n                pending.key > key && pending.move.itemId === move.itemId,\n            )\n          ) {\n            return;\n          }\n\n          setErrors((current) => ({\n            ...current,\n            [move.itemId]: {\n              key,\n              move,\n              message: toMessage?.(error, item) ?? text.error,\n            },\n          }));\n\n          const base = latest.current.baseItems;\n          const home = getKanbanLocation(\n            base,\n            move.itemId,\n            latest.current.lanes,\n          );\n\n          if (home) {\n            announce(text.failed(describe(move.itemId, home, base)));\n          }\n        },\n      );\n\n      return true;\n    },\n    [announce, clearError, describe, flushQueue, getRefusal],\n  );\n\n  const retry = useCallback(\n    (itemId: string) => {\n      const failure = errors[itemId];\n      const from = getKanbanLocation(\n        latest.current.displayedItems,\n        itemId,\n        latest.current.lanes,\n      );\n\n      if (!failure || !from) {\n        return;\n      }\n\n      commitMove({ itemId, from, to: failure.move.to });\n    },\n    [commitMove, errors],\n  );\n\n  const dismissError = useCallback((itemId: string) => {\n    setErrors((current) => {\n      const rest = { ...current };\n      delete rest[itemId];\n\n      return rest;\n    });\n  }, []);\n\n  // Focus follows the card when it re-mounts in another list.\n  useIsomorphicLayoutEffect(() => {\n    const id = focusRequestRef.current;\n\n    if (!id) {\n      return;\n    }\n\n    const element = cardRefs.current.get(id);\n\n    if (element && element.ownerDocument.activeElement !== element) {\n      element.focus({ preventScroll: true });\n      element.scrollIntoView?.({ block: \"nearest\", inline: \"nearest\" });\n    }\n\n    if (element) {\n      focusRequestRef.current = null;\n    }\n  });\n\n  const focusCard = useCallback((id: string) => {\n    setActiveId(id);\n    focusRequestRef.current = id;\n    const element = cardRefs.current.get(id);\n\n    if (element) {\n      element.focus({ preventScroll: true });\n      element.scrollIntoView?.({ block: \"nearest\", inline: \"nearest\" });\n      focusRequestRef.current = null;\n    }\n  }, []);\n\n  const isRtl = useCallback(() => {\n    const root = rootElementRef.current;\n\n    return !!root && getComputedStyle(root).direction === \"rtl\";\n  }, []);\n\n  const registerCell = useCallback(\n    (\n      columnId: string,\n      laneId: string | undefined,\n      part: \"zone\" | \"list\",\n      element: HTMLElement | null,\n    ) => {\n      const key = cellKey(columnId, laneId);\n      if (!element) {\n        (part === \"zone\" ? cellTargets.current : cellLists.current).delete(key);\n\n        return;\n      }\n\n      if (part === \"zone\") {\n        cellTargets.current.set(key, { key, columnId, laneId, zone: element });\n      } else {\n        cellLists.current.set(key, element);\n      }\n    },\n    [],\n  );\n\n  return {\n    activeId,\n    announce,\n    announcement,\n    cardRefs,\n    cellLists,\n    cells,\n    cellTargets,\n    collapsedColumns,\n    collapsedLanes,\n    columnById,\n    commitMove,\n    describe,\n    dismissError,\n    displayedItems,\n    drag,\n    dragRef,\n    errors,\n    focusCard,\n    focusRequestRef,\n    getRefusal,\n    isRtl,\n    labels,\n    laneById,\n    lanes,\n    latest,\n    pendingIds,\n    portalContainer,\n    prefersReducedMotion,\n    registerCell,\n    renderItems,\n    retry,\n    scrollerRef,\n    setActiveId,\n    setDrag,\n    setRootRef,\n    toggleColumn,\n    toggleLane,\n  };\n}\n\nexport type KanbanBoardState<T extends KanbanItem> = ReturnType<\n  typeof useKanbanBoardState<T>\n>;\n"
    },
    {
      "path": "packages/components/src/components/kanban-board/use-kanban-keyboard.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/use-kanban-keyboard.ts",
      "content": "import {\n  useCallback,\n  useEffect,\n  type FocusEvent,\n  type KeyboardEvent,\n} from \"react\";\nimport {\n  getKanbanCell,\n  getKanbanLocation,\n  isSameKanbanLocation,\n  resolveKanbanLaneId,\n  type KanbanColumn,\n  type KanbanItem,\n  type KanbanLocation,\n} from \"./kanban-board-model\";\nimport type { KanbanBoardState } from \"./use-kanban-board-state\";\n\n/**\n * Keyboard pick-up, move and drop, plus roving arrow-key navigation between\n * cards when nothing is lifted.\n */\nexport function useKanbanKeyboard<T extends KanbanItem>(\n  board: KanbanBoardState<T>,\n  {\n    columns,\n    disabled,\n  }: { columns: readonly KanbanColumn[]; disabled: boolean },\n) {\n  const {\n    activeId,\n    announce,\n    collapsedColumns,\n    collapsedLanes,\n    commitMove,\n    describe,\n    dragRef,\n    focusCard,\n    focusRequestRef,\n    getRefusal,\n    isRtl,\n    lanes,\n    latest,\n    renderItems,\n    setDrag,\n  } = board;\n\n  const visibleLaneIds = useCallback((): (string | undefined)[] => {\n    const { collapsedLanes: collapsed, lanes: laneList } = latest.current;\n\n    return laneList\n      ? laneList.filter((lane) => !collapsed.includes(lane.id)).map((l) => l.id)\n      : [undefined];\n  }, []);\n\n  const stepKeyboardDrag = useCallback(\n    (direction: \"up\" | \"down\" | \"left\" | \"right\" | \"home\" | \"end\") => {\n      const current = dragRef.current;\n\n      if (!current) {\n        return;\n      }\n\n      const {\n        displayedItems: items,\n        lanes: laneList,\n        collapsedColumns: collapsed,\n      } = latest.current;\n      const countIn = (columnId: string, laneId: string | undefined) =>\n        getKanbanCell(items, columnId, laneId, laneList).filter(\n          (item) => item.id !== current.itemId,\n        ).length;\n      const laneIds = visibleLaneIds();\n      const { preview } = current;\n      let next: KanbanLocation | null = null;\n      let refusal: string | null = null;\n\n      if (direction === \"home\") {\n        next = { ...preview, index: 0 };\n      } else if (direction === \"end\") {\n        next = { ...preview, index: countIn(preview.columnId, preview.laneId) };\n      } else if (direction === \"up\" || direction === \"down\") {\n        const count = countIn(preview.columnId, preview.laneId);\n        const laneIndex = laneIds.indexOf(preview.laneId);\n\n        if (direction === \"up\" && preview.index > 0) {\n          next = { ...preview, index: preview.index - 1 };\n        } else if (direction === \"down\" && preview.index < count) {\n          next = { ...preview, index: preview.index + 1 };\n        } else {\n          const neighbour = laneIds[laneIndex + (direction === \"up\" ? -1 : 1)];\n\n          if (laneIndex !== -1 && neighbour !== undefined) {\n            next = {\n              columnId: preview.columnId,\n              laneId: neighbour,\n              index:\n                direction === \"up\" ? countIn(preview.columnId, neighbour) : 0,\n            };\n          }\n        }\n      } else {\n        const forward = (direction === \"right\") !== isRtl();\n        const order = columns.map((column) => column.id);\n        let index = order.indexOf(preview.columnId);\n\n        while (!next) {\n          index += forward ? 1 : -1;\n          const columnId = order[index];\n\n          if (columnId === undefined) {\n            break;\n          }\n\n          if (collapsed.includes(columnId)) {\n            continue;\n          }\n\n          const candidate: KanbanLocation = {\n            columnId,\n            laneId: preview.laneId,\n            index: Math.min(preview.index, countIn(columnId, preview.laneId)),\n          };\n          const reason = getRefusal(current.itemId, candidate, current.from);\n\n          if (reason) {\n            refusal ??= reason;\n            continue;\n          }\n\n          next = candidate;\n        }\n      }\n\n      if (!next) {\n        if (refusal) {\n          announce(\n            latest.current.labels.refused(\n              describe(current.itemId, preview, items, refusal),\n            ),\n          );\n        }\n\n        return;\n      }\n\n      if (isSameKanbanLocation(next, preview)) {\n        return;\n      }\n\n      setDrag({ ...current, preview: next, blocked: null });\n      focusRequestRef.current = current.itemId;\n      announce(\n        latest.current.labels.moved(describe(current.itemId, next, items)),\n      );\n    },\n    [announce, columns, describe, getRefusal, isRtl, setDrag, visibleLaneIds],\n  );\n\n  const startKeyboardDrag = useCallback(\n    (itemId: string) => {\n      const { displayedItems: items, lanes: laneList } = latest.current;\n      const from = getKanbanLocation(items, itemId, laneList);\n\n      if (!from) {\n        return;\n      }\n\n      setDrag({\n        itemId,\n        from,\n        preview: from,\n        mode: \"keyboard\",\n        phase: \"dragging\",\n        blocked: null,\n      });\n      announce(latest.current.labels.pickedUp(describe(itemId, from, items)));\n    },\n    [announce, describe, setDrag],\n  );\n\n  const endKeyboardDrag = useCallback(\n    (commit: boolean) => {\n      const current = dragRef.current;\n\n      if (!current || current.mode !== \"keyboard\") {\n        return;\n      }\n\n      const items = latest.current.displayedItems;\n\n      setDrag(null);\n      focusRequestRef.current = current.itemId;\n\n      if (!commit || isSameKanbanLocation(current.from, current.preview)) {\n        announce(\n          latest.current.labels.cancelled(\n            describe(current.itemId, current.from, items),\n          ),\n        );\n\n        return;\n      }\n\n      const move = {\n        itemId: current.itemId,\n        from: current.from,\n        to: current.preview,\n      };\n\n      if (commitMove(move)) {\n        announce(\n          latest.current.labels.dropped(\n            describe(current.itemId, current.preview, items),\n          ),\n        );\n      }\n    },\n    [announce, commitMove, describe, setDrag],\n  );\n\n  // Disabling the board drops a lifted card back where it started.\n  useEffect(() => {\n    if (disabled) {\n      endKeyboardDrag(false);\n    }\n  }, [disabled, endKeyboardDrag]);\n\n  const navigate = useCallback(\n    (itemId: string, key: string) => {\n      const { collapsedColumns: collapsed, lanes: laneList } = latest.current;\n      const items = renderItems;\n      const laneIds = visibleLaneIds();\n      const columnCards = (columnId: string) =>\n        collapsed.includes(columnId)\n          ? []\n          : laneIds.flatMap((laneId) =>\n              getKanbanCell(items, columnId, laneId, laneList),\n            );\n      const item = items.find((candidate) => candidate.id === itemId);\n\n      if (!item) {\n        return;\n      }\n\n      const own = columnCards(item.columnId);\n      const position = own.findIndex((candidate) => candidate.id === itemId);\n      let target: T | undefined;\n\n      if (key === \"ArrowUp\") {\n        target = own[position - 1];\n      } else if (key === \"ArrowDown\") {\n        target = own[position + 1];\n      } else if (key === \"Home\") {\n        target = own[0];\n      } else if (key === \"End\") {\n        target = own[own.length - 1];\n      } else {\n        const forward = (key === \"ArrowRight\") !== isRtl();\n        const order = columns.map((column) => column.id);\n        let index = order.indexOf(item.columnId);\n\n        while (!target) {\n          index += forward ? 1 : -1;\n          const columnId = order[index];\n\n          if (columnId === undefined) {\n            break;\n          }\n\n          const cards = columnCards(columnId);\n\n          if (cards.length === 0) {\n            continue;\n          }\n\n          const laneId = resolveKanbanLaneId(item, laneList);\n          const sameLane = cards.filter(\n            (card) => resolveKanbanLaneId(card, laneList) === laneId,\n          );\n          const cellPosition = getKanbanCell(\n            items,\n            item.columnId,\n            laneId,\n            laneList,\n          ).findIndex((candidate) => candidate.id === itemId);\n\n          target =\n            sameLane.length > 0\n              ? sameLane[Math.min(cellPosition, sameLane.length - 1)]\n              : cards[Math.min(position, cards.length - 1)];\n        }\n      }\n\n      if (target) {\n        focusCard(target.id);\n      }\n    },\n    [columns, focusCard, isRtl, renderItems, visibleLaneIds],\n  );\n\n  const handleCardKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLLIElement>, itemId: string) => {\n      const current = dragRef.current;\n\n      if (current?.mode === \"keyboard\" && current.itemId === itemId) {\n        const map: Record<string, Parameters<typeof stepKeyboardDrag>[0]> = {\n          ArrowUp: \"up\",\n          ArrowDown: \"down\",\n          ArrowLeft: \"left\",\n          ArrowRight: \"right\",\n          Home: \"home\",\n          End: \"end\",\n        };\n\n        if (map[event.key]) {\n          event.preventDefault();\n          stepKeyboardDrag(\n            map[event.key] as Parameters<typeof stepKeyboardDrag>[0],\n          );\n        } else if (event.key === \" \" || event.key === \"Enter\") {\n          event.preventDefault();\n          endKeyboardDrag(true);\n        } else if (event.key === \"Escape\") {\n          event.preventDefault();\n          event.stopPropagation();\n          endKeyboardDrag(false);\n        } else if (event.key === \"Tab\") {\n          endKeyboardDrag(false);\n        }\n\n        return;\n      }\n\n      if (event.target !== event.currentTarget || current) {\n        return;\n      }\n\n      if ((event.key === \" \" || event.key === \"Enter\") && !disabled) {\n        event.preventDefault();\n        startKeyboardDrag(itemId);\n      } else if (\n        [\n          \"ArrowUp\",\n          \"ArrowDown\",\n          \"ArrowLeft\",\n          \"ArrowRight\",\n          \"Home\",\n          \"End\",\n        ].includes(event.key)\n      ) {\n        event.preventDefault();\n        navigate(itemId, event.key);\n      }\n    },\n    [disabled, endKeyboardDrag, navigate, startKeyboardDrag, stepKeyboardDrag],\n  );\n\n  const handleRootBlur = useCallback(\n    (event: FocusEvent<HTMLDivElement>) => {\n      const next = event.relatedTarget;\n\n      if (\n        dragRef.current?.mode === \"keyboard\" &&\n        next instanceof Node &&\n        !event.currentTarget.contains(next)\n      ) {\n        endKeyboardDrag(false);\n      }\n    },\n    [endKeyboardDrag],\n  );\n\n  // The roving tab stop must land on a rendered card, never one hidden inside\n  // a collapsed column or lane.\n  const isCardVisible = (item: T) =>\n    !collapsedColumns.includes(item.columnId) &&\n    !(\n      lanes &&\n      lanes.length > 0 &&\n      collapsedLanes.includes(resolveKanbanLaneId(item, lanes) ?? \"\")\n    );\n  const visibleItems = renderItems.filter(isCardVisible);\n  const rovingId =\n    activeId && visibleItems.some((item) => item.id === activeId)\n      ? activeId\n      : (visibleItems[0]?.id ?? null);\n\n  return { handleCardKeyDown, handleRootBlur, rovingId };\n}\n"
    },
    {
      "path": "packages/components/src/components/kanban-board/use-kanban-pointer.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/use-kanban-pointer.ts",
      "content": "import {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\nimport { animate, useMotionValue, useSpring } from \"motion/react\";\nimport {\n  getKanbanCell,\n  getKanbanLocation,\n  isSameKanbanLocation,\n  type KanbanItem,\n} from \"./kanban-board-model\";\nimport type {\n  KanbanDragHandleMode,\n  PointerSession,\n} from \"./kanban-board-types\";\nimport {\n  MOUSE_ACTIVATION_DISTANCE,\n  SPRING,\n  TOUCH_ACTIVATION_DELAY,\n  TOUCH_TOLERANCE,\n  autoScrollKanban,\n  hitTestKanbanCells,\n  isInteractiveTarget,\n} from \"./kanban-board-utils\";\nimport type { KanbanBoardState } from \"./use-kanban-board-state\";\n\n/**\n * Pointer Events drag engine: activation thresholds, the floating overlay with\n * velocity tilt, edge auto-scroll, and the spring settle into the drop slot.\n */\nexport function useKanbanPointer<T extends KanbanItem>(\n  board: KanbanBoardState<T>,\n  {\n    disabled,\n    dragHandle,\n    dragTilt,\n  }: { disabled: boolean; dragHandle: KanbanDragHandleMode; dragTilt: number },\n) {\n  const {\n    announce,\n    cardRefs,\n    cellLists,\n    cellTargets,\n    commitMove,\n    describe,\n    dragRef,\n    getRefusal,\n    latest,\n    scrollerRef,\n    setActiveId,\n    setDrag,\n  } = board;\n  const [overlaySize, setOverlaySize] = useState({ width: 0, height: 0 });\n  const sessionRef = useRef<PointerSession | null>(null);\n  const cleanupRef = useRef<(() => void) | null>(null);\n\n  const overlayX = useMotionValue(0);\n  const overlayY = useMotionValue(0);\n  const tiltTarget = useMotionValue(0);\n  const overlayRotate = useSpring(tiltTarget, {\n    stiffness: 380,\n    damping: 26,\n  });\n  const overlayScale = useMotionValue(1);\n\n  const hitTest = useCallback(\n    (x: number, y: number, itemId: string) =>\n      hitTestKanbanCells(\n        cellTargets.current.values(),\n        cellLists.current,\n        x,\n        y,\n        itemId,\n        (columnId, laneId) =>\n          getKanbanCell(\n            latest.current.displayedItems,\n            columnId,\n            laneId,\n            latest.current.lanes,\n          ).filter((item) => item.id !== itemId).length,\n      ),\n    [cellLists, cellTargets, latest],\n  );\n\n  const updatePointerPreview = useCallback(\n    (x: number, y: number) => {\n      const current = dragRef.current;\n\n      if (\n        !current ||\n        current.mode !== \"pointer\" ||\n        current.phase !== \"dragging\"\n      ) {\n        return;\n      }\n\n      const target = hitTest(x, y, current.itemId);\n\n      if (!target) {\n        if (current.blocked) {\n          setDrag({ ...current, blocked: null });\n        }\n\n        return;\n      }\n\n      const reason = getRefusal(current.itemId, target, current.from);\n\n      if (reason) {\n        if (current.blocked?.columnId !== target.columnId) {\n          setDrag({\n            ...current,\n            blocked: { columnId: target.columnId, reason },\n          });\n          announce(\n            latest.current.labels.refused(\n              describe(\n                current.itemId,\n                current.preview,\n                latest.current.displayedItems,\n                reason,\n              ),\n            ),\n          );\n        }\n\n        return;\n      }\n\n      if (isSameKanbanLocation(target, current.preview) && !current.blocked) {\n        return;\n      }\n\n      const changedCell =\n        target.columnId !== current.preview.columnId ||\n        target.laneId !== current.preview.laneId;\n\n      setDrag({ ...current, preview: target, blocked: null });\n\n      if (changedCell) {\n        announce(\n          latest.current.labels.moved(\n            describe(current.itemId, target, latest.current.displayedItems),\n          ),\n        );\n      }\n    },\n    [announce, describe, getRefusal, hitTest, setDrag],\n  );\n\n  const autoScroll = useCallback(\n    (x: number, y: number) =>\n      autoScrollKanban(scrollerRef.current, cellLists.current.values(), x, y),\n    [cellLists, scrollerRef],\n  );\n\n  const teardownSession = useCallback(() => {\n    const session = sessionRef.current;\n\n    if (session) {\n      clearTimeout(session.timer);\n      clearTimeout(session.idle);\n\n      if (session.frame !== undefined) {\n        cancelAnimationFrame(session.frame);\n      }\n    }\n\n    sessionRef.current = null;\n    cleanupRef.current?.();\n    cleanupRef.current = null;\n  }, []);\n\n  const settleOverlay = useCallback(\n    (itemId: string, after: () => void) => {\n      const finish = () => {\n        after();\n        tiltTarget.set(0);\n        overlayRotate.jump(0);\n        overlayScale.set(1);\n      };\n\n      if (latest.current.prefersReducedMotion) {\n        finish();\n\n        return;\n      }\n\n      // Wait for the placeholder to reach its final slot, then fly into it.\n      requestAnimationFrame(() => {\n        const target = cardRefs.current.get(itemId);\n\n        if (!target) {\n          finish();\n\n          return;\n        }\n\n        const rect = target.getBoundingClientRect();\n\n        tiltTarget.set(0);\n        Promise.all([\n          animate(overlayX, rect.left, SPRING),\n          animate(overlayY, rect.top, SPRING),\n          animate(overlayScale, 1, SPRING),\n        ]).then(finish, finish);\n      });\n    },\n    [overlayRotate, overlayScale, overlayX, overlayY, tiltTarget],\n  );\n\n  const finishPointerDrag = useCallback(\n    (commit: boolean) => {\n      const current = dragRef.current;\n\n      teardownSession();\n\n      if (\n        !current ||\n        current.mode !== \"pointer\" ||\n        current.phase !== \"dragging\"\n      ) {\n        return;\n      }\n\n      const items = latest.current.displayedItems;\n      const shouldCommit =\n        commit &&\n        !current.blocked &&\n        !isSameKanbanLocation(current.from, current.preview);\n      const destination = shouldCommit ? current.preview : current.from;\n\n      if (!commit) {\n        announce(\n          latest.current.labels.cancelled(\n            describe(current.itemId, current.from, items),\n          ),\n        );\n      } else if (current.blocked) {\n        announce(\n          latest.current.labels.refused(\n            describe(\n              current.itemId,\n              current.from,\n              items,\n              current.blocked.reason,\n            ),\n          ),\n        );\n      }\n\n      setDrag({\n        ...current,\n        preview: destination,\n        blocked: null,\n        phase: \"settling\",\n      });\n\n      settleOverlay(current.itemId, () => {\n        setDrag(null);\n\n        if (shouldCommit) {\n          const moved = commitMove(\n            { itemId: current.itemId, from: current.from, to: current.preview },\n            { skipValidation: false },\n          );\n\n          if (moved) {\n            announce(\n              latest.current.labels.dropped(\n                describe(current.itemId, current.preview, items),\n              ),\n            );\n          }\n        } else if (commit && !current.blocked) {\n          announce(\n            latest.current.labels.dropped(\n              describe(current.itemId, current.from, items),\n            ),\n          );\n        }\n      });\n    },\n    [announce, commitMove, describe, setDrag, settleOverlay, teardownSession],\n  );\n\n  const activatePointerDrag = useCallback(() => {\n    const session = sessionRef.current;\n\n    if (!session || session.active) {\n      return;\n    }\n\n    const element = cardRefs.current.get(session.itemId);\n    const { displayedItems: items, lanes: laneList } = latest.current;\n    const from = getKanbanLocation(items, session.itemId, laneList);\n\n    if (!element || !from) {\n      teardownSession();\n\n      return;\n    }\n\n    const rect = element.getBoundingClientRect();\n\n    session.active = true;\n    session.offsetX = session.startX - rect.left;\n    session.offsetY = session.startY - rect.top;\n    overlayX.jump(session.lastX - session.offsetX);\n    overlayY.jump(session.lastY - session.offsetY);\n    tiltTarget.jump(0);\n    overlayRotate.jump(0);\n    overlayScale.jump(1);\n\n    if (!latest.current.prefersReducedMotion) {\n      animate(overlayScale, 1.035, SPRING);\n    }\n\n    setOverlaySize({ width: rect.width, height: rect.height });\n    setActiveId(session.itemId);\n    setDrag({\n      itemId: session.itemId,\n      from,\n      preview: from,\n      mode: \"pointer\",\n      phase: \"dragging\",\n      blocked: null,\n    });\n    announce(\n      latest.current.labels.pickedUp(describe(session.itemId, from, items)),\n    );\n\n    const tick = () => {\n      const live = sessionRef.current;\n\n      if (!live?.active) {\n        return;\n      }\n\n      if (autoScroll(live.lastX, live.lastY)) {\n        updatePointerPreview(live.lastX, live.lastY);\n      }\n\n      live.frame = requestAnimationFrame(tick);\n    };\n\n    session.frame = requestAnimationFrame(tick);\n  }, [\n    announce,\n    autoScroll,\n    describe,\n    overlayRotate,\n    overlayScale,\n    overlayX,\n    overlayY,\n    setDrag,\n    teardownSession,\n    tiltTarget,\n    updatePointerPreview,\n  ]);\n\n  const handlePointerDown = useCallback(\n    (event: ReactPointerEvent<HTMLElement>, itemId: string) => {\n      if (\n        disabled ||\n        dragRef.current ||\n        sessionRef.current ||\n        (event.pointerType === \"mouse\" && event.button !== 0) ||\n        !event.isPrimary\n      ) {\n        return;\n      }\n\n      const card = event.currentTarget;\n      const handle =\n        event.target instanceof Element\n          ? event.target.closest(\"[data-kanban-drag-handle]\")\n          : null;\n\n      if (dragHandle === \"handle\") {\n        if (!handle || !card.contains(handle)) {\n          return;\n        }\n      } else if (isInteractiveTarget(event.target, card)) {\n        return;\n      }\n\n      const view = event.currentTarget.ownerDocument.defaultView ?? window;\n      const session: PointerSession = {\n        pointerId: event.pointerId,\n        pointerType: event.pointerType,\n        itemId,\n        startX: event.clientX,\n        startY: event.clientY,\n        lastX: event.clientX,\n        lastY: event.clientY,\n        lastTime: event.timeStamp,\n        offsetX: 0,\n        offsetY: 0,\n        active: false,\n        timer: undefined,\n        frame: undefined,\n        idle: undefined,\n      };\n\n      sessionRef.current = session;\n\n      if (event.pointerType !== \"mouse\") {\n        session.timer = setTimeout(activatePointerDrag, TOUCH_ACTIVATION_DELAY);\n      }\n\n      const onMove = (moveEvent: PointerEvent) => {\n        if (moveEvent.pointerId !== session.pointerId) {\n          return;\n        }\n\n        const distance = Math.hypot(\n          moveEvent.clientX - session.startX,\n          moveEvent.clientY - session.startY,\n        );\n\n        if (!session.active) {\n          session.lastX = moveEvent.clientX;\n          session.lastY = moveEvent.clientY;\n\n          if (session.pointerType === \"mouse\") {\n            if (distance >= MOUSE_ACTIVATION_DISTANCE) {\n              activatePointerDrag();\n            }\n          } else if (distance > TOUCH_TOLERANCE) {\n            teardownSession();\n          }\n\n          return;\n        }\n\n        moveEvent.preventDefault();\n\n        const elapsed = Math.max(1, moveEvent.timeStamp - session.lastTime);\n        const velocityX = (moveEvent.clientX - session.lastX) / elapsed;\n\n        session.lastX = moveEvent.clientX;\n        session.lastY = moveEvent.clientY;\n        session.lastTime = moveEvent.timeStamp;\n        overlayX.set(moveEvent.clientX - session.offsetX);\n        overlayY.set(moveEvent.clientY - session.offsetY);\n\n        if (!latest.current.prefersReducedMotion && dragTilt > 0) {\n          const tilt = Math.max(-dragTilt, Math.min(dragTilt, velocityX * 9));\n\n          tiltTarget.set(tilt);\n          clearTimeout(session.idle);\n          session.idle = setTimeout(() => tiltTarget.set(0), 90);\n        }\n\n        updatePointerPreview(moveEvent.clientX, moveEvent.clientY);\n      };\n      const onUp = (upEvent: PointerEvent) => {\n        if (upEvent.pointerId !== session.pointerId) {\n          return;\n        }\n\n        if (!session.active) {\n          teardownSession();\n\n          return;\n        }\n\n        // Swallow the click that follows a drag so card links don't fire.\n        const swallow = (clickEvent: MouseEvent) => {\n          clickEvent.stopPropagation();\n          clickEvent.preventDefault();\n        };\n\n        view.addEventListener(\"click\", swallow, { capture: true, once: true });\n        setTimeout(\n          () => view.removeEventListener(\"click\", swallow, { capture: true }),\n          0,\n        );\n        finishPointerDrag(true);\n      };\n      const onCancel = (cancelEvent: PointerEvent) => {\n        if (cancelEvent.pointerId === session.pointerId) {\n          if (session.active) {\n            finishPointerDrag(false);\n          } else {\n            teardownSession();\n          }\n        }\n      };\n      const onKey = (keyEvent: globalThis.KeyboardEvent) => {\n        if (keyEvent.key === \"Escape\" && session.active) {\n          keyEvent.preventDefault();\n          keyEvent.stopPropagation();\n          finishPointerDrag(false);\n        }\n      };\n      const onBlur = () => {\n        if (session.active) {\n          finishPointerDrag(false);\n        } else {\n          teardownSession();\n        }\n      };\n      // Once a touch drag is active, stop the page from panning underneath it.\n      const onTouchMove = (touchEvent: TouchEvent) => {\n        if (session.active && touchEvent.cancelable) {\n          touchEvent.preventDefault();\n        }\n      };\n      const onContextMenu = (menuEvent: Event) => {\n        if (session.pointerType !== \"mouse\") {\n          menuEvent.preventDefault();\n        }\n      };\n\n      view.addEventListener(\"pointermove\", onMove, { passive: false });\n      view.addEventListener(\"pointerup\", onUp);\n      view.addEventListener(\"pointercancel\", onCancel);\n      view.addEventListener(\"keydown\", onKey, true);\n      view.addEventListener(\"blur\", onBlur);\n      view.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n      view.addEventListener(\"contextmenu\", onContextMenu);\n\n      const root = view.document.documentElement;\n      const previousCursor = root.style.cursor;\n      const previousSelect = root.style.userSelect;\n\n      root.style.userSelect = \"none\";\n      if (event.pointerType === \"mouse\") {\n        root.style.cursor = \"grabbing\";\n      }\n\n      cleanupRef.current = () => {\n        view.removeEventListener(\"pointermove\", onMove);\n        view.removeEventListener(\"pointerup\", onUp);\n        view.removeEventListener(\"pointercancel\", onCancel);\n        view.removeEventListener(\"keydown\", onKey, true);\n        view.removeEventListener(\"blur\", onBlur);\n        view.removeEventListener(\"touchmove\", onTouchMove);\n        view.removeEventListener(\"contextmenu\", onContextMenu);\n        root.style.cursor = previousCursor;\n        root.style.userSelect = previousSelect;\n      };\n    },\n    [\n      activatePointerDrag,\n      disabled,\n      dragHandle,\n      dragTilt,\n      finishPointerDrag,\n      overlayX,\n      overlayY,\n      teardownSession,\n      tiltTarget,\n      updatePointerPreview,\n    ],\n  );\n\n  // Disabling the board cancels a drag in progress, or a press that has not\n  // started one yet.\n  useEffect(() => {\n    if (!disabled) {\n      return;\n    }\n\n    if (dragRef.current?.mode === \"pointer\") {\n      finishPointerDrag(false);\n    } else {\n      teardownSession();\n    }\n  }, [disabled, dragRef, finishPointerDrag, teardownSession]);\n\n  useEffect(() => () => teardownSession(), [teardownSession]);\n\n  return {\n    handlePointerDown,\n    overlayRotate,\n    overlayScale,\n    overlaySize,\n    overlayX,\n    overlayY,\n  };\n}\n"
    },
    {
      "path": "packages/components/src/components/kanban-board/kanban-board.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/kanban-board.tsx",
      "content": "import {\n  forwardRef,\n  useId,\n  type CSSProperties,\n  type ForwardedRef,\n  type ReactElement,\n  type Ref,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { LayoutGroup, motion } from \"motion/react\";\nimport { cn } from \"../../utils/cn\";\nimport {\n  countKanbanColumn,\n  getKanbanWipState,\n  resolveKanbanLaneId,\n  type KanbanColumn,\n  type KanbanItem,\n  type KanbanLane,\n  type KanbanLocation,\n} from \"./kanban-board-model\";\nimport { KanbanCardShell } from \"./kanban-board-card\";\nimport { buildKanbanMoveMenuOptions } from \"./kanban-board-menu\";\nimport { KanbanColumnHeader, KanbanLaneHeader } from \"./kanban-board-parts\";\nimport type {\n  KanbanBoardProps,\n  KanbanCardRenderState,\n} from \"./kanban-board-types\";\nimport {\n  boardScrollerClasses,\n  cellKey,\n  columnShellClasses,\n  defaultItemLabel,\n  kanbanBoardClassNames,\n  listClasses,\n  overlayClasses,\n} from \"./kanban-board-utils\";\nimport { useKanbanBoardState } from \"./use-kanban-board-state\";\nimport { useKanbanKeyboard } from \"./use-kanban-keyboard\";\nimport { useKanbanPointer } from \"./use-kanban-pointer\";\n\nfunction KanbanBoardImpl<T extends KanbanItem>(\n  {\n    canMove,\n    className,\n    collapsedColumns: collapsedColumnsProp,\n    collapsedLanes: collapsedLanesProp,\n    collapsibleColumns = true,\n    columnWidth = \"18rem\",\n    columns,\n    defaultCollapsedColumns,\n    defaultCollapsedLanes,\n    defaultItems,\n    disabled = false,\n    dragHandle = \"card\",\n    dragTilt = 6,\n    getErrorMessage,\n    getItemLabel = defaultItemLabel,\n    items: itemsProp,\n    label,\n    labels: labelsProp,\n    lanes: lanesProp,\n    onCollapsedColumnsChange,\n    onCollapsedLanesChange,\n    onItemsChange,\n    onMove,\n    renderCard,\n    renderColumnFooter,\n    renderEmptyColumn,\n    showMoveMenu = true,\n    style,\n    ...props\n  }: KanbanBoardProps<T>,\n  ref: ForwardedRef<HTMLDivElement>,\n) {\n  const board = useKanbanBoardState(\n    {\n      canMove,\n      collapsedColumns: collapsedColumnsProp,\n      collapsedLanes: collapsedLanesProp,\n      columns,\n      defaultCollapsedColumns,\n      defaultCollapsedLanes,\n      defaultItems,\n      disabled,\n      getErrorMessage,\n      getItemLabel,\n      items: itemsProp,\n      labels: labelsProp,\n      lanes: lanesProp,\n      onCollapsedColumnsChange,\n      onCollapsedLanesChange,\n      onItemsChange,\n      onMove,\n    },\n    ref,\n  );\n  const {\n    announce,\n    announcement,\n    cardRefs,\n    cells,\n    collapsedColumns,\n    collapsedLanes,\n    columnById,\n    commitMove,\n    describe,\n    dismissError,\n    displayedItems,\n    drag,\n    errors,\n    focusRequestRef,\n    getRefusal,\n    labels,\n    laneById,\n    lanes,\n    pendingIds,\n    portalContainer,\n    prefersReducedMotion,\n    registerCell,\n    renderItems,\n    retry,\n    scrollerRef,\n    setActiveId,\n    setRootRef,\n    toggleColumn,\n    toggleLane,\n  } = board;\n  const { handleCardKeyDown, handleRootBlur, rovingId } = useKanbanKeyboard(\n    board,\n    {\n      columns,\n      disabled,\n    },\n  );\n  const {\n    handlePointerDown,\n    overlayRotate,\n    overlayScale,\n    overlaySize,\n    overlayX,\n    overlayY,\n  } = useKanbanPointer(board, { disabled, dragHandle, dragTilt });\n  const baseId = useId();\n  const instructionsId = `${baseId}-instructions`;\n  const animateLayout = !prefersReducedMotion;\n\n  /* -------------------------------- rendering ------------------------------- */\n\n  const moveFromMenu = (\n    itemId: string,\n    from: KanbanLocation,\n    to: KanbanLocation,\n  ) => {\n    if (commitMove({ itemId, from, to })) {\n      focusRequestRef.current = itemId;\n      setActiveId(itemId);\n      announce(labels.dropped(describe(itemId, to, displayedItems)));\n    }\n  };\n\n  const renderCardShell = (\n    item: T,\n    index: number,\n    column: KanbanColumn,\n    lane: KanbanLane | undefined,\n    cellCount: number,\n  ) => {\n    const location: KanbanLocation = {\n      columnId: column.id,\n      laneId: lane?.id,\n      index,\n    };\n    const isMoving = drag?.itemId === item.id;\n    const failure = errors[item.id];\n    const isPending = pendingIds.has(item.id);\n    const state: KanbanCardRenderState = {\n      location,\n      column,\n      lane,\n      isDragging: isMoving,\n      isOverlay: false,\n      isPending,\n      error: failure?.message ?? null,\n      dragHandleProps:\n        dragHandle === \"handle\" && !disabled\n          ? {\n              \"data-kanban-drag-handle\": \"\",\n              style: { touchAction: \"none\", cursor: \"grab\" },\n            }\n          : undefined,\n    };\n\n    return (\n      <KanbanCardShell\n        key={item.id}\n        itemId={item.id}\n        itemLabel={getItemLabel(item)}\n        labels={labels}\n        instructionsId={instructionsId}\n        layoutId={animateLayout ? `${baseId}-card-${item.id}` : undefined}\n        cardRef={(node) => {\n          if (node) {\n            cardRefs.current.set(item.id, node);\n          } else if (cardRefs.current.get(item.id)?.isConnected === false) {\n            cardRefs.current.delete(item.id);\n          }\n        }}\n        focusable={rovingId === item.id}\n        disabled={disabled}\n        dragHandle={dragHandle}\n        isLifted={isMoving && drag.mode === \"keyboard\"}\n        isPlaceholder={isMoving && drag.mode === \"pointer\"}\n        isPending={isPending}\n        error={state.error}\n        showMoveMenu={showMoveMenu && !disabled && !isMoving}\n        getMenuOptions={() =>\n          buildKanbanMoveMenuOptions({\n            cellCount,\n            columns,\n            getRefusal,\n            item,\n            items: displayedItems,\n            labels,\n            lanes,\n            location,\n            onMove: (to) => moveFromMenu(item.id, location, to),\n          })\n        }\n        onFocus={() => setActiveId(item.id)}\n        onKeyDown={(event) => handleCardKeyDown(event, item.id)}\n        onPointerDown={(event) => handlePointerDown(event, item.id)}\n        onRetry={() => retry(item.id)}\n        onDismiss={() => dismissError(item.id)}\n      >\n        {renderCard(item, state)}\n      </KanbanCardShell>\n    );\n  };\n\n  const renderCell = (\n    column: KanbanColumn,\n    lane: KanbanLane | undefined,\n    headingId: string,\n  ) => {\n    const cellItems = cells.get(cellKey(column.id, lane?.id)) ?? [];\n    const listLabel = lane ? `${column.title}, ${lane.title}` : undefined;\n    const empty = cellItems.length === 0;\n\n    return (\n      <motion.ul\n        layoutScroll={animateLayout}\n        ref={(node: HTMLUListElement | null) =>\n          registerCell(column.id, lane?.id, \"list\", node)\n        }\n        aria-label={listLabel}\n        aria-labelledby={listLabel ? undefined : headingId}\n        className={cn(listClasses, !lanes && \"min-h-0 flex-1 overflow-y-auto\")}\n        data-slot=\"kanban-list\"\n      >\n        {cellItems.map((item, index) =>\n          renderCardShell(item, index, column, lane, cellItems.length),\n        )}\n        {empty ? (\n          <li\n            aria-hidden={renderEmptyColumn ? undefined : true}\n            className=\"text-muted-foreground/80 border-border/80 flex min-h-16 items-center justify-center rounded-lg border border-dashed px-3 text-center text-xs\"\n            data-slot=\"kanban-empty\"\n          >\n            {renderEmptyColumn?.(column, lane) ?? labels.emptyColumn}\n          </li>\n        ) : null}\n      </motion.ul>\n    );\n  };\n\n  const gridTemplateColumns = columns\n    .map((column) =>\n      collapsedColumns.includes(column.id)\n        ? \"var(--kanban-rail-width)\"\n        : \"var(--kanban-column-width)\",\n    )\n    .join(\" \");\n\n  const columnState = (column: KanbanColumn) => {\n    const count = countKanbanColumn(renderItems, column.id);\n    const isTarget =\n      drag?.mode === \"pointer\" &&\n      drag.phase === \"dragging\" &&\n      drag.preview.columnId === column.id &&\n      drag.preview.columnId !== drag.from.columnId;\n    const blocked =\n      drag?.blocked?.columnId === column.id ? drag.blocked.reason : null;\n\n    return {\n      collapsed: collapsedColumns.includes(column.id),\n      count,\n      isTarget,\n      blocked,\n      wip: getKanbanWipState(count, column.wipLimit),\n    };\n  };\n\n  const header = (column: KanbanColumn, headingId: string) => {\n    const state = columnState(column);\n\n    return (\n      <KanbanColumnHeader\n        collapsed={state.collapsed}\n        collapsible={collapsibleColumns}\n        column={column}\n        count={state.count}\n        headingId={headingId}\n        labels={labels}\n        onToggle={() => toggleColumn(column.id)}\n        refusal={state.blocked}\n        wip={state.wip}\n      />\n    );\n  };\n\n  const draggedItem = drag\n    ? displayedItems.find((item) => item.id === drag.itemId)\n    : undefined;\n  const overlay =\n    drag?.mode === \"pointer\" && draggedItem && portalContainer\n      ? createPortal(\n          <motion.div\n            aria-hidden=\"true\"\n            className={overlayClasses}\n            data-slot=\"kanban-drag-overlay\"\n            style={{\n              x: overlayX,\n              y: overlayY,\n              rotate: overlayRotate,\n              scale: overlayScale,\n              width: overlaySize.width,\n              minHeight: overlaySize.height,\n            }}\n          >\n            {(() => {\n              const column = columnById.get(drag.preview.columnId);\n\n              return column\n                ? renderCard(draggedItem, {\n                    location: drag.preview,\n                    column,\n                    lane:\n                      drag.preview.laneId !== undefined\n                        ? laneById.get(drag.preview.laneId)\n                        : undefined,\n                    isDragging: true,\n                    isOverlay: true,\n                    isPending: false,\n                    error: null,\n                    dragHandleProps: undefined,\n                  })\n                : null;\n            })()}\n          </motion.div>,\n          portalContainer,\n        )\n      : null;\n\n  const rootStyle = {\n    ...style,\n    \"--kanban-column-width\": columnWidth,\n  } as CSSProperties;\n\n  return (\n    <div\n      {...props}\n      ref={setRootRef}\n      role=\"region\"\n      aria-label={label ?? labels.board}\n      className={kanbanBoardClassNames({ className })}\n      style={rootStyle}\n      data-slot=\"kanban-board\"\n      data-dragging={drag ? drag.mode : undefined}\n      data-disabled={disabled || undefined}\n      onBlur={handleRootBlur}\n    >\n      <p id={instructionsId} className=\"sr-only\">\n        {labels.instructions}\n      </p>\n      <LayoutGroup id={baseId}>\n        <motion.div\n          layoutScroll={animateLayout}\n          ref={scrollerRef}\n          className={boardScrollerClasses}\n          data-slot=\"kanban-scroller\"\n        >\n          {lanes ? (\n            <div className=\"flex w-max min-w-full flex-col gap-3 pb-2\">\n              <div\n                className=\"bg-background/85 sticky top-0 z-20 grid gap-3 pb-1 backdrop-blur-sm\"\n                style={{ gridTemplateColumns }}\n              >\n                {columns.map((column) => {\n                  const state = columnState(column);\n\n                  return (\n                    <div\n                      key={column.id}\n                      className={cn(columnShellClasses, \"h-full\")}\n                      data-slot=\"kanban-column\"\n                      data-collapsed={state.collapsed || undefined}\n                      data-drop-target={state.isTarget || undefined}\n                      data-drop-blocked={!!state.blocked || undefined}\n                      data-wip={state.wip.status}\n                    >\n                      {header(column, `${baseId}-col-${column.id}`)}\n                    </div>\n                  );\n                })}\n              </div>\n              {lanes.map((lane) => {\n                const laneCollapsed = collapsedLanes.includes(lane.id);\n                const laneHeadingId = `${baseId}-lane-${lane.id}`;\n                const laneCount = renderItems.filter(\n                  (item) => resolveKanbanLaneId(item, lanes) === lane.id,\n                ).length;\n\n                return (\n                  <div\n                    key={lane.id}\n                    role=\"group\"\n                    aria-labelledby={laneHeadingId}\n                    className=\"flex flex-col\"\n                    data-slot=\"kanban-lane\"\n                    data-collapsed={laneCollapsed || undefined}\n                  >\n                    <KanbanLaneHeader\n                      collapsed={laneCollapsed}\n                      count={laneCount}\n                      headingId={laneHeadingId}\n                      labels={labels}\n                      lane={lane}\n                      onToggle={() => toggleLane(lane.id)}\n                    />\n                    {laneCollapsed ? null : (\n                      <div\n                        className=\"grid gap-3\"\n                        style={{ gridTemplateColumns }}\n                      >\n                        {columns.map((column) => {\n                          const state = columnState(column);\n\n                          return (\n                            <div\n                              key={column.id}\n                              ref={(node) =>\n                                registerCell(column.id, lane.id, \"zone\", node)\n                              }\n                              role=\"group\"\n                              aria-labelledby={`${baseId}-col-${column.id}`}\n                              className={cn(\n                                columnShellClasses,\n                                \"min-h-20\",\n                                state.collapsed &&\n                                  \"items-center justify-center\",\n                              )}\n                              data-slot=\"kanban-cell\"\n                              data-collapsed={state.collapsed || undefined}\n                              data-drop-target={\n                                (state.isTarget &&\n                                  drag?.preview.laneId === lane.id) ||\n                                undefined\n                              }\n                              data-drop-blocked={!!state.blocked || undefined}\n                            >\n                              {state.collapsed ? (\n                                <span className=\"text-muted-foreground text-xs tabular-nums\">\n                                  {\n                                    (\n                                      cells.get(cellKey(column.id, lane.id)) ??\n                                      []\n                                    ).length\n                                  }\n                                </span>\n                              ) : (\n                                <div className=\"pt-[calc(var(--dt-density-gap)*0.75+var(--dt-space-0-5))]\">\n                                  {renderCell(\n                                    column,\n                                    lane,\n                                    `${baseId}-col-${column.id}`,\n                                  )}\n                                </div>\n                              )}\n                            </div>\n                          );\n                        })}\n                      </div>\n                    )}\n                  </div>\n                );\n              })}\n            </div>\n          ) : (\n            <div\n              className=\"grid h-full w-max min-w-full gap-3 pb-2\"\n              style={{ gridTemplateColumns }}\n            >\n              {columns.map((column) => {\n                const state = columnState(column);\n                const headingId = `${baseId}-col-${column.id}`;\n                const columnItems =\n                  cells.get(cellKey(column.id, undefined)) ?? [];\n\n                return (\n                  <div\n                    key={column.id}\n                    role=\"group\"\n                    ref={(node) =>\n                      registerCell(column.id, undefined, \"zone\", node)\n                    }\n                    aria-labelledby={headingId}\n                    className={columnShellClasses}\n                    data-slot=\"kanban-column\"\n                    data-collapsed={state.collapsed || undefined}\n                    data-drop-target={state.isTarget || undefined}\n                    data-drop-blocked={!!state.blocked || undefined}\n                    data-wip={state.wip.status}\n                  >\n                    {header(column, headingId)}\n                    {state.collapsed ? null : (\n                      <>\n                        {renderCell(column, undefined, headingId)}\n                        {renderColumnFooter ? (\n                          <div\n                            className=\"px-[calc(var(--dt-density-gap)*0.75+var(--dt-space-0-5))] pb-[calc(var(--dt-density-gap)*0.75+var(--dt-space-0-5))]\"\n                            data-slot=\"kanban-column-footer\"\n                          >\n                            {renderColumnFooter(column, columnItems)}\n                          </div>\n                        ) : null}\n                      </>\n                    )}\n                  </div>\n                );\n              })}\n            </div>\n          )}\n        </motion.div>\n      </LayoutGroup>\n      <div aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </div>\n      {overlay}\n    </div>\n  );\n}\n\nexport const KanbanBoard = forwardRef(KanbanBoardImpl) as (<\n  T extends KanbanItem,\n>(\n  props: KanbanBoardProps<T> & { ref?: Ref<HTMLDivElement> },\n) => ReactElement | null) & { displayName?: string };\n\nKanbanBoard.displayName = \"KanbanBoard\";\n"
    },
    {
      "path": "packages/components/src/components/kanban-board/index.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/kanban-board/index.ts",
      "content": "export { KanbanBoard } from \"./kanban-board\";\nexport type {\n  KanbanBoardProps,\n  KanbanCardRenderState,\n  KanbanDragHandleMode,\n  KanbanDragHandleProps,\n  KanbanMoveContext,\n} from \"./kanban-board-types\";\nexport { kanbanBoardClassNames } from \"./kanban-board-utils\";\nexport {\n  defaultKanbanBoardLabels,\n  type KanbanAnnouncementContext,\n  type KanbanBoardLabels,\n} from \"./kanban-board-parts\";\nexport {\n  applyKanbanMove,\n  getKanbanCell,\n  getKanbanDropRefusal,\n  getKanbanLocation,\n  getKanbanWipState,\n  resolveKanbanLaneId,\n  resolveKanbanWipLimit,\n  type KanbanColumn,\n  type KanbanColumnTone,\n  type KanbanItem,\n  type KanbanLane,\n  type KanbanLocation,\n  type KanbanMove,\n  type KanbanWipLimit,\n  type KanbanWipMode,\n  type KanbanWipState,\n  type KanbanWipStatus,\n} from \"./kanban-board-model\";\n"
    },
    {
      "path": "packages/components/src/utils/provider-portal.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/utils/provider-portal.tsx",
      "content": "import {\n  createContext,\n  type ForwardedRef,\n  type ReactNode,\n  type RefCallback,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { UNSAFE_PortalProvider } from \"react-aria/PortalProvider\";\nimport { setDethinkPortalOwner } from \"./portal-owner\";\n\nconst providerPortalDefaultClasses = \"bg-background font-sans text-foreground\";\nconst providerPortalMirroredAttributes = [\n  \"data-theme\",\n  \"data-density\",\n  \"dir\",\n] as const;\n\nexport type ProviderPortalMirroredAttribute =\n  (typeof providerPortalMirroredAttributes)[number];\n\nexport interface ProviderPortalRootOptions<T extends HTMLElement> {\n  forwardedRef?: ForwardedRef<T>;\n  portalSlot: string;\n}\n\nexport interface ProviderPortalRootState<T extends HTMLElement> {\n  portalContainer: HTMLElement | null;\n  rootRef: RefCallback<T>;\n}\n\nexport interface DethinkPortalProviderProps {\n  children?: ReactNode;\n  container: HTMLElement | null;\n}\n\nconst DethinkPortalContext = createContext(false);\nconst OverlayVisibilityContext = createContext(true);\n\n/** Persistent surfaces can dismiss overlays without unmounting their content. */\nexport function DethinkOverlayScope({\n  visible,\n  children,\n}: {\n  visible: boolean;\n  children?: ReactNode;\n}) {\n  const parentVisible = useContext(OverlayVisibilityContext);\n  return (\n    <OverlayVisibilityContext.Provider value={visible && parentVisible}>\n      {children}\n    </OverlayVisibilityContext.Provider>\n  );\n}\n\nexport function useScopedOverlayState({\n  open,\n  defaultOpen = false,\n  onOpenChange,\n}: {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}): readonly [boolean, (open: boolean) => void] {\n  const visible = useContext(OverlayVisibilityContext);\n  const [localOpen, setLocalOpen] = useState(defaultOpen);\n  const requestedOpen = open ?? localOpen;\n  const closeRequested = useRef(false);\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (next && !visible) return;\n      if (open === undefined) setLocalOpen(next);\n      onOpenChange?.(next);\n    },\n    [open, onOpenChange, visible],\n  );\n  useEffect(() => {\n    if (visible) closeRequested.current = false;\n    else if (requestedOpen && !closeRequested.current) {\n      closeRequested.current = true;\n      // Synchronize overlay state with the containing surface's lifecycle.\n      setOpen(false);\n    }\n  }, [visible, requestedOpen, setOpen]);\n  return [visible && requestedOpen, setOpen];\n}\n\nfunction assignForwardedRef<T>(\n  ref: ForwardedRef<T> | undefined,\n  value: T | null,\n) {\n  if (typeof ref === \"function\") {\n    ref(value);\n  } else if (ref) {\n    ref.current = value;\n  }\n}\n\nfunction fallbackProviderAttribute(name: ProviderPortalMirroredAttribute) {\n  if (typeof document === \"undefined\") {\n    return null;\n  }\n\n  if (name === \"data-density\") {\n    return \"default\";\n  }\n\n  if (name === \"data-theme\") {\n    return document.documentElement.getAttribute(name) ?? \"system\";\n  }\n\n  return document.documentElement.getAttribute(\"dir\") ?? document.dir ?? \"ltr\";\n}\n\nexport function syncDethinkPortalContainer({\n  container,\n  portalSlot,\n  provider,\n}: {\n  container: HTMLElement;\n  portalSlot: string;\n  provider: HTMLElement | null;\n}) {\n  const source =\n    provider ??\n    (typeof document === \"undefined\" ? null : document.documentElement);\n\n  container.setAttribute(\"data-slot\", portalSlot);\n  container.setAttribute(\"data-dethink-provider\", \"\");\n  container.className = provider?.className || providerPortalDefaultClasses;\n  container.style.cssText = provider?.getAttribute(\"style\") ?? \"\";\n  container.style.display = \"contents\";\n\n  for (const attribute of providerPortalMirroredAttributes) {\n    const value =\n      source?.getAttribute(attribute) ?? fallbackProviderAttribute(attribute);\n\n    if (value) {\n      container.setAttribute(attribute, value);\n    } else {\n      container.removeAttribute(attribute);\n    }\n  }\n}\n\nexport function useProviderPortalRoot<T extends HTMLElement>({\n  forwardedRef,\n  portalSlot,\n}: ProviderPortalRootOptions<T>): ProviderPortalRootState<T> {\n  const [portalContainer] = useState<HTMLElement | null>(() => {\n    if (typeof document === \"undefined\") {\n      return null;\n    }\n\n    return document.createElement(\"div\");\n  });\n  const [rootElement, setRootElement] = useState<T | null>(null);\n\n  const rootRef = useCallback(\n    (node: T | null) => {\n      setRootElement(node);\n      assignForwardedRef(forwardedRef, node);\n\n      if (node && portalContainer && typeof document !== \"undefined\") {\n        setDethinkPortalOwner(portalContainer, node);\n        const provider =\n          node.closest<HTMLElement>(\"[data-dethink-provider]\") ?? null;\n\n        syncDethinkPortalContainer({\n          container: portalContainer,\n          portalSlot,\n          provider,\n        });\n\n        if (!portalContainer.isConnected) {\n          document.body.appendChild(portalContainer);\n        }\n      }\n    },\n    [forwardedRef, portalContainer, portalSlot],\n  );\n\n  useEffect(() => {\n    if (!portalContainer || typeof document === \"undefined\") {\n      return undefined;\n    }\n\n    const provider =\n      rootElement?.closest<HTMLElement>(\"[data-dethink-provider]\") ?? null;\n    const syncContainer = () => {\n      syncDethinkPortalContainer({\n        container: portalContainer,\n        portalSlot,\n        provider,\n      });\n    };\n\n    syncContainer();\n    document.body.appendChild(portalContainer);\n\n    const observerTarget = provider ?? document.documentElement;\n    const observer = new MutationObserver(syncContainer);\n    observer.observe(observerTarget, {\n      attributeFilter: [\"class\", \"data-density\", \"data-theme\", \"dir\", \"style\"],\n      attributes: true,\n    });\n\n    return () => {\n      observer.disconnect();\n      portalContainer.remove();\n    };\n  }, [portalContainer, portalSlot, rootElement]);\n\n  return {\n    portalContainer,\n    rootRef,\n  };\n}\n\nexport function DethinkPortalProvider({\n  children,\n  container,\n}: DethinkPortalProviderProps) {\n  const hasDethinkPortalParent = useContext(DethinkPortalContext);\n  const content = hasDethinkPortalParent ? (\n    children\n  ) : (\n    <UNSAFE_PortalProvider getContainer={() => container}>\n      {children}\n    </UNSAFE_PortalProvider>\n  );\n\n  return (\n    <DethinkPortalContext.Provider value>\n      {content}\n    </DethinkPortalContext.Provider>\n  );\n}\n"
    },
    {
      "path": "packages/components/src/utils/portal-owner.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/portal-owner.ts",
      "content": "/**\n * Links a Dethink portal container on <body> to the component root that owns\n * it, so a modal surface (the mobile sidebar) can keep overlays opened from\n * inside it usable while it makes the rest of the page inert. A global symbol\n * key, so components copied from the registry agree without sharing imports.\n */\nconst PORTAL_OWNER: unique symbol = Symbol.for(\"dethink.portal-owner\");\n\ntype OwnedElement = Element & { [PORTAL_OWNER]?: Element };\n\nexport function setDethinkPortalOwner(container: Element, owner: Element) {\n  (container as OwnedElement)[PORTAL_OWNER] = owner;\n}\n\n/** The element owning a Dethink portal container, or null for any other element. */\nexport function getDethinkPortalOwner(element: Element): Element | null {\n  return (element as OwnedElement)[PORTAL_OWNER] ?? null;\n}\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"
    }
  ]
}
