{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chat-notice","type":"registry:component","title":"Error and limit states","description":"Calm, specific notices for failed answers, being offline, rate limits and chats that grow too long.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/status-button.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/chat-notice.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AlertCircle, ChevronDown, Hourglass, Layers, RotateCcw, WifiOff } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * CHAT NOTICE: the moments an AI app has to say no\n *\n *   error       the answer failed; Retry opens a spinner and\n *               morphs to \"Retrying\" while it runs\n *   offline     no connection; \"Reconnecting\" opens in\n *   rate-limit  out of messages; the countdown rolls down to\n *               the reset, then the hourglass blurs into a check\n *               that draws itself and the words morph to \"You can\n *               send messages again\"\n *   context     the chat is too long; start fresh with a summary\n *\n * Calm, inline and specific: what happened, what happens next,\n * and the one action that helps. Technical details fold away.\n * ───────────────────────────────────────────────────────── */\n\nexport type ChatNoticeKind = \"error\" | \"offline\" | \"rate-limit\" | \"context\";\n\nexport interface ChatNoticeProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  kind: ChatNoticeKind;\n  title?: string;\n  description?: string;\n  /** Rate limit: when sending is allowed again (ms). Shows a live countdown. */\n  resetAt?: number;\n  onReset?: () => void;\n  onRetry?: () => void;\n  /** Shows a spinner on Retry, or \"Reconnecting\" when offline. */\n  retrying?: boolean;\n  onUpgrade?: () => void;\n  upgradeLabel?: string;\n  /** Context: start a new chat carrying a summary of this one. */\n  onSummarize?: () => void;\n  onNewChat?: () => void;\n  /** Technical details, e.g. an error message or request ID. */\n  details?: string;\n}\n\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst BUTTON = `inline-flex h-7 items-center gap-1.5 rounded-full px-3 text-[12px] font-medium transition-[background-color,color,transform] duration-300 active:scale-[0.97] ${FOCUS}`;\nconst SECONDARY = `${BUTTON} text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent`;\nconst PRIMARY = `${BUTTON} bg-primary text-primary-foreground hover:bg-primary/90`;\nconst TWO_DIGITS = { minimumIntegerDigits: 2 };\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** A piece of a line that opens out of nothing and folds back into it. */\nfunction Reveal({ show, reduced, className = \"\", children }: { show: boolean; reduced: boolean; className?: string; children: React.ReactNode }) {\n  return (\n    <span\n      aria-hidden={!show || undefined}\n      className={`inline-grid align-bottom ${className}`}\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        filter: show ? \"none\" : \"blur(3px)\",\n        transition: reduced ? \"none\" : `grid-template-columns 460ms ${MORPH}, opacity ${show ? \"320ms\" : \"160ms\"} ${MORPH}, filter 320ms ${MORPH}`,\n      }}\n    >\n      <span className=\"min-w-0 whitespace-nowrap [clip-path:inset(-4px_-2px)]\">{children}</span>\n    </span>\n  );\n}\n\nfunction useCountdown(resetAt: number | undefined, onReset?: () => void) {\n  // Unknown until mounted, so the first paint never claims the limit has already reset.\n  const [now, setNow] = React.useState<number | null>(null);\n  const fired = React.useRef(false);\n  React.useEffect(() => {\n    if (resetAt === undefined) return;\n    fired.current = false;\n    const tick = () => {\n      const t = Date.now();\n      setNow(t);\n      if (t >= resetAt && !fired.current) {\n        fired.current = true;\n        onReset?.();\n      }\n    };\n    tick();\n    const timer = window.setInterval(tick, 1000);\n    return () => window.clearInterval(timer);\n  }, [resetAt, onReset]);\n  return resetAt === undefined || now === null ? undefined : Math.max(0, resetAt - now);\n}\n\n/** \"4:59\" rolling down a second at a time (\"1h 05m\" when it's over an hour); seconds roll back 00 → 59 like a clock. */\nfunction Countdown({ ms, reduced }: { ms: number; reduced: boolean }) {\n  const s = Math.max(0, Math.ceil(ms / 1000));\n  const hours = Math.floor(s / 3600);\n  const minutes = hours ? Math.floor((s % 3600) / 60) : Math.floor(s / 60);\n  return (\n    <span className=\"inline-flex items-baseline tabular-nums\">\n      <Reveal show={hours > 0} reduced={reduced}>\n        <NumberRoll value={hours} suffix=\"h\" duration={600} />\n        &nbsp;\n      </Reveal>\n      <NumberRoll value={minutes} suffix={hours ? \"m\" : \"\"} format={hours ? TWO_DIGITS : undefined} direction=\"down\" duration={600} />\n      <Reveal show={!hours} reduced={reduced}>\n        :<NumberRoll value={s % 60} format={TWO_DIGITS} direction=\"down\" duration={600} />\n      </Reveal>\n    </span>\n  );\n}\n\nexport function ChatNotice({\n  kind,\n  title,\n  description,\n  resetAt,\n  onReset,\n  onRetry,\n  retrying = false,\n  onUpgrade,\n  upgradeLabel = \"Upgrade\",\n  onSummarize,\n  onNewChat,\n  details,\n  className = \"\",\n  ...props\n}: ChatNoticeProps) {\n  const reduced = useReducedMotion();\n  const left = useCountdown(kind === \"rate-limit\" ? resetAt : undefined, onReset);\n  const ready = left === 0;\n  const [detailsOpen, setDetailsOpen] = React.useState(false);\n  const detailsId = React.useId();\n  const counting = kind === \"rate-limit\" && description === undefined && resetAt !== undefined && !ready;\n\n  const preset = {\n    error: {\n      title: \"Something went wrong\",\n      description: \"The answer didn’t finish. Your message is safe.\",\n    },\n    offline: {\n      title: \"You’re offline\",\n      description: \"We’ll pick up where you left off when your connection is back.\",\n    },\n    \"rate-limit\": {\n      title: ready ? \"You can send messages again\" : \"You’ve reached your limit\",\n      description: ready ? \"Your limit has reset.\" : resetAt !== undefined ? \"It resets in\" : \"It resets soon.\",\n    },\n    context: {\n      title: \"This chat is getting long\",\n      description: \"Earlier messages may be forgotten. Start fresh with a summary to keep answers sharp.\",\n    },\n  }[kind];\n\n  const iconKey = kind === \"rate-limit\" && ready ? \"ready\" : kind;\n  const icons: Record<string, React.ReactNode> = {\n    error: <AlertCircle className=\"size-4 text-red-500\" />,\n    offline: <WifiOff className=\"size-4 text-muted-foreground\" />,\n    \"rate-limit\": <Hourglass className=\"size-4 text-amber-500\" />,\n    ready: (\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4 text-emerald-600 dark:text-emerald-400\">\n        <path\n          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          pathLength={1}\n          strokeDasharray={1}\n          style={{ strokeDashoffset: iconKey === \"ready\" ? 0 : 1, transition: iconKey === \"ready\" && !reduced ? `stroke-dashoffset 420ms ${MORPH} 140ms` : \"none\" }}\n        />\n      </svg>\n    ),\n    context: <Layers className=\"size-4 text-muted-foreground\" />,\n  };\n  const shownIcons = kind === \"rate-limit\" ? [\"rate-limit\", \"ready\"] : [kind];\n  const resetsAt = resetAt !== undefined && left !== undefined ? new Date(resetAt).toLocaleTimeString([], { hour: \"numeric\", minute: \"2-digit\" }) : undefined;\n\n  return (\n    <div\n      role={kind === \"error\" ? \"alert\" : \"status\"}\n      className={`flex flex-col gap-3 rounded-xl border border-border bg-background px-3.5 py-3 animate-[ui-fade-up_320ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none sm:flex-row sm:items-start ${className}`}\n      {...props}\n    >\n      <div className=\"flex min-w-0 flex-1 gap-3\">\n        <span aria-hidden=\"true\" className=\"relative mt-0.5 flex size-4 shrink-0\">\n          {shownIcons.map((k) => (\n            <span key={k} className=\"absolute inset-0 flex items-center justify-center\" style={swap(k === iconKey, reduced)}>\n              {icons[k]}\n            </span>\n          ))}\n        </span>\n        <div className=\"min-w-0\">\n          <p className=\"text-[13px] font-medium leading-5 text-foreground\">\n            <TextMorph animateWidth={false}>{title ?? preset.title}</TextMorph>\n          </p>\n          <p className=\"text-[12.5px] leading-5 text-muted-foreground\">\n            <TextMorph animateWidth={false}>{description ?? preset.description}</TextMorph>\n            {kind === \"rate-limit\" && description === undefined && resetAt !== undefined && (\n              // The ticking digits are hidden from screen readers (the notice is a live region); they hear the time it resets instead.\n              <Reveal show={counting && left !== undefined} reduced={reduced}>\n                <span aria-hidden=\"true\">\n                  &nbsp;\n                  {left !== undefined && <Countdown ms={left} reduced={reduced} />}.\n                </span>\n                {resetsAt && counting && <span className=\"sr-only\"> at {resetsAt}</span>}\n              </Reveal>\n            )}\n            {kind === \"offline\" && (\n              <Reveal show={retrying} reduced={reduced}>\n                <span className=\"ml-1.5 inline-flex items-center gap-1.5 text-foreground/70\">\n                  <span\n                    aria-hidden=\"true\"\n                    className={`size-1.5 rounded-full bg-amber-500 motion-reduce:animate-none ${retrying ? \"animate-[ui-breathe_1.4s_ease-in-out_infinite]\" : \"\"}`}\n                  />\n                  Reconnecting\n                </span>\n              </Reveal>\n            )}\n          </p>\n          {details && (\n            <>\n              <button\n                type=\"button\"\n                aria-expanded={detailsOpen}\n                aria-controls={detailsId}\n                onClick={() => setDetailsOpen((o) => !o)}\n                className={`mt-1.5 flex items-center gap-1 rounded text-[11.5px] text-muted-foreground transition-colors hover:text-foreground ${FOCUS}`}\n              >\n                <TextMorph>{detailsOpen ? \"Hide details\" : \"Show details\"}</TextMorph>\n                <ChevronDown\n                  aria-hidden=\"true\"\n                  className=\"size-3 transition-transform duration-300 motion-reduce:transition-none\"\n                  style={{ transform: detailsOpen ? \"rotate(180deg)\" : \"none\", transitionTimingFunction: MORPH }}\n                />\n              </button>\n              <div\n                id={detailsId}\n                inert={!detailsOpen}\n                className=\"grid\"\n                style={{\n                  gridTemplateRows: detailsOpen ? \"1fr\" : \"0fr\",\n                  opacity: detailsOpen ? 1 : 0,\n                  transition: reduced ? \"none\" : `grid-template-rows 420ms ${MORPH}, opacity ${detailsOpen ? \"320ms\" : \"160ms\"} ${MORPH}`,\n                }}\n              >\n                <div className=\"min-h-0 overflow-hidden\">\n                  <p className=\"pt-1 break-all font-mono text-[11px] leading-5 text-muted-foreground\">{details}</p>\n                </div>\n              </div>\n            </>\n          )}\n        </div>\n      </div>\n\n      <div className=\"flex shrink-0 flex-wrap items-center gap-2 pl-7 sm:pl-0\">\n        {(kind === \"error\" || kind === \"offline\") && onRetry && (\n          <StatusButton\n            variant=\"outline\"\n            size=\"sm\"\n            icon={<RotateCcw />}\n            status={retrying ? \"pending\" : \"idle\"}\n            labels={kind === \"offline\" ? { idle: \"Try now\", pending: \"Trying\" } : { idle: \"Retry\", pending: \"Retrying\" }}\n            onClick={onRetry}\n          />\n        )}\n        {kind === \"rate-limit\" && onUpgrade && (\n          // Folds away once the limit has reset.\n          <Reveal show={!ready} reduced={reduced}>\n            <span className=\"block p-0.5\">\n              <button type=\"button\" inert={ready} onClick={onUpgrade} className={PRIMARY}>\n                {upgradeLabel}\n              </button>\n            </span>\n          </Reveal>\n        )}\n        {kind === \"context\" && onNewChat && (\n          <button type=\"button\" onClick={onNewChat} className={SECONDARY}>\n            New chat\n          </button>\n        )}\n        {kind === \"context\" && onSummarize && (\n          <button type=\"button\" onClick={onSummarize} className={PRIMARY}>\n            Continue with summary\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-fade-up":{"from":{"opacity":"0","transform":"translateY(4px)"},"to":{"opacity":"1","transform":"none"}},"@keyframes ui-breathe":{"0%, 100%":{"transform":"scale(0.82)","opacity":"0.55"},"50%":{"transform":"scale(1)","opacity":"1"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/chat-notice"}