{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "permissions-matrix",
  "title": "Permissions matrix",
  "description": "Permissions matrix 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/permissions-matrix.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type KeyboardEvent as ReactKeyboardEvent,\n} from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\";\nimport { useTheme } from \"next-themes\";\nimport { Check, CircleCheck, Lock, Minus, Search, SearchX, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { useElementSize } from \"../_core/canvas\";\nimport {\n  type BjorkTableThemeMode,\n  getBjorkTablePalette,\n  useBjorkTableIsDark,\n} from \"./table-theme\";\n\nexport interface PermissionRole {\n  id: string;\n  label: string;\n  /** Number of people holding the role, shown under the header. */\n  members?: number;\n  /** Locked roles hold every permission and cannot be edited. */\n  locked?: boolean;\n  lockedReason?: string;\n}\n\nexport interface PermissionItem {\n  id: string;\n  label: string;\n  description?: string;\n  /** roleId -> reason. The cell keeps its current value and cannot be edited. */\n  lockedFor?: Record<string, string>;\n}\n\nexport interface PermissionGroup {\n  id: string;\n  label: string;\n  permissions: PermissionItem[];\n}\n\n/** permissionId -> roleId -> granted */\nexport type PermissionGrants = Record<string, Record<string, boolean>>;\n\nexport interface PermissionsMatrixProps {\n  roles?: PermissionRole[];\n  groups?: PermissionGroup[];\n  /** Saved state. Changes are tracked against this. */\n  value?: PermissionGrants;\n  /** Optional working copy to start from (e.g. restoring unsaved edits). */\n  initialDraft?: PermissionGrants;\n  title?: string;\n  subtitle?: string;\n  /** Max height of the scrolling matrix. */\n  maxHeight?: number;\n  loading?: boolean;\n  onSave?: (grants: PermissionGrants) => void | Promise<void>;\n  onChange?: (grants: PermissionGrants) => void;\n  theme?: BjorkTableThemeMode;\n  enableAnimations?: boolean;\n  className?: string;\n}\n\nexport const PERMISSIONS_MATRIX_SAMPLE_ROLES: PermissionRole[] = [\n  { id: \"owner\", label: \"Owner\", members: 1, locked: true, lockedReason: \"Owners always hold every permission.\" },\n  { id: \"admin\", label: \"Admin\", members: 3 },\n  { id: \"editor\", label: \"Editor\", members: 12 },\n  { id: \"viewer\", label: \"Viewer\", members: 28 },\n  { id: \"billing\", label: \"Billing\", members: 2 },\n];\n\nexport const PERMISSIONS_MATRIX_SAMPLE_GROUPS: PermissionGroup[] = [\n  {\n    id: \"projects\",\n    label: \"Projects\",\n    permissions: [\n      { id: \"projects.view\", label: \"View projects\", lockedFor: { viewer: \"Viewing projects is what the Viewer role is for.\" } },\n      { id: \"projects.create\", label: \"Create projects\" },\n      { id: \"projects.settings\", label: \"Edit project settings\" },\n      { id: \"projects.archive\", label: \"Archive projects\" },\n      { id: \"projects.delete\", label: \"Delete projects\", description: \"Permanently removes files and history\" },\n    ],\n  },\n  {\n    id: \"members\",\n    label: \"Members\",\n    permissions: [\n      { id: \"members.view\", label: \"View member list\" },\n      { id: \"members.invite\", label: \"Invite members\" },\n      { id: \"members.roles\", label: \"Change member roles\" },\n      { id: \"members.remove\", label: \"Remove members\" },\n    ],\n  },\n  {\n    id: \"billing\",\n    label: \"Billing\",\n    permissions: [\n      { id: \"billing.invoices\", label: \"View invoices\", lockedFor: { billing: \"The Billing role always reads invoices.\" } },\n      { id: \"billing.payment\", label: \"Update payment method\" },\n      { id: \"billing.plan\", label: \"Change plan\", description: \"Upgrades take effect immediately\" },\n    ],\n  },\n  {\n    id: \"api\",\n    label: \"API keys\",\n    permissions: [\n      { id: \"api.view\", label: \"View API keys\" },\n      { id: \"api.create\", label: \"Create API keys\" },\n      { id: \"api.revoke\", label: \"Revoke API keys\" },\n    ],\n  },\n  {\n    id: \"audit\",\n    label: \"Audit log\",\n    permissions: [\n      { id: \"audit.view\", label: \"View audit log\" },\n      { id: \"audit.export\", label: \"Export audit log\" },\n    ],\n  },\n];\n\nfunction grantsFrom(table: Record<string, string[]>): PermissionGrants {\n  const out: PermissionGrants = {};\n  for (const [permissionId, roleIds] of Object.entries(table)) {\n    out[permissionId] = {};\n    for (const roleId of roleIds) out[permissionId][roleId] = true;\n  }\n  return out;\n}\n\nexport const PERMISSIONS_MATRIX_SAMPLE: PermissionGrants = grantsFrom({\n  \"projects.view\": [\"owner\", \"admin\", \"editor\", \"viewer\", \"billing\"],\n  \"projects.create\": [\"owner\", \"admin\", \"editor\"],\n  \"projects.settings\": [\"owner\", \"admin\", \"editor\"],\n  \"projects.archive\": [\"owner\", \"admin\"],\n  \"projects.delete\": [\"owner\", \"admin\"],\n  \"members.view\": [\"owner\", \"admin\", \"editor\", \"viewer\", \"billing\"],\n  \"members.invite\": [\"owner\", \"admin\"],\n  \"members.roles\": [\"owner\", \"admin\"],\n  \"members.remove\": [\"owner\", \"admin\"],\n  \"billing.invoices\": [\"owner\", \"admin\", \"billing\"],\n  \"billing.payment\": [\"owner\", \"billing\"],\n  \"billing.plan\": [\"owner\", \"billing\"],\n  \"api.view\": [\"owner\", \"admin\", \"editor\"],\n  \"api.create\": [\"owner\", \"admin\"],\n  \"api.revoke\": [\"owner\", \"admin\"],\n  \"audit.view\": [\"owner\", \"admin\"],\n  \"audit.export\": [\"owner\"],\n});\n\ntype CheckState = boolean | \"mixed\";\n\ntype MatrixRow =\n  | { kind: \"group\"; key: string; group: PermissionGroup; items: PermissionItem[] }\n  | { kind: \"permission\"; key: string; group: PermissionGroup; item: PermissionItem };\n\nconst COMPACT_BREAKPOINT = 480;\n\nconst noopSubscribe = () => () => {};\n\nfunction useMounted() {\n  return useSyncExternalStore(noopSubscribe, () => true, () => false);\n}\n\nexport function PermissionsMatrix({\n  roles = PERMISSIONS_MATRIX_SAMPLE_ROLES,\n  groups = PERMISSIONS_MATRIX_SAMPLE_GROUPS,\n  value = PERMISSIONS_MATRIX_SAMPLE,\n  initialDraft,\n  title = \"Roles & permissions\",\n  subtitle = \"Kestrel Studio workspace · 46 members\",\n  maxHeight = 400,\n  loading = false,\n  onSave,\n  onChange,\n  theme = \"auto\",\n  enableAnimations = true,\n  className,\n}: PermissionsMatrixProps) {\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 shouldReduceMotion = useReducedMotion();\n  const shouldAnimate = enableAnimations && !shouldReduceMotion;\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const { width } = useElementSize(rootRef);\n  const compact = width > 0 && width < COMPACT_BREAKPOINT;\n\n  const [baseline, setBaseline] = useState<PermissionGrants>(value);\n  const [draft, setDraft] = useState<PermissionGrants>(initialDraft ?? value);\n  const [query, setQuery] = useState(\"\");\n  const [saving, setSaving] = useState(false);\n  const [savedFlash, setSavedFlash] = useState(false);\n  const [active, setActive] = useState<{ row: string; role: string } | null>(null);\n  const [tip, setTip] = useState<{ text: string; x: number; y: number } | null>(null);\n  const [keyboardMode, setKeyboardMode] = useState(false);\n\n  // Adopt a new saved state from the parent.\n  const [lastValue, setLastValue] = useState(value);\n  if (lastValue !== value) {\n    setLastValue(value);\n    setBaseline(value);\n    setDraft(value);\n  }\n\n  const lockReason = useCallback(\n    (item: PermissionItem, role: PermissionRole): string | null => {\n      if (role.locked) return role.lockedReason ?? `${role.label} cannot be edited.`;\n      return item.lockedFor?.[role.id] ?? null;\n    },\n    []\n  );\n\n  const isGranted = useCallback(\n    (grants: PermissionGrants, item: PermissionItem, role: PermissionRole) =>\n      role.locked ? true : Boolean(grants[item.id]?.[role.id]),\n    []\n  );\n\n  const normalizedQuery = query.trim().toLowerCase();\n  const visibleGroups = useMemo(() => {\n    if (!normalizedQuery) return groups.map((group) => ({ group, items: group.permissions }));\n    return groups\n      .map((group) => {\n        const groupHit = group.label.toLowerCase().includes(normalizedQuery);\n        const items = groupHit\n          ? group.permissions\n          : group.permissions.filter(\n              (item) =>\n                item.label.toLowerCase().includes(normalizedQuery) ||\n                item.description?.toLowerCase().includes(normalizedQuery)\n            );\n        return { group, items };\n      })\n      .filter((entry) => entry.items.length > 0);\n  }, [groups, normalizedQuery]);\n\n  const rows = useMemo<MatrixRow[]>(() => {\n    const out: MatrixRow[] = [];\n    for (const { group, items } of visibleGroups) {\n      out.push({ kind: \"group\", key: `g:${group.id}`, group, items });\n      for (const item of items) out.push({ kind: \"permission\", key: `p:${item.id}`, group, item });\n    }\n    return out;\n  }, [visibleGroups]);\n\n  const dirtyCells = useMemo(() => {\n    const set = new Set<string>();\n    for (const group of groups) {\n      for (const item of group.permissions) {\n        for (const role of roles) {\n          if (role.locked) continue;\n          if (Boolean(draft[item.id]?.[role.id]) !== Boolean(baseline[item.id]?.[role.id])) {\n            set.add(`${item.id}|${role.id}`);\n          }\n        }\n      }\n    }\n    return set;\n  }, [groups, roles, draft, baseline]);\n  const dirtyCount = dirtyCells.size;\n\n  const commit = (next: PermissionGrants) => {\n    setDraft(next);\n    setSavedFlash(false);\n    onChange?.(next);\n  };\n\n  const setCells = (items: PermissionItem[], role: PermissionRole, granted: boolean) => {\n    const next: PermissionGrants = { ...draft };\n    for (const item of items) {\n      if (lockReason(item, role)) continue;\n      next[item.id] = { ...(next[item.id] ?? {}), [role.id]: granted };\n    }\n    commit(next);\n  };\n\n  const groupState = (items: PermissionItem[], role: PermissionRole): CheckState => {\n    const on = items.filter((item) => isGranted(draft, item, role)).length;\n    if (on === 0) return false;\n    if (on === items.length) return true;\n    return \"mixed\";\n  };\n\n  const groupLocked = (items: PermissionItem[], role: PermissionRole) =>\n    role.locked ? lockReason(items[0], role) : items.every((item) => lockReason(item, role)) ? \"Every permission in this group is fixed for this role.\" : null;\n\n  const toggleRowCell = (row: MatrixRow, role: PermissionRole) => {\n    if (row.kind === \"permission\") {\n      if (lockReason(row.item, role)) return;\n      setCells([row.item], role, !isGranted(draft, row.item, role));\n    } else {\n      if (groupLocked(row.items, role)) return;\n      const state = groupState(row.items.filter((item) => !lockReason(item, role)), role);\n      setCells(row.items, role, state !== true);\n    }\n  };\n\n  const handleDiscard = () => {\n    setDraft(baseline);\n    onChange?.(baseline);\n  };\n\n  const handleSave = async () => {\n    if (!dirtyCount || saving) return;\n    const snapshot: PermissionGrants = {};\n    for (const group of groups) {\n      for (const item of group.permissions) {\n        snapshot[item.id] = {};\n        for (const role of roles) snapshot[item.id][role.id] = isGranted(draft, item, role);\n      }\n    }\n    setSaving(true);\n    try {\n      await onSave?.(snapshot);\n      setBaseline(draft);\n      setSavedFlash(true);\n    } finally {\n      setSaving(false);\n    }\n  };\n\n  // ---------- roving focus ----------\n  const activeRowKey = active && rows.some((row) => row.key === active.row) ? active.row : rows[0]?.key;\n  const activeRoleId = active && roles.some((role) => role.id === active.role) ? active.role : roles[0]?.id;\n\n  const focusCell = (from: HTMLElement, rowKey: string, roleId: string) => {\n    setActive({ row: rowKey, role: roleId });\n    from\n      .closest(\"table\")\n      ?.querySelector<HTMLButtonElement>(`[data-cell=\"${CSS.escape(`${rowKey}|${roleId}`)}\"]`)\n      ?.focus();\n  };\n\n  const handleCellKey = (e: ReactKeyboardEvent<HTMLButtonElement>, rowIndex: number, roleIndex: number) => {\n    if (!keyboardMode) setKeyboardMode(true);\n    let r = rowIndex;\n    let c = roleIndex;\n    switch (e.key) {\n      case \"ArrowDown\":\n        r = Math.min(rows.length - 1, r + 1);\n        break;\n      case \"ArrowUp\":\n        r = Math.max(0, r - 1);\n        break;\n      case \"ArrowRight\":\n        c = Math.min(roles.length - 1, c + 1);\n        break;\n      case \"ArrowLeft\":\n        c = Math.max(0, c - 1);\n        break;\n      case \"Home\":\n        c = 0;\n        if (e.ctrlKey || e.metaKey) r = 0;\n        break;\n      case \"End\":\n        c = roles.length - 1;\n        if (e.ctrlKey || e.metaKey) r = rows.length - 1;\n        break;\n      case \"PageDown\":\n        r = Math.min(rows.length - 1, r + 5);\n        break;\n      case \"PageUp\":\n        r = Math.max(0, r - 5);\n        break;\n      case \" \":\n      case \"Enter\":\n        e.preventDefault();\n        if (e.key === \" \") toggleRowCell(rows[rowIndex], roles[roleIndex]);\n        return;\n      default:\n        return;\n    }\n    e.preventDefault();\n    focusCell(e.currentTarget, rows[r].key, roles[c].id);\n  };\n\n  // ---------- tooltip ----------\n  const showTip = (target: HTMLElement, text: string) => {\n    const root = rootRef.current;\n    if (!root) return;\n    const a = target.getBoundingClientRect();\n    const b = root.getBoundingClientRect();\n    setTip({ text, x: a.left + a.width / 2 - b.left, y: a.top - b.top });\n  };\n  const hideTip = () => setTip(null);\n\n  // ---------- visuals ----------\n  const accentFill = isDark ? \"#ec5c13\" : \"#bd4514\";\n  const focusRing = \"outline-none focus-visible:ring-2 focus-visible:ring-[#ec5c13]/70\";\n  const stickyBg = isDark ? \"bg-[#111]\" : \"bg-[#fffcf6]\";\n  const groupBg = isDark ? \"bg-[#151515]\" : \"bg-[#fbf7ef]\";\n  const permWidth = compact ? 140 : 248;\n  const roleWidth = compact ? 74 : 104;\n  const tableMinWidth = permWidth + roles.length * roleWidth;\n\n  const renderBox = (state: CheckState, locked: boolean, dirty: boolean) => {\n    const on = state !== false;\n    const animate = shouldAnimate && !keyboardMode;\n    return (\n      <span\n        aria-hidden\n        className={cn(\n          \"relative grid size-[18px] place-items-center rounded-[5px] border transition-colors duration-150\",\n          locked\n            ? isDark\n              ? \"border-[#ededed]/10 bg-[#ededed]/[0.06] text-[#ededed]/45\"\n              : \"border-[#171717]/10 bg-[#171717]/[0.05] text-[#171717]/45\"\n            : on\n              ? \"border-transparent text-white\"\n              : isDark\n                ? \"border-[#ededed]/22 bg-transparent group-hover/cell:border-[#ededed]/40\"\n                : \"border-[#171717]/22 bg-[#fffcf6] group-hover/cell:border-[#171717]/40\"\n        )}\n        style={!locked && on ? { background: accentFill } : undefined}\n      >\n        {locked ? (\n          <>\n            {state === \"mixed\" ? (\n              <Minus className=\"size-3\" strokeWidth={3} />\n            ) : state ? (\n              <Check className=\"size-3\" strokeWidth={3} />\n            ) : null}\n            <span\n              className={cn(\n                \"absolute -bottom-[5px] -right-[5px] grid size-[11px] place-items-center rounded-full\",\n                isDark ? \"bg-[#111] text-[#ededed]/60\" : \"bg-[#fffcf6] text-[#171717]/55\"\n              )}\n            >\n              <Lock className=\"size-[8px]\" strokeWidth={2.75} />\n            </span>\n          </>\n        ) : (\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {state === \"mixed\" ? (\n              <motion.span\n                key=\"mixed\"\n                initial={animate ? { scale: 0.4, opacity: 0 } : false}\n                animate={{ scale: 1, opacity: 1 }}\n                exit={animate ? { scale: 0.4, opacity: 0 } : { opacity: 0, transition: { duration: 0 } }}\n                transition={animate ? { type: \"spring\", stiffness: 700, damping: 30 } : { duration: 0 }}\n                className=\"grid place-items-center\"\n              >\n                <Minus className=\"size-3\" strokeWidth={3} />\n              </motion.span>\n            ) : state ? (\n              <motion.span\n                key=\"on\"\n                initial={animate ? { scale: 0.4, opacity: 0 } : false}\n                animate={{ scale: 1, opacity: 1 }}\n                exit={animate ? { scale: 0.4, opacity: 0 } : { opacity: 0, transition: { duration: 0 } }}\n                transition={animate ? { type: \"spring\", stiffness: 700, damping: 30 } : { duration: 0 }}\n                className=\"grid place-items-center\"\n              >\n                <Check className=\"size-3\" strokeWidth={3} />\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        )}\n        {dirty ? (\n          <span\n            className={cn(\"absolute -right-[5px] -top-[5px] size-[7px] rounded-full ring-2\", isDark ? \"ring-[#111]\" : \"ring-[#fffcf6]\")}\n            style={{ background: isDark ? \"#d86a2c\" : \"#bd4514\" }}\n          />\n        ) : null}\n      </span>\n    );\n  };\n\n  const renderCell = (\n    row: MatrixRow,\n    rowIndex: number,\n    role: PermissionRole,\n    roleIndex: number,\n    borderClassName?: string\n  ) => {\n    const isGroup = row.kind === \"group\";\n    const reason = isGroup ? groupLocked(row.items, role) : lockReason(row.item, role);\n    const state: CheckState = isGroup\n      ? groupState(row.items, role)\n      : isGranted(draft, row.item, role);\n    const dirty = isGroup\n      ? row.items.some((item) => dirtyCells.has(`${item.id}|${role.id}`))\n      : dirtyCells.has(`${row.item.id}|${role.id}`);\n    const label = isGroup\n      ? `${role.label}: all ${row.group.label} permissions`\n      : `${role.label}: ${row.item.label}`;\n    const cellKey = `${row.key}|${role.id}`;\n    const isActive = row.key === activeRowKey && role.id === activeRoleId;\n    const describedBy = reason ? `pm-why-${cellKey.replace(/[^a-z0-9]/gi, \"-\")}` : undefined;\n\n    return (\n      <td\n        key={role.id}\n        role=\"gridcell\"\n        className={cn(\n          \"h-10 px-0 text-center align-middle\",\n          borderClassName,\n          dirty && !isGroup && (isDark ? \"bg-[#ec5c13]/[0.06]\" : \"bg-[#ec5c13]/[0.05]\")\n        )}\n      >\n        <button\n          data-cell={cellKey}\n          type=\"button\"\n          role=\"checkbox\"\n          aria-checked={state}\n          aria-label={label}\n          aria-disabled={reason ? true : undefined}\n          aria-describedby={describedBy}\n          tabIndex={isActive ? 0 : -1}\n          onPointerDown={() => {\n            if (keyboardMode) setKeyboardMode(false);\n          }}\n          onClick={() => {\n            setActive({ row: row.key, role: role.id });\n            toggleRowCell(row, role);\n          }}\n          onKeyDown={(e) => handleCellKey(e, rowIndex, roleIndex)}\n          onFocus={(e) => {\n            setActive({ row: row.key, role: role.id });\n            if (reason) showTip(e.currentTarget, reason);\n          }}\n          onBlur={hideTip}\n          onPointerEnter={(e) => reason && showTip(e.currentTarget, reason)}\n          onPointerLeave={hideTip}\n          className={cn(\n            \"group/cell mx-auto grid size-8 place-items-center rounded-[8px] transition-colors\",\n            reason ? \"cursor-not-allowed\" : cn(\"cursor-pointer\", palette.menuItem),\n            focusRing\n          )}\n        >\n          {renderBox(state, Boolean(reason), dirty)}\n          {describedBy ? (\n            <span id={describedBy} hidden>\n              {reason}\n            </span>\n          ) : null}\n        </button>\n      </td>\n    );\n  };\n\n  const headerLabel = \"text-[11px] font-medium uppercase tracking-[0.08em]\";\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"relative mx-auto w-full max-w-[860px] rounded-[18px] border\", palette.container, className)}\n    >\n      {/* toolbar */}\n      <div className={cn(\"flex flex-col gap-3 border-b px-4 py-3.5 sm:flex-row sm:items-center sm:justify-between\", palette.divider)}>\n        <div className=\"min-w-0\">\n          <div className={cn(\"text-[15px] font-semibold tracking-[-0.01em]\", palette.primaryText)}>{title}</div>\n          {subtitle ? <div className={cn(\"mt-0.5 text-[12px]\", palette.secondaryText)}>{subtitle}</div> : null}\n        </div>\n        <label\n          className={cn(\n            \"flex h-8 w-full items-center gap-2 rounded-[10px] border px-2.5 text-[13px] sm:w-[220px] focus-within:ring-2 focus-within:ring-[#ec5c13]/50\",\n            palette.control\n          )}\n        >\n          <Search aria-hidden className=\"size-3.5 shrink-0 opacity-60\" />\n          <span className=\"sr-only\">Search permissions</span>\n          <input\n            type=\"search\"\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            placeholder=\"Search permissions\"\n            className={cn(\n              \"min-w-0 flex-1 bg-transparent outline-none [&::-webkit-search-cancel-button]:hidden\",\n              isDark ? \"placeholder:text-[#ededed]/32\" : \"placeholder:text-[#171717]/40\",\n              palette.primaryText\n            )}\n          />\n          {query ? (\n            <button\n              type=\"button\"\n              aria-label=\"Clear search\"\n              onClick={() => setQuery(\"\")}\n              className={cn(\"grid size-5 place-items-center rounded-full opacity-60 hover:opacity-100\", focusRing)}\n            >\n              <X className=\"size-3\" />\n            </button>\n          ) : null}\n        </label>\n      </div>\n\n      {/* matrix */}\n      <div\n        role=\"region\"\n        aria-label={`${title} matrix`}\n        tabIndex={-1}\n        className=\"relative overflow-auto\"\n        style={{ maxHeight }}\n        onScroll={tip ? hideTip : undefined}\n      >\n        <table\n          role=\"grid\"\n          aria-label={title}\n          aria-rowcount={rows.length + 1}\n          aria-busy={loading || undefined}\n          className=\"w-full table-fixed border-separate border-spacing-0 text-[13px]\"\n          style={{ minWidth: tableMinWidth }}\n        >\n          <caption className=\"sr-only\">\n            {title}. Rows are permissions grouped by resource, columns are roles. Use arrow keys to move between\n            checkboxes and Space to toggle.\n          </caption>\n          <colgroup>\n            <col style={{ width: permWidth }} />\n            {roles.map((role) => (\n              <col key={role.id} style={{ width: roleWidth }} />\n            ))}\n          </colgroup>\n          <thead>\n            <tr>\n              <th\n                scope=\"col\"\n                className={cn(\n                  \"sticky left-0 top-0 z-30 border-b border-r px-4 py-2.5 text-left\",\n                  headerLabel,\n                  palette.header\n                )}\n              >\n                Permission\n              </th>\n              {roles.map((role) => (\n                <th\n                  key={role.id}\n                  scope=\"col\"\n                  className={cn(\"sticky top-0 z-20 border-b px-1 py-2.5 text-center font-normal\", palette.header)}\n                >\n                  <div className={cn(\"flex items-center justify-center gap-1\", headerLabel)}>\n                    {role.locked ? <Lock aria-hidden className=\"size-2.5\" strokeWidth={2.5} /> : null}\n                    {role.label}\n                  </div>\n                  {typeof role.members === \"number\" ? (\n                    <div className={cn(\"mt-0.5 text-[11px] tabular-nums normal-case tracking-normal\", palette.secondaryText)}>\n                      {role.members} {role.members === 1 ? \"member\" : \"members\"}\n                    </div>\n                  ) : null}\n                </th>\n              ))}\n            </tr>\n          </thead>\n\n          {loading ? (\n            <tbody aria-hidden>\n              {Array.from({ length: 7 }, (_, i) => (\n                <tr key={i}>\n                  <td className={cn(\"sticky left-0 border-b border-r px-4\", stickyBg, palette.divider)}>\n                    <span className={cn(\"block h-3 rounded\", palette.mutedSurface, !shouldReduceMotion && \"animate-pulse\")} style={{ width: `${55 + ((i * 17) % 35)}%` }} />\n                  </td>\n                  {roles.map((role) => (\n                    <td key={role.id} className={cn(\"h-10 border-b\", palette.divider)}>\n                      <span className={cn(\"mx-auto block size-[18px] rounded-[5px]\", palette.mutedSurface, !shouldReduceMotion && \"animate-pulse\")} />\n                    </td>\n                  ))}\n                </tr>\n              ))}\n            </tbody>\n          ) : rows.length === 0 ? (\n            <tbody>\n              <tr>\n                <td colSpan={roles.length + 1} className=\"px-4 py-12\">\n                  <div className={cn(\"sticky left-0 flex w-full max-w-[360px] flex-col items-start gap-1.5 sm:mx-auto sm:items-center sm:text-center\", palette.secondaryText)}>\n                    <SearchX aria-hidden className=\"size-5 opacity-70\" />\n                    <div className={cn(\"text-[13px] font-medium\", palette.primaryText)}>\n                      No permissions match “{query.trim()}”\n                    </div>\n                    <button\n                      type=\"button\"\n                      onClick={() => setQuery(\"\")}\n                      className={cn(\"text-[12px] underline-offset-2 hover:underline\", palette.accent, focusRing)}\n                    >\n                      Clear search\n                    </button>\n                  </div>\n                </td>\n              </tr>\n            </tbody>\n          ) : (\n            visibleGroups.map(({ group, items }, groupIndex) => {\n              const groupRowIndex = rows.findIndex((row) => row.key === `g:${group.id}`);\n              const groupRow = rows[groupRowIndex];\n              return (\n                <tbody key={group.id}>\n                  <motion.tr\n                    initial={shouldAnimate ? { opacity: 0 } : false}\n                    animate={{ opacity: 1 }}\n                    transition={shouldAnimate ? { delay: 0.05 * groupIndex, duration: 0.3 } : { duration: 0 }}\n                    className={groupBg}\n                  >\n                    <th\n                      scope=\"rowgroup\"\n                      className={cn(\n                        \"sticky left-0 z-10 border-b border-r px-4 text-left align-middle\",\n                        groupBg,\n                        palette.divider,\n                        groupIndex > 0 && \"border-t\"\n                      )}\n                    >\n                      <span className={cn(\"text-[11px] font-semibold uppercase tracking-[0.08em]\", palette.primaryText)}>\n                        {group.label}\n                      </span>\n                      <span className={cn(\"ml-2 text-[11px] tabular-nums\", palette.secondaryText)}>{items.length}</span>\n                    </th>\n                    {roles.map((role, roleIndex) =>\n                      renderCell(groupRow, groupRowIndex, role, roleIndex, cn(\"border-b\", palette.divider, groupIndex > 0 && \"border-t\"))\n                    )}\n                  </motion.tr>\n                  {items.map((item, itemIndex) => {\n                    const rowIndex = groupRowIndex + 1 + itemIndex;\n                    const row = rows[rowIndex];\n                    const last = itemIndex === items.length - 1;\n                    return (\n                      <motion.tr\n                        key={item.id}\n                        initial={shouldAnimate ? { opacity: 0 } : false}\n                        animate={{ opacity: 1 }}\n                        transition={\n                          shouldAnimate ? { delay: 0.05 * groupIndex + 0.025 * (itemIndex + 1), duration: 0.3 } : { duration: 0 }\n                        }\n                        className={cn(\"transition-colors\", isDark ? \"hover:bg-[#181818]/70\" : \"hover:bg-[#f8f2e7]/72\")}\n                      >\n                        <th\n                          scope=\"row\"\n                          className={cn(\n                            \"sticky left-0 z-10 border-r px-4 py-1.5 text-left font-normal\",\n                            !last && \"border-b\",\n                            stickyBg,\n                            palette.divider\n                          )}\n                        >\n                          <div className={cn(\"truncate text-[13px]\", palette.primaryText)}>{item.label}</div>\n                          {item.description && !compact ? (\n                            <div className={cn(\"truncate text-[11px]\", palette.secondaryText)}>{item.description}</div>\n                          ) : null}\n                        </th>\n                        {roles.map((role, roleIndex) =>\n                          renderCell(row, rowIndex, role, roleIndex, cn(!last && \"border-b\", palette.divider))\n                        )}\n                      </motion.tr>\n                    );\n                  })}\n                </tbody>\n              );\n            })\n          )}\n        </table>\n      </div>\n\n      {/* sticky footer */}\n      <div\n        className={cn(\n          \"sticky bottom-0 z-30 flex items-center justify-between gap-3 rounded-b-[18px] border-t px-4 py-2.5\",\n          palette.divider,\n          stickyBg\n        )}\n      >\n        <div aria-live=\"polite\" className=\"flex min-w-0 items-center gap-2 text-[13px]\">\n          {dirtyCount > 0 ? (\n            <>\n              <span aria-hidden className=\"size-1.5 shrink-0 rounded-full\" style={{ background: accentFill }} />\n              <span className={cn(\"truncate tabular-nums\", palette.primaryText)}>\n                {dirtyCount} unsaved {dirtyCount === 1 ? \"change\" : \"changes\"}\n              </span>\n            </>\n          ) : (\n            <>\n              <CircleCheck aria-hidden className={cn(\"size-3.5 shrink-0\", palette.secondaryText)} />\n              <span className={cn(\"truncate\", palette.secondaryText)}>\n                {savedFlash ? \"Changes saved\" : \"All changes saved\"}\n              </span>\n            </>\n          )}\n        </div>\n        <div className=\"flex shrink-0 items-center gap-2\">\n          <button\n            type=\"button\"\n            onClick={handleDiscard}\n            disabled={!dirtyCount || saving}\n            className={cn(\n              \"h-8 rounded-[10px] border px-3 text-[13px] transition-colors disabled:pointer-events-none disabled:opacity-40\",\n              palette.control,\n              focusRing\n            )}\n          >\n            Discard\n          </button>\n          <button\n            type=\"button\"\n            onClick={handleSave}\n            disabled={!dirtyCount || saving}\n            className={cn(\n              \"h-8 rounded-[10px] px-3.5 text-[13px] font-medium text-white transition-[opacity,filter] hover:brightness-110 disabled:pointer-events-none disabled:opacity-40\",\n              focusRing,\n              \"focus-visible:ring-offset-2\",\n              isDark ? \"focus-visible:ring-offset-[#111]\" : \"focus-visible:ring-offset-[#fffcf6]\"\n            )}\n            style={{ background: accentFill }}\n          >\n            {saving ? \"Saving…\" : \"Save\"}\n          </button>\n        </div>\n      </div>\n\n      {/* lock tooltip */}\n      {tip ? (\n        <div\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute z-50 max-w-[220px] -translate-x-1/2 -translate-y-full rounded-[8px] border px-2.5 py-1.5 text-[12px] leading-snug\",\n            palette.menu\n          )}\n          style={{ left: tip.x, top: tip.y - 6 }}\n        >\n          {tip.text}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/bjork-ui/tables/permissions-matrix.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/permissions-matrix.json",
    "installCommand": "npx shadcn@latest add https://ui.isaiahbjork.com/permissions-matrix.json"
  },
  "type": "registry:component"
}