{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-message-edit",
  "title": "Message Edit",
  "description": "Edit and resend a message in place, then step between its previous versions.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/components/message-edit.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronLeft, ChevronRight, Copy, Pencil } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * EDIT AND RESEND: change a sent message in place\n *\n *   sent      the message, with Copy and Edit on hover; Copy\n *             blurs into a check that draws itself\n *   editing   the bubble grows into an editor where it sits;\n *             Enter sends, Escape cancels\n *   versions  after a resend the pager slides open; \"2 / 3\"\n *             rolls, and the message slides in from the side\n *             you stepped toward while the bubble eases to fit\n *   disabled  editing waits while an answer is streaming\n *\n * The morph is one surface: it widens from its right edge and\n * grows to fit the field, its contents fade across, and it folds\n * back the same way.\n * ───────────────────────────────────────────────────────── */\n\nexport interface MessageEditProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onSubmit\"\n> {\n  text: string;\n  /** Called with the edited text; replace the message and regenerate from it. */\n  onSubmit: (text: string) => void;\n  /** Disable editing, e.g. while an answer streams. */\n  disabled?: boolean;\n  disabledReason?: string;\n  /** Branches of this message, shown as \"2 / 3\". */\n  versions?: {\n    index: number;\n    count: number;\n    onIndexChange: (index: number) => void;\n  };\n  /** How the sent message renders; defaults to the text. */\n  children?: React.ReactNode;\n}\n\nconst EASE = \"cubic-bezier(0.23,1,0.32,1)\";\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 flex size-7 items-center justify-center rounded-full text-muted-foreground transition-[background-color,color,transform] duration-150 hover:bg-accent hover:text-foreground active:scale-[0.94] disabled:pointer-events-none disabled:opacity-35 [&_svg]:size-3.5 ${FOCUS}`;\n\ntype Outgoing = {\n  key: string;\n  content: React.ReactNode;\n  dir: number;\n  width: number;\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/** 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\nfunction CopyGlyph({ copied, reduced }: { copied: boolean; reduced: boolean }) {\n  return (\n    <span className=\"relative flex size-3.5 items-center justify-center\">\n      <span\n        className=\"absolute inset-0 flex items-center justify-center\"\n        style={swap(!copied, reduced)}\n      >\n        <Copy />\n      </span>\n      <span\n        className=\"absolute inset-0 flex items-center justify-center text-foreground\"\n        style={swap(copied, reduced)}\n      >\n        <svg viewBox=\"0 0 16 16\" fill=\"none\">\n          <path\n            d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.8\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            pathLength={1}\n            strokeDasharray={1}\n            style={{\n              strokeDashoffset: copied ? 0 : 1,\n              transition:\n                copied && !reduced\n                  ? `stroke-dashoffset 380ms ${MORPH} 100ms`\n                  : \"none\",\n            }}\n          />\n        </svg>\n      </span>\n    </span>\n  );\n}\n\n/** The message you stepped away from: it leaves toward the side you came from. */\nfunction Leaving({\n  item,\n  onDone,\n}: {\n  item: Outgoing;\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 * 16}px)`,\n        },\n      ],\n      { duration: 240, 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      aria-hidden=\"true\"\n      inert\n      className=\"pointer-events-none absolute right-0 top-0 whitespace-pre-wrap px-4 py-2.5 text-[14px] leading-relaxed text-foreground\"\n      style={{ width: item.width }}\n    >\n      {item.content}\n    </div>\n  );\n}\n\nexport function MessageEdit({\n  text,\n  onSubmit,\n  disabled = false,\n  disabledReason = \"Wait for the answer to finish\",\n  versions,\n  children,\n  className = \"\",\n  ...props\n}: MessageEditProps) {\n  const reduced = useReducedMotion();\n  const [editing, setEditing] = React.useState(false);\n  const [draft, setDraft] = React.useState(text);\n  const [copied, setCopied] = React.useState(false);\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const fieldRef = React.useRef<HTMLTextAreaElement>(null);\n  const editRef = React.useRef<HTMLButtonElement>(null);\n  const fromRef = React.useRef<DOMRect | null>(null);\n  const returnFocus = React.useRef(false);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const versionFrom = React.useRef<DOMRect | null>(null);\n\n  // Stepping between versions: remember what was showing, so it can leave while the new one arrives.\n  const content = children ?? text;\n  const index = versions?.index ?? 0;\n  const [shown, setShown] = React.useState({\n    text,\n    index,\n    content,\n    editing,\n    step: 0,\n  });\n  const [outgoing, setOutgoing] = React.useState<Outgoing[]>([]);\n  const [enterDir, setEnterDir] = React.useState(0);\n  if (shown.text !== text || shown.editing !== editing) {\n    // A resend lands while the editor folds back, which is motion enough; only a step between versions slides.\n    const stepped =\n      shown.text !== text && !editing && !shown.editing && !reduced;\n    const dir = Math.sign(index - shown.index) || 1;\n    setShown({ text, index, content, editing, step: shown.step + 1 });\n    setEnterDir(stepped ? dir : 0);\n    if (stepped) {\n      // Capture the live layout before React replaces the text. Cached dimensions\n      // can predate a font load, and integer offsetWidth can wrap the final word.\n      versionFrom.current = surfaceRef.current?.getBoundingClientRect() ?? null;\n      setOutgoing((o) => [\n        ...o,\n        {\n          key: `${shown.step}`,\n          content: shown.content,\n          dir,\n          width: contentRef.current?.getBoundingClientRect().width ?? 0,\n        },\n      ]);\n    }\n  }\n  const doneLeaving = React.useCallback(\n    (key: string) => setOutgoing((o) => o.filter((x) => x.key !== key)),\n    [],\n  );\n\n  const morph = (next: boolean) => {\n    fromRef.current = surfaceRef.current?.getBoundingClientRect() ?? null;\n    setEditing(next);\n  };\n\n  const start = () => {\n    if (disabled) return;\n    setDraft(text);\n    morph(true);\n  };\n\n  const cancel = (refocus = true) => {\n    returnFocus.current = refocus;\n    morph(false);\n  };\n\n  const send = () => {\n    const next = draft.trim();\n    if (!next || next === text.trim()) return;\n    onSubmit(next);\n    returnFocus.current = true;\n    morph(false);\n  };\n\n  // Animate the surface from where it was to where it lands (both directions).\n  React.useLayoutEffect(() => {\n    const el = surfaceRef.current;\n    const from = fromRef.current;\n    fromRef.current = null;\n    if (editing) {\n      const field = fieldRef.current;\n      if (field) {\n        field.style.height = \"auto\";\n        field.style.height = `${field.scrollHeight}px`;\n        field.focus({ preventScroll: true });\n        field.setSelectionRange(field.value.length, field.value.length);\n      }\n    } else if (returnFocus.current) {\n      returnFocus.current = false;\n      editRef.current?.focus({ preventScroll: true });\n    }\n    if (!el || !from || reduced) return;\n    const to = el.getBoundingClientRect();\n    const animation = el.animate(\n      [\n        { width: `${from.width}px`, height: `${from.height}px` },\n        { width: `${to.width}px`, height: `${to.height}px` },\n      ],\n      { duration: 380, easing: EASE },\n    );\n    return () => animation.cancel();\n  }, [editing, reduced]);\n\n  // A new version: the bubble eases from its old size to the new text, which slides in from the side you stepped toward.\n  React.useLayoutEffect(() => {\n    const el = surfaceRef.current;\n    const pane = contentRef.current;\n    const from = versionFrom.current;\n    versionFrom.current = null;\n    if (reduced || !enterDir || !el || !pane || !from) return;\n    const to = el.getBoundingClientRect();\n    // Let the surface resize without reflowing the words inside it. Anchor the\n    // text at the right edge, including while the surface is narrower than it.\n    pane.style.width = `${to.width}px`;\n    pane.style.marginLeft = `calc(100% - ${to.width}px)`;\n    const releaseWidth = () => {\n      pane.style.width = \"\";\n      pane.style.marginLeft = \"\";\n    };\n    const resize = el.animate(\n      [\n        { width: `${from.width}px`, height: `${from.height}px` },\n        { width: `${to.width}px`, height: `${to.height}px` },\n      ],\n      { duration: 420, easing: MORPH },\n    );\n    resize.onfinish = releaseWidth;\n    const entrance = pane.animate(\n      [\n        {\n          opacity: 0,\n          filter: \"blur(6px)\",\n          transform: `translateX(${enterDir * 16}px)`,\n        },\n        { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n      ],\n      { duration: 420, easing: MORPH, delay: 60, fill: \"backwards\" },\n    );\n    return () => {\n      resize.cancel();\n      entrance.cancel();\n      releaseWidth();\n    };\n  }, [text, enterDir, reduced]);\n\n  React.useLayoutEffect(() => {\n    if (reduced) setOutgoing([]);\n  }, [reduced]);\n\n  React.useEffect(() => {\n    if (!copied) return;\n    const timer = window.setTimeout(() => setCopied(false), 1600);\n    return () => window.clearTimeout(timer);\n  }, [copied]);\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(text);\n      setCopied(true);\n    } catch {\n      // Clipboard blocked; nothing to show.\n    }\n  };\n\n  const unchanged = draft.trim() === text.trim() || !draft.trim();\n\n  return (\n    <div\n      className={`group/message flex w-full flex-col items-end ${className}`}\n      {...props}\n    >\n      <div\n        ref={surfaceRef}\n        className={`overflow-hidden rounded-[14px] transition-[background-color,box-shadow] duration-300 ${\n          editing\n            ? \"w-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n            : \"w-fit max-w-[85%] bg-muted shadow-[0_0_0_1px_transparent]\"\n        }`}\n      >\n        {editing ? (\n          <div\n            key=\"editor\"\n            className=\"p-3 animate-[ui-fade-in_220ms_ease-out_100ms_both] motion-reduce:animate-none\"\n          >\n            <textarea\n              ref={fieldRef}\n              value={draft}\n              rows={1}\n              aria-label=\"Edit message\"\n              onChange={(e) => {\n                setDraft(e.target.value);\n                e.target.style.height = \"auto\";\n                e.target.style.height = `${e.target.scrollHeight}px`;\n              }}\n              onKeyDown={(e) => {\n                if (e.nativeEvent.isComposing) return;\n                if (e.key === \"Escape\") {\n                  e.preventDefault();\n                  cancel();\n                } else if (e.key === \"Enter\" && !e.shiftKey) {\n                  e.preventDefault();\n                  send();\n                }\n              }}\n              className=\"block max-h-60 w-full resize-none bg-transparent px-1 text-[14px] leading-relaxed text-foreground outline-none\"\n            />\n            <div className=\"mt-2 flex items-center justify-end gap-1\">\n              <button\n                type=\"button\"\n                onClick={() => cancel()}\n                className={`cursor-pointer h-8 rounded-full px-3 text-[12.5px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n              >\n                Cancel\n              </button>\n              <button\n                type=\"button\"\n                onClick={send}\n                disabled={unchanged}\n                className={`cursor-pointer disabled:cursor-default h-8 rounded-full bg-primary px-3.5 text-[12.5px] font-medium text-primary-foreground transition-[opacity,transform] duration-150 active:scale-[0.96] disabled:opacity-35 ${FOCUS}`}\n              >\n                Send\n              </button>\n            </div>\n          </div>\n        ) : (\n          <div\n            key=\"message\"\n            className=\"relative animate-[ui-fade-in_220ms_ease-out_100ms_both] motion-reduce:animate-none\"\n          >\n            {outgoing.map((o) => (\n              <Leaving key={o.key} item={o} onDone={doneLeaving} />\n            ))}\n            <div\n              key={text}\n              ref={contentRef}\n              className=\"whitespace-pre-wrap px-4 py-2.5 text-[14px] leading-relaxed text-foreground\"\n            >\n              {content}\n            </div>\n          </div>\n        )}\n      </div>\n\n      <div\n        inert={editing}\n        className={`mt-1 flex items-center gap-0.5 transition-opacity duration-200 ${\n          editing\n            ? \"opacity-0\"\n            : \"opacity-0 focus-within:opacity-100 group-hover/message:opacity-100 [@media(hover:none)]:opacity-100\"\n        }`}\n      >\n        {versions && (\n          // Slides open the first time there is a second version.\n          <span\n            inert={versions.count < 2}\n            className=\"grid\"\n            style={{\n              gridTemplateColumns: versions.count > 1 ? \"1fr\" : \"0fr\",\n              opacity: versions.count > 1 ? 1 : 0,\n              transition: reduced\n                ? \"none\"\n                : `grid-template-columns 420ms ${MORPH}, opacity 300ms ${MORPH}`,\n            }}\n          >\n            <span className=\"flex min-w-0 items-center overflow-hidden\">\n              <span className=\"mr-1 flex shrink-0 items-center\">\n                <button\n                  type=\"button\"\n                  aria-label=\"Previous version\"\n                  disabled={versions.index <= 0}\n                  onClick={() => versions.onIndexChange(versions.index - 1)}\n                  className={ICON}\n                >\n                  <ChevronLeft />\n                </button>\n                <span\n                  aria-live=\"polite\"\n                  className=\"flex min-w-[2.5rem] justify-center gap-[0.5ch] font-mono text-[11px] tabular-nums text-muted-foreground\"\n                >\n                  <span className=\"sr-only\">Version </span>\n                  <NumberRoll value={versions.index + 1} duration={700} />\n                  <span aria-hidden=\"true\">/</span>\n                  <span className=\"sr-only\">of</span>\n                  <NumberRoll value={versions.count} duration={700} />\n                </span>\n                <button\n                  type=\"button\"\n                  aria-label=\"Next version\"\n                  disabled={versions.index >= versions.count - 1}\n                  onClick={() => versions.onIndexChange(versions.index + 1)}\n                  className={ICON}\n                >\n                  <ChevronRight />\n                </button>\n              </span>\n            </span>\n          </span>\n        )}\n        <button\n          type=\"button\"\n          aria-label=\"Copy message\"\n          onClick={copy}\n          className={ICON}\n        >\n          <CopyGlyph copied={copied} reduced={reduced} />\n        </button>\n        <button\n          ref={editRef}\n          type=\"button\"\n          aria-label=\"Edit message\"\n          title={disabled ? disabledReason : undefined}\n          aria-disabled={disabled || undefined}\n          onClick={start}\n          className={`${ICON} aria-disabled:cursor-not-allowed aria-disabled:opacity-35 aria-disabled:hover:bg-transparent aria-disabled:hover:text-muted-foreground`}\n        >\n          <Pencil />\n        </button>\n      </div>\n      {/* Outside the buttons, so their names stay their labels. */}\n      <span role=\"status\" className=\"sr-only\">\n        {copied ? \"Copied\" : \"\"}\n      </span>\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-fade-in": {
      "from": {
        "opacity": "0"
      },
      "to": {
        "opacity": "1"
      }
    },
    "@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": "message-edit",
      "exportName": "MessageEdit",
      "usage": "import { MessageEdit } from \"@/components/message-edit\";\n\n<MessageEdit text={message} onSubmit={setMessage} />",
      "accessibility": "Enter sends, Shift+Enter inserts a line, and Escape cancels. Focus returns to the edit button. Copy status and version changes are announced politely. Reduced motion disables morphs.",
      "previewNote": "Uses local demo data; no AI service or network requests.",
      "categories": [
        "ai"
      ],
      "documentation": {
        "useCases": [],
        "features": [],
        "states": [],
        "props": [
          {
            "name": "text",
            "type": "string",
            "description": "Required. Current message text."
          },
          {
            "name": "onSubmit",
            "type": "(text: string) => void",
            "description": "Required. Called with the edited text; replace the message and regenerate from it."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "description": "Optional. Disable editing, e.g. while an answer streams."
          },
          {
            "name": "disabledReason",
            "type": "string",
            "description": "Optional. Explanation shown when editing is unavailable."
          },
          {
            "name": "versions",
            "type": "{ index: number; count: number; onIndexChange: (index: number) => void; }",
            "description": "Optional. Branches of this message, shown as \"2 / 3\"."
          },
          {
            "name": "children",
            "type": "React.ReactNode",
            "description": "Optional. How the sent message renders; defaults to the text."
          }
        ]
      }
    }
  },
  "type": "registry:component"
}