{
  "schemaVersion": 1,
  "target": "react",
  "name": "approval-card",
  "version": "0.2.0",
  "category": "agent",
  "title": "Approval Card",
  "description": "Ask a person to approve or reject an agent action: what will run, then the decision, with duplicate-click protection while it is saved.",
  "tags": [
    "agent",
    "approval",
    "human-in-the-loop",
    "confirm",
    "review"
  ],
  "requires": {
    "projectTypes": [
      "next",
      "vite-react"
    ],
    "styling": "tailwind-v4"
  },
  "npmDependencies": {},
  "registryDependencies": [
    {
      "name": "badge",
      "version": "0.2.0"
    },
    {
      "name": "button",
      "version": "0.2.0"
    },
    {
      "name": "agent-run",
      "version": "0.1.1"
    }
  ],
  "docsPath": "/components/approval-card",
  "props": [
    {
      "name": "title",
      "type": "ReactNode",
      "description": "What the agent wants to do."
    },
    {
      "name": "status",
      "type": "\"pending\" | \"approved\" | \"rejected\" | \"expired\"",
      "description": "Decision state. Buttons show only while pending."
    },
    {
      "name": "description",
      "type": "ReactNode",
      "description": "Context for the decision."
    },
    {
      "name": "detail",
      "type": "string",
      "description": "The exact action, e.g. a command or diff, shown in a well under \"What will run\"."
    },
    {
      "name": "onApprove / onReject",
      "type": "() => void | Promise<void>",
      "description": "Called once per decision; both buttons ignore clicks until it settles. Errors show a retry message."
    },
    {
      "name": "approveLabel / rejectLabel",
      "type": "string",
      "default": "\"Approve\" / \"Reject\"",
      "description": "Button text."
    },
    {
      "name": "detailLabel",
      "type": "string",
      "default": "\"What will run\"",
      "description": "Label above detail."
    }
  ],
  "accessibility": [
    "The decision state is a live region and also a text label and glyph, not color alone.",
    "While a decision is saving, the chosen button shows a busy state and keeps focus; repeated clicks are ignored.",
    "After a decision, a sentence states the outcome (for example \"Rejected. Nothing ran.\").",
    "The card only reports a decision. Your backend must verify the approval and the user's authorization before acting."
  ],
  "states": [
    "pending",
    "approved",
    "rejected",
    "expired"
  ],
  "files": [
    {
      "path": "agent/approval-card.tsx",
      "content": "\"use client\";\n\nimport { type ComponentProps, type ReactNode, useRef, useState } from \"react\";\nimport { Badge, type BadgeTone } from \"../ui/badge\";\nimport { Button } from \"../ui/button\";\nimport type { ApprovalStatus } from \"./agent-run\";\n\nconst statuses: Record<ApprovalStatus, { label: string; tone: BadgeTone; outcome: string }> = {\n  pending: { label: \"Waiting for you\", tone: \"warning\", outcome: \"\" },\n  approved: { label: \"Approved\", tone: \"success\", outcome: \"Approved. The agent can continue.\" },\n  rejected: { label: \"Rejected\", tone: \"danger\", outcome: \"Rejected. Nothing ran.\" },\n  expired: { label: \"Expired\", tone: \"neutral\", outcome: \"This request expired before a decision was made.\" },\n};\n\nconst ease = \"ease-[cubic-bezier(0.23,1,0.32,1)]\";\n\nexport interface ApprovalCardProps extends Omit<ComponentProps<\"section\">, \"title\" | \"children\"> {\n  title: ReactNode;\n  status: ApprovalStatus;\n  description?: ReactNode;\n  /** What will happen if approved, such as a command or diff. Shown as preformatted text. */\n  detail?: string;\n  /** Called once per decision. While it runs, both buttons ignore further clicks. */\n  onApprove?: () => void | Promise<void>;\n  onReject?: () => void | Promise<void>;\n  approveLabel?: string;\n  rejectLabel?: string;\n  /** Label above `detail`. */\n  detailLabel?: string;\n}\n\n/**\n * Asks a person to approve or reject an agent action: what will run, then the decision.\n * The UI only reports the decision: your backend must verify the approval and the\n * user's authorization before acting.\n */\nexport function ApprovalCard({\n  title,\n  status,\n  description,\n  detail,\n  onApprove,\n  onReject,\n  approveLabel = \"Approve\",\n  rejectLabel = \"Reject\",\n  detailLabel = \"What will run\",\n  className,\n  ...props\n}: ApprovalCardProps) {\n  const [submitting, setSubmitting] = useState<\"approve\" | \"reject\" | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const inFlight = useRef(false);\n  const meta = statuses[status];\n  const pending = status === \"pending\";\n\n  async function decide(decision: \"approve\" | \"reject\") {\n    // A ref (not state) guards against double clicks that land before React re-renders.\n    if (inFlight.current || status !== \"pending\") return;\n    inFlight.current = true;\n    setSubmitting(decision);\n    setError(null);\n    try {\n      await (decision === \"approve\" ? onApprove : onReject)?.();\n    } catch {\n      setError(\"Your decision was not saved. Try again.\");\n    } finally {\n      inFlight.current = false;\n      setSubmitting(null);\n    }\n  }\n\n  return (\n    <section\n      aria-label={typeof title === \"string\" ? `Approval: ${title}` : \"Approval request\"}\n      data-status={status}\n      className={[\n        \"overflow-hidden rounded-xl border bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-[border-color] duration-200 motion-reduce:transition-none\",\n        \"dark:bg-black dark:text-white dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]\",\n        pending ? \"border-amber-500/40\" : \"border-zinc-950/10 dark:border-white/10\",\n        className,\n      ]\n        .filter(Boolean)\n        .join(\" \")}\n      {...props}\n    >\n      <div className=\"flex items-start gap-3 p-4\">\n        <DecisionGlyph key={status} status={status} />\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex flex-wrap items-start justify-between gap-x-3 gap-y-1\">\n            <h3 className=\"min-w-0 text-sm font-semibold\">{title}</h3>\n            <span role=\"status\" className=\"shrink-0\">\n              <Badge tone={meta.tone}>{meta.label}</Badge>\n            </span>\n          </div>\n          {description ? <p className=\"mt-1 text-sm text-zinc-600 dark:text-zinc-400\">{description}</p> : null}\n          {detail ? (\n            <div className=\"mt-3\">\n              <p className=\"mb-1.5 text-xs font-medium text-zinc-500\">{detailLabel}</p>\n              <pre className=\"overflow-x-auto rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed text-zinc-800 dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-300\">\n                {detail}\n              </pre>\n            </div>\n          ) : null}\n        </div>\n      </div>\n\n      {pending ? (\n        <div className=\"border-t border-zinc-950/[0.07] bg-amber-500/[0.04] px-4 py-3 dark:border-white/[0.08] dark:bg-amber-500/[0.05]\">\n          <div className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-2.5\">\n            <p className=\"text-xs text-zinc-600 dark:text-zinc-400\">Nothing runs until you decide.</p>\n            <div className=\"flex items-center gap-2\">\n              <Button size=\"sm\" variant=\"secondary\" loading={submitting === \"reject\"} disabled={submitting === \"approve\"} onClick={() => decide(\"reject\")}>\n                {rejectLabel}\n              </Button>\n              <Button size=\"sm\" loading={submitting === \"approve\"} disabled={submitting === \"reject\"} onClick={() => decide(\"approve\")}>\n                {approveLabel}\n              </Button>\n            </div>\n          </div>\n          {error ? (\n            <p\n              role=\"alert\"\n              className={`mt-2.5 text-sm text-red-600 transition-[opacity,translate] duration-200 ${ease} starting:-translate-y-1 starting:opacity-0 motion-reduce:transition-none dark:text-red-400`}\n            >\n              {error}\n            </p>\n          ) : null}\n        </div>\n      ) : (\n        <p\n          key={status}\n          className={[\n            \"border-t border-zinc-950/[0.07] px-4 py-3 text-xs dark:border-white/[0.08]\",\n            `transition-[opacity,translate] duration-200 ${ease} starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none`,\n            status === \"approved\"\n              ? \"text-emerald-700 dark:text-emerald-400\"\n              : status === \"rejected\"\n                ? \"text-red-700 dark:text-red-400\"\n                : \"text-zinc-500\",\n          ].join(\" \")}\n        >\n          {meta.outcome}\n        </p>\n      )}\n    </section>\n  );\n}\n\n/** The shared status shapes at 20px: pause bars while waiting, then check, x, or slash. */\nfunction DecisionGlyph({ status }: { status: ApprovalStatus }) {\n  const base = `mt-px size-5 shrink-0 transition-[opacity,scale] duration-200 ${ease} starting:scale-90 starting:opacity-0 motion-reduce:transition-none`;\n  // A 16 unit box drawn at 20px: a 1.2 stroke renders at 1.5px.\n  const stroke = { fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.2, strokeLinecap: \"round\", strokeLinejoin: \"round\" } as const;\n  const tone = {\n    pending: \"text-amber-600 dark:text-amber-400\",\n    approved: \"text-emerald-600 dark:text-emerald-400\",\n    rejected: \"text-red-600 dark:text-red-400\",\n    expired: \"text-zinc-500\",\n  }[status];\n\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" {...stroke} className={`${base} ${tone}`}>\n      <circle cx=\"8\" cy=\"8\" r=\"6.25\" />\n      {status === \"pending\" ? (\n        <path d=\"M6.6 5.9v4.2M9.4 5.9v4.2\" />\n      ) : status === \"approved\" ? (\n        <path\n          d=\"m5.35 8.25 1.75 1.75 3.55-3.75\"\n          pathLength={1}\n          strokeDasharray=\"1\"\n          className={`[stroke-dashoffset:0] transition-[stroke-dashoffset] delay-75 duration-300 ${ease} starting:[stroke-dashoffset:1] motion-reduce:transition-none`}\n        />\n      ) : status === \"rejected\" ? (\n        <path d=\"m6.1 6.1 3.8 3.8m0-3.8-3.8 3.8\" />\n      ) : (\n        <path d=\"m5.6 10.4 4.8-4.8\" />\n      )}\n    </svg>\n  );\n}\n"
    }
  ],
  "example": {
    "content": "\"use client\";\n\nimport { useState } from \"react\";\nimport type { ApprovalStatus } from \"@/components/seamless/agent/agent-run\";\nimport { ApprovalCard } from \"@/components/seamless/agent/approval-card\";\n\nexport default function ApprovalCardExample() {\n  const [status, setStatus] = useState<ApprovalStatus>(\"pending\");\n\n  // Replace with a call to your backend, which must verify the user may approve this action.\n  const record = (decision: \"approved\" | \"rejected\") => new Promise<void>((resolve) => setTimeout(() => (setStatus(decision), resolve()), 600));\n\n  return (\n    <ApprovalCard\n      className=\"w-full max-w-md\"\n      title=\"Open a pull request?\"\n      description=\"The agent wants to push a branch and open a pull request in acme/docs.\"\n      detail={'git push origin docs/rate-limits\\ngh pr create --title \"Update rate limits\"'}\n      status={status}\n      onApprove={() => record(\"approved\")}\n      onReject={() => record(\"rejected\")}\n    />\n  );\n}\n"
  }
}
