{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "task-steps",
  "type": "registry:ui",
  "title": "Task Steps",
  "description": "Stages of a long job with live timers, drawn checks and a filling rail.",
  "categories": [
    "async"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/task-steps.tsx",
      "content": "\"use client\";\nimport NumberFlow, { NumberFlowGroup } from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type TaskStepStatus = \"pending\" | \"active\" | \"done\" | \"failed\" | \"skipped\";\n\ntype Time = number | Date;\nconst ms = (t: Time | undefined) => (t == null ? undefined : typeof t === \"number\" ? t : t.getTime());\n\n// One shared clock for every running timer on the page. It ticks on the wall\n// clock's second boundary so all timers change together, and sleeps while the\n// tab is hidden (elapsed time is derived from Date.now, so nothing drifts).\nconst clock = (() => {\n  let now = 0;\n  let timer = 0;\n  const subs = new Set<() => void>();\n  const emit = () => {\n    now = Date.now();\n    subs.forEach((fn) => fn());\n  };\n  const schedule = () => {\n    window.clearTimeout(timer);\n    if (document.hidden) return;\n    timer = window.setTimeout(() => {\n      emit();\n      schedule();\n    }, 1000 - (Date.now() % 1000) + 4);\n  };\n  const onVisibility = () => {\n    if (!document.hidden) emit();\n    schedule();\n  };\n  return {\n    subscribe(fn: () => void) {\n      subs.add(fn);\n      if (subs.size === 1) {\n        now = Date.now();\n        schedule();\n        document.addEventListener(\"visibilitychange\", onVisibility);\n      }\n      return () => {\n        subs.delete(fn);\n        if (subs.size) return;\n        window.clearTimeout(timer);\n        document.removeEventListener(\"visibilitychange\", onVisibility);\n        now = 0;\n      };\n    },\n    // Before anyone subscribes there is no tick yet; take one reading and keep it.\n    get: () => (now ||= Date.now()),\n  };\n})();\nconst idle = () => () => {};\n\n/**\n * Milliseconds between `startedAt` and `endedAt`, or until now while it is\n * still running (re-rendering once a second). Null on the server and before\n * hydration, so nothing time-dependent is baked into the HTML.\n */\nexport function useElapsed(startedAt?: Time, endedAt?: Time) {\n  const start = ms(startedAt);\n  const end = ms(endedAt);\n  const running = start != null && end == null;\n  const now = useSyncExternalStore(running ? clock.subscribe : idle, clock.get, () => null);\n  if (start == null) return null;\n  if (end != null) return Math.max(0, end - start);\n  return now == null ? null : Math.max(0, now - start);\n}\n\n/** 42s · 3m 07s · 1h 12m. Short, unambiguous, the same width for every second. */\nexport function formatDuration(value: number) {\n  const s = Math.floor(value / 1000);\n  if (s < 60) return `${s}s`;\n  if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, \"0\")}s`;\n  return `${Math.floor(s / 3600)}h ${String(Math.floor(s / 60) % 60).padStart(2, \"0\")}m`;\n}\n\nexport type TaskStepsProps = React.ComponentProps<\"ol\">;\n\n/** An ordered list of the stages of one long job. Give it an aria-label that names the job. */\nexport function TaskSteps({ className, ...rest }: TaskStepsProps) {\n  return <ol className={cn(\"flex min-w-0 flex-col\", className)} {...rest} />;\n}\n\nexport type TaskStepProps = Omit<React.ComponentProps<\"li\">, \"children\"> & {\n  status: TaskStepStatus;\n  /** The stage, as a noun or -ing verb: \"Building\", \"Deploying to edge\". */\n  label: React.ReactNode;\n  /** One line under the label. While active it can change as work progresses; each new line crossfades in. */\n  description?: React.ReactNode;\n  /** When the step started. With no endedAt, the timer runs. */\n  startedAt?: Time;\n  /** When the step finished or failed. Freezes the timer. */\n  endedAt?: Time;\n  /** What went wrong, shown when the status is failed. */\n  error?: React.ReactNode;\n  /** Controls under the error: Retry, View logs. Shown when the status is failed. */\n  action?: React.ReactNode;\n};\n\nconst spoken: Record<TaskStepStatus, string> = {\n  pending: \"Not started\",\n  active: \"In progress\",\n  done: \"Done\",\n  failed: \"Failed\",\n  skipped: \"Skipped\",\n};\n\nexport function TaskStep({ status, label, description, startedAt, endedAt, error, action, className, ref, ...rest }: TaskStepProps) {\n  const reduce = useReducedMotion();\n  const self = useRef<HTMLLIElement>(null);\n  const block = useRef<HTMLDivElement>(null);\n\n  // Pressing Retry removes the button that had focus. Catch it on the step\n  // itself so focus never falls back to the top of the page.\n  useEffect(() => {\n    if (status !== \"failed\" && block.current?.contains(document.activeElement)) self.current?.focus();\n  }, [status]);\n  const elapsed = useElapsed(startedAt, endedAt);\n  const failed = status === \"failed\";\n  const showTime = elapsed != null && (status === \"active\" || status === \"done\" || failed);\n  const descKey = typeof description === \"string\" ? description : status;\n\n  return (\n    <li\n      ref={(node) => {\n        self.current = node;\n        if (typeof ref === \"function\") ref(node);\n        else if (ref) ref.current = node;\n      }}\n      tabIndex={-1}\n      data-status={status}\n      aria-current={status === \"active\" ? \"step\" : undefined}\n      className={cn(\n        \"group/step relative grid grid-cols-[20px_minmax(0,1fr)] gap-x-3 rounded-md pb-4 outline-none last:pb-0\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-4 focus-visible:outline-fg-3\",\n        className,\n      )}\n      {...rest}\n    >\n      {/* The segment to the next step. It fills from the top once this step is done. */}\n      <span aria-hidden className=\"absolute bottom-1 left-[9.5px] top-6 w-px overflow-hidden rounded-full bg-line-2 group-last/step:hidden\">\n        <span\n          className={cn(\n            \"absolute inset-0 origin-top bg-fg-3 transition-transform duration-500 ease-in-out-quart\",\n            status === \"done\" ? \"scale-y-100\" : \"scale-y-0\",\n          )}\n        />\n      </span>\n\n      <span className=\"relative z-[1] mt-px grid size-5 place-items-center\">\n        <AnimatePresence initial={false}>\n          <motion.span\n            key={status}\n            className=\"absolute inset-0 grid place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.8, transition: { duration: 0.12, ease: ease.in } }}\n            transition={reduce ? { duration: 0.15 } : spring.pop}\n          >\n            <Node status={status} reduce={!!reduce} />\n          </motion.span>\n        </AnimatePresence>\n      </span>\n\n      <div className=\"min-w-0\">\n        <div className=\"flex min-h-[22px] items-baseline gap-3\">\n          {/* The state word is spoken when it changes; the ticking timer is deliberately outside it. */}\n          <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n            {label}, {spoken[status].toLowerCase()}\n            {(status === \"done\" || failed) && elapsed != null ? ` after ${formatDuration(elapsed)}` : \"\"}\n          </span>\n          <span\n            aria-hidden\n            className={cn(\n              \"min-w-0 flex-1 truncate text-[13px] font-medium leading-[22px] tracking-[-0.006em] transition-colors duration-200\",\n              status === \"active\" || failed ? \"text-fg\" : status === \"done\" ? \"text-fg-2\" : status === \"skipped\" ? \"text-fg-4\" : \"text-fg-3\",\n            )}\n          >\n            {label}\n          </span>\n          <span\n            aria-hidden\n            className={cn(\n              \"shrink-0 font-mono text-[11.5px] tabular leading-[22px] transition-colors duration-200\",\n              status === \"active\" ? \"text-fg-2\" : failed ? \"text-danger\" : \"text-fg-3\",\n            )}\n          >\n            {showTime ? <Duration value={elapsed} /> : status === \"skipped\" ? <span className=\"text-fg-4\">Skipped</span> : null}\n          </span>\n        </div>\n\n        {description != null && (\n          <div className=\"grid\">\n            <AnimatePresence initial={false}>\n              <motion.p\n                key={descKey}\n                className={cn(\n                  \"col-start-1 row-start-1 truncate text-[12px] leading-[18px] transition-colors duration-200\",\n                  status === \"active\" ? \"text-fg-2\" : \"text-fg-3\",\n                )}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: -5, filter: \"blur(2px)\", transition: { duration: 0.14, ease: ease.in } }}\n                transition={{ duration: reduce ? 0.15 : 0.24, ease: ease.out }}\n              >\n                {description}\n              </motion.p>\n            </AnimatePresence>\n          </div>\n        )}\n\n        {/* Grows open instead of shoving the steps below it down in one frame. */}\n        <AnimatePresence initial={false}>\n          {failed && (error != null || action != null) && (\n            <motion.div\n              key=\"error\"\n              ref={block}\n              // Bleeds 4px sideways so focus rings on the actions aren't clipped by the height animation.\n              className=\"-mx-1 overflow-hidden px-1\"\n              initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0, transition: { duration: 0.16, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.26, ease: ease.out }}\n            >\n              <div className=\"flex flex-col items-start gap-2.5 pb-1 pt-1.5\">\n                {error != null && <p className=\"text-pretty text-[12px] leading-[18px] text-danger\">{error}</p>}\n                {action != null && <div className=\"flex flex-wrap items-center gap-2\">{action}</div>}\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </li>\n  );\n}\n\n// Seconds roll like an odometer rather than flicker; minutes join once they exist.\nfunction Duration({ value }: { value: number }) {\n  const s = Math.floor(value / 1000);\n  if (s >= 3600) return <>{formatDuration(value)}</>;\n  return (\n    <NumberFlowGroup>\n      <span className=\"inline-flex items-baseline gap-[0.35em]\">\n        {s >= 60 && <NumberFlow value={Math.floor(s / 60)} suffix=\"m\" />}\n        <NumberFlow value={s % 60} suffix=\"s\" format={s >= 60 ? { minimumIntegerDigits: 2 } : undefined} />\n      </span>\n    </NumberFlowGroup>\n  );\n}\n\nconst glyph = { viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.8, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n\nfunction Node({ status, reduce }: { status: TaskStepStatus; reduce: boolean }) {\n  const draw = (delay = 0.06) =>\n    reduce ? {} : { initial: { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: 0.32, ease: ease.out, delay } };\n\n  if (status === \"done\")\n    return (\n      <span className=\"grid size-5 place-items-center rounded-full bg-fg text-frame\">\n        <svg {...glyph} className=\"size-3\">\n          <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" {...draw()} />\n        </svg>\n      </span>\n    );\n  if (status === \"failed\")\n    return (\n      <span className=\"grid size-5 place-items-center rounded-full bg-danger text-frame\">\n        <svg {...glyph} className=\"size-3\">\n          <motion.path d=\"m5 5 6 6\" {...draw()} />\n          <motion.path d=\"m11 5-6 6\" {...draw(0.14)} />\n        </svg>\n      </span>\n    );\n  if (status === \"active\")\n    return (\n      <span className=\"relative grid size-5 place-items-center rounded-full text-fg\">\n        <svg viewBox=\"0 0 20 20\" fill=\"none\" aria-hidden className=\"absolute inset-0 size-5 animate-spin [animation-duration:0.9s]\">\n          <circle cx=\"10\" cy=\"10\" r=\"8.75\" stroke=\"var(--line-2)\" strokeWidth=\"1.5\" />\n          <path d=\"M10 1.25a8.75 8.75 0 0 1 8.75 8.75\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n        </svg>\n        <span className=\"size-[5px] rounded-full bg-fg\" />\n      </span>\n    );\n  if (status === \"skipped\")\n    return (\n      <span className=\"grid size-5 place-items-center rounded-full border border-dashed border-fg-4\">\n        <span className=\"h-px w-2 rounded-full bg-fg-4\" />\n      </span>\n    );\n  return <span className=\"size-5 rounded-full border-[1.5px] border-line-2\" />;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/task-steps"
}
