{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cavemio-inline-rewrite",
  "title": "Inline Rewrite",
  "description": "Drag a dial to rewrite a paragraph in place, with shared words gliding into position.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/components/inline-rewrite.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * INLINE REWRITE: drag a dial, the paragraph rewrites itself\n *\n *   dial      one thumb on a track of versions (Shorter, Original,\n *             Longer; or Blunt, Neutral, Warm). Drag it, tap the\n *             track or use the arrow keys; its label morphs and\n *             it settles onto the nearest stop with a little throw\n *   rewrite   the paragraph changes in place, word by word: the\n *             words both versions share glide to their new places,\n *             the ones that go blur out where they stood, the new\n *             ones rise in one after another and glow for a moment\n *             so you can see what changed. The box eases to its\n *             new height\n *   on demand versions without text are written the first time the\n *             dial settles on them (onRewrite): a spinner opens in\n *             the thumb and a sheen runs over the paragraph until\n *             the new words arrive\n *   count     the word count rolls; +added and −cut against the\n *             original roll beside it\n * ───────────────────────────────────────────────────────── */\n\nexport interface RewriteVersion {\n  label: string;\n  /** Leave it out to have onRewrite write it the first time it's chosen. */\n  text?: string;\n}\n\nexport interface RewriteRequest {\n  index: number;\n  label: string;\n  /** The paragraph as it reads now, and the original version. */\n  current: string;\n  original: string;\n  /** Aborted if the component unmounts first. */\n  signal: AbortSignal;\n}\n\nexport interface InlineRewriteProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"defaultValue\"\n> {\n  versions: RewriteVersion[];\n  /** The chosen version's index. */\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (index: number) => void;\n  /** The version the counts compare against. Defaults to the one chosen first. */\n  originalIndex?: number;\n  onRewrite?: (request: RewriteRequest) => Promise<string>;\n  /** Visible heading and accessible name for the slider, e.g. \"Tone\". */\n  label?: string;\n  /** Optional help text, shown beside the heading in an info tooltip. */\n  description?: string;\n  /** Let new words glow for a moment after each rewrite. */\n  highlight?: boolean;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst SHEEN =\n  \"[mask-image:linear-gradient(90deg,rgb(0_0_0/0.4)_35%,#000_50%,rgb(0_0_0/0.4)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none]\";\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\nconst split = (text: string) => text.split(/\\s+/).filter(Boolean);\nconst clamp = (n: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, n));\n\n/** Longest common subsequence table over words: dp[i][j] is the match length of a[i..] and b[j..]. */\nfunction table(a: string[], b: string[]) {\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  return dp;\n}\n\ntype Word = { key: number; text: string };\ntype Leaving = Word & { x: number; y: number; batch: number };\n\n/** Keep the words two texts share (and their keys); everything else is new. */\nfunction morphWords(\n  previous: Word[],\n  next: string[],\n  nextKey: () => number,\n): Word[] {\n  const a = previous.map((w) => w.text);\n  const dp = table(a, next);\n  const out: Word[] = [];\n  let i = 0;\n  let j = 0;\n  while (j < next.length) {\n    if (i < a.length && a[i] === next[j]) {\n      out.push(previous[i]);\n      i++;\n      j++;\n    } else if (i < a.length && dp[i + 1][j] >= dp[i][j + 1]) i++;\n    else out.push({ key: nextKey(), text: next[j++] });\n  }\n  return out;\n}\n\n/** The paragraph: words as their own boxes so each can move, leave or arrive. */\nfunction Words({\n  text,\n  reduced,\n  highlight,\n  className,\n}: {\n  text: string;\n  reduced: boolean;\n  highlight: boolean;\n  className: string;\n}) {\n  const boxRef = React.useRef<HTMLParagraphElement>(null);\n  const seq = React.useRef(1_000_000); // above the first render's keys\n  const [words, setWords] = React.useState<Word[]>(() =>\n    split(text).map((t, i) => ({ key: i + 1, text: t })),\n  );\n  const [leaving, setLeaving] = React.useState<Leaving[]>([]);\n  const shown = React.useRef(text);\n  const flip = React.useRef<{\n    first: Map<number, DOMRect>;\n    height: number;\n  } | null>(null);\n  const batch = React.useRef(0);\n\n  // Before the new words render: note where every word is, and keep the leaving ones where they stood.\n  React.useLayoutEffect(() => {\n    if (text === shown.current) return;\n    shown.current = text;\n    const next = morphWords(words, split(text), () => ++seq.current);\n    const box = boxRef.current;\n    if (!box || reduced) {\n      setWords(next);\n      setLeaving([]);\n      return;\n    }\n    const b = box.getBoundingClientRect();\n    const first = new Map<number, DOMRect>();\n    box.querySelectorAll<HTMLElement>(\"[data-k]\").forEach((el) => {\n      // Undo the last glow's reach over the spaces (it never moved anything) so boxes are just the words.\n      el.style.marginLeft = el.style.paddingLeft = el.style.borderRadius = \"\";\n      first.set(Number(el.dataset.k), el.getBoundingClientRect());\n    });\n    const keep = new Set(next.map((w) => w.key));\n    const id = ++batch.current;\n    const gone: Leaving[] = [];\n    for (const w of words) {\n      const r = first.get(w.key);\n      if (!keep.has(w.key) && r)\n        gone.push({ ...w, x: r.left - b.left, y: r.top - b.top, batch: id });\n    }\n    flip.current = { first, height: b.height };\n    setWords(next);\n    if (gone.length) {\n      setLeaving((l) => [...l, ...gone]);\n      window.setTimeout(\n        () => setLeaving((l) => l.filter((w) => w.batch !== id)),\n        320,\n      );\n    }\n  }, [text, reduced, words]);\n\n  // After: glide the kept words from where they were, raise the new ones, blur the leaving ones away.\n  React.useLayoutEffect(() => {\n    const f = flip.current;\n    const box = boxRef.current;\n    if (!f || !box) return;\n    flip.current = null;\n    const els = Array.from(box.querySelectorAll<HTMLElement>(\"[data-k]\"));\n    box.getAnimations().forEach((a) => a.cancel());\n    els.forEach((el) => el.getAnimations().forEach((a) => a.cancel()));\n    const height = box.getBoundingClientRect().height;\n    const rects = els.map((el) => el.getBoundingClientRect());\n    if (Math.abs(height - f.height) > 0.5)\n      box.animate([{ height: `${f.height}px` }, { height: `${height}px` }], {\n        duration: 480,\n        easing: EASE,\n      });\n    const primary = highlight\n      ? getComputedStyle(box).getPropertyValue(\"--primary\").trim()\n      : \"\";\n    const glow = primary\n      ? `color-mix(in oklab, ${primary} 16%, transparent)`\n      : \"rgb(0 0 0 / 0.06)\";\n    const isNew = els.map((el) => !f.first.has(Number(el.dataset.k)));\n    const sameLine = (i: number, j: number) =>\n      Math.abs(rects[i].top - rects[j].top) < 2;\n    let fresh = 0;\n    els.forEach((el, i) => {\n      const was = f.first.get(Number(el.dataset.k));\n      if (was) {\n        const dx = was.left - rects[i].left;\n        const dy = was.top - rects[i].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: 480, easing: EASE },\n          );\n        return;\n      }\n      const delay = Math.min(90 + fresh++ * 14, 420);\n      el.animate(\n        [\n          { opacity: 0, transform: \"translateY(0.35em)\", filter: \"blur(4px)\" },\n          { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n        ],\n        { duration: 380, delay, easing: EASE, fill: \"backwards\" },\n      );\n      if (!highlight) return;\n      // New words side by side glow as one run: reach back over the space between them, round only the run's ends.\n      const joinsBefore = i > 0 && isNew[i - 1] && sameLine(i - 1, i);\n      const joinsAfter =\n        i < els.length - 1 && isNew[i + 1] && sameLine(i, i + 1);\n      if (joinsBefore) {\n        const gap = Math.max(0, rects[i].left - rects[i - 1].right);\n        el.style.marginLeft = `${-gap}px`;\n        el.style.paddingLeft = `${gap}px`;\n      }\n      el.style.borderRadius = `${joinsBefore ? 0 : 4}px ${joinsAfter ? 0 : 4}px ${joinsAfter ? 0 : 4}px ${joinsBefore ? 0 : 4}px`;\n      el.animate(\n        [\n          { backgroundColor: glow },\n          { backgroundColor: glow, offset: 0.35 },\n          { backgroundColor: \"transparent\" },\n        ],\n        {\n          duration: 1800,\n          delay,\n          easing: \"ease-out\",\n          fill: \"backwards\",\n        },\n      );\n    });\n    box\n      .querySelectorAll<HTMLElement>(\"[data-leave]:not([data-out])\")\n      .forEach((el) => {\n        el.dataset.out = \"\";\n        el.animate(\n          [\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            {\n              opacity: 0,\n              transform: \"translateY(-0.2em) scale(0.96)\",\n              filter: \"blur(4px)\",\n            },\n          ],\n          { duration: 240, easing: EASE, fill: \"forwards\" },\n        );\n      });\n  }, [words, highlight]);\n\n  return (\n    <p ref={boxRef} className={`relative ${className}`}>\n      {words.map((w, i) => (\n        <React.Fragment key={w.key}>\n          {i > 0 && \" \"}\n          <span data-k={w.key} className=\"inline-block\">\n            {w.text}\n          </span>\n        </React.Fragment>\n      ))}\n      {leaving.map((w) => (\n        <span\n          key={`gone-${w.batch}-${w.key}`}\n          data-leave=\"\"\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute whitespace-nowrap\"\n          style={{ left: w.x, top: w.y }}\n        >\n          {w.text}\n        </span>\n      ))}\n    </p>\n  );\n}\n\n/** A gradient track with a floating value label and a circular thumb. */\nfunction Dial({\n  labels,\n  index,\n  busy,\n  label,\n  description,\n  reduced,\n  onMove,\n}: {\n  labels: string[];\n  index: number;\n  busy: boolean;\n  label: string;\n  description?: string;\n  reduced: boolean;\n  onMove: (index: number, settled: boolean) => void;\n}) {\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const thumbRef = React.useRef<HTMLDivElement>(null);\n  const headingRef = React.useRef<HTMLDivElement>(null);\n  const badgeRef = React.useRef<HTMLDivElement>(null);\n  const descriptionId = React.useId();\n  const [size, setSize] = React.useState({ width: 0, heading: 0, badge: 0 });\n  const [ready, setReady] = React.useState(false);\n  const [drag, setDrag] = React.useState<number | null>(null);\n  const live = React.useRef({ index, drag: null as number | null });\n  live.current = { index, drag };\n  const n = labels.length;\n  const disabled = n < 2;\n\n  React.useLayoutEffect(() => {\n    const track = trackRef.current;\n    if (!track) return;\n    const measure = () => {\n      setSize({\n        width: track.clientWidth,\n        heading: headingRef.current?.offsetWidth ?? 0,\n        badge: badgeRef.current?.offsetWidth ?? 0,\n      });\n    };\n    measure();\n    const frame = requestAnimationFrame(() => setReady(true));\n    const observer = new ResizeObserver(measure);\n    observer.observe(track);\n    if (headingRef.current) observer.observe(headingRef.current);\n    if (badgeRef.current) observer.observe(badgeRef.current);\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n    };\n  }, []);\n\n  const pad = Math.min(11, size.width / 2);\n  const span = Math.max(1, size.width - pad * 2);\n  const stopX = (i: number) =>\n    n < 2 ? size.width / 2 : pad + (i * span) / (n - 1);\n  const nearest = (x: number) =>\n    n < 2 ? 0 : clamp(Math.round(((x - pad) / span) * (n - 1)), 0, n - 1);\n  const x = drag === null ? stopX(index) : clamp(drag, pad, size.width - pad);\n  // Keep the value inside the component, clear of the heading at the first stops.\n  const badgeHalf = size.badge / 2;\n  const badgeX = clamp(\n    x,\n    Math.min(size.heading + 12 + badgeHalf, size.width - badgeHalf),\n    size.width - badgeHalf,\n  );\n  const arrowX = x - badgeX + badgeHalf;\n  const transition = (property: string) =>\n    drag !== null || reduced || !ready ? \"none\" : `${property} 420ms ${EASE}`;\n\n  const at = (e: React.PointerEvent) =>\n    e.clientX - (trackRef.current?.getBoundingClientRect().left ?? 0);\n  const follow = (px: number, settled: boolean) => {\n    const i = nearest(px);\n    if (i !== live.current.index || settled) onMove(i, settled);\n  };\n  const finish = () => {\n    const px = live.current.drag;\n    if (px === null) return;\n    live.current.drag = null;\n    setDrag(null);\n    follow(px, true);\n  };\n\n  const onKey = (e: React.KeyboardEvent) => {\n    if (disabled) return;\n    const steps: Record<string, number> = {\n      ArrowLeft: -1,\n      ArrowDown: -1,\n      ArrowRight: 1,\n      ArrowUp: 1,\n    };\n    let next: number | null = null;\n    if (e.key in steps) next = clamp(index + steps[e.key], 0, n - 1);\n    if (e.key === \"Home\") next = 0;\n    if (e.key === \"End\") next = n - 1;\n    if (next === null) return;\n    e.preventDefault();\n    if (next !== index) onMove(next, true);\n  };\n\n  return (\n    <div>\n      <div className=\"flex h-7 items-center\">\n        <div ref={headingRef} className=\"flex items-center gap-2\">\n          <span className=\"text-[14px] font-medium text-foreground\">\n            {label}\n          </span>\n          {description && (\n            <span className=\"group/info relative inline-flex\">\n              <button\n                type=\"button\"\n                aria-label={`About ${label}`}\n                aria-describedby={descriptionId}\n                className=\"inline-flex size-4 items-center justify-center rounded-full text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\"\n              >\n                <svg\n                  viewBox=\"0 0 20 20\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.6\"\n                  aria-hidden=\"true\"\n                  className=\"size-4\"\n                >\n                  <circle cx=\"10\" cy=\"10\" r=\"8\" />\n                  <path d=\"M10 9v5\" />\n                  <circle\n                    cx=\"10\"\n                    cy=\"6\"\n                    r=\".8\"\n                    fill=\"currentColor\"\n                    stroke=\"none\"\n                  />\n                </svg>\n              </button>\n              <span\n                id={descriptionId}\n                role=\"tooltip\"\n                className=\"pointer-events-none invisible absolute left-0 top-full z-20 mt-2 w-48 rounded-lg border border-border bg-background px-3 py-2 text-[12px] leading-relaxed text-muted-foreground shadow-sm group-hover/info:visible group-focus-within/info:visible\"\n              >\n                {description}\n              </span>\n            </span>\n          )}\n        </div>\n      </div>\n      <div\n        ref={trackRef}\n        onPointerDown={(e) => {\n          if (disabled || e.button !== 0 || !e.isPrimary) return;\n          e.currentTarget.setPointerCapture(e.pointerId);\n          const px = at(e);\n          live.current.drag = px;\n          setDrag(px);\n          follow(px, false);\n          thumbRef.current?.focus({ preventScroll: true });\n        }}\n        onPointerMove={(e) => {\n          if (live.current.drag === null || !e.isPrimary) return;\n          const px = at(e);\n          live.current.drag = px;\n          setDrag(px);\n          follow(px, false);\n        }}\n        onPointerUp={finish}\n        onPointerCancel={finish}\n        onLostPointerCapture={finish}\n        className={`relative mt-2 h-7 w-full touch-pan-y select-none ${\n          disabled\n            ? \"cursor-default\"\n            : drag === null\n              ? \"cursor-pointer\"\n              : \"cursor-grabbing\"\n        }`}\n      >\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 top-1/2 h-3 -translate-y-1/2 overflow-hidden rounded-full bg-muted\"\n        >\n          <div\n            className=\"h-full rounded-full bg-linear-to-r from-muted to-primary\"\n            style={{ width: x, transition: transition(\"width\") }}\n          />\n        </div>\n        <div\n          ref={badgeRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-[calc(50%+20px)] flex max-w-full -translate-x-1/2 items-center rounded-xl bg-muted px-3.5 py-1 text-[13px] leading-5 text-muted-foreground dark:bg-muted\"\n          style={{\n            left: badgeX,\n            opacity: size.width ? 1 : 0,\n            transition: transition(\"left\"),\n          }}\n        >\n          <span\n            className=\"absolute -bottom-[3px] size-2 -translate-x-1/2 rotate-45 bg-inherit\"\n            style={{\n              left: arrowX,\n              opacity: arrowX >= 8 && arrowX <= size.badge - 8 ? 1 : 0,\n            }}\n          />\n          <span className=\"relative truncate\">\n            <TextMorph animateWidth={false}>{labels[index] ?? \"\"}</TextMorph>\n          </span>\n        </div>\n        <div\n          ref={thumbRef}\n          role=\"slider\"\n          tabIndex={disabled ? -1 : 0}\n          aria-label={label}\n          aria-describedby={description ? descriptionId : undefined}\n          aria-orientation=\"horizontal\"\n          aria-valuemin={0}\n          aria-valuemax={Math.max(0, n - 1)}\n          aria-valuenow={Math.max(0, index)}\n          aria-valuetext={labels[index]}\n          aria-disabled={disabled || undefined}\n          aria-busy={busy || undefined}\n          onKeyDown={onKey}\n          className={`absolute top-1/2 flex size-[22px] -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-muted-foreground shadow-[0_2px_6px_#0000001a,0_1px_2px_#0000000d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background dark:border-border dark:bg-background ${\n            disabled\n              ? \"cursor-default\"\n              : drag === null\n                ? \"cursor-grab\"\n                : \"cursor-grabbing\"\n          }`}\n          style={{\n            left: x,\n            opacity: size.width ? 1 : 0,\n            transition: transition(\"left\"),\n          }}\n        >\n          {busy && (\n            <span\n              aria-hidden=\"true\"\n              className=\"size-3 animate-spin rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none\"\n            />\n          )}\n        </div>\n      </div>\n      <div\n        aria-hidden=\"true\"\n        className=\"mt-1 flex justify-between gap-3 text-[13px] leading-5 text-muted-foreground\"\n      >\n        <span>{labels[0]}</span>\n        {n > 1 && <span className=\"text-right\">{labels[n - 1]}</span>}\n      </div>\n    </div>\n  );\n}\n\nexport function InlineRewrite({\n  versions,\n  value,\n  defaultValue = 0,\n  onValueChange,\n  originalIndex,\n  onRewrite,\n  label = \"Rewrite\",\n  description,\n  highlight = true,\n  className = \"\",\n  ...props\n}: InlineRewriteProps) {\n  const reduced = useReducedMotion();\n  const [own, setOwn] = React.useState(defaultValue);\n  const index = clamp(value ?? own, 0, versions.length - 1);\n  const [first] = React.useState(index);\n  const original = originalIndex ?? first;\n  const [made, setMade] = React.useState<Record<number, string>>({});\n  const textAt = (i: number) => versions[i]?.text ?? made[i];\n  const [shown, setShown] = React.useState(() =>\n    textAt(index) !== undefined ? index : original,\n  );\n  const [settled, setSettled] = React.useState(true);\n  const [busy, setBusy] = React.useState<number | null>(null);\n  const [failed, setFailed] = React.useState(false);\n  const inflight = React.useRef(new Set<number>());\n  const controllers = React.useRef(new Set<AbortController>());\n  const latest = React.useRef({\n    shown,\n    original,\n    value,\n    onValueChange,\n    onRewrite,\n    versions,\n    made,\n  });\n  latest.current = {\n    shown,\n    original,\n    value,\n    onValueChange,\n    onRewrite,\n    versions,\n    made,\n  };\n\n  const choose = (i: number, done: boolean) => {\n    setSettled(done);\n    if (i === index) return;\n    setFailed(false);\n    if (value === undefined) setOwn(i);\n    onValueChange?.(i);\n  };\n\n  // Show the chosen version as soon as it has words; write it first if it has none (once the dial settles).\n  React.useEffect(() => {\n    if (textAt(index) !== undefined) {\n      setShown(index);\n      return;\n    }\n    const { onRewrite: rewrite } = latest.current;\n    if (!settled || !rewrite || inflight.current.has(index)) return;\n    const i = index;\n    const controller = new AbortController();\n    controllers.current.add(controller);\n    inflight.current.add(i);\n    setBusy(i);\n    setFailed(false);\n    const at = (n: number) =>\n      latest.current.versions[n]?.text ?? latest.current.made[n] ?? \"\";\n    rewrite({\n      index: i,\n      label: versions[i].label,\n      current: at(latest.current.shown),\n      original: at(latest.current.original),\n      signal: controller.signal,\n    })\n      .then((t) => {\n        if (!controller.signal.aborted) setMade((m) => ({ ...m, [i]: t }));\n      })\n      .catch(() => {\n        if (controller.signal.aborted) return;\n        // It didn't work: the dial goes back to what the paragraph says.\n        setFailed(true);\n        const back = latest.current.shown;\n        if (latest.current.value === undefined) setOwn(back);\n        latest.current.onValueChange?.(back);\n      })\n      .finally(() => {\n        inflight.current.delete(i);\n        controllers.current.delete(controller);\n        setBusy((b) => (b === i ? null : b));\n      });\n  }, [index, settled, made, versions]);\n\n  React.useEffect(() => {\n    if (!failed) return;\n    const timer = window.setTimeout(() => setFailed(false), 4000);\n    return () => window.clearTimeout(timer);\n  }, [failed]);\n\n  React.useEffect(() => {\n    const all = controllers.current;\n    return () => all.forEach((c) => c.abort());\n  }, []);\n\n  const words = split(textAt(shown) ?? \"\");\n  const base = split(textAt(original) ?? \"\");\n  const common = React.useMemo(\n    () => table(base, words)[0][0],\n    [base.join(\" \"), words.join(\" \")],\n  );\n  const added = words.length - common;\n  const cut = base.length - common;\n  const working = busy !== null && busy === index;\n  const status = failed ? \"Couldn’t rewrite it\" : working ? \"Rewriting\" : \"\";\n\n  return (\n    <div className={`@container w-full ${className}`} {...props}>\n      <Dial\n        labels={versions.map((v) => v.label)}\n        index={index}\n        busy={working}\n        label={label}\n        description={description}\n        reduced={reduced}\n        onMove={choose}\n      />\n\n      <Words\n        text={textAt(shown) ?? \"\"}\n        reduced={reduced}\n        highlight={highlight}\n        className={`mt-4 text-[14px] leading-[1.65] text-foreground @md:text-[15px] ${working ? SHEEN : \"\"}`}\n      />\n\n      <div className=\"mt-3 flex items-center gap-2.5 text-[12px] tabular-nums text-muted-foreground\">\n        <span>\n          <NumberRoll value={words.length} />{\" \"}\n          <TextMorph>{words.length === 1 ? \"word\" : \"words\"}</TextMorph>\n        </span>\n        <span\n          aria-hidden=\"true\"\n          className=\"size-[3px] rounded-full bg-muted-foreground/40\"\n        />\n        {/* Against the original: the counts, or the word \"Original\" when you're on it. */}\n        <span className=\"grid\">\n          <span\n            className=\"col-start-1 row-start-1 flex gap-2\"\n            aria-hidden={shown === original}\n            style={{\n              opacity: shown === original ? 0 : 1,\n              transition: `opacity 220ms ${EASE}`,\n            }}\n          >\n            <span className=\"text-[var(--success,var(--color-emerald-600))]\">\n              +<NumberRoll value={added} />\n            </span>\n            <span className=\"text-destructive\">\n              −<NumberRoll value={cut} />\n            </span>\n          </span>\n          <span\n            className=\"col-start-1 row-start-1\"\n            aria-hidden={shown !== original}\n            style={{\n              opacity: shown === original ? 1 : 0,\n              transition: `opacity 220ms ${EASE}`,\n            }}\n          >\n            Original\n          </span>\n        </span>\n        <span className={`ml-auto ${failed ? \"text-destructive\" : \"\"}`}>\n          <TextMorph>{status}</TextMorph>\n        </span>\n      </div>\n      <p className=\"sr-only\" role=\"status\">\n        {failed\n          ? \"Couldn’t rewrite it. Try again.\"\n          : `${versions[shown]?.label ?? \"\"}, ${words.length} words`}\n      </p>\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-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": "inline-rewrite",
      "exportName": "InlineRewrite",
      "usage": "import { InlineRewrite } from \"@/components/inline-rewrite\";\n\n<InlineRewrite label=\"Length\" description=\"Adjust how much detail the paragraph includes.\" defaultValue={1} versions={[{ label: \"Shorter\", text: \"Fresh beans, delivered.\" }, { label: \"Original\", text: \"Fresh beans from small roasters, delivered every two weeks.\" }]} />",
      "accessibility": "The visible label names the keyboard-accessible slider; arrow keys, Home and End select versions. Optional description text appears in an info tooltip on hover or focus and describes the slider for assistive technology. Status updates are announced politely. Reduced motion changes text in place and disables slider movement.",
      "previewNote": "Uses local demo data; no AI service or network requests.",
      "categories": [
        "ai",
        "text"
      ],
      "documentation": {
        "useCases": [],
        "features": [],
        "states": [],
        "props": [
          {
            "name": "versions",
            "type": "RewriteVersion[]",
            "description": "Required. Available rewrite versions, including their labels and text."
          },
          {
            "name": "value",
            "type": "number",
            "description": "Optional. The chosen version's index."
          },
          {
            "name": "defaultValue",
            "type": "number",
            "description": "Optional. Initial selection when uncontrolled."
          },
          {
            "name": "onValueChange",
            "type": "(index: number) => void",
            "description": "Optional. Called when the selection changes."
          },
          {
            "name": "originalIndex",
            "type": "number",
            "description": "Optional. The version the counts compare against. Defaults to the one chosen first."
          },
          {
            "name": "onRewrite",
            "type": "(request: RewriteRequest) => Promise<string>",
            "description": "Optional. Asynchronous rewrite handler. Resolve with the replacement text."
          },
          {
            "name": "label",
            "type": "string",
            "description": "Optional. Visible heading and accessible name for the slider, e.g. \"Tone\"."
          },
          {
            "name": "description",
            "type": "string",
            "description": "Optional. Help text, shown beside the heading in an info tooltip."
          },
          {
            "name": "highlight",
            "type": "boolean",
            "description": "Optional. Let new words glow for a moment after each rewrite."
          }
        ]
      }
    }
  },
  "type": "registry:component"
}