{
  "name": "streaming-text",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "streaming-text.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, keyframes } from \"@yugnex/core\";\r\nimport { useEffect, useRef, useState, type HTMLAttributes } from \"react\";\r\n\r\nconst blink = keyframes({\r\n  \"0%, 49%\": { opacity: 1 },\r\n  \"50%, 100%\": { opacity: 0 },\r\n});\r\n\r\nconst rootClass = css({\r\n  whiteSpace: \"pre-wrap\",\r\n  wordBreak: \"break-word\",\r\n});\r\n\r\nconst cursorClass = css({\r\n  display: \"inline-block\",\r\n  width: \"0.5em\",\r\n  height: \"1em\",\r\n  marginLeft: \"1px\",\r\n  verticalAlign: \"text-bottom\",\r\n  backgroundColor: \"currentColor\",\r\n  animation: `${blink} 1s steps(1) infinite`,\r\n  \"@media (prefers-reduced-motion: reduce)\": {\r\n    animation: \"none\",\r\n    opacity: 0.6,\r\n  },\r\n});\r\n\r\nconst srOnlyClass = css({\r\n  position: \"absolute\",\r\n  width: \"1px\",\r\n  height: \"1px\",\r\n  padding: 0,\r\n  margin: \"-1px\",\r\n  overflow: \"hidden\",\r\n  clip: \"rect(0,0,0,0)\",\r\n  whiteSpace: \"nowrap\",\r\n  border: 0,\r\n});\r\n\r\nexport interface StreamingTextProps extends Omit<HTMLAttributes<HTMLSpanElement>, \"children\"> {\r\n  /** The full text known so far. Safe to grow over time as tokens arrive — the reveal continues, it never restarts. */\r\n  text: string;\r\n  /** Reveal speed in characters per second. */\r\n  charsPerSecond?: number;\r\n  /** Show a blinking caret while text is still being revealed. */\r\n  cursor?: boolean;\r\n  /** Called once when the revealed length catches up to `text`. */\r\n  onComplete?: () => void;\r\n  /** Skip the animation and render `text` immediately. */\r\n  instant?: boolean;\r\n}\r\n\r\n/**\r\n * Reveals text progressively, the way an LLM response appears as it streams.\r\n *\r\n * Two details that matter in real use: the reveal is driven by elapsed time\r\n * rather than a fixed per-character interval, so it stays smooth regardless of\r\n * frame rate; and it tracks a *character count* rather than restarting when\r\n * `text` changes, so a prop that grows token-by-token from an API keeps\r\n * animating continuously instead of flickering back to the start.\r\n *\r\n * Respects `prefers-reduced-motion`, rendering the full text immediately.\r\n * The visible span is aria-hidden while animating and the complete text is\r\n * exposed to screen readers separately, so assistive tech never announces a\r\n * half-written word.\r\n */\r\nexport function StreamingText({\r\n  text,\r\n  charsPerSecond = 60,\r\n  cursor = true,\r\n  onComplete,\r\n  instant = false,\r\n  className,\r\n  ...props\r\n}: StreamingTextProps) {\r\n  const [revealed, setRevealed] = useState(instant ? text.length : 0);\r\n  const revealedRef = useRef(revealed);\r\n  revealedRef.current = revealed;\r\n\r\n  const completedRef = useRef(false);\r\n  const onCompleteRef = useRef(onComplete);\r\n  onCompleteRef.current = onComplete;\r\n\r\n  useEffect(() => {\r\n    if (instant) {\r\n      setRevealed(text.length);\r\n      return;\r\n    }\r\n\r\n    const reduce =\r\n      typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\r\n    if (reduce) {\r\n      setRevealed(text.length);\r\n      return;\r\n    }\r\n\r\n    if (revealedRef.current >= text.length) return;\r\n\r\n    let frame = 0;\r\n    let last = performance.now();\r\n\r\n    const tick = (now: number) => {\r\n      const elapsed = (now - last) / 1000;\r\n      last = now;\r\n      const next = Math.min(text.length, revealedRef.current + elapsed * charsPerSecond);\r\n      revealedRef.current = next;\r\n      setRevealed(next);\r\n      if (next < text.length) frame = requestAnimationFrame(tick);\r\n    };\r\n\r\n    frame = requestAnimationFrame(tick);\r\n    return () => cancelAnimationFrame(frame);\r\n  }, [text, charsPerSecond, instant]);\r\n\r\n  const done = revealed >= text.length;\r\n\r\n  useEffect(() => {\r\n    if (done && !completedRef.current && text.length > 0) {\r\n      completedRef.current = true;\r\n      onCompleteRef.current?.();\r\n    }\r\n    if (!done) completedRef.current = false;\r\n  }, [done, text.length]);\r\n\r\n  return (\r\n    <>\r\n      <span aria-hidden={!done} className={className ? `${rootClass} ${className}` : rootClass} {...props}>\r\n        {text.slice(0, Math.floor(revealed))}\r\n        {cursor && !done ? <span className={cursorClass} /> : null}\r\n      </span>\r\n      {done ? null : <span className={srOnlyClass}>{text}</span>}\r\n    </>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}