{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "deploy-pipeline",
  "type": "registry:component",
  "title": "Deploy Pipeline",
  "description": "Install, build, test and deploy as stages that light up and connect as they pass, live timers, a log peek that follows the running stage, a failed stage you can retry in place, and the preview URL as a chip when it’s ready.",
  "categories": [
    "component",
    "Dev Tools"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/deploy-pipeline.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  CheckIcon,\n  CopyIcon,\n  ExternalLinkIcon,\n  GitBranchIcon,\n  GlobeIcon,\n  type LucideIcon,\n  PackageIcon,\n  RotateCcwIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Fragment, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type StageStatus = \"queued\" | \"running\" | \"success\" | \"failed\" | \"skipped\";\n\nexport interface PipelineStage {\n  id: string;\n  name: string;\n  status: StageStatus;\n  icon?: LucideIcon;\n  logs: string[];\n  startedAt?: number;\n  durationMs?: number;\n  error?: string;\n}\n\nexport interface DeployPipelineProps {\n  stages: PipelineStage[];\n  commit: { message: string; sha: string; branch: string; author?: string };\n  url?: string;\n  onRetry?: (stageId: string) => void;\n  className?: string;\n}\n\nconst ease = [0.23, 1, 0.32, 1] as const;\n\nfunction useNow(active: boolean) {\n  const [now, setNow] = useState(() => Date.now());\n  useEffect(() => {\n    if (!active) return;\n    setNow(Date.now());\n    const t = setInterval(() => setNow(Date.now()), 250);\n    return () => clearInterval(t);\n  }, [active]);\n  return now;\n}\n\nconst secs = (ms: number) => {\n  const s = Math.max(0, Math.floor(ms / 1000));\n  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, \"0\")}s`;\n};\n\nfunction lineTone(line: string) {\n  if (/^(✗|×|error|ERR|FAIL)/i.test(line.trim())) return \"text-destructive-foreground\";\n  if (/^(✓|✔|PASS|ready)/i.test(line.trim())) return \"text-success-foreground\";\n  if (/^(⚠|warn)/i.test(line.trim())) return \"text-warning-foreground\";\n  if (/^(>|\\$)/.test(line.trim())) return \"text-foreground\";\n  return \"text-muted-foreground\";\n}\n\nfunction Node({ stage, selected, onSelect, elapsed, indicator }: { stage: PipelineStage; selected: boolean; onSelect: () => void; elapsed: number; indicator: string }) {\n  const reduce = useReducedMotion();\n  const Icon = stage.icon ?? PackageIcon;\n  const s = stage.status;\n  return (\n    <button\n      aria-label={`${stage.name}: ${s}`}\n      aria-pressed={selected}\n      className=\"group/node relative z-10 flex w-16 shrink-0 flex-col items-center gap-1.5 rounded-xl py-1 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-20\"\n      onClick={onSelect}\n      type=\"button\"\n    >\n      <span className=\"relative grid size-9 place-items-center\">\n        <span\n          className={cn(\n            \"absolute inset-0 rounded-full border bg-card transition-[background-color,border-color] duration-300\",\n            s === \"success\" && \"border-success bg-success\",\n            s === \"failed\" && \"border-destructive bg-destructive\",\n            s === \"running\" && \"border-info/30\",\n            (s === \"queued\" || s === \"skipped\") && \"border-dashed\",\n          )}\n        />\n        {s === \"running\" && (\n          <motion.svg\n            animate={reduce ? undefined : { rotate: 360 }}\n            aria-hidden\n            className=\"absolute -inset-px size-[38px] text-info\"\n            transition={{ duration: 1, ease: \"linear\", repeat: Number.POSITIVE_INFINITY }}\n            viewBox=\"0 0 38 38\"\n          >\n            <circle cx=\"19\" cy=\"19\" fill=\"none\" r=\"18\" stroke=\"currentColor\" strokeDasharray=\"28 200\" strokeLinecap=\"round\" strokeWidth=\"2\" />\n          </motion.svg>\n        )}\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            animate={{ scale: 1, opacity: 1 }}\n            className={cn(\n              \"relative grid place-items-center\",\n              s === \"success\" || s === \"failed\" ? \"text-white\" : s === \"running\" ? \"text-foreground\" : \"text-muted-foreground/70\",\n            )}\n            exit={{ scale: 0.6, opacity: 0 }}\n            initial={reduce ? { opacity: 0 } : { scale: 0.6, opacity: 0 }}\n            key={s === \"success\" ? \"ok\" : s === \"failed\" ? \"x\" : \"icon\"}\n            transition={{ type: \"spring\", stiffness: 600, damping: 24 }}\n          >\n            {s === \"success\" ? (\n              <CheckIcon className=\"size-4\" strokeWidth={3} />\n            ) : s === \"failed\" ? (\n              <XIcon className=\"size-4\" strokeWidth={3} />\n            ) : (\n              <Icon className=\"size-4\" />\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      <span className={cn(\"font-medium text-xs transition-colors\", selected ? \"text-foreground\" : \"text-muted-foreground [@media(hover:hover)]:group-hover/node:text-foreground\")}>\n        {stage.name}\n      </span>\n      <span\n        className={cn(\n          \"h-4 font-mono text-[11px] tabular-nums\",\n          s === \"failed\" ? \"text-destructive-foreground\" : \"text-muted-foreground\",\n        )}\n      >\n        {s === \"queued\" ? \"—\" : s === \"skipped\" ? \"skipped\" : secs(elapsed)}\n      </span>\n      {selected && (\n        <motion.span\n          className=\"absolute -bottom-[13px] h-0.5 w-6 rounded-full bg-foreground\"\n          layoutId={indicator}\n          transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 44 }}\n        />\n      )}\n    </button>\n  );\n}\n\nexport function DeployPipeline({ stages, commit, url, onRetry, className }: DeployPipelineProps) {\n  const reduce = useReducedMotion();\n  const indicator = useId();\n  const running = stages.find((s) => s.status === \"running\");\n  const failed = stages.find((s) => s.status === \"failed\");\n  const ready = stages.every((s) => s.status === \"success\" || s.status === \"skipped\");\n  const now = useNow(Boolean(running));\n  const [picked, setPicked] = useState<string | null>(null);\n  const [copied, setCopied] = useState(false);\n  const logRef = useRef<HTMLDivElement>(null);\n\n  const follow = failed?.id ?? running?.id ?? [...stages].reverse().find((s) => s.status !== \"queued\")?.id ?? stages[0].id;\n  const selectedId = picked ?? follow;\n  const selected = stages.find((s) => s.id === selectedId) ?? stages[0];\n\n  useEffect(() => {\n    if (running && picked && picked !== running.id) {\n      const pickedStage = stages.find((s) => s.id === picked);\n      if (pickedStage?.status === \"queued\") setPicked(null);\n    }\n  }, [running, picked, stages]);\n\n  useEffect(() => {\n    if (!copied) return;\n    const t = setTimeout(() => setCopied(false), 1400);\n    return () => clearTimeout(t);\n  }, [copied]);\n\n  useLayoutEffect(() => {\n    const el = logRef.current;\n    if (el) el.scrollTop = el.scrollHeight;\n  }, [selected.logs.length, selectedId]);\n\n  const elapsedOf = (s: PipelineStage) => (s.status === \"running\" && s.startedAt ? now - s.startedAt : (s.durationMs ?? 0));\n  const total = stages.reduce((sum, s) => sum + elapsedOf(s), 0);\n  const overall = failed ? \"failed\" : ready ? \"ready\" : running ? \"building\" : \"queued\";\n\n  return (\n    <div className={cn(\"flex w-full min-w-0 max-w-[540px] flex-col overflow-hidden rounded-2xl border bg-card text-sm shadow-xs/5\", className)}>\n      <div className=\"flex items-start gap-3 px-4 pt-3.5 pb-3\">\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"truncate font-medium\">{commit.message}</p>\n          <p className=\"mt-0.5 flex items-center gap-1.5 text-muted-foreground text-xs\">\n            <GitBranchIcon className=\"size-3\" />\n            <span className=\"truncate font-mono\">{commit.branch}</span>\n            <span className=\"text-muted-foreground/50\">·</span>\n            <span className=\"font-mono\">{commit.sha.slice(0, 7)}</span>\n            {commit.author && (\n              <>\n                <span className=\"text-muted-foreground/50 max-sm:hidden\">·</span>\n                <span className=\"truncate max-sm:hidden\">{commit.author}</span>\n              </>\n            )}\n          </p>\n        </div>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            animate={{ opacity: 1, scale: 1 }}\n            className={cn(\n              \"inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full px-2.5 font-medium text-xs tabular-nums\",\n              overall === \"ready\" && \"bg-success/10 text-success-foreground\",\n              overall === \"failed\" && \"bg-destructive/10 text-destructive-foreground\",\n              overall === \"building\" && \"bg-info/10 text-info-foreground\",\n              overall === \"queued\" && \"bg-muted text-muted-foreground\",\n            )}\n            exit={{ opacity: 0, scale: 0.95 }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}\n            key={overall}\n            transition={{ duration: 0.18, ease }}\n          >\n            <span\n              className={cn(\n                \"size-1.5 rounded-full\",\n                overall === \"ready\" && \"bg-success\",\n                overall === \"failed\" && \"bg-destructive\",\n                overall === \"building\" && \"animate-pulse bg-info\",\n                overall === \"queued\" && \"bg-muted-foreground/60\",\n              )}\n            />\n            {overall === \"ready\" ? \"Ready\" : overall === \"failed\" ? \"Failed\" : overall === \"building\" ? \"Building\" : \"Queued\"}\n            <span className=\"font-mono\">{secs(total)}</span>\n          </motion.span>\n        </AnimatePresence>\n      </div>\n\n      <div className=\"relative flex items-start justify-between px-3 pt-2 pb-3 sm:px-6\">\n        {stages.map((stage, i) => {\n          const next = stages[i + 1];\n          return (\n            <Fragment key={stage.id}>\n              <Node elapsed={elapsedOf(stage)} indicator={indicator} onSelect={() => setPicked(stage.id === follow ? null : stage.id)} selected={stage.id === selectedId} stage={stage} />\n              {next && (\n                <div aria-hidden className=\"relative mt-[22px] h-0.5 min-w-3 flex-1 overflow-hidden rounded-full bg-border\">\n                  <motion.span\n                    animate={{ scaleX: stage.status === \"success\" ? 1 : 0 }}\n                    className=\"absolute inset-0 origin-left rounded-full bg-success\"\n                    initial={false}\n                    transition={reduce ? { duration: 0 } : { duration: 0.45, ease }}\n                  />\n                </div>\n              )}\n            </Fragment>\n          );\n        })}\n      </div>\n\n      <div className=\"border-t\">\n        <div className=\"flex h-9 items-center gap-2 px-4 text-xs\">\n          <span className=\"font-medium\">{selected.name}</span>\n          <span className=\"text-muted-foreground tabular-nums\">\n            {selected.logs.length} {selected.logs.length === 1 ? \"line\" : \"lines\"}\n          </span>\n          {picked && (\n            <button\n              className=\"ms-auto -me-1.5 h-7 rounded-md px-1.5 text-muted-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring [@media(hover:hover)]:hover:text-foreground\"\n              onClick={() => setPicked(null)}\n              type=\"button\"\n            >\n              Follow live\n            </button>\n          )}\n        </div>\n        <div\n          aria-label={`${selected.name} logs`}\n          aria-live=\"off\"\n          className=\"h-[172px] overflow-y-auto bg-code px-4 py-2.5 font-mono text-[11.5px] leading-[19px] [scrollbar-width:thin]\"\n          ref={logRef}\n          role=\"log\"\n          tabIndex={0}\n        >\n          {selected.status === \"queued\" ? (\n            <p className=\"font-sans text-muted-foreground text-xs\">Waiting for earlier stages to finish.</p>\n          ) : (\n            selected.logs.map((line, i) => (\n              <motion.div\n                animate={{ opacity: 1 }}\n                className={cn(\"whitespace-pre-wrap break-words\", lineTone(line))}\n                initial={selected.status === \"running\" && !reduce ? { opacity: 0 } : false}\n                key={`${selected.id}-${i}`}\n                transition={{ duration: 0.15 }}\n              >\n                {line || \" \"}\n              </motion.div>\n            ))\n          )}\n          {selected.status === \"running\" && <span aria-hidden className=\"inline-block h-3 w-1.5 animate-pulse bg-muted-foreground/60 align-middle\" />}\n        </div>\n      </div>\n\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {failed ? (\n          <motion.div\n            animate={{ opacity: 1, y: 0 }}\n            className=\"flex items-center gap-3 border-t bg-destructive/[0.04] px-4 py-3\"\n            exit={{ opacity: 0 }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n            key=\"failed\"\n            transition={{ duration: 0.2, ease }}\n          >\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"font-medium text-destructive-foreground text-xs\">{failed.name} failed</p>\n              <p className=\"truncate text-muted-foreground text-xs\">{failed.error ?? \"See the logs above.\"}</p>\n            </div>\n            <Button className=\"shrink-0\" onClick={() => {\n                setPicked(null);\n                onRetry?.(failed.id);\n              }}\n              size=\"sm\"\n            >\n              <RotateCcwIcon />\n              Retry {failed.name.toLowerCase()}\n            </Button>\n          </motion.div>\n        ) : ready && url ? (\n          <motion.div\n            animate={{ opacity: 1, y: 0 }}\n            className=\"flex items-center gap-2 border-t px-4 py-3\"\n            exit={{ opacity: 0 }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n            key=\"ready\"\n            transition={{ duration: 0.22, ease }}\n          >\n            <motion.span\n              animate={{ scale: 1, opacity: 1 }}\n              className=\"inline-flex h-8 min-w-0 items-center gap-2 rounded-full border bg-background ps-3 pe-1 shadow-xs/5 dark:bg-input/32\"\n              initial={reduce ? false : { scale: 0.94, opacity: 0 }}\n              transition={{ type: \"spring\", stiffness: 420, damping: 26, delay: 0.06 }}\n            >\n              <GlobeIcon className=\"size-3.5 shrink-0 text-success-foreground\" />\n              <span className=\"min-w-0 truncate font-mono text-xs\">{url.replace(/^https?:\\/\\//, \"\")}</span>\n              <button\n                aria-label={copied ? \"Copied\" : \"Copy URL\"}\n                className=\"relative grid size-6 shrink-0 place-items-center rounded-full text-muted-foreground outline-none transition-[color,background-color,scale] after:absolute after:-inset-1 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] [@media(hover:hover)]:hover:bg-accent [@media(hover:hover)]:hover:text-foreground\"\n                onClick={async () => {\n                  try {\n                    await navigator.clipboard.writeText(url);\n                  } catch {}\n                  setCopied(true);\n                }}\n                type=\"button\"\n              >\n                {copied ? <CheckIcon className=\"size-3 text-success-foreground\" /> : <CopyIcon className=\"size-3\" />}\n              </button>\n            </motion.span>\n            <Button className=\"ms-auto shrink-0\" render={<a href={url} rel=\"noreferrer\" target=\"_blank\" />} size=\"sm\" variant=\"outline\">\n              Visit\n              <ExternalLinkIcon />\n            </Button>\n          </motion.div>\n        ) : url ? (\n          <motion.div\n            animate={{ opacity: 1 }}\n            className=\"flex items-center gap-2 border-t px-4 py-3\"\n            exit={{ opacity: 0 }}\n            initial={reduce ? false : { opacity: 0 }}\n            key=\"pending\"\n            transition={{ duration: 0.18, ease }}\n          >\n            <span className=\"inline-flex h-8 min-w-0 items-center gap-2 rounded-full border border-dashed ps-3 pe-3 text-muted-foreground\">\n              <GlobeIcon className=\"size-3.5 shrink-0\" />\n              <span className=\"min-w-0 truncate text-xs\">Preview URL appears once {stages[stages.length - 1]?.name ?? \"deploy\"} passes</span>\n            </span>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  );\n}\n"
    }
  ]
}