{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "connection-status",
  "type": "registry:ui",
  "title": "Connection Status",
  "description": "Offline, reconnecting and back-online in one pill that morphs between them.",
  "categories": [
    "notifications"
  ],
  "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/connection-status.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type ConnectionState = \"online\" | \"offline\" | \"reconnecting\";\ntype Phase = \"hidden\" | \"offline\" | \"reconnecting\" | \"back\";\n\nconst subscribeOnline = (notify: () => void) => {\n  window.addEventListener(\"online\", notify);\n  window.addEventListener(\"offline\", notify);\n  return () => {\n    window.removeEventListener(\"online\", notify);\n    window.removeEventListener(\"offline\", notify);\n  };\n};\n\n/** The browser's own online flag, live. Always true on the server so nothing flashes on load. */\nexport function useOnlineStatus() {\n  return useSyncExternalStore(\n    subscribeOnline,\n    () => navigator.onLine,\n    () => true,\n  );\n}\n\n/** Seconds until `at`, ticking on the second while `at` is set. null on the server or with no target. */\nfunction useSecondsUntil(at: number | undefined) {\n  const [clock] = useState(() => ({ now: 0 }));\n  const subscribe = useCallback(\n    (notify: () => void) => {\n      if (at === undefined) return () => {};\n      let timer = 0;\n      const tick = () => {\n        clock.now = Date.now();\n        notify();\n        const left = at - clock.now;\n        if (left > 0) timer = window.setTimeout(tick, left % 1000 || 1000);\n      };\n      tick();\n      return () => window.clearTimeout(timer);\n    },\n    [at, clock],\n  );\n  const now = useSyncExternalStore(\n    subscribe,\n    () => clock.now,\n    () => 0,\n  );\n  if (at === undefined || now === 0) return null;\n  return Math.max(0, Math.ceil((at - now) / 1000));\n}\n\nconst expo = `cubic-bezier(${ease.out.join(\",\")})`;\nconst digitTiming = {\n  transformTiming: { duration: 380, easing: expo },\n  spinTiming: { duration: 380, easing: expo },\n  opacityTiming: { duration: 140, easing: \"ease-out\" },\n};\n\nexport type ConnectionStatusProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Drive it from your own socket or sync engine. Left out, it follows the browser's online and offline events. */\n  status?: ConnectionState;\n  /** When the next automatic attempt happens (epoch ms), shown as a countdown while reconnecting. */\n  retryAt?: number;\n  /** Shows a Retry now button while reconnecting. */\n  onRetry?: () => void;\n  /** Changes waiting to sync. Named while offline; counted down to \"synced\" once back. */\n  pending?: number;\n  /** How long \"Back online\" stays after the last change has synced, in ms. */\n  backOnlineFor?: number;\n  /** fixed pins it to the viewport; absolute keeps it inside a positioned parent. */\n  position?: \"fixed\" | \"absolute\";\n  placement?: \"top\" | \"bottom\";\n};\n\nconst plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`;\n\nexport function ConnectionStatus({\n  status: statusProp,\n  retryAt,\n  onRetry,\n  pending = 0,\n  backOnlineFor = 2400,\n  position = \"fixed\",\n  placement = \"bottom\",\n  className,\n  ...rest\n}: ConnectionStatusProps) {\n  const reduce = useReducedMotion();\n  const browserOnline = useOnlineStatus();\n  const status: ConnectionState = statusProp ?? (browserOnline ? \"online\" : \"offline\");\n  const seconds = useSecondsUntil(status === \"reconnecting\" ? retryAt : undefined);\n\n  // \"Back online\" is a moment, not a state: it starts when the connection returns\n  // and remembers how many changes it has to sync on the way out.\n  const [previous, setPrevious] = useState(status);\n  const [back, setBack] = useState(false);\n  const [queued, setQueued] = useState(0);\n  const [trying, setTrying] = useState(false);\n  const [lastRetryAt, setLastRetryAt] = useState(retryAt);\n  if (previous !== status) {\n    setPrevious(status);\n    setTrying(false);\n    if (status === \"online\" && previous !== \"online\") {\n      setBack(true);\n      setQueued(pending);\n    } else if (status !== \"online\") setBack(false);\n  }\n  if (lastRetryAt !== retryAt) {\n    setLastRetryAt(retryAt);\n    setTrying(false);\n  }\n\n  // Leave only once everything has synced, and then after a beat so the tick is seen.\n  useEffect(() => {\n    if (!back || pending > 0) return;\n    const t = window.setTimeout(() => setBack(false), backOnlineFor);\n    return () => window.clearTimeout(t);\n  }, [back, pending, backOnlineFor]);\n\n  const phase: Phase = status === \"offline\" ? \"offline\" : status === \"reconnecting\" ? \"reconnecting\" : back ? \"back\" : \"hidden\";\n  const attempting = trying || seconds === 0;\n\n  // The pill's width follows its content on a spring instead of snapping.\n  const inner = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState<number | null>(null);\n  useLayoutEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setWidth(el.offsetWidth));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [phase]);\n\n  const fromEdge = placement === \"bottom\" ? 16 : -16;\n  const content = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, y: fromEdge / 2, filter: \"blur(3px)\" },\n    animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n    exit: reduce ? { opacity: 0 } : { opacity: 0, y: -fromEdge / 2, filter: \"blur(3px)\", transition: { duration: 0.14, ease: ease.in } },\n    transition: { duration: reduce ? 0.12 : 0.24, ease: ease.out },\n  };\n\n  let spoken = \"\";\n  if (phase === \"offline\") spoken = pending ? `You’re offline. ${plural(pending, \"change\", \"changes\")} will sync when you reconnect.` : \"You’re offline. Changes will sync when you reconnect.\";\n  if (phase === \"reconnecting\") spoken = \"Connection lost. Reconnecting.\";\n  if (phase === \"back\") spoken = \"Back online.\";\n\n  return (\n    <div\n      data-slot=\"connection-status\"\n      data-state={phase}\n      className={cn(\n        \"pointer-events-none inset-x-4 z-(--z-toast) flex justify-center @container\",\n        position === \"fixed\" ? \"fixed\" : \"absolute\",\n        placement === \"bottom\" ? (position === \"fixed\" ? \"bottom-[max(16px,env(safe-area-inset-bottom))]\" : \"bottom-4\") : \"top-4\",\n        className,\n      )}\n      {...rest}\n    >\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {spoken}\n      </span>\n      <AnimatePresence>\n        {phase !== \"hidden\" && (\n          <motion.div\n            key=\"pill\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: fromEdge, scale: 0.96, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\", width: width ?? \"auto\" }}\n            exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, y: fromEdge * 0.75, scale: 0.98, transition: { duration: 0.18, ease: ease.in } }}\n            transition={reduce ? { duration: 0.15, width: { duration: 0 } } : { ...spring.snappy, width: spring.soft }}\n            className=\"pointer-events-auto relative h-10 overflow-hidden rounded-full border border-line-2 bg-raised shadow-pop\"\n          >\n            <div ref={inner} className=\"flex h-full w-max max-w-[calc(100cqw-2px)] items-center\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {phase === \"offline\" && (\n                  <motion.div key=\"offline\" {...content} className=\"flex h-10 min-w-0 items-center gap-2.5 pl-3 pr-4\">\n                    <WifiOff />\n                    <p className=\"flex min-w-0 items-baseline gap-2 overflow-hidden whitespace-nowrap text-[13px]\">\n                      <span className=\"shrink-0 font-medium text-fg\">You’re offline</span>\n                      <span className=\"min-w-0 truncate text-[12.5px] text-fg-3\">\n                        {pending ? `${plural(pending, \"change\", \"changes\")} will sync later` : \"Changes will sync later\"}\n                      </span>\n                    </p>\n                  </motion.div>\n                )}\n                {phase === \"reconnecting\" && (\n                  <motion.div key=\"reconnecting\" {...content} className={cn(\"flex h-10 min-w-0 items-center gap-2.5 pl-3\", onRetry ? \"pr-1\" : \"pr-4\")}>\n                    <Spinner />\n                    <p className=\"flex min-w-0 items-baseline gap-2 overflow-hidden whitespace-nowrap text-[13px]\">\n                      <span className=\"shrink-0 font-medium text-fg\">Reconnecting</span>\n                      <span aria-hidden className=\"inline-flex min-w-0 items-baseline overflow-hidden text-[12.5px] text-fg-3 tabular\">\n                        {attempting || seconds === null ? (\n                          \"Trying now…\"\n                        ) : (\n                          <>\n                            {/* Narrow containers keep just the seconds. */}\n                            <span className=\"@max-sm:hidden\">Trying again in&nbsp;</span>\n                            <NumberFlow value={seconds} suffix=\"s\" {...digitTiming} />\n                          </>\n                        )}\n                      </span>\n                    </p>\n                    {onRetry && (\n                      <button\n                        type=\"button\"\n                        disabled={attempting}\n                        onClick={() => {\n                          setTrying(true);\n                          onRetry();\n                        }}\n                        className={cn(\n                          \"relative ml-1 h-8 shrink-0 rounded-full px-3 text-[12.5px] font-medium text-fg outline-none\",\n                          \"transition-[background-color,opacity,scale] duration-150 hover:bg-hover active:scale-[0.96] active:duration-75 disabled:pointer-events-none disabled:opacity-40\",\n                          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n                          \"before:absolute before:-inset-y-1.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n                        )}\n                      >\n                        Retry now\n                      </button>\n                    )}\n                  </motion.div>\n                )}\n                {phase === \"back\" && (\n                  <motion.div key=\"back\" {...content} className=\"flex h-10 min-w-0 items-center gap-2.5 pl-3 pr-4\">\n                    <Tick reduce={!!reduce} />\n                    <p className=\"flex min-w-0 items-baseline gap-2 overflow-hidden whitespace-nowrap text-[13px]\">\n                      <span className=\"shrink-0 font-medium text-fg\">Back online</span>\n                      {queued > 0 && (\n                        <span className=\"inline-flex min-w-0 items-baseline overflow-hidden text-[12.5px] text-fg-3 tabular\">\n                          {pending > 0 ? (\n                            <>\n                              Syncing&nbsp;\n                              <NumberFlow value={Math.max(1, queued - pending + 1)} {...digitTiming} />\n                              &nbsp;of {queued}\n                            </>\n                          ) : (\n                            `${plural(queued, \"change\", \"changes\")} synced`\n                          )}\n                        </span>\n                      )}\n                    </p>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nconst svg = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.4, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n\nfunction WifiOff() {\n  return (\n    <svg {...svg} className=\"shrink-0 text-fg-2\">\n      <path d=\"M2.2 6.2a8.4 8.4 0 0 1 3.1-1.9M9.6 4.1a8.4 8.4 0 0 1 4.2 2.1M4.3 8.4a5.4 5.4 0 0 1 2.3-1.3M11.7 8.4a5.4 5.4 0 0 0-1.2-.8M6.4 10.6a2.4 2.4 0 0 1 3.2 0\" />\n      <circle cx=\"8\" cy=\"12.9\" r=\".7\" fill=\"currentColor\" stroke=\"none\" />\n      <path d=\"m2.75 2.75 10.5 10.5\" />\n    </svg>\n  );\n}\n\nfunction Spinner() {\n  return (\n    <svg {...svg} className=\"shrink-0 animate-spin text-fg-2 [animation-duration:900ms] motion-reduce:animate-none\">\n      <circle cx=\"8\" cy=\"8\" r=\"5.75\" className=\"opacity-25\" />\n      <path d=\"M8 2.25a5.75 5.75 0 0 1 5.75 5.75\" />\n    </svg>\n  );\n}\n\nfunction Tick({ reduce }: { reduce: boolean }) {\n  return (\n    <motion.span\n      className=\"grid size-4 shrink-0 place-items-center rounded-full bg-success text-frame\"\n      initial={reduce ? false : { scale: 0.4, opacity: 0 }}\n      animate={{ scale: 1, opacity: 1 }}\n      transition={spring.pop}\n    >\n      <svg width=\"10\" height=\"10\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n        <motion.path\n          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n          initial={reduce ? false : { pathLength: 0 }}\n          animate={{ pathLength: 1 }}\n          transition={{ duration: 0.32, ease: ease.out, delay: 0.1 }}\n        />\n      </svg>\n    </motion.span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/connection-status"
}
