{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-answer-paper",
  "title": "Answer Paper",
  "description": "Drag the paper’s bottom handle to shorten an answer or restore its detail.",
  "dependencies": [
    "motion@14.2.0"
  ],
  "files": [
    {
      "path": "registry/components/cavemio-answer-paper.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  cloneElement,\n  isValidElement,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n} from \"motion/react\";\n\nexport type AnswerPaperLevel = {\n  id: string;\n  label: string;\n  content: ReactNode;\n  /** Estimated reading time in seconds, supplied with each answer version. */\n  readTime: number;\n};\n\nexport type CavemioAnswerPaperProps = {\n  /** Answer versions ordered from shortest to longest. */\n  levels: readonly AnswerPaperLevel[];\n  /** Controlled index into levels. */\n  level?: number;\n  /** Starts with the longest answer when omitted. */\n  defaultLevel?: number;\n  onLevelChange?: (index: number) => void;\n  paperColor?: CSSProperties[\"backgroundColor\"];\n  paperRadius?: CSSProperties[\"borderRadius\"];\n  textColor?: CSSProperties[\"color\"];\n  handleColor?: CSSProperties[\"color\"];\n  disabled?: boolean;\n  ariaLabel?: string;\n  className?: string;\n  style?: CSSProperties;\n};\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, value));\nconst indexWithin = (value: number, count: number) =>\n  clamp(Number.isFinite(value) ? Math.round(value) : count - 1, 0, count - 1);\n\nconst WORD_EASE = \"cubic-bezier(0.16,1,0.3,1)\";\n\n// Keep rich-text structure and natural whitespace while giving each word a box.\nfunction wordify(content: ReactNode): ReactNode {\n  return Children.map(content, (child) => {\n    if (typeof child === \"string\" || typeof child === \"number\") {\n      return String(child)\n        .split(/(\\s+)/)\n        .map((part, index) =>\n          !part || /^\\s+$/.test(part) ? (\n            part\n          ) : (\n            <span key={index} data-answer-word=\"\" className=\"inline-block\">\n              {part}\n            </span>\n          ),\n        );\n    }\n    if (\n      isValidElement<{ children?: ReactNode }>(child) &&\n      child.props.children\n    ) {\n      return cloneElement(child, { children: wordify(child.props.children) });\n    }\n    return child;\n  });\n}\n\n// Match shared words in reading order.\n// Matching across blocks lets a sentence glide from a section into a summary.\nfunction matchWords(before: HTMLElement[], after: HTMLElement[]) {\n  const beforeText = before.map((word) => word.textContent);\n  const afterText = after.map((word) => word.textContent);\n  const table = Array.from(\n    { length: before.length + 1 },\n    () => new Uint32Array(after.length + 1),\n  );\n  for (let i = before.length - 1; i >= 0; i--) {\n    for (let j = after.length - 1; j >= 0; j--) {\n      table[i][j] =\n        beforeText[i] === afterText[j]\n          ? table[i + 1][j + 1] + 1\n          : Math.max(table[i + 1][j], table[i][j + 1]);\n    }\n  }\n  const matches = new Map<number, number>();\n  let i = 0;\n  let j = 0;\n  while (i < before.length && j < after.length) {\n    if (beforeText[i] === afterText[j]) {\n      matches.set(j++, i++);\n    } else if (table[i + 1][j] >= table[i][j + 1]) i++;\n    else j++;\n  }\n  return matches;\n}\n\nfunction useWordTransition(\n  active: number,\n  layers: { current: (HTMLDivElement | null)[] },\n  overlay: { current: HTMLDivElement | null },\n  reduced: boolean,\n) {\n  const previous = useRef(active);\n  useLayoutEffect(() => {\n    const from = layers.current[previous.current];\n    const to = layers.current[active];\n    const ghosts = overlay.current;\n    previous.current = active;\n    if (!to || !ghosts) return;\n    if (reduced) {\n      layers.current.forEach((layer) =>\n        layer\n          ?.getAnimations({ subtree: true })\n          .forEach((animation) => animation.cancel()),\n      );\n      ghosts.replaceChildren();\n      return;\n    }\n    if (!from || from === to) return;\n\n    const before = Array.from(\n      from.querySelectorAll<HTMLElement>(\"[data-answer-word]\"),\n    );\n    const after = Array.from(\n      to.querySelectorAll<HTMLElement>(\"[data-answer-word]\"),\n    );\n    const origin = ghosts.getBoundingClientRect();\n    // Read presentation positions and opacity BEFORE cancelling an interrupted morph.\n    const first = before.map((word) => {\n      const rect = word.getBoundingClientRect();\n      const style = getComputedStyle(word);\n      return {\n        rect,\n        opacity: style.opacity,\n        filter: style.filter,\n        font: style.font,\n        letterSpacing: style.letterSpacing,\n        color: style.color,\n      };\n    });\n    const matches = matchWords(before, after);\n    const kept = new Set(matches.values());\n    layers.current.forEach((layer) =>\n      layer\n        ?.getAnimations({ subtree: true })\n        .forEach((animation) => animation.cancel()),\n    );\n    ghosts.replaceChildren();\n    const last = after.map((word) => word.getBoundingClientRect());\n    let fresh = 0;\n    after.forEach((word, index) => {\n      const match = matches.get(index);\n      const was = match === undefined ? undefined : first[match];\n      if (was) {\n        word.animate(\n          [\n            {\n              transform: `translate(${was.rect.left - last[index].left}px, ${was.rect.top - last[index].top}px)`,\n              opacity: was.opacity,\n              filter: was.filter,\n            },\n            { transform: \"none\", opacity: 1, filter: \"blur(0px)\" },\n          ],\n          { duration: 480, easing: WORD_EASE },\n        );\n      } else {\n        word.animate(\n          [\n            {\n              opacity: 0,\n              transform: \"translateY(0.35em)\",\n              filter: \"blur(4px)\",\n            },\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n          ],\n          {\n            duration: 380,\n            delay: Math.min(fresh++ * 14, 120),\n            easing: WORD_EASE,\n            fill: \"backwards\",\n          },\n        );\n      }\n    });\n    before.forEach((word, index) => {\n      if (kept.has(index)) return;\n      const was = first[index];\n      if (Number(was.opacity) === 0) return;\n      const ghost = document.createElement(\"span\");\n      ghost.textContent = word.textContent;\n      Object.assign(ghost.style, {\n        position: \"absolute\",\n        whiteSpace: \"pre\",\n        font: was.font,\n        letterSpacing: was.letterSpacing,\n        color: was.color,\n        left: `${was.rect.left - origin.left}px`,\n        top: `${was.rect.top - origin.top}px`,\n      });\n      ghosts.appendChild(ghost);\n      const animation = ghost.animate(\n        [\n          { opacity: was.opacity, transform: \"none\", filter: was.filter },\n          {\n            opacity: 0,\n            transform: \"translateY(-0.2em) scale(0.96)\",\n            filter: \"blur(4px)\",\n          },\n        ],\n        { duration: 240, easing: WORD_EASE, fill: \"forwards\" },\n      );\n      animation.onfinish = () => ghost.remove();\n    });\n  }, [active, layers, overlay, reduced]);\n\n  useEffect(() => {\n    const nodes = layers.current;\n    const ghosts = overlay.current;\n    return () => {\n      nodes.forEach((node) =>\n        node\n          ?.getAnimations({ subtree: true })\n          .forEach((animation) => animation.cancel()),\n      );\n      ghosts?.replaceChildren();\n    };\n  }, [layers, overlay]);\n}\n\nfunction AnswerLayer({\n  answer,\n  index,\n  active,\n  contentRef,\n  answerId,\n}: {\n  answer: AnswerPaperLevel;\n  index: number;\n  active: number;\n  contentRef: Ref<HTMLDivElement>;\n  answerId: string;\n}) {\n  const content = useMemo(() => wordify(answer.content), [answer.content]);\n  return (\n    <div\n      ref={contentRef}\n      data-answer-layer=\"\"\n      id={index === active ? answerId : undefined}\n      aria-hidden={index !== active}\n      inert={index !== active}\n      className=\"absolute inset-x-0 top-0 p-6 text-[15px] font-normal leading-[1.65] tracking-[-0.012em] [&_h3]:mb-2 [&_h3]:text-[16px] [&_h3]:font-semibold [&_h3]:leading-[1.35] [&_p]:m-0 [&_section+section]:mt-6 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_li+li]:mt-1\"\n      style={{\n        // Chrome can retain an animated child's inherited visibility:hidden\n        // after its layer becomes visible. Opacity does not inherit, so words\n        // always settle back to their normal painted state.\n        opacity: index === active ? 1 : 0,\n        pointerEvents: index === active ? \"auto\" : \"none\",\n      }}\n    >\n      {content}\n    </div>\n  );\n}\n\n/** A resizable answer whose content changes in detail as its paper is dragged. */\nexport function CavemioAnswerPaper({\n  levels,\n  level,\n  defaultLevel = levels.length - 1,\n  onLevelChange,\n  paperColor = \"var(--card, var(--background))\",\n  paperRadius = 14,\n  textColor = \"var(--foreground)\",\n  handleColor = \"var(--accent-ink, var(--foreground))\",\n  disabled = false,\n  ariaLabel = \"Answer length\",\n  className,\n  style,\n}: CavemioAnswerPaperProps) {\n  const [internalLevel, setInternalLevel] = useState(defaultLevel);\n  const [heights, setHeights] = useState<number[]>([]);\n  const [dragging, setDragging] = useState(false);\n  const active = indexWithin(level ?? internalLevel, levels.length);\n  const selected = levels[active];\n  const contentRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const overlayRef = useRef<HTMLDivElement | null>(null);\n  const height = useMotionValue(0);\n  const reducedMotion = useReducedMotion();\n  const releaseVelocity = useRef(0);\n  const activeRef = useRef(active);\n  const drag = useRef<{\n    pointer: number;\n    target: HTMLDivElement;\n    y: number;\n    height: number;\n    startLevel: number;\n    samples: { y: number; time: number }[];\n  } | null>(null);\n  const instructionsId = useId();\n  const answerId = useId();\n  activeRef.current = active;\n  useWordTransition(active, contentRefs, overlayRef, Boolean(reducedMotion));\n\n  useLayoutEffect(() => {\n    const nodes = contentRefs.current.slice(0, levels.length);\n    const measure = () => {\n      const next = nodes.map((node) => node?.offsetHeight ?? 0);\n      if (next.some((size) => size === 0)) return;\n      setHeights((previous) =>\n        next.length === previous.length &&\n        next.every((size, i) => size === previous[i])\n          ? previous\n          : next,\n      );\n      if (height.get() === 0) height.jump(next[activeRef.current]);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    nodes.forEach((node) => node && observer.observe(node));\n    return () => observer.disconnect();\n  }, [levels, height]);\n\n  useLayoutEffect(() => {\n    if (drag.current && !disabled) return;\n    const gesture = drag.current;\n    drag.current = null;\n    if (gesture?.target.hasPointerCapture(gesture.pointer)) {\n      gesture.target.releasePointerCapture(gesture.pointer);\n    }\n    if (disabled && dragging) setDragging(false);\n    if (!heights[active]) return;\n    if (reducedMotion) {\n      height.jump(heights[active]);\n      return;\n    }\n    const animation = animate(height, heights[active], {\n      type: \"spring\",\n      stiffness: 440,\n      damping: 42,\n      mass: 0.8,\n      velocity: releaseVelocity.current,\n    });\n    releaseVelocity.current = 0;\n    return () => animation.stop();\n  }, [active, heights, dragging, disabled, height, reducedMotion]);\n\n  if (!selected) {\n    throw new Error(\"CavemioAnswerPaper needs at least one answer level.\");\n  }\n\n  function select(index: number) {\n    const next = indexWithin(index, levels.length);\n    if (next === activeRef.current) return;\n    activeRef.current = next;\n    if (level === undefined) setInternalLevel(next);\n    onLevelChange?.(next);\n  }\n\n  function levelAtHeight(size: number, current = activeRef.current) {\n    let next = current;\n    // Separate thresholds prevent tiny pointer reversals from restarting a rewrite.\n    // Shorten early, before the longer answer would be clipped by its paper.\n    while (next > 0) {\n      const gap = heights[next] - heights[next - 1];\n      if (size >= heights[next] - Math.min(52, gap * 0.32)) break;\n      next--;\n    }\n    while (next < heights.length - 1) {\n      const gap = heights[next + 1] - heights[next];\n      if (size <= heights[next + 1] - Math.min(28, gap * 0.16)) break;\n      next++;\n    }\n    return next;\n  }\n\n  function start(event: PointerEvent<HTMLDivElement>) {\n    if (\n      disabled ||\n      levels.length < 2 ||\n      heights.length !== levels.length ||\n      event.button !== 0 ||\n      drag.current\n    )\n      return;\n    event.preventDefault();\n    event.currentTarget.dataset.pointerFocus = \"true\";\n    event.currentTarget.focus({ preventScroll: true });\n    event.currentTarget.setPointerCapture(event.pointerId);\n    height.stop();\n    drag.current = {\n      pointer: event.pointerId,\n      target: event.currentTarget,\n      y: event.clientY,\n      height: height.get(),\n      startLevel: active,\n      samples: [{ y: event.clientY, time: event.timeStamp }],\n    };\n    setDragging(true);\n  }\n\n  function move(event: PointerEvent<HTMLDivElement>) {\n    const gesture = drag.current;\n    if (!gesture || gesture.pointer !== event.pointerId) return;\n    const min = Math.min(...heights);\n    const max = Math.max(...heights);\n    const raw = gesture.height + event.clientY - gesture.y;\n    const bounded = clamp(raw, min, max);\n    const overflow = raw - bounded;\n    // A soft edge stays responsive without letting the paper escape its bounds.\n    const resistance = reducedMotion\n      ? 0\n      : (overflow * 0.18) / (1 + Math.abs(overflow) / 100);\n    height.set(bounded + resistance);\n    gesture.samples.push({ y: event.clientY, time: event.timeStamp });\n    gesture.samples = gesture.samples.filter(\n      (sample) => event.timeStamp - sample.time <= 80,\n    );\n    select(levelAtHeight(bounded));\n  }\n\n  function finish(event: PointerEvent<HTMLDivElement>, cancelled = false) {\n    const gesture = drag.current;\n    if (!gesture || gesture.pointer !== event.pointerId) return;\n    const first = gesture.samples[0];\n    const last = gesture.samples.at(-1)!;\n    const velocity =\n      event.timeStamp - last.time < 100 && last.time > first.time\n        ? clamp(\n            ((last.y - first.y) / (last.time - first.time)) * 1000,\n            -1600,\n            1600,\n          )\n        : 0;\n    drag.current = null;\n    releaseVelocity.current = cancelled ? 0 : velocity;\n    select(\n      cancelled\n        ? gesture.startLevel\n        : levelAtHeight(height.get() + clamp(velocity * 0.08, -44, 44)),\n    );\n    setDragging(false);\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n  }\n\n  function keyDown(event: KeyboardEvent<HTMLDivElement>) {\n    delete event.currentTarget.dataset.pointerFocus;\n    if (disabled) return;\n    const next = {\n      ArrowUp: active - 1,\n      ArrowLeft: active - 1,\n      ArrowDown: active + 1,\n      ArrowRight: active + 1,\n      Home: 0,\n      End: levels.length - 1,\n    }[event.key];\n    if (next === undefined) return;\n    event.preventDefault();\n    // Keyboard input takes over immediately, even while a pointer is held down.\n    const gesture = drag.current;\n    if (gesture) {\n      drag.current = null;\n      releaseVelocity.current = 0;\n      setDragging(false);\n      if (gesture.target.hasPointerCapture(gesture.pointer)) {\n        gesture.target.releasePointerCapture(gesture.pointer);\n      }\n    }\n    select(next);\n  }\n\n  return (\n    <div className={className} style={{ width: \"100%\", ...style }}>\n      <div className=\"relative\">\n        <motion.div\n          style={{\n            height,\n            backgroundColor: paperColor,\n            borderRadius: paperRadius,\n            color: textColor,\n            boxShadow:\n              \"0 26px 38px -30px rgba(48, 61, 28, 0.28), inset 0 1px 0 rgba(255,255,255,0.75)\",\n          }}\n          className=\"relative overflow-hidden\"\n        >\n          {levels.map((answer, index) => (\n            <AnswerLayer\n              key={answer.id}\n              answer={answer}\n              index={index}\n              active={active}\n              answerId={answerId}\n              contentRef={(node) => {\n                contentRefs.current[index] = node;\n              }}\n            />\n          ))}\n          <div\n            ref={overlayRef}\n            aria-hidden=\"true\"\n            inert\n            className=\"pointer-events-none absolute inset-0\"\n          />\n        </motion.div>\n        <div\n          role=\"slider\"\n          tabIndex={disabled || levels.length < 2 ? -1 : 0}\n          aria-label={ariaLabel}\n          aria-orientation=\"vertical\"\n          aria-valuemin={0}\n          aria-valuemax={levels.length - 1}\n          aria-valuenow={active}\n          aria-valuetext={`${selected.label}, ${selected.readTime} second read`}\n          aria-controls={answerId}\n          aria-describedby={instructionsId}\n          aria-disabled={disabled || levels.length < 2}\n          onPointerDown={start}\n          onPointerMove={move}\n          onPointerUp={(event) => finish(event)}\n          onPointerCancel={(event) => finish(event, true)}\n          onLostPointerCapture={(event) => finish(event, true)}\n          onKeyDown={keyDown}\n          onBlur={(event) => {\n            delete event.currentTarget.dataset.pointerFocus;\n          }}\n          title=\"Drag up to shorten · drag down for more detail\"\n          className=\"absolute bottom-0 left-1/2 z-10 flex h-11 w-20 -translate-x-1/2 translate-y-1/2 touch-none select-none items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 data-[pointer-focus=true]:ring-0 data-[pointer-focus=true]:ring-offset-0\"\n          style={{\n            cursor:\n              disabled || levels.length < 2\n                ? \"default\"\n                : dragging\n                  ? \"grabbing\"\n                  : \"ns-resize\",\n          }}\n        >\n          <motion.span\n            aria-hidden=\"true\"\n            animate={{\n              scale: dragging && !disabled && !reducedMotion ? 1.06 : 1,\n            }}\n            transition={{ duration: reducedMotion ? 0 : 0.12 }}\n            className=\"flex h-[22px] w-[56px] items-center justify-center rounded-full border border-border bg-background\"\n            style={{\n              boxShadow:\n                \"0 3px 5px rgba(48,61,28,0.10), 0 10px 18px rgba(48,61,28,0.05)\",\n              opacity: disabled ? 0.5 : 1,\n            }}\n          >\n            <span\n              className=\"h-[3px] w-5 rounded-full\"\n              style={{ backgroundColor: handleColor }}\n            />\n          </motion.span>\n        </div>\n      </div>\n      <div\n        className=\"mt-5 pr-1 text-right text-[12px] leading-5 tabular-nums text-muted-foreground\"\n        aria-hidden=\"true\"\n      >\n        {selected.readTime} s read\n      </div>\n      <span id={instructionsId} className=\"sr-only\">\n        Drag up for a shorter answer or down for more detail. Use Up and Down\n        arrow keys, Home for the shortest answer, or End for the full answer.\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:component"
    }
  ],
  "meta": {
    "cavemio": {
      "component": "answer-paper",
      "exportName": "CavemioAnswerPaper",
      "usage": "import { CavemioAnswerPaper } from \"@/components/cavemio-answer-paper\";\n\n<CavemioAnswerPaper paperColor=\"#fffdf8\" paperRadius={26} levels={[{ id: \"short\", label: \"Brief\", readTime: 1, content: <p>The short answer.</p> }, { id: \"full\", label: \"Full answer\", readTime: 10, content: <p>Your complete answer and supporting details go here.</p> }]} />",
      "accessibility": "The bottom handle is a keyboard-accessible slider. Up/Left shorten, Down/Right expand, Home selects the shortest version, and End restores the full answer. Inactive content is inert and hidden from assistive technology. Reduced motion disables spring and word movement, blur, and scale transitions.",
      "previewNote": "The demo reproduces the supplied video’s four prepared answer versions. The component displays caller-provided summaries; it does not generate AI text or make network requests.",
      "categories": [
        "ai",
        "interactions"
      ],
      "documentation": {
        "useCases": [],
        "features": [],
        "states": [],
        "props": [
          {
            "name": "levels",
            "type": "readonly AnswerPaperLevel[]",
            "description": "Required. Answer versions ordered from shortest to longest."
          },
          {
            "name": "level",
            "type": "number",
            "description": "Optional. Controlled index into levels."
          },
          {
            "name": "defaultLevel",
            "type": "number",
            "description": "Optional. Starts with the longest answer when omitted."
          },
          {
            "name": "onLevelChange",
            "type": "(index: number) => void",
            "description": "Optional. Called with the selected answer level index."
          },
          {
            "name": "paperColor",
            "type": "CSSProperties[\"backgroundColor\"]",
            "description": "Optional. Background color of the paper."
          },
          {
            "name": "paperRadius",
            "type": "CSSProperties[\"borderRadius\"]",
            "description": "Optional. Border radius of the paper."
          },
          {
            "name": "textColor",
            "type": "CSSProperties[\"color\"]",
            "description": "Optional. Color of the answer text."
          },
          {
            "name": "handleColor",
            "type": "CSSProperties[\"color\"]",
            "description": "Optional. Color of the drag handle."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "description": "Optional. Disable user interaction."
          },
          {
            "name": "ariaLabel",
            "type": "string",
            "description": "Optional. Accessible name for the component."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Optional. Additional CSS classes for the root element."
          },
          {
            "name": "style",
            "type": "CSSProperties",
            "description": "Optional. Inline styles for the root element."
          }
        ]
      }
    }
  },
  "type": "registry:component"
}