{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban",
  "title": "Kanban",
  "description": "A generic kanban board styled to the Untitled UI look: colour-coded columns with count badges, WIP-limit indication and overflow-menu slots, slot-based cards carrying badges, assignee avatars and due dates, and pointer drag-and-drop with a floating ghost and live drop indicator — fully keyboard-operable with aria-live announcements.",
  "dependencies": [
    "@untitledui/icons@^0.0.22"
  ],
  "registryDependencies": [
    "@nx-ui/avatar",
    "@nx-ui/badge",
    "@nx-ui/button",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/kanban/index.ts",
      "content": "\"use client\"\n\n/**\n * kanban — a headless-driven, generic kanban board in the nx-ui idiom.\n *\n * Composition: wrap the board in {@link KanbanProvider} (it owns column + card\n * state, controlled via `value`/`onValueChange` or uncontrolled via\n * `defaultValue`, and drives all interaction). Inside, {@link KanbanBoard}\n * renders the horizontally-scrolling columns via a render prop; each\n * {@link KanbanColumn} paints its header (colour dot, count badge, WIP-limit\n * indication, add button, overflow-menu slot) and drop zone, mapping its cards\n * through a render prop into {@link KanbanCard} — a draggable, keyboard-operable\n * card you fill with the slot sub-components.\n *\n * Drag-and-drop is implemented from scratch on pointer events (mouse + touch)\n * with a floating ghost and a live drop indicator; the same moves are fully\n * keyboard-operable (Space/Enter to grab, arrows to move within and across\n * columns, Space to drop, Escape to cancel) with polite aria-live\n * announcements.\n *\n * @example\n * <KanbanProvider columns={columns} value={cards} onValueChange={setCards}>\n *   <KanbanBoard>\n *     {(column) => (\n *       <KanbanColumn<Deal> column={column} onAddCard={add} menu={<Menu />}>\n *         {(deal) => (\n *           <KanbanCard card={deal}>\n *             <KanbanCardTitle>{deal.title}</KanbanCardTitle>\n *           </KanbanCard>\n *         )}\n *       </KanbanColumn>\n *     )}\n *   </KanbanBoard>\n * </KanbanProvider>\n */\n\nexport type {\n  KanbanCardData,\n  KanbanColumnData,\n  KanbanMoveEvent,\n  KanbanMoveMode,\n  KanbanTone,\n} from \"./kanban-types\"\nexport {\n  useKanban,\n  type KanbanApi,\n  type UseKanbanOptions,\n} from \"./use-kanban\"\nexport {\n  KanbanProvider,\n  useKanbanContext,\n  type KanbanProviderProps,\n} from \"./kanban-context\"\nexport { KanbanBoard, type KanbanBoardProps } from \"./kanban-board\"\nexport { KanbanColumn, type KanbanColumnProps } from \"./kanban-column\"\nexport {\n  KanbanCard,\n  KanbanCardTitle,\n  KanbanCardDescription,\n  KanbanCardBadges,\n  KanbanCardFooter,\n  KanbanCardDueDate,\n  KanbanCardAssignees,\n  type KanbanCardProps,\n  type KanbanAssignee,\n} from \"./kanban-card\"\n",
      "type": "registry:component",
      "target": "components/blocks/kanban/index.ts"
    },
    {
      "path": "components/blocks/kanban/kanban-board.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport type { KanbanColumnData } from \"./kanban-types\"\nimport { useKanbanContext } from \"./kanban-context\"\n\nexport interface KanbanBoardProps {\n  /** Render each column, in order. Wrap your content in <KanbanColumn>. */\n  children: (column: KanbanColumnData) => React.ReactNode\n  className?: string\n  /** Accessible name for the board region. */\n  \"aria-label\"?: string\n}\n\n/**\n * KanbanBoard — the horizontally-scrolling row of columns. It reads the columns\n * from board state and hands each to your render-prop `children`. Give it a\n * bounded height (its parent) so the columns can scroll their cards vertically\n * while the board scrolls horizontally.\n */\nexport function KanbanBoard({\n  children,\n  className,\n  \"aria-label\": ariaLabel = \"Board\",\n}: KanbanBoardProps) {\n  const { api } = useKanbanContext()\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={ariaLabel}\n      className={cn(\n        \"flex h-full items-stretch gap-4 overflow-x-auto overflow-y-hidden pb-2\",\n        className\n      )}\n    >\n      {api.columns.map((column) => (\n        <React.Fragment key={column.id}>{children(column)}</React.Fragment>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/kanban/kanban-board.tsx"
    },
    {
      "path": "components/blocks/kanban/kanban-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Calendar } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Avatar,\n  AvatarFallback,\n  AvatarGroup,\n  AvatarGroupCount,\n  AvatarImage,\n} from \"@/components/ui/avatar\"\nimport type { KanbanCardData } from \"./kanban-types\"\nimport { useKanbanContext } from \"./kanban-context\"\n\n/** Elements inside a card that must not begin a drag when interacted with. */\nconst NO_DRAG_SELECTOR =\n  'button, a, input, textarea, select, [role=\"menu\"], [role=\"menuitem\"], [data-no-drag]'\n\nexport interface KanbanCardProps<TCard extends KanbanCardData>\n  extends Omit<React.ComponentProps<\"div\">, \"id\"> {\n  /** The card this element represents. */\n  card: TCard\n}\n\n/**\n * KanbanCard — a single draggable, keyboard-operable card. It is the drag\n * source (whole-card grab, minus buttons/links/menus), the drop measurement\n * target, and the roving-tabindex focus stop. Compose its content from the\n * slot sub-components (`KanbanCardTitle`, `…Description`, `…Badges`, `…Footer`,\n * `…DueDate`, `…Assignees`) — or any markup you like.\n */\nexport function KanbanCard<TCard extends KanbanCardData>({\n  card,\n  className,\n  children,\n  ...props\n}: KanbanCardProps<TCard>) {\n  const {\n    registerCard,\n    startPointerDrag,\n    onCardKeyDown,\n    pointerCardId,\n    grabbedId,\n  } = useKanbanContext()\n\n  const isPointerDragging = pointerCardId === card.id\n  const isGrabbed = grabbedId === card.id\n\n  const setRef = React.useCallback(\n    (el: HTMLDivElement | null) => registerCard(card.id, el),\n    [card.id, registerCard]\n  )\n\n  return (\n    <div\n      ref={setRef}\n      role=\"button\"\n      tabIndex={0}\n      aria-roledescription=\"Draggable card\"\n      aria-pressed={isGrabbed}\n      data-slot=\"kanban-card\"\n      data-dragging={isPointerDragging || undefined}\n      data-grabbed={isGrabbed || undefined}\n      onPointerDown={(e) => {\n        if ((e.target as HTMLElement).closest(NO_DRAG_SELECTOR)) return\n        startPointerDrag(card.id, e)\n      }}\n      onKeyDown={(e) => onCardKeyDown(card.id, e)}\n      className={cn(\n        \"group/kanban-card flex cursor-grab touch-none flex-col gap-2 rounded-xl bg-card p-3.5 text-left text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 transition-shadow outline-none select-none hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing\",\n        \"data-dragging:opacity-40\",\n        \"data-grabbed:ring-2 data-grabbed:ring-ring data-grabbed:shadow-md\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  )\n}\n\nfunction KanbanCardTitle({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"kanban-card-title\"\n      className={cn(\"text-sm font-semibold text-foreground\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction KanbanCardDescription({\n  className,\n  ...props\n}: React.ComponentProps<\"p\">) {\n  return (\n    <p\n      data-slot=\"kanban-card-description\"\n      className={cn(\"line-clamp-2 text-xs text-muted-foreground\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction KanbanCardBadges({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"kanban-card-badges\"\n      className={cn(\"flex flex-wrap items-center gap-1.5\", className)}\n      {...props}\n    />\n  )\n}\n\nfunction KanbanCardFooter({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"kanban-card-footer\"\n      className={cn(\n        \"mt-0.5 flex items-center justify-between gap-2\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction KanbanCardDueDate({\n  className,\n  children,\n  overdue = false,\n  ...props\n}: React.ComponentProps<\"span\"> & { overdue?: boolean }) {\n  return (\n    <span\n      data-slot=\"kanban-card-due-date\"\n      className={cn(\n        \"inline-flex items-center gap-1 text-xs font-medium text-muted-foreground\",\n        overdue && \"text-destructive\",\n        className\n      )}\n      {...props}\n    >\n      <Calendar className=\"size-3.5\" />\n      {children}\n    </span>\n  )\n}\n\n/** A person on a card — name drives the avatar fallback initials. */\nexport interface KanbanAssignee {\n  name: string\n  src?: string\n}\n\nfunction initials(name: string) {\n  return name\n    .split(\" \")\n    .map((part) => part[0])\n    .filter(Boolean)\n    .slice(0, 2)\n    .join(\"\")\n    .toUpperCase()\n}\n\nfunction KanbanCardAssignees({\n  people,\n  max = 3,\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  people: KanbanAssignee[]\n  /** Maximum avatars before a \"+N\" count is shown. */\n  max?: number\n}) {\n  const shown = people.slice(0, max)\n  const overflow = people.length - shown.length\n\n  return (\n    <AvatarGroup\n      data-slot=\"kanban-card-assignees\"\n      className={className}\n      {...props}\n    >\n      {shown.map((person, i) => (\n        <Avatar key={`${person.name}-${i}`} size=\"sm\">\n          {person.src ? <AvatarImage src={person.src} alt={person.name} /> : null}\n          <AvatarFallback>{initials(person.name)}</AvatarFallback>\n        </Avatar>\n      ))}\n      {overflow > 0 ? <AvatarGroupCount>+{overflow}</AvatarGroupCount> : null}\n    </AvatarGroup>\n  )\n}\n\nexport {\n  KanbanCardTitle,\n  KanbanCardDescription,\n  KanbanCardBadges,\n  KanbanCardFooter,\n  KanbanCardDueDate,\n  KanbanCardAssignees,\n}\n",
      "type": "registry:component",
      "target": "components/blocks/kanban/kanban-card.tsx"
    },
    {
      "path": "components/blocks/kanban/kanban-column.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Plus } from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button } from \"@/components/ui/button\"\nimport type { KanbanCardData, KanbanColumnData, KanbanTone } from \"./kanban-types\"\nimport { useKanbanContext } from \"./kanban-context\"\n\nconst toneDot: Record<KanbanTone, string> = {\n  neutral: \"bg-utility-neutral-500\",\n  brand: \"bg-utility-brand-500\",\n  blue: \"bg-utility-blue-500\",\n  amber: \"bg-utility-amber-500\",\n  green: \"bg-utility-green-500\",\n  red: \"bg-utility-red-500\",\n  purple: \"bg-utility-purple-500\",\n}\n\n/** The 2px line that marks where a card would drop. */\nfunction DropIndicator({ active }: { active: boolean }) {\n  return (\n    <div\n      aria-hidden\n      data-active={active || undefined}\n      className={cn(\n        \"mx-0.5 h-0.5 rounded-full bg-primary opacity-0 transition-opacity\",\n        \"data-active:opacity-100\"\n      )}\n    />\n  )\n}\n\nexport interface KanbanColumnProps<TCard extends KanbanCardData> {\n  /** The column to render. */\n  column: KanbanColumnData\n  /** Render each card in this column. Wrap your content in <KanbanCard>. */\n  children: (card: TCard, index: number) => React.ReactNode\n  /** Show a \"+\" button in the header, called when pressed. */\n  onAddCard?: (columnId: string) => void\n  /** Slot for an overflow menu (e.g. a <DropdownMenu>) in the header. */\n  menu?: React.ReactNode\n  /** Shown in the card list when the column has no cards. */\n  emptyState?: React.ReactNode\n  className?: string\n}\n\n/**\n * KanbanColumn — one lane: a header (colour dot, title, count badge, optional\n * WIP-limit indication, add button and an overflow-menu slot) above a\n * vertically-scrolling card list that doubles as the drop zone. Cards come from\n * board state; you paint each via the render-prop `children`. When over its WIP\n * limit the count badge turns amber and the column is flagged `aria-invalid`.\n */\nexport function KanbanColumn<TCard extends KanbanCardData>({\n  column,\n  children,\n  onAddCard,\n  menu,\n  emptyState,\n  className,\n}: KanbanColumnProps<TCard>) {\n  const { api, registerColumn, dropTarget } = useKanbanContext()\n\n  const cards = api.getCards(column.id) as TCard[]\n  const count = cards.length\n  const overLimit =\n    column.wipLimit !== undefined && count > column.wipLimit\n\n  const setRef = React.useCallback(\n    (el: HTMLDivElement | null) => registerColumn(column.id, el),\n    [column.id, registerColumn]\n  )\n\n  const isTarget = dropTarget?.columnId === column.id\n  const tone = column.tone ?? \"neutral\"\n\n  return (\n    <section\n      aria-label={column.title}\n      className={cn(\n        \"flex h-full w-72 shrink-0 flex-col rounded-xl bg-muted/40 ring-1 ring-border\",\n        className\n      )}\n    >\n      <header className=\"flex items-center gap-2 px-3 py-3\">\n        <span\n          aria-hidden\n          className={cn(\"size-2 shrink-0 rounded-full\", toneDot[tone])}\n        />\n        <h3 className=\"truncate text-sm font-semibold text-foreground\">\n          {column.title}\n        </h3>\n        <Badge\n          variant={overLimit ? \"warning\" : \"gray\"}\n          aria-invalid={overLimit || undefined}\n          aria-label={\n            column.wipLimit !== undefined\n              ? `${count} of ${column.wipLimit} cards${\n                  overLimit ? \", over limit\" : \"\"\n                }`\n              : `${count} cards`\n          }\n        >\n          {column.wipLimit !== undefined ? `${count}/${column.wipLimit}` : count}\n        </Badge>\n        <div className=\"ml-auto flex items-center gap-0.5\">\n          {onAddCard ? (\n            <Button\n              type=\"button\"\n              variant=\"ghost\"\n              size=\"icon-sm\"\n              aria-label={`Add card to ${column.title}`}\n              onClick={() => onAddCard(column.id)}\n            >\n              <Plus />\n            </Button>\n          ) : null}\n          {menu}\n        </div>\n      </header>\n\n      <div\n        ref={setRef}\n        data-slot=\"kanban-column-list\"\n        aria-dropeffect=\"move\"\n        className=\"flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2.5 pb-2.5\"\n      >\n        {count === 0 ? (\n          <>\n            <DropIndicator active={isTarget} />\n            {emptyState ?? (\n              <div className=\"flex flex-1 items-center justify-center rounded-lg border border-dashed border-border px-4 py-8 text-center text-xs text-muted-foreground\">\n                No cards\n              </div>\n            )}\n          </>\n        ) : (\n          <>\n            {cards.map((card, index) => (\n              <React.Fragment key={card.id}>\n                <DropIndicator active={isTarget && dropTarget?.index === index} />\n                {children(card, index)}\n              </React.Fragment>\n            ))}\n            <DropIndicator active={isTarget && dropTarget?.index === count} />\n          </>\n        )}\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/kanban/kanban-column.tsx"
    },
    {
      "path": "components/blocks/kanban/kanban-context.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport type { KanbanCardData } from \"./kanban-types\"\nimport { useKanban, type KanbanApi, type UseKanbanOptions } from \"./use-kanban\"\n\n/** Where a dragged/grabbed card would land: a column and an insertion index. */\ninterface DropTarget {\n  columnId: string\n  index: number\n}\n\ninterface PointerDragState {\n  cardId: string\n  /** Captured width + markup of the source card, painted as the drag ghost. */\n  width: number\n  html: string\n  offsetX: number\n  offsetY: number\n  x: number\n  y: number\n  over: DropTarget | null\n}\n\ninterface KanbanContextValue {\n  api: KanbanApi<KanbanCardData>\n  registerColumn: (columnId: string, el: HTMLElement | null) => void\n  registerCard: (cardId: string, el: HTMLElement | null) => void\n  startPointerDrag: (cardId: string, e: React.PointerEvent) => void\n  onCardKeyDown: (cardId: string, e: React.KeyboardEvent) => void\n  /** Column + index a pointer or keyboard move currently targets. */\n  dropTarget: DropTarget | null\n  /** Id of the card being pointer-dragged, if any. */\n  pointerCardId: string | null\n  /** Id of the card grabbed via keyboard, if any. */\n  grabbedId: string | null\n}\n\nconst KanbanContext = React.createContext<KanbanContextValue | null>(null)\n\nexport function useKanbanContext(): KanbanContextValue {\n  const ctx = React.useContext(KanbanContext)\n  if (!ctx)\n    throw new Error(\"Kanban components must be used within <KanbanProvider>.\")\n  return ctx\n}\n\nconst DRAG_THRESHOLD = 4\n\nexport interface KanbanProviderProps<TCard extends KanbanCardData>\n  extends UseKanbanOptions<TCard> {\n  children: React.ReactNode\n}\n\n/**\n * KanbanProvider — owns board state (via {@link useKanban}) and drives all\n * interaction: pointer drag-and-drop (with a floating ghost and a live drop\n * target) and full keyboard control (grab, arrow-move across and within\n * columns, drop, cancel) with polite screen-reader announcements. Every move\n * funnels through the same `moveCard`, so pointer and keyboard stay in lockstep.\n */\nexport function KanbanProvider<TCard extends KanbanCardData>({\n  children,\n  ...options\n}: KanbanProviderProps<TCard>) {\n  const api = useKanban<TCard>(options)\n  // Cast to the base card type for the (non-generic) context surface.\n  const baseApi = api as unknown as KanbanApi<KanbanCardData>\n\n  const columnEls = React.useRef(new Map<string, HTMLElement>())\n  const cardEls = React.useRef(new Map<string, HTMLElement>())\n\n  const [pointerDrag, setPointerDrag] = React.useState<PointerDragState | null>(\n    null\n  )\n  const [grabbed, setGrabbed] = React.useState<{\n    cardId: string\n    origin: { columnId: string; index: number }\n  } | null>(null)\n  const [announcement, setAnnouncement] = React.useState(\"\")\n\n  // Keep the mutable bits reachable from window-level pointer handlers.\n  const pointerRef = React.useRef<PointerDragState | null>(null)\n  pointerRef.current = pointerDrag\n\n  const registerColumn = React.useCallback(\n    (columnId: string, el: HTMLElement | null) => {\n      if (el) columnEls.current.set(columnId, el)\n      else columnEls.current.delete(columnId)\n    },\n    []\n  )\n\n  const registerCard = React.useCallback(\n    (cardId: string, el: HTMLElement | null) => {\n      if (el) cardEls.current.set(cardId, el)\n      else cardEls.current.delete(cardId)\n    },\n    []\n  )\n\n  const columnTitle = React.useCallback(\n    (columnId: string) =>\n      api.columns.find((c) => c.id === columnId)?.title ?? columnId,\n    [api.columns]\n  )\n\n  /** Hit-test the pointer against columns and their cards to find a drop target. */\n  const computeTarget = React.useCallback(\n    (x: number, y: number, draggingId: string): DropTarget | null => {\n      let columnId: string | null = null\n      for (const col of api.columns) {\n        const el = columnEls.current.get(col.id)\n        if (!el) continue\n        const r = el.getBoundingClientRect()\n        if (x >= r.left && x <= r.right) {\n          columnId = col.id\n          break\n        }\n      }\n      if (!columnId) return null\n\n      const ordered = api\n        .getCards(columnId)\n        .filter((c) => c.id !== draggingId)\n\n      let index = ordered.length\n      for (let i = 0; i < ordered.length; i++) {\n        const el = cardEls.current.get(ordered[i].id)\n        if (!el) continue\n        const r = el.getBoundingClientRect()\n        if (y < r.top + r.height / 2) {\n          index = i\n          break\n        }\n      }\n      return { columnId, index }\n    },\n    [api]\n  )\n\n  // --- Pointer drag lifecycle --------------------------------------------\n\n  const endPointerDrag = React.useCallback(\n    (commit: boolean) => {\n      const state = pointerRef.current\n      if (commit && state?.over) {\n        api.moveCard(state.cardId, state.over.columnId, state.over.index, \"pointer\")\n        setAnnouncement(\n          `Moved card to ${columnTitle(state.over.columnId)}, position ${\n            state.over.index + 1\n          }.`\n        )\n      }\n      pointerRef.current = null\n      setPointerDrag(null)\n      document.body.style.removeProperty(\"user-select\")\n      document.body.style.removeProperty(\"cursor\")\n    },\n    [api, columnTitle]\n  )\n\n  // Window pointer handlers read fresh logic through this ref, so the listeners\n  // attached on drag-start never capture stale closures.\n  const handlersRef = React.useRef({ endPointerDrag, computeTarget })\n  handlersRef.current = { endPointerDrag, computeTarget }\n\n  const onMove = React.useCallback((e: PointerEvent) => {\n    const state = pointerRef.current\n    if (!state) return\n    const over = handlersRef.current.computeTarget(e.clientX, e.clientY, state.cardId)\n    const next: PointerDragState = { ...state, x: e.clientX, y: e.clientY, over }\n    pointerRef.current = next\n    setPointerDrag(next)\n  }, [])\n\n  const startPointerDrag = React.useCallback(\n    (cardId: string, e: React.PointerEvent) => {\n      // Primary button / touch / pen only; ignore modified clicks.\n      if (e.button !== 0) return\n      const el = cardEls.current.get(cardId)\n      if (!el) return\n\n      const rect = el.getBoundingClientRect()\n      const startX = e.clientX\n      const startY = e.clientY\n      const offsetX = startX - rect.left\n      const offsetY = startY - rect.top\n      const width = rect.width\n\n      let activated = false\n\n      const move = (ev: PointerEvent) => {\n        if (!activated) {\n          if (\n            Math.abs(ev.clientX - startX) < DRAG_THRESHOLD &&\n            Math.abs(ev.clientY - startY) < DRAG_THRESHOLD\n          )\n            return\n          activated = true\n          const html = el.outerHTML\n          document.body.style.setProperty(\"user-select\", \"none\")\n          document.body.style.setProperty(\"cursor\", \"grabbing\")\n          const initial: PointerDragState = {\n            cardId,\n            width,\n            html,\n            offsetX,\n            offsetY,\n            x: ev.clientX,\n            y: ev.clientY,\n            over: handlersRef.current.computeTarget(ev.clientX, ev.clientY, cardId),\n          }\n          pointerRef.current = initial\n          setPointerDrag(initial)\n        }\n        onMove(ev)\n      }\n\n      const up = () => {\n        window.removeEventListener(\"pointermove\", move)\n        window.removeEventListener(\"pointerup\", up)\n        window.removeEventListener(\"keydown\", key)\n        if (activated) handlersRef.current.endPointerDrag(true)\n      }\n\n      const key = (ev: KeyboardEvent) => {\n        if (ev.key === \"Escape\" && activated) {\n          window.removeEventListener(\"pointermove\", move)\n          window.removeEventListener(\"pointerup\", up)\n          window.removeEventListener(\"keydown\", key)\n          handlersRef.current.endPointerDrag(false)\n        }\n      }\n\n      window.addEventListener(\"pointermove\", move)\n      window.addEventListener(\"pointerup\", up)\n      window.addEventListener(\"keydown\", key)\n    },\n    [onMove]\n  )\n\n  // --- Keyboard grab/move lifecycle --------------------------------------\n\n  const announcePosition = React.useCallback(\n    (cardId: string) => {\n      const card = api.getCard(cardId)\n      if (!card) return\n      const index = api.indexOf(cardId)\n      const count = api.getCards(card.columnId).length\n      setAnnouncement(\n        `${columnTitle(card.columnId)}, position ${index + 1} of ${count}.`\n      )\n    },\n    [api, columnTitle]\n  )\n\n  const refocus = React.useCallback((cardId: string) => {\n    requestAnimationFrame(() => cardEls.current.get(cardId)?.focus())\n  }, [])\n\n  const onCardKeyDown = React.useCallback(\n    (cardId: string, e: React.KeyboardEvent) => {\n      const isGrabbed = grabbed?.cardId === cardId\n\n      if (!isGrabbed) {\n        if (e.key === \" \" || e.key === \"Enter\") {\n          e.preventDefault()\n          const card = api.getCard(cardId)\n          if (!card) return\n          setGrabbed({\n            cardId,\n            origin: { columnId: card.columnId, index: api.indexOf(cardId) },\n          })\n          setAnnouncement(\n            `Picked up card. Use the arrow keys to move it, space to drop, escape to cancel.`\n          )\n        }\n        return\n      }\n\n      const card = api.getCard(cardId)\n      if (!card) return\n      const colIdx = api.columns.findIndex((c) => c.id === card.columnId)\n      const index = api.indexOf(cardId)\n\n      switch (e.key) {\n        case \"ArrowUp\": {\n          e.preventDefault()\n          if (index > 0) {\n            api.moveCard(cardId, card.columnId, index - 1, \"keyboard\")\n            announcePosition(cardId)\n            refocus(cardId)\n          }\n          break\n        }\n        case \"ArrowDown\": {\n          e.preventDefault()\n          api.moveCard(cardId, card.columnId, index + 1, \"keyboard\")\n          announcePosition(cardId)\n          refocus(cardId)\n          break\n        }\n        case \"ArrowLeft\": {\n          e.preventDefault()\n          const prev = api.columns[colIdx - 1]\n          if (prev) {\n            const target = Math.min(index, api.getCards(prev.id).length)\n            api.moveCard(cardId, prev.id, target, \"keyboard\")\n            announcePosition(cardId)\n            refocus(cardId)\n          }\n          break\n        }\n        case \"ArrowRight\": {\n          e.preventDefault()\n          const next = api.columns[colIdx + 1]\n          if (next) {\n            const target = Math.min(index, api.getCards(next.id).length)\n            api.moveCard(cardId, next.id, target, \"keyboard\")\n            announcePosition(cardId)\n            refocus(cardId)\n          }\n          break\n        }\n        case \" \":\n        case \"Enter\": {\n          e.preventDefault()\n          setGrabbed(null)\n          setAnnouncement(\n            `Dropped card in ${columnTitle(card.columnId)}, position ${index + 1}.`\n          )\n          break\n        }\n        case \"Escape\": {\n          e.preventDefault()\n          api.moveCard(\n            cardId,\n            grabbed.origin.columnId,\n            grabbed.origin.index,\n            \"keyboard\"\n          )\n          setGrabbed(null)\n          setAnnouncement(\"Movement cancelled. Card returned to its start.\")\n          refocus(cardId)\n          break\n        }\n        default:\n          break\n      }\n    },\n    [announcePosition, api, columnTitle, grabbed, refocus]\n  )\n\n  const dropTarget: DropTarget | null = pointerDrag?.over ?? null\n\n  const value = React.useMemo<KanbanContextValue>(\n    () => ({\n      api: baseApi,\n      registerColumn,\n      registerCard,\n      startPointerDrag,\n      onCardKeyDown,\n      dropTarget,\n      pointerCardId: pointerDrag?.cardId ?? null,\n      grabbedId: grabbed?.cardId ?? null,\n    }),\n    [\n      baseApi,\n      registerColumn,\n      registerCard,\n      startPointerDrag,\n      onCardKeyDown,\n      dropTarget,\n      pointerDrag?.cardId,\n      grabbed?.cardId,\n    ]\n  )\n\n  return (\n    <KanbanContext.Provider value={value}>\n      {children}\n      {/* Floating drag ghost — an exact clone of the source card. */}\n      {pointerDrag ? (\n        <div\n          aria-hidden\n          className=\"pointer-events-none fixed z-50 rotate-2 opacity-95 drop-shadow-xl\"\n          style={{\n            top: pointerDrag.y - pointerDrag.offsetY,\n            left: pointerDrag.x - pointerDrag.offsetX,\n            width: pointerDrag.width,\n          }}\n          dangerouslySetInnerHTML={{ __html: pointerDrag.html }}\n        />\n      ) : null}\n      {/* Polite live region for screen-reader move announcements. */}\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </KanbanContext.Provider>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/blocks/kanban/kanban-context.tsx"
    },
    {
      "path": "components/blocks/kanban/kanban-types.ts",
      "content": "/**\n * kanban-types — the shared contract for the kanban block.\n *\n * The board is generic over the card shape: consumers bring their own card type\n * (a deal, a task, an issue…) so long as it carries a stable `id` and the\n * `columnId` it currently lives in. The block owns nothing domain-specific — it\n * moves cards between columns and tells you about it via callbacks.\n */\n\n/** Colour-dot tone for a column header. Maps to a UUI utility ramp + badge variant. */\nexport type KanbanTone =\n  | \"neutral\"\n  | \"brand\"\n  | \"blue\"\n  | \"amber\"\n  | \"green\"\n  | \"red\"\n  | \"purple\"\n\n/** A column (lane) on the board. Columns are static structure — passed as a prop. */\nexport interface KanbanColumnData {\n  /** Stable, unique column id. Cards reference this via `columnId`. */\n  id: string\n  /** Human-readable lane title, shown in the header. */\n  title: string\n  /** Colour-dot tone shown beside the title. */\n  tone?: KanbanTone\n  /**\n   * Optional work-in-progress limit. When the card count exceeds it, the count\n   * badge turns to a warning and the column is flagged over-limit (advisory —\n   * the board never blocks a drop).\n   */\n  wipLimit?: number\n}\n\n/**\n * The minimum a card must expose. Everything else is yours — the board treats\n * cards opaquely and hands the whole object back to your render prop.\n */\nexport interface KanbanCardData {\n  /** Stable, unique card id. */\n  id: string\n  /** Id of the column this card currently belongs to. */\n  columnId: string\n}\n\n/** How a move was initiated — surfaced on the change/move callbacks. */\nexport type KanbanMoveMode = \"pointer\" | \"keyboard\"\n\n/** Emitted whenever a card changes column and/or position. */\nexport interface KanbanMoveEvent<TCard extends KanbanCardData = KanbanCardData> {\n  /** The card that moved (already updated with its new `columnId`). */\n  card: TCard\n  from: { columnId: string; index: number }\n  to: { columnId: string; index: number }\n  mode: KanbanMoveMode\n}\n",
      "type": "registry:component",
      "target": "components/blocks/kanban/kanban-types.ts"
    },
    {
      "path": "components/blocks/kanban/use-kanban.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport type {\n  KanbanCardData,\n  KanbanColumnData,\n  KanbanMoveEvent,\n  KanbanMoveMode,\n} from \"./kanban-types\"\n\nexport interface UseKanbanOptions<TCard extends KanbanCardData> {\n  /** The columns (lanes), in display order. Static structure. */\n  columns: KanbanColumnData[]\n  /** Controlled card collection. Provide with `onValueChange` for a controlled board. */\n  value?: TCard[]\n  /** Initial cards for an uncontrolled board. */\n  defaultValue?: TCard[]\n  /** Called with the next card collection whenever a card moves. */\n  onValueChange?: (cards: TCard[], event: KanbanMoveEvent<TCard>) => void\n  /** Called after a move with details of what moved (both controlled and uncontrolled). */\n  onMove?: (event: KanbanMoveEvent<TCard>) => void\n}\n\nexport interface KanbanApi<TCard extends KanbanCardData> {\n  columns: KanbanColumnData[]\n  cards: TCard[]\n  /** Cards belonging to a column, in order. */\n  getCards: (columnId: string) => TCard[]\n  /** Index of a card within its own column (or -1). */\n  indexOf: (cardId: string) => number\n  /** Look a card up by id. */\n  getCard: (cardId: string) => TCard | undefined\n  /**\n   * Move a card into `toColumnId` at `toIndex` (clamped). No-op if it would not\n   * change anything. Fires `onValueChange` + `onMove`.\n   */\n  moveCard: (\n    cardId: string,\n    toColumnId: string,\n    toIndex: number,\n    mode?: KanbanMoveMode\n  ) => void\n}\n\n/** Regroup the flat card array so each column's cards are contiguous and ordered. */\nfunction reassign<TCard extends KanbanCardData>(\n  cards: TCard[],\n  columns: KanbanColumnData[],\n  cardId: string,\n  toColumnId: string,\n  toIndex: number\n): { next: TCard[]; moved: TCard } | null {\n  const moving = cards.find((c) => c.id === cardId)\n  if (!moving) return null\n\n  const groups = new Map<string, TCard[]>()\n  for (const col of columns) groups.set(col.id, [])\n  const orphans: TCard[] = []\n\n  for (const c of cards) {\n    if (c.id === cardId) continue\n    const bucket = groups.get(c.columnId)\n    if (bucket) bucket.push(c)\n    else orphans.push(c)\n  }\n\n  const target = groups.get(toColumnId)\n  if (!target) return null\n\n  const moved = { ...moving, columnId: toColumnId } as TCard\n  const clamped = Math.max(0, Math.min(toIndex, target.length))\n  target.splice(clamped, 0, moved)\n\n  const next: TCard[] = []\n  for (const col of columns) next.push(...(groups.get(col.id) ?? []))\n  next.push(...orphans)\n\n  return { next, moved }\n}\n\n/**\n * useKanban — the headless engine behind the board. Owns the card collection\n * (controlled via `value`/`onValueChange`, or uncontrolled via `defaultValue`),\n * groups cards by column, and exposes a single `moveCard` mutation that every\n * interaction — pointer drag and keyboard — funnels through.\n */\nexport function useKanban<TCard extends KanbanCardData>({\n  columns,\n  value,\n  defaultValue,\n  onValueChange,\n  onMove,\n}: UseKanbanOptions<TCard>): KanbanApi<TCard> {\n  const isControlled = value !== undefined\n  const [uncontrolled, setUncontrolled] = React.useState<TCard[]>(\n    defaultValue ?? []\n  )\n  const cards = isControlled ? (value as TCard[]) : uncontrolled\n\n  // Keep the latest values in a ref so the stable callbacks below never go stale.\n  const latest = React.useRef({ cards, columns, onValueChange, onMove, isControlled })\n  latest.current = { cards, columns, onValueChange, onMove, isControlled }\n\n  const getCards = React.useCallback(\n    (columnId: string) => latest.current.cards.filter((c) => c.columnId === columnId),\n    []\n  )\n\n  const getCard = React.useCallback(\n    (cardId: string) => latest.current.cards.find((c) => c.id === cardId),\n    []\n  )\n\n  const indexOf = React.useCallback((cardId: string) => {\n    const card = latest.current.cards.find((c) => c.id === cardId)\n    if (!card) return -1\n    return latest.current.cards\n      .filter((c) => c.columnId === card.columnId)\n      .findIndex((c) => c.id === cardId)\n  }, [])\n\n  const moveCard = React.useCallback<KanbanApi<TCard>[\"moveCard\"]>(\n    (cardId, toColumnId, toIndex, mode = \"pointer\") => {\n      const {\n        cards: current,\n        columns: cols,\n        onValueChange: onChange,\n        onMove: onMoveCb,\n        isControlled: controlled,\n      } = latest.current\n\n      const card = current.find((c) => c.id === cardId)\n      if (!card) return\n\n      const fromColumnId = card.columnId\n      const fromIndex = current\n        .filter((c) => c.columnId === fromColumnId)\n        .findIndex((c) => c.id === cardId)\n\n      const result = reassign(current, cols, cardId, toColumnId, toIndex)\n      if (!result) return\n\n      const newIndex = result.next\n        .filter((c) => c.columnId === toColumnId)\n        .findIndex((c) => c.id === cardId)\n\n      // Nothing actually changed — skip the callback churn.\n      if (fromColumnId === toColumnId && fromIndex === newIndex) return\n\n      const event: KanbanMoveEvent<TCard> = {\n        card: result.moved,\n        from: { columnId: fromColumnId, index: fromIndex },\n        to: { columnId: toColumnId, index: newIndex },\n        mode,\n      }\n\n      if (!controlled) setUncontrolled(result.next)\n      onChange?.(result.next, event)\n      onMoveCb?.(event)\n    },\n    []\n  )\n\n  return { columns, cards, getCards, indexOf, getCard, moveCard }\n}\n",
      "type": "registry:component",
      "target": "components/blocks/kanban/use-kanban.ts"
    }
  ],
  "type": "registry:block"
}