{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "connection-lost",
  "type": "registry:component",
  "title": "Connection Lost",
  "description": "An offline banner for apps: it counts the changes kept on the device, rolls a retry countdown with backoff, shows liquid dots while reconnecting, then turns into Back online with a drawn check and slides away.",
  "categories": [
    "component",
    "Empty & Error"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json"
  ],
  "files": [
    {
      "path": "registry/devl/connection-lost.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { WifiOffIcon } from \"lucide-react\";\nimport { AnimatePresence, MotionConfig, type MotionValue, motion, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ConnectionPhase = \"hidden\" | \"offline\" | \"reconnecting\" | \"synced\";\n\nexport interface ConnectionLostProps {\n  online: boolean;\n  pendingChanges?: number;\n  retryDelays?: number[];\n  checkConnection?: () => Promise<boolean>;\n  settleAfter?: number;\n  onSynced?: (count: number) => void;\n  onPhaseChange?: (phase: ConnectionPhase) => void;\n  className?: string;\n}\n\nconst ease = [0.23, 1, 0.32, 1] as const;\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\n\nfunction changes(n: number) {\n  return `${n} ${n === 1 ? \"change\" : \"changes\"}`;\n}\n\nfunction Face({ n, turn }: { n: number; turn: MotionValue<number> }) {\n  const y = useTransform(turn, (t) => {\n    let offset = (((n - t) % 10) + 10) % 10;\n    if (offset > 5) offset -= 10;\n    return `${offset * 100}%`;\n  });\n  return (\n    <motion.span aria-hidden className=\"absolute inset-0 text-center\" style={{ y }}>\n      {n}\n    </motion.span>\n  );\n}\n\nfunction RollingDigit({ value }: { value: number }) {\n  const reduce = useReducedMotion();\n  const target = useRef(value);\n  const turn = useSpring(value, { stiffness: 320, damping: 32, mass: 0.7 });\n\n  useEffect(() => {\n    const current = ((target.current % 10) + 10) % 10;\n    let step = value - current;\n    if (step > 5) step -= 10;\n    else if (step < -5) step += 10;\n    target.current += step;\n    if (reduce) turn.jump(target.current);\n    else turn.set(target.current);\n  }, [value, reduce, turn]);\n\n  return (\n    <span className=\"relative inline-block h-[1.4em] w-[0.62em] overflow-hidden leading-[1.4em] [mask-image:linear-gradient(transparent,black_25%,black_75%,transparent)]\">\n      {DIGITS.map((n) => (\n        <Face key={n} n={n} turn={turn} />\n      ))}\n    </span>\n  );\n}\n\nfunction Clock({ seconds }: { seconds: number }) {\n  const m = Math.floor(seconds / 60);\n  const s = String(seconds % 60).padStart(2, \"0\");\n  return (\n    <span className=\"inline-flex items-center font-medium text-foreground tabular-nums\">\n      <span className=\"sr-only\">{`${m}:${s}`}</span>\n      <span aria-hidden className=\"inline-flex items-center\">\n        <RollingDigit value={m} />\n        <span className=\"px-px\">:</span>\n        <RollingDigit value={Number(s[0])} />\n        <RollingDigit value={Number(s[1])} />\n      </span>\n    </span>\n  );\n}\n\nfunction GooDots() {\n  const reduce = useReducedMotion();\n  const filterId = `connection-goo-${useId().replace(/:/g, \"\")}`;\n  const spots = [6, 20, 34];\n\n  return (\n    <span aria-hidden className=\"relative block h-6 w-10 shrink-0 text-foreground\">\n      <svg className=\"absolute size-0\">\n        <filter colorInterpolationFilters=\"sRGB\" height=\"200%\" id={filterId} width=\"160%\" x=\"-30%\" y=\"-50%\">\n          <feGaussianBlur in=\"SourceGraphic\" result=\"blur\" stdDeviation=\"2\" />\n          <feColorMatrix in=\"blur\" values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7\" />\n        </filter>\n      </svg>\n      <span className=\"absolute inset-0\" style={{ filter: `url(#${filterId})` }}>\n        {spots.map((x, i) => (\n          <motion.span\n            animate={reduce ? { opacity: [0.35, 1, 0.35] } : { scale: [1, 0.85, 1] }}\n            className=\"absolute top-1/2 size-2 rounded-full bg-current\"\n            key={x}\n            style={{ left: x - 4, marginTop: -4 }}\n            transition={{ duration: 1.2, repeat: Number.POSITIVE_INFINITY, ease: \"easeInOut\", delay: i * 0.2 }}\n          />\n        ))}\n        {!reduce && (\n          <motion.span\n            animate={{ x: [0, 28, 0] }}\n            className=\"absolute top-1/2 left-px size-2.5 rounded-full bg-current\"\n            style={{ marginTop: -5 }}\n            transition={{ duration: 1.8, repeat: Number.POSITIVE_INFINITY, ease: \"easeInOut\" }}\n          />\n        )}\n      </span>\n    </span>\n  );\n}\n\nfunction DrawnCheck({ className }: { className?: string }) {\n  const reduce = useReducedMotion();\n  return (\n    <svg aria-hidden className={className} fill=\"none\" viewBox=\"0 0 24 24\">\n      <motion.path\n        animate={{ pathLength: 1 }}\n        d=\"M5 12.5l4.5 4.5L19 7.5\"\n        initial={{ pathLength: reduce ? 1 : 0 }}\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth={2.5}\n        transition={{ duration: 0.34, delay: 0.1, ease }}\n      />\n    </svg>\n  );\n}\n\nconst swap = {\n  initial: { opacity: 0, y: 5, filter: \"blur(2px)\" },\n  animate: { opacity: 1, y: 0, filter: \"blur(0px)\" },\n  exit: { opacity: 0, y: -5, filter: \"blur(2px)\" },\n  transition: { duration: 0.2, ease },\n};\n\nexport function ConnectionLost({\n  online,\n  pendingChanges = 0,\n  retryDelays = [8, 15, 30],\n  checkConnection,\n  settleAfter = 2600,\n  onSynced,\n  onPhaseChange,\n  className,\n}: ConnectionLostProps) {\n  const [phase, setPhase] = useState<ConnectionPhase>(online ? \"hidden\" : \"offline\");\n  const [left, setLeft] = useState(retryDelays[0] ?? 8);\n  const [synced, setSynced] = useState(0);\n  const attempt = useRef(0);\n  const run = useRef(0);\n  const onlineRef = useRef(online);\n  const pendingRef = useRef(pendingChanges);\n  const phaseRef = useRef(phase);\n\n  useEffect(() => {\n    onlineRef.current = online;\n    pendingRef.current = pendingChanges;\n  });\n\n  useEffect(() => {\n    phaseRef.current = phase;\n    onPhaseChange?.(phase);\n  }, [phase, onPhaseChange]);\n\n  useEffect(\n    () => () => {\n      run.current += 1;\n    },\n    [],\n  );\n\n  const delayFor = useCallback(\n    (n: number) => retryDelays[Math.min(n, retryDelays.length - 1)] ?? 8,\n    [retryDelays],\n  );\n\n  const reconnect = useCallback(async () => {\n    const token = ++run.current;\n    setPhase(\"reconnecting\");\n    const ok = checkConnection\n      ? await checkConnection().catch(() => false)\n      : await new Promise<boolean>((resolve) => setTimeout(() => resolve(onlineRef.current), 1400));\n    if (token !== run.current) return;\n    if (ok) {\n      attempt.current = 0;\n      setSynced(pendingRef.current);\n      setPhase(\"synced\");\n      onSynced?.(pendingRef.current);\n    } else {\n      attempt.current += 1;\n      setLeft(delayFor(attempt.current));\n      setPhase(\"offline\");\n    }\n  }, [checkConnection, delayFor, onSynced]);\n\n  useEffect(() => {\n    const current = phaseRef.current;\n    if (!online && (current === \"hidden\" || current === \"synced\")) {\n      run.current += 1;\n      attempt.current = 0;\n      setLeft(delayFor(0));\n      setPhase(\"offline\");\n    } else if (online && current === \"offline\") {\n      reconnect();\n    }\n  }, [online, delayFor, reconnect]);\n\n  useEffect(() => {\n    if (phase !== \"offline\") return;\n    if (left <= 0) {\n      reconnect();\n      return;\n    }\n    const t = setTimeout(() => setLeft((l) => l - 1), 1000);\n    return () => clearTimeout(t);\n  }, [phase, left, reconnect]);\n\n  useEffect(() => {\n    if (phase !== \"synced\") return;\n    const t = setTimeout(() => setPhase(\"hidden\"), settleAfter);\n    return () => clearTimeout(t);\n  }, [phase, settleAfter]);\n\n  const savedLine = pendingChanges > 0 ? `${changes(pendingChanges)} saved on this device.` : \"New changes will be saved on this device.\";\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div aria-live=\"polite\" className={cn(\"@container w-full\", className)} role=\"status\">\n        <AnimatePresence>\n          {phase !== \"hidden\" && (\n            <motion.div\n              animate={{ opacity: 1, y: 0, scale: 1 }}\n              className=\"flex items-center gap-2.5 rounded-xl border bg-popover p-2.5 pe-3 @md:gap-3 text-popover-foreground shadow-lg/5\"\n              exit={{ opacity: 0, y: 10, scale: 0.97 }}\n              initial={{ opacity: 0, y: 14, scale: 0.97 }}\n              transition={{ type: \"spring\", stiffness: 420, damping: 34 }}\n            >\n              <span\n                className={cn(\n                  \"flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors duration-300 @md:size-9\",\n                  phase === \"synced\" ? \"bg-success/12 text-success-foreground\" : \"bg-warning/12 text-warning-foreground\",\n                )}\n              >\n                {phase === \"synced\" ? <DrawnCheck className=\"size-4 @md:size-4.5\" /> : <WifiOffIcon className=\"size-4 @md:size-4.5\" />}\n              </span>\n\n              <div className=\"relative min-w-0 flex-1\">\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.div key={phase === \"synced\" ? \"synced\" : phase === \"reconnecting\" ? \"reconnecting\" : \"offline\"} {...swap}>\n                    {phase === \"synced\" ? (\n                      <>\n                        <p className=\"truncate font-medium text-sm\">\n                          Back online\n                          <span className=\"font-normal text-muted-foreground\"> · synced {changes(synced)}</span>\n                        </p>\n                        <p className=\"truncate text-muted-foreground text-xs\">Everything is up to date.</p>\n                      </>\n                    ) : (\n                      <>\n                        <p className=\"truncate font-medium text-sm\">{phase === \"reconnecting\" ? \"Reconnecting\" : \"You’re offline.\"}</p>\n                        <p className=\"truncate text-muted-foreground text-xs\">{savedLine}</p>\n                        {phase === \"offline\" && (\n                          <p className=\"flex items-center gap-1 text-muted-foreground text-xs @md:hidden\">\n                            Retrying in <Clock seconds={left} />\n                          </p>\n                        )}\n                      </>\n                    )}\n                  </motion.div>\n                </AnimatePresence>\n              </div>\n\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {phase === \"offline\" && (\n                  <motion.div className=\"flex shrink-0 items-center gap-2.5\" key=\"retry\" {...swap}>\n                    <span className=\"hidden items-center gap-1 whitespace-nowrap text-muted-foreground text-xs @md:inline-flex\">\n                      Retrying in <Clock seconds={left} />\n                    </span>\n                    <Button aria-label=\"Retry now\" className=\"text-sm active:scale-[0.97]\" onClick={reconnect} size=\"sm\" variant=\"outline\">\n                      <span className=\"@md:hidden\">Retry</span>\n                      <span className=\"hidden @md:inline\">Retry now</span>\n                    </Button>\n                  </motion.div>\n                )}\n                {phase === \"reconnecting\" && (\n                  <motion.div className=\"flex shrink-0 items-center pe-1\" key=\"goo\" {...swap}>\n                    <GooDots />\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </MotionConfig>\n  );\n}\n"
    }
  ]
}