{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-tree-table",
  "title": "File tree table",
  "description": "File tree 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/file-tree-table.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type MouseEvent as ReactMouseEvent,\n} from \"react\";\nimport { motion, useReducedMotion } from \"framer-motion\";\nimport { useTheme } from \"next-themes\";\nimport {\n  ArrowDown,\n  ArrowUp,\n  ChevronRight,\n  ChevronsDownUp,\n  ChevronsUpDown,\n  File,\n  FileCode2,\n  FileCog,\n  FileImage,\n  FileJson,\n  FileLock2,\n  FileTerminal,\n  FileText,\n  FileType2,\n  Folder,\n  FolderOpen,\n  type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  type BjorkTableThemeMode,\n  getBjorkTablePalette,\n  useBjorkTableIsDark,\n} from \"./table-theme\";\n\n/* ------------------------------------------------------------------ */\n/* Types                                                               */\n/* ------------------------------------------------------------------ */\n\nexport interface FileNode {\n  /** Stable id, unique across the whole tree (a path works well). */\n  id: string;\n  name: string;\n  /** Bytes. Ignored for folders, which show the sum of their contents. */\n  size?: number;\n  /** Last modified. Folders default to their newest descendant. */\n  modified?: string | number | Date;\n  /** Override the kind label derived from the extension. */\n  kind?: string;\n  /** Present (even empty) means the node is a folder. */\n  children?: FileNode[];\n}\n\nexport type FileTreeSortKey = \"name\" | \"size\" | \"kind\" | \"modified\";\n\nexport interface FileTreeTableProps {\n  nodes?: FileNode[];\n  /** Root label shown in the toolbar. */\n  title?: string;\n  /** Folder ids expanded on first render. */\n  defaultExpanded?: string[];\n  /** Row focused on first render. */\n  defaultFocusedId?: string;\n  /** Rows selected on first render. */\n  defaultSelected?: string[];\n  /** Allow Shift / Ctrl / Cmd multi-select. */\n  multiSelect?: boolean;\n  /** Reference time for relative dates. Defaults to the sample clock for the sample tree, otherwise now. */\n  now?: number | Date;\n  /** Max height of the scroll body in px. */\n  maxHeight?: number;\n  loading?: boolean;\n  onOpen?: (node: FileNode) => void;\n  onSelectionChange?: (ids: string[], nodes: FileNode[]) => void;\n  className?: string;\n  theme?: BjorkTableThemeMode;\n  enableAnimations?: boolean;\n}\n\n/* ------------------------------------------------------------------ */\n/* Sample: an invented web app repository                              */\n/* ------------------------------------------------------------------ */\n\nexport const FILE_TREE_SAMPLE_NOW = Date.UTC(2026, 9, 6, 16, 30);\n\nconst minutesAgo = (m: number) => FILE_TREE_SAMPLE_NOW - m * 60_000;\nconst H = 60;\nconst D = 24 * H;\n\nfunction f(path: string, size: number, ago: number): FileNode {\n  const name = path.split(\"/\").pop() ?? path;\n  return { id: path, name, size, modified: minutesAgo(ago) };\n}\n\nfunction d(path: string, children: FileNode[]): FileNode {\n  const name = path.split(\"/\").pop() ?? path;\n  return { id: path, name, children };\n}\n\nexport const FILE_TREE_SAMPLE: FileNode[] = [\n  d(\".github\", [\n    d(\".github/workflows\", [\n      f(\".github/workflows/ci.yml\", 2_184, 9 * D),\n      f(\".github/workflows/preview-deploy.yml\", 1_412, 23 * D),\n      f(\".github/workflows/nightly-e2e.yml\", 1_036, 41 * D),\n    ]),\n    f(\".github/CODEOWNERS\", 214, 63 * D),\n    f(\".github/pull_request_template.md\", 688, 120 * D),\n  ]),\n  d(\"app\", [\n    d(\"app/(marketing)\", [\n      f(\"app/(marketing)/page.tsx\", 6_412, 2 * D),\n      f(\"app/(marketing)/layout.tsx\", 1_288, 18 * D),\n      d(\"app/(marketing)/pricing\", [\n        f(\"app/(marketing)/pricing/page.tsx\", 8_906, 3 * D),\n        f(\"app/(marketing)/pricing/plans.ts\", 2_342, 3 * D),\n      ]),\n      d(\"app/(marketing)/changelog\", [\n        f(\"app/(marketing)/changelog/page.tsx\", 3_120, 5 * D),\n        f(\"app/(marketing)/changelog/entries.mdx\", 24_880, 1 * D),\n      ]),\n    ]),\n    d(\"app/dashboard\", [\n      f(\"app/dashboard/page.tsx\", 9_734, 42),\n      f(\"app/dashboard/layout.tsx\", 2_206, 6 * D),\n      f(\"app/dashboard/loading.tsx\", 612, 30 * D),\n      d(\"app/dashboard/projects\", [\n        f(\"app/dashboard/projects/page.tsx\", 7_380, 3 * H),\n        d(\"app/dashboard/projects/[slug]\", [\n          f(\"app/dashboard/projects/[slug]/page.tsx\", 11_942, 3 * H),\n          f(\"app/dashboard/projects/[slug]/settings.tsx\", 6_115, 2 * D),\n          f(\"app/dashboard/projects/[slug]/activity-feed.tsx\", 5_288, 19 * H),\n        ]),\n        f(\"app/dashboard/projects/new-project-form.tsx\", 5_402, 8 * D),\n      ]),\n      d(\"app/dashboard/billing\", [\n        f(\"app/dashboard/billing/page.tsx\", 6_880, 12 * D),\n        f(\"app/dashboard/billing/invoice-row.tsx\", 2_140, 12 * D),\n        f(\"app/dashboard/billing/usage-meter.tsx\", 3_318, 9 * D),\n      ]),\n      d(\"app/dashboard/settings\", [\n        f(\"app/dashboard/settings/page.tsx\", 5_022, 21 * D),\n        f(\"app/dashboard/settings/team-members.tsx\", 6_708, 4 * D),\n        f(\"app/dashboard/settings/api-keys.tsx\", 4_416, 4 * D),\n      ]),\n    ]),\n    d(\"app/api\", [\n      d(\"app/api/auth\", [\n        f(\"app/api/auth/route.ts\", 3_604, 26 * D),\n        f(\"app/api/auth/callback/route.ts\", 2_870, 26 * D),\n      ]),\n      d(\"app/api/projects\", [\n        f(\"app/api/projects/route.ts\", 4_212, 5 * H),\n        f(\"app/api/projects/[id]/route.ts\", 3_996, 5 * H),\n      ]),\n      d(\"app/api/webhooks\", [f(\"app/api/webhooks/billing/route.ts\", 5_160, 11 * D)]),\n    ]),\n    f(\"app/layout.tsx\", 2_480, 15 * D),\n    f(\"app/globals.css\", 7_934, 2 * D),\n    f(\"app/not-found.tsx\", 1_102, 88 * D),\n    f(\"app/favicon.ico\", 15_086, 210 * D),\n  ]),\n  d(\"components\", [\n    d(\"components/ui\", [\n      f(\"components/ui/button.tsx\", 4_208, 6 * D),\n      f(\"components/ui/dialog.tsx\", 6_744, 13 * D),\n      f(\"components/ui/dropdown-menu.tsx\", 8_390, 13 * D),\n      f(\"components/ui/input.tsx\", 1_918, 34 * D),\n      f(\"components/ui/popover.tsx\", 3_276, 34 * D),\n      f(\"components/ui/tabs.tsx\", 2_954, 19 * D),\n      f(\"components/ui/toast.tsx\", 5_588, 7 * D),\n      f(\"components/ui/tooltip.tsx\", 2_016, 19 * D),\n    ]),\n    d(\"components/charts\", [\n      f(\"components/charts/area-chart.tsx\", 7_812, 1 * D),\n      f(\"components/charts/sparkline.tsx\", 2_664, 1 * D),\n      f(\"components/charts/use-chart-scale.ts\", 3_148, 1 * D),\n    ]),\n    f(\"components/command-palette.tsx\", 12_402, 20),\n    f(\"components/project-card.tsx\", 4_380, 4 * D),\n    f(\"components/sidebar-nav.tsx\", 6_036, 2 * D),\n    f(\"components/theme-switch.tsx\", 1_724, 52 * D),\n  ]),\n  d(\"lib\", [\n    f(\"lib/db.ts\", 2_906, 17 * D),\n    f(\"lib/auth.ts\", 5_274, 26 * D),\n    f(\"lib/billing.ts\", 6_618, 11 * D),\n    f(\"lib/format.ts\", 1_840, 9 * D),\n    f(\"lib/rate-limit.ts\", 2_212, 44 * D),\n    f(\"lib/utils.ts\", 986, 60 * D),\n    d(\"lib/validators\", [\n      f(\"lib/validators/project.ts\", 1_602, 8 * D),\n      f(\"lib/validators/team.ts\", 1_198, 21 * D),\n    ]),\n  ]),\n  d(\"db\", [\n    d(\"db/migrations\", [\n      f(\"db/migrations/0001_init.sql\", 4_812, 190 * D),\n      f(\"db/migrations/0002_projects.sql\", 2_306, 150 * D),\n      f(\"db/migrations/0003_billing.sql\", 3_418, 96 * D),\n      f(\"db/migrations/0004_api_keys.sql\", 1_204, 27 * D),\n      f(\"db/migrations/0005_usage_events.sql\", 2_640, 5 * D),\n    ]),\n    f(\"db/schema.ts\", 9_120, 5 * D),\n    f(\"db/seed.ts\", 4_066, 33 * D),\n  ]),\n  d(\"public\", [\n    d(\"public/fonts\", [\n      f(\"public/fonts/inter-var.woff2\", 324_812, 300 * D),\n      f(\"public/fonts/mono-regular.woff2\", 98_440, 300 * D),\n    ]),\n    d(\"public/images\", [\n      f(\"public/images/hero-dashboard.png\", 1_482_330, 14 * D),\n      f(\"public/images/og-default.png\", 268_904, 59 * D),\n      f(\"public/images/empty-projects.svg\", 6_288, 59 * D),\n      f(\"public/images/logo-mark.svg\", 1_412, 240 * D),\n    ]),\n    f(\"public/robots.txt\", 68, 240 * D),\n  ]),\n  d(\"scripts\", [\n    f(\"scripts/seed-dev.sh\", 1_306, 33 * D),\n    f(\"scripts/check-bundle.mjs\", 2_480, 16 * D),\n  ]),\n  d(\"tests\", [\n    d(\"tests/e2e\", [\n      f(\"tests/e2e/onboarding.spec.ts\", 5_704, 6 * D),\n      f(\"tests/e2e/billing.spec.ts\", 4_230, 11 * D),\n      f(\"tests/e2e/projects.spec.ts\", 6_982, 3 * H),\n    ]),\n    f(\"tests/format.test.ts\", 2_118, 9 * D),\n    f(\"tests/rate-limit.test.ts\", 1_744, 44 * D),\n  ]),\n  f(\".env.example\", 512, 26 * D),\n  f(\".gitignore\", 384, 210 * D),\n  f(\"Dockerfile\", 1_208, 47 * D),\n  f(\"README.md\", 6_904, 2 * D),\n  f(\"next.config.ts\", 1_146, 15 * D),\n  f(\"package.json\", 2_874, 1 * D),\n  f(\"pnpm-lock.yaml\", 412_906, 1 * D),\n  f(\"tsconfig.json\", 702, 88 * D),\n];\n\n/* ------------------------------------------------------------------ */\n/* Helpers                                                             */\n/* ------------------------------------------------------------------ */\n\nfunction toMs(value: FileNode[\"modified\"]): number | null {\n  if (value === undefined) return null;\n  if (value instanceof Date) return value.getTime();\n  if (typeof value === \"number\") return value;\n  const t = Date.parse(value);\n  return Number.isNaN(t) ? null : t;\n}\n\nexport function formatBytes(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  const units = [\"KB\", \"MB\", \"GB\", \"TB\"];\n  let v = bytes / 1024;\n  let i = 0;\n  while (v >= 1024 && i < units.length - 1) {\n    v /= 1024;\n    i += 1;\n  }\n  return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;\n}\n\nfunction formatRelative(ms: number, now: number): string {\n  const s = Math.max(0, Math.round((now - ms) / 1000));\n  if (s < 45) return \"just now\";\n  const m = Math.round(s / 60);\n  if (m < 60) return `${m} min ago`;\n  const h = Math.round(m / 60);\n  if (h < 24) return `${h} hr ago`;\n  const days = Math.round(h / 24);\n  if (days === 1) return \"yesterday\";\n  if (days < 7) return `${days} days ago`;\n  if (days < 30) return `${Math.round(days / 7)} wk ago`;\n  if (days < 365) return `${Math.round(days / 30)} mo ago`;\n  const y = Math.round(days / 365);\n  return `${y} yr ago`;\n}\n\nconst absoluteFormat = new Intl.DateTimeFormat(\"en-US\", {\n  dateStyle: \"medium\",\n  timeStyle: \"short\",\n});\n\nconst KIND_BY_EXT: Record<string, string> = {\n  tsx: \"React component\",\n  jsx: \"React component\",\n  ts: \"TypeScript\",\n  mts: \"TypeScript\",\n  js: \"JavaScript\",\n  mjs: \"JavaScript\",\n  cjs: \"JavaScript\",\n  json: \"JSON\",\n  md: \"Markdown\",\n  mdx: \"MDX document\",\n  css: \"Stylesheet\",\n  svg: \"SVG image\",\n  png: \"PNG image\",\n  jpg: \"JPEG image\",\n  jpeg: \"JPEG image\",\n  webp: \"WebP image\",\n  ico: \"Icon\",\n  woff2: \"Web font\",\n  woff: \"Web font\",\n  yml: \"YAML\",\n  yaml: \"YAML\",\n  toml: \"TOML\",\n  sql: \"SQL migration\",\n  sh: \"Shell script\",\n  txt: \"Plain text\",\n  example: \"Env template\",\n  env: \"Environment\",\n};\n\nconst KIND_BY_NAME: Record<string, string> = {\n  dockerfile: \"Dockerfile\",\n  \".gitignore\": \"Git ignore\",\n  codeowners: \"Code owners\",\n  license: \"License\",\n  \"pnpm-lock.yaml\": \"Lockfile\",\n  \"package-lock.json\": \"Lockfile\",\n  \"yarn.lock\": \"Lockfile\",\n};\n\nfunction extOf(name: string) {\n  const i = name.lastIndexOf(\".\");\n  return i > 0 ? name.slice(i + 1).toLowerCase() : \"\";\n}\n\nfunction kindOf(node: FileNode): string {\n  if (node.kind) return node.kind;\n  if (node.children) return \"Folder\";\n  const lower = node.name.toLowerCase();\n  return KIND_BY_NAME[lower] ?? KIND_BY_EXT[extOf(lower)] ?? \"Document\";\n}\n\nfunction iconOf(node: FileNode, open: boolean): LucideIcon {\n  if (node.children) return open ? FolderOpen : Folder;\n  const lower = node.name.toLowerCase();\n  if (lower.endsWith(\".lock\") || lower.includes(\"-lock.\")) return FileLock2;\n  const ext = extOf(lower);\n  if ([\"ts\", \"tsx\", \"js\", \"jsx\", \"mjs\", \"cjs\", \"mts\", \"sql\", \"css\"].includes(ext)) return FileCode2;\n  if (ext === \"json\") return FileJson;\n  if ([\"md\", \"mdx\", \"txt\"].includes(ext) || lower === \"license\" || lower === \"codeowners\") return FileText;\n  if ([\"png\", \"jpg\", \"jpeg\", \"webp\", \"svg\", \"ico\", \"gif\", \"avif\"].includes(ext)) return FileImage;\n  if ([\"woff\", \"woff2\", \"ttf\", \"otf\"].includes(ext)) return FileType2;\n  if (ext === \"sh\") return FileTerminal;\n  if ([\"yml\", \"yaml\", \"toml\", \"example\", \"env\"].includes(ext) || lower.startsWith(\".\") || lower === \"dockerfile\")\n    return FileCog;\n  return File;\n}\n\ninterface NodeMeta {\n  node: FileNode;\n  parentId: string | null;\n  isFolder: boolean;\n  size: number;\n  modified: number | null;\n  kind: string;\n  fileCount: number;\n}\n\nfunction buildMeta(nodes: FileNode[]) {\n  const meta = new Map<string, NodeMeta>();\n  const walk = (\n    list: FileNode[],\n    parentId: string | null,\n  ): { size: number; modified: number | null; fileCount: number } => {\n    let size = 0;\n    let modified: number | null = null;\n    let fileCount = 0;\n    for (const node of list) {\n      const isFolder = Array.isArray(node.children);\n      let nodeSize = node.size ?? 0;\n      let nodeModified = toMs(node.modified);\n      let nodeFiles = 1;\n      if (isFolder) {\n        const agg = walk(node.children ?? [], node.id);\n        nodeSize = agg.size;\n        nodeModified = nodeModified ?? agg.modified;\n        nodeFiles = agg.fileCount;\n      }\n      meta.set(node.id, {\n        node,\n        parentId,\n        isFolder,\n        size: nodeSize,\n        modified: nodeModified,\n        kind: kindOf(node),\n        fileCount: isFolder ? nodeFiles : 1,\n      });\n      size += nodeSize;\n      fileCount += nodeFiles;\n      if (nodeModified !== null && (modified === null || nodeModified > modified)) modified = nodeModified;\n    }\n    return { size, modified, fileCount };\n  };\n  const totals = walk(nodes, null);\n  return { meta, totals };\n}\n\ninterface FlatRow {\n  id: string;\n  level: number;\n  setSize: number;\n  posInSet: number;\n}\n\nconst collator = new Intl.Collator(\"en\", { numeric: true, sensitivity: \"base\" });\n\nconst subscribeNoop = () => () => {};\nconst useMounted = () =>\n  useSyncExternalStore(\n    subscribeNoop,\n    () => true,\n    () => false,\n  );\n\nlet clientNow: number | null = null;\nconst getClientNow = () => (clientNow ??= Date.now());\nconst getServerNow = () => null;\n\nconst INDENT = 18;\nconst PAD = 14;\nconst ACCENT_RING =\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#ec5c13]\";\n\n/* ------------------------------------------------------------------ */\n/* Component                                                           */\n/* ------------------------------------------------------------------ */\n\nexport function FileTreeTable({\n  nodes,\n  title = \"lumen-console\",\n  defaultExpanded,\n  defaultFocusedId,\n  defaultSelected,\n  multiSelect = true,\n  now,\n  maxHeight = 460,\n  loading = false,\n  onOpen,\n  onSelectionChange,\n  className,\n  theme = \"auto\",\n  enableAnimations = true,\n}: FileTreeTableProps) {\n  const tree = nodes ?? FILE_TREE_SAMPLE;\n  const mounted = useMounted();\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 clock = useSyncExternalStore(subscribeNoop, getClientNow, getServerNow);\n  const nowMs =\n    now !== undefined\n      ? now instanceof Date\n        ? now.getTime()\n        : now\n      : nodes === undefined\n        ? FILE_TREE_SAMPLE_NOW\n        : clock;\n\n  const { meta, totals } = useMemo(() => buildMeta(tree), [tree]);\n\n  const [expanded, setExpanded] = useState<Set<string>>(\n    () =>\n      new Set(\n        defaultExpanded ??\n          (nodes === undefined ? [\"app\", \"app/dashboard\", \"app/dashboard/projects\", \"app/dashboard/projects/[slug]\", \"components\"] : []),\n      ),\n  );\n  const [sort, setSort] = useState<{ key: FileTreeSortKey; dir: \"asc\" | \"desc\" }>({\n    key: \"name\",\n    dir: \"asc\",\n  });\n  const [focusedId, setFocusedId] = useState<string | null>(defaultFocusedId ?? null);\n  const [selected, setSelected] = useState<Set<string>>(() => new Set(defaultSelected ?? []));\n  const [anchorId, setAnchorId] = useState<string | null>(defaultFocusedId ?? null);\n  // How freshly revealed rows enter: staggered on mount, quick fade on pointer expand, instant from the keyboard.\n  const [reveal, setReveal] = useState<\"mount\" | \"pointer\" | \"none\">(\"mount\");\n\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const pendingFocus = useRef(false);\n  const typeahead = useRef({ buffer: \"\", at: 0 });\n\n  const compare = useCallback(\n    (a: FileNode, b: FileNode) => {\n      const ma = meta.get(a.id)!;\n      const mb = meta.get(b.id)!;\n      if (ma.isFolder !== mb.isFolder) return ma.isFolder ? -1 : 1;\n      let r = 0;\n      if (sort.key === \"size\") r = ma.size - mb.size;\n      else if (sort.key === \"kind\") r = collator.compare(ma.kind, mb.kind);\n      else if (sort.key === \"modified\") r = (ma.modified ?? 0) - (mb.modified ?? 0);\n      if (r === 0) r = collator.compare(a.name, b.name);\n      return sort.dir === \"asc\" ? r : -r;\n    },\n    [meta, sort],\n  );\n\n  const rows = useMemo(() => {\n    const out: FlatRow[] = [];\n    const walk = (list: FileNode[], level: number) => {\n      const sorted = [...list].sort(compare);\n      sorted.forEach((node, i) => {\n        out.push({ id: node.id, level, setSize: sorted.length, posInSet: i + 1 });\n        if (node.children && expanded.has(node.id)) walk(node.children, level + 1);\n      });\n    };\n    walk(tree, 1);\n    return out;\n  }, [tree, compare, expanded]);\n\n  const indexById = useMemo(() => {\n    const m = new Map<string, number>();\n    rows.forEach((r, i) => m.set(r.id, i));\n    return m;\n  }, [rows]);\n\n  // If the focused row got hidden by a collapse, focus falls back to its nearest visible ancestor.\n  const activeId = useMemo(() => {\n    let id = focusedId;\n    while (id && !indexById.has(id)) id = meta.get(id)?.parentId ?? null;\n    return id ?? rows[0]?.id ?? null;\n  }, [focusedId, indexById, meta, rows]);\n\n  useEffect(() => {\n    if (!pendingFocus.current || !activeId) return;\n    pendingFocus.current = false;\n    const el = scrollRef.current?.querySelector<HTMLTableRowElement>(\n      `tr[data-row-id=\"${CSS.escape(activeId)}\"]`,\n    );\n    if (!el) return;\n    el.focus({ preventScroll: true });\n    el.scrollIntoView({ block: \"nearest\", inline: \"nearest\" });\n  }, [activeId, rows]);\n\n  const emitSelection = useCallback(\n    (next: Set<string>) => {\n      setSelected(next);\n      if (onSelectionChange) {\n        const ids = rows.filter((r) => next.has(r.id)).map((r) => r.id);\n        for (const id of next) if (!ids.includes(id)) ids.push(id);\n        onSelectionChange(\n          ids,\n          ids.map((id) => meta.get(id)!.node),\n        );\n      }\n    },\n    [meta, onSelectionChange, rows],\n  );\n\n  const moveTo = useCallback(\n    (id: string, mode: \"select\" | \"extend\" | \"focus-only\") => {\n      pendingFocus.current = true;\n      setFocusedId(id);\n      if (mode === \"focus-only\") return;\n      if (mode === \"extend\" && multiSelect) {\n        const from = indexById.get(anchorId ?? activeId ?? id) ?? 0;\n        const to = indexById.get(id) ?? 0;\n        const [a, b] = from < to ? [from, to] : [to, from];\n        emitSelection(new Set(rows.slice(a, b + 1).map((r) => r.id)));\n        return;\n      }\n      setAnchorId(id);\n      emitSelection(new Set([id]));\n    },\n    [activeId, anchorId, emitSelection, indexById, multiSelect, rows],\n  );\n\n  const setFolder = useCallback((id: string, open: boolean, via: \"pointer\" | \"keyboard\") => {\n    setReveal(via === \"pointer\" ? \"pointer\" : \"none\");\n    setExpanded((prev) => {\n      if (prev.has(id) === open) return prev;\n      const next = new Set(prev);\n      if (open) next.add(id);\n      else next.delete(id);\n      return next;\n    });\n  }, []);\n\n  const expandAll = (via: \"pointer\" | \"keyboard\") => {\n    setReveal(via === \"pointer\" ? \"pointer\" : \"none\");\n    setExpanded(new Set([...meta.values()].filter((m) => m.isFolder).map((m) => m.node.id)));\n  };\n  const collapseAll = () => {\n    setExpanded(new Set());\n  };\n\n  const toggleSort = (key: FileTreeSortKey) => {\n    setSort((s) =>\n      s.key === key ? { key, dir: s.dir === \"asc\" ? \"desc\" : \"asc\" } : { key, dir: key === \"modified\" || key === \"size\" ? \"desc\" : \"asc\" },\n    );\n  };\n\n  const open = (id: string, via: \"pointer\" | \"keyboard\") => {\n    const m = meta.get(id);\n    if (!m) return;\n    if (m.isFolder) setFolder(id, !expanded.has(id), via);\n    onOpen?.(m.node);\n  };\n\n  const handleKeyDown = (e: ReactKeyboardEvent<HTMLTableSectionElement>) => {\n    const id = (e.target as HTMLElement).closest<HTMLElement>(\"tr[data-row-id]\")?.dataset.rowId;\n    if (!id) return;\n    const i = indexById.get(id);\n    if (i === undefined) return;\n    const m = meta.get(id)!;\n    const mod = e.metaKey || e.ctrlKey;\n    const mode = e.shiftKey && multiSelect ? \"extend\" : mod && multiSelect ? \"focus-only\" : \"select\";\n    let handled = true;\n\n    switch (e.key) {\n      case \"ArrowDown\":\n        if (i < rows.length - 1) moveTo(rows[i + 1].id, mode);\n        break;\n      case \"ArrowUp\":\n        if (i > 0) moveTo(rows[i - 1].id, mode);\n        break;\n      case \"ArrowRight\":\n        if (m.isFolder) {\n          if (!expanded.has(id)) setFolder(id, true, \"keyboard\");\n          else if (rows[i + 1] && rows[i + 1].level > rows[i].level) moveTo(rows[i + 1].id, \"select\");\n        }\n        break;\n      case \"ArrowLeft\":\n        if (m.isFolder && expanded.has(id)) setFolder(id, false, \"keyboard\");\n        else if (m.parentId) moveTo(m.parentId, \"select\");\n        break;\n      case \"Home\":\n        moveTo(rows[0].id, mode);\n        break;\n      case \"End\":\n        moveTo(rows[rows.length - 1].id, mode);\n        break;\n      case \"PageDown\":\n        moveTo(rows[Math.min(rows.length - 1, i + 10)].id, mode);\n        break;\n      case \"PageUp\":\n        moveTo(rows[Math.max(0, i - 10)].id, mode);\n        break;\n      case \"Enter\":\n        open(id, \"keyboard\");\n        break;\n      case \" \":\n        if (multiSelect && mod) {\n          const next = new Set(selected);\n          if (next.has(id)) next.delete(id);\n          else next.add(id);\n          setAnchorId(id);\n          emitSelection(next);\n        } else if (multiSelect && e.shiftKey) {\n          moveTo(id, \"extend\");\n        } else {\n          setAnchorId(id);\n          emitSelection(new Set([id]));\n        }\n        break;\n      case \"*\": {\n        // APG: expand every sibling folder of the focused row.\n        setReveal(\"none\");\n        const parent = m.parentId ? meta.get(m.parentId)!.node.children! : tree;\n        setExpanded((prev) => {\n          const next = new Set(prev);\n          parent.forEach((n) => n.children && next.add(n.id));\n          return next;\n        });\n        break;\n      }\n      case \"a\":\n        if (mod && multiSelect) {\n          emitSelection(new Set(rows.map((r) => r.id)));\n        } else handled = false;\n        break;\n      default:\n        handled = false;\n    }\n\n    if (!handled && e.key.length === 1 && !mod && !e.altKey && e.key !== \" \") {\n      // Typeahead: letters typed within 600ms build a prefix; one repeated letter cycles matches.\n      const t = typeahead.current;\n      const stamp = e.timeStamp;\n      t.buffer = stamp - t.at > 600 ? e.key.toLowerCase() : t.buffer + e.key.toLowerCase();\n      t.at = stamp;\n      const cycling = t.buffer.length > 1 && [...t.buffer].every((c) => c === t.buffer[0]);\n      const needle = cycling ? t.buffer[0] : t.buffer;\n      const start = t.buffer.length === 1 || cycling ? i + 1 : i;\n      for (let k = 0; k < rows.length; k += 1) {\n        const r = rows[(start + k) % rows.length];\n        if (meta.get(r.id)!.node.name.toLowerCase().startsWith(needle)) {\n          moveTo(r.id, \"select\");\n          break;\n        }\n      }\n      handled = true;\n    }\n\n    if (handled) {\n      e.preventDefault();\n      e.stopPropagation();\n    }\n  };\n\n  const handleRowClick = (e: ReactMouseEvent, id: string) => {\n    setFocusedId(id);\n    if (multiSelect && e.shiftKey) {\n      const from = indexById.get(anchorId ?? id) ?? 0;\n      const to = indexById.get(id) ?? 0;\n      const [a, b] = from < to ? [from, to] : [to, from];\n      emitSelection(new Set(rows.slice(a, b + 1).map((r) => r.id)));\n      return;\n    }\n    setAnchorId(id);\n    if (multiSelect && (e.metaKey || e.ctrlKey)) {\n      const next = new Set(selected);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      emitSelection(next);\n      return;\n    }\n    emitSelection(new Set([id]));\n  };\n\n  // Toolbar context: the focused row's path, and a selection summary for the footer.\n  const breadcrumb = useMemo(() => {\n    const parts: string[] = [];\n    let id: string | null = activeId;\n    while (id) {\n      const m = meta.get(id);\n      if (!m) break;\n      parts.unshift(m.node.name);\n      id = m.parentId;\n    }\n    return parts;\n  }, [activeId, meta]);\n\n  const selectionSummary = useMemo(() => {\n    let bytes = 0;\n    let count = 0;\n    for (const id of selected) {\n      const m = meta.get(id);\n      if (!m) continue;\n      bytes += m.size;\n      count += 1;\n    }\n    return { bytes, count };\n  }, [meta, selected]);\n\n  const anyExpanded = expanded.size > 0;\n\n  const headers: { key: FileTreeSortKey; label: string; className: string; align: \"left\" | \"right\" }[] = [\n    { key: \"name\", label: \"Name\", className: \"\", align: \"left\" },\n    { key: \"size\", label: \"Size\", className: \"w-[84px] @lg:w-[96px]\", align: \"right\" },\n    { key: \"kind\", label: \"Kind\", className: \"hidden w-[150px] @2xl:table-cell\", align: \"left\" },\n    { key: \"modified\", label: \"Modified\", className: \"w-[104px] @lg:w-[128px]\", align: \"right\" },\n  ];\n\n  const ariaSort = (key: FileTreeSortKey) =>\n    sort.key === key ? (sort.dir === \"asc\" ? \"ascending\" : \"descending\") : \"none\";\n\n  const guideColor = isDark ? \"bg-[#ededed]/[0.07]\" : \"bg-[#171717]/[0.08]\";\n  const activeGuideColor = isDark ? \"bg-[#d86a2c]/45\" : \"bg-[#bd4514]/40\";\n  // Light up the guide of the focused row's folder so the eye can trace its siblings.\n  const guideSpan = useMemo(() => {\n    const parentId = activeId ? meta.get(activeId)?.parentId ?? null : null;\n    const from = parentId ? indexById.get(parentId) : undefined;\n    if (from === undefined) return { depth: -1, from: -1, to: -1 };\n    const level = rows[from].level;\n    let to = from + 1;\n    while (to < rows.length && rows[to].level > level) to += 1;\n    return { depth: level - 1, from, to };\n  }, [activeId, indexById, meta, rows]);\n\n  return (\n    <div\n      className={cn(\n        \"@container mx-auto w-full max-w-[920px] overflow-hidden rounded-[18px] border text-[13px]\",\n        palette.container,\n        className,\n      )}\n    >\n      {/* Toolbar */}\n      <div className={cn(\"flex items-center gap-3 border-b px-4 py-3\", palette.divider)}>\n        <div className=\"flex min-w-0 flex-1 items-center\">\n          <nav aria-label=\"Focused path\" className=\"min-w-0\">\n            <ol className=\"flex min-w-0 items-center gap-1 overflow-hidden whitespace-nowrap\">\n              <li className={cn(\"shrink-0 font-medium\", palette.primaryText)}>{title}</li>\n              {breadcrumb.length > 2 ? (\n                <li aria-hidden className={cn(\"flex shrink-0 items-center gap-1 @2xl:hidden\", palette.secondaryText)}>\n                  <span>/</span>\n                  <span>…</span>\n                </li>\n              ) : null}\n              {breadcrumb.map((part, i) => (\n                <li\n                  key={`${part}-${i}`}\n                  className={cn(\n                    \"flex min-w-0 items-center gap-1\",\n                    i === breadcrumb.length - 1 ? palette.primaryText : palette.secondaryText,\n                    i < breadcrumb.length - 2 ? \"hidden @2xl:flex\" : \"\",\n                    i === breadcrumb.length - 1 ? \"min-w-[4rem]\" : \"shrink-[2]\",\n                  )}\n                >\n                  <span aria-hidden className={palette.secondaryText}>\n                    /\n                  </span>\n                  <span className=\"truncate\">{part}</span>\n                </li>\n              ))}\n            </ol>\n          </nav>\n        </div>\n        <div className=\"flex shrink-0 items-center gap-1.5\">\n          <button\n            type=\"button\"\n            onClick={(e) => expandAll(e.detail === 0 ? \"keyboard\" : \"pointer\")}\n            className={cn(\n              \"inline-flex h-8 items-center gap-1.5 rounded-[9px] border px-2.5 text-[12px] transition-colors\",\n              palette.control,\n              ACCENT_RING,\n            )}\n          >\n            <ChevronsUpDown size={13} aria-hidden />\n            <span className=\"hidden @md:inline\">Expand all</span>\n            <span className=\"sr-only @md:hidden\">Expand all</span>\n          </button>\n          <button\n            type=\"button\"\n            onClick={collapseAll}\n            disabled={!anyExpanded}\n            className={cn(\n              \"inline-flex h-8 items-center gap-1.5 rounded-[9px] border px-2.5 text-[12px] transition-colors disabled:opacity-45\",\n              palette.control,\n              ACCENT_RING,\n            )}\n          >\n            <ChevronsDownUp size={13} aria-hidden />\n            <span className=\"hidden @md:inline\">Collapse all</span>\n            <span className=\"sr-only @md:hidden\">Collapse all</span>\n          </button>\n        </div>\n      </div>\n\n      {/* Scroll body: both axes so the header can stick while wide content scrolls sideways. */}\n      <div\n        ref={scrollRef}\n        role=\"region\"\n        aria-label={`${title} files`}\n        tabIndex={0}\n        className={cn(\"relative overflow-auto overscroll-contain\", ACCENT_RING)}\n        style={{ maxHeight, scrollPaddingTop: 38 }}\n      >\n        <table\n          role=\"treegrid\"\n          aria-label={`${title} file tree`}\n          aria-multiselectable={multiSelect || undefined}\n          aria-rowcount={rows.length + 1}\n          aria-busy={loading || undefined}\n          className=\"w-full min-w-[340px] table-fixed border-separate border-spacing-0\"\n        >\n          <caption className=\"sr-only\">\n            Files in {title}. Arrow keys move between rows, Right expands a folder, Left collapses it, Enter opens,\n            type to jump by name.\n          </caption>\n          <thead>\n            <tr role=\"row\">\n              {headers.map((h) => (\n                <th\n                  key={h.key}\n                  role=\"columnheader\"\n                  scope=\"col\"\n                  aria-sort={ariaSort(h.key)}\n                  className={cn(\n                    \"sticky top-0 z-10 h-[38px] border-b p-0 font-medium\",\n                    palette.header,\n                    h.className,\n                  )}\n                >\n                  <button\n                    type=\"button\"\n                    onClick={() => toggleSort(h.key)}\n                    className={cn(\n                      \"group/sort flex h-full w-full items-center gap-1 px-3 text-[11px] uppercase tracking-[0.08em]\",\n                      h.align === \"right\" ? \"justify-end\" : \"justify-start\",\n                      h.key === \"name\" ? \"pl-[14px]\" : \"\",\n                      sort.key === h.key ? palette.primaryText : \"\",\n                      ACCENT_RING,\n                    )}\n                  >\n                    {h.label}\n                    <span\n                      aria-hidden\n                      className={cn(\n                        \"inline-flex size-3.5 items-center justify-center transition-opacity\",\n                        sort.key === h.key ? cn(\"opacity-100\", palette.accent) : \"opacity-0 group-hover/sort:opacity-50\",\n                      )}\n                    >\n                      {sort.key === h.key && sort.dir === \"desc\" ? <ArrowDown size={11} /> : <ArrowUp size={11} />}\n                    </span>\n                  </button>\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody onKeyDown={handleKeyDown}>\n            {loading ? (\n              Array.from({ length: 8 }, (_, i) => (\n                <tr key={`sk-${i}`} role=\"row\" aria-hidden>\n                  <td role=\"gridcell\" className={cn(\"h-[34px] border-b px-3\", palette.divider)}>\n                    <div\n                      className={cn(\"h-3 rounded-full\", palette.mutedSurface)}\n                      style={{ width: `${40 + ((i * 37) % 40)}%`, marginLeft: (i % 3) * INDENT }}\n                    />\n                  </td>\n                  <td role=\"gridcell\" className={cn(\"border-b px-3\", palette.divider)}>\n                    <div className={cn(\"ml-auto h-3 w-10 rounded-full\", palette.mutedSurface)} />\n                  </td>\n                  <td role=\"gridcell\" className={cn(\"hidden border-b px-3 @2xl:table-cell\", palette.divider)}>\n                    <div className={cn(\"h-3 w-20 rounded-full\", palette.mutedSurface)} />\n                  </td>\n                  <td role=\"gridcell\" className={cn(\"border-b px-3\", palette.divider)}>\n                    <div className={cn(\"ml-auto h-3 w-14 rounded-full\", palette.mutedSurface)} />\n                  </td>\n                </tr>\n              ))\n            ) : rows.length === 0 ? (\n              <tr role=\"row\">\n                <td role=\"gridcell\" colSpan={4} className={cn(\"px-4 py-12 text-center\", palette.secondaryText)}>\n                  This folder is empty.\n                </td>\n              </tr>\n            ) : (\n              rows.map((row, index) => {\n                const m = meta.get(row.id)!;\n                const isOpen = m.isFolder && expanded.has(row.id);\n                const isSelected = selected.has(row.id);\n                const isActive = row.id === activeId;\n                const Icon = iconOf(m.node, isOpen);\n                const indent = PAD + (row.level - 1) * INDENT;\n                const enter =\n                  shouldAnimate && reveal !== \"none\"\n                    ? {\n                        initial: { opacity: 0, y: reveal === \"mount\" ? 6 : -3 },\n                        animate: { opacity: 1, y: 0 },\n                        transition: {\n                          type: \"spring\" as const,\n                          stiffness: 520,\n                          damping: 38,\n                          delay: reveal === \"mount\" ? Math.min(index, 18) * 0.022 : 0,\n                        },\n                      }\n                    : { initial: false as const };\n                return (\n                  <motion.tr\n                    key={row.id}\n                    {...enter}\n                    data-row-id={row.id}\n                    role=\"row\"\n                    aria-level={row.level}\n                    aria-setsize={row.setSize}\n                    aria-posinset={row.posInSet}\n                    aria-rowindex={index + 2}\n                    aria-expanded={m.isFolder ? isOpen : undefined}\n                    aria-selected={multiSelect ? isSelected : isSelected || undefined}\n                    tabIndex={isActive ? 0 : -1}\n                    onClick={(e) => handleRowClick(e, row.id)}\n                    onDoubleClick={() => open(row.id, \"pointer\")}\n                    onFocus={() => {\n                      if (row.id !== focusedId) setFocusedId(row.id);\n                    }}\n                    className={cn(\n                      \"group/row cursor-default select-none transition-colors duration-150\",\n                      isSelected ? palette.selectedRow : palette.row,\n                      ACCENT_RING,\n                    )}\n                  >\n                    <td role=\"gridcell\" className={cn(\"relative h-[34px] border-b p-0\", palette.divider)}>\n                      {isSelected ? (\n                        <span aria-hidden className=\"absolute inset-y-0 left-0 w-[2px] bg-[#ec5c13]\" />\n                      ) : null}\n                      {Array.from({ length: row.level - 1 }, (_, depth) => (\n                        <span\n                          key={depth}\n                          aria-hidden\n                          className={cn(\n                            \"pointer-events-none absolute -bottom-px top-0 w-px\",\n                            depth === guideSpan.depth && index > guideSpan.from && index < guideSpan.to\n                              ? activeGuideColor\n                              : guideColor,\n                          )}\n                          style={{ left: PAD + depth * INDENT + 7 }}\n                        />\n                      ))}\n                      <div className=\"flex h-full min-w-0 items-center gap-1.5 pr-3\" style={{ paddingLeft: indent }}>\n                        {m.isFolder ? (\n                          <span\n                            aria-hidden\n                            onClick={(e) => {\n                              e.stopPropagation();\n                              setFocusedId(row.id);\n                              setFolder(row.id, !isOpen, \"pointer\");\n                            }}\n                            className={cn(\n                              \"-ml-0.5 grid size-[18px] shrink-0 place-items-center rounded-[5px]\",\n                              palette.secondaryText,\n                              palette.menuItem,\n                            )}\n                          >\n                            <ChevronRight\n                              size={13}\n                              strokeWidth={2}\n                              className={cn(\n                                \"motion-safe:transition-transform motion-safe:duration-150\",\n                                isOpen && \"rotate-90\",\n                              )}\n                            />\n                          </span>\n                        ) : (\n                          <span aria-hidden className=\"w-[16px] shrink-0\" />\n                        )}\n                        <Icon\n                          aria-hidden\n                          size={15}\n                          strokeWidth={1.7}\n                          className={cn(\"shrink-0\", m.isFolder ? palette.accent : palette.secondaryText)}\n                        />\n                        <span\n                          className={cn(\n                            \"truncate\",\n                            palette.primaryText,\n                            m.isFolder && \"font-medium\",\n                          )}\n                        >\n                          {m.node.name}\n                        </span>\n                        {m.isFolder && !isOpen ? (\n                          <span className={cn(\"hidden shrink-0 text-[11px] tabular-nums @md:inline\", palette.secondaryText)}>\n                            {m.fileCount}\n                          </span>\n                        ) : null}\n                      </div>\n                    </td>\n                    <td\n                      role=\"gridcell\"\n                      className={cn(\n                        \"border-b px-3 text-right tabular-nums\",\n                        palette.divider,\n                        m.isFolder ? palette.secondaryText : palette.primaryText,\n                      )}\n                    >\n                      {formatBytes(m.size)}\n                    </td>\n                    <td\n                      role=\"gridcell\"\n                      className={cn(\"hidden truncate border-b px-3 @2xl:table-cell\", palette.divider, palette.secondaryText)}\n                    >\n                      {m.isFolder ? `Folder · ${m.fileCount} ${m.fileCount === 1 ? \"file\" : \"files\"}` : m.kind}\n                    </td>\n                    <td\n                      role=\"gridcell\"\n                      className={cn(\"truncate border-b px-3 text-right tabular-nums\", palette.divider, palette.secondaryText)}\n                    >\n                      {m.modified !== null && nowMs !== null ? (\n                        <time\n                          dateTime={new Date(m.modified).toISOString()}\n                          title={mounted ? absoluteFormat.format(m.modified) : undefined}\n                        >\n                          {formatRelative(m.modified, nowMs)}\n                        </time>\n                      ) : (\n                        <span aria-label=\"Unknown\">—</span>\n                      )}\n                    </td>\n                  </motion.tr>\n                );\n              })\n            )}\n          </tbody>\n        </table>\n      </div>\n\n      {/* Footer */}\n      <div\n        className={cn(\n          \"flex items-center justify-between gap-3 border-t px-4 py-2.5 text-[12px] tabular-nums\",\n          palette.divider,\n          palette.secondaryText,\n        )}\n      >\n        <span>\n          {totals.fileCount} files · {formatBytes(totals.size)}\n        </span>\n        <span aria-live=\"polite\" className={selectionSummary.count ? palette.primaryText : undefined}>\n          {selectionSummary.count\n            ? `${selectionSummary.count} selected · ${formatBytes(selectionSummary.bytes)}`\n            : `${rows.length} rows shown`}\n        </span>\n      </div>\n    </div>\n  );\n}\n\nexport default FileTreeTable;\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/tables/file-tree-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/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/file-tree-table.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/file-tree-table.json"
  },
  "type": "registry:component"
}