{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "incident-banner",
  "type": "registry:component",
  "title": "Incident Banner",
  "description": "A dismissible status banner that moves through investigating, identified, monitoring and resolved: its colour, icon and progress track change with each update, and the timeline unfolds underneath.",
  "categories": [
    "component",
    "Status"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/incident-banner.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { CheckIcon, ChevronDownIcon, EyeIcon, SearchIcon, WrenchIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useId, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type IncidentStage = \"investigating\" | \"identified\" | \"monitoring\" | \"resolved\";\n\nexport interface IncidentUpdate {\n  id: string;\n  stage: IncidentStage;\n  at: string;\n  message: string;\n}\n\nexport interface IncidentBannerProps {\n  title: string;\n  updates: IncidentUpdate[];\n  affected?: string[];\n  timezone?: string;\n  defaultExpanded?: boolean;\n  dismissible?: boolean;\n  onDismiss?: () => void;\n  action?: ReactNode;\n  className?: string;\n}\n\nfunction useHeight<T extends HTMLElement>() {\n  const ref = useRef<T>(null);\n  const [height, setHeight] = useState<number | null>(null);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize[0]?.blockSize ?? el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, height] as const;\n}\n\nconst STAGES: IncidentStage[] = [\"investigating\", \"identified\", \"monitoring\", \"resolved\"];\n\nconst TONE: Record<\n  IncidentStage,\n  { label: string; icon: ReactNode; border: string; tint: string; chip: string; dot: string; ring: string; fill: string }\n> = {\n  investigating: {\n    label: \"Investigating\",\n    icon: <SearchIcon />,\n    border: \"border-destructive/30\",\n    tint: \"bg-destructive/[0.04] dark:bg-destructive/[0.08]\",\n    chip: \"bg-destructive/10 text-destructive-foreground dark:bg-destructive/16\",\n    dot: \"bg-destructive text-white\",\n    ring: \"bg-destructive\",\n    fill: \"bg-destructive\",\n  },\n  identified: {\n    label: \"Identified\",\n    icon: <WrenchIcon />,\n    border: \"border-warning/35\",\n    tint: \"bg-warning/[0.05] dark:bg-warning/[0.08]\",\n    chip: \"bg-warning/12 text-warning-foreground dark:bg-warning/16\",\n    dot: \"bg-warning text-white\",\n    ring: \"bg-warning\",\n    fill: \"bg-warning\",\n  },\n  monitoring: {\n    label: \"Monitoring\",\n    icon: <EyeIcon />,\n    border: \"border-info/30\",\n    tint: \"bg-info/[0.045] dark:bg-info/[0.09]\",\n    chip: \"bg-info/10 text-info-foreground dark:bg-info/16\",\n    dot: \"bg-info text-white\",\n    ring: \"bg-info\",\n    fill: \"bg-info\",\n  },\n  resolved: {\n    label: \"Resolved\",\n    icon: <CheckIcon />,\n    border: \"border-success/30\",\n    tint: \"bg-success/[0.045] dark:bg-success/[0.08]\",\n    chip: \"bg-success/10 text-success-foreground dark:bg-success/16\",\n    dot: \"bg-success text-white\",\n    ring: \"bg-success\",\n    fill: \"bg-success\",\n  },\n};\n\nconst minutes = (hhmm: string) => {\n  const [h, m] = hhmm.split(\":\").map(Number);\n  return h * 60 + m;\n};\nconst span = (a: string, b: string) => {\n  const d = (minutes(b) - minutes(a) + 1440) % 1440;\n  return d >= 60 ? `${Math.floor(d / 60)}h ${d % 60}m` : `${d}m`;\n};\n\nexport function IncidentBanner({\n  title,\n  updates,\n  affected = [],\n  timezone = \"UTC\",\n  defaultExpanded = false,\n  dismissible = true,\n  onDismiss,\n  action,\n  className,\n}: IncidentBannerProps) {\n  const reduce = useReducedMotion();\n  const panelId = useId();\n  const [open, setOpen] = useState(defaultExpanded);\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  const latest = updates[updates.length - 1];\n  const stage = latest?.stage ?? \"investigating\";\n  const tone = TONE[stage];\n  const step = STAGES.indexOf(stage);\n  const first = updates[0];\n  const resolved = stage === \"resolved\";\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 380, damping: 36 };\n\n  return (\n    <section\n      aria-label={`Incident: ${title}`}\n      aria-live=\"polite\"\n      className={cn(\n        \"w-full max-w-[520px] overflow-hidden rounded-2xl border text-card-foreground shadow-xs/5 transition-[background-color,border-color] duration-300\",\n        \"bg-card\",\n        tone.border,\n        className,\n      )}\n    >\n      <div className={cn(\"transition-colors duration-300\", tone.tint)}>\n        <div className=\"flex items-start gap-3 p-4\">\n          <span className=\"relative mt-0.5 flex size-7 shrink-0 items-center justify-center\">\n            {!resolved && mounted && !reduce ? (\n              <motion.span\n                animate={{ opacity: [0.35, 0], scale: [1, 1.7] }}\n                className={cn(\"absolute inset-0 rounded-full\", tone.ring)}\n                initial={{ opacity: 0, scale: 1 }}\n                transition={{ duration: 1.8, repeat: Number.POSITIVE_INFINITY, ease: \"easeOut\" }}\n              />\n            ) : null}\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                animate={{ scale: 1, opacity: 1, rotate: 0 }}\n                className={cn(\n                  \"relative flex size-7 items-center justify-center rounded-full transition-colors duration-300 [&_svg]:size-3.5\",\n                  tone.dot,\n                )}\n                exit={{ scale: 0.8, opacity: 0 }}\n                initial={{ scale: reduce ? 1 : 0.8, opacity: 0, rotate: reduce ? 0 : -30 }}\n                key={stage}\n                transition={spring}\n              >\n                {tone.icon}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n\n          <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n            <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  animate={{ opacity: 1, y: 0 }}\n                  className={cn(\"rounded-full px-2 py-0.5 font-medium text-xs transition-colors duration-300\", tone.chip)}\n                  exit={{ opacity: 0, y: reduce ? 0 : -6 }}\n                  initial={{ opacity: 0, y: reduce ? 0 : 6 }}\n                  key={stage}\n                  transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n                >\n                  {tone.label}\n                </motion.span>\n              </AnimatePresence>\n              <span className=\"text-muted-foreground text-xs tabular-nums\">\n                {resolved && first\n                  ? `${span(first.at, latest.at)} total · ${first.at}–${latest.at} ${timezone}`\n                  : first\n                    ? `Since ${first.at} ${timezone}`\n                    : null}\n              </span>\n            </div>\n            <h3 className=\"text-pretty font-medium text-sm\">{title}</h3>\n            <AnimatePresence initial={false}>\n              {!open ? (\n                <motion.div\n                  animate={{ height: \"auto\", opacity: 1 }}\n                  className=\"overflow-hidden\"\n                  exit={{ height: 0, opacity: 0 }}\n                  initial={{ height: 0, opacity: 0 }}\n                  transition={reduce ? { duration: 0 } : { height: { type: \"spring\", stiffness: 420, damping: 40 }, opacity: { duration: 0.15 } }}\n                >\n                  <AnimatePresence initial={false} mode=\"wait\">\n                    <motion.p\n                      animate={{ opacity: 1 }}\n                      className=\"line-clamp-2 text-pretty text-muted-foreground text-xs\"\n                      exit={{ opacity: 0 }}\n                      initial={{ opacity: 0 }}\n                      key={latest?.id}\n                      transition={{ duration: 0.18 }}\n                    >\n                      {latest?.message}\n                    </motion.p>\n                  </AnimatePresence>\n                </motion.div>\n              ) : null}\n            </AnimatePresence>\n          </div>\n\n          <div className=\"-me-1.5 -mt-1 flex shrink-0 items-center gap-0.5\">\n            {action}\n            {dismissible ? (\n              <Button aria-label=\"Dismiss incident banner\" onClick={onDismiss} size=\"icon-sm\" variant=\"ghost\">\n                <XIcon />\n              </Button>\n            ) : null}\n          </div>\n        </div>\n\n        <div className=\"flex items-center gap-3 px-4 pb-3\">\n          <ol aria-label=\"Incident progress\" className=\"flex flex-1 items-center gap-1\">\n            {STAGES.map((s, i) => (\n              <li className=\"relative h-1 flex-1 overflow-hidden rounded-full bg-foreground/10\" key={s}>\n                <span className=\"sr-only\">{`${TONE[s].label}${i <= step ? \", reached\" : \"\"}`}</span>\n                <motion.span\n                  animate={{ scaleX: i <= step ? 1 : 0 }}\n                  aria-hidden\n                  className={cn(\"absolute inset-0 rounded-full transition-colors duration-300\", tone.fill)}\n                  initial={false}\n                  style={{ originX: 0 }}\n                  transition={{ ...spring, delay: reduce ? 0 : 0.05 }}\n                />\n              </li>\n            ))}\n          </ol>\n          <button\n            aria-controls={panelId}\n            aria-expanded={open}\n            className=\"-me-1.5 inline-flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded-md px-1.5 text-muted-foreground text-xs outline-none transition-[color,background-color,scale] duration-150 ease-out hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\"\n            onClick={() => setOpen((v) => !v)}\n            type=\"button\"\n          >\n            {updates.length} update{updates.length === 1 ? \"\" : \"s\"}\n            <motion.span animate={{ rotate: open ? 180 : 0 }} className=\"inline-flex\" transition={spring}>\n              <ChevronDownIcon className=\"size-3.5\" />\n            </motion.span>\n          </button>\n        </div>\n      </div>\n\n      <AnimatePresence initial={false}>\n        {open ? (\n          <motion.div\n            animate={{ height: \"auto\", opacity: 1 }}\n            className=\"overflow-hidden border-t\"\n            exit={{ height: 0, opacity: 0 }}\n            id={panelId}\n            initial={{ height: 0, opacity: 0 }}\n            transition={reduce ? { duration: 0 } : { height: { type: \"spring\", stiffness: 420, damping: 40 }, opacity: { duration: 0.15 } }}\n          >\n            <div className=\"flex flex-col gap-3 p-4\">\n              {affected.length ? (\n                <div className=\"flex flex-wrap items-center gap-1.5 text-xs\">\n                  <span className=\"text-muted-foreground\">Affected</span>\n                  {affected.map((a) => (\n                    <span className=\"rounded-md border bg-background px-1.5 py-0.5 dark:bg-input/32\" key={a}>\n                      {a}\n                    </span>\n                  ))}\n                </div>\n              ) : null}\n              <TimelineHeight spring={spring}>\n                <ol className=\"relative flex flex-col\">\n                  <AnimatePresence initial={false}>\n                    {[...updates].reverse().map((u, i, arr) => (\n                      <motion.li\n                        animate={{ opacity: 1, y: 0 }}\n                        className=\"relative flex gap-3 pb-3 last:pb-0\"\n                        exit={{ opacity: 0 }}\n                        initial={{ opacity: 0, y: reduce ? 0 : -6 }}\n                        key={u.id}\n                        layout=\"position\"\n                        transition={spring}\n                      >\n                        {i < arr.length - 1 ? (\n                          <span aria-hidden className=\"absolute top-5 bottom-0 left-[7px] w-px bg-border\" />\n                        ) : null}\n                        <span\n                          className={cn(\n                            \"relative mt-1 flex size-[15px] shrink-0 items-center justify-center rounded-full ring-4 ring-card [&_svg]:size-2.5\",\n                            i === 0 ? TONE[u.stage].dot : \"bg-muted text-muted-foreground\",\n                          )}\n                        >\n                          {TONE[u.stage].icon}\n                        </span>\n                        <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n                          <div className=\"flex items-baseline gap-2 text-xs\">\n                            <span className=\"font-medium\">{TONE[u.stage].label}</span>\n                            <span className=\"text-muted-foreground tabular-nums\">\n                              {u.at} {timezone}\n                            </span>\n                          </div>\n                          <p className=\"text-pretty text-muted-foreground text-xs\">{u.message}</p>\n                        </div>\n                      </motion.li>\n                    ))}\n                  </AnimatePresence>\n                </ol>\n              </TimelineHeight>\n            </div>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </section>\n  );\n}\n\nfunction TimelineHeight({ children, spring }: { children: ReactNode; spring: object }) {\n  const [ref, height] = useHeight<HTMLDivElement>();\n  return (\n    <motion.div animate={{ height: height ?? \"auto\" }} className=\"-m-1 overflow-hidden p-1\" initial={false} transition={spring}>\n      <div ref={ref}>{children}</div>\n    </motion.div>\n  );\n}\n"
    }
  ]
}