{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "decode-text",
  "type": "registry:ui",
  "title": "Decode Text",
  "description": "Text reveal that scrambles glyphs and resolves them left to right, like an AI decoding — width-stable, with the final text read to screen readers at once.",
  "dependencies": [],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/kura-merge-refs.json",
    "https://kuro.abstergo.fyi/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/decode-text/decode-text.tsx",
      "type": "registry:ui",
      "target": "components/kura/decode-text/decode-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/kura/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { mergeRefs } from \"@/lib/kura/merge-refs\";\n\nexport type DecodeTextStartOn = \"mount\" | \"view\";\n\nexport interface DecodeTextProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** The final text. Plain string only. */\n  children: string;\n  /** When the first reveal runs: on mount, or the first time it scrolls into view. Default \"mount\". */\n  startOn?: DecodeTextStartOn;\n  /** Replay the reveal whenever this value changes (a counter, an id, a boolean…). */\n  trigger?: string | number | boolean;\n  /** Milliseconds for the reveal to sweep the whole text. Default 1000. */\n  duration?: number;\n  /** How many characters ahead of the resolved edge actively scramble (in accent). Default 6. */\n  spread?: number;\n  /** Characters used for the scramble. Default: A–Z, 0–9 and a few symbols. */\n  glyphs?: string;\n  /** Element to render. Default \"span\". */\n  as?: \"span\" | \"p\" | \"div\" | \"h1\" | \"h2\" | \"h3\" | \"code\";\n  /** Called when a reveal finishes. */\n  onComplete?: () => void;\n}\n\ntype Phase = \"pending\" | \"running\" | \"done\";\n\nconst DEFAULT_GLYPHS = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=/<>\";\n\nconst easeOut = (t: number) => 1 - (1 - t) ** 2;\n\n/**\n * A text reveal that scrambles glyphs and resolves them left to right, like an AI decoding a\n * message. Every glyph slot reserves the width of its final character, so proportional fonts do\n * not jitter (monospace fonts look best). Screen readers get the final text immediately; the\n * animation is aria-hidden. Runs on mount, on first view, or when `trigger` changes.\n */\nexport function DecodeText({\n  children: text,\n  startOn = \"mount\",\n  trigger,\n  duration = 1000,\n  spread = 6,\n  glyphs = DEFAULT_GLYPHS,\n  as = \"span\",\n  onComplete,\n  className,\n  ref,\n  ...props\n}: DecodeTextProps) {\n  const reducedMotion = useReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const charRefs = React.useRef<Array<HTMLSpanElement | null>>([]);\n  const [phase, setPhase] = React.useState<Phase>(\"pending\");\n  const seen = React.useRef(startOn === \"mount\");\n  const live = React.useRef({ duration, spread, glyphs, onComplete });\n  live.current = { duration, spread, glyphs, onComplete };\n\n  const words = React.useMemo(() => text.split(/(\\s+)/), [text]);\n\n  const scrambleAll = React.useCallback(() => {\n    const g = live.current.glyphs || DEFAULT_GLYPHS;\n    for (const el of charRefs.current) {\n      if (!el) continue;\n      el.textContent = g[Math.floor(Math.random() * g.length)] ?? \"\";\n      el.dataset.state = \"pending\";\n    }\n  }, []);\n\n  // Decide what to do whenever the text or trigger changes.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: replay on text/trigger changes only.\n  React.useLayoutEffect(() => {\n    if (reducedMotion) {\n      setPhase(\"done\");\n      return;\n    }\n    if (seen.current) {\n      setPhase(\"running\");\n      return;\n    }\n    setPhase(\"pending\");\n    const el = rootRef.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") {\n      seen.current = true;\n      setPhase(\"running\");\n      return;\n    }\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry?.isIntersecting) return;\n        io.disconnect();\n        seen.current = true;\n        setPhase(\"running\");\n      },\n      { threshold: 0.4 },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [text, trigger, reducedMotion]);\n\n  // Pending: show a still scramble so the slot is not empty while waiting to be seen.\n  React.useLayoutEffect(() => {\n    if (phase === \"pending\") scrambleAll();\n  }, [phase, scrambleAll]);\n\n  // Running: drive the glyphs imperatively (no React re-render per frame).\n  // biome-ignore lint/correctness/useExhaustiveDependencies: restarts per run; reads live props.\n  React.useLayoutEffect(() => {\n    if (phase !== \"running\") return;\n    const els = charRefs.current.filter((e): e is HTMLSpanElement => !!e);\n    const finals = els.map((e) => e.dataset.char ?? \"\");\n    const n = els.length;\n    scrambleAll();\n    const start = performance.now();\n    const lastSwap = new Float64Array(n);\n    let raf = 0;\n    const frame = (now: number) => {\n      const { duration: d, spread: s, glyphs: gl } = live.current;\n      const g = gl || DEFAULT_GLYPHS;\n      const t = Math.min(1, (now - start) / Math.max(1, d));\n      const front = easeOut(t) * (n + s);\n      for (let k = 0; k < n; k++) {\n        const el = els[k]!;\n        if (front >= k + s || t >= 1) {\n          if (el.dataset.state !== \"done\") {\n            el.textContent = finals[k]!;\n            el.dataset.state = \"done\";\n          }\n          continue;\n        }\n        const active = front >= k;\n        const every = active ? 45 : 140;\n        if (now - lastSwap[k]! > every) {\n          lastSwap[k] = now;\n          el.textContent = g[Math.floor(Math.random() * g.length)] ?? \"\";\n        }\n        el.dataset.state = active ? \"active\" : \"pending\";\n      }\n      if (t < 1) raf = requestAnimationFrame(frame);\n      else {\n        setPhase(\"done\");\n        live.current.onComplete?.();\n      }\n    };\n    raf = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(raf);\n  }, [phase, text, trigger]);\n\n  const Comp = as as \"span\";\n  charRefs.current = [];\n  let index = 0;\n\n  return (\n    <Comp\n      ref={mergeRefs(rootRef as React.Ref<HTMLSpanElement>, ref)}\n      data-slot=\"decode-text\"\n      data-state={phase}\n      className={cn(\"tabular-nums\", className)}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      {phase === \"done\" ? (\n        <span aria-hidden data-slot=\"decode-text-glyphs\">\n          {text}\n        </span>\n      ) : (\n        <span aria-hidden data-slot=\"decode-text-glyphs\">\n          {words.map((word, w) =>\n            /^\\s+$/.test(word) || word === \"\" ? (\n              word\n            ) : (\n              <span key={w} className=\"inline-block whitespace-nowrap\">\n                {Array.from(word).map((ch, c) => {\n                  const i = index++;\n                  return (\n                    <span key={c} className=\"inline-grid\">\n                      <span className=\"invisible col-start-1 row-start-1\">{ch}</span>\n                      <span\n                        ref={(el) => {\n                          charRefs.current[i] = el;\n                        }}\n                        data-slot=\"decode-text-char\"\n                        data-char={ch}\n                        data-state=\"pending\"\n                        className=\"col-start-1 row-start-1 overflow-visible text-center transition-colors duration-(--kura-duration-fast) data-[state=active]:text-kura-accent-text data-[state=pending]:text-kura-fg-subtle data-[state=pending]:opacity-50\"\n                      >\n                        {ch}\n                      </span>\n                    </span>\n                  );\n                })}\n              </span>\n            ),\n          )}\n        </span>\n      )}\n    </Comp>\n  );\n}\n"
    },
    {
      "path": "registry/kura/decode-text/index.ts",
      "type": "registry:ui",
      "target": "components/kura/decode-text/index.ts",
      "content": "export { DecodeText, type DecodeTextProps } from \"./decode-text\";\n"
    }
  ],
  "categories": [
    "effects",
    "text",
    "reveal",
    "scramble",
    "decode"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/decode-text · Markdown for agents: https://kuro.abstergo.fyi/components/decode-text.md",
  "meta": {
    "kura": {
      "status": "beta",
      "whenToUse": [
        "Revealing a short status or result the agent just produced: “Plan ready”, “Connected to runner-07”.",
        "Hero taglines or section headings that should arrive with a technical, machine-like flourish.",
        "IDs, hashes, timings and other mono data appearing after a computation."
      ],
      "whenNotToUse": [
        "Streaming a model's answer token by token — use streaming-text.",
        "An in-progress label — use shimmer-text or thinking-indicator.",
        "Paragraphs or anything longer than a line or two: the effect becomes noise and per-glyph DOM gets heavy.",
        "Text that changes many times a second (live counters) — render it plainly."
      ]
    }
  }
}
