{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"action-receipt","type":"registry:component","title":"Action receipts","description":"What the agent actually did, one quiet line each, with Undo while a ring drains. Several stack into one.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/action-receipt.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { Check, ChevronRight, RotateCcw, X } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * ACTION RECEIPTS: what the agent did, and the way back\n *\n *   done      a quiet line per action: what happened, where;\n *             Undo with a ring that drains while you still can,\n *             folding away when the window closes\n *   undoing   the ring blurs into a spinner; \"Undo\" morphs to\n *             \"Undoing\"\n *   undone    a strike draws itself across the line; \"Undone\"\n *   failed    why it failed, and Retry\n *   stacked   three or more fold into \"3 actions · Undo all\",\n *             which opens into the list; the counts roll\n *\n * Undo is one button from first to last: its icon slot and label\n * change, it never gets swapped for another element.\n *\n * Feed it the actions your tools performed; it keeps the timing.\n * ───────────────────────────────────────────────────────── */\n\nexport type ReceiptStatus = \"done\" | \"undoing\" | \"undone\" | \"failed\";\n\nexport interface Receipt {\n  id: string;\n  /** What happened, e.g. \"Added “Soft launch” to Saturday\". */\n  title: string;\n  /** Where or when, e.g. \"Calendar · 9:00 am\". */\n  detail?: string;\n  icon?: React.ReactNode;\n  status?: ReceiptStatus;\n  error?: string;\n  /** Until when it can be undone (ms timestamp). Without it, no Undo. */\n  undoUntil?: number;\n}\n\nexport interface ActionReceiptsProps extends React.HTMLAttributes<HTMLDivElement> {\n  actions: Receipt[];\n  onUndo?: (id: string) => void | Promise<void>;\n  /** Defaults to undoing each undoable action. */\n  onUndoAll?: () => void | Promise<void>;\n  onRetry?: (id: string) => void;\n  /** Fold into a summary from this many actions (0 never folds). */\n  stackFrom?: number;\n  /** How long the undo window is, for the ring (ms). */\n  undoWindow?: number;\n}\n\nconst EASE = \"cubic-bezier(0.23,1,0.32,1)\";\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst LINK = `inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium text-foreground transition-colors hover:bg-accent ${FOCUS}`;\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** A piece of a line that opens out of nothing and folds back into it. */\nfunction Reveal({ show, reduced, className = \"\", children }: { show: boolean; reduced: boolean; className?: string; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!show || undefined}\n      className={`grid ${className}`}\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        filter: show ? \"none\" : \"blur(3px)\",\n        transition: reduced ? \"none\" : `grid-template-columns 420ms ${MORPH}, opacity ${show ? \"300ms\" : \"160ms\"} ${MORPH}, filter 300ms ${MORPH}`,\n      }}\n    >\n      <span className=\"flex min-w-0 items-center whitespace-nowrap [clip-path:inset(-4px_-2px)]\">{children}</span>\n    </span>\n  );\n}\n\n/** Re-renders when the next undo window closes, so Undo disappears on time. */\nfunction useUndoClock(actions: Receipt[]) {\n  const [now, setNow] = React.useState(() => Date.now());\n  React.useEffect(() => {\n    const next = actions.map((a) => a.undoUntil ?? 0).filter((t) => t > now).sort((a, b) => a - b)[0];\n    if (!next) return;\n    const timer = window.setTimeout(() => setNow(Date.now()), next - now + 20);\n    return () => window.clearTimeout(timer);\n  }, [actions, now]);\n  return now;\n}\n\n/** A ring that drains from full to empty over the time left, with CSS only. */\nfunction DrainRing({ until, window: total, reduced }: { until: number; window: number; reduced: boolean }) {\n  const [start] = React.useState(() => Date.now());\n  const left = Math.max(0, until - start);\n  const elapsed = Math.max(0, total - left);\n  const c = 2 * Math.PI * 5.5;\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 14 14\" className=\"size-3.5 -rotate-90\">\n      <circle cx=\"7\" cy=\"7\" r=\"5.5\" fill=\"none\" strokeWidth=\"1.5\" className=\"stroke-border\" />\n      <circle\n        cx=\"7\"\n        cy=\"7\"\n        r=\"5.5\"\n        fill=\"none\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n        strokeDasharray={c}\n        className=\"stroke-foreground/70\"\n        style={\n          reduced\n            ? { strokeDashoffset: c * (elapsed / total) }\n            : ({\n                \"--ui-ring\": `${c}`,\n                animation: `ui-drain ${total}ms linear -${elapsed}ms forwards`,\n              } as React.CSSProperties)\n        }\n      />\n    </svg>\n  );\n}\n\nfunction Line({\n  action,\n  now,\n  pending,\n  undoWindow,\n  reduced,\n  onUndo,\n  onRetry,\n}: {\n  action: Receipt;\n  now: number;\n  pending: boolean;\n  undoWindow: number;\n  reduced: boolean;\n  onUndo?: (id: string) => void;\n  onRetry?: (id: string) => void;\n}) {\n  const status = pending ? \"undoing\" : action.status ?? \"done\";\n  const canUndo = status === \"done\" && Boolean(onUndo) && (action.undoUntil ?? 0) > now;\n  const undone = status === \"undone\";\n  const failed = status === \"failed\";\n\n  // The one button's state: what it says and whether it does anything.\n  const mode = status === \"undoing\" ? \"undoing\" : undone ? \"undone\" : failed && onRetry ? \"retry\" : canUndo ? \"undo\" : null;\n  const [lastMode, setLastMode] = React.useState(mode);\n  if (mode && mode !== lastMode) setLastMode(mode);\n  const shown = mode ?? lastMode;\n  const actionable = mode === \"undo\" || mode === \"retry\";\n  const label = { undo: \"Undo\", undoing: \"Undoing\", undone: \"Undone\", retry: \"Retry\" };\n  const layer = \"absolute inset-0 flex items-center justify-center\";\n\n  return (\n    <li className=\"flex min-h-10 items-center gap-2.5 py-1 animate-[ui-fade-up_320ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none\">\n      <span\n        aria-hidden=\"true\"\n        className={`relative flex size-6 shrink-0 items-center justify-center rounded-full transition-colors duration-300 [&_svg]:size-3.5 [&_svg]:stroke-[1.8] ${\n          failed ? \"bg-red-500/10 text-red-600 dark:text-red-400\" : \"bg-muted text-muted-foreground\"\n        }`}\n      >\n        <span className={layer} style={swap(!failed, reduced)}>\n          {action.icon ?? <Check />}\n        </span>\n        <span className={layer} style={swap(failed, reduced)}>\n          <X />\n        </span>\n      </span>\n      <span className=\"min-w-0 flex-1\">\n        <span className=\"block truncate text-[13px]\">\n          {/* The strike draws itself across the title, left to right. */}\n          <span\n            className={`bg-[linear-gradient(color-mix(in_oklab,currentColor_55%,transparent),color-mix(in_oklab,currentColor_55%,transparent))] bg-no-repeat [background-position:0_58%] ${undone ? \"text-muted-foreground\" : \"text-foreground\"}`}\n            style={{ backgroundSize: undone ? \"100% 1px\" : \"0% 1px\", transition: reduced ? \"none\" : `background-size 480ms ${MORPH} 80ms, color 300ms` }}\n          >\n            {action.title}\n          </span>\n        </span>\n        {(failed ? action.error : action.detail) && (\n          <span className={`block truncate text-[11.5px] transition-colors duration-300 ${failed ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n            {failed ? action.error : action.detail}\n          </span>\n        )}\n      </span>\n\n      <span role=\"status\" className=\"sr-only\">\n        {status === \"undoing\" ? `Undoing ${action.title}` : undone ? `Undone: ${action.title}` : failed ? `Failed: ${action.error ?? action.title}` : \"\"}\n      </span>\n      {shown && (\n        <Reveal show={Boolean(mode)} reduced={reduced} className=\"shrink-0\">\n          <span className=\"block p-0.5\">\n            <button\n              type=\"button\"\n              inert={!mode}\n              aria-disabled={!actionable || undefined}\n              aria-label={mode === \"undo\" ? `Undo: ${action.title}` : undefined}\n              onClick={() => (mode === \"undo\" ? onUndo?.(action.id) : mode === \"retry\" ? onRetry?.(action.id) : undefined)}\n              className={`inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium transition-colors ${FOCUS} ${\n                actionable ? \"text-foreground hover:bg-accent\" : \"cursor-default text-muted-foreground\"\n              }`}\n            >\n              <span aria-hidden=\"true\" className=\"relative flex size-3.5 shrink-0 items-center justify-center\">\n                {/* Keyed by the window, so a fresh window drains from full; paused once it has faded out. */}\n                <span className={`${layer} ${shown === \"undo\" ? \"\" : \"[&_*]:[animation-play-state:paused]\"}`} style={swap(shown === \"undo\", reduced)}>\n                  <DrainRing key={action.undoUntil} until={action.undoUntil ?? 0} window={undoWindow} reduced={reduced} />\n                </span>\n                <span className={layer} style={swap(shown === \"undoing\", reduced)}>\n                  <span className={`size-3 rounded-full border-[1.5px] border-border border-t-foreground/70 motion-reduce:animate-none ${shown === \"undoing\" ? \"animate-spin\" : \"\"}`} />\n                </span>\n                <span className={layer} style={swap(shown === \"undone\" || shown === \"retry\", reduced)}>\n                  <RotateCcw className=\"size-3\" />\n                </span>\n              </span>\n              <TextMorph>{label[shown]}</TextMorph>\n            </button>\n          </span>\n        </Reveal>\n      )}\n    </li>\n  );\n}\n\nexport function ActionReceipts({\n  actions,\n  onUndo,\n  onUndoAll,\n  onRetry,\n  stackFrom = 3,\n  undoWindow = 10_000,\n  className = \"\",\n  ...props\n}: ActionReceiptsProps) {\n  const reduced = useReducedMotion();\n  const now = useUndoClock(actions);\n  const [pending, setPending] = React.useState<Set<string>>(() => new Set());\n  const [open, setOpen] = React.useState(false);\n  const listId = React.useId();\n\n  const undo = async (id: string) => {\n    if (!onUndo) return;\n    setPending((p) => new Set(p).add(id));\n    try {\n      await onUndo(id);\n    } finally {\n      setPending((p) => {\n        const next = new Set(p);\n        next.delete(id);\n        return next;\n      });\n    }\n  };\n\n  const undoable = actions.filter((a) => (a.status ?? \"done\") === \"done\" && (a.undoUntil ?? 0) > now && !pending.has(a.id));\n  const undoAll = async () => {\n    if (onUndoAll) {\n      setPending(new Set(undoable.map((a) => a.id)));\n      try {\n        await onUndoAll();\n      } finally {\n        setPending(new Set());\n      }\n    } else await Promise.all(undoable.map((a) => undo(a.id)));\n  };\n\n  const stacked = stackFrom > 0 && actions.length >= stackFrom;\n  const failed = actions.filter((a) => a.status === \"failed\").length;\n  const undone = actions.filter((a) => a.status === \"undone\").length;\n  const busy = pending.size > 0;\n\n  const lines = (\n    <ul aria-label=\"Actions taken\">\n      {actions.map((a) => (\n        <Line key={a.id} action={a} now={now} pending={pending.has(a.id)} undoWindow={undoWindow} reduced={reduced} onUndo={onUndo ? undo : undefined} onRetry={onRetry} />\n      ))}\n    </ul>\n  );\n\n  if (!stacked) {\n    return (\n      <div className={`w-full ${className}`} {...props}>\n        {lines}\n      </div>\n    );\n  }\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      <div className=\"flex min-h-10 items-center gap-2.5\">\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          aria-controls={listId}\n          onClick={() => setOpen((o) => !o)}\n          className=\"group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring/40\"\n        >\n          <span aria-hidden=\"true\" className=\"flex shrink-0 items-center\">\n            {actions.slice(0, 3).map((a, i) => (\n              <span\n                key={a.id}\n                className={`flex size-6 items-center justify-center rounded-full ring-2 ring-background [&_svg]:size-3.5 [&_svg]:stroke-[1.8] ${i ? \"-ml-2\" : \"\"} ${\n                  a.status === \"failed\" ? \"bg-red-500/10 text-red-600 dark:text-red-400\" : \"bg-muted text-muted-foreground\"\n                }`}\n                style={{ zIndex: 3 - i }}\n              >\n                {a.status === \"failed\" ? <X /> : a.icon ?? <Check />}\n              </span>\n            ))}\n          </span>\n          {/* \"4 actions · 1 failed · 2 undone\": counts roll, and the extra parts open in as they apply. */}\n          <span className=\"flex min-w-0 items-baseline truncate text-[13px] text-foreground\">\n            <NumberRoll value={actions.length} duration={600} />\n            &nbsp;\n            <TextMorph>{actions.length === 1 ? \"action\" : \"actions\"}</TextMorph>\n            <Reveal show={failed > 0} reduced={reduced}>\n              &nbsp;·&nbsp;\n              <NumberRoll value={failed} duration={600} />\n              &nbsp;failed\n            </Reveal>\n            <Reveal show={undone > 0} reduced={reduced}>\n              &nbsp;·&nbsp;\n              <NumberRoll value={undone} duration={600} />\n              &nbsp;undone\n            </Reveal>\n          </span>\n          <ChevronRight\n            aria-hidden=\"true\"\n            className=\"size-3.5 shrink-0 text-muted-foreground transition-transform duration-300 group-hover:text-foreground motion-reduce:transition-none\"\n            style={{ transform: open ? \"rotate(90deg)\" : \"none\", transitionTimingFunction: EASE }}\n          />\n        </button>\n        {onUndo && (\n          // Opens while there's more than one thing to undo; says what it's doing while it does it.\n          <Reveal show={undoable.length > 1 || busy} reduced={reduced} className=\"shrink-0\">\n            <span className=\"block p-0.5\">\n              <button\n                type=\"button\"\n                inert={!(undoable.length > 1 || busy)}\n                onClick={() => !busy && undoAll()}\n                aria-disabled={busy || undefined}\n                className={`${LINK} ${busy ? \"cursor-default text-muted-foreground hover:bg-transparent\" : \"\"}`}\n              >\n                <span aria-hidden=\"true\" className=\"relative flex size-3 shrink-0 items-center justify-center\">\n                  <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(!busy, reduced)}>\n                    <RotateCcw className=\"size-3\" />\n                  </span>\n                  <span className=\"absolute inset-0 flex items-center justify-center\" style={swap(busy, reduced)}>\n                    <span className={`size-3 rounded-full border-[1.5px] border-border border-t-foreground/70 motion-reduce:animate-none ${busy ? \"animate-spin\" : \"\"}`} />\n                  </span>\n                </span>\n                <TextMorph>{busy ? \"Undoing all\" : \"Undo all\"}</TextMorph>\n              </button>\n            </span>\n          </Reveal>\n        )}\n      </div>\n      <div\n        id={listId}\n        inert={!open}\n        className=\"grid\"\n        style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 380ms ${EASE}` }}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <div className=\"ml-3 border-l border-border pl-[18px]\">{lines}</div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-fade-up":{"from":{"opacity":"0","transform":"translateY(4px)"},"to":{"opacity":"1","transform":"none"}},"@keyframes ui-drain":{"from":{"stroke-dashoffset":"0"},"to":{"stroke-dashoffset":"var(--ui-ring)"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/action-receipt"}