{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "approval-card",
  "type": "registry:ui",
  "title": "Approval Card",
  "description": "An inline permission request that answers to Enter and Escape, then folds to one line.",
  "categories": [
    "ai-agents"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/approval-card.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type ApprovalDecision = \"pending\" | \"allowed\" | \"always\" | \"denied\" | \"expired\";\nexport type ApprovalRisk = \"low\" | \"medium\" | \"high\";\n\nexport type ApprovalCardProps = Omit<React.ComponentProps<\"section\">, \"title\" | \"children\"> & {\n  /** What the agent wants to do, as a verb phrase: “Run a database migration”. */\n  title: React.ReactNode;\n  /** The exact target, one line: “on billing-staging”, “to alex@acme.dev”. */\n  description?: React.ReactNode;\n  /** The specifics: the command, the diff, the recipient, the amount. */\n  children?: React.ReactNode;\n  risk?: ApprovalRisk;\n  /** Why it carries that risk, in one sentence. */\n  riskReason?: React.ReactNode;\n  /** Short text for the decided line. Defaults to the title. */\n  summary?: React.ReactNode;\n  /** Offers a third button that allows this kind of action from now on. Never offered for high risk. */\n  alwaysLabel?: string;\n  decision?: ApprovalDecision;\n  defaultDecision?: ApprovalDecision;\n  onDecisionChange?: (decision: ApprovalDecision) => void;\n  /** Milliseconds to wait before denying on the person's behalf. The timer pauses while the pointer is over the card. */\n  timeout?: number;\n  /** Move focus to the card when it appears, so Enter and Escape answer it. */\n  autoFocus?: boolean;\n};\n\nconst riskCopy: Record<ApprovalRisk, string> = { low: \"Low risk\", medium: \"Medium risk\", high: \"High risk\" };\nconst decided: Record<Exclude<ApprovalDecision, \"pending\">, string> = { allowed: \"Allowed\", always: \"Always allowed\", denied: \"Denied\", expired: \"Timed out\" };\n\n/**\n * A request from an agent, in the flow of the conversation, waiting for a person.\n * Enter allows and Escape denies while the card has focus; once answered it folds\n * to a single line that records what was decided.\n */\nexport function ApprovalCard({\n  title,\n  description,\n  children,\n  risk = \"medium\",\n  riskReason,\n  summary,\n  alwaysLabel,\n  decision: decisionProp,\n  defaultDecision = \"pending\",\n  onDecisionChange,\n  timeout,\n  autoFocus = true,\n  className,\n  ...rest\n}: ApprovalCardProps) {\n  const reduce = useReducedMotion();\n  const [decision, setDecision] = useControllableState<ApprovalDecision>({ value: decisionProp, defaultValue: defaultDecision, onChange: onDecisionChange });\n  const pending = decision === \"pending\";\n  const card = useRef<HTMLDivElement>(null);\n  const line = useRef<HTMLDivElement>(null);\n  const hadFocus = useRef(false);\n  const titleId = useId();\n  const high = risk === \"high\";\n  const offerAlways = !!alwaysLabel && !high;\n\n  const decide = (next: Exclude<ApprovalDecision, \"pending\">) => {\n    if (!pending) return;\n    hadFocus.current = !!card.current?.contains(document.activeElement);\n    setDecision(next);\n  };\n\n  // Countdown: one tick a second, paused while the pointer rests on the card.\n  // Running out denies. Never the other way round.\n  const [left, setLeft] = useState(timeout ?? 0);\n  const [paused, setPaused] = useState(false);\n  const remaining = useRef(timeout ?? 0);\n  const expire = useRef(() => {});\n  useEffect(() => {\n    expire.current = () => setDecision(\"expired\");\n  });\n  useEffect(() => {\n    if (!pending || timeout == null || paused) return;\n    const id = window.setInterval(() => {\n      remaining.current = Math.max(0, remaining.current - 1000);\n      setLeft(remaining.current);\n      if (remaining.current === 0) expire.current();\n    }, 1000);\n    return () => window.clearInterval(id);\n  }, [pending, timeout, paused]);\n\n  // Focus arrives with the request, and moves to the record once it is answered.\n  useEffect(() => {\n    if (pending && autoFocus) card.current?.focus({ preventScroll: true });\n  }, [pending, autoFocus]);\n  useEffect(() => {\n    if (!pending && hadFocus.current) {\n      hadFocus.current = false;\n      line.current?.focus({ preventScroll: true });\n    }\n  }, [pending]);\n\n  // Measure the content so the card can shrink to one line instead of snapping.\n  const inner = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number>();\n  useEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize?.[0]?.blockSize ?? el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (!pending || e.defaultPrevented) return;\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      decide(\"denied\");\n    }\n    // Enter on the card itself; on a focused button the button's own action wins.\n    // High-risk requests don't take the shortcut: allowing them is a deliberate press.\n    if (e.key === \"Enter\" && e.target === e.currentTarget && !high) {\n      e.preventDefault();\n      decide(\"allowed\");\n    }\n  };\n\n  const seconds = Math.ceil(left / 1000);\n\n  return (\n    <section\n      aria-labelledby={pending ? titleId : `${titleId}-line`}\n      data-decision={decision}\n      data-risk={risk}\n      className={cn(\"@container/approval min-w-0\", className)}\n      {...rest}\n    >\n      <motion.div\n        className={cn(\n          \"relative overflow-hidden border bg-raised shadow-[var(--shadow)] transition-[border-color,border-radius] duration-300\",\n          pending ? \"rounded-xl border-line-2\" : \"rounded-lg border-line\",\n        )}\n        initial={false}\n        animate={{ height: height ?? \"auto\" }}\n        transition={reduce ? { duration: 0 } : { duration: 0.32, ease: ease.inOut }}\n      >\n        <div ref={inner}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {pending ? (\n              <motion.div\n                key=\"request\"\n                ref={card}\n                tabIndex={-1}\n                onKeyDown={onKeyDown}\n                onPointerEnter={(e) => e.pointerType === \"mouse\" && setPaused(true)}\n                onPointerLeave={() => setPaused(false)}\n                className=\"rounded-[11px] outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\"\n                exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.16, ease: ease.in } }}\n              >\n                <div className=\"flex flex-col gap-3 p-3.5 pb-3\">\n                  <div className=\"flex items-start gap-3\">\n                    <span\n                      aria-hidden\n                      className={cn(\n                        \"mt-px grid size-7 shrink-0 place-items-center rounded-lg border\",\n                        high ? \"border-danger/25 bg-danger-soft text-danger\" : risk === \"medium\" ? \"border-warning/25 bg-warning-soft text-warning\" : \"border-line bg-hover text-fg-2\",\n                      )}\n                    >\n                      <Shield />\n                    </span>\n                    <div className=\"grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-0.5\">\n                      <h3 id={titleId} className=\"min-w-0 text-balance text-[14px] font-medium leading-5 tracking-[-0.012em] text-fg\">\n                        {title}\n                      </h3>\n                      <span\n                        className={cn(\n                          \"inline-flex h-5 items-center gap-1.5 justify-self-end rounded-full px-2 text-[11px] font-medium\",\n                          high ? \"bg-danger-soft text-danger\" : risk === \"medium\" ? \"bg-warning-soft text-warning\" : \"bg-hover text-fg-3\",\n                        )}\n                      >\n                        <span aria-hidden className=\"size-1.5 rounded-full bg-current\" />\n                        {riskCopy[risk]}\n                      </span>\n                      <p className=\"min-w-0 text-[12.5px] leading-[18px] text-fg-2\">{description}</p>\n                      {timeout != null && (\n                        <span className=\"justify-self-end whitespace-nowrap text-[11px] leading-[18px] tabular text-fg-3\">{paused ? \"Timer paused\" : `Denies in ${seconds}s`}</span>\n                      )}\n                    </div>\n                  </div>\n\n                  {children != null && <div className=\"flex min-w-0 flex-col gap-2 text-[12.5px] text-fg-2\">{children}</div>}\n\n                  {riskReason != null && (\n                    <p className={cn(\"flex items-start gap-1.5 text-[12px] leading-[18px]\", high ? \"text-danger\" : \"text-fg-3\")}>\n                      <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"mt-0.5 shrink-0\">\n                        <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n                        <path d=\"M8 5v3.5\" />\n                        <circle cx=\"8\" cy=\"10.9\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n                      </svg>\n                      <span className=\"min-w-0 text-pretty\">{riskReason}</span>\n                    </p>\n                  )}\n                </div>\n\n                <div className=\"relative flex flex-wrap items-center justify-end gap-2 border-t border-line bg-frame/60 px-3 py-2.5\">\n                  {/* The divider doubles as the countdown: it drains toward the left. */}\n                  {timeout != null && (\n                    <span aria-hidden className=\"absolute inset-x-0 -top-px h-px overflow-hidden\">\n                      <span\n                        className=\"absolute inset-0 origin-left bg-fg-3 transition-[scale] duration-1000 ease-linear motion-reduce:transition-none\"\n                        style={{ scale: `${Math.max(0, left - (paused ? 0 : 1000)) / timeout} 1` }}\n                      />\n                    </span>\n                  )}\n                  {offerAlways && (\n                    <Button\n                      variant=\"ghost\"\n                      // In a narrow column the two answers share the first row; the standing permission sits under them.\n                      className=\"-ml-1.5 mr-auto justify-center @max-[26rem]/approval:order-last @max-[26rem]/approval:mx-0 @max-[26rem]/approval:w-full\"\n                      onClick={() => decide(\"always\")}>\n                      {alwaysLabel}\n                    </Button>\n                  )}\n                  <Button variant=\"secondary\" className=\"justify-center @max-[26rem]/approval:flex-1\" onClick={() => decide(\"denied\")} hint=\"Esc\">\n                    Deny\n                  </Button>\n                  <Button variant=\"primary\" className=\"justify-center @max-[26rem]/approval:flex-1\" onClick={() => decide(\"allowed\")} hint={high ? undefined : \"↵\"}>\n                    Allow\n                  </Button>\n                </div>\n              </motion.div>\n            ) : (\n              <motion.div\n                key=\"decided\"\n                id={`${titleId}-line`}\n                ref={line}\n                tabIndex={-1}\n                className=\"flex h-9 min-w-0 items-center gap-2 rounded-[7px] px-2.5 outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                transition={reduce ? { duration: 0.15 } : { duration: 0.26, ease: ease.out, delay: 0.08 }}\n              >\n                <Outcome decision={decision} reduce={!!reduce} />\n                <span className={cn(\"shrink-0 text-[13px] font-medium\", decision === \"allowed\" || decision === \"always\" ? \"text-fg\" : \"text-fg-2\")}>{decided[decision]}</span>\n                <span className=\"min-w-0 flex-1 truncate text-[12.5px] text-fg-3\">{decision === \"always\" && alwaysLabel ? alwaysLabel.replace(/^always allow\\s*/i, \"\") || (summary ?? title) : (summary ?? title)}</span>\n                {decision === \"expired\" && timeout != null && <span className=\"shrink-0 text-[12px] tabular text-fg-4\">No answer in {Math.round(timeout / 1000)}s</span>}\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      </motion.div>\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {pending ? \"\" : decided[decision]}\n      </span>\n    </section>\n  );\n}\n\nfunction Button({ variant, hint, className, children, ...rest }: React.ComponentProps<\"button\"> & { variant: \"primary\" | \"secondary\" | \"ghost\"; hint?: string }) {\n  return (\n    <button\n      type=\"button\"\n      data-variant={variant}\n      className={cn(\n        \"relative inline-flex h-8 shrink-0 select-none items-center gap-2 rounded-lg px-3 text-[12.5px] font-medium tracking-[-0.005em]\",\n        \"touch-manipulation outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75\",\n        \"pointer-coarse:before:absolute pointer-coarse:before:-inset-y-1.5 pointer-coarse:before:inset-x-0 pointer-coarse:before:content-['']\",\n        variant === \"primary\" && \"bg-fg text-frame hover:bg-fg/90\",\n        variant === \"secondary\" && \"border border-line-2 bg-raised text-fg hover:border-fg-4 hover:bg-hover\",\n        variant === \"ghost\" && \"px-2.5 text-fg-2 hover:bg-hover hover:text-fg\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n      {hint && (\n        <kbd\n          aria-hidden\n          className={cn(\n            \"-mr-1 hidden h-[18px] min-w-[18px] items-center justify-center rounded px-1 font-mono text-[10.5px] font-normal pointer-fine:inline-flex\",\n            variant === \"primary\" ? \"bg-frame/15 text-frame/70\" : \"bg-hover text-fg-3\",\n          )}\n        >\n          {hint}\n        </kbd>\n      )}\n    </button>\n  );\n}\n\nfunction Shield() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M8 2.25 3.25 4v3.6c0 3 2 5.1 4.75 6.15C10.75 12.7 12.75 10.6 12.75 7.6V4z\" />\n      <path d=\"M8 5.75v2.5\" />\n      <circle cx=\"8\" cy=\"10.4\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n    </svg>\n  );\n}\n\nconst glyph = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.5, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n\nfunction Outcome({ decision, reduce }: { decision: ApprovalDecision; reduce: boolean }) {\n  const draw = (delay = 0.14) => ({\n    initial: { pathLength: reduce ? 1 : 0 },\n    animate: { pathLength: 1 },\n    transition: reduce ? { duration: 0 } : { duration: 0.32, ease: ease.out, delay },\n  });\n  return (\n    <motion.span\n      className=\"grid size-4 shrink-0 place-items-center\"\n      initial={reduce ? false : { scale: 0.6 }}\n      animate={{ scale: 1 }}\n      transition={spring.pop}\n    >\n      {decision === \"allowed\" || decision === \"always\" ? (\n        <svg {...glyph} className=\"text-success\">\n          <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" {...draw()} />\n        </svg>\n      ) : decision === \"denied\" ? (\n        <svg {...glyph} className=\"text-fg-3\">\n          <motion.path d=\"m4.5 4.5 7 7\" {...draw()} />\n          <motion.path d=\"m11.5 4.5-7 7\" {...draw(0.22)} />\n        </svg>\n      ) : (\n        <svg {...glyph} className=\"text-fg-3\">\n          <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n          <motion.path d=\"M8 4.75V8l2.25 1.5\" {...draw()} />\n        </svg>\n      )}\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/approval-card"
}
