Skip to content
loopkit
free204 lines · framer-motion

Approval Card

The agent asks. A human decides. A request card with risk level, context chips, a diff preview and a countdown. Approve or reject with a click or a key, and a stamp lands on the card.

Use it for
Merges, payments, emails to customers, anything an agent should not do alone.
Themes
Light + dark
License
Commercial use (draft)
Live

deploy-bot asks

Merge PR #482 into main

60

Tests passed. The change touches billing, so a human has to sign off.

Medium riskRepo: acme/webFiles: 4 changedEst. cost: $0.04
- const fee = total * 0.029
+ const fee = Math.round(total * 0.029 * 100) / 100
tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";

export type ApprovalRisk = "low" | "medium" | "high";
export type ApprovalDecision = "approved" | "rejected" | "expired";

export interface ApprovalCardProps {
  agent?: string;
  title?: string;
  summary?: string;
  risk?: ApprovalRisk;
  details?: { label: string; value: string }[];
  /** Seconds before the request expires. 0 = never. */
  timeout?: number;
  /** Show a unified diff preview of the proposed change. */
  diff?: string[];
  onDecision?: (decision: ApprovalDecision) => void;
  /** Resolve automatically after N ms (for demos). */
  autoDecision?: { decision: "approved" | "rejected"; after: number };
  className?: string;
}

const riskStyles: Record<ApprovalRisk, { label: string; dot: string; chip: string }> = {
  low: { label: "Low risk", dot: "bg-ok", chip: "text-ok bg-ok/10 ring-ok/25" },
  medium: { label: "Medium risk", dot: "bg-warn", chip: "text-warn bg-warn/10 ring-warn/25" },
  high: { label: "High risk", dot: "bg-destructive", chip: "text-destructive bg-destructive/10 ring-destructive/25" },
};

