{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "approval-card",
  "title": "Approval Card",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A human-in-the-loop confirmation for agent actions: what will happen, risk level in words, the exact command, Approve, Deny and Always allow, a timeout countdown and a recorded decision.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/approval-card.tsx",
      "content": "// Ballmac UI: Approval Card. https://ui.ballmac.com/components/approval-card\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, OctagonAlert, ShieldAlert, ShieldCheck, Timer, X } from \"lucide-react\"\nimport { motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages, defineMessage, type Message } from \"@/lib/ballmac/i18n\"\n\ntype ApprovalRisk = \"low\" | \"medium\" | \"high\"\ntype ApprovalStatus = \"pending\" | \"approved\" | \"denied\" | \"expired\"\n\nconst RISK: Record<ApprovalRisk, { label: Message; hint: Message; icon: React.ComponentType<{ className?: string }>; bar: string; chip: string }> = {\n  low: { label: defineMessage(\"approval-card.RISK.low\", \"Low risk\"), hint: defineMessage(\"approval-card.RISK.low.hint\", \"Reads data or changes nothing\"), icon: ShieldCheck, bar: \"bg-chart-2\", chip: \"border-chart-2/30 bg-chart-2/10\" },\n  medium: { label: defineMessage(\"approval-card.RISK.medium\", \"Medium risk\"), hint: defineMessage(\"approval-card.RISK.medium.hint\", \"Changes files or settings you can undo\"), icon: ShieldAlert, bar: \"bg-chart-3\", chip: \"border-chart-3/30 bg-chart-3/10\" },\n  high: { label: defineMessage(\"approval-card.RISK.high\", \"High risk\"), hint: defineMessage(\"approval-card.RISK.high.hint\", \"Hard to undo or sends data out\"), icon: OctagonAlert, bar: \"bg-destructive\", chip: \"border-destructive/30 bg-destructive/10\" },\n}\n\ntype ApprovalDetail = {\n  /** Label, such as \"Directory\". */\n  label: string\n  /** Value, such as a path or host. Shown in mono type. */\n  value: string\n}\n\ntype ApprovalCardProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** What the agent wants to do, as a short sentence, for example \"Run a shell command\". */\n  title: string\n  /** Why the agent wants to do it. */\n  description?: string\n  /** How much damage a mistake could do. Sets the accent color, icon and wording. */\n  risk?: ApprovalRisk\n  /** Label/value rows under the description. */\n  details?: ApprovalDetail[]\n  /** The exact thing that will run: a command, a diff or a request body. */\n  preview?: React.ReactNode\n  /** Accessible name of the scrollable preview. */\n  previewLabel?: string\n  /** Current status (controlled). Without it the card keeps its own status after a button is pressed. */\n  status?: ApprovalStatus\n  /** Called when Approve is pressed. */\n  onApprove?: () => void\n  /** Called when Deny is pressed. */\n  onDeny?: () => void\n  /** Adds a third button that approves now and asks not to be asked again. */\n  onAlwaysAllow?: () => void\n  /** Text of the always-allow button. */\n  alwaysAllowLabel?: string\n  /** Seconds until the request is denied on its own. The card counts down and calls `onExpire`. */\n  expiresIn?: number\n  /** Called when the countdown reaches zero. */\n  onExpire?: () => void\n  /** Text after the decision, such as \"by you at 10:42\". */\n  resolvedNote?: string\n  /** Approve button text. */\n  approveLabel?: string\n  /** Deny button text. */\n  denyLabel?: string\n}\n\nfunction ApprovalCard({\n  title,\n  description,\n  risk = \"medium\",\n  details,\n  preview,\n  previewLabel,\n  status: statusProp,\n  onApprove,\n  onDeny,\n  onAlwaysAllow,\n  alwaysAllowLabel,\n  expiresIn,\n  onExpire,\n  resolvedNote,\n  approveLabel,\n  denyLabel,\n  className,\n  ...props\n}: ApprovalCardProps) {\n  const msg = useMessages()\n  previewLabel ??= msg(\"approval-card.previewLabel\", \"Exactly what will run\")\n  alwaysAllowLabel ??= msg(\"approval-card.alwaysAllowLabel\", \"Always allow\")\n  approveLabel ??= msg(\"approval-card.approveLabel\", \"Approve\")\n  denyLabel ??= msg(\"approval-card.denyLabel\", \"Deny\")\n  const reduce = useReducedMotion()\n  const id = React.useId()\n  const [internal, setInternal] = React.useState<ApprovalStatus>(\"pending\")\n  const status = statusProp ?? internal\n  const [left, setLeft] = React.useState(expiresIn ?? 0)\n  const resolvedRef = React.useRef<HTMLDivElement>(null)\n  const wasPending = React.useRef(true)\n  const meta = RISK[risk]\n  const RiskIcon = meta.icon\n  const onExpireRef = React.useRef(onExpire)\n  React.useEffect(() => {\n    onExpireRef.current = onExpire\n  })\n\n  React.useEffect(() => {\n    if (expiresIn === undefined || status !== \"pending\") return\n    setLeft(expiresIn)\n    const timer = setInterval(() => {\n      setLeft((s) => {\n        if (s <= 1) {\n          clearInterval(timer)\n          return 0\n        }\n        return s - 1\n      })\n    }, 1000)\n    return () => clearInterval(timer)\n  }, [expiresIn, status])\n\n  React.useEffect(() => {\n    if (expiresIn !== undefined && status === \"pending\" && left === 0) {\n      setInternal(\"expired\")\n      onExpireRef.current?.()\n    }\n  }, [left, expiresIn, status])\n\n  // Buttons disappear on a decision, so move focus to the result instead of dropping it on the page.\n  React.useEffect(() => {\n    if (status !== \"pending\" && wasPending.current) resolvedRef.current?.focus()\n    wasPending.current = status === \"pending\"\n  }, [status])\n\n  function decide(next: \"approved\" | \"denied\", cb?: () => void) {\n    setInternal(next)\n    cb?.()\n  }\n\n  const pending = status === \"pending\"\n  const resolvedText =\n    status === \"approved\" ? \"Approved\" : status === \"denied\" ? \"Denied\" : \"Timed out and denied\"\n\n  return (\n    <section\n      data-slot=\"approval-card\"\n      data-status={status}\n      data-risk={risk}\n      aria-labelledby={`${id}-title`}\n      className={cn(\n        \"relative w-full overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs\",\n        className\n      )}\n      {...props}\n    >\n      <span aria-hidden=\"true\" className={cn(\"absolute inset-y-0 start-0 w-1\", pending ? meta.bar : \"bg-border\")} />\n      <div className=\"grid gap-3.5 p-4 ps-5 sm:p-5 sm:ps-6\">\n        <div className=\"flex flex-wrap items-start justify-between gap-2\">\n          <div className=\"min-w-0\">\n            <h3 id={`${id}-title`} className=\"text-sm leading-6 font-semibold text-foreground\">\n              {title}\n            </h3>\n            {description && <p className=\"mt-0.5 text-[13px] leading-5 text-muted-foreground\">{description}</p>}\n          </div>\n          <span\n            title={msg.of(meta.hint)}\n            className={cn(\n              \"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium text-foreground\",\n              meta.chip\n            )}\n          >\n            <RiskIcon aria-hidden=\"true\" className=\"size-3.5\" />\n            {msg.of(meta.label)}\n            <span className=\"sr-only\">. {msg.of(meta.hint)}</span>\n          </span>\n        </div>\n\n        {details && details.length > 0 && (\n          <dl className=\"grid gap-1.5 text-[13px]\">\n            {details.map((d) => (\n              <div key={d.label} className=\"flex flex-wrap items-baseline gap-x-3 gap-y-0.5\">\n                <dt className=\"w-24 shrink-0 text-muted-foreground\">{d.label}</dt>\n                <dd className=\"min-w-0 font-mono text-xs break-all text-foreground\">{d.value}</dd>\n              </div>\n            ))}\n          </dl>\n        )}\n\n        {preview && (\n          <div\n            role=\"region\"\n            aria-label={previewLabel}\n            tabIndex={0}\n            className=\"max-h-44 overflow-auto rounded-lg border bg-muted/50 px-3 py-2.5 font-mono whitespace-pre-wrap break-words text-xs leading-5 text-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n          >\n            {preview}\n          </div>\n        )}\n\n        {pending ? (\n          <div className=\"flex flex-wrap items-center gap-2 pt-0.5\">\n            <button\n              type=\"button\"\n              onClick={() => decide(\"approved\", onApprove)}\n              className=\"inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-[inset_0_1px_0_0_rgb(255_255_255/0.12),0_1px_2px_0_rgb(0_0_0/0.12)] outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n            >\n              <Check aria-hidden=\"true\" className=\"size-4\" />\n              {approveLabel}\n            </button>\n            <button\n              type=\"button\"\n              onClick={() => decide(\"denied\", onDeny)}\n              className=\"inline-flex h-9 items-center gap-1.5 rounded-md border bg-background px-4 text-sm font-medium shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n            >\n              <X aria-hidden=\"true\" className=\"size-4\" />\n              {denyLabel}\n            </button>\n            {onAlwaysAllow && (\n              <button\n                type=\"button\"\n                onClick={() => decide(\"approved\", onAlwaysAllow)}\n                className=\"inline-flex h-9 items-center rounded-md px-3 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n              >\n                {alwaysAllowLabel}\n              </button>\n            )}\n            {expiresIn !== undefined && (\n              <span className=\"ms-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground tabular-nums\">\n                <Timer aria-hidden=\"true\" className=\"size-3.5\" />\n                {msg(\"approval-card.deniesItselfIn\", \"Denies itself in {seconds}s\", { seconds: left })}\n              </span>\n            )}\n          </div>\n        ) : null}\n      </div>\n\n      {pending && expiresIn !== undefined && (\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"absolute inset-x-0 bottom-0 h-0.5 origin-left bg-foreground/60\"\n          initial={false}\n          animate={{ scaleX: expiresIn > 0 ? left / expiresIn : 0 }}\n          transition={reduce ? { duration: 0 } : { duration: 1, ease: \"linear\" }}\n        />\n      )}\n\n      <div\n        ref={resolvedRef}\n        tabIndex={-1}\n        role=\"status\"\n        aria-live=\"polite\"\n        className={cn(\n          \"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n          !pending && \"border-t bg-muted/40 px-5 py-2.5 sm:px-6\"\n        )}\n      >\n        {!pending && (\n          <p className=\"flex items-center gap-2 text-[13px] font-medium text-foreground\">\n            {status === \"approved\" ? (\n              <Check aria-hidden=\"true\" className=\"size-4\" />\n            ) : status === \"denied\" ? (\n              <X aria-hidden=\"true\" className=\"size-4\" />\n            ) : (\n              <Timer aria-hidden=\"true\" className=\"size-4\" />\n            )}\n            {resolvedText}\n            {resolvedNote && <span className=\"font-normal text-muted-foreground\">{resolvedNote}</span>}\n          </p>\n        )}\n      </div>\n    </section>\n  )\n}\n\nexport { ApprovalCard, type ApprovalCardProps, type ApprovalRisk, type ApprovalStatus, type ApprovalDetail }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/approval-card.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "agent",
      "approval",
      "confirm",
      "safety",
      "human-in-the-loop"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "title, description, risk (low | medium | high), details rows and a preview of the exact action. onApprove, onDeny, optional onAlwaysAllow. expiresIn counts down and denies. After a decision the card shows the outcome and moves focus to it.",
      "whenToUse": [
        "Before an agent runs commands, writes files or sends data out",
        "Any action that needs a person's explicit yes"
      ],
      "whenNotToUse": [
        "Confirming a destructive UI action; use alert-dialog",
        "Reporting what a tool already did; use tool-call-card"
      ],
      "composesWith": [
        "agent-plan",
        "tool-call-card",
        "ai-message"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Approve, Deny, Always allow, and the scrollable preview"
        },
        {
          "keys": "Screen readers",
          "action": "Risk is read as words; the decision is announced and focus moves to it"
        },
        {
          "keys": "Color",
          "action": "Risk uses an icon and label as well as a colored edge"
        }
      ],
      "customization": [
        "risk",
        "details",
        "preview",
        "expiresIn and onExpire",
        "status (controlled)",
        "button labels"
      ]
    },
    "examples": [
      "approval-card-demo",
      "approval-card-risks"
    ],
    "url": "https://ui.ballmac.com/components/approval-card"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}