{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-table",
  "title": "Sortable table",
  "description": "Sortable table is a production-ready tables component from BJORK UI, packaged for shadcn registries.",
  "dependencies": [
    "clsx",
    "framer-motion",
    "lucide-react",
    "next-themes",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "components/bjork-ui/tables/sortable-table.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n  type MouseEvent as ReactMouseEvent,\n  type ReactNode,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\";\nimport { useTheme } from \"next-themes\";\nimport { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Columns3, Search, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { useElementSize } from \"../_core/canvas\";\nimport {\n  type BjorkTableThemeMode,\n  getBjorkSignalPalette,\n  getBjorkTablePalette,\n  useBjorkTableIsDark,\n} from \"./table-theme\";\n\nexport type SortableColumnFormat = \"number\" | \"currency\" | \"date\" | \"text\";\nexport type SortDirection = \"asc\" | \"desc\";\nexport type SortableValue = string | number | Date | boolean | null | undefined;\n\nexport interface SortRule {\n  key: string;\n  direction: SortDirection;\n}\n\nexport interface SortableCellContext {\n  /** Raw value from the accessor. */\n  value: SortableValue;\n  /** Value run through the column format (what the cell shows by default). */\n  formatted: string;\n  isDark: boolean;\n}\n\nexport interface SortableColumn<T> {\n  key: string;\n  header: string;\n  /** Property name or function returning the sortable, searchable value. Defaults to `row[key]`. */\n  accessor?: keyof T | ((row: T) => SortableValue);\n  /** Custom cell renderer. Sorting and search still use the accessor value. */\n  cell?: (row: T, context: SortableCellContext) => ReactNode;\n  /** Defaults to true. */\n  sortable?: boolean;\n  /** Defaults to \"right\" for number/currency/date, \"left\" otherwise. */\n  align?: \"left\" | \"right\" | \"center\";\n  /** Column width in px (or any CSS width). */\n  width?: number | string;\n  format?: SortableColumnFormat;\n  /** ISO 4217 code for `format: \"currency\"`. Falls back to the table's `currency`. */\n  currency?: string;\n  /** Can the column be hidden from the Columns menu? Defaults to true. */\n  hideable?: boolean;\n  defaultHidden?: boolean;\n  /** Include the column in the global search. Defaults to true. */\n  searchable?: boolean;\n}\n\nexport interface SortableTableProps<T> {\n  columns?: SortableColumn<T>[];\n  rows?: T[];\n  getRowId?: (row: T, index: number) => string;\n  /** Accessible name for a row's checkbox. Defaults to the first column's value plus the row id. */\n  getRowLabel?: (row: T) => string;\n  /** Screen-reader caption for the table. */\n  caption?: string;\n  title?: string;\n  /** Noun used in the range label, e.g. \"1–10 of 48 deployments\". */\n  itemLabel?: string;\n  searchPlaceholder?: string;\n  pageSizeOptions?: number[];\n  defaultPageSize?: number;\n  defaultSort?: SortRule[];\n  defaultSelectedIds?: string[];\n  /** Hold shift while clicking a header to sort by more than one column. Defaults to true. */\n  multiSort?: boolean;\n  selectable?: boolean;\n  onSelectionChange?: (selectedIds: string[]) => void;\n  onSortChange?: (sort: SortRule[]) => void;\n  loading?: boolean;\n  /** Body scrolls under a sticky header past this height (px). */\n  maxBodyHeight?: number;\n  locale?: string;\n  currency?: string;\n  /** Time zone used by `format: \"date\"` columns. Defaults to UTC so server and client agree. */\n  timeZone?: string;\n  className?: string;\n  theme?: BjorkTableThemeMode;\n  enableAnimations?: boolean;\n}\n\n/* ------------------------------------------------------------------ */\n/* Sample data                                                         */\n/* ------------------------------------------------------------------ */\n\nexport type DeploymentStatus = \"Ready\" | \"Building\" | \"Failed\" | \"Canceled\";\n\nexport interface Deployment {\n  id: string;\n  service: string;\n  status: DeploymentStatus;\n  environment: \"Production\" | \"Preview\" | \"Staging\";\n  branch: string;\n  author: string;\n  durationSec: number;\n  cost: number;\n  createdAt: string;\n}\n\nconst SAMPLE_SERVICES = [\n  \"checkout-api\",\n  \"ledger-sync\",\n  \"search-indexer\",\n  \"auth-gateway\",\n  \"media-resizer\",\n  \"notify-worker\",\n  \"billing-web\",\n  \"atlas-dashboard\",\n];\nconst SAMPLE_BRANCHES = [\n  \"main\",\n  \"feat/pricing-v2\",\n  \"fix/retry-backoff\",\n  \"chore/bump-deps\",\n  \"feat/bulk-export\",\n  \"release/4.12\",\n  \"fix/tz-rollover\",\n];\nconst SAMPLE_AUTHORS = [\"ana.k\", \"devraj\", \"m.oyel\", \"tsuki\", \"pbravo\", \"linnea\"];\nconst SAMPLE_ENVS: Deployment[\"environment\"][] = [\"Production\", \"Preview\", \"Staging\", \"Preview\"];\nconst SAMPLE_STATUSES: DeploymentStatus[] = [\n  \"Ready\", \"Ready\", \"Ready\", \"Building\", \"Ready\", \"Failed\", \"Ready\", \"Ready\", \"Canceled\", \"Ready\",\n];\n\nconst SAMPLE_STATUS_OVERRIDES: Partial<Record<number, DeploymentStatus>> = {\n  0: \"Building\",\n  1: \"Ready\",\n  2: \"Failed\",\n  3: \"Ready\",\n  4: \"Ready\",\n  5: \"Ready\",\n  6: \"Canceled\",\n  7: \"Ready\",\n};\n\nfunction buildSampleDeployments(count: number): Deployment[] {\n  // Deterministic LCG so server and client render the same rows.\n  let seed = 20261008;\n  const next = () => {\n    seed = (seed * 1664525 + 1013904223) % 4294967296;\n    return seed / 4294967296;\n  };\n  const base = Date.UTC(2026, 9, 8, 17, 42);\n  let offset = 0;\n  return Array.from({ length: count }, (_, index) => {\n    offset += Math.round(18 + next() * 70);\n    const hex = Math.floor(next() * 0xffffff).toString(16).padStart(6, \"0\");\n    const picked = SAMPLE_STATUSES[Math.floor(next() * SAMPLE_STATUSES.length)];\n    const status = SAMPLE_STATUS_OVERRIDES[index] ?? picked;\n    return {\n      id: `dpl_${hex}`,\n      service: SAMPLE_SERVICES[Math.floor(next() * SAMPLE_SERVICES.length)],\n      status,\n      environment: SAMPLE_ENVS[Math.floor(next() * SAMPLE_ENVS.length)],\n      branch: SAMPLE_BRANCHES[Math.floor(next() * SAMPLE_BRANCHES.length)],\n      author: SAMPLE_AUTHORS[Math.floor(next() * SAMPLE_AUTHORS.length)],\n      durationSec: Math.round(36 + next() * 380),\n      cost: Math.round(6 + next() * 340) / 100,\n      createdAt: new Date(base - offset * 60_000).toISOString(),\n    };\n  });\n}\n\nexport const SORTABLE_TABLE_SAMPLE_ROWS: Deployment[] = buildSampleDeployments(48);\n\nconst deploymentTone: Record<DeploymentStatus, \"green\" | \"orange\" | \"red\" | \"neutral\"> = {\n  Ready: \"green\",\n  Building: \"orange\",\n  Failed: \"red\",\n  Canceled: \"neutral\",\n};\n\nfunction formatDuration(seconds: number) {\n  const m = Math.floor(seconds / 60);\n  const s = seconds % 60;\n  return m ? `${m}m ${String(s).padStart(2, \"0\")}s` : `${s}s`;\n}\n\nexport const SORTABLE_TABLE_SAMPLE_COLUMNS: SortableColumn<Deployment>[] = [\n  {\n    key: \"service\",\n    header: \"Deployment\",\n    width: 230,\n    hideable: false,\n    cell: (row, { isDark }) => (\n      <span className=\"flex min-w-0 flex-col\">\n        <span className={cn(\"truncate font-medium\", isDark ? \"text-[#ededed]/90\" : \"text-[#171717]/86\")}>\n          {row.service}\n        </span>\n        <span className={cn(\"truncate font-mono text-[11px]\", isDark ? \"text-[#ededed]/38\" : \"text-[#171717]/50\")}>\n          {row.id} · {row.author}\n        </span>\n      </span>\n    ),\n  },\n  {\n    key: \"status\",\n    header: \"Status\",\n    width: 122,\n    cell: (row, { isDark }) => {\n      const tone = getBjorkSignalPalette(deploymentTone[row.status], isDark);\n      return (\n        <span\n          className={cn(\n            \"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[12px] font-medium\",\n            tone.bgColor,\n            tone.borderColor,\n            tone.textColor\n          )}\n        >\n          <span aria-hidden className={cn(\"h-1.5 w-1.5 rounded-full\", tone.dotColor)} />\n          {row.status}\n        </span>\n      );\n    },\n  },\n  { key: \"environment\", header: \"Environment\", width: 144 },\n  {\n    key: \"branch\",\n    header: \"Branch\",\n    width: 156,\n    cell: (row) => <span className=\"block truncate font-mono text-[12px]\">{row.branch}</span>,\n  },\n  {\n    key: \"durationSec\",\n    header: \"Duration\",\n    width: 120,\n    format: \"number\",\n    cell: (row) => formatDuration(row.durationSec),\n  },\n  { key: \"cost\", header: \"Cost\", width: 112, format: \"currency\" },\n  { key: \"createdAt\", header: \"Created\", width: 150, format: \"date\", accessor: (row) => new Date(row.createdAt) },\n];\n\n/* ------------------------------------------------------------------ */\n/* Helpers                                                             */\n/* ------------------------------------------------------------------ */\n\nfunction readValue<T>(row: T, column: SortableColumn<T>): SortableValue {\n  if (typeof column.accessor === \"function\") return column.accessor(row);\n  const key = (column.accessor ?? column.key) as keyof T;\n  return (row as Record<keyof T, unknown>)[key] as SortableValue;\n}\n\nfunction compareValues(a: SortableValue, b: SortableValue) {\n  const aEmpty = a === null || a === undefined || a === \"\";\n  const bEmpty = b === null || b === undefined || b === \"\";\n  if (aEmpty || bEmpty) return aEmpty === bEmpty ? 0 : aEmpty ? 1 : -1;\n  if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();\n  if (typeof a === \"number\" && typeof b === \"number\") return a - b;\n  if (typeof a === \"boolean\" && typeof b === \"boolean\") return Number(a) - Number(b);\n  return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: \"base\" });\n}\n\nfunction pageList(current: number, total: number): (number | \"gap\")[] {\n  if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);\n  const pages = new Set([1, total, current - 1, current, current + 1]);\n  const sorted = [...pages].filter((p) => p >= 1 && p <= total).sort((a, b) => a - b);\n  const out: (number | \"gap\")[] = [];\n  sorted.forEach((page, i) => {\n    if (i > 0 && page - sorted[i - 1] > 1) out.push(\"gap\");\n    out.push(page);\n  });\n  return out;\n}\n\nconst noopSubscribe = () => () => {};\n\nconst focusRing =\n  \"outline-none focus-visible:ring-2 focus-visible:ring-[#ec5c13]/45 focus-visible:ring-offset-0\";\n\n/* ------------------------------------------------------------------ */\n/* Component                                                           */\n/* ------------------------------------------------------------------ */\n\nexport function SortableTable<T = Deployment>({\n  columns: columnsProp,\n  rows: rowsProp,\n  getRowId,\n  getRowLabel,\n  caption = \"Deployments\",\n  title = \"Deployments\",\n  itemLabel = \"deployments\",\n  searchPlaceholder = \"Search deployments\",\n  pageSizeOptions = [5, 10, 20, 50],\n  defaultPageSize = 10,\n  defaultSort = [],\n  defaultSelectedIds,\n  multiSort = true,\n  selectable = true,\n  onSelectionChange,\n  onSortChange,\n  loading = false,\n  maxBodyHeight = 520,\n  locale = \"en-US\",\n  currency = \"USD\",\n  timeZone = \"UTC\",\n  className,\n  theme = \"auto\",\n  enableAnimations = true,\n}: SortableTableProps<T>) {\n  const columns = useMemo(\n    () => columnsProp ?? (SORTABLE_TABLE_SAMPLE_COLUMNS as unknown as SortableColumn<T>[]),\n    [columnsProp]\n  );\n  const rows = useMemo(() => rowsProp ?? (SORTABLE_TABLE_SAMPLE_ROWS as unknown as T[]), [rowsProp]);\n\n  const mounted = useSyncExternalStore(noopSubscribe, () => true, () => false);\n  const { resolvedTheme } = useTheme();\n  const forcedTheme = theme === \"auto\" ? undefined : theme;\n  const detectedIsDark = useBjorkTableIsDark(resolvedTheme, forcedTheme);\n  const isDark = !mounted && theme === \"auto\" ? true : detectedIsDark;\n  const palette = getBjorkTablePalette(isDark);\n  const reduceMotion = useReducedMotion();\n  const shouldAnimate = enableAnimations && !reduceMotion;\n\n  const [query, setQuery] = useState(\"\");\n  const [sort, setSort] = useState<SortRule[]>(defaultSort);\n  const [pageSize, setPageSize] = useState(defaultPageSize);\n  const [page, setPage] = useState(1);\n  const [selected, setSelected] = useState<Set<string>>(() => new Set(defaultSelectedIds));\n  const [hidden, setHidden] = useState<Set<string>>(\n    () => new Set(columns.filter((c) => c.defaultHidden).map((c) => c.key))\n  );\n  const [menuOpen, setMenuOpen] = useState(false);\n  const [entered, setEntered] = useState(false);\n\n  const uid = useId();\n  const searchRef = useRef<HTMLInputElement>(null);\n  const menuButtonRef = useRef<HTMLButtonElement>(null);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const selectAllRef = useRef<HTMLInputElement>(null);\n  const regionRef = useRef<HTMLDivElement>(null);\n  const { width: regionWidth } = useElementSize(regionRef);\n\n  useEffect(() => {\n    // Rows mounted after the first commit (paging, sorting) appear instantly.\n    const id = window.setTimeout(() => setEntered(true), 50);\n    return () => window.clearTimeout(id);\n  }, []);\n\n  const pageSizes = useMemo(\n    () => [...new Set([...pageSizeOptions, pageSize])].sort((a, b) => a - b),\n    [pageSizeOptions, pageSize]\n  );\n\n  const visibleColumns = useMemo(() => columns.filter((c) => !hidden.has(c.key)), [columns, hidden]);\n\n  const formatters = useMemo(() => {\n    const number = new Intl.NumberFormat(locale);\n    const date = new Intl.DateTimeFormat(locale, {\n      month: \"short\",\n      day: \"numeric\",\n      hour: \"numeric\",\n      minute: \"2-digit\",\n      timeZone,\n    });\n    const currencies = new Map<string, Intl.NumberFormat>();\n    const money = (code: string) => {\n      let f = currencies.get(code);\n      if (!f) {\n        f = new Intl.NumberFormat(locale, { style: \"currency\", currency: code });\n        currencies.set(code, f);\n      }\n      return f;\n    };\n    return { number, date, money };\n  }, [locale, timeZone]);\n\n  const formatValue = useCallback(\n    (value: SortableValue, column: SortableColumn<T>) => {\n      if (value === null || value === undefined || value === \"\") return \"—\";\n      switch (column.format) {\n        case \"number\":\n          return typeof value === \"number\" ? formatters.number.format(value) : String(value);\n        case \"currency\":\n          return typeof value === \"number\"\n            ? formatters.money(column.currency ?? currency).format(value)\n            : String(value);\n        case \"date\": {\n          const d = value instanceof Date ? value : new Date(String(value));\n          return Number.isNaN(d.getTime()) ? String(value) : formatters.date.format(d);\n        }\n        default:\n          return value instanceof Date ? formatters.date.format(value) : String(value);\n      }\n    },\n    [currency, formatters]\n  );\n\n  const rowId = useCallback(\n    (row: T, index: number) =>\n      getRowId ? getRowId(row, index) : String((row as { id?: unknown }).id ?? index),\n    [getRowId]\n  );\n\n  const trimmedQuery = query.trim();\n  const filtered = useMemo(() => {\n    const indexed = rows.map((row, index) => ({ row, id: rowId(row, index) }));\n    if (!trimmedQuery) return indexed;\n    const needle = trimmedQuery.toLowerCase();\n    const searchable = visibleColumns.filter((c) => c.searchable !== false);\n    return indexed.filter(({ row }) =>\n      searchable.some((column) => {\n        const value = readValue(row, column);\n        if (value === null || value === undefined) return false;\n        const raw = value instanceof Date ? \"\" : String(value).toLowerCase();\n        return raw.includes(needle) || formatValue(value, column).toLowerCase().includes(needle);\n      })\n    );\n  }, [rows, rowId, trimmedQuery, visibleColumns, formatValue]);\n\n  const sorted = useMemo(() => {\n    const rules = sort\n      .map((rule) => ({ rule, column: columns.find((c) => c.key === rule.key) }))\n      .filter((r): r is { rule: SortRule; column: SortableColumn<T> } => Boolean(r.column));\n    if (!rules.length) return filtered;\n    return [...filtered].sort((a, b) => {\n      for (const { rule, column } of rules) {\n        const diff = compareValues(readValue(a.row, column), readValue(b.row, column));\n        if (diff !== 0) return rule.direction === \"asc\" ? diff : -diff;\n      }\n      return 0;\n    });\n  }, [filtered, sort, columns]);\n\n  const total = sorted.length;\n  const pageCount = Math.max(1, Math.ceil(total / pageSize));\n  const currentPage = Math.min(page, pageCount);\n  const start = (currentPage - 1) * pageSize;\n  const pageRows = sorted.slice(start, start + pageSize);\n  const rangeLabel =\n    total === 0\n      ? `0 of 0`\n      : `${formatters.number.format(start + 1)}–${formatters.number.format(start + pageRows.length)} of ${formatters.number.format(total)}`;\n\n  const pageIds = pageRows.map((r) => r.id);\n  const selectedOnPage = pageIds.filter((id) => selected.has(id)).length;\n  const allOnPage = pageIds.length > 0 && selectedOnPage === pageIds.length;\n  const someOnPage = selectedOnPage > 0 && !allOnPage;\n\n  useEffect(() => {\n    if (selectAllRef.current) selectAllRef.current.indeterminate = someOnPage;\n  }, [someOnPage]);\n\n  const updateSelection = (next: Set<string>) => {\n    setSelected(next);\n    onSelectionChange?.([...next]);\n  };\n\n  const toggleRow = (id: string) => {\n    const next = new Set(selected);\n    if (next.has(id)) next.delete(id);\n    else next.add(id);\n    updateSelection(next);\n  };\n\n  const toggleAllOnPage = () => {\n    const next = new Set(selected);\n    if (allOnPage) pageIds.forEach((id) => next.delete(id));\n    else pageIds.forEach((id) => next.add(id));\n    updateSelection(next);\n  };\n\n  const cycleSort = (key: string, additive: boolean) => {\n    const existing = sort.find((r) => r.key === key);\n    const nextDirection: SortDirection | null = !existing\n      ? \"asc\"\n      : existing.direction === \"asc\"\n        ? \"desc\"\n        : null;\n    let next: SortRule[];\n    if (additive && multiSort) {\n      next = nextDirection\n        ? existing\n          ? sort.map((r) => (r.key === key ? { key, direction: nextDirection } : r))\n          : [...sort, { key, direction: nextDirection }]\n        : sort.filter((r) => r.key !== key);\n    } else {\n      next = nextDirection ? [{ key, direction: nextDirection }] : [];\n    }\n    setSort(next);\n    setPage(1);\n    onSortChange?.(next);\n  };\n\n  const toggleColumn = (key: string) => {\n    setHidden((prev) => {\n      const next = new Set(prev);\n      if (next.has(key)) next.delete(key);\n      else if (columns.length - next.size > 1) next.add(key);\n      return next;\n    });\n  };\n\n  const clearSearch = () => {\n    setQuery(\"\");\n    setPage(1);\n    searchRef.current?.focus();\n  };\n\n  // Columns menu: close on outside pointer and Escape, focus the first option on open.\n  useEffect(() => {\n    if (!menuOpen) return;\n    const first = menuRef.current?.querySelector<HTMLInputElement>(\"input:not(:disabled)\");\n    first?.focus();\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Node;\n      if (menuRef.current?.contains(target) || menuButtonRef.current?.contains(target)) return;\n      setMenuOpen(false);\n    };\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        setMenuOpen(false);\n        menuButtonRef.current?.focus();\n      }\n    };\n    document.addEventListener(\"pointerdown\", onPointer);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onPointer);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [menuOpen]);\n\n  const columnWidth = (column: SortableColumn<T>) =>\n    column.width === undefined ? 140 : typeof column.width === \"number\" ? column.width : column.width;\n  const minTableWidth =\n    (selectable ? 44 : 0) +\n    visibleColumns.reduce((sum, c) => sum + (typeof c.width === \"number\" ? c.width : 140), 0);\n\n  const alignOf = (column: SortableColumn<T>) =>\n    column.align ??\n    (column.format === \"number\" || column.format === \"currency\" || column.format === \"date\" ? \"right\" : \"left\");\n\n  const inputTone = isDark\n    ? \"border-[#232323] bg-[#181818] text-[#ededed]/90 placeholder:text-[#ededed]/30\"\n    : \"border-[#eadfce] bg-[#fffcf6] text-[#171717]/86 placeholder:text-[#171717]/40 shadow-[var(--bjork-shadow-soft)]\";\n  const skeletonTone = isDark ? \"bg-[#ededed]/[0.06]\" : \"bg-[#171717]/[0.06]\";\n  const colSpan = visibleColumns.length + (selectable ? 1 : 0);\n  const selectedCount = selected.size;\n\n  return (\n    <div className={cn(\"mx-auto w-full max-w-[1100px] text-[13px]\", className)}>\n      <div className={cn(\"relative overflow-hidden rounded-[18px] border\", palette.container)}>\n        {/* Toolbar */}\n        <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2.5 px-4 pb-3 pt-3.5\">\n          <div className=\"flex min-w-0 flex-1 basis-[180px] items-baseline gap-2\">\n            {selectedCount > 0 ? (\n              <>\n                <span className={cn(\"font-medium tabular-nums\", palette.accent)}>\n                  {formatters.number.format(selectedCount)} selected\n                </span>\n                <button\n                  type=\"button\"\n                  onClick={() => updateSelection(new Set())}\n                  className={cn(\"rounded text-[12px] underline-offset-2 hover:underline\", palette.secondaryText, focusRing)}\n                >\n                  Clear\n                </button>\n              </>\n            ) : (\n              <>\n                <span className={cn(\"truncate text-[14px] font-medium\", palette.primaryText)}>{title}</span>\n                <span className={cn(\"tabular-nums\", palette.secondaryText)}>\n                  {loading ? \"—\" : formatters.number.format(rows.length)}\n                </span>\n              </>\n            )}\n          </div>\n\n          <div className=\"flex w-full items-center gap-2 sm:w-auto\">\n            <label className=\"relative min-w-0 flex-1 sm:w-[240px] sm:flex-none\">\n              <span className=\"sr-only\">{searchPlaceholder}</span>\n              <Search\n                aria-hidden\n                className={cn(\"pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2\", palette.secondaryText)}\n              />\n              <input\n                ref={searchRef}\n                type=\"search\"\n                value={query}\n                placeholder={searchPlaceholder}\n                aria-controls={`${uid}-table`}\n                onChange={(event) => {\n                  setQuery(event.target.value);\n                  setPage(1);\n                }}\n                onKeyDown={(event) => {\n                  if (event.key === \"Escape\" && query) {\n                    event.preventDefault();\n                    setQuery(\"\");\n                    setPage(1);\n                  }\n                }}\n                className={cn(\n                  \"h-8 w-full rounded-[9px] border pl-8 pr-8 text-[13px] [&::-webkit-search-cancel-button]:appearance-none\",\n                  inputTone,\n                  focusRing\n                )}\n              />\n              {query ? (\n                <button\n                  type=\"button\"\n                  aria-label=\"Clear search\"\n                  onClick={clearSearch}\n                  className={cn(\n                    \"absolute right-1.5 top-1/2 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-md\",\n                    palette.secondaryText,\n                    palette.menuItem,\n                    focusRing\n                  )}\n                >\n                  <X className=\"h-3 w-3\" />\n                </button>\n              ) : null}\n            </label>\n\n            <div className=\"relative\">\n              <button\n                ref={menuButtonRef}\n                type=\"button\"\n                aria-haspopup=\"true\"\n                aria-expanded={menuOpen}\n                aria-controls={`${uid}-columns`}\n                onClick={() => setMenuOpen((open) => !open)}\n                className={cn(\n                  \"flex h-8 items-center gap-1.5 rounded-[9px] border px-2.5 text-[13px] transition-colors\",\n                  palette.control,\n                  focusRing\n                )}\n              >\n                <Columns3 aria-hidden className=\"h-3.5 w-3.5\" />\n                <span>Columns</span>\n                {hidden.size > 0 ? (\n                  <span className={cn(\"rounded px-1 text-[11px] tabular-nums\", palette.accentBg, palette.accent)}>\n                    {columns.length - hidden.size}/{columns.length}\n                  </span>\n                ) : null}\n              </button>\n              <AnimatePresence>\n                {menuOpen ? (\n                  <motion.div\n                    ref={menuRef}\n                    id={`${uid}-columns`}\n                    role=\"group\"\n                    aria-label=\"Visible columns\"\n                    initial={shouldAnimate ? { opacity: 0, y: -4 } : false}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={shouldAnimate ? { opacity: 0, y: -4, transition: { duration: 0.12 } } : { opacity: 0, transition: { duration: 0 } }}\n                    transition={{ type: \"spring\", stiffness: 520, damping: 34 }}\n                    className={cn(\"absolute right-0 z-30 mt-1.5 w-52 rounded-[12px] border p-1\", palette.menu)}\n                  >\n                    <div className={cn(\"px-2.5 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-[0.08em]\", palette.secondaryText)}>\n                      Show columns\n                    </div>\n                    {columns.map((column) => {\n                      const isVisible = !hidden.has(column.key);\n                      const locked =\n                        column.hideable === false || (isVisible && columns.length - hidden.size <= 1);\n                      return (\n                        <label\n                          key={column.key}\n                          className={cn(\n                            \"flex cursor-pointer items-center gap-2.5 rounded-[8px] px-2.5 py-1.5 text-[13px]\",\n                            palette.menuItem,\n                            locked && \"cursor-not-allowed opacity-50\"\n                          )}\n                        >\n                          <input\n                            type=\"checkbox\"\n                            checked={isVisible}\n                            disabled={locked}\n                            onChange={() => toggleColumn(column.key)}\n                            className={cn(\"h-3.5 w-3.5 rounded\", focusRing)}\n                            style={{ accentColor: palette.checkboxAccent }}\n                          />\n                          {column.header}\n                        </label>\n                      );\n                    })}\n                  </motion.div>\n                ) : null}\n              </AnimatePresence>\n            </div>\n          </div>\n        </div>\n\n        {/* Table */}\n        <div\n          ref={regionRef}\n          role=\"region\"\n          aria-label={`${caption} table`}\n          tabIndex={0}\n          className={cn(\"overflow-auto border-t\", palette.divider, focusRing, \"focus-visible:ring-inset\")}\n          style={{ maxHeight: maxBodyHeight }}\n        >\n          <table\n            id={`${uid}-table`}\n            aria-busy={loading || undefined}\n            aria-rowcount={loading ? undefined : total + 1}\n            className=\"w-full table-fixed border-separate border-spacing-0\"\n            style={{ minWidth: minTableWidth }}\n          >\n            <caption className=\"sr-only\">\n              {caption}\n              {sort.length\n                ? `, sorted by ${sort\n                    .map((r) => `${columns.find((c) => c.key === r.key)?.header ?? r.key} ${r.direction === \"asc\" ? \"ascending\" : \"descending\"}`)\n                    .join(\", then \")}`\n                : \"\"}\n              {multiSort ? \". Shift-click a column header to add it to the sort.\" : \"\"}\n            </caption>\n            <colgroup>\n              {selectable ? <col style={{ width: 44 }} /> : null}\n              {visibleColumns.map((column) => (\n                <col key={column.key} style={{ width: columnWidth(column) }} />\n              ))}\n            </colgroup>\n            <thead>\n              <tr>\n                {selectable ? (\n                  <th\n                    scope=\"col\"\n                    className={cn(\"sticky top-0 z-[2] border-b px-0 py-0 text-center\", palette.header)}\n                  >\n                    <input\n                      ref={selectAllRef}\n                      type=\"checkbox\"\n                      aria-label=\"Select all rows on this page\"\n                      checked={allOnPage}\n                      disabled={loading || pageIds.length === 0}\n                      onChange={toggleAllOnPage}\n                      className={cn(\"relative top-[1px] h-3.5 w-3.5 cursor-pointer rounded\", focusRing)}\n                      style={{ accentColor: palette.checkboxAccent }}\n                    />\n                  </th>\n                ) : null}\n                {visibleColumns.map((column) => {\n                  const align = alignOf(column);\n                  const ruleIndex = sort.findIndex((r) => r.key === column.key);\n                  const rule = ruleIndex >= 0 ? sort[ruleIndex] : undefined;\n                  const canSort = column.sortable !== false;\n                  return (\n                    <th\n                      key={column.key}\n                      scope=\"col\"\n                      aria-sort={rule ? (rule.direction === \"asc\" ? \"ascending\" : \"descending\") : canSort ? \"none\" : undefined}\n                      className={cn(\n                        \"sticky top-0 z-[1] h-10 border-b px-3 text-[11px] font-medium uppercase tracking-[0.08em]\",\n                        palette.header,\n                        align === \"right\" ? \"text-right\" : align === \"center\" ? \"text-center\" : \"text-left\"\n                      )}\n                    >\n                      {canSort ? (\n                        <button\n                          type=\"button\"\n                          onClick={(event: ReactMouseEvent) => cycleSort(column.key, event.shiftKey)}\n                          className={cn(\n                            \"group/sort -mx-1.5 inline-flex max-w-full items-center gap-1 rounded-md px-1.5 py-1 uppercase tracking-[0.08em] transition-colors\",\n                            align === \"right\" && \"flex-row-reverse\",\n                            rule ? palette.primaryText : isDark ? \"hover:text-[#ededed]/80\" : \"hover:text-[#171717]/86\",\n                            focusRing\n                          )}\n                        >\n                          <span className=\"truncate\">{column.header}</span>\n                          <SortGlyph\n                            direction={rule?.direction}\n                            order={sort.length > 1 && rule ? ruleIndex + 1 : undefined}\n                            animate={shouldAnimate}\n                            accent={palette.accent}\n                          />\n                        </button>\n                      ) : (\n                        <span className=\"truncate\">{column.header}</span>\n                      )}\n                    </th>\n                  );\n                })}\n              </tr>\n            </thead>\n            <tbody>\n              {loading\n                ? Array.from({ length: Math.min(pageSize, 10) }, (_, i) => (\n                    <tr key={`skeleton-${i}`} aria-hidden>\n                      {selectable ? (\n                        <td className={cn(\"h-12 border-b\", palette.divider)}>\n                          <span className={cn(\"mx-auto block h-3.5 w-3.5 rounded\", skeletonTone)} />\n                        </td>\n                      ) : null}\n                      {visibleColumns.map((column, c) => (\n                        <td key={column.key} className={cn(\"h-12 border-b px-3\", palette.divider)}>\n                          <span\n                            className={cn(\n                              \"block h-2.5 rounded-full\",\n                              skeletonTone,\n                              shouldAnimate && \"animate-pulse\",\n                              alignOf(column) === \"right\" && \"ml-auto\"\n                            )}\n                            style={{ width: `${38 + ((i * 7 + c * 13) % 45)}%`, animationDelay: `${(i + c) * 60}ms` }}\n                          />\n                        </td>\n                      ))}\n                    </tr>\n                  ))\n                : pageRows.map(({ row, id }, i) => {\n                    const isSelected = selected.has(id);\n                    return (\n                      <motion.tr\n                        key={id}\n                        initial={shouldAnimate && !entered ? { opacity: 0, y: 6 } : false}\n                        animate={{ opacity: 1, y: 0 }}\n                        transition={{ type: \"spring\", stiffness: 420, damping: 34, delay: shouldAnimate && !entered ? i * 0.03 : 0 }}\n                        aria-selected={selectable ? isSelected : undefined}\n                        className={cn(\n                          \"transition-colors duration-150\",\n                          isSelected ? palette.selectedRow : palette.row\n                        )}\n                      >\n                        {selectable ? (\n                          <td className={cn(\"relative h-12 border-b text-center\", palette.divider)}>\n                            {isSelected ? (\n                              <span aria-hidden className=\"absolute inset-y-0 left-0 w-[2px] bg-[#ec5c13]\" />\n                            ) : null}\n                            <input\n                              type=\"checkbox\"\n                              aria-label={`Select ${\n                                getRowLabel\n                                  ? getRowLabel(row)\n                                  : columns[0]\n                                    ? `${formatValue(readValue(row, columns[0]), columns[0])} ${id}`\n                                    : `row ${start + i + 1}`\n                              }`}\n                              checked={isSelected}\n                              onChange={() => toggleRow(id)}\n                              className={cn(\"relative top-[1px] h-3.5 w-3.5 cursor-pointer rounded\", focusRing)}\n                              style={{ accentColor: palette.checkboxAccent }}\n                            />\n                          </td>\n                        ) : null}\n                        {visibleColumns.map((column) => {\n                          const value = readValue(row, column);\n                          const formatted = formatValue(value, column);\n                          const align = alignOf(column);\n                          const numeric = column.format && column.format !== \"text\";\n                          return (\n                            <td\n                              key={column.key}\n                              className={cn(\n                                \"h-12 border-b px-3 align-middle\",\n                                palette.divider,\n                                palette.primaryText,\n                                align === \"right\" ? \"text-right\" : align === \"center\" ? \"text-center\" : \"text-left\",\n                                numeric && \"tabular-nums\"\n                              )}\n                            >\n                              {column.cell ? (\n                                column.cell(row, { value, formatted, isDark })\n                              ) : (\n                                <span className=\"block truncate\">{formatted}</span>\n                              )}\n                            </td>\n                          );\n                        })}\n                      </motion.tr>\n                    );\n                  })}\n              {!loading && total === 0 ? (\n                <tr>\n                  <td colSpan={colSpan} className=\"p-0\">\n                    {/* Pinned to the visible scroll viewport so it stays centered on wide tables. */}\n                    <div className=\"sticky left-0 px-6 py-14 text-center\" style={{ width: regionWidth || \"100%\" }}>\n                    <div className=\"mx-auto flex max-w-[320px] flex-col items-center gap-3\">\n                      <span\n                        aria-hidden\n                        className={cn(\"flex h-10 w-10 items-center justify-center rounded-full border\", palette.divider, palette.mutedSurface)}\n                      >\n                        <Search className={cn(\"h-4 w-4\", palette.secondaryText)} />\n                      </span>\n                      <p className={cn(\"text-[14px] font-medium\", palette.primaryText)}>\n                        {trimmedQuery ? (\n                          <>\n                            No results for <span className={palette.accent}>&lsquo;{trimmedQuery}&rsquo;</span>\n                          </>\n                        ) : (\n                          `No ${itemLabel} yet`\n                        )}\n                      </p>\n                      <p className={cn(\"text-[12px]\", palette.secondaryText)}>\n                        {trimmedQuery\n                          ? \"Try a shorter term, or search a column that is currently visible.\"\n                          : `New ${itemLabel} will show up here.`}\n                      </p>\n                      {trimmedQuery ? (\n                        <button\n                          type=\"button\"\n                          onClick={clearSearch}\n                          className={cn(\"mt-1 h-8 rounded-[9px] border px-3 text-[13px] transition-colors\", palette.control, focusRing)}\n                        >\n                          Clear search\n                        </button>\n                      ) : null}\n                    </div>\n                    </div>\n                  </td>\n                </tr>\n              ) : null}\n            </tbody>\n          </table>\n        </div>\n\n        {/* Pagination */}\n        <nav\n          aria-label=\"Pagination\"\n          className={cn(\"flex flex-wrap items-center justify-between gap-x-4 gap-y-2.5 border-t px-4 py-3\", palette.divider)}\n        >\n          <div className=\"flex items-center gap-3\">\n            <p aria-live=\"polite\" className={cn(\"min-w-[9ch] tabular-nums\", palette.secondaryText)}>\n              {loading ? \"Loading…\" : rangeLabel}\n              <span className=\"sr-only\"> {itemLabel}</span>\n            </p>\n            <label className={cn(\"flex items-center gap-2\", palette.secondaryText)}>\n              <span className=\"hidden sm:inline\">Rows</span>\n              <span className=\"sr-only sm:hidden\">Rows per page</span>\n              <select\n                value={pageSize}\n                onChange={(event) => {\n                  setPageSize(Number(event.target.value));\n                  setPage(1);\n                }}\n                className={cn(\"h-7 cursor-pointer rounded-[8px] border px-1.5 text-[12px] tabular-nums\", palette.control, focusRing)}\n              >\n                {pageSizes.map((size) => (\n                  <option key={size} value={size}>\n                    {size}\n                  </option>\n                ))}\n              </select>\n            </label>\n          </div>\n          <div className=\"flex items-center gap-1\">\n            <PagerButton\n              label=\"Previous page\"\n              disabled={loading || currentPage <= 1}\n              onClick={() => setPage(currentPage - 1)}\n              className={palette.control}\n            >\n              <ChevronLeft className=\"h-3.5 w-3.5\" />\n            </PagerButton>\n            {pageList(currentPage, pageCount).map((item, i) =>\n              item === \"gap\" ? (\n                <span key={`gap-${i}`} aria-hidden className={cn(\"w-5 text-center\", palette.secondaryText)}>\n                  …\n                </span>\n              ) : (\n                <button\n                  key={item}\n                  type=\"button\"\n                  aria-label={`Page ${item}`}\n                  aria-current={item === currentPage ? \"page\" : undefined}\n                  disabled={loading}\n                  onClick={() => setPage(item)}\n                  className={cn(\n                    \"h-7 min-w-7 rounded-[8px] border px-1.5 text-[12px] tabular-nums transition-colors disabled:opacity-40\",\n                    item === currentPage\n                      ? cn(palette.accentBg, palette.accentBorder, palette.accent, \"font-medium\")\n                      : cn(\"border-transparent\", palette.secondaryText, palette.menuItem),\n                    focusRing\n                  )}\n                >\n                  {item}\n                </button>\n              )\n            )}\n            <PagerButton\n              label=\"Next page\"\n              disabled={loading || currentPage >= pageCount}\n              onClick={() => setPage(currentPage + 1)}\n              className={palette.control}\n            >\n              <ChevronRight className=\"h-3.5 w-3.5\" />\n            </PagerButton>\n          </div>\n        </nav>\n      </div>\n    </div>\n  );\n}\n\nfunction PagerButton({\n  label,\n  disabled,\n  onClick,\n  className,\n  children,\n}: {\n  label: string;\n  disabled: boolean;\n  onClick: () => void;\n  className: string;\n  children: ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      disabled={disabled}\n      onClick={onClick}\n      className={cn(\n        \"flex h-7 w-7 items-center justify-center rounded-[8px] border transition-colors disabled:cursor-not-allowed disabled:opacity-35\",\n        className,\n        focusRing\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n\nfunction SortGlyph({\n  direction,\n  order,\n  animate,\n  accent,\n}: {\n  direction?: SortDirection;\n  order?: number;\n  animate: boolean;\n  accent: string;\n}) {\n  const style: CSSProperties = { width: 12, height: 12 };\n  return (\n    <span aria-hidden className=\"inline-flex shrink-0 items-center gap-0.5\">\n      {direction ? (\n        <motion.span\n          key={direction}\n          initial={animate ? { opacity: 0, y: direction === \"asc\" ? 3 : -3 } : false}\n          animate={{ opacity: 1, y: 0 }}\n          transition={{ type: \"spring\", stiffness: 600, damping: 30 }}\n          className={cn(\"inline-flex\", accent)}\n        >\n          {direction === \"asc\" ? <ArrowUp style={style} /> : <ArrowDown style={style} />}\n        </motion.span>\n      ) : (\n        <span className=\"inline-flex flex-col opacity-0 transition-opacity group-hover/sort:opacity-50 group-focus-visible/sort:opacity-50\">\n          <ArrowUp style={style} />\n        </span>\n      )}\n      {order ? <span className={cn(\"text-[11px] tabular-nums\", accent)}>{order}</span> : null}\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/tables/sortable-table.tsx"
    },
    {
      "path": "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",
      "type": "registry:lib",
      "target": "@lib/utils.ts"
    },
    {
      "path": "components/bjork-ui/_core/canvas.ts",
      "content": "\"use client\";\n\nimport { useEffect, useState, type RefObject } from \"react\";\nimport { isCoarsePointer } from \"./loop\";\n\n// Caps DPR at 1.5 on coarse pointers and 2 otherwise.\nexport function defaultMaxDpr(): number {\n  return isCoarsePointer() ? 1.5 : 2;\n}\n\n// Sets the backing size for a CSS box and returns the DPR used.\nexport function sizeCanvas(\n  canvas: HTMLCanvasElement,\n  cssW: number,\n  cssH: number,\n  maxDpr: number = defaultMaxDpr(),\n): number {\n  const dpr = Math.min(typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1, maxDpr);\n  canvas.width = Math.max(1, Math.round(cssW * dpr));\n  canvas.height = Math.max(1, Math.round(cssH * dpr));\n  canvas.style.width = `${cssW}px`;\n  canvas.style.height = `${cssH}px`;\n  return dpr;\n}\n\n// Rounded size of an element, kept current with a ResizeObserver.\nexport function useElementSize(ref: RefObject<HTMLElement | null>): { width: number; height: number } {\n  const [size, setSize] = useState({ width: 0, height: 0 });\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver((entries) => {\n      const box = entries[0]?.contentRect;\n      if (!box) return;\n      const width = Math.round(box.width);\n      const height = Math.round(box.height);\n      setSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }));\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref]);\n\n  return size;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/canvas.ts"
    },
    {
      "path": "components/bjork-ui/_core/loop.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, type RefObject } from \"react\";\n\nexport interface VisibleLoopOptions {\n  enabled?: boolean;\n  fpsCap?: number;\n  rootMargin?: string;\n}\n\n/**\n * Runs `frame(dt, t)` on requestAnimationFrame while the element is near the viewport,\n * the tab is visible and `enabled` is not false. Return `false` from `frame` to go idle\n * until `wake()` is called. Writes `data-loop` on the element: running, idle or paused.\n */\nexport function useVisibleLoop(\n  ref: RefObject<HTMLElement | null>,\n  frame: (dt: number, t: number) => boolean | void,\n  opts?: VisibleLoopOptions,\n): { wake: () => void } {\n  const enabled = opts?.enabled !== false;\n  const fpsCap = opts?.fpsCap ?? 0;\n  const rootMargin = opts?.rootMargin ?? \"96px\";\n\n  const frameRef = useRef(frame);\n  useEffect(() => {\n    frameRef.current = frame;\n  }, [frame]);\n\n  const wakeRef = useRef<() => void>(() => {});\n  const wake = useCallback(() => wakeRef.current(), []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || typeof window === \"undefined\") return;\n\n    let wants = true; // false once the frame callback returns false\n    let inView = false;\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const minFrame = fpsCap > 0 ? 1000 / fpsCap : 0;\n\n    const canRun = () => enabled && inView && document.visibilityState === \"visible\";\n    const stop = () => {\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n    };\n\n    const update = () => {\n      if (!enabled) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      if (!wants) {\n        stop();\n        el.dataset.loop = \"idle\";\n        return;\n      }\n      if (!canRun()) {\n        stop();\n        el.dataset.loop = \"paused\";\n        return;\n      }\n      el.dataset.loop = \"running\";\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(tick);\n      }\n    };\n\n    const tick = (now: number) => {\n      raf = 0;\n      if (!canRun() || !wants) {\n        update();\n        return;\n      }\n      const elapsed = now - last;\n      if (minFrame && elapsed < minFrame - 0.5) {\n        raf = requestAnimationFrame(tick);\n        return;\n      }\n      last = now;\n      const dt = Math.min(0.05, Math.max(0, elapsed / 1000));\n      t += dt;\n      const keep = frameRef.current(dt, t);\n      if (keep === false) {\n        wants = false;\n        update();\n        return;\n      }\n      raf = requestAnimationFrame(tick);\n    };\n\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        inView = entry.isIntersecting;\n        update();\n      },\n      { rootMargin },\n    );\n    observer.observe(el);\n\n    const onVisibility = () => update();\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    wakeRef.current = () => {\n      wants = true;\n      update();\n    };\n\n    update();\n\n    return () => {\n      stop();\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      wakeRef.current = () => {};\n    };\n  }, [ref, enabled, fpsCap, rootMargin]);\n\n  return useMemo(() => ({ wake }), [wake]);\n}\n\n// matchMedia based check. False on the server.\nexport function isCoarsePointer(): boolean {\n  if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return false;\n  return window.matchMedia(\"(pointer: coarse)\").matches;\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/_core/loop.ts"
    },
    {
      "path": "components/bjork-ui/tables/table-theme.ts",
      "content": "import { useEffect, useState } from \"react\";\n\nexport type BjorkTableThemeMode = \"light\" | \"dark\" | \"auto\";\n\nexport function useBjorkTableIsDark(\n  resolvedTheme?: string,\n  forcedTheme?: Exclude<BjorkTableThemeMode, \"auto\">\n) {\n  const [documentTheme, setDocumentTheme] = useState<\"light\" | \"dark\" | null>(null);\n  const [previewTheme, setPreviewTheme] = useState<\"light\" | \"dark\" | null>(null);\n  // Server and first client render must agree, otherwise React keeps the\n  // server-rendered (dark) classes when the real theme resolves to light.\n  const [mounted, setMounted] = useState(false);\n\n  useEffect(() => {\n    setMounted(true);\n    const syncDocumentTheme = () => {\n      const root = document.documentElement;\n      const searchParams = new URLSearchParams(window.location.search);\n      const theme = searchParams.get(\"theme\");\n\n      setPreviewTheme(theme === \"light\" || theme === \"dark\" ? theme : null);\n\n      setDocumentTheme(\n        root.classList.contains(\"light\")\n          ? \"light\"\n          : root.classList.contains(\"dark\")\n            ? \"dark\"\n            : null\n      );\n    };\n\n    syncDocumentTheme();\n\n    const observer = new MutationObserver(syncDocumentTheme);\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => observer.disconnect();\n  }, []);\n\n  const activeTheme =\n    forcedTheme ?? previewTheme ?? documentTheme ?? (mounted ? resolvedTheme : undefined);\n\n  return activeTheme !== \"light\";\n}\n\nexport function getBjorkTablePalette(isDark: boolean) {\n  return {\n    container: isDark\n      ? \"border-[#1c1c1c] bg-[#121212] shadow-[inset_0_7px_14px_rgba(255,255,255,0.03),inset_0_0.5px_0.5px_rgba(255,255,255,0.06),0_14px_20px_-6px_rgba(0,0,0,0.45)]\"\n      : \"border-[#f5ede2] bg-[#fffcf6] shadow-[inset_0_7px_14px_rgba(88,72,49,0.045),inset_0_0.5px_0.5px_rgba(255,255,255,0.92),inset_1px_0_0_rgba(88,72,49,0.026),inset_-1px_0_0_rgba(255,255,255,0.68),0_14px_22px_-9px_rgba(66,52,33,0.11)]\",\n    header: isDark\n      ? \"border-[#232323] bg-[#181818] text-[#ededed]/42\"\n      : \"border-[#f1e8dc] bg-[#fbf7ef] text-[#171717]/60\",\n    row: isDark\n      ? \"border-[#232323] hover:bg-[#181818]/70\"\n      : \"border-[#f1e8dc] hover:bg-[#f8f2e7]/72\",\n    selectedRow: isDark\n      ? \"border-[#2a2a2a] bg-[#181818]\"\n      : \"border-[#eadfce] bg-[#f8f2e7]\",\n    control: isDark\n      ? \"border-[#232323] bg-[#181818] text-[#ededed]/70 hover:bg-[#202020] shadow-[inset_0_1px_0_rgba(255,255,255,0.045)]\"\n      : \"border-[#eadfce] bg-[#fffcf6] text-[#171717]/68 shadow-[var(--bjork-shadow-soft)] hover:bg-[#f8f2e7]\",\n    menu: isDark\n      ? \"border-[#232323] bg-[#181818] text-[#ededed]/76 shadow-[inset_0_1px_0_rgba(255,255,255,0.055),inset_0_12px_24px_rgba(255,255,255,0.018),0_22px_42px_-22px_rgba(0,0,0,0.9)]\"\n      : \"border-[#eadfce] bg-[#fffcf6] text-[#171717]/76 shadow-[inset_0_1px_0_rgba(88,72,49,0.04),inset_0_12px_24px_rgba(88,72,49,0.022),0_22px_44px_-26px_rgba(66,52,33,0.22)]\",\n    menuItem: isDark ? \"hover:bg-[#232323]\" : \"hover:bg-[#f8f2e7]\",\n    menuActive: isDark ? \"bg-[#232323]\" : \"bg-[#f8f2e7]\",\n    divider: isDark ? \"border-[#232323]\" : \"border-[#f1e8dc]\",\n    primaryText: isDark ? \"text-[#ededed]/90\" : \"text-[#171717]/86\",\n    secondaryText: isDark ? \"text-[#ededed]/38\" : \"text-[#171717]/60\",\n    mutedSurface: isDark ? \"bg-[#181818]\" : \"bg-[#f8f2e7]\",\n    popover: isDark ? \"bg-[#111] text-[#ededed]\" : \"bg-[#fffcf6] text-[#171717]\",\n    accent: isDark ? \"text-[#d86a2c]\" : \"text-[#bd4514]\",\n    accentBg: isDark ? \"bg-[#ec5c13]/12\" : \"bg-[#ec5c13]/14\",\n    accentBorder: isDark ? \"border-[#ec5c13]/24\" : \"border-[#bd4514]/22\",\n    checkboxAccent: isDark ? \"#d86a2c\" : \"#bd4514\",\n  };\n}\n\nexport function getBjorkSignalPalette(\n  tone: \"green\" | \"red\" | \"orange\" | \"neutral\",\n  isDark: boolean\n) {\n  const palettes = {\n    green: {\n      bgColor: isDark ? \"bg-[#3e6f52]/18\" : \"bg-[#dce8dc]\",\n      borderColor: isDark ? \"border-[#5f9f78]/28\" : \"border-[#bad0bd]\",\n      textColor: isDark ? \"text-[#77b58b]\" : \"text-[#47785c]\",\n      dotColor: isDark ? \"bg-[#77b58b]\" : \"bg-[#47785c]\",\n      color: isDark ? \"#77b58b\" : \"#47785c\",\n    },\n    red: {\n      bgColor: isDark ? \"bg-[#87463f]/18\" : \"bg-[#eadad5]\",\n      borderColor: isDark ? \"border-[#c46a61]/28\" : \"border-[#d7bcb4]\",\n      textColor: isDark ? \"text-[#cf7c72]\" : \"text-[#9d5149]\",\n      dotColor: isDark ? \"bg-[#cf7c72]\" : \"bg-[#9d5149]\",\n      color: isDark ? \"#cf7c72\" : \"#9d5149\",\n    },\n    orange: {\n      bgColor: isDark ? \"bg-[#ec5c13]/14\" : \"bg-[#ead9cb]\",\n      borderColor: isDark ? \"border-[#ec5c13]/28\" : \"border-[#d5b59f]\",\n      textColor: isDark ? \"text-[#d86a2c]\" : \"text-[#9f4317]\",\n      dotColor: isDark ? \"bg-[#d86a2c]\" : \"bg-[#9f4317]\",\n      color: isDark ? \"#d86a2c\" : \"#9f4317\",\n    },\n    neutral: {\n      bgColor: isDark ? \"bg-[#ededed]/8\" : \"bg-[#ded7ca]\",\n      borderColor: isDark ? \"border-[#ededed]/14\" : \"border-[#cfc6b7]\",\n      textColor: isDark ? \"text-[#ededed]/58\" : \"text-[#171717]/60\",\n      dotColor: isDark ? \"bg-[#ededed]/45\" : \"bg-[#171717]/42\",\n      color: isDark ? \"#a3a3a3\" : \"#706b62\",\n    },\n  } as const;\n\n  return palettes[tone];\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/tables/table-theme.ts"
    }
  ],
  "cssVars": {
    "light": {
      "bjork-bg": "#f7f3ea",
      "bjork-surface": "#fffcf6",
      "bjork-surface-muted": "rgba(250, 246, 237, 0.94)",
      "bjork-surface-hover": "#f5efe3",
      "bjork-surface-active": "#efe7d8",
      "bjork-panel": "#f8f2e7",
      "bjork-menu": "rgba(255, 252, 246, 0.98)",
      "bjork-field": "rgba(255, 252, 246, 0.96)",
      "bjork-field-muted": "rgba(248, 242, 231, 0.82)",
      "bjork-field-inset": "rgba(239, 231, 216, 0.72)",
      "bjork-error-bg": "#fff0e8",
      "bjork-text": "#171717",
      "bjork-text-strong": "rgba(23, 23, 23, 0.9)",
      "bjork-text-medium": "rgba(23, 23, 23, 0.78)",
      "bjork-text-muted": "rgba(23, 23, 23, 0.68)",
      "bjork-text-soft": "rgba(23, 23, 23, 0.62)",
      "bjork-text-faint": "rgba(23, 23, 23, 0.22)",
      "bjork-inverted-text": "#fff8f0",
      "bjork-border": "#eee6db",
      "bjork-border-muted": "#f5ede2",
      "bjork-border-strong": "#e1d7c8",
      "bjork-thumb": "#a49b8e",
      "bjork-track": "#ded6ca",
      "bjork-accent": "#ec7d43",
      "bjork-accent-hover": "#f0935f",
      "bjork-accent-soft": "rgba(236, 125, 67, 0.1)",
      "bjork-accent-muted": "rgba(236, 125, 67, 0.2)",
      "bjork-accent-badge": "rgba(236, 125, 67, 0.16)",
      "bjork-accent-badge-foreground": "#8d421d",
      "bjork-accent-foreground": "#3f2112",
      "bjork-accent-fill": "#ec7d43",
      "bjork-accent-fill-hover": "#f0935f",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.92), inset 1px 0 0 rgba(88, 72, 49, 0.026), inset -1px 0 0 rgba(255, 255, 255, 0.68), 0 14px 22px -9px rgba(66, 52, 33, 0.11)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(88, 72, 49, 0.045), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.86)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.64), 0 18px 38px -30px rgba(66, 52, 33, 0.2)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(88, 72, 49, 0.04), inset 0 12px 24px rgba(88, 72, 49, 0.022), inset 1px 0 0 rgba(88, 72, 49, 0.024), inset -1px 0 0 rgba(255, 255, 255, 0.62), 0 22px 44px -26px rgba(66, 52, 33, 0.22)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(88, 72, 49, 0.12), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.72)",
      "bjork-ring-offset": "#f7f3ea"
    },
    "dark": {
      "bjork-bg": "#111111",
      "bjork-surface": "#121212",
      "bjork-surface-muted": "rgba(22, 22, 22, 0.92)",
      "bjork-surface-hover": "#161616",
      "bjork-surface-active": "#202020",
      "bjork-panel": "#0d0d0d",
      "bjork-menu": "rgba(18, 18, 18, 0.98)",
      "bjork-field": "rgba(18, 18, 18, 0.95)",
      "bjork-field-muted": "rgba(18, 18, 18, 0.55)",
      "bjork-field-inset": "rgba(9, 9, 9, 0.75)",
      "bjork-error-bg": "#130d0a",
      "bjork-text": "#ededed",
      "bjork-text-strong": "rgba(237, 237, 237, 0.9)",
      "bjork-text-medium": "rgba(237, 237, 237, 0.72)",
      "bjork-text-muted": "rgba(237, 237, 237, 0.6)",
      "bjork-text-soft": "rgba(237, 237, 237, 0.5)",
      "bjork-text-faint": "rgba(237, 237, 237, 0.22)",
      "bjork-inverted-text": "#080808",
      "bjork-border": "#232323",
      "bjork-border-muted": "#1c1c1c",
      "bjork-border-strong": "#343434",
      "bjork-thumb": "#626262",
      "bjork-track": "#090909",
      "bjork-accent": "#ec5c13",
      "bjork-accent-hover": "#f07832",
      "bjork-accent-soft": "rgba(236, 92, 19, 0.12)",
      "bjork-accent-muted": "rgba(236, 92, 19, 0.24)",
      "bjork-accent-badge": "#b84a12",
      "bjork-accent-badge-foreground": "#fff2ea",
      "bjork-accent-foreground": "#fff2ea",
      "bjork-accent-fill": "#b84a12",
      "bjork-accent-fill-hover": "#a84a14",
      "bjork-shadow-surface": "inset 0 7px 14px rgba(255, 255, 255, 0.03), inset 0 0.5px 0.5px rgba(255, 255, 255, 0.06), 0 14px 20px -6px rgba(0, 0, 0, 0.45)",
      "bjork-shadow-soft": "inset 0 1px 0 rgba(255, 255, 255, 0.045)",
      "bjork-shadow-panel": "inset 0 1px 0 rgba(255, 255, 255, 0.035), 0 18px 36px -28px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-menu": "inset 0 1px 0 rgba(255, 255, 255, 0.055), inset 0 12px 24px rgba(255, 255, 255, 0.018), inset 0 -18px 26px rgba(0, 0, 0, 0.24), 0 22px 42px -22px rgba(0, 0, 0, 0.9)",
      "bjork-shadow-inset": "inset 0 1px 10px rgba(0, 0, 0, 0.45)",
      "bjork-ring-offset": "#080808"
    }
  },
  "css": {
    ".bjork-range": {
      "box-shadow": "inset 0 1px 1px rgba(255, 255, 255, 0.04), 0 0 14px rgba(189, 69, 20, 0.06)"
    },
    ".bjork-range::-webkit-slider-thumb": {
      "appearance": "none",
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-range::-moz-range-thumb": {
      "width": "16px",
      "height": "8px",
      "border": "0",
      "border-radius": "999px",
      "background": "var(--bjork-thumb)",
      "box-shadow": "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)"
    },
    ".bjork-layered-button-accent, .bjork-layered-button-raised": {
      "position": "relative",
      "isolation": "isolate",
      "appearance": "none",
      "overflow": "hidden",
      "border": "0",
      "border-radius": "13px",
      "box-shadow": "inset 0 1px 0 #ffffff38, 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a",
      "transition": "filter 150ms ease-out, box-shadow 150ms ease-out"
    },
    ".bjork-layered-button-accent::after, .bjork-layered-button-raised::after": {
      "position": "absolute",
      "inset": "0",
      "pointer-events": "none",
      "content": "\"\"",
      "border": "1.5px solid transparent",
      "border-radius": "inherit",
      "background": "linear-gradient(#ffffffb8, #0000003d 41% 75%, #ffffff47) border-box",
      "mix-blend-mode": "overlay",
      "-webkit-mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "mask-image": "linear-gradient(#000, #000), linear-gradient(#000, #000)",
      "-webkit-mask-clip": "padding-box, border-box",
      "mask-clip": "padding-box, border-box",
      "-webkit-mask-origin": "padding-box, border-box",
      "mask-origin": "padding-box, border-box",
      "-webkit-mask-composite": "xor",
      "mask-composite": "exclude"
    },
    ".bjork-layered-button-accent": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000040",
      "background": "radial-gradient(ellipse at -20px top, rgba(255, 255, 255, 0.14), transparent), linear-gradient(180deg, #ec5c13 0%, #ec5c13 62%, color-mix(in oklab, #ec5c13 84%, black 16%) 100%)"
    },
    ".bjork-layered-button-raised": {
      "color": "oklch(100% 0 0)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, oklch(0.1881 0.006 265), oklch(0.5493 0.0081 265))"
    },
    ".dark .bjork-layered-button-raised": {
      "color": "oklch(0.2574 0.0056 265)",
      "text-shadow": "0 -1px #00000026",
      "background": "radial-gradient(ellipse at -20px top, #ffffff38, #fff0), linear-gradient(180deg, color-mix(in oklab, oklch(0.9674 0.0013 265) 80%, oklch(0.2574 0.0056 265) 20%), color-mix(in oklab, oklch(0.669 0.0107 265) 80%, oklch(0.2574 0.0056 265) 20%))"
    },
    "@media (hover: hover)": {
      ".bjork-layered-button-accent:hover": {
        "filter": "brightness(1.08) saturate(1.04)"
      },
      ".bjork-layered-button-raised:hover": {
        "filter": "brightness(1.08) contrast(0.9)"
      }
    }
  },
  "meta": {
    "collection": "Tables",
    "registryUrl": "https://ui.isaiahbjork.com/sortable-table.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/sortable-table.json"
  },
  "type": "registry:component"
}