{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban",
  "title": "Kanban",
  "description": "Kanban from the Get IT Done design system.",
  "dependencies": [
    "@dnd-kit/core@6.3.1",
    "lucide-react@^0.379.0"
  ],
  "registryDependencies": [
    "https://zero-design.itdone.de/r/data-state-panel.json",
    "https://zero-design.itdone.de/r/heading-tag.json",
    "https://zero-design.itdone.de/r/labels-context.json",
    "https://zero-design.itdone.de/r/recipes.json",
    "https://zero-design.itdone.de/r/utils.json",
    "https://zero-design.itdone.de/r/theme.json"
  ],
  "files": [
    {
      "path": "registry/ui/kanban.tsx",
      "content": "'use client';\n\nimport {\n  DndContext,\n  DragOverlay,\n  PointerSensor,\n  TouchSensor,\n  useDraggable,\n  useSensor,\n  useSensors,\n  type DragEndEvent,\n  type DragMoveEvent,\n  type DragStartEvent,\n  type PointerSensorOptions,\n} from '@dnd-kit/core';\nimport { Plus } from 'lucide-react';\nimport {\n  Children,\n  createContext,\n  Fragment,\n  forwardRef,\n  isValidElement,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type ReactElement,\n  type ReactNode,\n} from 'react';\n\nimport { focusRing, transitionBase } from '@/lib/recipes';\nimport { cn } from '@/lib/utils';\nimport type { HeadingTag } from '@/components/ui/heading-tag';\nimport { DataStatePanel, SkeletonBar } from '@/components/ui/data-state-panel';\nimport { useLabels, usePreset } from '@/components/ui/labels-context';\n\nexport interface KanbanMove {\n  cardId: string;\n  fromColumnId: string;\n  toColumnId: string;\n  // Insertion index in the target column, counted without the moved card.\n  index: number;\n}\n\ninterface DropTarget {\n  columnId: string;\n  index: number;\n}\n\ntype KanbanColumnWidth = 'fixed' | 'fluid';\n\ninterface KanbanContextValue {\n  columnWidth: KanbanColumnWidth;\n  movable: boolean;\n  grabbedId: string | null;\n  target: DropTarget | null;\n  setTarget: (target: DropTarget | null) => void;\n  grab: (id: string) => void;\n  commit: () => void;\n  cancel: () => void;\n  getBoard: () => HTMLElement | null;\n  registerFace: (id: string, face: ReactNode) => void;\n  // A move made outside the board's own drag and keyboard model (the \"Move to\" menu): announces it and returns focus to the moved card.\n  noteMove: (cardId: string, columnLabel: string) => void;\n}\n\nconst KanbanContext = createContext<KanbanContextValue | null>(null);\n\nconst useKanban = () => {\n  const ctx = useContext(KanbanContext);\n  if (!ctx) throw new Error('KanbanColumn must be rendered inside <Kanban>');\n  return ctx;\n};\n\n// Inert context for a KanbanCard rendered outside a Kanban: not draggable, nothing to grab.\nconst standaloneKanban: KanbanContextValue = {\n  columnWidth: 'fixed',\n  movable: false,\n  grabbedId: null,\n  target: null,\n  setTarget: () => undefined,\n  grab: () => undefined,\n  commit: () => undefined,\n  cancel: () => undefined,\n  getBoard: () => null,\n  registerFace: () => undefined,\n  noteMove: () => undefined,\n};\n\n// For KanbanMoveToMenu: reports a menu move to the surrounding Kanban. A no-op outside a Kanban.\nexport const useKanbanMoveNotice = () => useContext(KanbanContext)?.noteMove ?? standaloneKanban.noteMove;\n\n// Mouse and pen start after a small movement, touch needs a long press so scrolling keeps working.\nexport const KANBAN_POINTER_ACTIVATION = { distance: 4 } as const;\n// 12px tolerance: a finger landing on a board that is still coasting drifts a few pixels before the 250ms press completes.\nexport const KANBAN_TOUCH_ACTIVATION = { delay: 250, tolerance: 12 } as const;\n\n// While a finger is down the board must not run scroll snap animations (they keep the scroller moving under the long press); the flag is a DOM attribute so no re-render happens mid gesture.\nconst suspendSnapWhileTouching = (board: HTMLElement) => {\n  board.dataset.touching = 'true';\n  const release = () => {\n    delete board.dataset.touching;\n    document.removeEventListener('pointerup', release, true);\n    document.removeEventListener('pointercancel', release, true);\n    document.removeEventListener('touchend', release, true);\n    document.removeEventListener('touchcancel', release, true);\n  };\n  document.addEventListener('pointerup', release, true);\n  document.addEventListener('pointercancel', release, true);\n  document.addEventListener('touchend', release, true);\n  document.addEventListener('touchcancel', release, true);\n};\n\n// Auto-scroll stays off after a drag starts until the pointer has travelled this many pixels from where it was pressed. Without it a long press that lands in the 15% edge zone scrolls the board away under the finger before the user has moved at all; a deliberate drag crosses the distance within a few frames.\nexport const KANBAN_AUTOSCROLL_ARM_DISTANCE = 16;\n\n// Edge zone (share of the board) and speed of the auto-scroll. Slower than the dnd-kit default so a narrow viewport does not run past the target column.\nexport const KANBAN_AUTOSCROLL = { acceleration: 4, threshold: { x: 0.15, y: 0.15 } } as const;\n// The autoScroll prop of a DndContext for a board that scrolls sideways. Pass it only after the pointer travelled KANBAN_AUTOSCROLL_ARM_DISTANCE (use `false` before), see the Kanban story.\nexport const kanbanAutoScroll = KANBAN_AUTOSCROLL;\n\n// How long the board waits for a moved card to appear (async moves) before it gives up on moving focus.\nexport const KANBAN_FOCUS_WAIT_MS = 5000;\n\n// How long the board keeps watching a moved card for a rollback (the parent puts it back after a failed save). Past this the move counts as accepted.\nexport const KANBAN_REVERT_WATCH_MS = 15000;\n\n// Pointer sensor for mouse and pen. Touch is handled by the long-press TouchSensor.\nclass KanbanPointerSensor extends PointerSensor {\n  static activators = [\n    {\n      eventName: 'onPointerDown' as const,\n      handler: (event: { nativeEvent: PointerEvent }, options: PointerSensorOptions) => {\n        if (event.nativeEvent.pointerType === 'touch') return false;\n        return PointerSensor.activators[0].handler(event as never, options as never);\n      },\n    },\n  ];\n}\n\n// The drag sensors of the Kanban board: mouse and pen after 4px (never touch), touch after a 250ms long press. The built-in keyboard model is not a dnd-kit sensor.\n// For custom cards on dnd-kit: `<DndContext sensors={useKanbanSensors()} autoScroll={kanbanAutoScroll}>`. Must be called in a component.\nexport const useKanbanSensors = () =>\n  useSensors(\n    useSensor(KanbanPointerSensor, { activationConstraint: KANBAN_POINTER_ACTIVATION }),\n    useSensor(TouchSensor, { activationConstraint: KANBAN_TOUCH_ACTIVATION }),\n  );\n\nconst columnElements = (board: HTMLElement) => Array.from(board.querySelectorAll<HTMLElement>('[data-kanban-column]'));\nconst cardElements = (column: HTMLElement) => Array.from(column.querySelectorAll<HTMLElement>('[data-kanban-card]'));\n\n// Maps a viewport point to a column and an insertion index, null when the point is outside the board.\nexport const resolveKanbanTarget = (board: HTMLElement, point: { x: number; y: number }, grabbedId: string | null): DropTarget | null => {\n  const rect = board.getBoundingClientRect();\n  if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) return null;\n  const columns = columnElements(board);\n  if (columns.length === 0) return null;\n  const distance = (el: HTMLElement) => {\n    const r = el.getBoundingClientRect();\n    return point.x < r.left ? r.left - point.x : point.x > r.right ? point.x - r.right : 0;\n  };\n  const column = columns.reduce((best, el) => (distance(el) < distance(best) ? el : best), columns[0]);\n  const items = cardElements(column).filter((el) => el.dataset.cardId !== grabbedId);\n  const at = items.findIndex((el) => {\n    const r = el.getBoundingClientRect();\n    return point.y < r.top + r.height / 2;\n  });\n  return { columnId: column.dataset.columnId ?? '', index: at === -1 ? items.length : at };\n};\n\nconst pointerOf = (event: Event | null | undefined): { x: number; y: number } | null => {\n  if (!event) return null;\n  const touch = (event as TouchEvent).touches?.[0] ?? (event as TouchEvent).changedTouches?.[0];\n  const source = touch ?? (event as MouseEvent);\n  return typeof source.clientX === 'number' ? { x: source.clientX, y: source.clientY } : null;\n};\n\n// Fluid: flex 1 1 0 with a floor, the `kanban-column-min` size token (208px, tokens 5.0.1).\nconst columnWidthClass = (width: KanbanColumnWidth, grow: boolean) =>\n  width === 'fluid'\n    ? 'min-w-kanban-column-min flex-1 basis-kanban-column-min'\n    : cn('w-kanban-column min-w-kanban-column shrink-0', grow && 'grow');\n\nconst useReducedMotion = () => {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    if (typeof window === 'undefined' || !window.matchMedia) return;\n    const query = window.matchMedia('(prefers-reduced-motion: reduce)');\n    setReduced(query.matches);\n    const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);\n    query.addEventListener?.('change', onChange);\n    return () => query.removeEventListener?.('change', onChange);\n  }, []);\n  return reduced;\n};\n\nexport interface KanbanProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> {\n  // Accessible name of the board.\n  'aria-label': string;\n  state?: 'default' | 'loading' | 'error';\n  // Enables drag and the keyboard move model. Called once on drop, the parent owns card order.\n  onMove?: (move: KanbanMove) => void;\n  onRetry?: () => void;\n  errorTitle?: string;\n  errorDescription?: string;\n  loadingLabel?: string;\n  // Number of skeleton columns while loading.\n  loadingColumns?: number;\n  // `fluid` columns share the board width and shrink down to the column minimum before the board scrolls. `fixed` keeps the token column width and always scrolls. Default `fluid`, `fixed` in the zero preset.\n  columnWidth?: KanbanColumnWidth;\n  // Fades the edge(s) of the board that can still scroll, so cut-off columns read as scrollable. Only visible while the board overflows.\n  overflowHint?: boolean;\n}\n\nconst Kanban = forwardRef<HTMLDivElement, KanbanProps>(\n  (\n    {\n      className,\n      children,\n      state = 'default',\n      onMove,\n      onRetry,\n      errorTitle: errorTitleProp,\n      errorDescription: errorDescriptionProp,\n      loadingLabel: loadingLabelProp,\n      loadingColumns = 4,\n      columnWidth: columnWidthProp,\n      overflowHint = false,\n      ...props\n    },\n    ref,\n  ) => {\n    const l = useLabels();\n    const zeroPreset = usePreset() === 'zero';\n    const columnWidth: KanbanColumnWidth = columnWidthProp ?? (zeroPreset ? 'fixed' : 'fluid');\n    const errorTitle = errorTitleProp ?? l.kanbanErrorTitle;\n    const errorDescription = errorDescriptionProp ?? l.kanbanErrorDescription;\n    const loadingLabel = loadingLabelProp ?? l.kanbanLoading;\n    const boardRef = useRef<HTMLDivElement | null>(null);\n    const [grabbedId, setGrabbedId] = useState<string | null>(null);\n    const [target, setTarget] = useState<DropTarget | null>(null);\n    const [announcement, setAnnouncement] = useState('');\n    // fromColumnId: the card is only focused once it left this column (async moves keep the old card on the board until the server answers).\n    const [focusAfterMove, setFocusAfterMove] = useState<{ id: string; fromColumnId?: string } | null>(null);\n    // Where a card came from. When the card leaves this position and later returns to it, the move was rolled back and the card gets its focus back.\n    // node: the card's handle at the time of the move. When it was replaced by a new node at the same position without the card ever leaving, the parent remounted the board (confirm-then-move: a loading state or refetch around a rejected save) and the focus it held is gone.\n    const [revertWatch, setRevertWatch] = useState<{ id: string; columnId: string; index: number; nonce: number; node: HTMLElement | null } | null>(null);\n    const leftOrigin = useRef(false);\n    // A drag or a keyboard grab is running: no pending refocus may move focus or scroll the board then.\n    const interactingRef = useRef(false);\n    const movable = Boolean(onMove);\n    const dndId = useId();\n    const reducedMotion = useReducedMotion();\n    const targetRef = useRef<DropTarget | null>(null);\n    // Touch lists are emptied after the event, so the press point is stored when it happens.\n    const pointerRef = useRef<{ x: number; y: number } | null>(null);\n    const faces = useRef(new Map<string, ReactNode>());\n    const [dragId, setDragId] = useState<string | null>(null);\n    interactingRef.current = Boolean(dragId || grabbedId);\n    // Auto-scroll is armed once the pointer moved KANBAN_AUTOSCROLL_ARM_DISTANCE after the drag started.\n    const [scrollArmed, setScrollArmed] = useState(false);\n    const dragOriginRef = useRef<{ x: number; y: number } | null>(null);\n    // Detached node: the own live region announces moves, so the dnd-kit one is kept out of the document.\n    const [dndContainer] = useState<HTMLElement | undefined>(() => (typeof document === 'undefined' ? undefined : document.createElement('div')));\n    const sensors = useKanbanSensors();\n\n    const setRefs = (node: HTMLDivElement | null) => {\n      boardRef.current = node;\n      if (typeof ref === 'function') ref(node);\n      else if (ref) (ref as { current: HTMLDivElement | null }).current = node;\n    };\n\n    const findCard = useCallback((id: string) => {\n      const board = boardRef.current;\n      if (!board) return null;\n      const el = board.querySelector<HTMLElement>(`[data-kanban-card][data-card-id=\"${CSS.escape(id)}\"]`);\n      const column = el?.closest<HTMLElement>('[data-kanban-column]');\n      if (!el || !column) return null;\n      const siblings = cardElements(column).filter((c) => c !== el);\n      return { el, column, columnId: column.dataset.columnId ?? '', index: cardElements(column).indexOf(el), others: siblings };\n    }, []);\n\n    const describe = useCallback((t: DropTarget) => {\n      const board = boardRef.current;\n      if (!board) return '';\n      const column = columnElements(board).find((c) => c.dataset.columnId === t.columnId);\n      return column ? l.kanbanPosition(column.getAttribute('aria-label') ?? t.columnId, t.index + 1) : '';\n    }, [l]);\n\n    const grab = useCallback(\n      (id: string) => {\n        const info = findCard(id);\n        if (!info) return;\n        setGrabbedId(id);\n        const initial = { columnId: info.columnId, index: info.index };\n        targetRef.current = initial;\n        setTarget(initial);\n        setAnnouncement(l.kanbanPickedUp(info.el.dataset.cardTitle, describe(initial)));\n      },\n      [describe, findCard, l],\n    );\n\n    const cancel = useCallback(() => {\n      if (grabbedId) setAnnouncement(l.kanbanMoveCancelled);\n      setFocusAfterMove(grabbedId ? { id: grabbedId } : null);\n      setGrabbedId(null);\n      targetRef.current = null;\n      setTarget(null);\n    }, [grabbedId, l]);\n\n    const finish = useCallback(\n      (id: string, next: DropTarget) => {\n        const info = findCard(id);\n        if (info && onMove) {\n          const unchanged = info.columnId === next.columnId && info.index === next.index;\n          if (!unchanged) {\n            leftOrigin.current = false;\n            setRevertWatch({ id, columnId: info.columnId, index: info.index, nonce: Date.now(), node: info.el.querySelector<HTMLElement>('[data-kanban-handle]') });\n            onMove({ cardId: id, fromColumnId: info.columnId, toColumnId: next.columnId, index: next.index });\n            setAnnouncement(l.kanbanDropped(info.el.dataset.cardTitle, describe(next)));\n          } else {\n            setAnnouncement(l.kanbanDroppedInPlace);\n          }\n        }\n        setFocusAfterMove({ id, fromColumnId: info && info.columnId !== next.columnId ? info.columnId : undefined });\n        setGrabbedId(null);\n        targetRef.current = null;\n        setTarget(null);\n      },\n      [describe, findCard, onMove, l],\n    );\n\n    const commit = useCallback(() => {\n      if (grabbedId && target) finish(grabbedId, target);\n    }, [finish, grabbedId, target]);\n\n    const updateTarget = useCallback(\n      (next: DropTarget | null) => {\n        const prev = targetRef.current;\n        if (prev?.columnId === next?.columnId && prev?.index === next?.index) return;\n        targetRef.current = next;\n        setTarget(next);\n        if (next && grabbedId) setAnnouncement(describe(next));\n      },\n      [describe, grabbedId],\n    );\n\n    const registerFace = useCallback((id: string, face: ReactNode) => {\n      faces.current.set(id, face);\n    }, []);\n\n    const noteMove = useCallback(\n      (cardId: string, columnLabel: string) => {\n        setAnnouncement(l.kanbanMovedTo(columnLabel));\n        const origin = findCard(cardId);\n        leftOrigin.current = false;\n        if (origin) setRevertWatch({ id: cardId, columnId: origin.columnId, index: origin.index, nonce: Date.now(), node: origin.el.querySelector<HTMLElement>('[data-kanban-handle]') });\n        setFocusAfterMove({ id: cardId, fromColumnId: origin?.columnId });\n      },\n      [l, findCard],\n    );\n\n    // The drag delta includes the auto-scroll offset, so the live pointer position is tracked directly.\n    useEffect(() => {\n      if (!dragId) return;\n      const track = (event: Event) => {\n        const point = pointerOf(event);\n        if (!point) return;\n        pointerRef.current = point;\n        const origin = dragOriginRef.current;\n        if (origin && Math.hypot(point.x - origin.x, point.y - origin.y) >= KANBAN_AUTOSCROLL_ARM_DISTANCE) {\n          dragOriginRef.current = null;\n          setScrollArmed(true);\n        }\n      };\n      document.addEventListener('pointermove', track, { capture: true, passive: true });\n      document.addEventListener('touchmove', track, { capture: true, passive: true });\n      return () => {\n        document.removeEventListener('pointermove', track, { capture: true });\n        document.removeEventListener('touchmove', track, { capture: true });\n      };\n    }, [dragId]);\n\n    const pointerTarget = (event: DragMoveEvent) => {\n      const point = pointerRef.current ?? pointerOf(event.activatorEvent);\n      const board = boardRef.current;\n      if (!point || !board) return null;\n      return resolveKanbanTarget(board, point, String(event.active.id));\n    };\n\n    const handleDragStart = (event: DragStartEvent) => {\n      const id = String(event.active.id);\n      setDragId(id);\n      dragOriginRef.current = pointerOf(event.activatorEvent);\n      setScrollArmed(false);\n      grab(id);\n    };\n\n    const handleDragMove = (event: DragMoveEvent) => {\n      if (grabbedId !== String(event.active.id)) return;\n      updateTarget(pointerTarget(event));\n    };\n\n    const handleDragEnd = (event: DragEndEvent) => {\n      const id = String(event.active.id);\n      const next = pointerTarget(event as DragMoveEvent) ?? targetRef.current;\n      setDragId(null);\n      if (next) finish(id, next);\n      else cancel();\n    };\n\n    const handleDragCancel = () => {\n      setDragId(null);\n      cancel();\n    };\n\n    useEffect(() => {\n      if (!focusAfterMove) return undefined;\n      const board = boardRef.current;\n      // Focus moves to the card only while it is on the body or inside the board; focus the user moved elsewhere stays.\n      const mayTakeFocus = () => {\n        const active = document.activeElement;\n        return !active || active === document.body || Boolean(board?.contains(active));\n      };\n      // A new interaction (drag, long press timer, finger down) owns focus and scroll; the refocus waits or gives up.\n      const busy = () => interactingRef.current || Boolean(board?.dataset.touching);\n      const tryFocus = () => {\n        if (busy()) return false;\n        const info = findCard(focusAfterMove.id);\n        const handle = info?.el.querySelector<HTMLElement>('[data-kanban-handle]');\n        if (focusAfterMove.fromColumnId && info?.columnId === focusAfterMove.fromColumnId) {\n          // The card has not left its column: the move is pending or was rejected (confirm-then-move). Focus that fell to the page after the drop (touch and pointer drags, a dragged node that unmounted) goes back to the card; a pending move still focuses it in the new column later.\n          const active = document.activeElement;\n          if (handle && (!active || active === document.body)) handle.focus({ preventScroll: true });\n          return false;\n        }\n        if (!handle) return false;\n        if (mayTakeFocus()) handle.focus();\n        setFocusAfterMove(null);\n        return true;\n      };\n      if (tryFocus()) return undefined;\n      // The parent may update the board only after a server response: watch the board until the moved card appears, but not forever.\n      const observer = new MutationObserver(() => {\n        if (tryFocus()) observer.disconnect();\n      });\n      if (board) observer.observe(board, { childList: true, subtree: true });\n      const timer = setTimeout(() => setFocusAfterMove(null), KANBAN_FOCUS_WAIT_MS);\n      return () => {\n        observer.disconnect();\n        clearTimeout(timer);\n      };\n    }, [focusAfterMove, findCard, children]);\n\n    // Rollback: a move that failed on the server puts the card back, which remounts it and drops focus to the page. Once the card is back at its original position, focus returns to it.\n    useEffect(() => {\n      if (!revertWatch) return undefined;\n      const board = boardRef.current;\n      const atOrigin = () => {\n        const info = findCard(revertWatch.id);\n        return info ? info.columnId === revertWatch.columnId && info.index === revertWatch.index : null;\n      };\n      const check = () => {\n        if (interactingRef.current || board?.dataset.touching) return false;\n        const here = atOrigin();\n        if (here === null) return false;\n        if (!here) {\n          leftOrigin.current = true;\n          return false;\n        }\n        // Still or again at the origin. Without a departure only a remounted handle counts: the card never moved (rejected drop) but the node that held focus is gone.\n        if (!leftOrigin.current && (!revertWatch.node || revertWatch.node.isConnected)) return false;\n        const active = document.activeElement;\n        const handle = findCard(revertWatch.id)?.el.querySelector<HTMLElement>('[data-kanban-handle]');\n        if (handle && (!active || active === document.body || Boolean(board?.contains(active)))) {\n          handle.focus({ preventScroll: true });\n          setAnnouncement(l.kanbanMoveReverted(handle.closest<HTMLElement>('[data-kanban-card]')?.dataset.cardTitle ?? ''));\n        }\n        setRevertWatch(null);\n        return true;\n      };\n      if (check()) return undefined;\n      const observer = new MutationObserver(() => {\n        if (check()) observer.disconnect();\n      });\n      if (board) observer.observe(board, { childList: true, subtree: true });\n      const timer = setTimeout(() => setRevertWatch(null), KANBAN_REVERT_WATCH_MS);\n      return () => {\n        observer.disconnect();\n        clearTimeout(timer);\n      };\n    }, [revertWatch, findCard, l]);\n\n    // A pending refocus (rejected or reverted move) belongs to the interaction that started it. Any new interaction disarms it: a press, a touch or a key on another card, or focus that lands somewhere else.\n    const pendingId = focusAfterMove?.id ?? revertWatch?.id ?? null;\n    const armed = Boolean(focusAfterMove || revertWatch);\n    useEffect(() => {\n      if (!armed) return undefined;\n      const insidePending = (target: EventTarget | null) => {\n        const card = target instanceof Element ? target.closest<HTMLElement>('[data-kanban-card]') : null;\n        return Boolean(card && card.dataset.cardId === pendingId);\n      };\n      const disarm = () => {\n        setFocusAfterMove(null);\n        setRevertWatch(null);\n      };\n      const onPress = () => disarm();\n      const onKey = (event: Event) => {\n        if (!insidePending(event.target)) disarm();\n      };\n      const onFocusIn = (event: Event) => {\n        const target = event.target;\n        if (target === document.body || target === document.documentElement || insidePending(target)) return;\n        disarm();\n      };\n      document.addEventListener('pointerdown', onPress, true);\n      document.addEventListener('touchstart', onPress, true);\n      document.addEventListener('keydown', onKey, true);\n      document.addEventListener('focusin', onFocusIn, true);\n      return () => {\n        document.removeEventListener('pointerdown', onPress, true);\n        document.removeEventListener('touchstart', onPress, true);\n        document.removeEventListener('keydown', onKey, true);\n        document.removeEventListener('focusin', onFocusIn, true);\n      };\n    }, [armed, pendingId]);\n\n    // A board that scrolls sideways but holds nothing focusable (empty columns) must still be reachable by keyboard (WCAG 2.1.1).\n    const [needsTabStop, setNeedsTabStop] = useState(false);\n    useEffect(() => {\n      const board = boardRef.current;\n      if (!board) return undefined;\n      const measure = () => {\n        const scrollable = board.scrollWidth - board.clientWidth > 1;\n        const focusable = board.querySelector('button, a[href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');\n        setNeedsTabStop(scrollable && !focusable);\n      };\n      measure();\n      const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);\n      observer?.observe(board);\n      Array.from(board.children).forEach((child) => observer?.observe(child));\n      return () => observer?.disconnect();\n    });\n\n    // Which sides of the board can still scroll, for the overflow hint.\n    const [overflowSides, setOverflowSides] = useState({ start: false, end: false });\n    useEffect(() => {\n      const board = boardRef.current;\n      if (!overflowHint || !board) return;\n      const measure = () => {\n        const max = board.scrollWidth - board.clientWidth;\n        const start = max > 1 && board.scrollLeft > 1;\n        const end = max > 1 && board.scrollLeft < max - 1;\n        setOverflowSides((prev) => (prev.start === start && prev.end === end ? prev : { start, end }));\n      };\n      measure();\n      board.addEventListener('scroll', measure, { passive: true });\n      const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);\n      observer?.observe(board);\n      Array.from(board.children).forEach((child) => observer?.observe(child));\n      return () => {\n        board.removeEventListener('scroll', measure);\n        observer?.disconnect();\n      };\n    }, [overflowHint, state, children]);\n\n    const value = useMemo<KanbanContextValue>(\n      () => ({\n        columnWidth,\n        movable,\n        grabbedId,\n        target,\n        setTarget: updateTarget,\n        grab,\n        commit,\n        cancel,\n        getBoard: () => boardRef.current,\n        registerFace,\n        noteMove,\n      }),\n      [columnWidth, movable, grabbedId, target, updateTarget, grab, commit, cancel, registerFace, noteMove],\n    );\n\n    if (state === 'loading') {\n      return (\n        <div\n          ref={setRefs}\n          className={cn('flex min-w-0 max-w-full snap-x gap-3 overflow-x-auto overscroll-x-contain pb-1', className)}\n          {...props}\n          role=\"status\"\n          aria-label={loadingLabel}\n          aria-busy=\"true\"\n        >\n          {Array.from({ length: loadingColumns }).map((_, i) => (\n            <div key={i} className={cn('flex flex-col gap-2 rounded-surface bg-muted p-2', columnWidthClass(columnWidth, true))}>\n              <SkeletonBar className=\"mx-1 my-1 h-3 w-1/3\" />\n              {[0, 1].map((c) => (\n                <div key={c} className=\"flex flex-col gap-2 rounded-surface border border-solid border-hairline bg-card p-3\">\n                  <SkeletonBar className=\"w-2/3\" />\n                  <SkeletonBar className=\"w-1/2\" />\n                </div>\n              ))}\n            </div>\n          ))}\n        </div>\n      );\n    }\n\n    if (state === 'error') {\n      return (\n        <div ref={setRefs} className={className} {...props}>\n          <DataStatePanel kind=\"error\" outlined title={errorTitle} description={errorDescription} onRetry={onRetry} />\n        </div>\n      );\n    }\n\n    return (\n      <KanbanContext.Provider value={value}>\n        <DndContext\n          id={dndId}\n          sensors={movable ? sensors : []}\n          autoScroll={movable && scrollArmed ? kanbanAutoScroll : false}\n          accessibility={{\n            container: dndContainer,\n            announcements: {\n              onDragStart: () => undefined,\n              onDragOver: () => undefined,\n              onDragEnd: () => undefined,\n              onDragCancel: () => undefined,\n            },\n            screenReaderInstructions: { draggable: '' },\n          }}\n          onDragStart={handleDragStart}\n          onDragMove={handleDragMove}\n          onDragEnd={handleDragEnd}\n          onDragCancel={handleDragCancel}\n        >\n          <div\n            ref={setRefs}\n            role=\"group\"\n            tabIndex={needsTabStop ? 0 : undefined}\n            data-overflow-start={overflowHint && overflowSides.start ? 'true' : undefined}\n            data-overflow-end={overflowHint && overflowSides.end ? 'true' : undefined}\n            data-autoscroll={dragId ? (scrollArmed ? 'armed' : 'pending') : undefined}\n            className={cn(\n              'relative flex min-w-0 max-w-full snap-x gap-3 overflow-x-auto overscroll-x-contain pb-1 data-[touching=true]:snap-none',\n              // Auto scroll while dragging and scroll snap fight each other.\n              dragId && 'snap-none',\n              // Edge fade: a mask on the scroll container, so it works on any surface colour. The width is the spacing-8 step.\n              overflowHint && '[--kanban-fade:theme(spacing.8)] data-[overflow-end=true]:[mask-image:linear-gradient(to_right,#000_calc(100%_-_var(--kanban-fade)),transparent)] data-[overflow-start=true]:[mask-image:linear-gradient(to_left,#000_calc(100%_-_var(--kanban-fade)),transparent)] data-[overflow-start=true]:data-[overflow-end=true]:[mask-image:linear-gradient(to_right,transparent,#000_var(--kanban-fade),#000_calc(100%_-_var(--kanban-fade)),transparent)]',\n              className,\n            )}\n            {...props}\n            onTouchStartCapture={(event) => {\n              suspendSnapWhileTouching(event.currentTarget);\n              pointerRef.current = pointerOf(event.nativeEvent);\n              props.onTouchStartCapture?.(event);\n            }}\n            onPointerDownCapture={(event) => {\n              if (event.pointerType === 'touch') suspendSnapWhileTouching(event.currentTarget);\n              pointerRef.current = pointerOf(event.nativeEvent);\n              props.onPointerDownCapture?.(event);\n            }}\n          >\n            {children}\n            {/* Inside the positioned root so the sr-only box cannot widen a scroll wrapper around it. */}\n            <div aria-live=\"assertive\" role=\"status\" className=\"sr-only\">\n              {announcement}\n            </div>\n          </div>\n          <DragOverlay dropAnimation={reducedMotion ? null : undefined}>{dragId ? faces.current.get(dragId) : null}</DragOverlay>\n        </DndContext>\n      </KanbanContext.Provider>\n    );\n  },\n);\nKanban.displayName = 'Kanban';\n\n// Full class strings so Tailwind's content scan finds every stage token.\nconst stageDot = {\n  1: 'bg-data-stage-1',\n  2: 'bg-data-stage-2',\n  3: 'bg-data-stage-3',\n  4: 'bg-data-stage-4',\n  5: 'bg-data-stage-5',\n  // Beyond five stages: the project colour slots and the internal colour (data tokens).\n  6: 'bg-data-project-1',\n  7: 'bg-data-project-2',\n  8: 'bg-data-project-3',\n  9: 'bg-data-project-4',\n  10: 'bg-data-project-5',\n  11: 'bg-data-project-6',\n  12: 'bg-data-project-7',\n  13: 'bg-data-project-8',\n  internal: 'bg-data-internal',\n} as const;\n\nexport interface KanbanColumnProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {\n  id: string;\n  title: ReactNode;\n  // Heading element of the column title, so the board fits the document outline. Default h3.\n  titleAs?: HeadingTag;\n  // Accessible name of the column. Defaults to the title when it is a string.\n  label?: string;\n  count?: number;\n  // Right aligned header slot: a sum, a share or nothing.\n  meta?: ReactNode;\n  // Second header line under the title, e.g. a conversion rate or probability.\n  headerExtra?: ReactNode;\n  // Shown dashed when the column has no cards.\n  emptyLabel?: ReactNode;\n  // Deprecated: use addText.\n  addLabel?: string;\n  // Visible texts of the column. Win over the provider text.\n  emptyText?: ReactNode;\n  addText?: string;\n  onAdd?: () => void;\n  // Highlights the column as the active one (e.g. a stage in focus).\n  selected?: boolean;\n  // Pipeline stage color 1 to 5 (data-stage tokens), 6 to 13 (project colours 1 to 8) or `internal`, shown as a dot before the title.\n  stage?: keyof typeof stageDot;\n  // Custom dot colour (user data): a CSS colour or a token utility such as `bg-data-project-3`. Wins over `stage`.\n  dotColor?: string;\n  // Reads the card id of a child element. Only needed when a wrapper hides the id; without it the id prop is tried, then the rendered card.\n  getItemId?: (child: ReactElement) => string | undefined;\n}\n\nconst KanbanColumn = forwardRef<HTMLElement, KanbanColumnProps>(\n  ({ className, id, title, titleAs: Title = 'h3', label, count, meta, headerExtra, emptyLabel: emptyLabelProp, emptyText, addLabel, addText, onAdd, selected, stage, dotColor, getItemId, children, ...props }, ref) => {\n    const l = useLabels();\n    const zero = usePreset() === 'zero';\n    const emptyLabel = emptyText ?? emptyLabelProp ?? l.kanbanEmptyColumn;\n    const ctx = useKanban();\n    const cards = Children.toArray(children).filter(isValidElement) as ReactElement<{ id?: string }>[];\n    const isTarget = ctx.target?.columnId === id && ctx.grabbedId != null;\n    const name = label ?? (typeof title === 'string' ? title : id);\n    const listRef = useRef<HTMLUListElement | null>(null);\n    // Position of the grabbed card read from the rendered card, so wrappers without an id prop still work.\n    const [domGrabbed, setDomGrabbed] = useState(-1);\n    useLayoutEffect(() => {\n      const grabbedId = ctx.grabbedId;\n      if (grabbedId == null) {\n        setDomGrabbed(-1);\n        return;\n      }\n      const items = Array.from(listRef.current?.querySelectorAll<HTMLElement>(':scope > [data-kanban-card]') ?? []);\n      setDomGrabbed(items.findIndex((el) => el.dataset.cardId === grabbedId));\n    }, [ctx.grabbedId, cards.length]);\n    const idOf = (card: ReactElement<{ id?: string }>) => getItemId?.(card) ?? (typeof card.props.id === 'string' ? card.props.id : undefined);\n    const propGrabbed = ctx.grabbedId == null ? -1 : cards.findIndex((card) => idOf(card) === ctx.grabbedId);\n    const grabbedIndex = propGrabbed >= 0 ? propGrabbed : domGrabbed;\n    // Zero: count and sum are quiet detail text, the empty slot sits directly under the header and the add row is subtle.\n    const headerMeta = zero ? 'typography-detail text-text-subtle-on-muted' : 'typography-text-extra-small text-foreground';\n    const emptySlot =\n      cards.length === 0 ? (\n        <div className={cn('typography-text-extra-small rounded-surface border border-dashed px-3 py-6 text-center', zero ? 'border-border-default text-text-subtle-on-muted' : 'border-hairline text-foreground')}>\n          {emptyLabel}\n        </div>\n      ) : null;\n\n    const rendered: ReactNode[] = [];\n    let slot = 0;\n    cards.forEach((card, position) => {\n      const grabbed = position === grabbedIndex;\n      if (isTarget && !grabbed && ctx.target?.index === slot) rendered.push(<DropMarker key=\"drop-marker\" />);\n      rendered.push(card);\n      if (!grabbed) slot += 1;\n    });\n    if (isTarget && ctx.target && ctx.target.index >= slot) rendered.push(<DropMarker key=\"drop-marker\" />);\n\n    return (\n      <section\n        ref={ref}\n        aria-label={name}\n        data-kanban-column=\"\"\n        data-column-id={id}\n        data-selected={selected || undefined}\n        data-stage={stage}\n        data-drop-target={isTarget || undefined}\n        className={cn(\n          'flex snap-start flex-col gap-2 rounded-surface border border-solid border-transparent bg-muted p-2',\n          columnWidthClass(ctx.columnWidth, !zero),\n          'data-[selected=true]:border-border-focus data-[drop-target=true]:border-border-focus data-[drop-target=true]:bg-muted-strong',\n          className,\n        )}\n        {...props}\n      >\n        <header className=\"mx-1 mt-1 flex flex-col gap-micro text-foreground\">\n          <div className=\"typography-ui-semibold flex items-center gap-2\">\n            {dotColor ? (\n              <span\n                aria-hidden=\"true\"\n                data-column-dot=\"\"\n                className={cn('h-2 w-2 shrink-0 rounded-full', dotColor.startsWith('bg-') ? dotColor : 'bg-current')}\n                style={dotColor.startsWith('bg-') ? undefined : { backgroundColor: dotColor }}\n              />\n            ) : stage ? (\n              <span aria-hidden=\"true\" data-column-dot=\"\" className={cn('h-2 w-2 shrink-0 rounded-full', stageDot[stage])} />\n            ) : null}\n            <Title className=\"typography-ui-semibold\">{title}</Title>\n            {count != null ? <span className={cn('tabular-nums', headerMeta)}>{count}</span> : null}\n            {meta != null ? <span className={cn('ml-auto tabular-nums', headerMeta)}>{meta}</span> : null}\n          </div>\n          {headerExtra != null ? <div className=\"typography-text-extra-small text-foreground\" data-kanban-header-extra=\"\">{headerExtra}</div> : null}\n        </header>\n        {zero ? emptySlot : null}\n        <ul ref={listRef} className=\"-m-1 flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-1\">\n          {rendered}\n        </ul>\n        {zero ? null : emptySlot}\n        {onAdd ? (\n          <button\n            type=\"button\"\n            onClick={onAdd}\n            className={cn(\n              'typography-text-extra-small flex h-6 items-center rounded-item px-2 py-1 text-left hover:bg-muted-strong',\n              zero ? 'gap-gap-control text-text-subtle-on-muted' : 'gap-2 text-foreground',\n              transitionBase,\n              focusRing,\n            )}\n          >\n            <Plus aria-hidden=\"true\" className=\"h-icon-xs w-icon-xs\" />\n            {addText ?? addLabel ?? l.add}\n          </button>\n        ) : null}\n      </section>\n    );\n  },\n);\nKanbanColumn.displayName = 'KanbanColumn';\n\n// Rendered actions, with fragments flattened and empty children dropped, so `<><A /><B /></>` counts as two.\nconst flattenActions = (node: ReactNode): ReactNode[] =>\n  Children.toArray(node).flatMap((child) => (isValidElement<{ children?: ReactNode }>(child) && child.type === Fragment ? flattenActions(child.props.children) : [child]));\n\nconst DropMarker = () => <li aria-hidden=\"true\" data-kanban-drop-marker=\"\" className=\"h-1 shrink-0 rounded-full bg-border-focus\" />;\n\nexport interface KanbanCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {\n  id: string;\n  title: string;\n  subtitle?: ReactNode;\n  // Record id or reference, shown in the code type role at the right of the title.\n  code?: string;\n  // Leading tile, e.g. a logo tile or avatar.\n  leading?: ReactNode;\n  selected?: boolean;\n  // Controls shown at the top right of the card, outside the drag handle (e.g. a \"Move to\" menu button). Keeps the move possible without dragging.\n  actions?: ReactNode;\n  // Where the actions sit. 'top' is the top right corner next to the title, 'bottom' a row under the content so the title keeps the full width. 'inline' centres them on the title line at the card padding (right 12px, top 12px) so they read as part of the title row; the title truncates before them.\n  actionsPlacement?: 'top' | 'bottom' | 'inline';\n  // Title lines before it is cut off. 2 lets a long title wrap.\n  titleLines?: 1 | 2;\n}\n\nconst KanbanCard = forwardRef<HTMLDivElement, KanbanCardProps>(\n  ({ className, id, title, subtitle, code, leading, selected, actions, actionsPlacement = 'top', titleLines = 1, children, onClick, onKeyDown, ...props }, ref) => {\n    const boardCtx = useContext(KanbanContext);\n    const zero = usePreset() === 'zero';\n    const standalone = boardCtx === null;\n    const ctx = boardCtx ?? standaloneKanban;\n    const grabbed = ctx.grabbedId === id;\n    const { setNodeRef, listeners } = useDraggable({ id, disabled: !ctx.movable });\n    const setRefs = (node: HTMLDivElement | null) => {\n      setNodeRef(node);\n      if (typeof ref === 'function') ref(node);\n      else if (ref) (ref as { current: HTMLDivElement | null }).current = node;\n    };\n    // Run the consumer handler first, then the sensor activator.\n    const composed: Record<string, (event: never) => void> = {};\n    Object.entries(listeners ?? {}).forEach(([name, handler]) => {\n      const own = (props as Record<string, unknown>)[name] as ((event: unknown) => void) | undefined;\n      composed[name] = (event: never) => {\n        own?.(event);\n        (handler as (event: never) => void)(event);\n      };\n    });\n\n    const move = (dx: number, dy: number) => {\n      const board = ctx.getBoard();\n      const t = ctx.target;\n      if (!board || !t) return;\n      const columns = columnElements(board);\n      const at = Math.max(0, columns.findIndex((c) => c.dataset.columnId === t.columnId));\n      const nextColumn = Math.min(columns.length - 1, Math.max(0, at + dx));\n      const col = columns[nextColumn];\n      const count = cardElements(col).filter((c) => c.dataset.cardId !== id).length;\n      const index = dx !== 0 ? Math.min(t.index, count) : Math.min(count, Math.max(0, t.index + dy));\n      ctx.setTarget({ columnId: col.dataset.columnId ?? t.columnId, index });\n    };\n\n    const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n      onKeyDown?.(event);\n      if (event.defaultPrevented) return;\n      if (event.key === 'Enter') {\n        event.preventDefault();\n        if (grabbed) ctx.commit();\n        else event.currentTarget.click();\n        return;\n      }\n      if (!ctx.movable) {\n        if (event.key === ' ' && onClick) {\n          event.preventDefault();\n          event.currentTarget.click();\n        }\n        return;\n      }\n      if (event.key === ' ') {\n        event.preventDefault();\n        if (grabbed) ctx.commit();\n        else ctx.grab(id);\n        return;\n      }\n      if (!grabbed) return;\n      const keys: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };\n      if (keys[event.key]) {\n        event.preventDefault();\n        move(keys[event.key][0], keys[event.key][1]);\n      } else if (event.key === 'Escape') {\n        event.preventDefault();\n        ctx.cancel();\n      }\n    };\n\n    const actionCount = actions ? flattenActions(actions).length : 0;\n    const inlineActions = actionsPlacement === 'inline' && actionCount > 0;\n    // Inline actions only reserve space on the title row, so badges and avatars below keep the full card width.\n    const inlineActionsPad = inlineActions ? (actionCount === 1 ? 'pr-7' : 'pr-16') : undefined;\n\n    const content = (\n      <>\n        <div className={cn('flex items-start gap-3', inlineActionsPad)}>\n          {leading}\n          <div className=\"min-w-0 flex-1\">\n            <div className=\"flex items-baseline justify-between gap-2\">\n              <p className={cn('typography-ui-medium', titleLines === 2 ? 'line-clamp-2 break-words' : 'truncate')}>{title}</p>\n              {code ? <span className=\"typography-code-sm shrink-0 text-muted-foreground\">{code}</span> : null}\n            </div>\n            {subtitle ? <p className=\"typography-text-extra-small mt-2 truncate text-muted-foreground\">{subtitle}</p> : null}\n          </div>\n        </div>\n        {children ? <div className=\"flex items-center gap-2\">{children}</div> : null}\n      </>\n    );\n\n    const bottomActions = actionsPlacement === 'bottom' && actionCount > 0;\n    const faceBase = cn(\n      'flex flex-col gap-2 rounded-surface border border-solid bg-card p-3 text-foreground',\n      zero ? 'border-border-default shadow-elevation-surface' : 'border-hairline shadow-elevation-control',\n      bottomActions && 'pb-11',\n      !bottomActions && actionsPlacement !== 'inline' && actionCount === 1 && 'pr-10',\n      !bottomActions && actionsPlacement !== 'inline' && actionCount > 1 && 'pr-20',\n    );\n\n    // The overlay copy follows the pointer while the original stays as a faded placeholder.\n    const { registerFace } = ctx;\n    useEffect(() => {\n      registerFace(\n        id,\n        <div data-kanban-overlay=\"\" className={cn(faceBase, 'cursor-grabbing select-none border-border-focus shadow-elevation-raised', className)}>\n          {content}\n        </div>,\n      );\n    });\n\n    const touchStyle: CSSProperties | undefined = ctx.movable ? { WebkitTouchCallout: 'none' } : undefined;\n\n    // Standalone and without a click handler the card is plain content, not a control.\n    const inert = standalone && !onClick;\n    const Wrapper = standalone ? 'div' : 'li';\n    return (\n      <Wrapper data-kanban-card=\"\" data-card-id={id} data-card-title={title} data-grabbed={grabbed || undefined} className=\"relative list-none\">\n        <div\n          data-kanban-handle=\"\"\n          role={inert ? undefined : 'button'}\n          tabIndex={inert ? undefined : 0}\n          aria-pressed={inert ? undefined : ctx.movable ? grabbed : selected}\n          aria-roledescription={ctx.movable ? 'draggable card' : undefined}\n          data-selected={selected || undefined}\n          {...props}\n          {...composed}\n          style={{ ...touchStyle, ...props.style }}\n          ref={setRefs}\n          onClick={onClick}\n          onKeyDown={handleKeyDown}\n          className={cn(\n            faceBase,\n            !inert && 'cursor-pointer',\n            ctx.movable && 'select-none',\n            'hover:border-border-bold data-[selected=true]:border-border-focus aria-[pressed=true]:border-border-focus aria-[pressed=true]:shadow-elevation-raised',\n            zero && 'data-[selected=true]:ring-[3px] data-[selected=true]:ring-ring/30',\n            grabbed && 'border-dashed border-border-focus',\n            transitionBase,\n            focusRing,\n            className,\n          )}\n        >\n          {content}\n        </div>\n        {actions ? <div data-kanban-actions=\"\" data-placement={bottomActions ? 'bottom' : actionsPlacement === 'inline' ? 'inline' : 'top'} className={cn('absolute flex items-center gap-1', bottomActions ? 'bottom-2 right-2' : actionsPlacement === 'inline' ? 'right-3 top-3 h-5' : 'right-2 top-2')}>{actions}</div> : null}\n      </Wrapper>\n    );\n  },\n);\nKanbanCard.displayName = 'KanbanCard';\n\nexport { Kanban, KanbanCard, KanbanColumn };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}