{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-response-versions",
  "title": "Response Versions",
  "description": "Step between regenerated answers with \"2 / 3\", and regenerate without losing the old ones.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/components/response-versions.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronLeft, ChevronRight, RotateCcw } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * RESPONSE VERSIONS: \"2 / 3\" between regenerated answers\n *\n *   browsing      the arrows step between versions: the answer\n *                 you leave slides out one way through a blur, the\n *                 next comes in from the other side, the height\n *                 eases between them and \"2 / 3\" rolls\n *   regenerating  the regenerate arrow blurs into a spinner until\n *                 the new version arrives; the count rolls up\n *\n * Wrap the current version's content.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ResponseVersionsProps extends React.HTMLAttributes<HTMLDivElement> {\n  count: number;\n  /** Zero-based index of the version shown. */\n  index: number;\n  onIndexChange: (index: number) => void;\n  onRegenerate?: () => void;\n  regenerating?: boolean;\n  /** The current version's content. */\n  children: React.ReactNode;\n}\n\ntype Leaving = { key: string; node: React.ReactNode; dir: number };\n\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS =\n  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst ICON = `cursor-pointer disabled:cursor-default relative flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-35 ${FOCUS}`;\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(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\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\n    ? \"none\"\n    : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\n/** The version you stepped away from: it leaves toward the side you came from, then removes itself. */\nfunction LeavingPane({\n  item,\n  onDone,\n}: {\n  item: Leaving;\n  onDone: (key: string) => void;\n}) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const animation = el.animate(\n      [\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n        {\n          opacity: 0,\n          filter: \"blur(6px)\",\n          transform: `translateX(${-item.dir * 24}px)`,\n        },\n      ],\n      { duration: 260, easing: MORPH, fill: \"forwards\" },\n    );\n    animation.onfinish = () => onDone(item.key);\n    return () => animation.cancel();\n  }, [item, onDone]);\n  return (\n    <div\n      ref={ref}\n      inert\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute inset-x-0 top-0\"\n    >\n      {item.node}\n    </div>\n  );\n}\n\nexport function ResponseVersions({\n  count,\n  index,\n  onIndexChange,\n  onRegenerate,\n  regenerating = false,\n  children,\n  className = \"\",\n  ...props\n}: ResponseVersionsProps) {\n  const reduced = useReducedMotion();\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const paneRef = React.useRef<HTMLDivElement>(null);\n  const latest = React.useRef<React.ReactNode>(children);\n  const fromHeight = React.useRef<number | null>(null);\n\n  // A step: what was showing (as it last rendered) leaves, and the new version comes in from the side you stepped toward.\n  const [shown, setShown] = React.useState({ index, step: 0 });\n  const [leaving, setLeaving] = React.useState<Leaving[]>([]);\n  const [dir, setDir] = React.useState(0);\n  if (shown.index !== index) {\n    // Keep the visible height before React replaces the pane, including a\n    // partially completed resize. A cached natural height jumps on reversal.\n    fromHeight.current = boxRef.current?.getBoundingClientRect().height ?? null;\n    const d = index > shown.index ? 1 : -1;\n    setShown({ index, step: shown.step + 1 });\n    setDir(reduced ? 0 : d);\n    if (!reduced)\n      setLeaving((l) => [\n        ...l,\n        // A repeated route can still have a pane leaving; each step needs its own key.\n        { key: `${shown.step}`, node: latest.current, dir: d },\n      ]);\n    else if (leaving.length) setLeaving([]);\n  }\n  const done = React.useCallback(\n    (key: string) => setLeaving((l) => l.filter((x) => x.key !== key)),\n    [],\n  );\n\n  React.useLayoutEffect(() => {\n    if (reduced) {\n      setLeaving([]);\n      setDir(0);\n    }\n  }, [reduced]);\n\n  React.useLayoutEffect(() => {\n    const from = fromHeight.current;\n    fromHeight.current = null;\n    if (!dir || reduced) return;\n    const pane = paneRef.current;\n    const box = boxRef.current;\n    const enter = pane?.animate(\n      [\n        {\n          opacity: 0,\n          filter: \"blur(6px)\",\n          transform: `translateX(${dir * 24}px)`,\n        },\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n      ],\n      { duration: 420, easing: MORPH, delay: 60, fill: \"backwards\" },\n    );\n    // The box eases from the old answer's height to the new one's.\n    let resize: Animation | undefined;\n    const to = box?.getBoundingClientRect().height;\n    if (box && from !== null && to !== undefined && Math.abs(to - from) > 0.1) {\n      box.style.overflow = \"hidden\";\n      resize = box.animate([{ height: `${from}px` }, { height: `${to}px` }], {\n        duration: 420,\n        easing: MORPH,\n      });\n      resize.onfinish = () => {\n        box.style.overflow = \"\";\n      };\n    }\n    return () => {\n      enter?.cancel();\n      resize?.cancel();\n      // Restore synchronously: an old cancellation event must not clear the\n      // overflow protection of the next transition.\n      if (resize && box) box.style.overflow = \"\";\n    };\n  }, [index, dir, reduced]);\n\n  // Remember what this render showed, including streamed content, for the next step.\n  React.useLayoutEffect(() => {\n    latest.current = children;\n  });\n\n  return (\n    <div className={className} {...props}>\n      <div\n        ref={boxRef}\n        className={`relative ${!reduced && leaving.length ? \"overflow-hidden\" : \"\"}`}\n      >\n        {!reduced &&\n          leaving.map((l) => (\n            <LeavingPane key={l.key} item={l} onDone={done} />\n          ))}\n        <div key={index} ref={paneRef}>\n          {children}\n        </div>\n      </div>\n\n      <div className=\"mt-3 flex items-center gap-1\">\n        <button\n          type=\"button\"\n          aria-label=\"Previous version\"\n          disabled={index <= 0}\n          onClick={() => onIndexChange(index - 1)}\n          className={ICON}\n        >\n          <ChevronLeft className=\"size-4\" />\n        </button>\n        <span\n          aria-live=\"polite\"\n          className=\"flex min-w-[3.25rem] justify-center gap-[0.5ch] font-mono text-[11.5px] tabular-nums text-muted-foreground\"\n        >\n          <span className=\"sr-only\">Version </span>\n          <NumberRoll value={index + 1} duration={600} />\n          <span aria-hidden=\"true\">/</span>\n          <span className=\"sr-only\">of</span>\n          <NumberRoll value={count} duration={600} />\n        </span>\n        <button\n          type=\"button\"\n          aria-label=\"Next version\"\n          disabled={index >= count - 1}\n          onClick={() => onIndexChange(index + 1)}\n          className={ICON}\n        >\n          <ChevronRight className=\"size-4\" />\n        </button>\n        {onRegenerate && (\n          <button\n            type=\"button\"\n            aria-label={regenerating ? \"Regenerating\" : \"Regenerate\"}\n            disabled={regenerating}\n            onClick={onRegenerate}\n            className={`cursor-pointer disabled:cursor-default ${ICON} ml-1`}\n          >\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 flex items-center justify-center\"\n              style={swap(!regenerating, reduced)}\n            >\n              <RotateCcw className=\"size-3.5\" />\n            </span>\n            <span\n              aria-hidden=\"true\"\n              className=\"absolute inset-0 flex items-center justify-center\"\n              style={swap(regenerating, reduced)}\n            >\n              <span\n                className={`size-3.5 rounded-full border-[1.5px] border-border border-t-foreground/70 motion-reduce:animate-none ${regenerating ? \"animate-spin\" : \"\"}`}\n              />\n            </span>\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/components/number-roll.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * NUMBER ROLL: numbers that change like an odometer\n *\n *   rolling   each digit is a column that rolls to its new\n *             value, in the direction the number moved: going\n *             up, a 9 rolls on to 0 instead of spinning back\n *   places    new places slide open from nothing; lost ones\n *             fold away\n *   format    anything Intl.NumberFormat does: currency,\n *             percent, compact (\"1.2K\"), decimals\n *\n * The real number is always there as text for screen readers\n * and copy and paste; the columns are only what you see.\n * ───────────────────────────────────────────────────────── */\n\nexport interface NumberRollProps extends Omit<\n  React.HTMLAttributes<HTMLSpanElement>,\n  \"children\"\n> {\n  value: number;\n  /** Intl.NumberFormat options, e.g. { style: \"currency\", currency: \"USD\" }. */\n  format?: Intl.NumberFormatOptions;\n  locales?: string | string[];\n  prefix?: string;\n  suffix?: string;\n  /** Start from this value and roll to `value` once mounted, e.g. 0 for stats. */\n  from?: number;\n  /** Roll duration in ms. */\n  duration?: number;\n  /** Always roll this way, e.g. \"down\" for a countdown's seconds, which wrap from 00 to 59. Defaults to the way the number moved. */\n  direction?: \"up\" | \"down\";\n}\n\ntype Column = { key: string; char: string; digit: number | null };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst SETS = 5; // the strip repeats 0–9 five times so a roll never runs out of digits\nconst MIDDLE = 20;\n/** The strip as one text node, a digit a line: fifty lines, but one element to lay out instead of fifty. */\nconst STRIP = Array.from({ length: SETS * 10 }, (_, i) => i % 10).join(\"\\n\");\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(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\n/** Digits are keyed by their place counted from the right, so the units column stays the units column. */\nfunction columns(text: string): Column[] {\n  const chars = [...text];\n  const seen = new Map<string, number>();\n  let place = 0;\n  const out: Column[] = [];\n  for (let i = chars.length - 1; i >= 0; i--) {\n    const char = chars[i];\n    if (/\\d/.test(char))\n      out.push({ key: `d${place++}`, char, digit: Number(char) });\n    else {\n      // Symbols are keyed by how many of the same symbol sit to their right, so a comma that shifts is the same comma.\n      const n = seen.get(char) ?? 0;\n      seen.set(char, n + 1);\n      out.push({ key: `s${char}${n}`, char, digit: null });\n    }\n  }\n  return out.reverse();\n}\n\n// Memoised: a parent that re-renders with the same number doesn't rebuild the strips.\nconst Digit = React.memo(function Digit({\n  digit,\n  trend,\n  duration,\n  reduced,\n  rollIn,\n}: {\n  digit: number;\n  trend: number;\n  duration: number;\n  reduced: boolean;\n  rollIn: boolean;\n}) {\n  const [pos, setPos] = React.useState(MIDDLE + (rollIn ? 0 : digit));\n  const [moving, setMoving] = React.useState(false);\n  const posRef = React.useRef(pos);\n  const stripRef = React.useRef<HTMLSpanElement>(null);\n\n  React.useLayoutEffect(() => {\n    const current = posRef.current;\n    const showing = ((current % 10) + 10) % 10;\n    if (showing === digit) return;\n    if (reduced) {\n      posRef.current = MIDDLE + digit;\n      setMoving(false);\n      setPos(MIDDLE + digit);\n      return;\n    }\n    const up = trend > 0 || (trend === 0 && digit > showing);\n    let target = up\n      ? current + ((digit - showing + 10) % 10)\n      : current - ((showing - digit + 10) % 10);\n    // Values arriving faster than a roll (a progress percent) never let it re-centre, so before it runs off the strip,\n    // jump the strip back by whole sets mid-roll (it looks identical) and roll on from there.\n    if (target < 10 || target > SETS * 10 - 11) {\n      const shift = Math.round((target - MIDDLE) / 10) * 10;\n      const strip = stripRef.current;\n      if (strip?.offsetHeight) {\n        let visual = current;\n        try {\n          visual =\n            -new DOMMatrixReadOnly(getComputedStyle(strip).transform).m42 /\n            (strip.offsetHeight / (SETS * 10));\n        } catch {}\n        const transition = strip.style.transition;\n        strip.style.transition = \"none\";\n        strip.style.transform = `translateY(${(-(visual - shift) * 100) / (SETS * 10)}%)`;\n        void strip.offsetHeight;\n        strip.style.transition = transition;\n      }\n      target -= shift;\n    }\n    posRef.current = target;\n    setMoving(true);\n    setPos(target);\n  }, [digit, trend, reduced]);\n\n  // After a roll, quietly re-centre on the middle set so the next roll always has room.\n  const settle = () => {\n    const centred = MIDDLE + (((posRef.current % 10) + 10) % 10);\n    posRef.current = centred;\n    setMoving(false);\n    setPos(centred);\n  };\n\n  return (\n    <span className=\"relative inline-block [clip-path:inset(0)]\">\n      <span className=\"invisible\">{digit}</span>\n      <span\n        ref={stripRef}\n        aria-hidden=\"true\"\n        onTransitionEnd={settle}\n        className=\"absolute inset-x-0 top-0 block whitespace-pre text-center\"\n        style={{\n          transform: `translateY(${(-pos * 100) / (SETS * 10)}%)`,\n          transition: moving ? `transform ${duration}ms ${EASE}` : \"none\",\n        }}\n      >\n        {STRIP}\n      </span>\n    </span>\n  );\n});\n\n/** A place that appeared after the first paint slides open; places that were there from the start just sit. */\nfunction Place({\n  enter,\n  children,\n}: {\n  enter: boolean;\n  children: React.ReactNode;\n}) {\n  const [entering] = React.useState(enter);\n  return (\n    <span\n      className={`inline-block ${entering ? \"[clip-path:inset(0)] animate-[ui-col-in_500ms_cubic-bezier(0.16,1,0.3,1)_both]\" : \"\"}`}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport function NumberRoll({\n  value,\n  format,\n  locales,\n  prefix = \"\",\n  suffix = \"\",\n  from,\n  duration = 900,\n  direction,\n  className = \"\",\n  ...props\n}: NumberRollProps) {\n  const reduced = useReducedMotion();\n  const [shown, setShown] = React.useState(from ?? value);\n  const previous = React.useRef(shown);\n  const mounted = React.useRef(false);\n\n  // Roll from `from` to the real value once on screen (a frame later, so the start is painted).\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => setShown(value));\n    return () => cancelAnimationFrame(frame);\n  }, [value]);\n\n  React.useEffect(() => {\n    mounted.current = true;\n  }, []);\n\n  const formatter = React.useMemo(\n    () => new Intl.NumberFormat(locales, format),\n    [locales, format && JSON.stringify(format)],\n  );\n  const text = `${prefix}${formatter.format(shown)}${suffix}`;\n  const cols = React.useMemo(() => columns(text), [text]);\n  const trend =\n    direction === \"up\"\n      ? 1\n      : direction === \"down\"\n        ? -1\n        : Math.sign(shown - previous.current);\n\n  // Places that disappear fold away instead of vanishing.\n  const [leaving, setLeaving] = React.useState<Column[]>([]);\n  const lastCols = React.useRef(cols);\n  React.useLayoutEffect(() => {\n    const gone = lastCols.current.filter(\n      (c) => !cols.some((n) => n.key === c.key),\n    );\n    lastCols.current = cols;\n    previous.current = shown;\n    if (!gone.length || reduced) return;\n    setLeaving(gone);\n    const timer = window.setTimeout(() => setLeaving([]), 420);\n    return () => window.clearTimeout(timer);\n  }, [cols, shown, reduced]);\n\n  return (\n    <span className={`inline-flex tabular-nums ${className}`} {...props}>\n      <span className=\"sr-only\">{`${prefix}${formatter.format(value)}${suffix}`}</span>\n      <span\n        aria-hidden=\"true\"\n        className=\"inline-flex items-baseline whitespace-pre\"\n      >\n        {leaving.map((c) => (\n          <span\n            key={`out-${c.key}`}\n            className=\"inline-block [clip-path:inset(0)] animate-[ui-col-out_400ms_cubic-bezier(0.16,1,0.3,1)_forwards]\"\n          >\n            {c.char}\n          </span>\n        ))}\n        {cols.map((c) => (\n          <Place key={c.key} enter={mounted.current && !reduced}>\n            {c.digit === null ? (\n              c.char\n            ) : (\n              <Digit\n                digit={c.digit}\n                trend={trend}\n                duration={duration}\n                reduced={reduced}\n                rollIn={mounted.current}\n              />\n            )}\n          </Place>\n        ))}\n      </span>\n    </span>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "css": {
    "@keyframes ui-col-in": {
      "from": {
        "max-width": "0",
        "opacity": "0"
      },
      "to": {
        "max-width": "1em",
        "opacity": "1"
      }
    },
    "@keyframes ui-col-out": {
      "from": {
        "max-width": "1em",
        "opacity": "1"
      },
      "to": {
        "max-width": "0",
        "opacity": "0"
      }
    }
  },
  "meta": {
    "license": "MIT",
    "cavemio": {
      "component": "response-versions",
      "exportName": "ResponseVersions",
      "usage": "import { ResponseVersions } from \"@/components/response-versions\";\n\n<ResponseVersions count={versions.length} index={index} onIndexChange={setIndex} onRegenerate={regenerate} regenerating={busy}>\n  {versions[index]}\n</ResponseVersions>",
      "accessibility": "Native previous/next buttons are disabled at the ends. The version counter is announced politely. Leaving content is inert and hidden from assistive technology. Regenerate is disabled while busy; reduced motion swaps content immediately and disables the spinner and counter movement.",
      "previewNote": "A separate assistant-answer wrapper sharing Number Roll with Message Edit. Demo regeneration is simulated locally. The caller owns version history, selected index, and generation requests.",
      "categories": [
        "ai"
      ],
      "documentation": {
        "useCases": [],
        "features": [],
        "states": [],
        "props": [
          {
            "name": "count",
            "type": "number",
            "description": "Required. Total number of response versions."
          },
          {
            "name": "index",
            "type": "number",
            "description": "Required. Zero-based index of the version shown."
          },
          {
            "name": "onIndexChange",
            "type": "(index: number) => void",
            "description": "Required. Called with the zero-based index to display."
          },
          {
            "name": "onRegenerate",
            "type": "() => void",
            "description": "Optional. Called when a new response is requested."
          },
          {
            "name": "regenerating",
            "type": "boolean",
            "description": "Optional. Whether a new response is being generated."
          },
          {
            "name": "children",
            "type": "React.ReactNode",
            "description": "Required. The current version's content."
          }
        ]
      }
    }
  },
  "type": "registry:component"
}