{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "api-error-card",
  "title": "API Error Card",
  "description": "An LLM API failure card whose recovery is chosen by error class — a countdown ring with one-shot opt-in auto-retry for 429s, a key-settings exit for auth, a status-page exit for overload, and trim / summarize exits when the context is too long.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/api-error-card.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  Activity,\n  ArrowUpRight,\n  Check,\n  CircleAlert,\n  Copy,\n  Gauge,\n  KeyRound,\n  Layers,\n  RotateCw,\n  Scissors,\n  Server,\n  ServerCrash,\n  Sparkles,\n  WifiOff,\n  X,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/* -------------------------------------------------------------------------- *\n * The taxonomy\n *\n * An LLM call fails in a handful of *materially different* ways, and the only\n * thing that matters to the user is which recovery actually works. \"Try again\"\n * fixes an overloaded model and does nothing at all for a rejected API key or a\n * request that is simply too big — offering it there is a button guaranteed to\n * reproduce the error.\n *\n * So the class is the input, and everything else (copy, which slots render,\n * whether retry exists, whether it is gated by a countdown) is looked up from\n * it. Adding a class means adding one row to RECIPES, not another branch in the\n * JSX.\n * -------------------------------------------------------------------------- */\n\nexport type ApiErrorKind =\n  | \"rate_limited\"\n  | \"auth\"\n  | \"overloaded\"\n  | \"server\"\n  | \"context_length\"\n  | \"timeout\"\n  | \"unknown\"\n\n/** The recovery affordances a class can ask the consumer for. */\nexport type ApiErrorSlotId = \"settings\" | \"status\" | \"trim\" | \"summarize\"\n\n/**\n * A slot is a link **or** a click handler, never neither: the union turns a\n * decorative button that goes nowhere into a compile error instead of a support\n * ticket.\n */\nexport type ApiErrorAction =\n  | { label: string; href: string; onClick?: never }\n  | { label: string; onClick: () => void; href?: never }\n\ntype IconComponent = React.ComponentType<{\n  className?: string\n  \"aria-hidden\"?: boolean | \"true\" | \"false\"\n}>\n\ninterface Recipe {\n  title: string\n  /** Why this happened and what will not fix it — the sentence that ends the retry loop. */\n  hint: string\n  icon: IconComponent\n  /** Is sending the same request again a plausible fix at all? */\n  retryable: boolean\n  /** Slots this class asks for, in the order they should be offered. */\n  slots: ApiErrorSlotId[]\n}\n\nconst RECIPES: Record<ApiErrorKind, Recipe> = {\n  rate_limited: {\n    title: \"Rate limited\",\n    hint: \"The provider is throttling this key. The wait is enforced on their side — sending again before it ends only spends another request on another 429.\",\n    icon: Gauge,\n    retryable: true,\n    slots: [],\n  },\n  auth: {\n    title: \"Authentication failed\",\n    hint: \"The provider rejected the credentials. A retry sends the same key and fails identically — check that the key exists, has not expired, and is allowed to call this model.\",\n    icon: KeyRound,\n    retryable: false,\n    slots: [\"settings\"],\n  },\n  overloaded: {\n    title: \"Model overloaded\",\n    hint: \"The provider is at capacity and shed this request before the model saw it. Nothing was billed, and backing off is the entire fix.\",\n    icon: Server,\n    retryable: true,\n    slots: [\"status\"],\n  },\n  server: {\n    title: \"Provider error\",\n    hint: \"The request failed inside the provider, not in your app. Sending it again is safe — reuse the same idempotency key if you have one.\",\n    icon: ServerCrash,\n    retryable: true,\n    slots: [\"status\"],\n  },\n  context_length: {\n    title: \"Context too long\",\n    hint: \"The request is larger than the model's context window. The same payload will fail the same way forever — it has to get smaller before it can be sent.\",\n    icon: Scissors,\n    retryable: false,\n    slots: [\"trim\", \"summarize\"],\n  },\n  timeout: {\n    title: \"Request timed out\",\n    hint: \"No response arrived before the client deadline. The model may have finished anyway, so a blind retry can pay for the same completion twice.\",\n    icon: WifiOff,\n    retryable: true,\n    slots: [],\n  },\n  unknown: {\n    title: \"Request failed\",\n    hint: \"The provider returned an error this app does not recognise. The request id below is the one thing support will ask for.\",\n    icon: CircleAlert,\n    retryable: true,\n    slots: [],\n  },\n}\n\nconst SLOT_ICON: Record<ApiErrorSlotId, IconComponent> = {\n  settings: KeyRound,\n  status: Activity,\n  trim: Scissors,\n  summarize: Sparkles,\n}\n\n/**\n * Map a transport-level failure onto a class. Provider `code` strings win over\n * the HTTP status because they are more specific: a context overflow arrives as\n * 400 + `context_length_exceeded`, and a bare 400 is otherwise indistinguishable\n * from a malformed request.\n *\n * Exported so it can be replaced wholesale — it is the one part of this\n * component every provider disagrees about.\n */\nexport function classifyApiError(status?: number, code?: string): ApiErrorKind {\n  const c = (code ?? \"\").toLowerCase()\n  if (c.includes(\"context_length\") || c.includes(\"context_window\") || c.includes(\"too_many_tokens\")) {\n    return \"context_length\"\n  }\n  if (c.includes(\"timeout\") || c.includes(\"timed_out\") || c.includes(\"etimedout\") || c.includes(\"econnreset\")) {\n    return \"timeout\"\n  }\n  if (c.includes(\"rate_limit\") || c.includes(\"quota\")) return \"rate_limited\"\n  if (c.includes(\"api_key\") || c.includes(\"authentication\") || c.includes(\"permission\")) return \"auth\"\n  if (c.includes(\"overloaded\") || c.includes(\"capacity\")) return \"overloaded\"\n\n  switch (status) {\n    case 401:\n    case 403:\n      return \"auth\"\n    case 408:\n    case 504:\n      return \"timeout\"\n    case 413:\n      return \"context_length\"\n    case 429:\n      return \"rate_limited\"\n    case 503:\n    // \"overloaded\" ships as 529 at more than one provider.\n    case 529:\n      return \"overloaded\"\n    default:\n      if (typeof status === \"number\" && status >= 500) return \"server\"\n      return \"unknown\"\n  }\n}\n\n/* -------------------------------------------------------------------------- *\n * The clock\n *\n * `retryAt` is an **absolute instant** on purpose. A `Retry-After: 30` header is\n * only true at the moment the response was produced; turning it into a countdown\n * means anchoring it to a local instant, and the caller — who knows when the\n * response landed — is the only one who can do that honestly\n * (`Date.now() + seconds * 1000`). Accepting the seconds here would silently\n * restart the whole wait every time the card remounts.\n *\n * Every tick recomputes `target - Date.now()`. Never \"previous value minus one\":\n * a background tab is throttled to roughly one timer per minute, so a\n * decrementing clock comes back from a five-minute sleep five minutes wrong.\n * -------------------------------------------------------------------------- */\n\nconst TICK_MS = 250\nconst RING_RADIUS = 20\nconst RING_LENGTH = 2 * Math.PI * RING_RADIUS\n\ninterface Tick {\n  /** ms left until the retry window opens. */\n  remaining: number\n  /** Length of this wait in ms, frozen for the cycle — the ring's denominator. */\n  window: number\n}\n\n/** NaN / Infinity means \"no wait was given\", never \"wait forever\". */\nfunction toInstant(value: string | number | Date): number | null {\n  const ms = value instanceof Date ? value.getTime() : new Date(value).getTime()\n  return Number.isFinite(ms) ? ms : null\n}\n\nfunction ordinal(n: number): string {\n  const rem100 = n % 100\n  if (rem100 >= 11 && rem100 <= 13) return `${n}th`\n  switch (n % 10) {\n    case 1:\n      return `${n}st`\n    case 2:\n      return `${n}nd`\n    case 3:\n      return `${n}rd`\n    default:\n      return `${n}th`\n  }\n}\n\nfunction spokenEnglish(totalSeconds: number): string {\n  const s = Math.max(0, Math.round(totalSeconds))\n  const hours = Math.floor(s / 3600)\n  const minutes = Math.floor((s % 3600) / 60)\n  const seconds = s % 60\n  const parts: string[] = []\n  if (hours) parts.push(`${hours} hour${hours === 1 ? \"\" : \"s\"}`)\n  if (minutes) parts.push(`${minutes} minute${minutes === 1 ? \"\" : \"s\"}`)\n  if (seconds || parts.length === 0) parts.push(`${seconds} second${seconds === 1 ? \"\" : \"s\"}`)\n  return parts.join(\" \")\n}\n\n/**\n * Ring labels are budgeted to ~4 glyphs, and the unit is chosen by the *window*\n * rather than by what is left — otherwise a 90 second wait jumps from \"1:00\" to\n * \"59s\" halfway through and reads like a bug.\n */\nfunction formatRing(seconds: number, windowSeconds: number): string {\n  const s = Math.max(0, seconds)\n  if (s >= 3600) return `${Math.ceil(s / 60)}m`\n  if (windowSeconds >= 60) return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`\n  return `${s}s`\n}\n\n/* -------------------------------------------------------------------------- *\n * Copy\n * -------------------------------------------------------------------------- */\n\nexport interface ApiErrorCardLabels {\n  /** Line beside the ring while the wait runs. */\n  waiting: string\n  waitingNote: string\n  /** Replaces it once the window opens. */\n  unlocked: string\n  unlockedNote: string\n  /** The retry button never carries the ticking number, so its accessible name is stable. */\n  retry: string\n  retrying: string\n  /** Shown once this cycle's single retry has been spent. */\n  retrySent: string\n  autoRetry: string\n  autoRetryOn: string\n  autoRetryOff: string\n  autoRetryStopped: (attempts: number) => string\n  attemptChip: (attempt: number) => string\n  requestId: string\n  copyRequestId: string\n  requestIdCopied: string\n  dismiss: string\n  ringLabel: (spoken: string) => string\n  ringOpenLabel: string\n  /** Announced once when the card arms — the arrival of the error is the news. */\n  armAnnouncement: (title: string, waiting: boolean) => string\n  /** Announced once when the wait ends and the user is back in control. */\n  unlockedAnnouncement: string\n  /** Announced once when the automatic path spends the cycle's retry. */\n  autoRetryAnnouncement: string\n}\n\nconst DEFAULT_LABELS: ApiErrorCardLabels = {\n  waiting: \"Retrying is blocked for now\",\n  waitingNote: \"Anything sent before the window opens comes back as the same error.\",\n  unlocked: \"The retry window is open\",\n  unlockedNote: \"Send it again whenever you are ready.\",\n  retry: \"Try again\",\n  retrying: \"Retrying…\",\n  retrySent: \"Retry sent\",\n  autoRetry: \"Retry automatically\",\n  autoRetryOn: \"Fires once, the moment the window opens.\",\n  autoRetryOff: \"Off — you decide when the request goes out again.\",\n  autoRetryStopped: attempts =>\n    `Automatic retries stopped after ${attempts} attempts. Send it manually or change the request.`,\n  attemptChip: attempt => `${ordinal(attempt)} failure in a row`,\n  requestId: \"Request\",\n  copyRequestId: \"Copy request id\",\n  requestIdCopied: \"Request id copied\",\n  dismiss: \"Dismiss this error\",\n  ringLabel: spoken => `${spoken} until this request can be sent again`,\n  ringOpenLabel: \"The retry window is open\",\n  armAnnouncement: (title, waiting) =>\n    waiting ? `${title}. Retrying is blocked until the wait ends.` : `${title}.`,\n  unlockedAnnouncement: \"The wait is over. You can try again now.\",\n  autoRetryAnnouncement: \"The wait is over. Retrying automatically now.\",\n}\n\n/* -------------------------------------------------------------------------- *\n * Request id — the one string support will ask for\n * -------------------------------------------------------------------------- */\n\nfunction CopyRequestId({ copiedLabel, label, value }: { copiedLabel: string; label: string; value: string }) {\n  const [copied, setCopied] = React.useState(false)\n  const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n  // The reset timer is the only thing this button owns, and it must not outlive\n  // it: a card dismissed one frame after the click would set state on a corpse.\n  React.useEffect(\n    () => () => {\n      if (timer.current !== null) clearTimeout(timer.current)\n    },\n    [],\n  )\n\n  const handleCopy = () => {\n    const clipboard = typeof navigator === \"undefined\" ? undefined : navigator.clipboard\n    // An insecure context has no clipboard. Stay silent rather than throwing —\n    // the id is still selectable text, which is the fallback that always works.\n    if (!clipboard) return\n    clipboard\n      .writeText(value)\n      .then(() => {\n        setCopied(true)\n        if (timer.current !== null) clearTimeout(timer.current)\n        timer.current = setTimeout(() => setCopied(false), 2000)\n      })\n      .catch(() => undefined)\n  }\n\n  return (\n    <button\n      aria-label={copied ? copiedLabel : label}\n      className=\"inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n      onClick={handleCopy}\n      type=\"button\"\n    >\n      {copied ? <Check aria-hidden=\"true\" className=\"size-3.5\" /> : <Copy aria-hidden=\"true\" className=\"size-3.5\" />}\n    </button>\n  )\n}\n\n/* -------------------------------------------------------------------------- *\n * Recovery buttons — a link or a handler, decided by the consumer\n * -------------------------------------------------------------------------- */\n\nfunction ActionButton({\n  action,\n  emphasis,\n  icon: Icon,\n}: {\n  action: ApiErrorAction\n  emphasis: boolean\n  icon: IconComponent\n}) {\n  const className = cn(\n    \"inline-flex h-9 min-w-0 cursor-pointer items-center gap-1.5 rounded-md px-3 text-sm font-medium\",\n    \"transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n    emphasis ? \"bg-primary text-primary-foreground hover:bg-primary/90\" : \"border hover:bg-muted\",\n  )\n  const external = action.href !== undefined && /^https?:/i.test(action.href)\n\n  if (action.href !== undefined) {\n    return (\n      <a\n        className={className}\n        href={action.href}\n        rel={external ? \"noopener noreferrer\" : undefined}\n        target={external ? \"_blank\" : undefined}\n      >\n        <Icon aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n        <span className=\"truncate\">{action.label}</span>\n        {external ? <ArrowUpRight aria-hidden=\"true\" className=\"size-3.5 shrink-0 opacity-70\" /> : null}\n      </a>\n    )\n  }\n  return (\n    <button className={className} onClick={action.onClick} type=\"button\">\n      <Icon aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n      <span className=\"truncate\">{action.label}</span>\n    </button>\n  )\n}\n\n/* -------------------------------------------------------------------------- *\n * Component\n * -------------------------------------------------------------------------- */\n\nexport interface ApiErrorCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** The failure class. Use `classifyApiError(status, code)` if you only have the response. */\n  kind: ApiErrorKind\n  /** Overrides the class headline. */\n  title?: string\n  /** Overrides the class explanation — the \"what will not fix this\" sentence. */\n  hint?: React.ReactNode\n  /** The provider's own message, shown verbatim in a monospace block. */\n  message?: string\n  status?: number\n  /** Provider error code (`rate_limit_error`, `context_length_exceeded`, …). */\n  code?: string\n  /** Copyable, and re-arms the card when it changes — a new id is a new failure. */\n  requestId?: string\n  model?: string\n  /**\n   * 1-based count of *consecutive* failures of this request. Above 1 it renders\n   * a chip; at `maxAutoRetries` it switches the automatic path off.\n   */\n  attempt?: number\n  /**\n   * Absolute instant the retry window opens (`Date`, epoch ms or ISO string).\n   * From a `Retry-After` header, anchor it yourself: `Date.now() + s * 1000`.\n   */\n  retryAt?: string | number | Date\n  /**\n   * Reference instant for the first paint only. Lets the server and the first\n   * client frame print identical digits; without it the ring shows an em dash\n   * for one frame, because render never reads the clock.\n   */\n  now?: string | number | Date\n  /** Consumer-owned pending flag while a retry is in flight. */\n  retrying?: boolean\n  /** Force the retry affordance on or off, overriding the class default. */\n  retryable?: boolean\n  /** Runs one retry. Called at most once per cycle — see the one-shot lock. */\n  onRetry?: () => void\n  /** Offer the automatic path at all (default true). It also needs `retryAt` to exist. */\n  allowAutoRetry?: boolean\n  /** Controlled auto-retry state; leave undefined for the uncontrolled toggle. */\n  autoRetry?: boolean\n  /** Initial state of the uncontrolled toggle (default false). */\n  defaultAutoRetry?: boolean\n  onAutoRetryChange?: (next: boolean) => void\n  /** Consecutive-failure ceiling for the automatic path (default 3). */\n  maxAutoRetries?: number\n  /**\n   * Recovery handlers by slot. Pass the same object to every error — a class\n   * renders only the slots it asks for and ignores the rest.\n   */\n  slots?: Partial<Record<ApiErrorSlotId, ApiErrorAction>>\n  /** Extra actions appended for any class. */\n  actions?: ApiErrorAction[]\n  onDismiss?: () => void\n  labels?: Partial<ApiErrorCardLabels>\n}\n\nexport const ApiErrorCard = React.forwardRef<HTMLDivElement, ApiErrorCardProps>(function ApiErrorCard(\n  {\n    kind,\n    title,\n    hint,\n    message,\n    status,\n    code,\n    requestId,\n    model,\n    attempt = 1,\n    retryAt,\n    now,\n    retrying = false,\n    retryable,\n    onRetry,\n    allowAutoRetry = true,\n    autoRetry,\n    defaultAutoRetry = false,\n    onAutoRetryChange,\n    maxAutoRetries = 3,\n    slots,\n    actions,\n    onDismiss,\n    labels,\n    className,\n    ...props\n  },\n  ref,\n) {\n  const text = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labels }), [labels])\n  const recipe = RECIPES[kind] ?? RECIPES.unknown\n  const titleId = React.useId()\n  const autoLabelId = React.useId()\n  const autoHintId = React.useId()\n\n  const attemptNumber = Number.isFinite(attempt) ? Math.max(1, Math.trunc(attempt)) : 1\n  const ceiling = Number.isFinite(maxAutoRetries) ? Math.trunc(maxAutoRetries) : 3\n\n  const retryEnabled = onRetry !== undefined && (retryable ?? recipe.retryable)\n  const targetMs = retryAt !== undefined ? toInstant(retryAt) : null\n  const hasCountdown = retryEnabled && targetMs !== null\n\n  /**\n   * One cycle = one failure the user is allowed to answer once. A new instant, a\n   * new attempt number or a new request id each mean \"this is a different\n   * failure\", so the lock, the clock and the announcement re-arm together.\n   */\n  const armKey = `${kind}|${targetMs ?? \"none\"}|${attemptNumber}|${requestId ?? \"\"}`\n\n  const initialTick = React.useCallback((): Tick | null => {\n    if (targetMs === null) return null\n    const reference = now !== undefined ? toInstant(now) : null\n    if (reference === null) return null\n    const remaining = Math.max(0, targetMs - reference)\n    return { remaining, window: remaining }\n  }, [targetMs, now])\n\n  const [tick, setTick] = React.useState<Tick | null>(initialTick)\n  const [fired, setFired] = React.useState(false)\n  const [internalAuto, setInternalAuto] = React.useState(defaultAutoRetry)\n  /** `id` forces a fresh node into the live region, so identical words re-announce. */\n  const [live, setLive] = React.useState<{ id: number; text: string }>({ id: 0, text: \"\" })\n\n  // Re-arm during render (React's adjust-state pattern) rather than in an\n  // effect: waiting for the effect paints one frame of the previous wait.\n  const [prevArm, setPrevArm] = React.useState(armKey)\n  if (prevArm !== armKey) {\n    setPrevArm(armKey)\n    setTick(initialTick())\n    setFired(false)\n  }\n\n  const say = React.useCallback((next: string) => {\n    setLive(s => ({ id: s.id + 1, text: next }))\n  }, [])\n\n  const autoOn = autoRetry ?? internalAuto\n  const autoBlocked = attemptNumber >= ceiling\n  const autoArmed = hasCountdown && allowAutoRetry && autoOn && !autoBlocked && !retrying\n\n  // An unknown remaining time counts as locked: a retry fired into an unmeasured\n  // window is exactly the request the provider just refused.\n  const locked = hasCountdown && (tick === null || tick.remaining > 0)\n\n  // latest-ref: the interval is armed once per cycle, but when it fires it must\n  // read the toggle and the handler as they are *now*, not as they were then.\n  const latest = React.useRef({ text, onRetry, autoArmed, title: title ?? recipe.title, locked })\n  React.useEffect(() => {\n    latest.current = { text, onRetry, autoArmed, title: title ?? recipe.title, locked }\n  })\n\n  /** One-shot lock: at most one retry leaves this card per cycle, auto or manual. */\n  const firedRef = React.useRef(false)\n  /** The window opens exactly once per cycle, however many ticks observe it. */\n  const openedRef = React.useRef(false)\n\n  const fireRetry = React.useCallback(\n    (auto: boolean) => {\n      if (firedRef.current) return\n      firedRef.current = true\n      setFired(true)\n      // A manual retry needs no announcement: the user pressed the button.\n      if (auto) say(latest.current.text.autoRetryAnnouncement)\n      latest.current.onRetry?.()\n    },\n    [say],\n  )\n\n  // Announce the error itself once per cycle. A live region that already holds\n  // content when it enters the DOM is unreliable, so the region mounts empty and\n  // is filled on the next frame.\n  React.useEffect(() => {\n    const raf = requestAnimationFrame(() => {\n      const l = latest.current\n      say(l.text.armAnnouncement(l.title, l.locked))\n    })\n    return () => cancelAnimationFrame(raf)\n  }, [armKey, say])\n\n  React.useEffect(() => {\n    firedRef.current = false\n    openedRef.current = false\n    if (targetMs === null) return\n\n    const armedAt = Date.now()\n    const windowMs = Math.max(0, targetMs - armedAt)\n    let interval: ReturnType<typeof setInterval> | undefined\n\n    const measure = () => {\n      // ★ target − now, every time. Never a decrement: a throttled background tab\n      //   drops almost every tick and a decrementing clock never notices.\n      const remaining = Math.max(0, targetMs - Date.now())\n      setTick({ remaining, window: windowMs })\n      if (remaining > 0) return\n\n      if (interval !== undefined) {\n        clearInterval(interval)\n        interval = undefined\n      }\n      if (openedRef.current) return\n      openedRef.current = true\n\n      if (latest.current.autoArmed) fireRetry(true)\n      else say(latest.current.text.unlockedAnnouncement)\n    }\n\n    // Timers and frame callbacks are async boundaries, so setting state inside\n    // them is fine; the effect body itself never sets state synchronously.\n    const raf = requestAnimationFrame(measure)\n    interval = setInterval(measure, TICK_MS)\n\n    // While the tab is hidden the interval is throttled to minutes, so the first\n    // thing to do on return is recompute rather than wait for the next tick.\n    const onVisibility = () => {\n      if (document.visibilityState === \"visible\") measure()\n    }\n    document.addEventListener(\"visibilitychange\", onVisibility)\n\n    return () => {\n      cancelAnimationFrame(raf)\n      if (interval !== undefined) clearInterval(interval)\n      document.removeEventListener(\"visibilitychange\", onVisibility)\n    }\n  }, [armKey, targetMs, fireRetry, say])\n\n  const handleAutoChange = () => {\n    const next = !autoOn\n    if (autoRetry === undefined) setInternalAuto(next)\n    onAutoRetryChange?.(next)\n    // Arming it after the window already opened must not sit there doing\n    // nothing: the toggle owes the user the retry it just promised.\n    if (next && retryEnabled && !locked && !autoBlocked && !retrying) fireRetry(true)\n  }\n\n  const retryDisabled = locked || retrying || fired\n  const handleRetry = () => {\n    // aria-disabled, not the native attribute: a natively disabled button leaves\n    // the tab order, so a keyboard user parked on it loses focus to <body> the\n    // moment the wait ends. The guard lives in the handler instead.\n    if (retryDisabled) return\n    fireRetry(false)\n  }\n\n  const known = tick !== null\n  const secondsLeft = known ? Math.ceil(tick.remaining / 1000) : 0\n  const windowSeconds = known ? Math.ceil(tick.window / 1000) : 0\n  const ringText = known ? formatRing(secondsLeft, windowSeconds) : \"—\"\n  // An unmeasured wait draws an empty ring, not a full one: on the server frame\n  // the progress is unknown, and \"unknown\" must not look like \"done\".\n  const elapsed = known ? (tick.window > 0 ? 1 - tick.remaining / tick.window : 1) : 0\n\n  const slotActions = recipe.slots\n    .map(id => ({ id, action: slots?.[id], icon: SLOT_ICON[id] }))\n    .filter((entry): entry is { id: ApiErrorSlotId; action: ApiErrorAction; icon: IconComponent } =>\n      Boolean(entry.action),\n    )\n  const extraActions = (actions ?? []).map((action, index) => ({\n    id: `extra-${index}`,\n    action,\n    icon: ArrowUpRight as IconComponent,\n  }))\n  const allActions = [...slotActions, ...extraActions]\n  // With no retry button the class's own exit becomes the primary one — an error\n  // card with nothing emphasised reads as a dead end.\n  const emphasisIndex = retryEnabled ? -1 : 0\n\n  const Icon = recipe.icon\n  const tag = [status !== undefined ? String(status) : null, code].filter(Boolean).join(\" · \")\n\n  return (\n    <div\n      aria-labelledby={titleId}\n      className={cn(\n        \"flex w-full max-w-full min-w-0 flex-col gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5\",\n        className,\n      )}\n      data-kind={kind}\n      data-state={locked ? \"waiting\" : \"open\"}\n      ref={ref}\n      role=\"group\"\n      {...props}\n    >\n      {/* Always mounted, starts empty: only transitions are announced, never the\n          ticking number — a per-second live region shouts over the reader. */}\n      <span aria-atomic=\"true\" aria-live=\"polite\" className=\"sr-only\" role=\"status\">\n        <span key={live.id}>{live.text}</span>\n      </span>\n\n      <div className=\"flex min-w-0 items-start gap-3\">\n        <span\n          aria-hidden=\"true\"\n          className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive\"\n        >\n          <Icon className=\"size-4\" />\n        </span>\n\n        <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n          <div className=\"flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1\">\n            <p className=\"text-sm font-semibold break-words\" id={titleId}>\n              {title ?? recipe.title}\n            </p>\n            {tag ? (\n              <span className=\"rounded-md border px-1.5 py-0.5 font-mono text-xs break-all text-muted-foreground\">\n                {tag}\n              </span>\n            ) : null}\n          </div>\n          <p className=\"text-sm break-words text-muted-foreground\">{hint ?? recipe.hint}</p>\n        </div>\n\n        {onDismiss ? (\n          <button\n            aria-label={text.dismiss}\n            className=\"-mt-1 -mr-1 inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n            onClick={onDismiss}\n            type=\"button\"\n          >\n            <X aria-hidden=\"true\" className=\"size-4\" />\n          </button>\n        ) : null}\n      </div>\n\n      {message ? (\n        <p className=\"min-w-0 rounded-lg border bg-muted/40 px-3 py-2 font-mono text-xs wrap-anywhere text-muted-foreground\">\n          {message}\n        </p>\n      ) : null}\n\n      {attemptNumber > 1 ? (\n        <p className=\"flex min-w-0 items-center gap-1.5 rounded-md border border-destructive/30 bg-destructive/5 px-2.5 py-1.5 text-xs font-medium break-words text-destructive\">\n          <Layers aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n          {text.attemptChip(attemptNumber)}\n        </p>\n      ) : null}\n\n      {hasCountdown ? (\n        <div className=\"flex min-w-0 flex-col gap-3\">\n          <div className=\"flex min-w-0 items-center gap-3\">\n            <div\n              aria-label={locked ? text.ringLabel(spokenEnglish(secondsLeft)) : text.ringOpenLabel}\n              className=\"relative flex size-12 shrink-0 items-center justify-center text-primary\"\n              role=\"img\"\n            >\n              <svg aria-hidden=\"true\" className=\"absolute inset-0 size-12 -rotate-90\" viewBox=\"0 0 48 48\">\n                <circle\n                  className=\"stroke-current opacity-20\"\n                  cx=\"24\"\n                  cy=\"24\"\n                  fill=\"none\"\n                  r={RING_RADIUS}\n                  strokeWidth=\"4\"\n                />\n                {/* The sweep is transitioned for exactly one tick, so it reads as\n                    motion instead of a stutter — and it is dropped entirely under\n                    reduced motion, where the number carries the state alone. */}\n                <circle\n                  className=\"stroke-current transition-[stroke-dashoffset] duration-200 ease-linear motion-reduce:transition-none\"\n                  cx=\"24\"\n                  cy=\"24\"\n                  fill=\"none\"\n                  r={RING_RADIUS}\n                  strokeDasharray={RING_LENGTH}\n                  strokeDashoffset={RING_LENGTH * (1 - Math.min(1, Math.max(0, elapsed)))}\n                  strokeLinecap=\"round\"\n                  strokeWidth=\"4\"\n                />\n              </svg>\n              {locked ? (\n                <span className=\"relative text-xs font-semibold tabular-nums text-foreground\">{ringText}</span>\n              ) : (\n                <Check aria-hidden=\"true\" className=\"relative size-5\" />\n              )}\n            </div>\n\n            <div className=\"flex min-w-0 flex-col gap-0.5\">\n              <p className=\"text-sm font-medium break-words\">{locked ? text.waiting : text.unlocked}</p>\n              <p className=\"text-xs break-words text-muted-foreground\">\n                {locked ? text.waitingNote : text.unlockedNote}\n              </p>\n            </div>\n          </div>\n\n          {allowAutoRetry ? (\n            <div className=\"flex min-w-0 items-start justify-between gap-3 rounded-lg border bg-muted/40 px-3 py-2\">\n              <div className=\"flex min-w-0 flex-col gap-0.5\">\n                <span className=\"text-xs font-medium break-words\" id={autoLabelId}>\n                  {text.autoRetry}\n                </span>\n                <span className=\"text-xs break-words text-muted-foreground\" id={autoHintId}>\n                  {autoBlocked ? text.autoRetryStopped(attemptNumber) : autoOn ? text.autoRetryOn : text.autoRetryOff}\n                </span>\n              </div>\n              <button\n                aria-checked={autoOn}\n                aria-describedby={autoHintId}\n                aria-labelledby={autoLabelId}\n                className={cn(\n                  \"relative mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full p-0.5\",\n                  \"transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50\",\n                  \"disabled:cursor-not-allowed disabled:opacity-50\",\n                  autoOn ? \"bg-primary\" : \"bg-input\",\n                )}\n                disabled={autoBlocked}\n                onClick={handleAutoChange}\n                role=\"switch\"\n                type=\"button\"\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"size-4 rounded-full bg-background transition-transform duration-200 ease-out motion-reduce:transition-none\",\n                    autoOn ? \"translate-x-4\" : \"translate-x-0\",\n                  )}\n                />\n              </button>\n            </div>\n          ) : null}\n        </div>\n      ) : null}\n\n      {model || requestId ? (\n        <div className=\"flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 border-t pt-3 text-xs text-muted-foreground\">\n          {model ? <span className=\"font-mono break-all\">{model}</span> : null}\n          {requestId ? (\n            <span className=\"inline-flex min-w-0 items-center gap-1.5\">\n              <span>{text.requestId}</span>\n              <code className=\"min-w-0 truncate font-mono text-foreground\">{requestId}</code>\n              <CopyRequestId copiedLabel={text.requestIdCopied} label={text.copyRequestId} value={requestId} />\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n\n      {retryEnabled || allActions.length > 0 ? (\n        <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n          {retryEnabled ? (\n            <button\n              aria-disabled={retryDisabled}\n              className={cn(\n                \"inline-flex h-9 min-w-0 cursor-pointer items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground\",\n                \"transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                retryDisabled && \"cursor-not-allowed opacity-50 hover:opacity-50\",\n              )}\n              onClick={handleRetry}\n              type=\"button\"\n            >\n              <RotateCw\n                aria-hidden=\"true\"\n                className={cn(\"size-4 shrink-0\", retrying && \"animate-spin motion-reduce:animate-none\")}\n              />\n              <span className=\"truncate\">{retrying ? text.retrying : fired ? text.retrySent : text.retry}</span>\n            </button>\n          ) : null}\n\n          {allActions.map((entry, index) => (\n            <ActionButton action={entry.action} emphasis={index === emphasisIndex} icon={entry.icon} key={entry.id} />\n          ))}\n        </div>\n      ) : null}\n    </div>\n  )\n})\n\nApiErrorCard.displayName = \"ApiErrorCard\"\n\nexport default ApiErrorCard\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}