{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "run-card",
  "type": "registry:ui",
  "title": "Run card",
  "description": "An agent's run as a card: what it is making, how far it has got in words (Step 2 of 3) and in a ring, and its steps. Hide folds it into a raised block you press to open again; the ring travels between the two while the words glide, and the height snaps. A polite status keeps saying the step while the card is folded.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/plan.json",
    "https://littmd.vercel.app/r/rolling-number.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/run-card.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/run-card 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Run card: one agent run, such as \"Creating your world\", as a card that says what it is making, how far it has got\n * in words and in a ring, and which step it is on, and that folds into a block you press to open it again\n * (components.md, \"Run card\"). The steps are a Plan's; the card is the frame a run gets when it is the thing on\n * screen, and the block is the same run put aside while you keep working.\n *\n * - Anatomy, open: a flat card in the flow (litt-raised at the surface radius, padded 16px; never glass, R3) that fills\n *   its container. A header row: the title (body, the title weight, --litt-fg) over the words (small, --litt-fg-muted), then the\n *   ring, the figure (body, --litt-fg, tabular), the caller's `actions` and Hide (a ghost button with a chevron, 44px on a\n *   finger). On a narrow card (a container query) the header is two rows: the title and the buttons, then the ring,\n *   the words and the figure. Under it \"Activity\" (caption, --litt-fg-subtle) over the steps, a bare Plan (its marks, tones\n *   and hidden words; the card says the count and keeps the live region), and the `preview` beside them once the card\n *   is wide enough, under them when it is not.\n * - Flat: the open card is a grey card (--litt-surface-3) with no hairline; the folded block is the grey bubble the tokens give.\n * - Anatomy, folded: the same box as the raised block (R3: --litt-block, --litt-block-lift, the control radius, md height, the\n *   width of its words and never wider than its container): a button with aria-expanded=\"false\" holding the ring,\n *   the title over the words (the card's sizes on 16px lines, the title cut at the end), the figure and Expand with a\n *   chevron. On a phone the word Expand is for assistive tech only, so the title keeps the room; the chevron stays.\n *   The whole block is the button, so a finger anywhere on it opens the card.\n * - Words (R5): from the steps, never a dot or a color alone. A running step: \"Step 2 of 3\". A failed step: \"Step 2\n *   failed\". Every step done or skipped, one at least done: \"Done\". Nothing running: \"Waiting\". Never \"2/3\", never\n *   trailing dots.\n * - The ring is the Gauge's ticks, closed: 32 two-pixel ticks round a 32px ring, from the top and clockwise, lit in\n *   --litt-fg up to the figure and --litt-fg at 12% after it. The ticks are short (2.5px) on a wide radius, a band of marks, so\n *   the ring never reads as a loading spinner's spokes. Progress is one quantity, so it takes no hue (R7); a failed\n *   run's lit ticks are --litt-danger, a status mark, as the Plan's x is. At least one tick is lit above zero, and the ring\n *   is full only at 100%. Decorative: the words and the figure say the same.\n * - The figure is `progress` (0 to 1) as a whole percent, rounded down so it never says more than the run has done,\n *   else the steps done over all the steps; a finished run is 100%. It rolls (Rolling number) when it changes, and\n *   its box is as wide as \"100%\", so nothing beside it shifts.\n * - Hand: the moment is a press, on Hide or on the block. The material swaps at once, as a press does, and the parts\n *   keep their places by transform: the ring travels between its two seats (a shared layoutId on a text-free part, as\n *   the Segmented's block does), the title, the words and the figure glide by layout=\"position\" (text never\n *   stretches), all on the responsive tier. The box itself glides by its corner only (layout=\"position\": its size\n *   snaps, its radius and shadow are never scaled), so in a parent that centers it the parts stay inside it. Folding,\n *   the steps leave on the exit token first, then the box becomes the block; opening, the card is back at once and\n *   the steps come in with a quarter of the travel token. The height snaps, as every disclosure's does. Press again\n *   mid-way and it turns round.\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on): the ring travels on fun mode's swap spring and\n *   settles a hair past its seat (the glide spring, tuned for a segment's short hop, carries a ring crossing a card\n *   about 20px past and out of the block), and its ticks sweep to each new figure on the grow spring while the figure\n *   counts with them, as the Gauge's do, never past the figure. At rest it is the Calm card exactly; reduced motion\n *   turns it off.\n * - Keys and assistive tech: a group named by the title. Hide and the block are buttons with aria-expanded; Hide is\n *   named \"Hide Creating your world\", the block by what it shows (\"Creating your world Step 1 of 3 12% Expand\").\n *   Enter or Space folds and opens it. When the button you pressed had focus, focus moves to the one that replaces\n *   it (Tool approval's pattern), so a keyboard never falls back to the page. The steps are a list named \"Activity\".\n *   One polite status, outside the card and the block, says \"Step 2 of 3: Populating your world\", \"Step 2 failed:\n *   Populating your world\" and \"Done: Creating your world\" when they change, open or folded, and never on the first\n *   render.\n * - Motion: only transform and opacity move. The ticks and the figure change at once in Calm; colors fade on the\n *   instant tier; the block sinks --litt-press-depth and its cast tightens (--litt-block-press) at once while pressed.\n * - Touch: the block is 44px tall and Hide grows to 44px on a finger (useDevice). A pressed block takes its hover\n *   fill, so a press still shows under reduced motion.\n * - Disabled: nothing to disable. The run is the agent's; Hide and Expand always work.\n * - Forced colors: the block keeps a border, the lit ticks take CanvasText and the rest GrayText.\n */\nimport { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from \"react\";\nimport { AnimatePresence, LayoutGroup, motion, useMotionValueEvent } from \"motion/react\";\nimport { Button } from \"./button\";\nimport { ChevronDownIcon, ChevronUpIcon } from \"./icons\";\nimport { Plan, type PlanStep } from \"./plan\";\nimport { RollingNumber } from \"./rolling-number\";\nimport { countText, useDevice, useExit, useFun, useFunValue, useTransition, useTravel } from \"../../lib/litt/motion\";\n\nexport type RunCardProps = {\n  /** What the run is making, in a few words, sentence case and no trailing dots: \"Creating your world\". It titles the card and names its buttons. */\n  title: string;\n  /** The run's steps in order, as Plan takes them: a label, a status and an id, so a step keeps its place when the run changes. Update a status in place as the agent works. */\n  steps: PlanStep[];\n  /** How far the whole run has got, from 0 to 1, when the agent knows more than the steps say (0.12 is 12% into the first of three). Without it, the steps done over all the steps. */\n  progress?: number;\n  /** What the run is making so far, such as an image or a live preview. It sits beside the steps while the card is open, under them when the card is narrow, and is gone while folded. */\n  preview?: ReactNode;\n  /** Controls at the end of the header while the card is open, before Hide, such as a ghost sm Stop or Retry button. */\n  actions?: ReactNode;\n  /** Whether the card is open, when you control it. Pair it with onOpenChange. */\n  open?: boolean;\n  /** Whether it starts open when you do not control it. */\n  defaultOpen?: boolean;\n  /** Called with the next state when Hide or the folded block is pressed. */\n  onOpenChange?: (open: boolean) => void;\n  /** Extra classes for the box, open or folded, such as a max width. The open card fills its container; the folded block is the width of its words. */\n  className?: string;\n};\n\n/* ----------------------------------------------------------------- what the steps say */\n\ntype Reading = {\n  /** \"Step 2 of 3\", \"Step 2 failed\", \"Done\" or \"Waiting\". */\n  words: string;\n  /** The step the words are about: the failed one, else the running one, else none. */\n  current: PlanStep | null;\n  /** 0 to 1: the progress, else the steps done over all the steps; 1 once the run is done. */\n  share: number;\n  failed: boolean;\n};\n\n/** The figure's words, as Rolling number writes them. */\nconst PERCENT = new Intl.NumberFormat(\"en-GB\", { style: \"percent\" });\n\nconst clamp01 = (n: number) => (n > 0 ? Math.min(1, n) : 0);\n\n/** What the steps say about the run. One reading, so the card, the block and the live region never disagree. */\nfunction readRun(steps: readonly PlanStep[], progress: number | undefined): Reading {\n  const total = steps.length;\n  const done = steps.filter((s) => s.status === \"done\").length;\n  const finished = done > 0 && steps.every((s) => s.status === \"done\" || s.status === \"skipped\");\n  const share = finished ? 1 : clamp01(progress ?? (total ? done / total : 0));\n  const failedAt = steps.findIndex((s) => s.status === \"failed\");\n  if (failedAt >= 0) return { words: `Step ${failedAt + 1} failed`, current: steps[failedAt], share, failed: true };\n  const runningAt = steps.findIndex((s) => s.status === \"running\");\n  if (runningAt >= 0) return { words: `Step ${runningAt + 1} of ${total}`, current: steps[runningAt], share, failed: false };\n  return { words: finished ? \"Done\" : \"Waiting\", current: null, share, failed: false };\n}\n\n/* ----------------------------------------------------------------- the ring */\n\n/* Short ticks on a wide radius, so the ring reads as a band of marks and never as a loading spinner's spokes. */\nconst TICKS = 32;\nconst RING = 32; // px, the ring's box\nconst OUTER = 15; // a tick runs from INNER to OUTER from the center, so its square end stays inside the box\nconst INNER = 12.5;\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n/** Tick i stands for the i-th of TICKS equal parts of the run, centered in it, from the top and clockwise. */\nconst TICK_PATHS = Array.from({ length: TICKS }, (_, i) => {\n  const angle = (2 * Math.PI * (i + 0.5)) / TICKS;\n  const sin = Math.sin(angle);\n  const cos = Math.cos(angle);\n  const c = RING / 2;\n  return `M${round(c + INNER * sin)} ${round(c - INNER * cos)}L${round(c + OUTER * sin)} ${round(c - OUTER * cos)}`;\n});\nconst ticks = (from: number, to: number) => TICK_PATHS.slice(from, to).join(\"\");\n\n/** How many ticks a share lights: none at zero, at least one above it, all of them only when the run is done. */\nconst litFor = (share: number) => (share <= 0 ? 0 : share >= 1 ? TICKS : Math.min(TICKS - 1, Math.max(1, Math.round(share * TICKS))));\n\nconst REST = \"stroke-[color-mix(in_oklab,var(--litt-fg)_12%,transparent)] forced-colors:stroke-[GrayText]\";\nconst LIT = \"stroke-litt-fg forced-colors:stroke-[CanvasText]\";\nconst LIT_FAILED = \"stroke-litt-danger forced-colors:stroke-[CanvasText]\";\nconst TICK_FADE = \"transition-colors duration-(--litt-dur-instant) ease-litt\";\n\n/** The ring: `lit` ticks in --litt-fg (--litt-danger when the run failed) and the rest at 12%. Decorative. */\nfunction Ring({ lit, failed }: { lit: number; failed: boolean }) {\n  return (\n    <svg width={RING} height={RING} viewBox={`0 0 ${RING} ${RING}`} aria-hidden=\"true\" focusable=\"false\" className=\"block shrink-0\">\n      <g fill=\"none\" strokeWidth={2} strokeLinecap=\"butt\">\n        {lit < TICKS && <path d={ticks(lit, TICKS)} className={REST} />}\n        {lit > 0 && <path d={ticks(0, lit)} className={(failed ? LIT_FAILED : LIT) + \" \" + TICK_FADE} />}\n      </g>\n    </svg>\n  );\n}\n\n/* ----------------------------------------------------------------- the box's two materials */\n\n/** Folded: the raised block, a button the width of its words and never wider than its container, that sinks when pressed (Button's secondary). */\nconst BLOCK =\n  \"group/block flex h-litt-control-md w-fit max-w-full cursor-pointer items-center gap-3 rounded-litt-control bg-litt-block pr-litt-control-pad-sm pl-2 text-left \" +\n  \"shadow-(--litt-block-lift) select-none [-webkit-tap-highlight-color:transparent] forced-colors:border \" +\n  \"transition-[background-color,translate] duration-(--litt-dur-instant) ease-litt \" +\n  \"hover:bg-(--litt-block-hover) pointer-coarse:active:bg-(--litt-block-hover) \" +\n  \"active:translate-y-(--litt-press-depth) active:shadow-(--litt-block-press) \" +\n  \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\";\n\n/* ----------------------------------------------------------------- RunCard */\n\n/** An agent run as a card with a ring, its words and its steps, and Hide; folded, the same run as a raised block with Expand. */\nexport function RunCard({\n  title,\n  steps,\n  progress,\n  preview,\n  actions,\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  className = \"\",\n}: RunCardProps) {\n  const id = useId();\n  const titleId = `${id}-title`;\n  const bodyId = `${id}-body`;\n  const { coarse } = useDevice();\n\n  const [inner, setInner] = useState(defaultOpen);\n  const open = openProp ?? inner;\n\n  /* What is on screen. Opening shows the card at once; folding keeps the card until its steps have left, then shows\n     the block. State that follows a prop, set while rendering (React's pattern), so the card is back in the same render. */\n  const [shown, setShown] = useState<\"card\" | \"block\">(open ? \"card\" : \"block\");\n  if (open && shown === \"block\") setShown(\"card\");\n  const openNow = useRef(open);\n  openNow.current = open;\n\n  const run = readRun(steps, progress);\n  const percent = Math.floor(run.share * 100 + 1e-9);\n  const lit = litFor(run.share);\n\n  /* Motion: the parts glide on the responsive tier; in fun mode the ring travels on the swap spring (see the header). */\n  const fun = useFun();\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n  const travel = useTravel();\n  const ringMove = fun ? fun.swap : enter;\n\n  /* Fun mode: the ticks sweep and the figure counts (the Gauge's recipe). Held here, not in the ring, so the sweep\n     survives the swap between card and block and never replays its arrival on a fold. */\n  const sweep = useFunValue(lit, { count: percent });\n  const [swept, setSwept] = useState(lit);\n  useMotionValueEvent(sweep.value, \"change\", (t) => setSwept(Math.max(0, Math.min(TICKS, Math.round(t)))));\n  /* Counting from the render the target changes in, not from the sweep's first frame, so the new figure never\n     flashes before the sweep sets off. */\n  const counting = !!fun && (sweep.moving || swept !== lit);\n  const ringLit = counting ? swept : lit;\n  const figure = counting ? (\n    <span className=\"tabular-nums\">{countText(PERCENT.format(percent / 100), percent, sweep.shown)}</span>\n  ) : (\n    <RollingNumber value={percent / 100} format={{ style: \"percent\" }} />\n  );\n\n  /* The body enters with a drop only after the first render: a card that loads open just appears. Not through\n     AnimatePresence initial={false}, which would swallow the Plan's own entrances (see tool-call.tsx). */\n  const mounted = useRef(false);\n  useEffect(() => {\n    mounted.current = true;\n  }, []);\n\n  /* The button pressed is replaced by its opposite, which takes focus when the pressed one had it. */\n  const hadFocus = useRef(false);\n  const hideRef = useRef<HTMLButtonElement>(null);\n  const blockRef = useRef<HTMLButtonElement>(null);\n  useEffect(() => {\n    if (!hadFocus.current) return;\n    hadFocus.current = false;\n    (shown === \"block\" ? blockRef : hideRef).current?.focus({ preventScroll: true });\n  }, [shown]);\n  const toggle = (e: MouseEvent<HTMLButtonElement>) => {\n    hadFocus.current = e.currentTarget === document.activeElement;\n    const next = !open;\n    if (openProp === undefined) setInner(next);\n    onOpenChange?.(next);\n  };\n\n  /* One voice, open or folded: the words and the step they are about, when either changes. Quiet on the first render. */\n  const [said, setSaid] = useState(\"\");\n  const heard = useRef<string | null>(null);\n  const line = `${run.words}: ${run.current ? run.current.label : title}`;\n  useEffect(() => {\n    const before = heard.current;\n    heard.current = line;\n    if (before !== null && before !== line) setSaid(line);\n  }, [line]);\n\n  /* The ring and the figure are the same parts in both seats; `place` puts them in the card's header grid. */\n  const ring = (place: string) => (\n    <motion.span layoutId=\"ring\" transition={ringMove} className={\"block shrink-0 \" + place}>\n      <Ring lit={ringLit} failed={run.failed} />\n    </motion.span>\n  );\n  const figureBox = (place: string) => (\n    <motion.span layoutId=\"figure\" layout=\"position\" transition={enter} className={\"inline-grid shrink-0 text-litt-body tabular-nums text-litt-fg \" + place}>\n      {/* As wide as the widest figure, so a count or a roll never moves its neighbours. */}\n      <span aria-hidden=\"true\" className=\"invisible col-start-1 row-start-1\">\n        {PERCENT.format(1)}\n      </span>\n      <span className=\"col-start-1 row-start-1\">{figure}</span>\n    </motion.span>\n  );\n\n  return (\n    /* The root fills its container whatever the parent's alignment: the open card measures itself (a container\n       query), so it has no width of its own to shrink to. The folded block inside stays the width of its words. */\n    <div className=\"w-full min-w-0\">\n      <LayoutGroup id={id}>\n        {shown === \"card\" ? (\n          /* The box keeps its place by transform too (its corner, never its size), so in a parent that centers it the\n             parts still glide inside it. */\n          <motion.div\n            layoutId=\"box\"\n            layout=\"position\"\n            transition={enter}\n            role=\"group\"\n            aria-labelledby={titleId}\n            className={\n              \"litt-raised @container grid gap-4 p-4 in-[[data-material=flat]]:bg-litt-surface-3 in-[[data-material=flat]]:shadow-none in-[[data-material=flat]]:forced-colors:border \" +\n              className\n            }\n          >\n            {/* One row when the card has room: the title over the words, then the ring, the figure and the buttons.\n                Narrow, two: the title and the buttons, then the ring, the words and the figure in a row of their own\n                (a wrapper that is `contents` when wide, so its parts take the grid's columns). */}\n            <div className=\"grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 @sm:grid-cols-[minmax(0,1fr)_auto_auto_auto] @sm:gap-y-0\">\n              <motion.p layoutId=\"title\" layout=\"position\" transition={enter} id={titleId} className=\"row-start-1 text-litt-body font-litt-title text-litt-fg\">\n                {title}\n              </motion.p>\n              <div className=\"col-span-2 row-start-2 flex items-center gap-3 @sm:contents\">\n                {ring(\"@sm:col-start-2 @sm:row-span-2 @sm:row-start-1\")}\n                <motion.p\n                  layoutId=\"words\"\n                  layout=\"position\"\n                  transition={enter}\n                  className=\"w-fit text-litt-small whitespace-nowrap text-litt-fg-muted @sm:col-start-1 @sm:row-start-2\"\n                >\n                  {run.words}\n                </motion.p>\n                {figureBox(\"@sm:col-start-3 @sm:row-span-2 @sm:row-start-1\")}\n              </div>\n              <div className=\"col-start-2 row-start-1 -mr-2 flex items-center gap-1 @sm:col-start-4 @sm:row-span-2\">\n                {actions}\n                <Button\n                  ref={hideRef}\n                  variant=\"ghost\"\n                  size={coarse ? \"md\" : \"sm\"}\n                  aria-expanded={open}\n                  aria-controls={open ? bodyId : undefined}\n                  aria-label={`Hide ${title}`}\n                  iconRight={<ChevronDownIcon />}\n                  onClick={toggle}\n                >\n                  Hide\n                </Button>\n              </div>\n            </div>\n            <AnimatePresence onExitComplete={() => !openNow.current && setShown(\"block\")}>\n              {open && (\n                <motion.div\n                  key=\"body\"\n                  id={bodyId}\n                  initial={mounted.current ? { opacity: 0, y: -travel / 4 } : false}\n                  animate={{ opacity: 1, y: 0, transition: enter }}\n                  exit={{ opacity: 0, transition: exit }}\n                >\n                  <div className=\"grid gap-4 @md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]\">\n                    <div className=\"grid content-start gap-1\">\n                      <p aria-hidden=\"true\" className=\"text-litt-caption text-litt-fg-subtle\">\n                        Activity\n                      </p>\n                      <Plan label=\"Activity\" steps={steps} bare />\n                    </div>\n                    {preview != null && preview !== false && <div className=\"min-w-0\">{preview}</div>}\n                  </div>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </motion.div>\n        ) : (\n          <motion.button\n            ref={blockRef}\n            layoutId=\"box\"\n            layout=\"position\"\n            transition={enter}\n            type=\"button\"\n            aria-expanded={false}\n            onClick={toggle}\n            className={BLOCK + \" \" + className}\n          >\n            {ring(\"\")}\n            {/* The title over the words, as in the card, so the two glide together and never cross. The same sizes on\n                16px lines, so the pair fits the block and no glyph changes size on the way. */}\n            <span className=\"grid min-w-0\">\n              <motion.span layoutId=\"title\" layout=\"position\" transition={enter} className=\"truncate text-litt-body/4 font-litt-title text-litt-fg\">\n                {title}\n              </motion.span>\n              <motion.span layoutId=\"words\" layout=\"position\" transition={enter} className=\"truncate text-litt-small/4 text-litt-fg-muted\">\n                {run.words}\n              </motion.span>\n            </span>\n            {figureBox(\"\")}\n            <span\n              className={\n                \"flex shrink-0 items-center gap-1 text-litt-small font-litt-title text-litt-fg-muted transition-colors duration-(--litt-dur-instant) ease-litt \" +\n                \"group-hover/block:text-litt-fg pointer-coarse:group-active/block:text-litt-fg\"\n              }\n            >\n              {/* On a phone the word gives its room to the title; the chevron stays, and the block is still named \"Expand\". */}\n              <span className=\"max-sm:sr-only\">Expand</span>\n              <ChevronUpIcon />\n            </span>\n          </motion.button>\n        )}\n      </LayoutGroup>\n      <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {said}\n      </div>\n    </div>\n  );\n}\n",
      "target": "components/litt/run-card.tsx"
    }
  ],
  "docs": "Import RunCard from components/litt/run-card. Give it a title and the run's steps as Plan takes them (label, status, an id), and optionally progress from 0 to 1, a preview, actions such as a Stop button, and open with onOpenChange. Update steps in place as the agent works. A plan with no run behind it is a Plan.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
