{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "deploy-status",
  "type": "registry:ui",
  "title": "Deploy Status",
  "description": "Deployment row with a live, rolling duration and status changes that settle in place.",
  "categories": [
    "developer"
  ],
  "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/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/deploy-status.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUpRight } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type DeployState = \"queued\" | \"building\" | \"ready\" | \"error\" | \"canceled\";\n\nconst LABEL: Record<DeployState, string> = { queued: \"Queued\", building: \"Building\", ready: \"Ready\", error: \"Error\", canceled: \"Canceled\" };\nconst TEXT: Record<DeployState, string> = { queued: \"text-fg-3\", building: \"text-warning\", ready: \"text-success\", error: \"text-danger\", canceled: \"text-fg-3\" };\n\n// One shared one-second clock for every live row on the page, paused while the tab is hidden.\nconst listeners = new Set<() => void>();\nlet timer = 0;\nlet current = 0;\nconst read = () => Math.floor(Date.now() / 1000) * 1000;\nconst tick = () => {\n  current = read();\n  listeners.forEach((l) => l());\n};\nfunction sync() {\n  window.clearInterval(timer);\n  timer = document.hidden || !listeners.size ? 0 : window.setInterval(tick, 1000);\n  if (!document.hidden) tick();\n}\nfunction subscribe(cb: () => void) {\n  listeners.add(cb);\n  if (listeners.size === 1) {\n    sync();\n    document.addEventListener(\"visibilitychange\", sync);\n  }\n  return () => {\n    listeners.delete(cb);\n    if (!listeners.size) {\n      window.clearInterval(timer);\n      document.removeEventListener(\"visibilitychange\", sync);\n    }\n  };\n}\nconst noSub = () => () => {};\n// The snapshot only moves when the clock ticks, so React always reads a stable value between ticks.\nconst snapshot = () => current || (current = read());\n\n/** The current time in whole seconds, ticking while `live`. Null during server render and hydration. */\nexport function useNow(live = true) {\n  return useSyncExternalStore(live ? subscribe : noSub, snapshot, () => null);\n}\n\nconst toMs = (t?: number | string | Date) => (t === undefined ? undefined : t instanceof Date ? t.getTime() : typeof t === \"string\" ? Date.parse(t) : t);\n\n/** 48s, 1m 12s, 1h 3m. */\nexport function formatDuration(ms: number) {\n  const s = Math.max(0, Math.round(ms / 1000));\n  if (s < 60) return `${s}s`;\n  const m = Math.floor(s / 60);\n  if (m < 60) return `${m}m ${s % 60}s`;\n  return `${Math.floor(m / 60)}h ${m % 60}m`;\n}\nfunction ago(ms: number) {\n  const s = Math.max(0, Math.round(ms / 1000));\n  if (s < 45) return \"Just now\";\n  const m = Math.round(s / 60);\n  if (m < 60) return `${m}m ago`;\n  const h = Math.round(m / 60);\n  if (h < 24) return `${h}h ago`;\n  return `${Math.round(h / 24)}d ago`;\n}\n\nexport type DeployStatusDotProps = Omit<React.ComponentProps<\"span\">, \"children\"> & { status: DeployState };\n\n/** The status mark on its own: a ring while queued, a pulse while building, a solid dot when settled. */\nexport function DeployStatusDot({ status, className, ...rest }: DeployStatusDotProps) {\n  const reduce = useReducedMotion();\n  const color = { queued: \"border border-fg-4\", building: \"bg-warning\", ready: \"bg-success\", error: \"bg-danger\", canceled: \"bg-fg-4\" }[status];\n  return (\n    <span aria-hidden data-status={status} className={cn(\"relative grid size-4 shrink-0 place-items-center\", className)} {...rest}>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={status}\n          className=\"absolute inset-0 grid place-items-center\"\n          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n          animate={{ opacity: 1, scale: 1 }}\n          exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, transition: { duration: 0.12 } }}\n          transition={reduce ? { duration: 0.15 } : spring.pop}\n        >\n          {status === \"building\" && <span className=\"absolute size-2 animate-ping-soft rounded-full bg-warning motion-reduce:hidden\" />}\n          {/* Settling into ready or error sends out one ring, so a change seen from the corner of the eye registers. */}\n          {/* Hidden by CSS under reduced motion, so server and client render the same tree. */}\n          {(status === \"ready\" || status === \"error\") && (\n            <motion.span\n              className={cn(\"absolute size-2 rounded-full motion-reduce:hidden\", status === \"ready\" ? \"bg-success\" : \"bg-danger\")}\n              initial={{ scale: 1, opacity: 0.5 }}\n              animate={{ scale: 3, opacity: 0 }}\n              transition={{ duration: 0.7, ease: ease.out }}\n            />\n          )}\n          <span className={cn(\"relative size-2 rounded-full\", color)} />\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport type DeployCommit = {\n  message: string;\n  sha: string;\n  branch?: string;\n  author?: string;\n};\n\nexport type DeployStatusProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  status: DeployState;\n  commit: DeployCommit;\n  /** \"Production\", \"Preview\"… shown as a tag beside the message. */\n  environment?: string;\n  /** When the deployment was created: drives \"2m ago\" and the queued timer. */\n  createdAt?: number | string | Date;\n  /** When the build started: the duration counts from here while building. */\n  startedAt?: number | string | Date;\n  /** When it finished: freezes the duration. */\n  finishedAt?: number | string | Date;\n  /** What went wrong, shown under the commit when status is \"error\". */\n  error?: string;\n  /** Link to the build logs. */\n  logsHref?: string;\n  /** Or handle the logs button yourself (open a drawer). */\n  onViewLogs?: () => void;\n};\n\nexport function DeployStatus({\n  status,\n  commit,\n  environment,\n  createdAt,\n  startedAt,\n  finishedAt,\n  error,\n  logsHref,\n  onViewLogs,\n  className,\n  ref: userRef,\n  ...rest\n}: DeployStatusProps) {\n  const reduce = useReducedMotion();\n  const [ref, offscreen] = useOffscreen<HTMLDivElement>();\n  const live = status === \"queued\" || status === \"building\";\n  const now = useNow(true);\n  const created = toMs(createdAt);\n  const started = toMs(startedAt);\n  const finished = toMs(finishedAt);\n\n  // Queued counts from creation, building from the start, settled rows show the frozen total.\n  const from = status === \"queued\" ? created : started ?? created;\n  const to = live ? now : finished;\n  const elapsed = from !== undefined && to != null ? Math.max(0, to - from) : null;\n  const seconds = elapsed === null ? null : Math.round(elapsed / 1000);\n\n  const settledLine =\n    status === \"ready\" ? `Ready in ${elapsed !== null ? formatDuration(elapsed) : \"—\"}` : status === \"error\" ? \"Build failed\" : status === \"canceled\" ? \"Canceled\" : \"\";\n\n  const logs = cn(\n    \"relative inline-flex h-7 shrink-0 items-center justify-center gap-1 rounded-md text-[12px] font-medium text-fg-2 outline-none\",\n    \"px-2 @max-md:w-7 @max-md:px-0\",\n    \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-fg/[0.06] hover:text-fg active:scale-[0.96] active:duration-75\",\n    \"focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 focus-visible:outline-solid\",\n    // 44px touch target around a 28px drawing.\n    \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n  );\n  const logsLabel = `View logs for ${commit.sha.slice(0, 7)}`;\n  const logsInner = (\n    <>\n      <span className=\"@max-md:sr-only\">Logs</span>\n      <ArrowUpRight size={13} className=\"text-fg-3 transition-transform duration-150 ease-out group-hover/logs:-translate-y-px group-hover/logs:translate-x-px\" />\n    </>\n  );\n  const envTag = environment && (\n    <span className=\"shrink-0 rounded-[5px] border border-line-2 px-1.5 font-sans text-[11px] leading-[18px] text-fg-3\">{environment}</span>\n  );\n\n  return (\n    <div\n      ref={(node) => {\n        ref(node);\n        if (typeof userRef === \"function\") userRef(node);\n        else if (userRef) userRef.current = node;\n      }}\n      data-slot=\"deploy-status\"\n      data-status={status}\n      data-offscreen={offscreen || undefined}\n      className={cn(\n        \"@container relative min-w-0 overflow-hidden px-4 py-3 text-fg\",\n        // Continuous marks (the pulse, the scanner) stop drawing when nobody can see them.\n        \"data-offscreen:[&_*]:[animation-play-state:paused]\",\n        className,\n      )}\n      {...rest}\n    >\n      <div className=\"flex min-w-0 items-start gap-3\">\n        <DeployStatusDot status={status} className=\"mt-0.5\" />\n\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex min-w-0 items-center gap-2\">\n            {/* The message is the name of the row: it wraps to two lines on narrow rows instead of vanishing into an ellipsis. */}\n            <span className=\"min-w-0 truncate text-[13px] font-medium tracking-[-0.005em] @max-md:line-clamp-2 @max-md:whitespace-normal\">{commit.message}</span>\n            <span className=\"contents @max-md:hidden\">{envTag}</span>\n          </div>\n          <div className=\"mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[12px] text-fg-3\">\n            <span className=\"hidden @max-md:contents\">{envTag}</span>\n            {commit.branch && (\n              <span className=\"flex min-w-0 items-center gap-1\">\n                <BranchIcon />\n                <span className=\"max-w-40 truncate font-mono text-[11.5px] text-fg-2 @max-md:max-w-28\">{commit.branch}</span>\n              </span>\n            )}\n            <span className=\"font-mono text-[11.5px] text-fg-3\">{commit.sha.slice(0, 7)}</span>\n            {commit.author && (\n              <span className=\"flex min-w-0 items-center gap-1.5 @max-md:hidden\">\n                <span aria-hidden className=\"text-fg-4\">·</span>\n                <span className=\"truncate\">{commit.author}</span>\n              </span>\n            )}\n            {created !== undefined && (\n              <span className=\"flex items-center gap-1.5\">\n                <span aria-hidden className=\"text-fg-4\">·</span>\n                <time\n                  dateTime={new Date(created).toISOString()}\n                  title={now === null ? undefined : new Date(created).toLocaleString()}\n                  className=\"tabular whitespace-nowrap\"\n                >\n                  {now === null ? \"\\u00a0\" : ago(now - created)}\n                </time>\n              </span>\n            )}\n          </div>\n        </div>\n\n        <div className=\"flex shrink-0 flex-col items-end gap-1\">\n          {/* Every label shares one cell, so the column never changes width as the status moves on. */}\n          <span className=\"grid text-right text-[12px] font-medium\">\n            {Object.values(LABEL).map((l) => (\n              <span key={l} aria-hidden className=\"invisible col-start-1 row-start-1\">\n                {l}\n              </span>\n            ))}\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={status}\n                className={cn(\"col-start-1 row-start-1\", TEXT[status])}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" }}\n                transition={{ duration: 0.22, ease: ease.out }}\n              >\n                {LABEL[status]}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n          <Elapsed seconds={seconds} animated={!reduce && live} />\n        </div>\n\n        {(logsHref || onViewLogs) &&\n          (logsHref ? (\n            <a href={logsHref} className={cn(logs, \"group/logs mt-1.5\")} aria-label={logsLabel} title={logsLabel}>\n              {logsInner}\n            </a>\n          ) : (\n            <button type=\"button\" onClick={onViewLogs} className={cn(logs, \"group/logs mt-1.5\")} aria-label={logsLabel} title={logsLabel}>\n              {logsInner}\n            </button>\n          ))}\n      </div>\n\n      <AnimatePresence initial={false}>\n        {status === \"error\" && error && (\n          <motion.div\n            initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n            animate={{ opacity: 1, height: \"auto\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, transition: { duration: 0.16, ease: ease.out } }}\n            transition={{ duration: 0.22, ease: ease.out }}\n            className=\"overflow-hidden pl-7\"\n          >\n            <p className=\"mt-2 rounded-md bg-danger-soft px-2 py-1 font-mono text-[11.5px] leading-4 text-danger [overflow-wrap:anywhere]\">{error}</p>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      {/* A thin scanner along the bottom edge while it builds. */}\n      <AnimatePresence>\n        {status === \"building\" && (\n          <motion.span\n            aria-hidden\n            className=\"absolute inset-x-0 bottom-0 h-px overflow-hidden motion-reduce:hidden\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.3 } }}\n          >\n            <span className=\"absolute inset-y-0 left-0 w-1/2 animate-indeterminate bg-linear-to-r from-transparent via-warning/70 to-transparent\" />\n          </motion.span>\n        )}\n      </AnimatePresence>\n\n      <span role=\"status\" className=\"sr-only\">\n        {status === \"building\" ? \"Building\" : status === \"queued\" ? \"Queued\" : settledLine}\n      </span>\n    </div>\n  );\n}\n\n/** 48s rolls second by second; past a minute the minutes and seconds roll on their own. */\nfunction Elapsed({ seconds, animated }: { seconds: number | null; animated: boolean }) {\n  return (\n    <span className=\"tabular flex h-4 items-center font-mono text-[11.5px] text-fg-3\">\n      {seconds === null ? (\n        \"—\"\n      ) : seconds < 3600 ? (\n        <>\n          {seconds >= 60 && <NumberFlow value={Math.floor(seconds / 60)} suffix=\"m\" animated={animated} willChange className=\"mr-[0.5ch]\" />}\n          <NumberFlow value={seconds % 60} suffix=\"s\" animated={animated} willChange />\n        </>\n      ) : (\n        formatDuration(seconds * 1000)\n      )}\n    </span>\n  );\n}\n\n/** Whether the element is outside the viewport, for pausing continuous animation. */\nfunction useOffscreen<T extends Element>() {\n  const [el, setEl] = useState<T | null>(null);\n  const [offscreen, setOffscreen] = useState(false);\n  useEffect(() => {\n    if (!el || typeof IntersectionObserver === \"undefined\") return;\n    const io = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting));\n    io.observe(el);\n    return () => io.disconnect();\n  }, [el]);\n  return [setEl, offscreen] as const;\n}\n\nfunction BranchIcon() {\n  return (\n    <svg width={12} height={12} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden className=\"shrink-0\">\n      <circle cx=\"4.5\" cy=\"3.5\" r=\"1.5\" />\n      <circle cx=\"4.5\" cy=\"12.5\" r=\"1.5\" />\n      <circle cx=\"11.5\" cy=\"5\" r=\"1.5\" />\n      <path d=\"M4.5 5v6M11.5 6.5c0 3-7 2-7 4.5\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/deploy-status"
}
