{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "plan",
  "type": "registry:ui",
  "title": "Plan",
  "description": "An agent's steps for a task, ticked off as it works: the label and a mono count (\"3 of 7\") over a 4px rail in the foreground, then an ordered list of steps, each with a 16px status mark. The running step is a half-filled circle that never spins, a done step steps its color back and is never struck through, a failed step has a danger x and says Failed, a skipped one is a dashed circle that says Skipped, and any step can carry a detail line. A polite live region says which step started and when the plan is done. Statuses swap at once, and a step added later fades in.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/plan.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/plan 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Plan: an agent's steps for a task, ticked off as it works, such as \"Move the Northwind site to the new grid\".\n * It sits bare in a turn beside the turn's other parts, or in a raised panel the caller draws beside the chat.\n *\n * - Header (components.md, Plan): the label at the small size in fg and the count in a tabular caption in fg-subtle\n *   with tabular numbers (\"3 of 7\"), over a 4px rail drawn exactly as the Meter's: a track at fg 12% and the done\n *   share in fg, both at the control radius. Progress is one quantity, not a category, so the rail takes no hue\n *   (R7), and its fill moves at once, as the Slider's range does. The Meter itself is not imported: it is a\n *   role=\"meter\" and the list is already named by the label, so the rail is decoration here, and a plan should not\n *   pull in the whole chart file.\n * - What counts as done: the count is the steps whose status is done, over all the steps, and the rail is the same\n *   share. A failed step has not been done and a skipped step never will be, so neither counts. A plan with one\n *   skipped step ends at \"6 of 7\", and the step says \"Skipped\" in words, so the number is never read as a miss.\n * - Steps: an ordered list named by the label, rows at least 32px tall (the Timeline's row), each the 16px\n *   StatusIcon and the label at the small size. Pending is fg-subtle. Running, the current step, is fg with the\n *   half-filled circle, never spinning or pulsing, and carries aria-current=\"step\". Done is fg-muted with the check,\n *   never struck through: the color steps it back. Failed takes the x in danger, its label stays fg, and \"Failed\"\n *   follows in words. Skipped is the dashed circle in fg-subtle with \"Skipped\". Status color stays on the icon.\n * - Flat: a grey card at the surface radius with no frame. A done step is a round ink mark with a --litt-canvas tick, the\n *   running step an ink half-filled ring, and the rail's fill the ink.\n * - Detail: a step can carry one line under it at the caption size in fg-subtle, in the label's column.\n * - Words: every status is also said in hidden text after the label (\"Run the build, running\"), and the visible\n *   \"Failed\" and \"Skipped\" are hidden from assistive tech so the status is said once.\n * - Bare (`bare`): only the list, for a parent that draws its own header, count and live region (the Run card). The\n *   label still names the list, out of sight.\n * - Live region: a polite status says \"Step 4 of 7: Run the build\" when the running step changes, \"Plan done\" when\n *   every step is done or skipped (nothing is left to run), and \"Step 4 of 7 failed: Run the build\" when a step\n *   fails, so a stalled plan is never silent. It never speaks on the first render, so a plan loaded from history\n *   stays quiet.\n * - Motion: none on the steps. A status swaps at once and its colors fade on the instant tier. A step added when the\n *   plan changes fades in on the responsive tier with a quarter of the travel token, and never moves the steps\n *   above it. Steps present on the first render do not animate. Keys come from `id`, so give every step an id when\n *   the plan can change; without ids the position is the key. A removed step is gone at once.\n */\nimport { useEffect, useId, useRef, useState, type ReactNode } from \"react\";\nimport { motion } from \"motion/react\";\nimport { CheckIcon, StatusIcon, type StepStatus } from \"./icons\";\nimport { useTransition, useTravel } from \"../../lib/litt/motion\";\n\n/** One step of a plan. */\nexport type PlanStep = {\n  /** A stable id, so a step keeps its place and only a new one fades in when the plan changes. Without it the step's position is its key. */\n  id?: string;\n  /** What the step does, in sentence case: \"Run the build\". It wraps in a narrow column and it is what a screen reader hears when the step starts. */\n  label: string;\n  /** How the step stands: pending, running (the current step), done, failed or skipped. The icon, the label's color and a hidden word say it. */\n  status: StepStatus;\n  /** One line under the label at the caption size, such as why a step failed: \"Build failed: font file missing\". */\n  detail?: ReactNode;\n};\n\nexport type PlanProps = {\n  /** What the plan is for, in a few words: \"Move the Northwind site to the new grid\". It is shown at the top and names the list for assistive tech. */\n  label: string;\n  /** The steps in order. Give each a stable id when steps can be added later, and update a step's status in place as the agent works. */\n  steps: PlanStep[];\n  /** Draws only the list, with no label row, count, rail or live region, for a parent that says all of them itself (a Run card). The label still names the list. */\n  bare?: boolean;\n  /** Extra classes for the plan, such as a width. It fills its container. */\n  className?: string;\n};\n\n/* ----------------------------------------------------------------- lookups */\n\n/** The icon takes the text color, so the tone is set here: running fg, done fg-muted, failed danger, the rest fg-subtle. */\nconst ICON_TONE: Record<StepStatus, string> = {\n  pending: \"text-litt-fg-subtle\",\n  running: \"text-litt-fg in-[[data-material=flat]]:text-litt-fg\",\n  done: \"text-litt-fg-muted\",\n  failed: \"text-litt-danger\",\n  skipped: \"text-litt-fg-subtle\",\n};\n\n/** The label steps its color for state. A failed step's label stays fg: the x and the word say it. */\nconst LABEL_TONE: Record<StepStatus, string> = {\n  pending: \"text-litt-fg-subtle\",\n  running: \"text-litt-fg\",\n  done: \"text-litt-fg-muted\",\n  failed: \"text-litt-fg\",\n  skipped: \"text-litt-fg-subtle\",\n};\n\n/** What a screen reader hears after the label. */\nconst SAID: Record<StepStatus, string> = {\n  pending: \"pending\",\n  running: \"running\",\n  done: \"done\",\n  failed: \"failed\",\n  skipped: \"skipped\",\n};\n\n/** The words that follow the label on screen. Pending, running and done say it with their color and mark alone, and hidden text. */\nconst SHOWN: Partial<Record<StepStatus, string>> = { failed: \"Failed\", skipped: \"Skipped\" };\n\n/** The Meter's track and fill. */\nconst TRACK = \"bg-[color-mix(in_oklab,var(--litt-fg)_12%,transparent)]\";\n\nconst FADE = \"transition-[color] duration-(--litt-dur-instant) ease-litt\";\n\n/* ----------------------------------------------------------------- what to announce */\n\ntype Reading = { running: string | null; finished: boolean; failed: Set<string> };\n\n/** The steps' keys: the id, or the position when a step has none. */\nconst keysOf = (steps: PlanStep[]) => steps.map((s, i) => s.id ?? `#${i}`);\n\nfunction read(steps: PlanStep[], keys: string[]): Reading {\n  const running = steps.findIndex((s) => s.status === \"running\");\n  return {\n    running: running < 0 ? null : keys[running],\n    /* Nothing is left to run and nothing failed: every step is done or skipped, and at least one is done. */\n    finished: steps.some((s) => s.status === \"done\") && steps.every((s) => s.status === \"done\" || s.status === \"skipped\"),\n    failed: new Set(keys.filter((_, i) => steps[i].status === \"failed\")),\n  };\n}\n\n/* ----------------------------------------------------------------- Plan */\n\n/** An agent's steps as an ordered list under a count and a thin rail. A polite live region says when the running step changes, when a step fails and when the plan is done. */\nexport function Plan({ label, steps, bare = false, className = \"\" }: PlanProps) {\n  const id = useId();\n  const enter = useTransition(\"responsive\");\n  const travel = useTravel();\n  const keys = keysOf(steps);\n\n  const total = steps.length;\n  const done = steps.filter((s) => s.status === \"done\").length;\n  const share = total > 0 ? (done / total) * 100 : 0;\n\n  /* Which steps were on screen at the last commit, so only a step that arrives later fades in. The first render\n     marks every step as seen, so a plan that loads full (or renders on the server) never animates. */\n  const seen = useRef<Set<string> | null>(null);\n  if (seen.current === null) seen.current = new Set(keys);\n  const shown = seen.current;\n  useEffect(() => {\n    seen.current = new Set(keys);\n  });\n\n  /* What the live region says. It stays empty on the first render, and only a change fills it. */\n  const [said, setSaid] = useState(\"\");\n  const last = useRef<Reading | null>(null);\n  useEffect(() => {\n    const now = read(steps, keys);\n    const before = last.current;\n    last.current = now;\n    if (!before) return;\n    const lines: string[] = [];\n    const failedAt = keys.findIndex((k) => now.failed.has(k) && !before.failed.has(k));\n    if (failedAt >= 0) lines.push(`Step ${failedAt + 1} of ${total} failed: ${steps[failedAt].label}`);\n    if (now.finished && !before.finished) {\n      lines.push(\"Plan done\");\n    } else if (now.running !== null && now.running !== before.running) {\n      const at = keys.indexOf(now.running);\n      lines.push(`Step ${at + 1} of ${total}: ${steps[at].label}`);\n    }\n    if (lines.length) setSaid(lines.join(\". \"));\n  });\n\n  return (\n    <div className={(bare ? \"\" : \"in-[[data-material=flat]]:rounded-litt-surface in-[[data-material=flat]]:bg-litt-surface-3 in-[[data-material=flat]]:p-4 in-[[data-material=flat]]:forced-colors:border \") + className}>\n      {bare ? (\n        <span id={id} className=\"sr-only\">\n          {label}\n        </span>\n      ) : (\n        <div className=\"grid gap-2\">\n          <div className=\"flex items-baseline justify-between gap-4\">\n            <span id={id} className=\"text-litt-small font-litt-title text-litt-fg\">\n              {label}\n            </span>\n            <span className=\"text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n              {done} of {total}\n              <span className=\"sr-only\"> steps done</span>\n            </span>\n          </div>\n          <div aria-hidden=\"true\" className={\"h-1 overflow-hidden rounded-litt-control \" + TRACK}>\n            <div className=\"h-full rounded-litt-control bg-litt-fg in-[[data-material=flat]]:bg-litt-fg\" style={{ width: `${share}%` }} />\n          </div>\n        </div>\n      )}\n\n      <ol role=\"list\" aria-labelledby={id} className={\"m-0 list-none p-0 \" + (bare ? \"\" : \"mt-2\")}>\n        {steps.map((step, i) => {\n          const key = keys[i];\n          const word = SHOWN[step.status];\n          return (\n            <motion.li\n              key={key}\n              aria-current={step.status === \"running\" ? \"step\" : undefined}\n              initial={shown.has(key) ? false : { opacity: 0, y: -travel / 4 }}\n              animate={{ opacity: 1, y: 0, transition: enter }}\n              className=\"grid min-h-8 grid-cols-[1rem_minmax(0,1fr)] items-start gap-x-3 py-1.5\"\n            >\n              <span className={`flex h-[1lh] items-center text-litt-small ${FADE} ${ICON_TONE[step.status]}`}>\n                {step.status === \"done\" ? (\n                  <>\n                    <StatusIcon status=\"done\" className=\"in-[[data-material=flat]]:hidden\" />\n                    <span className=\"hidden size-4 place-items-center rounded-litt-pill bg-litt-fg text-litt-canvas in-[[data-material=flat]]:grid forced-colors:border\">\n                      <CheckIcon className=\"size-3\" />\n                    </span>\n                  </>\n                ) : (\n                  <StatusIcon status={step.status} />\n                )}\n              </span>\n              <div className=\"min-w-0\">\n                <p className={`text-litt-small ${FADE} ${LABEL_TONE[step.status]}`}>\n                  {step.label}\n                  <span className=\"sr-only\">, {SAID[step.status]}</span>\n                  {word && (\n                    <span aria-hidden=\"true\" className=\"ml-2 text-litt-caption whitespace-nowrap text-litt-fg-subtle\">\n                      {word}\n                    </span>\n                  )}\n                </p>\n                {step.detail != null && step.detail !== false && <p className=\"mt-0.5 text-litt-caption font-normal text-litt-fg-subtle\">{step.detail}</p>}\n              </div>\n            </motion.li>\n          );\n        })}\n      </ol>\n\n      {!bare && (\n        <div role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n          {said}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "target": "components/litt/plan.tsx"
    }
  ],
  "docs": "Import Plan from components/litt/plan. Pass a label and steps of { id, label, status, detail? }, with status one of pending, running, done, failed or skipped. Hold the steps in state and change a status in place as the agent works, and give every step a stable id so only a step you add later fades in. The count is the done steps over all steps, and failed and skipped steps do not count. It fills its container and has no card, so put it in a turn or in a panel you draw.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
