{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-clarifying-question",
  "title": "Clarifying Question",
  "description": "The agent pauses to ask. Pick with a click or a number key, or type your own; it folds into a one-line receipt.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/components/clarifying-question.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowUp } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * CLARIFYING QUESTION: the agent pauses to ask\n *\n *   asking    the question and its options; press 1–9 or click\n *   other     \"Something else\" turns into a field in place\n *   multiple  options toggle (their checks draw themselves), then\n *             \"Continue · 3\", the count rolling as you pick\n *   answered  the same card folds into one line: the options close,\n *             \"Needs your answer\" lifts away and the question and\n *             your answer slide into its place as a check draws\n *   skipped   \"Skipped, the agent will decide\"\n *\n * Pair it with a client-side tool that has no execute: render it\n * for the tool's input and send the answer back as the output.\n * ───────────────────────────────────────────────────────── */\n\nexport interface QuestionOption {\n  id: string;\n  label: string;\n  description?: string;\n}\n\nexport interface QuestionAnswer {\n  ids: string[];\n  labels: string[];\n  /** What they typed under \"Something else\". */\n  other?: string;\n}\n\nexport interface ClarifyingQuestionProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"onSubmit\"\n> {\n  question: string;\n  detail?: string;\n  options: QuestionOption[];\n  /** Allow several answers, confirmed with Continue. */\n  multiple?: boolean;\n  /** Offer \"Something else\" with a field. */\n  allowOther?: boolean;\n  otherLabel?: string;\n  /** The answer once given, e.g. the tool's output; shows the folded line. */\n  answer?: QuestionAnswer | null;\n  onAnswer: (answer: QuestionAnswer) => void;\n  /** Shows Skip; the agent continues with its own choice. */\n  onSkip?: () => void;\n  skipped?: boolean;\n  skippedText?: string;\n  autoFocus?: boolean;\n}\n\nconst OTHER = \"__other__\";\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\";\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/** A piece of a line that opens out of nothing and folds back into it. */\nfunction Reveal({\n  show,\n  reduced,\n  className = \"\",\n  children,\n}: {\n  show: boolean;\n  reduced: boolean;\n  className?: string;\n  children: React.ReactNode;\n}) {\n  return (\n    <span\n      aria-hidden={!show || undefined}\n      className={`grid ${className}`}\n      style={{\n        gridTemplateColumns: show ? \"1fr\" : \"0fr\",\n        opacity: show ? 1 : 0,\n        filter: show ? \"none\" : \"blur(3px)\",\n        transition: reduced\n          ? \"none\"\n          : `grid-template-columns 460ms ${MORPH}, opacity ${show ? \"320ms\" : \"160ms\"} ${MORPH}, filter 320ms ${MORPH}`,\n      }}\n    >\n      <span className=\"min-w-0 truncate [clip-path:inset(-4px_-2px)]\">\n        {children}\n      </span>\n    </span>\n  );\n}\n\nfunction DrawnCheck({\n  drawn,\n  reduced,\n  className = \"size-3\",\n}: {\n  drawn: boolean;\n  reduced: boolean;\n  className?: string;\n}) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className={className}>\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{\n          strokeDashoffset: drawn ? 0 : 1,\n          transition:\n            drawn && !reduced\n              ? `stroke-dashoffset 320ms ${MORPH} 60ms`\n              : \"none\",\n        }}\n      />\n    </svg>\n  );\n}\n\nexport function ClarifyingQuestion({\n  question,\n  detail,\n  options,\n  multiple = false,\n  allowOther = true,\n  otherLabel = \"Something else\",\n  answer,\n  onAnswer,\n  onSkip,\n  skipped = false,\n  skippedText = \"the agent will decide\",\n  autoFocus = false,\n  className = \"\",\n  ...props\n}: ClarifyingQuestionProps) {\n  const id = React.useId();\n  const reduced = useReducedMotion();\n  const [own, setOwn] = React.useState<QuestionAnswer | null>(null);\n  const [ownSkipped, setOwnSkipped] = React.useState(false);\n  const [picked, setPicked] = React.useState<string[]>([]);\n  const [otherOpen, setOtherOpen] = React.useState(false);\n  const [otherText, setOtherText] = React.useState(\"\");\n  const [active, setActive] = React.useState(0);\n  const [highlight, setHighlight] = React.useState({\n    top: 0,\n    height: 0,\n    ready: false,\n  });\n\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const rowRefs = React.useRef<(HTMLButtonElement | HTMLDivElement | null)[]>(\n    [],\n  );\n  const otherRef = React.useRef<HTMLInputElement>(null);\n\n  const final = answer ?? own;\n  const isSkipped = skipped || ownSkipped;\n  const done = Boolean(final) || isSkipped;\n  const rows = allowOther\n    ? [...options, { id: OTHER, label: otherLabel }]\n    : options;\n\n  // The options are about to fold away: keep focus on the card instead of dropping it on the page.\n  const fold = () => {\n    if (surfaceRef.current?.contains(document.activeElement))\n      surfaceRef.current.focus({ preventScroll: true });\n  };\n\n  React.useEffect(() => {\n    if (autoFocus && !done)\n      (rowRefs.current[0] as HTMLElement | null)?.focus({\n        preventScroll: true,\n      });\n    // Autofocus applies only when this question mounts.\n  }, []);\n\n  React.useEffect(() => {\n    if (otherOpen) otherRef.current?.focus({ preventScroll: true });\n  }, [otherOpen]);\n\n  // Digits also answer when nothing else has focus (never while typing in a field).\n  const chooseRef = React.useRef<(i: number) => void>(() => {});\n  React.useEffect(() => {\n    if (done) return;\n    const onKey = (e: KeyboardEvent) => {\n      const focused = document.activeElement;\n      if (\n        (focused && focused !== document.body) ||\n        e.metaKey ||\n        e.ctrlKey ||\n        e.altKey ||\n        !/^[1-9]$/.test(e.key)\n      )\n        return;\n      if (Number(e.key) > rows.length) return;\n      e.preventDefault();\n      chooseRef.current(Number(e.key) - 1);\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [done, rows.length]);\n\n  // The highlight slides to the active row and takes its height.\n  React.useLayoutEffect(() => {\n    const row = rowRefs.current[active];\n    if (!row || done) return;\n    const measure = () => {\n      const top = row.offsetTop;\n      const height = row.offsetHeight;\n      setHighlight((h) =>\n        h.top === top && h.height === height && h.ready\n          ? h\n          : { top, height, ready: h.height > 0 },\n      );\n    };\n    measure();\n    // Descriptions can wrap after a resize or font load without changing selection.\n    const observer = new ResizeObserver(measure);\n    observer.observe(row);\n    if (row.parentElement) observer.observe(row.parentElement);\n    return () => observer.disconnect();\n  }, [active, otherOpen, done, rows.length]);\n\n  const submit = (picks: string[], other?: string) => {\n    const order = (x: string) => options.findIndex((o) => o.id === x);\n    const ids = [...picks].sort((a, b) => order(a) - order(b));\n    const next: QuestionAnswer = {\n      ids,\n      labels: ids.map((i) => options.find((o) => o.id === i)?.label ?? i),\n      ...(other ? { other } : {}),\n    };\n    fold();\n    if (answer === undefined) setOwn(next);\n    onAnswer(next);\n  };\n\n  const skip = () => {\n    fold();\n    setOwnSkipped(true);\n    onSkip?.();\n  };\n\n  const choose = (i: number) => {\n    const row = rows[i];\n    if (!row) return;\n    setActive(i);\n    if (row.id === OTHER) {\n      setOtherOpen((o) => (multiple ? !o : true));\n      return;\n    }\n    if (multiple)\n      setPicked((p) =>\n        p.includes(row.id) ? p.filter((x) => x !== row.id) : [...p, row.id],\n      );\n    else submit([row.id]);\n  };\n\n  React.useLayoutEffect(() => {\n    chooseRef.current = choose;\n  });\n\n  const submitOther = () => {\n    const text = otherText.trim();\n    if (!text) return;\n    if (multiple) submit(picked, text);\n    else submit([], text);\n  };\n\n  const onListKey = (e: React.KeyboardEvent) => {\n    if (e.target instanceof HTMLInputElement) return;\n    if (/^[1-9]$/.test(e.key) && Number(e.key) <= rows.length) {\n      e.preventDefault();\n      const next = Number(e.key) - 1;\n      choose(next);\n      // A single answer has already focused the receipt; don't refocus a closing row.\n      if (multiple || rows[next].id === OTHER)\n        (rowRefs.current[next] as HTMLElement | null)?.focus();\n    } else if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      const next =\n        (active + (e.key === \"ArrowDown\" ? 1 : -1) + rows.length) % rows.length;\n      setActive(next);\n      (rowRefs.current[next] as HTMLElement | null)?.focus();\n    }\n  };\n\n  const summary = final\n    ? [...final.labels, final.other].filter(Boolean).join(\", \")\n    : \"\";\n  const receipt = isSkipped && !final ? `Skipped, ${skippedText}` : summary;\n  const count = picked.length + (otherOpen && otherText.trim() ? 1 : 0);\n  const canContinue = count > 0;\n\n  return (\n    <div\n      ref={surfaceRef}\n      tabIndex={-1}\n      className={`overflow-hidden rounded-[14px] border border-border bg-background outline-none ${className}`}\n      {...props}\n    >\n      {/* The header stays put; once answered it becomes the whole card: question · answer. */}\n      <div\n        className=\"flex min-w-0 items-center text-[12px]\"\n        style={{\n          padding: done ? \"13px 16px\" : \"14px 16px 0\",\n          transition: reduced ? \"none\" : `padding 460ms ${MORPH}`,\n        }}\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"relative mr-2 flex size-4 shrink-0 items-center justify-center\"\n        >\n          <span\n            className=\"absolute inset-0 flex items-center justify-center\"\n            style={swap(!done, reduced)}\n          >\n            <span className=\"relative flex size-1.5\">\n              <span\n                className={`absolute inset-0 rounded-full bg-primary motion-reduce:animate-none ${done ? \"\" : \"animate-[ui-ping_1.6s_cubic-bezier(0,0,0.2,1)_infinite]\"}`}\n              />\n              <span className=\"relative size-1.5 rounded-full bg-primary\" />\n            </span>\n          </span>\n          <span\n            className=\"absolute inset-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground\"\n            style={swap(done, reduced)}\n          >\n            <DrawnCheck drawn={done} reduced={reduced} className=\"size-2.5\" />\n          </span>\n        </span>\n        <span\n          aria-hidden={done || undefined}\n          className=\"shrink-0 text-muted-foreground\"\n        >\n          <TextMorph>{done ? \"\" : \"Needs your answer\"}</TextMorph>\n        </span>\n        <Reveal show={done} reduced={reduced} className=\"min-w-0\">\n          <span title={question} className=\"text-muted-foreground\">\n            {question}\n          </span>\n        </Reveal>\n        <Reveal show={done} reduced={reduced} className=\"max-w-[55%] shrink-0\">\n          <span aria-hidden=\"true\" className=\"px-2 text-muted-foreground/50\">\n            ·\n          </span>\n          <span title={receipt} className=\"font-medium text-foreground\">\n            {receipt}\n          </span>\n        </Reveal>\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {done ? `${question}: ${receipt}` : \"\"}\n      </span>\n\n      {/* Everything else folds shut once it's answered. */}\n      <div\n        inert={done}\n        aria-hidden={done || undefined}\n        className=\"grid\"\n        style={{\n          gridTemplateRows: done ? \"0fr\" : \"1fr\",\n          opacity: done ? 0 : 1,\n          transition: reduced\n            ? \"none\"\n            : `grid-template-rows 460ms ${MORPH}, opacity ${done ? \"180ms\" : \"320ms\"} ${MORPH}`,\n        }}\n      >\n        <div className=\"min-h-0 overflow-hidden\">\n          <div className=\"p-1.5 pt-0\">\n            <div className=\"px-2.5 pb-2.5\">\n              <p\n                id={`${id}-q`}\n                className=\"mt-1.5 text-[14px] font-medium leading-snug text-foreground\"\n              >\n                {question}\n              </p>\n              {detail && (\n                <p className=\"mt-0.5 text-[12.5px] leading-relaxed text-muted-foreground\">\n                  {detail}\n                </p>\n              )}\n            </div>\n\n            <div\n              role={multiple ? \"group\" : \"radiogroup\"}\n              aria-labelledby={`${id}-q`}\n              onKeyDown={onListKey}\n              className=\"relative\"\n            >\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute inset-x-0 top-0 rounded-[14px] bg-accent\"\n                style={{\n                  height: highlight.height,\n                  transform: `translateY(${highlight.top}px)`,\n                  transition:\n                    highlight.ready && !reduced\n                      ? `transform 200ms ${EASE}, height 200ms ${EASE}`\n                      : \"none\",\n                }}\n              />\n              {rows.map((row, i) => {\n                const isOther = row.id === OTHER;\n                const checked = isOther ? otherOpen : picked.includes(row.id);\n                const description =\n                  \"description\" in row ? row.description : undefined;\n                const marker = multiple ? (\n                  <span\n                    aria-hidden=\"true\"\n                    className={`flex size-[18px] shrink-0 items-center justify-center rounded-[6px] transition-[background-color,box-shadow] duration-200 ${\n                      checked\n                        ? \"bg-primary text-primary-foreground\"\n                        : \"shadow-[inset_0_0_0_1.5px_var(--border)]\"\n                    }`}\n                  >\n                    <DrawnCheck drawn={checked} reduced={reduced} />\n                  </span>\n                ) : (\n                  <kbd\n                    aria-hidden=\"true\"\n                    className={`flex size-[18px] shrink-0 items-center justify-center rounded-[6px] font-mono text-[10.5px] transition-colors ${\n                      i === active\n                        ? \"bg-background text-foreground shadow-[0_0_0_1px_var(--border)]\"\n                        : \"text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n                    }`}\n                  >\n                    {i + 1}\n                  </kbd>\n                );\n\n                if (isOther && otherOpen) {\n                  return (\n                    <div\n                      key={row.id}\n                      ref={(el) => {\n                        rowRefs.current[i] = el;\n                      }}\n                      className=\"relative flex min-h-11 items-center gap-3 rounded-[14px] px-2.5\"\n                    >\n                      {marker}\n                      <input\n                        ref={otherRef}\n                        value={otherText}\n                        onChange={(e) => setOtherText(e.target.value)}\n                        onFocus={() => setActive(i)}\n                        onKeyDown={(e) => {\n                          if (e.nativeEvent.isComposing) return;\n                          if (e.key === \"Enter\") {\n                            e.preventDefault();\n                            submitOther();\n                          } else if (e.key === \"Escape\") {\n                            e.preventDefault();\n                            setOtherOpen(false);\n                            requestAnimationFrame(() =>\n                              (\n                                rowRefs.current[i] as HTMLElement | null\n                              )?.focus(),\n                            );\n                          }\n                        }}\n                        placeholder=\"Type your answer\"\n                        aria-label={otherLabel}\n                        className=\"h-8 min-w-0 flex-1 bg-transparent text-[13.5px] text-foreground outline-none placeholder:text-muted-foreground animate-[ui-fade-in_200ms_ease-out_both] motion-reduce:animate-none\"\n                      />\n                      {!multiple && (\n                        <button\n                          type=\"button\"\n                          aria-label=\"Send answer\"\n                          disabled={!otherText.trim()}\n                          onClick={submitOther}\n                          className={`cursor-pointer disabled:cursor-default flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground transition-[opacity,transform] active:scale-[0.94] disabled:opacity-30 ${FOCUS}`}\n                        >\n                          <ArrowUp className=\"size-3.5\" strokeWidth={2.4} />\n                        </button>\n                      )}\n                    </div>\n                  );\n                }\n\n                return (\n                  <button\n                    key={row.id}\n                    ref={(el) => {\n                      rowRefs.current[i] = el;\n                    }}\n                    type=\"button\"\n                    role={multiple ? \"checkbox\" : \"radio\"}\n                    aria-checked={multiple ? checked : false}\n                    tabIndex={i === active ? 0 : -1}\n                    onClick={() => choose(i)}\n                    onPointerMove={() => i !== active && setActive(i)}\n                    onFocus={() => setActive(i)}\n                    className=\"cursor-pointer relative flex min-h-11 w-full items-center gap-3 rounded-[14px] px-2.5 py-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/30\"\n                  >\n                    {marker}\n                    <span className=\"min-w-0 flex-1\">\n                      <span\n                        className={`block text-[13.5px] ${isOther ? \"text-muted-foreground\" : \"text-foreground\"}`}\n                      >\n                        {isOther ? `${row.label}…` : row.label}\n                      </span>\n                      {description && (\n                        <span className=\"mt-px block text-[12px] leading-snug text-muted-foreground\">\n                          {description}\n                        </span>\n                      )}\n                    </span>\n                  </button>\n                );\n              })}\n            </div>\n\n            {(onSkip || multiple) && (\n              <div className=\"flex items-center justify-between gap-2 px-1 pb-0.5 pt-1.5\">\n                {onSkip ? (\n                  <button\n                    type=\"button\"\n                    onClick={skip}\n                    className={`cursor-pointer h-8 rounded-full px-2.5 text-[12.5px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n                  >\n                    Skip\n                  </button>\n                ) : (\n                  <span />\n                )}\n                {multiple && (\n                  <button\n                    type=\"button\"\n                    disabled={!canContinue}\n                    onClick={() =>\n                      submit(\n                        picked,\n                        otherOpen && otherText.trim()\n                          ? otherText.trim()\n                          : undefined,\n                      )\n                    }\n                    className={`cursor-pointer disabled:cursor-default inline-flex h-8 items-center rounded-full bg-primary px-3.5 text-[12.5px] font-medium text-primary-foreground transition-[opacity,transform] duration-300 active:scale-[0.96] disabled:opacity-35 ${FOCUS}`}\n                  >\n                    Continue\n                    {/* \" · 3\" opens beside it with the first pick and rolls as you pick more. */}\n                    <span\n                      aria-hidden=\"true\"\n                      className=\"grid\"\n                      style={{\n                        gridTemplateColumns: count > 0 ? \"1fr\" : \"0fr\",\n                        opacity: count > 0 ? 1 : 0,\n                        transition: reduced\n                          ? \"none\"\n                          : `grid-template-columns 380ms ${MORPH}, opacity 260ms ${MORPH}`,\n                      }}\n                    >\n                      <span className=\"flex min-w-0 items-baseline overflow-hidden whitespace-nowrap tabular-nums\">\n                        &nbsp;·&nbsp;\n                        <NumberRoll value={Math.max(count, 1)} duration={500} />\n                      </span>\n                    </span>\n                    {count > 0 && (\n                      <span className=\"sr-only\">, {count} selected</span>\n                    )}\n                  </button>\n                )}\n              </div>\n            )}\n          </div>\n        </div>\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"
    },
    {
      "path": "registry/components/text-morph.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * TEXT MORPH: text that changes the way it should\n *\n *   shared   letters both texts share stay alive and slide to\n *            their new place (\"Save\" → \"Saving\" keeps \"Sav\")\n *   new      letters that weren't there rise in out of a blur,\n *            one after another\n *   gone     letters that leave lift away and blur out where\n *            they stood\n *   width    the box eases to its new width, so a button or\n *            pill holding it resizes with it\n *\n * Texts with little in common crossfade as a whole instead of\n * flinging single letters across. The real text is always\n * there for screen readers.\n * ───────────────────────────────────────────────────────── */\n\nexport interface TextMorphProps extends Omit<\n  React.HTMLAttributes<HTMLSpanElement>,\n  \"children\"\n> {\n  children: string;\n  /** Morph time in ms. */\n  duration?: number;\n  /** Where new letters come from: below (\"up\") or above (\"down\"). */\n  direction?: \"up\" | \"down\";\n  /** Ease the box's width too; turn off for text that wraps. */\n  animateWidth?: boolean;\n}\n\ntype Glyph = { key: number; char: string };\ntype Leaving = Glyph & { x: number; y: number };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\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/** Keep the letters two texts share (longest common subsequence); everything else is new. */\nfunction morph(\n  previous: Glyph[],\n  text: string,\n  nextKey: () => number,\n): Glyph[] {\n  const a = previous.map((g) => g.char);\n  const b = Array.from(text);\n  const fresh = () => b.map((char) => ({ key: nextKey(), char }));\n  if (!a.length || !b.length) return fresh();\n\n  const dp = Array.from(\n    { length: a.length + 1 },\n    () => new Uint16Array(b.length + 1),\n  );\n  for (let i = a.length - 1; i >= 0; i--)\n    for (let j = b.length - 1; j >= 0; j--)\n      dp[i][j] =\n        a[i] === b[j]\n          ? dp[i + 1][j + 1] + 1\n          : Math.max(dp[i + 1][j], dp[i][j + 1]);\n\n  // Too little in common reads as two different words: crossfade them instead.\n  if (dp[0][0] < Math.min(a.length, b.length) * 0.5) return fresh();\n\n  const out: Glyph[] = [];\n  let i = 0;\n  let j = 0;\n  while (j < b.length) {\n    if (i < a.length && a[i] === b[j]) {\n      out.push(previous[i++]);\n      j++;\n    } else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) i++;\n    else out.push({ key: nextKey(), char: b[j++] });\n  }\n  return out;\n}\n\nfunction LeavingGlyph({\n  glyph,\n  lift,\n  duration,\n  onDone,\n}: {\n  glyph: Leaving;\n  lift: number;\n  duration: number;\n  onDone: (key: number) => void;\n}) {\n  const ref = React.useRef<HTMLSpanElement>(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        { opacity: 0, filter: \"blur(4px)\", transform: `translateY(${lift}em)` },\n      ],\n      { duration: duration * 0.6, easing: EASE, fill: \"forwards\" },\n    );\n    animation.onfinish = () => onDone(glyph.key);\n    return () => animation.cancel();\n  }, [glyph.key, lift, duration, onDone]);\n  return (\n    <span\n      ref={ref}\n      aria-hidden=\"true\"\n      className=\"pointer-events-none absolute whitespace-pre\"\n      style={{ left: glyph.x, top: glyph.y }}\n    >\n      {glyph.char}\n    </span>\n  );\n}\n\nexport function TextMorph({\n  children: text,\n  duration = 460,\n  direction = \"up\",\n  animateWidth = true,\n  className = \"\",\n  ...props\n}: TextMorphProps) {\n  const reduced = useReducedMotion();\n  const counter = React.useRef(text.length);\n  const nextKey = React.useCallback(() => ++counter.current, []);\n  const [glyphs, setGlyphs] = React.useState<Glyph[]>(() =>\n    Array.from(text).map((char, i) => ({ key: i + 1, char })),\n  );\n  const [leaving, setLeaving] = React.useState<Leaving[]>([]);\n  const rootRef = React.useRef<HTMLSpanElement>(null);\n  const els = React.useRef(new Map<number, HTMLSpanElement>());\n  const before = React.useRef<{\n    at: Map<number, { x: number; y: number }>;\n    width: number;\n  } | null>(null);\n  const rise = direction === \"up\" ? 0.35 : -0.35;\n\n  // A preference change must also finish an in-flight morph whose target text\n  // already matches the glyphs (including the empty label in a folded receipt).\n  React.useLayoutEffect(() => {\n    if (!reduced) return;\n    before.current = null;\n    const root = rootRef.current;\n    root\n      ?.getAnimations({ subtree: true })\n      .forEach((animation) => animation.cancel());\n    if (root) root.style.whiteSpace = \"\";\n    setLeaving((previous) => (previous.length ? [] : previous));\n  }, [reduced]);\n\n  // 1. The text changed: note where every letter stands, then work out which letters stay.\n  React.useLayoutEffect(() => {\n    if (text === glyphs.map((g) => g.char).join(\"\")) return;\n    const root = rootRef.current;\n    if (!root || reduced) {\n      setLeaving([]);\n      setGlyphs(Array.from(text).map((char) => ({ key: nextKey(), char })));\n      return;\n    }\n    const box = root.getBoundingClientRect();\n    const at = new Map<number, { x: number; y: number }>();\n    els.current.forEach((el, key) => {\n      const r = el.getBoundingClientRect();\n      at.set(key, { x: r.left - box.left, y: r.top - box.top });\n    });\n    before.current = { at, width: box.width };\n    const next = morph(glyphs, text, nextKey);\n    const kept = new Set(next.map((g) => g.key));\n    setLeaving((l) => [\n      ...l,\n      ...glyphs\n        .filter((g) => !kept.has(g.key))\n        .map((g) => ({ ...g, ...(at.get(g.key) ?? { x: 0, y: 0 }) })),\n    ]);\n    setGlyphs(next);\n  }, [text, reduced]);\n\n  // 2. The new letters are in place: glide the ones that stayed, raise the new ones, ease the width.\n  React.useLayoutEffect(() => {\n    const prev = before.current;\n    const root = rootRef.current;\n    if (!prev || !root) return;\n    before.current = null;\n    const box = root.getBoundingClientRect();\n    let order = 0;\n    for (const g of glyphs) {\n      const el = els.current.get(g.key);\n      if (!el) continue;\n      const from = prev.at.get(g.key);\n      if (from) {\n        const r = el.getBoundingClientRect();\n        const dx = from.x - (r.left - box.left);\n        const dy = from.y - (r.top - box.top);\n        if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)\n          el.animate(\n            [\n              { transform: `translate(${dx}px, ${dy}px)` },\n              { transform: \"none\" },\n            ],\n            { duration, easing: EASE },\n          );\n      } else {\n        el.animate(\n          [\n            {\n              opacity: 0,\n              filter: \"blur(4px)\",\n              transform: `translateY(${rise}em)`,\n            },\n            { opacity: 1, filter: \"blur(0px)\", transform: \"none\" },\n          ],\n          {\n            duration,\n            easing: EASE,\n            delay: Math.min(order++ * 16, 160),\n            fill: \"backwards\",\n          },\n        );\n      }\n    }\n    if (animateWidth && Math.abs(prev.width - box.width) > 0.5) {\n      // Hold the words on one line while the box is narrower than them, or \"No credits left\" would wrap mid-morph.\n      root.style.whiteSpace = \"nowrap\";\n      const resize = root.animate(\n        [{ width: `${prev.width}px` }, { width: `${box.width}px` }],\n        { duration, easing: EASE },\n      );\n      resize.onfinish = resize.oncancel = () => {\n        root.style.whiteSpace = \"\";\n      };\n    }\n  }, [glyphs, duration, rise, animateWidth]);\n\n  const done = React.useCallback(\n    (key: number) => setLeaving((l) => l.filter((g) => g.key !== key)),\n    [],\n  );\n\n  // Words stay unbroken; lines may only wrap at spaces.\n  const words: Glyph[][] = [];\n  for (const g of glyphs) {\n    if (\n      g.char === \" \" ||\n      !words.length ||\n      words[words.length - 1][0]?.char === \" \"\n    )\n      words.push([g]);\n    else words[words.length - 1].push(g);\n  }\n\n  return (\n    <span\n      ref={rootRef}\n      className={`relative inline-block ${className}`}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      {words.map((word, w) => (\n        <span\n          key={w}\n          aria-hidden=\"true\"\n          className=\"inline-block whitespace-pre\"\n        >\n          {word.map((g) => (\n            <span\n              key={g.key}\n              ref={(el) => {\n                if (el) els.current.set(g.key, el);\n                else els.current.delete(g.key);\n              }}\n              className=\"inline-block\"\n            >\n              {g.char}\n            </span>\n          ))}\n        </span>\n      ))}\n      {leaving.map((g) => (\n        <LeavingGlyph\n          key={`out-${g.key}`}\n          glyph={g}\n          lift={-rise}\n          duration={duration}\n          onDone={done}\n        />\n      ))}\n    </span>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "css": {
    "@keyframes ui-fade-in": {
      "from": {
        "opacity": "0"
      },
      "to": {
        "opacity": "1"
      }
    },
    "@keyframes ui-ping": {
      "from": {
        "transform": "scale(1)",
        "opacity": "0.55"
      },
      "to": {
        "transform": "scale(2.6)",
        "opacity": "0"
      }
    },
    "@keyframes ui-sheen": {
      "from": {
        "mask-position": "150% 0"
      },
      "to": {
        "mask-position": "-50% 0"
      }
    },
    "@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": "clarifying-question",
      "exportName": "ClarifyingQuestion",
      "usage": "import { ClarifyingQuestion } from \"@/components/clarifying-question\";\n\n<ClarifyingQuestion question=\"Which shop is this plan for?\" options={[{ id: \"main\", label: \"Main Street\" }, { id: \"harbour\", label: \"Harbour Road\" }]} onAnswer={handleAnswer} />",
      "accessibility": "Options are labelled radio buttons or checkboxes. Arrow keys move between rows; number keys pick options. Enter submits custom text and Escape returns to options. Answered options are inert and a status region announces the receipt. Reduced motion disables folding, the status pulse, and text/counter movement.",
      "previewNote": "Reuses Number Roll and Text Morph. Demo answers stay local; connect onAnswer and onSkip to your application or AI tool output.",
      "categories": [
        "ai"
      ],
      "documentation": {
        "useCases": [],
        "features": [],
        "states": [],
        "props": [
          {
            "name": "question",
            "type": "string",
            "description": "Required. Question displayed to the user."
          },
          {
            "name": "detail",
            "type": "string",
            "description": "Optional. Supporting explanation below the question."
          },
          {
            "name": "options",
            "type": "QuestionOption[]",
            "description": "Required. Available choices with stable IDs and labels."
          },
          {
            "name": "multiple",
            "type": "boolean",
            "description": "Optional. Allow several answers, confirmed with Continue."
          },
          {
            "name": "allowOther",
            "type": "boolean",
            "description": "Optional. Offer \"Something else\" with a field."
          },
          {
            "name": "otherLabel",
            "type": "string",
            "description": "Optional. Label for the custom-answer choice."
          },
          {
            "name": "answer",
            "type": "QuestionAnswer | null",
            "description": "Optional. The answer once given, e.g. the tool's output; shows the folded line."
          },
          {
            "name": "onAnswer",
            "type": "(answer: QuestionAnswer) => void",
            "description": "Required. Called with the selected or custom answer."
          },
          {
            "name": "onSkip",
            "type": "() => void",
            "description": "Optional. Shows Skip; the agent continues with its own choice."
          },
          {
            "name": "skipped",
            "type": "boolean",
            "description": "Optional. Whether the question has been skipped."
          },
          {
            "name": "skippedText",
            "type": "string",
            "description": "Optional. Text displayed for a skipped question."
          },
          {
            "name": "autoFocus",
            "type": "boolean",
            "description": "Optional. Move focus into the question when it appears."
          }
        ]
      }
    }
  },
  "type": "registry:component"
}