{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"plan","type":"registry:component","title":"Plan","description":"An agent’s checklist that ticks itself off: pending, in progress, done, skipped or failed.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/plan.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronDown, ListChecks, X } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * PLAN: an agent's checklist, ticking itself off\n *\n *   pending   not started\n *   running   being worked on now; the ring turns into a spinner\n *   done      finished; the spinner blurs into a check that draws\n *             itself, and a strike-through draws across the label\n *   skipped   no longer needed\n *   failed    couldn't be done; the reason folds open underneath\n *\n * A hairline under the header fills as tasks finish and the count\n * rolls (\"3 of 5\"). When every task is settled \"done\" slides in\n * beside it and the plan folds into that one line.\n * ───────────────────────────────────────────────────────── */\n\nexport type PlanTaskStatus = \"pending\" | \"running\" | \"done\" | \"skipped\" | \"failed\";\n\nexport interface PlanTask {\n  id?: string;\n  label: string;\n  /** A note under the task, e.g. why it failed or what it produced. */\n  detail?: string;\n  status: PlanTaskStatus;\n}\n\nexport interface PlanProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  tasks: PlanTask[];\n  title?: string;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Fold into the summary once every task is settled. */\n  collapseWhenDone?: boolean;\n}\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst STATUS_TEXT: Record<PlanTaskStatus, string> = {\n  pending: \"Not started\",\n  running: \"In progress\",\n  done: \"Done\",\n  skipped: \"Skipped\",\n  failed: \"Failed\",\n};\n\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\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/** Every mark is there at once; the one for the task's status shows, and they trade places through a blur. */\nfunction Mark({ status, reduced }: { status: PlanTaskStatus; reduced: boolean }) {\n  const layer = \"absolute inset-0 flex items-center justify-center\";\n  return (\n    <span aria-hidden=\"true\" className=\"relative flex size-3.5 shrink-0\">\n      <span className={layer} style={swap(status === \"pending\", reduced)}>\n        <span className=\"size-3.5 rounded-full border-[1.5px] border-border\" />\n      </span>\n      <span className={layer} style={swap(status === \"running\", reduced)}>\n        {/* Spins only while it's showing. */}\n        <span className={`size-3.5 rounded-full border-[1.5px] border-border border-t-foreground motion-reduce:animate-none ${status === \"running\" ? \"animate-spin\" : \"\"}`} />\n      </span>\n      <span className={layer} style={swap(status === \"done\", reduced)}>\n        <svg viewBox=\"0 0 16 16\" className=\"size-3.5\">\n          <circle cx=\"8\" cy=\"8\" r=\"8\" className=\"fill-foreground\" />\n          <path\n            d=\"M4.8 8.3l2.1 2.1 4.3-4.6\"\n            fill=\"none\"\n            strokeWidth=\"1.8\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            pathLength={1}\n            strokeDasharray={1}\n            className=\"stroke-background\"\n            style={{ strokeDashoffset: status === \"done\" ? 0 : 1, transition: status === \"done\" && !reduced ? `stroke-dashoffset 420ms ${MORPH} 140ms` : \"none\" }}\n          />\n        </svg>\n      </span>\n      <span className={layer} style={swap(status === \"skipped\", reduced)}>\n        <span className=\"flex size-3.5 items-center justify-center rounded-full border-[1.5px] border-border\">\n          <span className=\"h-[1.5px] w-1.5 rounded-full bg-muted-foreground\" />\n        </span>\n      </span>\n      <span className={layer} style={swap(status === \"failed\", reduced)}>\n        <span className=\"flex size-3.5 items-center justify-center rounded-full bg-red-500 text-white\">\n          <X className=\"size-2.5\" strokeWidth={3} />\n        </span>\n      </span>\n    </span>\n  );\n}\n\n/** A note that folds open under its task and folds shut again, keeping its words while it closes. */\nfunction Detail({ text, failed, reduced }: { text?: string; failed: boolean; reduced: boolean }) {\n  const [last, setLast] = React.useState(text);\n  if (text && text !== last) setLast(text);\n  return (\n    <span\n      aria-hidden={!text || undefined}\n      className=\"grid\"\n      style={{\n        gridTemplateRows: text ? \"1fr\" : \"0fr\",\n        opacity: text ? 1 : 0,\n        transition: reduced ? \"none\" : `grid-template-rows 420ms ${MORPH}, opacity ${text ? \"320ms\" : \"160ms\"} ${MORPH}, color 300ms`,\n      }}\n    >\n      <span className={`min-h-0 overflow-hidden text-[12px] leading-5 transition-colors duration-300 ${failed ? \"text-red-500\" : \"text-muted-foreground\"}`}>\n        <span className=\"block pt-0.5\">{text ?? last}</span>\n      </span>\n    </span>\n  );\n}\n\nexport function Plan({\n  tasks,\n  title = \"Plan\",\n  open,\n  defaultOpen = true,\n  onOpenChange,\n  collapseWhenDone = true,\n  className = \"\",\n  ...props\n}: PlanProps) {\n  const reduced = useReducedMotion();\n  const [ownOpen, setOwnOpen] = React.useState(defaultOpen);\n  const [touched, setTouched] = React.useState(false);\n  const panelId = React.useId();\n\n  const done = tasks.filter((t) => t.status === \"done\").length;\n  const settled = tasks.length > 0 && tasks.every((t) => t.status === \"done\" || t.status === \"skipped\" || t.status === \"failed\");\n  const failed = tasks.some((t) => t.status === \"failed\");\n  const expanded = open ?? ownOpen;\n\n  // Fold away once everything is settled, unless the user has taken control or something failed.\n  React.useEffect(() => {\n    if (!collapseWhenDone || open !== undefined || touched || !settled || failed) return;\n    const timer = window.setTimeout(() => setOwnOpen(false), 1200);\n    return () => window.clearTimeout(timer);\n  }, [collapseWhenDone, open, touched, settled, failed]);\n\n  const toggle = () => {\n    const next = !expanded;\n    setTouched(true);\n    if (open === undefined) setOwnOpen(next);\n    onOpenChange?.(next);\n  };\n\n  return (\n    <div className={`w-full ${className}`} {...props}>\n      <button\n        type=\"button\"\n        aria-expanded={expanded}\n        aria-controls={panelId}\n        onClick={toggle}\n        className={`-mx-1.5 flex w-[calc(100%+0.75rem)] items-center gap-2 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-accent ${FOCUS}`}\n      >\n        <ListChecks aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n        <span className=\"text-[13px] font-medium text-foreground\">{title}</span>\n        <span aria-hidden=\"true\" className=\"ml-auto flex items-baseline font-mono text-[11.5px] tabular-nums text-muted-foreground\">\n          <NumberRoll value={done} duration={600} />\n          &nbsp;of&nbsp;\n          <NumberRoll value={tasks.length} duration={600} />\n          <span\n            className=\"grid\"\n            style={{\n              gridTemplateColumns: settled ? \"1fr\" : \"0fr\",\n              opacity: settled ? 1 : 0,\n              transition: reduced ? \"none\" : `grid-template-columns 420ms ${MORPH}, opacity 300ms ${MORPH}`,\n            }}\n          >\n            <span className=\"min-w-0 overflow-hidden whitespace-nowrap\">&nbsp;done</span>\n          </span>\n        </span>\n        <ChevronDown\n          aria-hidden=\"true\"\n          className={`size-3.5 shrink-0 text-muted-foreground transition-transform duration-300 motion-reduce:transition-none ${expanded ? \"rotate-180\" : \"\"}`}\n        />\n      </button>\n      {/* Outside the button, so its name stays the title. */}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {settled ? `${done} of ${tasks.length} done` : `${done} of ${tasks.length}`}\n      </span>\n\n      <div aria-hidden=\"true\" className=\"mt-1.5 h-px w-full overflow-hidden bg-border\">\n        <div\n          className=\"h-full bg-foreground transition-[width] duration-700 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n          style={{ width: `${tasks.length ? (done / tasks.length) * 100 : 0}%` }}\n        />\n      </div>\n\n      <div\n        id={panelId}\n        inert={!expanded}\n        className={`grid transition-[grid-template-rows,opacity] duration-[400ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none ${\n          expanded ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\"\n        }`}\n      >\n        <div className=\"overflow-hidden\">\n          <ol aria-label={title} className=\"flex flex-col gap-0.5 pt-2.5\">\n            {tasks.map((task, i) => {\n              const quiet = task.status === \"done\" || task.status === \"skipped\";\n              return (\n                <li key={task.id ?? `${i}-${task.label}`} className=\"flex gap-2.5 py-1\">\n                  <span className=\"mt-[3px] flex\">\n                    <Mark status={task.status} reduced={reduced} />\n                  </span>\n                  <span className=\"min-w-0\">\n                    <span className=\"block text-[13px] leading-5\">\n                      {/* The strike draws itself across each line of the label, left to right. */}\n                      <span\n                        className={`bg-[linear-gradient(var(--border),var(--border))] bg-no-repeat [background-position:0_58%] [box-decoration-break:clone] [-webkit-box-decoration-break:clone] ${\n                          quiet ? \"text-muted-foreground\" : task.status === \"pending\" ? \"text-foreground/70\" : \"text-foreground\"\n                        }`}\n                        style={{\n                          backgroundSize: quiet ? \"100% 1px\" : \"0% 1px\",\n                          transition: reduced ? \"none\" : `background-size 520ms ${MORPH} 120ms, color 300ms`,\n                        }}\n                      >\n                        {task.label}\n                      </span>\n                      <span className=\"sr-only\">, {STATUS_TEXT[task.status]}</span>\n                    </span>\n                    <Detail text={task.detail} failed={task.status === \"failed\"} reduced={reduced} />\n                  </span>\n                </li>\n              );\n            })}\n          </ol>\n        </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/plan"}