{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button-retry-backoff",
  "title": "Button Retry Backoff",
  "description": "A retry button that visibly winds a torsion spring through its backoff — disabled and charging while a --foreground arc grows on real timing, notching every failure, pressable only once fully wound.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/button-retry-backoff/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\n\n// TorsionRetry — a retry button that is honest about its rate limit. Idle and\n// charged are the only pressable states; a failure winds a torsion spring: a\n// 1.5px --foreground arc grows 0->360deg around the icon's --border track on\n// LINEAR timing matched exactly to the real backoff delay (no fake easing —\n// the visual duration IS the enforced cooldown), while the refresh glyph\n// rotates slowly backward as if under tension. Reaching full wind snaps the\n// button to \"charged\": the icon pops with one damped-spring overshoot, the\n// glyph un-tenses 5deg forward (a permanent correction, not a bounce-back),\n// and the button gains --ns-accent text/border as its one legitimate accent use.\n// Every failure also drops a permanent 2px --ns-muted notch tick at a fixed\n// angular slot on the ring (1st failure always the same slot, 2nd always the\n// next, etc.) — a live history of the episode, not just its current backoff.\n// A successful retry resolves the episode: notches and attempt count reset,\n// the ring goes fully quiet. Hot-path values (arc offset, glyph rotation,\n// settle-spring) are written directly to refs every frame; React state only\n// carries status/attempt/caption, so re-renders stay coarse. Under\n// prefers-reduced-motion the arc advances in discrete steps with no glyph\n// rotation and no overshoot. Zero dependencies, DOM + SVG + CSS only.\n\nexport interface TorsionRetryProps {\n  /** called when the user presses Retry once idle/charged; resolve/return\n   *  false (or throw) to report failure, anything else counts as success */\n  onRetry?: () => Promise<boolean> | boolean;\n  /** wind duration for the first failure, ms (default 4000 — a real cooldown) */\n  baseDelayMs?: number;\n  /** multiplier applied per additional consecutive failure (default 2) */\n  factor?: number;\n  /** upper bound on wind duration regardless of attempt count (default 60000) */\n  maxDelayMs?: number;\n  /** fixed angular slots the ring can notch before saturating (default 8) —\n   *  the attempt count in the text description is never capped, only the ring */\n  maxNotches?: number;\n  /** button label in the idle/charged state (default \"Retry\") */\n  label?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\ntype Status = \"idle\" | \"checking\" | \"winding\" | \"charged\";\n\nconst SIZE = 28;\nconst CENTER = SIZE / 2;\nconst TRACK_R = 10.5;\nconst CIRCUMFERENCE = 2 * Math.PI * TRACK_R;\nconst NOTCH_INNER = 11.25;\nconst NOTCH_OUTER = 13.25;\nconst ROTATE_DEG_PER_SEC = 26; // slow backward tension while winding\nconst REDUCED_STEPS = 10; // discrete arc steps under prefers-reduced-motion\nconst DT_MAX = 0.05; // s — clamp tab-switch/frame-stall jumps\n\n// settle spring (icon pop + glyph un-tense), same recipe family as\n// confirm-hold-ink's proven \"pop\" constants — underdamped, one visible bounce\nconst SETTLE_K = 300;\nconst SETTLE_C = 12;\n\nfunction computeDelay(attempt: number, base: number, factor: number, max: number) {\n  return Math.min(max, base * Math.pow(factor, Math.max(0, attempt - 1)));\n}\n\nfunction notchAngleDeg(i: number, count: number) {\n  return (i + 0.5) * (360 / count);\n}\n\nfunction RefreshGlyph() {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" aria-hidden=\"true\">\n      <path\n        d=\"M11.5 4.2A5 5 0 1 0 12.4 8.1\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.3\"\n        strokeLinecap=\"round\"\n      />\n      <path\n        d=\"M11.6 1.6v3h-3\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.3\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport function TorsionRetry({\n  onRetry,\n  baseDelayMs = 4000,\n  factor = 2,\n  maxDelayMs = 60000,\n  maxNotches = 8,\n  label = \"Retry\",\n  className = \"\",\n}: TorsionRetryProps) {\n  const btnRef = useRef<HTMLButtonElement>(null);\n  const arcRef = useRef<SVGCircleElement>(null);\n  const iconRef = useRef<HTMLSpanElement>(null);\n  const glyphRef = useRef<HTMLSpanElement>(null);\n  const descId = useId();\n\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const [attempt, setAttempt] = useState(0);\n  const [caption, setCaption] = useState(\"Ready\");\n\n  const onRetryRef = useRef(onRetry);\n  onRetryRef.current = onRetry;\n  const cfgRef = useRef({ baseDelayMs, factor, maxDelayMs });\n  cfgRef.current = { baseDelayMs, factor, maxDelayMs };\n\n  // hot-path state — refs only, never triggers a render\n  const s = useRef({\n    status: \"idle\" as Status,\n    attempt: 0,\n    delay: 0,\n    windStart: 0,\n    lastFrame: 0,\n    glyphDeg: 0, // accumulated rotation, direct-written to glyphRef\n    reduced: false,\n    lastStep: -1,\n    lastAnnouncedSec: -1,\n    raf: 0,\n    pausedAt: 0,\n    // settle spring — icon scale + glyph nudge, run once per charge\n    settling: false,\n    scaleV: 1,\n    scaleVel: 0,\n    nudgeV: 0,\n    nudgeVel: 0,\n  }).current;\n\n  useEffect(() => {\n    const btn = btnRef.current;\n    const arc = arcRef.current;\n    const icon = iconRef.current;\n    const glyph = glyphRef.current;\n    if (!btn || !arc || !icon || !glyph) return;\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    s.reduced = mq.matches;\n    const onMq = () => {\n      s.reduced = mq.matches;\n    };\n    mq.addEventListener(\"change\", onMq);\n\n    const applyGlyph = (deg: number) => {\n      glyph.style.transform = s.reduced ? \"\" : `rotate(${deg}deg)`;\n    };\n    const applyArc = (progress: number) => {\n      arc.style.strokeDashoffset = String(CIRCUMFERENCE * (1 - progress));\n    };\n    const applyIconScale = (scale: number) => {\n      icon.style.transform = scale === 1 ? \"\" : `scale(${scale})`;\n    };\n\n    const secondsCaption = (remainingMs: number, attemptNum: number) => {\n      const secs = Math.max(0, Math.ceil(remainingMs / 1000));\n      return `Retry available in ${secs} second${secs === 1 ? \"\" : \"s\"}, attempt ${attemptNum}`;\n    };\n\n    const stopRaf = () => {\n      if (s.raf) cancelAnimationFrame(s.raf);\n      s.raf = 0;\n    };\n\n    const settleTick = (now: number) => {\n      const dt = Math.min(DT_MAX, (now - s.lastFrame) / 1000);\n      s.lastFrame = now;\n\n      s.scaleVel += ((1 - s.scaleV) * SETTLE_K - SETTLE_C * s.scaleVel) * dt;\n      s.scaleV += s.scaleVel * dt;\n      s.nudgeVel += ((5 - s.nudgeV) * SETTLE_K - SETTLE_C * s.nudgeVel) * dt;\n      s.nudgeV += s.nudgeVel * dt;\n\n      applyIconScale(s.reduced ? 1 : s.scaleV);\n      applyGlyph(s.glyphDeg + s.nudgeV);\n\n      const done =\n        Math.abs(s.scaleV - 1) < 0.001 &&\n        Math.abs(s.scaleVel) < 0.01 &&\n        Math.abs(s.nudgeV - 5) < 0.05 &&\n        Math.abs(s.nudgeVel) < 0.05;\n\n      if (done || s.reduced) {\n        s.scaleV = 1;\n        s.nudgeV = 5;\n        s.glyphDeg += 5;\n        applyIconScale(1);\n        applyGlyph(s.glyphDeg);\n        s.settling = false;\n        s.raf = 0;\n        return;\n      }\n      s.raf = requestAnimationFrame(settleTick);\n    };\n\n    const startSettle = () => {\n      s.settling = true;\n      s.scaleV = 0.97;\n      s.scaleVel = 0.6;\n      s.nudgeV = 0;\n      s.nudgeVel = 40; // deg/s kick — matches the spring's own timescale\n      s.lastFrame = performance.now();\n      stopRaf();\n      s.raf = requestAnimationFrame(settleTick);\n    };\n\n    const windTick = (now: number) => {\n      const elapsed = now - s.windStart;\n      const dt = Math.min(DT_MAX, (now - s.lastFrame) / 1000);\n      s.lastFrame = now;\n      const progress = Math.min(1, s.delay > 0 ? elapsed / s.delay : 1);\n\n      if (s.reduced) {\n        const step = Math.min(REDUCED_STEPS, Math.floor(progress * REDUCED_STEPS));\n        if (step !== s.lastStep) {\n          s.lastStep = step;\n          applyArc(step / REDUCED_STEPS);\n        }\n      } else {\n        applyArc(progress);\n        s.glyphDeg -= ROTATE_DEG_PER_SEC * dt;\n        applyGlyph(s.glyphDeg);\n      }\n\n      const remaining = Math.max(0, s.delay - elapsed);\n      const sec = Math.ceil(remaining / 1000);\n      if (sec !== s.lastAnnouncedSec) {\n        s.lastAnnouncedSec = sec;\n        setCaption(secondsCaption(remaining, s.attempt));\n      }\n\n      if (progress >= 1) {\n        s.status = \"charged\";\n        setStatus(\"charged\");\n        setCaption(\"Retry available now\");\n        applyArc(1);\n        s.raf = 0;\n        if (s.reduced) {\n          s.glyphDeg += 5;\n          applyGlyph(s.glyphDeg);\n        } else {\n          startSettle();\n        }\n        return;\n      }\n      s.raf = requestAnimationFrame(windTick);\n    };\n\n    const startWind = (attemptNum: number) => {\n      const { baseDelayMs: b, factor: f, maxDelayMs: m } = cfgRef.current;\n      const delay = computeDelay(attemptNum, b, f, m);\n      s.attempt = attemptNum;\n      s.delay = delay;\n      s.windStart = performance.now();\n      s.lastFrame = s.windStart;\n      s.lastStep = -1;\n      s.lastAnnouncedSec = -1;\n      s.status = \"winding\";\n      setStatus(\"winding\");\n      applyArc(0);\n      stopRaf();\n      s.raf = requestAnimationFrame(windTick);\n    };\n\n    const handleClick = () => {\n      if (s.status !== \"idle\" && s.status !== \"charged\") return;\n      stopRaf();\n      s.status = \"checking\";\n      setStatus(\"checking\");\n      setCaption(\"Retrying…\");\n      Promise.resolve()\n        .then(() => onRetryRef.current?.())\n        .then(\n          (ok) => {\n            if (ok !== false) {\n              s.attempt = 0;\n              setAttempt(0);\n              s.status = \"idle\";\n              setStatus(\"idle\");\n              setCaption(\"Ready\");\n              applyArc(0);\n            } else {\n              const next = s.attempt + 1;\n              setAttempt(next);\n              startWind(next);\n            }\n          },\n          () => {\n            const next = s.attempt + 1;\n            setAttempt(next);\n            startWind(next);\n          }\n        );\n    };\n\n    btn.addEventListener(\"click\", handleClick);\n\n    // pause the wind clock while the tab is hidden — resume without losing\n    // the elapsed progress by shifting windStart forward by the paused span\n    const onVisibility = () => {\n      if (document.hidden) {\n        if (s.raf) {\n          stopRaf();\n          s.pausedAt = performance.now();\n        }\n      } else if (s.pausedAt) {\n        const gap = performance.now() - s.pausedAt;\n        s.pausedAt = 0;\n        if (s.status === \"winding\") {\n          s.windStart += gap;\n          s.lastFrame = performance.now();\n          s.raf = requestAnimationFrame(windTick);\n        } else if (s.settling) {\n          s.lastFrame = performance.now();\n          s.raf = requestAnimationFrame(settleTick);\n        }\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      btn.removeEventListener(\"click\", handleClick);\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      mq.removeEventListener(\"change\", onMq);\n      stopRaf();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const notchCount = Math.min(attempt, maxNotches);\n  const busy = status === \"checking\" || status === \"winding\";\n\n  return (\n    <div className={`inline-flex flex-col items-start gap-1.5 ${className}`}>\n      <button\n        ref={btnRef}\n        type=\"button\"\n        data-torsion-retry\n        disabled={busy}\n        aria-describedby={descId}\n        className={[\n          \"inline-flex items-center gap-2 rounded-sm border px-3 py-1.5 text-sm font-medium\",\n          \"border-border bg-background text-foreground\",\n          \"hover:border-ns-muted hover:bg-border/60\",\n          \"disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-background\",\n          \"transition-colors duration-150 motion-reduce:transition-none\",\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\",\n          status === \"charged\" ? \"border-ns-accent text-ns-accent\" : \"\",\n        ].join(\" \")}\n      >\n        <span\n          ref={iconRef}\n          className=\"relative inline-flex h-7 w-7 shrink-0 items-center justify-center\"\n        >\n          <svg\n            width={SIZE}\n            height={SIZE}\n            viewBox={`0 0 ${SIZE} ${SIZE}`}\n            aria-hidden=\"true\"\n            className=\"absolute inset-0\"\n          >\n            <g transform={`rotate(-90 ${CENTER} ${CENTER})`}>\n              <circle\n                cx={CENTER}\n                cy={CENTER}\n                r={TRACK_R}\n                fill=\"none\"\n                stroke=\"var(--border)\"\n                strokeWidth={1.5}\n              />\n              {Array.from({ length: notchCount }, (_, i) => {\n                const rad = (notchAngleDeg(i, maxNotches) * Math.PI) / 180;\n                const x1 = CENTER + Math.cos(rad) * NOTCH_INNER;\n                const y1 = CENTER + Math.sin(rad) * NOTCH_INNER;\n                const x2 = CENTER + Math.cos(rad) * NOTCH_OUTER;\n                const y2 = CENTER + Math.sin(rad) * NOTCH_OUTER;\n                return (\n                  <line\n                    key={i}\n                    x1={x1}\n                    y1={y1}\n                    x2={x2}\n                    y2={y2}\n                    stroke=\"var(--ns-muted)\"\n                    strokeWidth={2}\n                    strokeLinecap=\"round\"\n                  />\n                );\n              })}\n              <circle\n                ref={arcRef}\n                cx={CENTER}\n                cy={CENTER}\n                r={TRACK_R}\n                fill=\"none\"\n                stroke=\"var(--foreground)\"\n                strokeWidth={1.5}\n                strokeLinecap=\"round\"\n                strokeDasharray={CIRCUMFERENCE}\n                strokeDashoffset={CIRCUMFERENCE}\n              />\n            </g>\n          </svg>\n          <span ref={glyphRef} className=\"relative flex text-foreground\">\n            <RefreshGlyph />\n          </span>\n        </span>\n        <span>{label}</span>\n      </button>\n\n      <p\n        id={descId}\n        aria-live=\"polite\"\n        aria-atomic=\"true\"\n        className=\"min-h-[1em] font-mono text-[11px] leading-tight text-ns-muted\"\n      >\n        {busy ? <span data-torsion-armed>{caption}</span> : caption}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/button-retry-backoff.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "button",
      "retry",
      "error-handling",
      "rate-limit",
      "backoff",
      "network"
    ],
    "instruction": "A retry button that refuses to be the dishonest always-enabled kind: it is a real <button>, genuinely disabled while backing off, wired to an onRetry callback that returns/resolves false (or throws) to report failure. On failure a 1.5px --foreground arc grows from 0 to 360 degrees around an inner icon's --border track on LINEAR timing matched exactly to the real computed backoff delay (baseDelayMs * factor ^ (attempt-1), capped at maxDelayMs) — the animation duration IS the enforced cooldown, not a decorative approximation of it — while the refresh glyph inside rotates slowly backward as if winding under tension. The button only becomes pressable again once the wind completes: at that instant the icon pops with one damped-spring overshoot and the glyph un-tenses forward by a permanent 5 degrees (a correction, not a bounce back to where it started), and the button gains --ns-accent text and border as its one legitimate accent use, signaling 'ready.' Every failure also drops a permanent 2px --ns-muted tick at a fixed angular slot on the ring — the 1st failure always the same slot, the 2nd always the next — so the ring reads as a history of the current error episode, not just a countdown; the ring saturates at maxNotches (default 8) but the textual attempt count never does. A successful retry resolves the episode: notches and attempt count reset to zero and the ring goes fully quiet. Accessibility: aria-describedby points at a polite, atomic live region that is also rendered as visible text (all ring state exists as text, not just pixels) and only updates at coarse boundaries — once per second while winding ('Retry available in N seconds, attempt K'), once when checking ('Retrying…'), and once, distinctly, the instant it charges ('Retry available now') so the enabled state is announced exactly once rather than on every frame; focus is never stolen programmatically. Hot-path values (arc offset, glyph rotation, the settle spring) are written directly to refs every animation frame; React state only carries status/attempt/caption so re-renders stay coarse. Under prefers-reduced-motion the arc advances in discrete steps with no glyph rotation and no overshoot, but the backoff and notch history stay fully legible as text. Differs from a countdown readout by charging a mechanism toward readiness (an increasing arc, a state the button gates on) rather than depleting a displayed number, and by being an interactive control that accumulates failure history rather than a passive readout of remaining time. Zero dependencies, DOM + SVG + CSS only — no canvas."
  },
  "type": "registry:ui"
}