{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"table-advanced","type":"registry:component","title":"Table Advanced Data Table","description":"The paginated DataTable: Shift+click to sort by several columns, per-column text or select filters plus a global search, a column visibility picker, column reordering, rows that expand into detail panels and range selection with a bulk-action bar.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/table/data-table.tsx","type":"registry:component","target":"@components/motion/table/data-table.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/table\n\nimport {\n  ArrowLeft,\n  ArrowRight,\n  ArrowUp,\n  ChevronDown,\n  ChevronRight,\n  Columns3,\n  EyeOff,\n  GripVertical,\n  ListFilter,\n  MoreHorizontal,\n  Search,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  Fragment,\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Checkbox } from \"@/components/motion/checkbox\";\nimport { Pagination } from \"@/components/motion/pagination\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PANEL, SPRING_SWAP } from \"@/lib/ease\";\nimport { TOUCH_GESTURE_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  DEFAULT_STACK_BELOW,\n  StackedFields,\n  StackSortSelect,\n  splitStackColumns,\n  useStackedLayout,\n} from \"./stacked\";\nimport type {\n  HeaderCellRefs,\n  MobileLayout,\n  SortState,\n  TableColumn,\n} from \"./types\";\nimport { useColumnReorder } from \"./use-column-reorder\";\nimport { alignFlex, alignText, readCell, readSortValue } from \"./utils\";\n\n/**\n * DataTable is the paginated sibling of the virtualized `Table`. The virtual\n * table positions a recycled window of fixed-height rows, which cannot carry\n * layout-animated reorders or variable-height expanded rows; this one renders\n * a page of real rows so both can move. It reuses the table's column type,\n * cell readers, reorder hook, `Checkbox` and `Pagination`.\n */\n\nexport type { MobileLayout, SortDirection, SortState } from \"./types\";\n\n/** `string` filters by \"contains\" (text); `string[]` keeps rows whose value is one of the options (select). */\nexport type DataTableFilterValue = string | string[];\nexport type DataTableFilters = Record<string, DataTableFilterValue>;\n\nexport type DataTableColumn<T> = TableColumn<T> & {\n  /** Adds a filter to the column menu: a \"contains\" text field or a multi-select of options. */\n  filter?: \"text\" | \"select\";\n  /** Options for a `select` filter. Defaults to the distinct values in `data`. */\n  filterOptions?: string[];\n  /** Text used by filters and global search. Falls back to the sort value / `row[key]`. */\n  filterValue?: (row: T) => string;\n  /** Allow hiding the column from the column menu and the Columns picker (default true). */\n  hideable?: boolean;\n  /** Include the column in the global search (default true). */\n  searchable?: boolean;\n};\n\nexport interface DataTableProps<T> {\n  data: T[];\n  columns: DataTableColumn<T>[];\n  /** Stable id per row — keeps selection, expansion and the reorder animation attached to the right row. */\n  getRowId?: (row: T, index: number) => string;\n  /** Accessible name of a row, used by its checkbox and expand button. Defaults to the first column's text. */\n  getRowLabel?: (row: T) => string;\n  /** Sort stack, primary first. Click a header to sort by it alone; Shift+click adds a secondary sort. */\n  sort?: SortState[];\n  defaultSort?: SortState[];\n  onSortChange?: (sort: SortState[]) => void;\n  /** Per-column filters keyed by column key. */\n  filters?: DataTableFilters;\n  defaultFilters?: DataTableFilters;\n  onFiltersChange?: (filters: DataTableFilters) => void;\n  /** Global search across every `searchable` column. */\n  search?: string;\n  defaultSearch?: string;\n  onSearchChange?: (search: string) => void;\n  /** Render the search field in the toolbar (default true). */\n  showSearch?: boolean;\n  searchPlaceholder?: string;\n  /** Keys of hidden columns. */\n  hiddenColumns?: string[];\n  defaultHiddenColumns?: string[];\n  onHiddenColumnsChange?: (keys: string[]) => void;\n  /** Column key order. */\n  columnOrder?: string[];\n  defaultColumnOrder?: string[];\n  onColumnOrderChange?: (keys: string[]) => void;\n  /** Drag headers by their grip, or use Move left/right in the column menu (default true). */\n  reorderable?: boolean;\n  /** Renders a chevron column; the returned node fills a height-morphing detail panel under the row. */\n  renderExpanded?: (row: T) => ReactNode;\n  expandedRowIds?: string[];\n  defaultExpandedRowIds?: string[];\n  onExpandedChange?: (ids: string[]) => void;\n  /** Leading checkbox column; Shift+click selects a range. */\n  selectable?: boolean;\n  selectedRowIds?: string[];\n  defaultSelectedRowIds?: string[];\n  onSelectionChange?: (ids: string[]) => void;\n  /** Extra controls in the selected-count bar, e.g. bulk actions. */\n  selectionActions?: (ids: string[]) => ReactNode;\n  /** Rows per page; `0` shows every row without pagination (default 10). */\n  pageSize?: number;\n  /** 1-based page. Resets to 1 when search, filters or sort change. */\n  page?: number;\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n  /** Extra toolbar content, rendered right of the search field. */\n  toolbar?: ReactNode;\n  emptyState?: ReactNode;\n  /** Narrowest a column without an explicit px width may get before the table scrolls (default 120). */\n  minColumnWidth?: number;\n  /**\n   * `\"scroll\"` (default) keeps today's sideways-scrolling table. `\"stack\"`\n   * renders each row as a card (title from `primary` columns, the rest as\n   * header: value pairs, with selection, expand and a sort select plus filter\n   * chips above the list) whenever the table's own width is below\n   * `stackBelow`. The server always renders the table.\n   */\n  mobileLayout?: MobileLayout;\n  /** Width in px under which `mobileLayout=\"stack\"` shows cards (default 560). */\n  stackBelow?: number;\n  className?: string;\n}\n\ntype Entry<T> = { row: T; id: string };\n\nconst SELECT_COL_PX = 44;\nconst EXPAND_COL_PX = 40;\nconst PANEL_WIDTH = 232;\n\nfunction useControllable<V>(\n  value: V | undefined,\n  defaultValue: V,\n  onChange?: (next: V) => void,\n) {\n  const [internal, setInternal] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const set = useCallback(\n    (next: V) => {\n      if (!controlled) setInternal(next);\n      onChange?.(next);\n    },\n    [controlled, onChange],\n  );\n  return [current, set] as const;\n}\n\nconst EMPTY_SORT: SortState[] = [];\nconst EMPTY_FILTERS: DataTableFilters = {};\nconst EMPTY_KEYS: string[] = [];\n\nfunction labelOf<T>(column: DataTableColumn<T>) {\n  return typeof column.header === \"string\" ? column.header : column.key;\n}\n\nfunction textOf<T>(row: T, column: DataTableColumn<T>): string {\n  if (column.filterValue) return column.filterValue(row);\n  const value = readSortValue(row, column) as unknown;\n  return value == null ? \"\" : String(value);\n}\n\nfunction isFilterActive(value: DataTableFilterValue | undefined) {\n  if (value == null) return false;\n  return Array.isArray(value) ? value.length > 0 : value.trim() !== \"\";\n}\n\n/** Next sort stack for a header click. Plain click cycles this column alone (asc → desc → off);\n * Shift+click cycles it inside the stack, appending it as a secondary key when new. */\nexport function nextSortStack(\n  sorts: SortState[],\n  key: string,\n  additive: boolean,\n): SortState[] {\n  const existing = sorts.find((s) => s.key === key);\n  if (!additive) {\n    if (!existing) return [{ key, direction: \"asc\" }];\n    // Part of a multi-sort: collapse to this column alone, flipped.\n    if (sorts.length > 1)\n      return [\n        { key, direction: existing.direction === \"asc\" ? \"desc\" : \"asc\" },\n      ];\n    return existing.direction === \"asc\" ? [{ key, direction: \"desc\" }] : [];\n  }\n  if (!existing) return [...sorts, { key, direction: \"asc\" }];\n  if (existing.direction === \"asc\")\n    return sorts.map((s) => (s.key === key ? { key, direction: \"desc\" } : s));\n  return sorts.filter((s) => s.key !== key);\n}\n\nfunction compareValues(a: unknown, b: unknown) {\n  if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n  // Explicit locale so server and client sort identically.\n  return String(a ?? \"\").localeCompare(String(b ?? \"\"), \"en-US\", {\n    numeric: true,\n    sensitivity: \"base\",\n  });\n}\n\n/* ------------------------------------------------------------------------ */\n/* Popover panel (column menu + Columns picker)                              */\n/* ------------------------------------------------------------------------ */\n\ntype AnchorRect = { left: number; right: number; bottom: number };\n\nfunction Panel({\n  open,\n  onClose,\n  getAnchor,\n  triggerRef,\n  label,\n  repositionKey,\n  children,\n}: {\n  open: boolean;\n  onClose: () => void;\n  getAnchor: () => AnchorRect | null;\n  triggerRef: RefObject<HTMLButtonElement | null>;\n  label: string;\n  /** Re-measure the anchor when this changes (the column moved). */\n  repositionKey?: string | number;\n  children: ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  const panelRef = useRef<HTMLDivElement>(null);\n  const [pos, setPos] = useState<{ top: number; left: number } | null>(null);\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n\n  // biome-ignore lint/correctness/useExhaustiveDependencies: repositionKey is the re-measure trigger.\n  useLayoutEffect(() => {\n    if (!open) return;\n    const measure = () => {\n      const r = getAnchor();\n      if (!r) return;\n      const left = Math.min(\n        Math.max(8, r.right - PANEL_WIDTH),\n        window.innerWidth - PANEL_WIDTH - 8,\n      );\n      const top = r.bottom + 6;\n      setPos((prev) =>\n        prev && prev.top === top && prev.left === left ? prev : { top, left },\n      );\n    };\n    measure();\n    // After a column move the header glides to its new slot (a layout\n    // animation); follow it for a moment so the panel stays attached.\n    let frame = 0;\n    const until = performance.now() + 600;\n    const follow = () => {\n      measure();\n      if (performance.now() < until) frame = requestAnimationFrame(follow);\n    };\n    frame = requestAnimationFrame(follow);\n    return () => cancelAnimationFrame(frame);\n  }, [open, getAnchor, repositionKey]);\n\n  useEffect(() => {\n    if (!open) return;\n    const frame = requestAnimationFrame(() => {\n      panelRef.current\n        ?.querySelector<HTMLElement>(\n          \"input, button:not([disabled]):not([aria-disabled='true'])\",\n        )\n        ?.focus();\n    });\n    const inside = (target: EventTarget | null) =>\n      target instanceof Node &&\n      (panelRef.current?.contains(target) ||\n        triggerRef.current?.contains(target));\n    const onPointer = (e: PointerEvent) => {\n      if (!inside(e.target)) onClose();\n    };\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      onClose();\n      triggerRef.current?.focus();\n    };\n    // Fixed coordinates go stale once anything outside the panel scrolls.\n    const onScroll = (e: Event) => {\n      if (!(e.target instanceof Node && panelRef.current?.contains(e.target)))\n        onClose();\n    };\n    document.addEventListener(\"pointerdown\", onPointer, true);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"scroll\", onScroll, true);\n    window.addEventListener(\"resize\", onClose);\n    return () => {\n      cancelAnimationFrame(frame);\n      document.removeEventListener(\"pointerdown\", onPointer, true);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"scroll\", onScroll, true);\n      window.removeEventListener(\"resize\", onClose);\n    };\n  }, [open, onClose, triggerRef]);\n\n  if (!mounted) return null;\n  return createPortal(\n    <AnimatePresence>\n      {open && pos ? (\n        <motion.div\n          ref={panelRef}\n          role=\"dialog\"\n          aria-label={label}\n          onBlur={(e) => {\n            const next = e.relatedTarget;\n            if (\n              next instanceof Node &&\n              !panelRef.current?.contains(next) &&\n              !triggerRef.current?.contains(next)\n            )\n              onClose();\n          }}\n          className=\"fixed z-50 overflow-hidden rounded-xl border border-border bg-popover p-1 text-popover-foreground text-sm shadow-xl\"\n          style={{ top: pos.top, left: pos.left, width: PANEL_WIDTH }}\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4 }}\n          animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}\n          exit={\n            reduce\n              ? { opacity: 0, transition: { duration: 0.1 } }\n              : {\n                  opacity: 0,\n                  scale: 0.98,\n                  y: -2,\n                  transition: { duration: 0.12, ease: EASE_OUT },\n                }\n          }\n          transition={reduce ? { duration: 0.12 } : SPRING_PANEL}\n        >\n          {children}\n        </motion.div>\n      ) : null}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\nconst PANEL_ITEM =\n  \"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-sm outline-none transition-colors hover:bg-muted focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground\";\nconst PANEL_HEADING =\n  \"px-2.5 pt-2 pb-1 font-medium text-[11px] text-muted-foreground uppercase tracking-wide\";\nconst PANEL_DIVIDER = \"my-1 h-px bg-border\";\n\n/* ------------------------------------------------------------------------ */\n/* DataTable                                                                 */\n/* ------------------------------------------------------------------------ */\n\nexport function DataTable<T>({\n  data,\n  columns,\n  getRowId,\n  getRowLabel,\n  sort: sortProp,\n  defaultSort = EMPTY_SORT,\n  onSortChange,\n  filters: filtersProp,\n  defaultFilters = EMPTY_FILTERS,\n  onFiltersChange,\n  search: searchProp,\n  defaultSearch = \"\",\n  onSearchChange,\n  showSearch = true,\n  searchPlaceholder = \"Search…\",\n  hiddenColumns: hiddenProp,\n  defaultHiddenColumns = EMPTY_KEYS,\n  onHiddenColumnsChange,\n  columnOrder,\n  defaultColumnOrder,\n  onColumnOrderChange,\n  reorderable = true,\n  renderExpanded,\n  expandedRowIds,\n  defaultExpandedRowIds = EMPTY_KEYS,\n  onExpandedChange,\n  selectable = false,\n  selectedRowIds,\n  defaultSelectedRowIds = EMPTY_KEYS,\n  onSelectionChange,\n  selectionActions,\n  pageSize = 10,\n  page: pageProp,\n  defaultPage = 1,\n  onPageChange,\n  toolbar,\n  emptyState = \"No results\",\n  minColumnWidth = 120,\n  mobileLayout = \"scroll\",\n  stackBelow = DEFAULT_STACK_BELOW,\n  className,\n}: DataTableProps<T>) {\n  const reduce = useReducedMotion() ?? false;\n  const uid = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const stacked = useStackedLayout(\n    rootRef,\n    mobileLayout === \"stack\",\n    stackBelow,\n  );\n  const thRefs: HeaderCellRefs = useRef<\n    Record<string, HTMLTableCellElement | null>\n  >({});\n\n  const [sorts, setSorts] = useControllable(sortProp, defaultSort, onSortChange);\n  const [filters, setFilters] = useControllable(\n    filtersProp,\n    defaultFilters,\n    onFiltersChange,\n  );\n  const [search, setSearch] = useControllable(\n    searchProp,\n    defaultSearch,\n    onSearchChange,\n  );\n  const [hidden, setHidden] = useControllable(\n    hiddenProp,\n    defaultHiddenColumns,\n    onHiddenColumnsChange,\n  );\n  const [expanded, setExpanded] = useControllable(\n    expandedRowIds,\n    defaultExpandedRowIds,\n    onExpandedChange,\n  );\n  const [selectedIds, setSelectedIds] = useControllable(\n    selectedRowIds,\n    defaultSelectedRowIds,\n    onSelectionChange,\n  );\n  const [pageRaw, setPage] = useControllable(pageProp, defaultPage, onPageChange);\n\n  const [announcement, setAnnouncement] = useState(\"\");\n  // Rows that mount after the first paint (filter, page, data) fade in; the\n  // first render stays static so server and client output match.\n  const [hydrated, setHydrated] = useState(false);\n  useEffect(() => setHydrated(true), []);\n\n  const {\n    orderedColumns: reorderedColumns,\n    dragKey,\n    dropIndex,\n    startReorder,\n    moveReorder,\n    endReorder,\n    commitOrder,\n  } = useColumnReorder({\n    columns,\n    thRefs,\n    order: columnOrder,\n    defaultOrder: defaultColumnOrder,\n    onColumnOrderChange,\n  });\n\n  // The hook returns the same column objects it was given, just reordered.\n  const orderedColumns = reorderedColumns as DataTableColumn<T>[];\n  const hiddenSet = useMemo(() => new Set(hidden), [hidden]);\n  const visibleColumns = useMemo(\n    () => orderedColumns.filter((c) => !hiddenSet.has(c.key)),\n    [orderedColumns, hiddenSet],\n  );\n\n  /* ---- data pipeline: rows → search → filters → sort → page ---- */\n\n  const rows = useMemo<Entry<T>[]>(\n    () =>\n      data.map((row, index) => ({\n        row,\n        id: getRowId ? getRowId(row, index) : String(index),\n      })),\n    [data, getRowId],\n  );\n\n  const filteredRows = useMemo(() => {\n    const query = search.trim().toLowerCase();\n    const searchable = columns.filter((c) => c.searchable !== false);\n    const active = columns.filter((c) => isFilterActive(filters[c.key]));\n    if (!query && active.length === 0) return rows;\n    return rows.filter(({ row }) => {\n      if (\n        query &&\n        !searchable.some((c) => textOf(row, c).toLowerCase().includes(query))\n      )\n        return false;\n      for (const column of active) {\n        const value = filters[column.key];\n        const text = textOf(row, column);\n        if (Array.isArray(value)) {\n          if (!value.includes(text)) return false;\n        } else if (!text.toLowerCase().includes(value.trim().toLowerCase())) {\n          return false;\n        }\n      }\n      return true;\n    });\n  }, [rows, columns, search, filters]);\n\n  const sortedRows = useMemo(() => {\n    const keys = sorts\n      .map((s) => ({ s, column: columns.find((c) => c.key === s.key) }))\n      .filter(\n        (k): k is { s: SortState; column: DataTableColumn<T> } =>\n          k.column !== undefined,\n      );\n    if (keys.length === 0) return filteredRows;\n    return [...filteredRows].sort((a, b) => {\n      for (const { s, column } of keys) {\n        const cmp = compareValues(\n          readSortValue(a.row, column),\n          readSortValue(b.row, column),\n        );\n        if (cmp !== 0) return s.direction === \"asc\" ? cmp : -cmp;\n      }\n      return 0;\n    });\n  }, [filteredRows, sorts, columns]);\n\n  const paged = pageSize > 0;\n  const pageCount = paged\n    ? Math.max(1, Math.ceil(sortedRows.length / pageSize))\n    : 1;\n  const page = Math.min(Math.max(1, pageRaw), pageCount);\n  const pageRows = paged\n    ? sortedRows.slice((page - 1) * pageSize, page * pageSize)\n    : sortedRows;\n\n  /* ---- sorting ---- */\n\n  const applySort = useCallback(\n    (next: SortState[]) => {\n      setSorts(next);\n      setPage(1);\n      const primary = next[0];\n      const column = primary && columns.find((c) => c.key === primary.key);\n      setAnnouncement(\n        column\n          ? `Sorted by ${next\n              .map((s) => {\n                const c = columns.find((col) => col.key === s.key);\n                return `${c ? labelOf(c) : s.key} ${s.direction === \"asc\" ? \"ascending\" : \"descending\"}`;\n              })\n              .join(\", then \")}`\n          : \"Sorting cleared\",\n      );\n    },\n    [columns, setSorts, setPage],\n  );\n\n  /* ---- filtering ---- */\n\n  const setColumnFilter = useCallback(\n    (key: string, value: DataTableFilterValue) => {\n      const next = { ...filters };\n      if (isFilterActive(value)) next[key] = value;\n      else delete next[key];\n      setFilters(next);\n      setPage(1);\n    },\n    [filters, setFilters, setPage],\n  );\n\n  const activeFilterCount = columns.filter((c) =>\n    isFilterActive(filters[c.key]),\n  ).length;\n\n  const clearFilters = useCallback(() => {\n    setFilters({});\n    if (search) setSearch(\"\");\n    setPage(1);\n  }, [setFilters, search, setSearch, setPage]);\n\n  const selectOptions = useCallback(\n    (column: DataTableColumn<T>) => {\n      if (column.filterOptions) return column.filterOptions;\n      const seen = new Set<string>();\n      for (const { row } of rows) seen.add(textOf(row, column));\n      return [...seen].sort((a, b) => compareValues(a, b));\n    },\n    [rows],\n  );\n\n  /* ---- columns: visibility + keyboard move ---- */\n\n  const toggleHidden = useCallback(\n    (key: string) => {\n      if (hiddenSet.has(key)) setHidden(hidden.filter((k) => k !== key));\n      else if (visibleColumns.length > 1) setHidden([...hidden, key]);\n    },\n    [hidden, hiddenSet, setHidden, visibleColumns.length],\n  );\n\n  const moveColumn = useCallback(\n    (key: string, dir: -1 | 1) => {\n      const visibleKeys = visibleColumns.map((c) => c.key);\n      const at = visibleKeys.indexOf(key);\n      const neighbor = visibleKeys[at + dir];\n      if (at === -1 || !neighbor) return;\n      const keys = orderedColumns.map((c) => c.key).filter((k) => k !== key);\n      const target = keys.indexOf(neighbor) + (dir === 1 ? 1 : 0);\n      keys.splice(target, 0, key);\n      commitOrder(keys);\n      const column = visibleColumns[at];\n      setAnnouncement(\n        `${labelOf(column)} column moved to position ${at + dir + 1} of ${visibleKeys.length}`,\n      );\n    },\n    [visibleColumns, orderedColumns, commitOrder],\n  );\n\n  /* ---- expansion ---- */\n\n  const expandedSet = useMemo(() => new Set(expanded), [expanded]);\n  const toggleExpanded = useCallback(\n    (id: string) => {\n      setExpanded(\n        expandedSet.has(id)\n          ? expanded.filter((x) => x !== id)\n          : [...expanded, id],\n      );\n    },\n    [expanded, expandedSet, setExpanded],\n  );\n\n  /* ---- selection (Shift+click selects a range in the current order) ---- */\n\n  const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);\n  const anchorRef = useRef<string | null>(null);\n  const shiftRef = useRef(false);\n  useEffect(() => {\n    if (!selectable) return;\n    let held = false;\n    const onKey = (e: KeyboardEvent) => {\n      held = e.shiftKey;\n    };\n    // Capture phase runs before the checkbox's own click handler, so the\n    // toggle knows whether this click (or Space/Enter press) was shifted.\n    const onClick = (e: MouseEvent) => {\n      shiftRef.current = e.shiftKey || held;\n    };\n    window.addEventListener(\"keydown\", onKey, true);\n    window.addEventListener(\"keyup\", onKey, true);\n    window.addEventListener(\"click\", onClick, true);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey, true);\n      window.removeEventListener(\"keyup\", onKey, true);\n      window.removeEventListener(\"click\", onClick, true);\n    };\n  }, [selectable]);\n\n  const toggleRow = useCallback(\n    (id: string, checked: boolean) => {\n      const next = new Set(selectedSet);\n      const order = sortedRows.map((r) => r.id);\n      const from = anchorRef.current ? order.indexOf(anchorRef.current) : -1;\n      const to = order.indexOf(id);\n      if (shiftRef.current && from !== -1 && to !== -1) {\n        const [lo, hi] = from < to ? [from, to] : [to, from];\n        for (let i = lo; i <= hi; i++) {\n          if (checked) next.add(order[i]);\n          else next.delete(order[i]);\n        }\n      } else if (checked) next.add(id);\n      else next.delete(id);\n      shiftRef.current = false;\n      anchorRef.current = id;\n      setSelectedIds([...next]);\n    },\n    [selectedSet, sortedRows, setSelectedIds],\n  );\n\n  const allSelected =\n    sortedRows.length > 0 && sortedRows.every((r) => selectedSet.has(r.id));\n  const someSelected = sortedRows.some((r) => selectedSet.has(r.id));\n  const toggleAll = useCallback(() => {\n    const next = new Set(selectedSet);\n    for (const r of sortedRows) {\n      if (allSelected) next.delete(r.id);\n      else next.add(r.id);\n    }\n    setSelectedIds([...next]);\n  }, [allSelected, selectedSet, sortedRows, setSelectedIds]);\n\n  const rowLabel = useCallback(\n    (row: T) => {\n      if (getRowLabel) return getRowLabel(row);\n      const first = visibleColumns[0] ?? columns[0];\n      return first ? textOf(row, first) : \"row\";\n    },\n    [getRowLabel, visibleColumns, columns],\n  );\n\n  /* ---- layout ---- */\n\n  const leadCount = (selectable ? 1 : 0) + (renderExpanded ? 1 : 0);\n  const spanAll = visibleColumns.length + leadCount;\n  const minTableWidth = useMemo(() => {\n    let total =\n      (selectable ? SELECT_COL_PX : 0) + (renderExpanded ? EXPAND_COL_PX : 0);\n    for (const column of visibleColumns) {\n      const px =\n        column.width?.endsWith(\"px\") && Number.parseFloat(column.width);\n      total += px && Number.isFinite(px) ? px : minColumnWidth;\n    }\n    return Math.round(total);\n  }, [visibleColumns, selectable, renderExpanded, minColumnWidth]);\n\n  const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n  const rowLayout = reduce ? false : (\"position\" as const);\n  const firstIndex = paged ? (page - 1) * pageSize : 0;\n  const selectedCount = selectedIds.length;\n  const numberFormat = useMemo(() => new Intl.NumberFormat(\"en-US\"), []);\n\n  // Index of the visible header showing the drop line (`dropIndex` counts\n  // hidden columns too, so translate it to the drawn header's key).\n  const dropKey =\n    dragKey && dropIndex !== null\n      ? (orderedColumns[dropIndex]?.key ?? null)\n      : null;\n  const dropAtEnd =\n    dragKey !== null && dropIndex !== null && dropIndex >= orderedColumns.length;\n\n  const { primary: titleColumns, fields: fieldColumns } = splitStackColumns(\n    visibleColumns,\n  );\n  const filterChipColumns = visibleColumns.filter((c) => c.filter);\n  const hasSortable = visibleColumns.some((c) => c.sortable);\n\n  const clearFiltersButton =\n    activeFilterCount > 0 || search ? (\n      <button\n        type=\"button\"\n        onClick={clearFilters}\n        className=\"rounded-md px-2 py-1 font-medium text-foreground text-xs underline-offset-4 hover:underline\"\n      >\n        Clear search and filters\n      </button>\n    ) : null;\n\n  const expandButton = (id: string, name: string, isOpen: boolean) => (\n    <button\n      type=\"button\"\n      aria-expanded={isOpen}\n      aria-controls={isOpen ? `${uid}-detail-${id}` : undefined}\n      aria-label={`${isOpen ? \"Collapse\" : \"Expand\"} ${name}`}\n      onClick={() => toggleExpanded(id)}\n      className=\"grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 pointer-coarse:size-9\"\n    >\n      <motion.span\n        aria-hidden\n        className=\"grid place-items-center\"\n        animate={{ rotate: isOpen ? 90 : 0 }}\n        transition={reduce ? { duration: 0 } : SPRING_SWAP}\n      >\n        <ChevronRight className=\"size-4\" />\n      </motion.span>\n    </button>\n  );\n\n  // Height is the point of this panel: it morphs open from 0 to its content height.\n  const detailPanel = (entry: Entry<T>, stackedCard = false) => (\n    <motion.div\n      key=\"detail\"\n      id={`${uid}-detail-${entry.id}`}\n      initial={{ height: 0, opacity: 0 }}\n      animate={{ height: \"auto\", opacity: 1 }}\n      exit={{ height: 0, opacity: 0 }}\n      transition={{\n        height: reduce ? { duration: 0 } : { duration: 0.26, ease: EASE_OUT },\n        opacity: { duration: reduce ? 0.12 : 0.2 },\n      }}\n      className=\"overflow-hidden\"\n    >\n      <div className={stackedCard ? \"pt-2\" : undefined}>\n        <div\n          className={cn(\"bg-muted/30 px-4 py-3\", stackedCard && \"rounded-lg\")}\n        >\n          {renderExpanded?.(entry.row)}\n        </div>\n      </div>\n    </motion.div>\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      data-layout={stacked ? \"stack\" : \"table\"}\n      className={cn(\n        \"w-full overflow-hidden rounded-2xl border border-border bg-card text-card-foreground text-sm\",\n        className,\n      )}\n    >\n      {/* Toolbar; the selection bar slides over it while rows are selected. */}\n      <div className=\"relative flex items-center gap-2 border-border border-b p-2\">\n        {showSearch ? (\n          <div className=\"relative min-w-0 flex-1\">\n            <Search\n              aria-hidden\n              className=\"-translate-y-1/2 pointer-events-none absolute top-1/2 left-2.5 size-4 text-muted-foreground\"\n            />\n            <input\n              type=\"search\"\n              value={search}\n              onChange={(e) => {\n                setSearch(e.target.value);\n                setPage(1);\n              }}\n              placeholder={searchPlaceholder}\n              aria-label=\"Search rows\"\n              className=\"h-8 w-full min-w-0 rounded-lg border border-border bg-background pr-8 pl-8 text-sm outline-none transition-colors placeholder:text-muted-foreground/70 focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/30 [&::-webkit-search-cancel-button]:hidden\"\n            />\n            <AnimatePresence initial={false}>\n              {search ? (\n                <motion.button\n                  type=\"button\"\n                  aria-label=\"Clear search\"\n                  onClick={() => {\n                    setSearch(\"\");\n                    setPage(1);\n                  }}\n                  initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n                  transition={reduce ? { duration: 0.1 } : SPRING_SWAP}\n                  className=\"-translate-y-1/2 absolute top-1/2 right-1.5 grid size-5 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground\"\n                >\n                  <X className=\"size-3.5\" />\n                </motion.button>\n              ) : null}\n            </AnimatePresence>\n          </div>\n        ) : (\n          <div className=\"flex-1\" />\n        )}\n        {activeFilterCount > 0 ? (\n          <button\n            type=\"button\"\n            onClick={clearFilters}\n            className=\"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground\"\n          >\n            <ListFilter className=\"size-3.5 text-primary\" />\n            {activeFilterCount} {activeFilterCount === 1 ? \"filter\" : \"filters\"}\n            <X className=\"size-3\" />\n          </button>\n        ) : null}\n        {toolbar}\n        <ColumnsPicker\n          columns={orderedColumns}\n          hiddenSet={hiddenSet}\n          visibleCount={visibleColumns.length}\n          onToggle={toggleHidden}\n        />\n\n        <AnimatePresence initial={false}>\n          {selectable && selectedCount > 0 ? (\n            <motion.div\n              key=\"selection-bar\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: \"-100%\" }}\n              animate={reduce ? { opacity: 1 } : { opacity: 1, y: \"0%\" }}\n              exit={\n                reduce\n                  ? { opacity: 0, transition: { duration: 0.1 } }\n                  : {\n                      opacity: 0,\n                      y: \"-100%\",\n                      transition: { duration: 0.16, ease: EASE_OUT },\n                    }\n              }\n              transition={reduce ? { duration: 0.12 } : SPRING_PANEL}\n              className=\"absolute inset-0 flex items-center gap-2 bg-primary px-3 text-primary-foreground\"\n            >\n              <span className=\"inline-flex items-center gap-1 font-medium tabular-nums\">\n                <span className=\"relative inline-grid overflow-hidden\">\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    <motion.span\n                      key={selectedCount}\n                      initial={reduce ? { opacity: 0 } : { opacity: 0, y: \"80%\" }}\n                      animate={{ opacity: 1, y: \"0%\" }}\n                      exit={reduce ? { opacity: 0 } : { opacity: 0, y: \"-80%\" }}\n                      transition={reduce ? { duration: 0.1 } : SPRING_SWAP}\n                    >\n                      {numberFormat.format(selectedCount)}\n                    </motion.span>\n                  </AnimatePresence>\n                </span>\n                selected\n              </span>\n              <div className=\"ml-auto flex items-center gap-1\">\n                {selectionActions?.(selectedIds)}\n                <button\n                  type=\"button\"\n                  onClick={() => {\n                    setSelectedIds([]);\n                    anchorRef.current = null;\n                  }}\n                  className=\"inline-flex h-7 items-center gap-1 rounded-md px-2 font-medium text-xs transition-colors hover:bg-primary-foreground/15\"\n                >\n                  <X className=\"size-3.5\" />\n                  Clear\n                </button>\n              </div>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n\n      {stacked ? (\n        <>\n          {selectable || hasSortable || filterChipColumns.length > 0 ? (\n            <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2 border-border border-b bg-muted/40 px-3 py-2\">\n              {selectable ? (\n                <Checkbox\n                  checked={allSelected}\n                  indeterminate={!allSelected && someSelected}\n                  onCheckedChange={toggleAll}\n                  label=\"Select all\"\n                  className=\"text-muted-foreground text-xs\"\n                />\n              ) : null}\n              <StackSortSelect\n                columns={visibleColumns}\n                value={sorts[0] ?? null}\n                onChange={(next) => applySort(next ? [next] : [])}\n                className=\"ml-auto\"\n              />\n              {filterChipColumns.length > 0 ? (\n                <div className=\"flex w-full flex-wrap gap-1.5\">\n                  {filterChipColumns.map((column) => {\n                    const index = visibleColumns.indexOf(column);\n                    const sortIndex = sorts.findIndex(\n                      (s) => s.key === column.key,\n                    );\n                    return (\n                      <ColumnMenu\n                        key={column.key}\n                        chip\n                        column={column}\n                        thRefs={thRefs}\n                        position={index}\n                        sortState={sortIndex === -1 ? null : sorts[sortIndex]}\n                        filtered={isFilterActive(filters[column.key])}\n                        filterValue={filters[column.key]}\n                        options={\n                          column.filter === \"select\"\n                            ? selectOptions(column)\n                            : null\n                        }\n                        canMoveLeft={reorderable && index > 0}\n                        canMoveRight={\n                          reorderable && index < visibleColumns.length - 1\n                        }\n                        reorderable={reorderable}\n                        canHide={\n                          column.hideable !== false && visibleColumns.length > 1\n                        }\n                        onSort={(direction) =>\n                          applySort(\n                            direction\n                              ? [{ key: column.key, direction }]\n                              : sorts.filter((s) => s.key !== column.key),\n                          )\n                        }\n                        onFilter={(value) => setColumnFilter(column.key, value)}\n                        onMove={(dir) => moveColumn(column.key, dir)}\n                        onHide={() => toggleHidden(column.key)}\n                      />\n                    );\n                  })}\n                </div>\n              ) : null}\n            </div>\n          ) : null}\n          {pageRows.length === 0 ? (\n            <div className=\"flex flex-col items-center gap-2 px-4 py-12 text-center text-muted-foreground\">\n              {emptyState}\n              {clearFiltersButton}\n            </div>\n          ) : (\n            <ul aria-label=\"Rows\">\n              {pageRows.map((entry) => {\n                const isSelected = selectedSet.has(entry.id);\n                const isOpen = expandedSet.has(entry.id);\n                const name = rowLabel(entry.row);\n                return (\n                  <motion.li\n                    key={entry.id}\n                    layout={rowLayout}\n                    transition={layoutTransition}\n                    initial={hydrated ? { opacity: 0 } : false}\n                    animate={{ opacity: 1 }}\n                    data-selected={isSelected || undefined}\n                    className=\"border-border/60 border-b px-3 py-3 transition-colors data-[selected=true]:bg-primary/[0.06]\"\n                  >\n                    <div className=\"flex min-w-0 items-center gap-3\">\n                      {selectable ? (\n                        <Checkbox\n                          checked={isSelected}\n                          onCheckedChange={(checked) =>\n                            toggleRow(entry.id, checked)\n                          }\n                          aria-label={`Select ${name}`}\n                        />\n                      ) : null}\n                      <div className=\"flex min-w-0 flex-1 flex-wrap items-center gap-x-2 font-medium text-foreground\">\n                        {titleColumns.map((column) => (\n                          <span key={column.key} className=\"min-w-0 truncate\">\n                            {readCell(entry.row, column)}\n                          </span>\n                        ))}\n                      </div>\n                      {renderExpanded\n                        ? expandButton(entry.id, name, isOpen)\n                        : null}\n                    </div>\n                    <StackedFields\n                      className={cn(\"mt-2\", selectable && \"pl-8\")}\n                      fields={fieldColumns.map((column) => ({\n                        key: column.key,\n                        label: column.header,\n                        value: readCell(entry.row, column),\n                      }))}\n                    />\n                    <AnimatePresence initial={false}>\n                      {renderExpanded && isOpen ? (\n                        detailPanel(entry, true)\n                      ) : null}\n                    </AnimatePresence>\n                  </motion.li>\n                );\n              })}\n            </ul>\n          )}\n        </>\n      ) : (\n        <div className=\"overflow-x-auto [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\">\n          <table\n            className=\"w-full border-collapse\"\n            style={{ tableLayout: \"fixed\", minWidth: `max(100%, ${minTableWidth}px)` }}\n          >\n            <colgroup>\n              {selectable ? <col style={{ width: SELECT_COL_PX }} /> : null}\n              {renderExpanded ? <col style={{ width: EXPAND_COL_PX }} /> : null}\n              {visibleColumns.map((column) => (\n                <col\n                  key={column.key}\n                  style={\n                    column.width && !column.width.endsWith(\"fr\")\n                      ? { width: column.width }\n                      : undefined\n                  }\n                />\n              ))}\n            </colgroup>\n            <thead>\n              <tr className=\"h-10\">\n                {selectable ? (\n                  <th className=\"border-border border-b bg-muted/60\">\n                    <div className=\"flex items-center justify-center\">\n                      <Checkbox\n                        checked={allSelected}\n                        indeterminate={!allSelected && someSelected}\n                        onCheckedChange={toggleAll}\n                        aria-label=\"Select all rows\"\n                      />\n                    </div>\n                  </th>\n                ) : null}\n                {renderExpanded ? (\n                  <th className=\"border-border border-b bg-muted/60\">\n                    <span className=\"sr-only\">Expand</span>\n                  </th>\n                ) : null}\n                {visibleColumns.map((column, index) => {\n                  const sortIndex = sorts.findIndex((s) => s.key === column.key);\n                  const sortState = sortIndex === -1 ? null : sorts[sortIndex];\n                  const filtered = isFilterActive(filters[column.key]);\n                  const isDragging = dragKey === column.key;\n                  const isLast = index === visibleColumns.length - 1;\n                  return (\n                    <motion.th\n                      key={column.key}\n                      ref={(el: HTMLTableCellElement | null) => {\n                        thRefs.current[column.key] = el;\n                      }}\n                      layout={rowLayout}\n                      transition={layoutTransition}\n                      scope=\"col\"\n                      aria-sort={\n                        sortIndex === 0 && sortState\n                          ? sortState.direction === \"asc\"\n                            ? \"ascending\"\n                            : \"descending\"\n                          : undefined\n                      }\n                      data-drop={dropKey === column.key || undefined}\n                      data-dropend={(dropAtEnd && isLast) || undefined}\n                      className={cn(\n                        \"group/th relative border-border border-b bg-muted/60 p-0 font-medium text-muted-foreground\",\n                        \"data-[drop=true]:before:absolute data-[drop=true]:before:inset-y-1 data-[drop=true]:before:left-0 data-[drop=true]:before:w-0.5 data-[drop=true]:before:rounded-full data-[drop=true]:before:bg-primary\",\n                        \"data-[dropend=true]:after:absolute data-[dropend=true]:after:inset-y-1 data-[dropend=true]:after:right-0 data-[dropend=true]:after:w-0.5 data-[dropend=true]:after:rounded-full data-[dropend=true]:after:bg-primary\",\n                        isDragging && \"opacity-50\",\n                      )}\n                    >\n                      <div\n                        className={cn(\n                          \"relative flex h-10 items-center\",\n                          alignFlex(column.align),\n                        )}\n                      >\n                        {reorderable ? (\n                          <button\n                            type=\"button\"\n                            tabIndex={-1}\n                            aria-label={`Drag to reorder ${labelOf(column)} column`}\n                            onPointerDown={(e) => startReorder(column.key, e)}\n                            onPointerMove={moveReorder}\n                            onPointerUp={endReorder}\n                            onPointerCancel={endReorder}\n                            className={cn(\n                              \"absolute inset-y-0 left-0 z-[1] flex w-4 cursor-grab touch-none items-center justify-center text-muted-foreground/50 opacity-0 transition-[color,opacity] hover:text-foreground active:cursor-grabbing group-hover/th:opacity-100 [@media(hover:none)]:opacity-100\",\n                              TOUCH_GESTURE_CLASS,\n                            )}\n                          >\n                            <GripVertical className=\"size-3.5\" />\n                          </button>\n                        ) : null}\n                        {column.sortable ? (\n                          <button\n                            type=\"button\"\n                            title=\"Click to sort, Shift+click to add a secondary sort\"\n                            onClick={(e) =>\n                              applySort(\n                                nextSortStack(sorts, column.key, e.shiftKey),\n                              )\n                            }\n                            className={cn(\n                              \"flex h-full min-w-0 flex-1 select-none items-center gap-1 outline-none transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:underline\",\n                              \"px-4\",\n                              filtered && \"pr-9\",\n                              alignFlex(column.align),\n                              sortState && \"text-foreground\",\n                            )}\n                          >\n                            <span className=\"truncate\">{column.header}</span>\n                            <SortIndicator\n                              direction={sortState?.direction ?? null}\n                              priority={sorts.length > 1 && sortState ? sortIndex + 1 : null}\n                              reduce={reduce}\n                            />\n                          </button>\n                        ) : (\n                          <span\n                            className={cn(\n                              \"min-w-0 flex-1 truncate\",\n                              \"px-4\",\n                              filtered && \"pr-9\",\n                              alignText(column.align),\n                            )}\n                          >\n                            {column.header}\n                          </span>\n                        )}\n                        <ColumnMenu\n                          column={column}\n                          thRefs={thRefs}\n                          position={index}\n                          sortState={sortState}\n                          filtered={filtered}\n                          filterValue={filters[column.key]}\n                          options={\n                            column.filter === \"select\" ? selectOptions(column) : null\n                          }\n                          canMoveLeft={reorderable && index > 0}\n                          canMoveRight={reorderable && !isLast}\n                          reorderable={reorderable}\n                          canHide={\n                            column.hideable !== false && visibleColumns.length > 1\n                          }\n                          onSort={(direction) =>\n                            applySort(\n                              direction\n                                ? [{ key: column.key, direction }]\n                                : sorts.filter((s) => s.key !== column.key),\n                            )\n                          }\n                          onFilter={(value) => setColumnFilter(column.key, value)}\n                          onMove={(dir) => moveColumn(column.key, dir)}\n                          onHide={() => toggleHidden(column.key)}\n                        />\n                      </div>\n                    </motion.th>\n                  );\n                })}\n              </tr>\n            </thead>\n            <tbody>\n              {pageRows.length === 0 ? (\n                <tr>\n                  <td\n                    colSpan={spanAll}\n                    className=\"px-4 py-12 text-center text-muted-foreground\"\n                  >\n                    <div className=\"flex flex-col items-center gap-2\">\n                      {emptyState}\n                      {clearFiltersButton}\n                    </div>\n                  </td>\n                </tr>\n              ) : (\n                pageRows.map((entry) => {\n                  const isSelected = selectedSet.has(entry.id);\n                  const isOpen = expandedSet.has(entry.id);\n                  const detailId = `${uid}-detail-${entry.id}`;\n                  const name = rowLabel(entry.row);\n                  return (\n                    <Fragment key={entry.id}>\n                      <motion.tr\n                        layout={rowLayout}\n                        transition={layoutTransition}\n                        initial={hydrated ? { opacity: 0 } : false}\n                        animate={{ opacity: 1 }}\n                        data-selected={isSelected || undefined}\n                        data-expanded={isOpen || undefined}\n                        className={cn(\n                          \"h-12 border-border/60 border-b transition-colors hover:bg-muted/40\",\n                          \"data-[selected=true]:bg-primary/[0.06] data-[expanded=true]:border-transparent\",\n                        )}\n                      >\n                        {selectable ? (\n                          <td className=\"select-none\">\n                            <div className=\"flex items-center justify-center\">\n                              <Checkbox\n                                checked={isSelected}\n                                onCheckedChange={(checked) =>\n                                  toggleRow(entry.id, checked)\n                                }\n                                aria-label={`Select ${name}`}\n                              />\n                            </div>\n                          </td>\n                        ) : null}\n                        {renderExpanded ? (\n                          <td>\n                            <div className=\"flex items-center justify-center\">\n                              <button\n                                type=\"button\"\n                                aria-expanded={isOpen}\n                                aria-controls={isOpen ? detailId : undefined}\n                                aria-label={`${isOpen ? \"Collapse\" : \"Expand\"} ${name}`}\n                                onClick={() => toggleExpanded(entry.id)}\n                                className=\"grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\"\n                              >\n                                <motion.span\n                                  aria-hidden\n                                  className=\"grid place-items-center\"\n                                  animate={{ rotate: isOpen ? 90 : 0 }}\n                                  transition={\n                                    reduce ? { duration: 0 } : SPRING_SWAP\n                                  }\n                                >\n                                  <ChevronRight className=\"size-4\" />\n                                </motion.span>\n                              </button>\n                            </div>\n                          </td>\n                        ) : null}\n                        {visibleColumns.map((column) => (\n                          <motion.td\n                            key={column.key}\n                            layout={rowLayout}\n                            transition={layoutTransition}\n                            className={cn(\n                              \"truncate px-4 text-foreground\",\n                              alignText(column.align),\n                            )}\n                          >\n                            {readCell(entry.row, column)}\n                          </motion.td>\n                        ))}\n                      </motion.tr>\n                      <AnimatePresence initial={false}>\n                        {renderExpanded && isOpen ? (\n                          <motion.tr\n                            key=\"detail\"\n                            layout={rowLayout}\n                            transition={layoutTransition}\n                            className=\"border-border/60 border-b\"\n                          >\n                            <td colSpan={spanAll} className=\"p-0\">\n                              {detailPanel(entry)}\n                            </td>\n                          </motion.tr>\n                        ) : null}\n                      </AnimatePresence>\n                    </Fragment>\n                  );\n                })\n              )}\n            </tbody>\n          </table>\n        </div>\n      )}\n\n      {paged ? (\n        <div className=\"flex flex-wrap items-center justify-between gap-2 border-border border-t px-3 py-2\">\n          <span className=\"text-muted-foreground text-xs tabular-nums\">\n            {sortedRows.length === 0\n              ? \"0 rows\"\n              : `${numberFormat.format(firstIndex + 1)}–${numberFormat.format(\n                  firstIndex + pageRows.length,\n                )} of ${numberFormat.format(sortedRows.length)}`}\n          </span>\n          {pageCount > 1 ? (\n            <Pagination\n              total={pageCount}\n              page={page}\n              onPageChange={setPage}\n              siblings={0}\n              aria-label=\"Table pages\"\n              className=\"[&_button]:size-8\"\n            />\n          ) : null}\n        </div>\n      ) : null}\n\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------------ */\n/* Header parts                                                              */\n/* ------------------------------------------------------------------------ */\n\nfunction SortIndicator({\n  direction,\n  priority,\n  reduce,\n}: {\n  direction: SortState[\"direction\"] | null;\n  priority: number | null;\n  reduce: boolean;\n}) {\n  return (\n    <span aria-hidden className=\"inline-flex shrink-0 items-center gap-0.5\">\n      <motion.span\n        className=\"inline-flex\"\n        initial={false}\n        animate={{\n          rotate: direction === \"desc\" ? 180 : 0,\n          opacity: direction ? 1 : 0,\n          y: direction || reduce ? 0 : 2,\n        }}\n        transition={reduce ? { duration: 0.12 } : SPRING_SWAP}\n      >\n        <ArrowUp className=\"size-3.5\" />\n      </motion.span>\n      <AnimatePresence initial={false}>\n        {priority ? (\n          <motion.span\n            key=\"priority\"\n            initial={{ opacity: 0, scale: reduce ? 1 : 0.5 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: reduce ? 1 : 0.5 }}\n            transition={reduce ? { duration: 0.1 } : SPRING_SWAP}\n            className=\"grid size-3.5 place-items-center rounded-full bg-primary font-semibold text-[9px] text-primary-foreground tabular-nums\"\n          >\n            {priority}\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction ColumnMenu<T>({\n  column,\n  thRefs,\n  position,\n  sortState,\n  filtered,\n  filterValue,\n  options,\n  canMoveLeft,\n  canMoveRight,\n  reorderable,\n  canHide,\n  onSort,\n  onFilter,\n  onMove,\n  onHide,\n  chip = false,\n}: {\n  /** Render the trigger as a labelled chip anchored to itself (stacked layout) instead of a header-cell button. */\n  chip?: boolean;\n  column: DataTableColumn<T>;\n  thRefs: HeaderCellRefs;\n  position: number;\n  sortState: SortState | null;\n  filtered: boolean;\n  filterValue: DataTableFilterValue | undefined;\n  options: string[] | null;\n  canMoveLeft: boolean;\n  canMoveRight: boolean;\n  reorderable: boolean;\n  canHide: boolean;\n  onSort: (direction: SortState[\"direction\"] | null) => void;\n  onFilter: (value: DataTableFilterValue) => void;\n  onMove: (dir: -1 | 1) => void;\n  onHide: () => void;\n}) {\n  const [open, setOpen] = useState(false);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const label = labelOf(column);\n  const close = useCallback(() => setOpen(false), []);\n\n  // Anchor to the whole header cell, so the panel lines up with the column.\n  const getAnchor = useCallback((): AnchorRect | null => {\n    const r = (\n      chip ? triggerRef.current : thRefs.current[column.key]\n    )?.getBoundingClientRect();\n    return r ? { left: r.left, right: r.right, bottom: r.bottom } : null;\n  }, [chip, thRefs, column.key]);\n\n  const hasSort = !!column.sortable;\n  const hasFilter = !!column.filter;\n  const selected = Array.isArray(filterValue) ? filterValue : [];\n  const hasAnything = hasSort || hasFilter || reorderable || canHide;\n  if (!hasAnything) return null;\n\n  return (\n    <>\n      {chip ? (\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          aria-haspopup=\"dialog\"\n          aria-expanded={open}\n          onClick={() => setOpen((v) => !v)}\n          className={cn(\n            \"inline-flex h-7 items-center gap-1 rounded-full border px-2.5 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 pointer-coarse:h-9 pointer-coarse:px-3\",\n            filtered\n              ? \"border-primary/40 bg-primary/10 text-foreground\"\n              : \"border-border bg-background text-muted-foreground\",\n          )}\n        >\n          {filtered ? (\n            <ListFilter aria-hidden className=\"size-3.5 text-primary\" />\n          ) : null}\n          {label}\n          <span className=\"sr-only\">\n            {filtered ? \" (filtered)\" : \"\"} column options\n          </span>\n          <ChevronDown aria-hidden className=\"size-3.5\" />\n        </button>\n      ) : (\n        <button\n          ref={triggerRef}\n          type=\"button\"\n          aria-label={`${label} column options`}\n          aria-haspopup=\"dialog\"\n          aria-expanded={open}\n          onClick={() => setOpen((v) => !v)}\n          className={cn(\n            \"-translate-y-1/2 absolute top-1/2 right-1 grid size-6 place-items-center rounded-md bg-muted outline-none transition-[color,background-color,opacity] hover:bg-background hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50\",\n            filtered || open\n              ? \"text-primary opacity-100\"\n              : \"opacity-0 group-hover/th:opacity-100 [@media(hover:none)]:opacity-100\",\n          )}\n        >\n          {filtered ? (\n            <ListFilter className=\"size-3.5\" />\n          ) : (\n            <MoreHorizontal className=\"size-3.5\" />\n          )}\n        </button>\n      )}\n      <Panel\n        open={open}\n        onClose={close}\n        getAnchor={getAnchor}\n        triggerRef={triggerRef}\n        label={`${label} column options`}\n        repositionKey={position}\n      >\n        {hasSort ? (\n          <>\n            <button\n              type=\"button\"\n              aria-pressed={sortState?.direction === \"asc\"}\n              onClick={() => onSort(\"asc\")}\n              className={PANEL_ITEM}\n            >\n              <ArrowUp />\n              Sort ascending\n            </button>\n            <button\n              type=\"button\"\n              aria-pressed={sortState?.direction === \"desc\"}\n              onClick={() => onSort(\"desc\")}\n              className={PANEL_ITEM}\n            >\n              <ArrowUp className=\"rotate-180\" />\n              Sort descending\n            </button>\n            {sortState ? (\n              <button\n                type=\"button\"\n                onClick={() => onSort(null)}\n                className={PANEL_ITEM}\n              >\n                <X />\n                Clear sort\n              </button>\n            ) : null}\n          </>\n        ) : null}\n        {hasFilter ? (\n          <>\n            {hasSort ? <div className={PANEL_DIVIDER} /> : null}\n            <div className={PANEL_HEADING}>Filter</div>\n            {column.filter === \"text\" ? (\n              <div className=\"px-1.5 pb-1.5\">\n                <input\n                  type=\"text\"\n                  value={typeof filterValue === \"string\" ? filterValue : \"\"}\n                  onChange={(e) => onFilter(e.target.value)}\n                  placeholder={`${label} contains…`}\n                  aria-label={`Filter ${label} (contains)`}\n                  className=\"h-8 w-full rounded-md border border-border bg-background px-2.5 text-sm outline-none placeholder:text-muted-foreground/70 focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/30\"\n                />\n              </div>\n            ) : (\n              <div className=\"max-h-44 overflow-y-auto px-1 pb-1 [scrollbar-color:var(--border-strong)_transparent] [scrollbar-width:thin]\">\n                {(options ?? []).map((option) => (\n                  <div\n                    key={option}\n                    className=\"rounded-lg px-1.5 py-1 hover:bg-muted\"\n                  >\n                    <Checkbox\n                      checked={selected.includes(option)}\n                      onCheckedChange={(checked) =>\n                        onFilter(\n                          checked\n                            ? [...selected, option]\n                            : selected.filter((o) => o !== option),\n                        )\n                      }\n                      label={option}\n                      className=\"w-full gap-2 text-sm [&_button]:size-4\"\n                    />\n                  </div>\n                ))}\n              </div>\n            )}\n            {filtered ? (\n              <button\n                type=\"button\"\n                onClick={() => onFilter(column.filter === \"text\" ? \"\" : [])}\n                className={PANEL_ITEM}\n              >\n                <X />\n                Clear filter\n              </button>\n            ) : null}\n          </>\n        ) : null}\n        {reorderable || canHide ? (\n          <>\n            {hasSort || hasFilter ? <div className={PANEL_DIVIDER} /> : null}\n            {reorderable ? (\n              <>\n                <button\n                  type=\"button\"\n                  disabled={!canMoveLeft}\n                  onClick={() => onMove(-1)}\n                  className={PANEL_ITEM}\n                >\n                  <ArrowLeft />\n                  Move left\n                </button>\n                <button\n                  type=\"button\"\n                  disabled={!canMoveRight}\n                  onClick={() => onMove(1)}\n                  className={PANEL_ITEM}\n                >\n                  <ArrowRight />\n                  Move right\n                </button>\n              </>\n            ) : null}\n            {canHide ? (\n              <button\n                type=\"button\"\n                onClick={() => {\n                  setOpen(false);\n                  onHide();\n                }}\n                className={PANEL_ITEM}\n              >\n                <EyeOff />\n                Hide column\n              </button>\n            ) : null}\n          </>\n        ) : null}\n      </Panel>\n    </>\n  );\n}\n\nfunction ColumnsPicker<T>({\n  columns,\n  hiddenSet,\n  visibleCount,\n  onToggle,\n}: {\n  columns: DataTableColumn<T>[];\n  hiddenSet: Set<string>;\n  visibleCount: number;\n  onToggle: (key: string) => void;\n}) {\n  const [open, setOpen] = useState(false);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const close = useCallback(() => setOpen(false), []);\n  const getAnchor = useCallback((): AnchorRect | null => {\n    const r = triggerRef.current?.getBoundingClientRect();\n    return r ? { left: r.left, right: r.right, bottom: r.bottom } : null;\n  }, []);\n  const hiddenCount = columns.filter((c) => hiddenSet.has(c.key)).length;\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        onClick={() => setOpen((v) => !v)}\n        className=\"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50\"\n      >\n        <Columns3 className=\"size-3.5 text-muted-foreground\" />\n        Columns\n        {hiddenCount > 0 ? (\n          <span className=\"rounded-full bg-muted px-1.5 text-[10px] text-muted-foreground tabular-nums\">\n            {hiddenCount} hidden\n          </span>\n        ) : null}\n      </button>\n      <Panel\n        open={open}\n        onClose={close}\n        getAnchor={getAnchor}\n        triggerRef={triggerRef}\n        label=\"Visible columns\"\n      >\n        <div className={PANEL_HEADING}>Visible columns</div>\n        {columns.map((column) => {\n          const visible = !hiddenSet.has(column.key);\n          const locked =\n            column.hideable === false || (visible && visibleCount <= 1);\n          return (\n            <div key={column.key} className=\"rounded-lg px-1.5 py-1 hover:bg-muted\">\n              <Checkbox\n                checked={visible}\n                disabled={locked}\n                onCheckedChange={() => onToggle(column.key)}\n                label={labelOf(column)}\n                className=\"w-full gap-2 text-sm [&_button]:size-4\"\n              />\n            </div>\n          );\n        })}\n      </Panel>\n    </>\n  );\n}\n"},{"path":"components/motion/table/stacked.tsx","type":"registry:component","target":"@components/motion/table/stacked.tsx","content":"\"use client\";\n\nimport {\n  type ReactNode,\n  type RefObject,\n  useId,\n  useLayoutEffect,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { SortState, TableColumn } from \"./types\";\n\n/*\n * Shared pieces of the stacked (\"card list\") layout both tables switch to in a\n * narrow container (`mobileLayout=\"stack\"`).\n */\n\n/** Container width (px) below which `mobileLayout=\"stack\"` shows cards. */\nexport const DEFAULT_STACK_BELOW = 560;\n\n/**\n * True once the element measures narrower than `stackBelow`. Starts false, so\n * the server and the hydrating client both render the table, then switches in\n * a layout effect (before the first paint after hydration) and follows the\n * element's width from there. The element must not size to its content, or the\n * swap could feed back into the measurement — both tables measure their\n * full-width root.\n */\nexport function useStackedLayout(\n  ref: RefObject<HTMLElement | null>,\n  enabled: boolean,\n  stackBelow: number,\n) {\n  const [stacked, setStacked] = useState(false);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!enabled || !el) {\n      setStacked(false);\n      return;\n    }\n    const update = (width: number) => setStacked(width < stackBelow);\n    update(el.getBoundingClientRect().width);\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver((entries) => {\n      const entry = entries[entries.length - 1];\n      if (entry) update(entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width);\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref, enabled, stackBelow]);\n  return enabled && stacked;\n}\n\n/** Plain-text name of a column, for option labels and announcements. */\nexport function columnLabel<T>(column: TableColumn<T>) {\n  return typeof column.header === \"string\" ? column.header : column.key;\n}\n\n/**\n * Splits visible columns into the card title (`primary` columns, or the first\n * column when none opts in) and the label/value fields under it, leaving out\n * `hideOnMobile` columns. A primary column is always shown.\n */\nexport function splitStackColumns<C extends TableColumn<never>>(columns: C[]) {\n  const flagged = columns.filter((c) => c.primary);\n  const primary = flagged.length > 0 ? flagged : columns.slice(0, 1);\n  const titleKeys = new Set(primary.map((c) => c.key));\n  const fields = columns.filter((c) => !titleKeys.has(c.key) && !c.hideOnMobile);\n  return { primary, fields };\n}\n\n/** Rough card height before measurement: title row + one line per field. */\nexport function estimateCardHeight(fieldCount: number) {\n  return 56 + fieldCount * 28;\n}\n\nexport type StackedField = {\n  key: string;\n  label: ReactNode;\n  value: ReactNode;\n  /** The value is an input: let it take the full width instead of hugging the right edge. */\n  control?: boolean;\n};\n\n/**\n * A card's label/value pairs as a description list, so each value is read\n * with its column header.\n */\nexport function StackedFields({\n  fields,\n  className,\n}: {\n  fields: StackedField[];\n  className?: string;\n}) {\n  if (fields.length === 0) return null;\n  return (\n    <dl className={cn(\"grid gap-1.5\", className)}>\n      {fields.map((field) => (\n        <div key={field.key} className=\"flex min-w-0 items-center gap-3\">\n          <dt className=\"w-2/5 max-w-40 shrink-0 truncate text-muted-foreground text-xs\">\n            {field.label}\n          </dt>\n          <dd\n            className={cn(\n              \"min-w-0 flex-1 text-foreground\",\n              field.control ? \"pl-2\" : \"truncate text-right\",\n            )}\n          >\n            {field.value}\n          </dd>\n        </div>\n      ))}\n    </dl>\n  );\n}\n\nconst SORT_NONE = \"\";\n\n/**\n * Compact sort control shown above the cards, where there are no headers to\n * click: one native select of \"column, direction\" pairs.\n */\nexport function StackSortSelect<T>({\n  columns,\n  value,\n  onChange,\n  className,\n}: {\n  columns: TableColumn<T>[];\n  value: SortState | null;\n  onChange: (next: SortState | null) => void;\n  className?: string;\n}) {\n  const id = useId();\n  const sortable = columns.filter((c) => c.sortable);\n  if (sortable.length === 0) return null;\n  const current =\n    value && sortable.some((c) => c.key === value.key)\n      ? `${value.direction}:${value.key}`\n      : SORT_NONE;\n  return (\n    <div className={cn(\"flex min-w-0 items-center gap-2\", className)}>\n      <label htmlFor={id} className=\"shrink-0 text-muted-foreground text-xs\">\n        Sort by\n      </label>\n      <select\n        id={id}\n        value={current}\n        onChange={(e) => {\n          const raw = e.target.value;\n          if (raw === SORT_NONE) return onChange(null);\n          const split = raw.indexOf(\":\");\n          onChange({\n            direction: raw.slice(0, split) === \"desc\" ? \"desc\" : \"asc\",\n            key: raw.slice(split + 1),\n          });\n        }}\n        className=\"h-8 min-w-0 max-w-full rounded-lg border border-border bg-background px-2 text-foreground text-sm outline-none transition-colors focus-visible:border-border-strong focus-visible:ring-2 focus-visible:ring-ring/30\"\n      >\n        <option value={SORT_NONE}>None</option>\n        {sortable.map((column) => {\n          const label = columnLabel(column);\n          return [\n            <option key={`asc:${column.key}`} value={`asc:${column.key}`}>\n              {label} (ascending)\n            </option>,\n            <option key={`desc:${column.key}`} value={`desc:${column.key}`}>\n              {label} (descending)\n            </option>,\n          ];\n        })}\n      </select>\n    </div>\n  );\n}\n"},{"path":"components/motion/table/types.ts","type":"registry:component","target":"@components/motion/table/types.ts","content":"import type { ReactNode } from \"react\";\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport type SortState = {\n  key: string;\n  direction: SortDirection;\n};\n\nexport type TableColumn<T> = {\n  /** Stable key; also the default object property read for the cell + sort value. */\n  key: string;\n  /** Header content. */\n  header: ReactNode;\n  /** Allow clicking the header to sort by this column. */\n  sortable?: boolean;\n  /** Cell text alignment. */\n  align?: \"left\" | \"center\" | \"right\";\n  /** Column width as a CSS length, e.g. \"160px\" or \"20%\". Omit to share remaining space equally. */\n  width?: string;\n  /** Custom cell renderer. Falls back to `row[key]`. */\n  cell?: (row: T) => ReactNode;\n  /** Render an inline text input for this column's cells (ignored when `cell` is set). */\n  editable?: boolean;\n  /** Value used for sorting. Falls back to `row[key]`. */\n  sortValue?: (row: T) => string | number;\n  /** Stacked layout (`mobileLayout=\"stack\"`): show this cell in the card title.\n   * Defaults to the first column when no column sets it. */\n  primary?: boolean;\n  /** Stacked layout: leave this column out of the card's label/value pairs. */\n  hideOnMobile?: boolean;\n};\n\n/**\n * Narrow-container layout. `\"scroll\"` keeps the table and scrolls it sideways;\n * `\"stack\"` renders one card per row while the table's own width is below\n * `stackBelow`.\n */\nexport type MobileLayout = \"scroll\" | \"stack\";\n\nexport type InsertPosition = \"before\" | \"after\";\n\nexport interface TableProps<T> {\n  data: T[];\n  columns: TableColumn<T>[];\n  /** Stable id per row, required for correct selection across sorts. Defaults to row index. */\n  getRowId?: (row: T, index: number) => string;\n  /** Render a leading checkbox column with select-all in the header. */\n  selectable?: boolean;\n  selectedRowIds?: string[];\n  defaultSelectedRowIds?: string[];\n  onSelectionChange?: (ids: string[]) => void;\n  sort?: SortState | null;\n  defaultSort?: SortState | null;\n  onSortChange?: (sort: SortState | null) => void;\n  /** Allow dragging the right edge of a header to resize that column. */\n  resizable?: boolean;\n  /** Minimum column width in px when resizing. */\n  minColumnWidth?: number;\n  onColumnResize?: (key: string, width: number) => void;\n  /** Allow dragging a header grip to reorder columns. */\n  reorderable?: boolean;\n  onColumnOrderChange?: (keys: string[]) => void;\n  /** Called when an `editable` cell changes. */\n  onCellEdit?: (rowId: string, columnKey: string, value: string) => void;\n  /** When set, non-sortable headers become editable inputs for the column name. */\n  onColumnRename?: (columnKey: string, value: string) => void;\n  /** Enables the row menu (Insert before / after). Receives the target index. */\n  onInsertRow?: (index: number, position: InsertPosition) => void;\n  /** Enables Delete in the row menu. */\n  onDeleteRow?: (rowId: string, index: number) => void;\n  /** Enables the column menu (Insert before / after). Receives the target column index. */\n  onInsertColumn?: (index: number, position: InsertPosition) => void;\n  /** Enables Delete in the column menu. */\n  onDeleteColumn?: (columnKey: string, index: number) => void;\n  /** Fixed row height in px — required for virtualization. */\n  rowHeight?: number;\n  /** Scroll viewport height in px. */\n  height?: number;\n  /** Rows rendered above/below the viewport. */\n  overscan?: number;\n  /** Fires when the viewport scrolls near the bottom — load the next page. */\n  onEndReached?: () => void;\n  /** Currently fetching — shows skeleton rows and pauses `onEndReached`. */\n  loading?: boolean;\n  /** How many skeleton rows to show while loading more (default 3). */\n  skeletonRows?: number;\n  emptyState?: ReactNode;\n  /**\n   * `\"scroll\"` (default) keeps today's sideways-scrolling table. `\"stack\"`\n   * swaps to a virtualized card list (title from `primary` columns, the rest as\n   * header: value pairs, editable cells stay inputs) whenever the table's own\n   * width is below `stackBelow`. The server always renders the table.\n   */\n  mobileLayout?: MobileLayout;\n  /** Width in px under which `mobileLayout=\"stack\"` shows cards (default 560). */\n  stackBelow?: number;\n  className?: string;\n}\n\n/** A data row paired with its stable id. */\nexport type TableRow<T> = { row: T; id: string };\n\n/** Ref map from column key to its header cell, shared across the resize/reorder hooks. */\nexport type HeaderCellRefs = {\n  current: Record<string, HTMLTableCellElement | null>;\n};\n"},{"path":"components/motion/table/use-column-reorder.ts","type":"registry:component","target":"@components/motion/table/use-column-reorder.ts","content":"import {\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useMemo,\n  useState,\n} from \"react\";\nimport { capturePointer, releasePointer } from \"@/lib/touch\";\nimport type { HeaderCellRefs, TableColumn } from \"./types\";\n\nexport function useColumnReorder<T>({\n  columns,\n  thRefs,\n  order: orderProp,\n  defaultOrder,\n  onColumnOrderChange,\n}: {\n  columns: TableColumn<T>[];\n  thRefs: HeaderCellRefs;\n  /** Controlled column key order. Omit to keep the order internally. */\n  order?: string[];\n  /** Initial order for uncontrolled use; defaults to the `columns` order. */\n  defaultOrder?: string[];\n  onColumnOrderChange?: (keys: string[]) => void;\n}) {\n  const [internalOrder, setOrder] = useState<string[]>(\n    () => defaultOrder ?? columns.map((c) => c.key),\n  );\n  const order = orderProp ?? internalOrder;\n  const [dragKey, setDragKey] = useState<string | null>(null);\n  const [dropIndex, setDropIndex] = useState<number | null>(null);\n\n  // Apply the current order, tolerating columns added/removed at runtime. New\n  // columns are placed at their position in `columns` (after their left\n  // neighbor), not appended — so an inserted column lands where it was added.\n  const orderedColumns = useMemo(() => {\n    const byKey = new Map(columns.map((c) => [c.key, c]));\n    const resultKeys = order.filter((k) => byKey.has(k));\n    const present = new Set(resultKeys);\n    columns.forEach((column, i) => {\n      if (present.has(column.key)) return;\n      let at = resultKeys.length;\n      if (i === 0) {\n        at = 0;\n      } else {\n        const idx = resultKeys.indexOf(columns[i - 1].key);\n        at = idx === -1 ? i : idx + 1;\n      }\n      resultKeys.splice(at, 0, column.key);\n      present.add(column.key);\n    });\n    return resultKeys\n      .map((k) => byKey.get(k))\n      .filter((c): c is TableColumn<T> => c !== undefined);\n  }, [order, columns]);\n\n  const dropIndexFor = useCallback(\n    (clientX: number) => {\n      for (let i = 0; i < orderedColumns.length; i++) {\n        const rect =\n          thRefs.current[orderedColumns[i].key]?.getBoundingClientRect();\n        if (rect && clientX < rect.left + rect.width / 2) return i;\n      }\n      return orderedColumns.length;\n    },\n    [orderedColumns, thRefs],\n  );\n\n  /** Commit a full key order — the drop of a drag, or a keyboard move. */\n  const commitOrder = useCallback(\n    (keys: string[]) => {\n      if (orderProp === undefined) setOrder(keys);\n      onColumnOrderChange?.(keys);\n    },\n    [orderProp, onColumnOrderChange],\n  );\n\n  const startReorder = useCallback((key: string, e: ReactPointerEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setDragKey(key);\n    capturePointer(e.currentTarget, e.pointerId);\n  }, []);\n\n  const moveReorder = useCallback(\n    (e: ReactPointerEvent) => {\n      if (!dragKey) return;\n      setDropIndex(dropIndexFor(e.clientX));\n    },\n    [dragKey, dropIndexFor],\n  );\n\n  const endReorder = useCallback(\n    (e: ReactPointerEvent) => {\n      releasePointer(e.currentTarget, e.pointerId);\n      if (dragKey && dropIndex !== null) {\n        const keys = orderedColumns.map((c) => c.key);\n        const from = keys.indexOf(dragKey);\n        if (from !== -1) {\n          const without = keys.filter((_, i) => i !== from);\n          let to = dropIndex;\n          if (from < to) to--;\n          without.splice(to, 0, dragKey);\n          commitOrder(without);\n        }\n      }\n      setDragKey(null);\n      setDropIndex(null);\n    },\n    [dragKey, dropIndex, orderedColumns, commitOrder],\n  );\n\n  return {\n    orderedColumns,\n    dragKey,\n    dropIndex,\n    startReorder,\n    moveReorder,\n    endReorder,\n    commitOrder,\n  };\n}\n"},{"path":"components/motion/table/utils.ts","type":"registry:component","target":"@components/motion/table/utils.ts","content":"import type { ReactNode } from \"react\";\nimport type { TableColumn } from \"./types\";\n\nexport const CHECKBOX_PX = 48;\nexport const CHECKBOX_WIDTH = `${CHECKBOX_PX}px`;\n\n/** Highlights the top edge of the active column's header cell. */\nexport const COLUMN_ACTIVE_SHADOW = \"inset 0 1px 0 var(--color-primary)\";\n\nexport function alignFlex(align: TableColumn<unknown>[\"align\"]) {\n  if (align === \"right\") return \"justify-end\";\n  if (align === \"center\") return \"justify-center\";\n  return \"justify-start\";\n}\n\nexport function alignText(align: TableColumn<unknown>[\"align\"]) {\n  if (align === \"right\") return \"text-right\";\n  if (align === \"center\") return \"text-center\";\n  return \"text-left\";\n}\n\nexport function readCell<T>(row: T, column: TableColumn<T>): ReactNode {\n  if (column.cell) return column.cell(row);\n  return (row as Record<string, ReactNode>)[column.key];\n}\n\nexport function readSortValue<T>(\n  row: T,\n  column: TableColumn<T>,\n): string | number {\n  if (column.sortValue) return column.sortValue(row);\n  return (row as Record<string, string | number>)[column.key];\n}\n"},{"path":"components/motion/checkbox.tsx","type":"registry:component","target":"@components/motion/checkbox.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useId } from \"react\";\nimport { EASE_OUT, SPRING_PRESS } from \"@/lib/ease\";\nimport { TOUCH_TARGET_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nconst CHECK_PATH = \"M5 13l4 4L19 7\";\nconst INDETERMINATE_PATH = \"M6 12h12\";\n\nexport interface CheckboxProps {\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n  disabled?: boolean;\n  indeterminate?: boolean;\n  label?: string;\n  className?: string;\n  id?: string;\n  \"aria-label\"?: string;\n  /** Associates an external message (e.g. a form error) with the control. */\n  \"aria-describedby\"?: string;\n}\n\nexport function Checkbox({\n  checked,\n  onCheckedChange,\n  disabled,\n  indeterminate,\n  label,\n  className,\n  id: idProp,\n  \"aria-label\": ariaLabel,\n  \"aria-describedby\": ariaDescribedBy,\n}: CheckboxProps) {\n  const autoId = useId();\n  const id = idProp ?? autoId;\n  const reduce = useReducedMotion();\n  const showMark = checked || indeterminate;\n  const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;\n\n  return (\n    <label\n      htmlFor={id}\n      className={cn(\n        \"inline-flex items-center gap-3\",\n        disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n        className,\n      )}\n    >\n      <motion.button\n        id={id}\n        type=\"button\"\n        role=\"checkbox\"\n        aria-checked={indeterminate ? \"mixed\" : checked}\n        aria-label={ariaLabel}\n        aria-describedby={ariaDescribedBy}\n        disabled={disabled}\n        onClick={() => !disabled && onCheckedChange(!checked)}\n        whileTap={reduce || disabled ? undefined : { scale: 0.92 }}\n        transition={SPRING_PRESS}\n        data-state={\n          checked ? \"checked\" : indeterminate ? \"indeterminate\" : \"unchecked\"\n        }\n        className={cn(\n          \"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200\",\n          TOUCH_TARGET_CLASS,\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"disabled:cursor-not-allowed disabled:opacity-60\",\n          showMark\n            ? \"border-primary bg-primary text-primary-foreground\"\n            : \"border-muted-foreground/50 bg-background hover:border-muted-foreground\",\n        )}\n      >\n        <AnimatePresence initial={false}>\n          {showMark ? (\n            <motion.svg\n              key={indeterminate ? \"indeterminate\" : \"checked\"}\n              width=\"12\"\n              height=\"12\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={3}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}\n              animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}\n              exit={\n                reduce\n                  ? { opacity: 0 }\n                  : { opacity: 0, scale: 0.5, filter: \"blur(4px)\" }\n              }\n              transition={\n                reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }\n              }\n              aria-hidden\n            >\n              <title>{indeterminate ? \"Partially selected\" : \"Selected\"}</title>\n              <motion.path\n                d={path}\n                initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}\n                animate={{ pathLength: 1 }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : {\n                        duration: indeterminate ? 0.2 : 0.3,\n                        ease: EASE_OUT,\n                        delay: 0.04,\n                      }\n                }\n              />\n            </motion.svg>\n          ) : null}\n        </AnimatePresence>\n      </motion.button>\n      {label ? (\n        <span className={cn(\"select-none text-sm text-foreground\", disabled && \"opacity-60\")}>\n          {label}\n        </span>\n      ) : null}\n    </label>\n  );\n}\n"},{"path":"components/motion/pagination.tsx","type":"registry:component","target":"@components/motion/pagination.tsx","content":"\"use client\";\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport { useCallback, useId, useState } from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { TOUCH_TARGET_CLASS, TOUCH_TARGET_Y_CLASS } from \"@/lib/touch\";\nimport { cn } from \"@/lib/utils\";\n\nexport type PaginationVariant = \"numbers\" | \"dots\";\nexport type PaginationItem = number | \"ellipsis\";\n\nexport interface PaginationProps {\n  /** Total number of pages (1-based). */\n  total: number;\n  /** Controlled current page (1-based). */\n  page?: number;\n  /** Initial page for uncontrolled use. */\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n  /** Pages shown on each side of the current page. */\n  siblings?: number;\n  /** Pages always shown at the start and end. */\n  boundaries?: number;\n  /** `numbers` renders page buttons; `dots` is a compact indicator row. */\n  variant?: PaginationVariant;\n  /** Most dots shown at once in the `dots` variant; the window follows the page. */\n  maxDots?: number;\n  /** Render previous / next controls. */\n  showControls?: boolean;\n  disabled?: boolean;\n  /** Accessible name for the `<nav>` landmark. */\n  \"aria-label\"?: string;\n  /** Accessible name per page button. */\n  getPageLabel?: (page: number) => string;\n  className?: string;\n}\n\nfunction range(start: number, end: number) {\n  const out: number[] = [];\n  for (let i = start; i <= end; i++) out.push(i);\n  return out;\n}\n\n/**\n * Page items with ellipses. The item count stays constant once ellipses\n * appear, so slots keep their position and only their numbers change.\n */\nexport function getPaginationItems(\n  total: number,\n  page: number,\n  siblings = 1,\n  boundaries = 1,\n): PaginationItem[] {\n  const count = Math.max(0, Math.floor(total));\n  const sib = Math.max(0, Math.floor(siblings));\n  const bound = Math.max(1, Math.floor(boundaries));\n  // boundaries + siblings + current + two ellipses\n  const slots = sib * 2 + 3 + bound * 2;\n  if (count <= slots) return range(1, count);\n\n  const left = Math.max(page - sib, bound + 1);\n  const right = Math.min(page + sib, count - bound);\n  const showLeft = left > bound + 2;\n  const showRight = right < count - bound - 1;\n\n  if (!showLeft && showRight) {\n    const leftCount = sib * 2 + bound + 2;\n    return [\n      ...range(1, leftCount),\n      \"ellipsis\",\n      ...range(count - bound + 1, count),\n    ];\n  }\n  if (showLeft && !showRight) {\n    const rightCount = sib * 2 + bound + 2;\n    return [\n      ...range(1, bound),\n      \"ellipsis\",\n      ...range(count - rightCount + 1, count),\n    ];\n  }\n  return [\n    ...range(1, bound),\n    \"ellipsis\",\n    ...range(left, right),\n    \"ellipsis\",\n    ...range(count - bound + 1, count),\n  ];\n}\n\nfunction clamp(value: number, total: number) {\n  return Math.min(Math.max(1, Math.round(value)), Math.max(1, total));\n}\n\n// Numbers roll in the direction of travel when the window shifts.\nconst ROLL_VARIANTS: Variants = {\n  initial: (dir: number) => ({ opacity: 0, y: dir >= 0 ? \"100%\" : \"-100%\" }),\n  animate: { opacity: 1, y: \"0%\", transition: SPRING_SWAP },\n  exit: (dir: number) => ({\n    opacity: 0,\n    y: dir >= 0 ? \"-100%\" : \"100%\",\n    transition: { duration: 0.14, ease: EASE_OUT },\n  }),\n};\n\nconst FADE_VARIANTS: Variants = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1, transition: { duration: 0.15 } },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\nconst ITEM_CLASS =\n  \"relative grid size-9 shrink-0 place-items-center rounded-full text-sm tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40\";\n\nexport function Pagination({\n  total,\n  page,\n  defaultPage = 1,\n  onPageChange,\n  siblings = 1,\n  boundaries = 1,\n  variant = \"numbers\",\n  maxDots = 7,\n  showControls = true,\n  disabled = false,\n  \"aria-label\": ariaLabel = \"Pagination\",\n  getPageLabel = (p) => `Page ${p}`,\n  className,\n}: PaginationProps) {\n  const reduce = useReducedMotion() ?? false;\n  const canHover = useHoverCapable();\n  const groupId = useId();\n  const [internal, setInternal] = useState(() => clamp(defaultPage, total));\n  const controlled = page !== undefined;\n  const current = clamp(controlled ? page : internal, total);\n\n  // Direction of the last move drives the roll. Derived during render with\n  // the setState-in-render pattern so it is ready on the same pass.\n  const [track, setTrack] = useState({ page: current, dir: 0 });\n  if (track.page !== current) {\n    setTrack({ page: current, dir: current > track.page ? 1 : -1 });\n  }\n  const direction = track.page !== current\n    ? current > track.page ? 1 : -1\n    : track.dir;\n\n  const go = useCallback(\n    (next: number) => {\n      const target = clamp(next, total);\n      if (target === current || disabled) return;\n      if (!controlled) setInternal(target);\n      onPageChange?.(target);\n    },\n    [controlled, current, disabled, onPageChange, total],\n  );\n\n  const nudge = !reduce && canHover;\n  const rollVariants = reduce ? FADE_VARIANTS : ROLL_VARIANTS;\n  const pillTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n\n  const control = (dir: -1 | 1) => {\n    const isPrev = dir === -1;\n    const Icon = isPrev ? ChevronLeft : ChevronRight;\n    const atEdge = isPrev ? current <= 1 : current >= total;\n    return (\n      <motion.button\n        type=\"button\"\n        aria-label={isPrev ? \"Previous page\" : \"Next page\"}\n        disabled={disabled || atEdge || total < 1}\n        onClick={() => go(current + dir)}\n        initial=\"rest\"\n        animate=\"rest\"\n        whileHover={nudge ? \"hover\" : undefined}\n        whileTap={reduce ? undefined : { scale: 0.9 }}\n        transition={SPRING_PRESS}\n        className={cn(\n          ITEM_CLASS,\n          TOUCH_TARGET_CLASS,\n          \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n          variant === \"dots\" && \"size-8\",\n        )}\n      >\n        <motion.span\n          aria-hidden=\"true\"\n          variants={{ rest: { x: 0 }, hover: { x: isPrev ? -2 : 2 } }}\n          transition={SPRING_PRESS}\n          className=\"grid place-items-center\"\n        >\n          <Icon className=\"size-4\" />\n        </motion.span>\n      </motion.button>\n    );\n  };\n\n  if (variant === \"dots\") {\n    const size = Math.max(3, Math.min(maxDots, total));\n    const start = Math.min(\n      Math.max(1, current - Math.floor(size / 2)),\n      Math.max(1, total - size + 1),\n    );\n    const dots = range(start, Math.min(total, start + size - 1));\n    const first = dots[0] ?? 1;\n    const last = dots[dots.length - 1] ?? 1;\n\n    return (\n      <nav aria-label={ariaLabel} className={cn(\"inline-flex items-center gap-1\", className)}>\n        {showControls ? control(-1) : null}\n        <LayoutGroup id={groupId}>\n          <ul className=\"flex items-center gap-0.5\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {dots.map((p) => {\n                const active = p === current;\n                // Shrink edge dots when more pages hide beyond them.\n                const edge =\n                  (p === first && first > 1) || (p === last && last < total);\n                return (\n                  <motion.li\n                    key={p}\n                    layout={reduce ? false : \"position\"}\n                    initial={{ opacity: 0, scale: reduce ? 1 : 0.4 }}\n                    animate={{ opacity: 1, scale: edge && !reduce ? 0.6 : 1 }}\n                    exit={{ opacity: 0, scale: reduce ? 1 : 0.4 }}\n                    transition={reduce ? { duration: 0.12 } : SPRING_LAYOUT}\n                    className=\"flex\"\n                  >\n                    <button\n                      type=\"button\"\n                      aria-label={getPageLabel(p)}\n                      aria-current={active ? \"page\" : undefined}\n                      disabled={disabled}\n                      onClick={() => go(p)}\n                      className={cn(\n                        \"group relative grid h-5 w-5 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:opacity-40\",\n                        TOUCH_TARGET_Y_CLASS,\n                      )}\n                    >\n                      <span className=\"size-2 rounded-full bg-muted-foreground/35 transition-colors group-hover:bg-muted-foreground/60\" />\n                      {active ? (\n                        <motion.span\n                          layoutId=\"pagination-dot\"\n                          transition={pillTransition}\n                          className=\"absolute inset-x-0 top-1.5 h-2 rounded-full bg-foreground\"\n                        />\n                      ) : null}\n                    </button>\n                  </motion.li>\n                );\n              })}\n            </AnimatePresence>\n          </ul>\n        </LayoutGroup>\n        {showControls ? control(1) : null}\n      </nav>\n    );\n  }\n\n  const items = getPaginationItems(total, current, siblings, boundaries);\n\n  return (\n    <nav aria-label={ariaLabel} className={cn(\"inline-flex\", className)}>\n      <ul className=\"flex items-center gap-1\">\n        {showControls ? <li className=\"flex\">{control(-1)}</li> : null}\n        <LayoutGroup id={groupId}>\n          {items.map((item, index) => {\n            const active = item === current;\n            const itemKey = String(item);\n            return (\n              // Slots are keyed by position so buttons (and their focus)\n              // persist while the numbers inside roll to new values.\n              // biome-ignore lint/suspicious/noArrayIndexKey: positional slots are the point.\n              <li key={index} className=\"relative flex\">\n                {active ? (\n                  <motion.span\n                    layoutId=\"pagination-pill\"\n                    transition={pillTransition}\n                    className=\"absolute inset-0 rounded-full bg-primary\"\n                  />\n                ) : null}\n                {item === \"ellipsis\" ? (\n                  <span\n                    aria-hidden=\"true\"\n                    className={cn(ITEM_CLASS, \"text-muted-foreground\")}\n                  >\n                    …\n                  </span>\n                ) : (\n                  <motion.button\n                    type=\"button\"\n                    aria-label={getPageLabel(item)}\n                    aria-current={active ? \"page\" : undefined}\n                    disabled={disabled}\n                    onClick={() => go(item)}\n                    whileTap={reduce ? undefined : { scale: 0.9 }}\n                    transition={SPRING_PRESS}\n                    className={cn(\n                      ITEM_CLASS,\n                      TOUCH_TARGET_CLASS,\n                      active\n                        ? \"text-primary-foreground\"\n                        : \"text-muted-foreground hover:bg-muted hover:text-foreground\",\n                    )}\n                  >\n                    {/* The roll clips here rather than on the button, so the\n                        button's touch hit area can overhang it. */}\n                    <span className=\"relative grid size-full place-items-center overflow-hidden rounded-[inherit]\">\n                      <AnimatePresence\n                        initial={false}\n                        mode=\"popLayout\"\n                        custom={direction}\n                      >\n                        <motion.span\n                          key={itemKey}\n                          custom={direction}\n                          variants={rollVariants}\n                          initial=\"initial\"\n                          animate=\"animate\"\n                          exit=\"exit\"\n                          className=\"col-start-1 row-start-1\"\n                        >\n                          {item}\n                        </motion.span>\n                      </AnimatePresence>\n                    </span>\n                  </motion.button>\n                )}\n              </li>\n            );\n          })}\n        </LayoutGroup>\n        {showControls ? <li className=\"flex\">{control(1)}</li> : null}\n      </ul>\n    </nav>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.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"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"}]}