{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-ui",
  "description": "A streaming chat UI built on MessageScroller — anchored turns, follow-the-edge auto-scroll, a dot-matrix thinking indicator, and word-by-word reveal.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "@dotmatrix/dotm-square-11",
    "message-scroller"
  ],
  "files": [
    {
      "path": "registry/new-york/chat/chat-ui.tsx",
      "content": "\"use client\";\n\n/**\n * A streaming chat UI built on MessageScroller — anchored turns, follow-the-edge auto-scroll, a dot-matrix thinking indicator, and word-by-word reveal.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ArrowUp, Bot } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { DotmSquare11 } from \"@/components/ui/dotm-square-11\";\nimport {\n  MessageScroller,\n  MessageScrollerButton,\n  MessageScrollerContent,\n  MessageScrollerItem,\n  MessageScrollerProvider,\n  MessageScrollerViewport,\n} from \"@/components/ui/message-scroller\";\n\nconst TIMING = {\n  thinkDelay: 2200, // ms the dot-matrix thinking indicator shows before streaming\n  wordInterval: 55, // ms between each streamed word\n};\n\n/* Snappy-but-soft spring used for every row entrance. */\nconst ENTRANCE_SPRING = { type: \"spring\" as const, stiffness: 420, damping: 30 };\n\n/* Animate the transcript row itself (transform + opacity only — never height/\n * margin, which would fight the scroller's positioning). */\nconst MotionItem = motion.create(MessageScrollerItem);\n\ntype Role = \"user\" | \"assistant\";\n\ninterface Message {\n  id: number;\n  role: Role;\n  text: string;\n  streaming?: boolean;\n}\n\nconst INITIAL_MESSAGES: Message[] = [\n  {\n    id: 0,\n    role: \"assistant\",\n    text: \"Hey! Ask me anything — I'll think for a moment, then stream a reply.\",\n  },\n];\n\n/* Canned replies cycle on each send — swap this for a real runtime. */\nconst CANNED_REPLIES = [\n  \"Good motion is mostly timing and restraint: animate a property or two, keep it under 300ms, and let the easing do the talking.\",\n  \"The magic in a chat UI is the small stuff — a thinking indicator, a word-by-word reveal, and a scroller that never fights the reader's place.\",\n  \"I'd reach for a spring on entrance and an ease-out on exit. Springs feel alive; ease-outs feel polished and final.\",\n  \"Try staggering each word by ~40–60ms as it streams. It reads like real thinking instead of a paste, and keep older turns anchored so people never lose where they are.\",\n];\n\nconst THINKING_STATUSES = [\"Connecting\", \"Thinking\", \"Generating\"];\n\nexport function ChatUI() {\n  const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);\n  const [input, setInput] = useState(\"\");\n  const [isThinking, setIsThinking] = useState(false);\n\n  const idRef = useRef(INITIAL_MESSAGES.length);\n  const replyRef = useRef(0);\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const reduceMotion = useReducedMotion();\n\n  // Auto-grow the composer textarea up to a max height (collapses back on send).\n  useEffect(() => {\n    const el = textareaRef.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    el.style.height = `${Math.min(el.scrollHeight, 128)}px`;\n  }, [input]);\n\n  const markStreamDone = useCallback((id: number) => {\n    setMessages((prev) =>\n      prev.map((m) => (m.id === id ? { ...m, streaming: false } : m)),\n    );\n  }, []);\n\n  const send = useCallback(() => {\n    const text = input.trim();\n    if (!text || isThinking) return;\n\n    setMessages((prev) => [...prev, { id: idRef.current++, role: \"user\", text }]);\n    setInput(\"\");\n\n    setIsThinking(true);\n    const reply = CANNED_REPLIES[replyRef.current % CANNED_REPLIES.length];\n    replyRef.current += 1;\n\n    window.setTimeout(() => {\n      setIsThinking(false);\n      setMessages((prev) => [\n        ...prev,\n        { id: idRef.current++, role: \"assistant\", text: reply, streaming: true },\n      ]);\n    }, TIMING.thinkDelay);\n  }, [input, isThinking]);\n\n  return (\n    <div className=\"grid min-h-screen place-items-center bg-background p-4 text-foreground\">\n      <div className=\"flex h-[600px] max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-border bg-card shadow-sm\">\n        {/* Header */}\n        <div className=\"flex items-center gap-3 border-b border-border px-5 py-4\">\n          <BotAvatar />\n          <div className=\"leading-tight\">\n            <p className=\"text-sm font-medium text-foreground\">Assistant</p>\n            <p className=\"flex items-center gap-1.5 text-xs text-muted-foreground\">\n              <span className=\"size-1.5 rounded-full bg-emerald-500\" />\n              online\n            </p>\n          </div>\n        </div>\n\n        {/* Transcript — MessageScroller owns scroll, anchoring, and follow-output */}\n        <MessageScrollerProvider autoScroll scrollPreviousItemPeek={56}>\n          <MessageScroller className=\"min-h-0 flex-1\">\n            {/* data-lenis-prevent: harmless without Lenis; lets the transcript\n                scroll natively if the host app uses Lenis smooth-scroll */}\n            <MessageScrollerViewport data-lenis-prevent className=\"px-5 py-5\">\n              <MessageScrollerContent className=\"gap-4\">\n                {messages.map((message) => (\n                  <MessageRow\n                    key={message.id}\n                    message={message}\n                    reduceMotion={!!reduceMotion}\n                    onStreamDone={markStreamDone}\n                  />\n                ))}\n\n                <AnimatePresence>\n                  {isThinking && (\n                    <MotionItem\n                      key=\"thinking\"\n                      messageId=\"thinking\"\n                      initial={{ opacity: 0, y: 8 }}\n                      animate={{ opacity: 1, y: 0 }}\n                      exit={{ opacity: 0, y: 8 }}\n                      transition={ENTRANCE_SPRING}\n                      className=\"flex justify-start gap-2\"\n                    >\n                      <BotAvatar className=\"self-end\" />\n                      <div className=\"rounded-2xl rounded-bl-sm bg-muted px-3 py-1.5\">\n                        <ThinkingIndicator />\n                      </div>\n                    </MotionItem>\n                  )}\n                </AnimatePresence>\n              </MessageScrollerContent>\n            </MessageScrollerViewport>\n\n            <MessageScrollerButton className=\"shadow-sm\" />\n          </MessageScroller>\n        </MessageScrollerProvider>\n\n        {/* Composer */}\n        <form\n          onSubmit={(e) => {\n            e.preventDefault();\n            send();\n          }}\n          className=\"border-t border-border p-3\"\n        >\n          <div className=\"flex items-end gap-2 rounded-3xl border border-border bg-muted p-1.5 transition-shadow focus-within:ring-2 focus-within:ring-ring\">\n            <textarea\n              ref={textareaRef}\n              value={input}\n              onChange={(e) => setInput(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" && !e.shiftKey) {\n                  e.preventDefault();\n                  send();\n                }\n              }}\n              rows={1}\n              placeholder=\"Type a message…\"\n              aria-label=\"Message input\"\n              className=\"max-h-32 min-h-9 flex-1 resize-none bg-transparent px-3 py-1.5 text-sm leading-relaxed text-foreground outline-none placeholder:text-muted-foreground\"\n            />\n            <motion.button\n              type=\"submit\"\n              disabled={!input.trim() || isThinking}\n              whileTap={{ scale: 0.88 }}\n              className=\"grid size-9 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground transition-opacity disabled:opacity-40\"\n              aria-label=\"Send message\"\n            >\n              <ArrowUp className=\"size-5\" />\n            </motion.button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n\n/* The dot-matrix \"thinking\" indicator: the @dotmatrix loader + a shimmering\n * status label that advances Connecting → Thinking → Generating. */\nfunction ThinkingIndicator() {\n  const [step, setStep] = useState(0);\n\n  useEffect(() => {\n    const t = setInterval(\n      () => setStep((n) => Math.min(n + 1, THINKING_STATUSES.length - 1)),\n      1300,\n    );\n    return () => clearInterval(t);\n  }, []);\n\n  return (\n    <div\n      className=\"flex items-center gap-3 py-1\"\n      aria-label=\"Assistant is thinking\"\n    >\n      <DotmSquare11 size={20} dotSize={2.8} className=\"shrink-0\" />\n      <span className=\"animate-pulse text-muted-foreground text-sm font-normal\">\n        {THINKING_STATUSES[step]}\n      </span>\n    </div>\n  );\n}\n\nfunction BotAvatar({ className }: { className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"grid size-8 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground\",\n        className,\n      )}\n    >\n      <Bot className=\"size-4\" />\n    </div>\n  );\n}\n\ninterface MessageRowProps {\n  message: Message;\n  reduceMotion: boolean;\n  onStreamDone: (id: number) => void;\n}\n\n/* One transcript row: the animated MessageScrollerItem holding a chat bubble.\n * User rows are scroll anchors, so a new turn pins near the top of the view. */\nfunction MessageRow({ message, reduceMotion, onStreamDone }: MessageRowProps) {\n  const isUser = message.role === \"user\";\n  const handleDone = useCallback(\n    () => onStreamDone(message.id),\n    [onStreamDone, message.id],\n  );\n\n  return (\n    <MotionItem\n      messageId={String(message.id)}\n      scrollAnchor={isUser}\n      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.96 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      transition={ENTRANCE_SPRING}\n      className={cn(\"flex w-full gap-2\", isUser ? \"justify-end\" : \"justify-start\")}\n    >\n      {!isUser && <BotAvatar className=\"self-end\" />}\n      <div\n        className={cn(\n          \"max-w-[78%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap wrap-break-word\",\n          isUser\n            ? \"rounded-br-sm bg-primary text-primary-foreground\"\n            : \"rounded-bl-sm bg-muted text-foreground\",\n        )}\n      >\n        {message.streaming ? (\n          <StreamingText\n            text={message.text}\n            reduceMotion={reduceMotion}\n            onDone={handleDone}\n          />\n        ) : (\n          message.text\n        )}\n      </div>\n    </MotionItem>\n  );\n}\n\ninterface StreamingTextProps {\n  text: string;\n  reduceMotion: boolean;\n  onDone: () => void;\n}\n\n/* Reveals text one word at a time; each new word fades in, a caret blinks at\n * the tail until the last word lands. autoScroll follows the growth. */\nfunction StreamingText({ text, reduceMotion, onDone }: StreamingTextProps) {\n  const words = useMemo(() => text.split(\" \"), [text]);\n  const [count, setCount] = useState(reduceMotion ? words.length : 0);\n\n  useEffect(() => {\n    if (count >= words.length) {\n      onDone();\n      return;\n    }\n    const t = window.setTimeout(() => {\n      setCount((c) => c + 1);\n    }, TIMING.wordInterval);\n    return () => window.clearTimeout(t);\n  }, [count, words.length, onDone]);\n\n  return (\n    <span>\n      {words.slice(0, count).map((word, i) => (\n        <motion.span\n          key={i}\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          transition={{ duration: 0.18 }}\n        >\n          {i > 0 ? \" \" : \"\"}\n          {word}\n        </motion.span>\n      ))}\n      {count < words.length && (\n        <motion.span\n          aria-hidden\n          className=\"ml-0.5 inline-block h-[1em] w-[2px] translate-y-[2px] bg-current\"\n          animate={{ opacity: [1, 0.2, 1] }}\n          transition={{ duration: 0.8, repeat: Infinity, ease: \"easeInOut\" }}\n        />\n      )}\n    </span>\n  );\n}\n\nexport default ChatUI;\n",
      "type": "registry:component",
      "target": "components/pixel-perfect/chat-ui.tsx"
    }
  ],
  "css": {
    "@utility scrollbar-thin": {
      "scrollbar-width": "thin"
    },
    "@utility scrollbar-none": {
      "scrollbar-width": "none",
      "&::-webkit-scrollbar": {
        "display": "none"
      }
    },
    "@utility scrollbar-gutter-stable": {
      "scrollbar-gutter": "stable"
    },
    "@utility scroll-fade-b": {
      "-webkit-mask-image": "linear-gradient(to bottom, #000 calc(100% - 1.75rem), transparent)",
      "mask-image": "linear-gradient(to bottom, #000 calc(100% - 1.75rem), transparent)"
    }
  },
  "type": "registry:block"
}