export function ApprovalCard({
  agent = "deploy-bot",
  title = "Merge PR #482 into main",
  summary = "Tests passed. The change touches billing, so a human has to sign off.",
  risk = "medium",
  details = [
    { label: "Repo", value: "acme/web" },
    { label: "Files", value: "4 changed" },
    { label: "Est. cost", value: "$0.04" },
  ],
  timeout = 60,
  diff = ["- const fee = total * 0.029", "+ const fee = Math.round(total * 0.029 * 100) / 100"],
  onDecision,
  autoDecision,
  className,
}: ApprovalCardProps) {
  const reduce = useReducedMotion();
  const [decision, setDecision] = React.useState<ApprovalDecision | null>(null);
  const [left, setLeft] = React.useState(timeout);
  const titleId = React.useId();

  const decide = React.useCallback(
    (d: ApprovalDecision) => {
      setDecision((cur) => {
        if (cur) return cur;
        onDecision?.(d);
        return d;
      });
    },
    [onDecision],
  );

  React.useEffect(() => {
    setLeft(timeout);
    setDecision(null);
  }, [timeout]);

  React.useEffect(() => {
    if (!timeout || decision) return;
    const t = setInterval(() => setLeft((s) => Math.max(0, s - 1)), 1000);
    return () => clearInterval(t);
  }, [timeout, decision]);

  React.useEffect(() => {
    if (timeout && left === 0 && !decision) decide("expired");
  }, [left, timeout, decision, decide]);

  React.useEffect(() => {
    if (!autoDecision) return;
    const t = setTimeout(() => decide(autoDecision.decision), autoDecision.after);
    return () => clearTimeout(t);
  }, [autoDecision, decide]);

  const r = riskStyles[risk];
  const pct = timeout ? left / timeout : 1;

  return (
    <motion.section
      role="group"
      aria-labelledby={titleId}
      initial={reduce ? false : { opacity: 0, y: 16, scale: 0.97 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      transition={{ type: "spring", stiffness: 380, damping: 30 }}
      onKeyDown={(e) => {
        if (decision) return;
        if (e.key === "a" || e.key === "A") decide("approved");
        if (e.key === "r" || e.key === "R") decide("rejected");
      }}
      className={cn(
        "relative w-full max-w-md overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-[0_1px_0_rgb(0_0_0/0.04),0_12px_32px_-12px_rgb(0_0_0/0.18)]",
        className,
      )}
    >
      <header className="flex items-center gap-3 px-5 pt-4">
        <span className="grid size-8 place-items-center rounded-lg bg-primary font-mono text-[11px] font-bold text-primary-foreground" aria-hidden>
          {agent.slice(0, 2).toUpperCase()}
        </span>
        <div className="min-w-0 flex-1">
          <p className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">{agent} asks</p>
          <h3 id={titleId} className="truncate text-[15px] font-semibold leading-tight">
            {title}
          </h3>
        </div>
        {timeout > 0 && !decision && (
          <div className="relative size-9 shrink-0" aria-label={`${left} seconds left`} role="timer">
            <svg viewBox="0 0 36 36" className="size-9 -rotate-90">
              <circle cx="18" cy="18" r="15" fill="none" className="stroke-muted" strokeWidth="3" />
              <circle
                cx="18" cy="18" r="15" fill="none" strokeWidth="3" strokeLinecap="round"
                className={cn("transition-[stroke-dashoffset] duration-1000 ease-linear", pct < 0.25 ? "stroke-destructive" : "stroke-signal")}
                strokeDasharray={94.25} strokeDashoffset={94.25 * (1 - pct)}
              />
            </svg>
            <span className="absolute inset-0 grid place-items-center font-mono text-[10px] tabular-nums">{left}</span>
          </div>
        )}
      </header>

      <div className="space-y-3 px-5 pb-4 pt-3">
        <p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
        <div className="flex flex-wrap items-center gap-2">
          <span className={cn("inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1", r.chip)}>
            <span className={cn("size-1.5 rounded-full", r.dot)} />
            {r.label}
          </span>
          {details.map((d) => (
            <span key={d.label} className="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
              {d.label}: <span className="font-medium text-foreground">{d.value}</span>
            </span>
          ))}
        </div>
        {diff.length > 0 && (
          <pre className="overflow-x-auto rounded-lg bg-muted/70 p-3 font-mono text-[11.5px] leading-5 scrollbar-thin">
            {diff.map((line, i) => (
              <div key={i} className={cn(line.startsWith("+") && "text-ok", line.startsWith("-") && "text-destructive")}>
                {line}
              </div>
            ))}
          </pre>
        )}
      </div>

      <footer className="flex gap-2 border-t border-border bg-muted/40 px-5 py-3">
        <button
          type="button"
          disabled={!!decision}
          onClick={() => decide("rejected")}
          className="h-9 flex-1 rounded-lg border border-border bg-card text-sm font-medium transition hover:bg-muted disabled:opacity-50"
        >
          Reject <kbd className="ml-1 font-mono text-[10px] text-muted-foreground">R</kbd>
        </button>
        <button
          type="button"
          disabled={!!decision}
          onClick={() => decide("approved")}
          className="h-9 flex-1 rounded-lg bg-primary text-sm font-semibold text-primary-foreground transition hover:opacity-90 disabled:opacity-50"
        >
          Approve <kbd className="ml-1 font-mono text-[10px] opacity-60">A</kbd>
        </button>
      </footer>

      <AnimatePresence>
        {decision && (
          <motion.div
            key="stamp"
            role="status"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            transition={{ duration: 0.15 }}
            className="pointer-events-none absolute inset-0 grid place-items-center bg-card/70"
          >
            <motion.span
              initial={reduce ? { opacity: 0, rotate: -8 } : { opacity: 0, scale: 1.6, rotate: -14 }}
              animate={{ opacity: 1, scale: 1, rotate: -8 }}
              transition={{ type: "spring", stiffness: 500, damping: 22 }}
              className={cn(
                "rounded-md border-[3px] bg-card px-4 py-1 font-mono text-xl font-black uppercase tracking-[0.2em]",
                decision === "approved" && "border-ok text-ok",
                decision === "rejected" && "border-destructive text-destructive",
                decision === "expired" && "border-muted-foreground text-muted-foreground",
              )}
            >
              {decision}
            </motion.span>
          </motion.div>
        )}
      </AnimatePresence>
    </motion.section>
  );
}

export default ApprovalCard;

How it behaves

  • →Countdown ring drains over timeout seconds and turns red in the last quarter; at zero the request resolves as expired.
  • →Approve / Reject buttons, plus A and R keyboard shortcuts while focus is inside the card.
  • →After a decision, a rotated rubber-stamp overlay springs in (APPROVED green, REJECTED red, EXPIRED grey) and buttons disable.
  • →Diff lines starting with + are green, - are red, in a monospace block.
  • →onDecision(decision) fires exactly once.

Accessibility

  • ✓Card is a group labelled by its title.
  • ✓Countdown has role=timer and an aria-label with seconds left.
  • ✓Stamp is announced via role=status.
  • ✓Honours prefers-reduced-motion.