{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "permission-grid",
  "title": "Permission Grid",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A grid of everything an agent could do, each row set to Allow, Ask or Deny with native radios, risk written out, a warning when a high-risk action is allowed, grouped rows and one-click presets.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/permission-grid.tsx",
      "content": "// Ballmac UI: Permission Grid. https://ui.ballmac.com/components/permission-grid\n\"use client\"\n\nimport * as React from \"react\"\nimport { Ban, Check, HelpCircle, ShieldAlert, type LucideIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype Decision = \"allow\" | \"ask\" | \"deny\"\ntype Risk = \"low\" | \"medium\" | \"high\"\n\ntype Permission = {\n  id: string\n  /** What the agent can do, for example \"Run shell commands\". */\n  label: string\n  /** One line on what it means in practice. */\n  description?: string\n  /** How much harm a mistake could do. Shown in words. */\n  risk?: Risk\n  /** A heading to group related permissions under, such as \"Files\". */\n  group?: string\n}\n\ntype PermissionPreset = {\n  id: string\n  /** Button text, for example \"Cautious\". */\n  label: string\n  /** The decision for each permission id. Ids it leaves out are not changed. */\n  values: Record<string, Decision>\n}\n\nconst DECISIONS: { value: Decision; icon: LucideIcon; fallback: string }[] = [\n  { value: \"allow\", icon: Check, fallback: \"Allow\" },\n  { value: \"ask\", icon: HelpCircle, fallback: \"Ask\" },\n  { value: \"deny\", icon: Ban, fallback: \"Deny\" },\n]\n\ntype PermissionGridProps = Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> & {\n  /** The things the agent could do. */\n  permissions: Permission[]\n  /** The decision for each id. Controlled. */\n  value?: Record<string, Decision>\n  /** The starting decisions for uncontrolled use. Ids it leaves out start on Ask. */\n  defaultValue?: Record<string, Decision>\n  /** Called with the full set of decisions after any change. */\n  onValueChange?: (value: Record<string, Decision>) => void\n  /** One-click settings that fill the grid. */\n  presets?: PermissionPreset[]\n  /** Heading. */\n  title?: string\n}\n\n/**\n * What an agent may do, one row per permission with Allow, Ask or Deny. Each row is a native radio group, so the arrow keys move between the three\n * answers and Tab moves to the next row. Risk is written out, and a risky permission set to Allow says it runs without asking.\n */\nfunction PermissionGrid({ permissions, value: valueProp, defaultValue, onValueChange, presets, title, className, ...props }: PermissionGridProps) {\n  const msg = useMessages()\n  const uid = React.useId()\n  title ??= msg(\"permission-grid.title\", \"What the agent may do\")\n  const [inner, setInner] = React.useState<Record<string, Decision>>(defaultValue ?? {})\n  const value = valueProp ?? inner\n  const decision = (id: string): Decision => value[id] ?? \"ask\"\n\n  function commit(next: Record<string, Decision>) {\n    if (valueProp === undefined) setInner(next)\n    onValueChange?.(next)\n  }\n  const counts = { allow: 0, ask: 0, deny: 0 }\n  for (const p of permissions) counts[decision(p.id)]++\n\n  const groups: { name?: string; items: Permission[] }[] = []\n  for (const p of permissions) {\n    const last = groups[groups.length - 1]\n    if (last && last.name === p.group) last.items.push(p)\n    else groups.push({ name: p.group, items: [p] })\n  }\n  const riskText = (r: Risk) => msg(`permission-grid.risk.${r}`, r === \"low\" ? \"Low risk\" : r === \"medium\" ? \"Medium risk\" : \"High risk\")\n\n  return (\n    <div data-slot=\"permission-grid\" className={cn(\"grid w-full min-w-0 gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5\", className)} {...props}>\n      <div className=\"flex flex-wrap items-center justify-between gap-3\">\n        <div>\n          <p className=\"text-sm font-medium text-foreground\">{title}</p>\n          <p aria-live=\"polite\" className=\"mt-0.5 text-xs text-muted-foreground tabular-nums\">\n            {msg(\"permission-grid.summary\", \"{allow} allowed, {ask} ask first, {deny} denied\", counts)}\n          </p>\n        </div>\n        {presets && presets.length > 0 && (\n          <div role=\"group\" aria-label={msg(\"permission-grid.presets\", \"Presets\")} className=\"flex flex-wrap gap-1.5\">\n            {presets.map((p) => (\n              <button key={p.id} type=\"button\" onClick={() => commit({ ...Object.fromEntries(permissions.map((x) => [x.id, decision(x.id)])), ...p.values })} className=\"h-8 rounded-full border bg-background px-3 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50\">\n                {p.label}\n              </button>\n            ))}\n          </div>\n        )}\n      </div>\n\n      <div className=\"overflow-x-auto rounded-lg border\">\n        <table className=\"w-full min-w-[30rem] border-collapse text-sm\">\n          <caption className=\"sr-only\">{title}</caption>\n          <thead className=\"bg-muted/40 text-xs text-muted-foreground\">\n            <tr>\n              <th scope=\"col\" className=\"px-3 py-2 text-start font-medium\">{msg(\"permission-grid.permission\", \"Permission\")}</th>\n              {DECISIONS.map((d) => (\n                <th key={d.value} scope=\"col\" className=\"w-20 px-2 py-2 text-center font-medium\">{msg(`permission-grid.${d.value}`, d.fallback)}</th>\n              ))}\n            </tr>\n          </thead>\n          {groups.map((g, gi) => (\n            <tbody key={g.name ?? gi} className=\"divide-y border-t\">\n              {g.name && (\n                <tr className=\"bg-muted/20\">\n                  <th colSpan={4} scope=\"colgroup\" className=\"px-3 py-1.5 text-start text-xs font-medium text-muted-foreground\">{g.name}</th>\n                </tr>\n              )}\n              {g.items.map((p) => {\n                const d = decision(p.id)\n                return (\n                  <tr key={p.id} data-decision={d}>\n                    <th scope=\"row\" className=\"px-3 py-3 text-start font-normal\">\n                      <span className=\"flex flex-wrap items-center gap-x-2 gap-y-0.5\">\n                        <span className=\"font-medium text-foreground\">{p.label}</span>\n                        {p.risk && (\n                          <span className={cn(\"inline-flex items-center gap-1 rounded-full border px-1.5 py-px text-[11px]\", p.risk === \"high\" ? \"border-destructive/50 text-destructive\" : \"text-muted-foreground\")}>\n                            {p.risk === \"high\" && <ShieldAlert aria-hidden=\"true\" className=\"size-3\" />}\n                            {riskText(p.risk)}\n                          </span>\n                        )}\n                      </span>\n                      {p.description && <span className=\"mt-0.5 block text-xs text-muted-foreground\">{p.description}</span>}\n                      {p.risk === \"high\" && d === \"allow\" && (\n                        <span className=\"mt-1 flex items-center gap-1 text-xs font-medium text-destructive\">\n                          <ShieldAlert aria-hidden=\"true\" className=\"size-3\" /> {msg(\"permission-grid.noAsk\", \"Runs without asking\")}\n                        </span>\n                      )}\n                    </th>\n                    {DECISIONS.map((x) => (\n                      <td key={x.value} className=\"px-2 py-3 text-center\">\n                        <label className=\"relative inline-flex size-8 cursor-pointer items-center justify-center\">\n                          <input type=\"radio\" name={`${uid}-${p.id}`} value={x.value} checked={d === x.value} onChange={() => commit({ ...Object.fromEntries(permissions.map((y) => [y.id, decision(y.id)])), [p.id]: x.value })} aria-label={msg(\"permission-grid.radio\", \"{label}: {decision}\", { label: p.label, decision: msg(`permission-grid.${x.value}`, x.fallback) })} className=\"peer sr-only\" />\n                          <span aria-hidden=\"true\" className={cn(\"flex size-7 items-center justify-center rounded-full border transition-colors peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 motion-reduce:transition-none\", d === x.value ? (x.value === \"deny\" ? \"border-destructive bg-destructive text-white\" : \"border-foreground bg-foreground text-background\") : \"text-muted-foreground/60 hover:bg-accent\")}>\n                            <x.icon className=\"size-3.5\" />\n                          </span>\n                        </label>\n                      </td>\n                    ))}\n                  </tr>\n                )\n              })}\n            </tbody>\n          ))}\n        </table>\n      </div>\n    </div>\n  )\n}\n\nexport { PermissionGrid, type Decision, type Permission, type PermissionGridProps, type PermissionPreset, type Risk }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/permission-grid.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agents",
      "permissions",
      "settings",
      "safety",
      "tools",
      "approval"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "permissions is [{ id, label, description?, risk?: low | medium | high, group? }]; value / defaultValue / onValueChange hold { [id]: 'allow' | 'ask' | 'deny' } (missing ids mean ask). presets is [{ id, label, values }] and fills the grid in one click.",
      "whenToUse": [
        "Letting people decide which tools an agent may use without asking",
        "A permissions page for an assistant or a bot"
      ],
      "whenNotToUse": [
        "One overall level of freedom; use autonomy-dial",
        "A single approval in the moment; use approval-card",
        "User roles in a workspace; use the access review block"
      ],
      "composesWith": [
        "autonomy-dial",
        "approval-card",
        "tool-call-card",
        "settings-panel"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "One stop per row: the chosen answer, or the first if none is"
        },
        {
          "keys": "ArrowRight / ArrowLeft / ArrowDown / ArrowUp",
          "action": "Moves between Allow, Ask and Deny in the row (native radios)"
        },
        {
          "keys": "Screen readers",
          "action": "A real table; each radio is named 'Run shell commands: Allow'; the counts are a polite live region"
        },
        {
          "keys": "Risk and decisions",
          "action": "Written as words and icons; colour only reinforces them"
        }
      ],
      "customization": [
        "permissions with groups and risk",
        "presets that set many rows at once",
        "title",
        "Controlled or uncontrolled value"
      ]
    },
    "statesNone": "A control with no data of its own: it shows the permissions you pass in and reports the choices.",
    "examples": [
      "permission-grid-demo",
      "permission-grid-dial"
    ],
    "url": "https://ui.ballmac.com/components/permission-grid"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}