{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "retro-streaming-text",
  "title": "RetroStreamingText",
  "description": "Text that arrives behind a blinking Mac OS 9 insertion caret, for streamed AI replies.",
  "registryDependencies": [
    "https://nostalgia-ui.com/r/nostalgia-theme.json",
    "https://nostalgia-ui.com/r/retro-motion.json"
  ],
  "files": [
    {
      "path": "registry/new-york/ui/retro-streaming-text.tsx",
      "content": "\"use client\"\n\n/*\n * RetroStreamingText: text that arrives behind a Mac OS 9 insertion caret.\n *\n * Crafted moment: the caret. A 1px black bar leads the text while it types,\n * solid like a real insertion point under the keyboard; when the network\n * stalls it starts blinking at the Finder's rhythm, as if waiting for the\n * next keystroke, and when the reply is done it fades away (classic flair:\n * it simply goes). Network deltas arrive in lumps, so the reveal is paced on\n * requestAnimationFrame: it types at a steady floor speed and speeds up with\n * the backlog, so it never trails the stream by more than about half a\n * second. Classic flair and reduced motion show text the moment it arrives.\n *\n * `useRetroStreamingText` and `RetroCaret` are exported for components that\n * format the text themselves (RetroMessage renders paragraphs and code with\n * the caret inside the last block).\n */\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { motionOff, useMergedRef } from \"@/registry/new-york/lib/retro-motion\"\n\n/** Typing speed with no backlog, in characters per second. */\nconst FLOOR_CPS = 70\n/** The backlog drains in about this long, so lag stays under ~0.5s. */\nconst CATCH_UP_S = 0.22\n/** No new characters for this long: the caret starts blinking. */\nconst IDLE_MS = 530\n\n/** Where the caret is in its life. */\ntype RetroCaretPhase =\n  /** never shown: the text was complete when it mounted */\n  | \"off\"\n  /** text is arriving: solid bar */\n  | \"typing\"\n  /** streaming, but nothing new for a moment: blinks */\n  | \"waiting\"\n  /** the stream finished: fades out */\n  | \"done\"\n\nconst CSS = `\n@keyframes os9-insertion-blink { 0%, 49.9% { opacity: 1; } 50%, 100% { opacity: 0; } }\n.os9-caret { transition: opacity 200ms ease-out; }\n.os9-caret[data-phase=\"waiting\"] { animation: os9-insertion-blink 1.06s step-end infinite; }\n.os9-caret[data-phase=\"done\"] { opacity: 0; }\n[data-flair=\"classic\"] .os9-caret { transition: none; }\n@media (prefers-reduced-motion: reduce) {\n  .os9-caret { transition: none; }\n  .os9-caret[data-phase=\"waiting\"] { animation: none; }\n}\n`\n\n/* don't cut a surrogate pair (emoji) in half */\nfunction safeEnd(text: string, n: number) {\n  const code = text.charCodeAt(n - 1)\n  return code >= 0xd800 && code <= 0xdbff ? n + 1 : n\n}\n\n/** True when `next` carries on from the first `count` characters of `prev`. */\nfunction continues(prev: string, count: number, next: string) {\n  return prev === next || next.startsWith(prev.slice(0, count))\n}\n\n/**\n * Paces a growing string for display. Attach `ref` to an element inside the\n * flair scope (it is read to decide whether to animate).\n */\nfunction useRetroStreamingText(text: string, streaming = false) {\n  const elRef = React.useRef<HTMLElement | null>(null)\n\n  /* text that was already complete on mount shows at once, with no caret */\n  const [everStreamed, setEverStreamed] = React.useState(streaming)\n  if (streaming && !everStreamed) setEverStreamed(true)\n\n  /* how much of which string has been revealed */\n  const [progress, setProgress] = React.useState(() => ({\n    text,\n    count: streaming ? 0 : text.length,\n  }))\n  const countRef = React.useRef(progress.count)\n  const textRef = React.useRef(text)\n  /* a continuation keeps its place; a different string starts over */\n  const count = continues(progress.text, progress.count, text)\n    ? progress.count\n    : streaming\n      ? 0\n      : text.length\n\n  React.useEffect(() => {\n    /* a different string (not a continuation) starts over */\n    const prev = textRef.current\n    textRef.current = text\n    if (!continues(prev, Math.floor(countRef.current), text)) {\n      countRef.current = streaming ? 0 : text.length\n    }\n    const target = text.length\n    countRef.current = Math.min(countRef.current, target)\n    let raf = 0\n    let last = performance.now()\n    const tick = (now: number) => {\n      const dt = Math.min(64, now - last) / 1000\n      last = now\n      if (motionOff(elRef.current)) {\n        countRef.current = target\n      } else {\n        const backlog = target - countRef.current\n        const rate = Math.max(FLOOR_CPS, backlog / CATCH_UP_S)\n        countRef.current = Math.min(target, countRef.current + rate * dt)\n      }\n      const next = Math.min(target, safeEnd(text, Math.floor(countRef.current)))\n      setProgress((p) => (p.text === text && p.count === next ? p : { text, count: next }))\n      if (countRef.current < target) raf = requestAnimationFrame(tick)\n    }\n    raf = requestAnimationFrame(tick)\n    return () => cancelAnimationFrame(raf)\n  }, [text, streaming])\n\n  const caughtUp = count >= text.length\n\n  /* caught up but still streaming: after a beat, the caret waits (blinks) */\n  const [idle, setIdle] = React.useState(false)\n  React.useEffect(() => {\n    if (!streaming || !caughtUp) return\n    const timer = window.setTimeout(() => setIdle(true), IDLE_MS)\n    return () => {\n      window.clearTimeout(timer)\n      setIdle(false)\n    }\n  }, [streaming, caughtUp, text])\n\n  const phase: RetroCaretPhase = !everStreamed\n    ? \"off\"\n    : !caughtUp\n      ? \"typing\"\n      : streaming\n        ? idle\n          ? \"waiting\"\n          : \"typing\"\n        : \"done\"\n\n  return {\n    /** the part of `text` to show now */\n    shown: caughtUp ? text : text.slice(0, count),\n    /** what the caret should do */\n    phase,\n    /** true while characters are still being revealed or the stream is open */\n    active: streaming || !caughtUp,\n    ref: elRef,\n  }\n}\n\n/* ── caret ─────────────────────────────────────────────── */\n\ninterface RetroCaretProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  phase?: RetroCaretPhase\n}\n\n/**\n * The OS 9 insertion point: a 1px black bar a line tall. It takes no width\n * (the text after it doesn't move when it goes) and is hidden from screen\n * readers.\n */\nfunction RetroCaret({ phase = \"waiting\", className, ...props }: RetroCaretProps) {\n  if (phase === \"off\") return null\n  return (\n    <>\n      <style href=\"os9-caret\" precedence=\"default\">\n        {CSS}\n      </style>\n      <span\n        aria-hidden=\"true\"\n        data-slot=\"retro-caret\"\n        data-phase={phase}\n        className={cn(\n          \"os9-caret pointer-events-none relative inline-block h-[1.15em] w-px -mr-px\",\n          \"translate-x-px bg-os9-black align-[-0.2em]\",\n          className\n        )}\n        {...props}\n      />\n    </>\n  )\n}\n\n/* ── streaming text ────────────────────────────────────── */\n\ninterface RetroStreamingTextProps extends Omit<React.ComponentProps<\"span\">, \"children\"> {\n  /** Everything received so far. Grow it as deltas arrive. */\n  text: string\n  /** True while more text may still arrive. */\n  streaming?: boolean\n  /** Show the insertion caret. Default true. */\n  caret?: boolean\n}\n\nfunction RetroStreamingText({\n  text,\n  streaming = false,\n  caret = true,\n  className,\n  ref,\n  ...props\n}: RetroStreamingTextProps) {\n  const { shown, phase, ref: paceRef } = useRetroStreamingText(text, streaming)\n  const mergedRef = useMergedRef<HTMLSpanElement>(ref, paceRef as React.Ref<HTMLSpanElement>)\n  return (\n    <span\n      ref={mergedRef}\n      data-slot=\"retro-streaming-text\"\n      data-streaming={streaming || undefined}\n      className={cn(\"whitespace-pre-wrap [overflow-wrap:anywhere]\", className)}\n      {...props}\n    >\n      {shown}\n      {caret ? <RetroCaret phase={phase} /> : null}\n    </span>\n  )\n}\n\nexport { RetroStreamingText, RetroCaret, useRetroStreamingText }\nexport type { RetroStreamingTextProps, RetroCaretProps, RetroCaretPhase }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}