{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-thread",
  "type": "registry:ui",
  "title": "Chat thread",
  "description": "A chat thread with grouped messages, reactions, read receipts, typing, and a composer with attachments.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/chat-thread/chat-thread.tsx",
      "type": "registry:component",
      "target": "@components/xzero/chat-thread/chat-thread.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { ClipboardEvent as ReactClipboardEvent, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { ArrowDown, ArrowUp, FileText, Paperclip, RotateCw, SmilePlus, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./chat-thread.module.css\";\n\nexport interface ChatParticipant {\n  id: string;\n  name: string;\n  /** Portrait URL. Initials show when it is missing. */\n  avatar?: string;\n}\n\nexport interface ChatAttachment {\n  id: string;\n  name: string;\n  kind: \"image\" | \"file\";\n  url?: string;\n  /** Bytes. */\n  size?: number;\n  /** Intrinsic image size, used to reserve space before the image loads. */\n  width?: number;\n  height?: number;\n  alt?: string;\n}\n\nexport interface ChatReaction {\n  emoji: string;\n  count: number;\n  /** The current user reacted with this emoji. */\n  mine?: boolean;\n}\n\nexport type ChatMessageStatus = \"sending\" | \"sent\" | \"delivered\" | \"read\" | \"failed\";\n\nexport interface ChatMessage {\n  id: string;\n  authorId: string;\n  text?: string;\n  createdAt: Date | string | number;\n  attachments?: ChatAttachment[];\n  reactions?: ChatReaction[];\n  /** Delivery state of the current user's own messages. */\n  status?: ChatMessageStatus;\n}\n\nexport interface ChatDraft {\n  text: string;\n  files: File[];\n}\n\n/** Words the composer shows or announces. Each has an English default. */\nexport interface ChatComposerLabels {\n  /** Accessible name of the send button. */\n  send?: string;\n  /** Accessible name of the attach button. */\n  attach?: string;\n  /** Accessible name of the list of files waiting to send. */\n  attachments?: string;\n  /** Accessible name of the button that takes a file back out. */\n  removeFile?: (name: string) => string;\n}\n\n/** Every word the thread shows or announces. Each has an English default; words with a count or names are functions, so an app can apply its own plural rules. */\nexport interface ChatThreadLabels extends ChatComposerLabels {\n  sending?: string;\n  sent?: string;\n  delivered?: string;\n  read?: string;\n  failed?: string;\n  retry?: string;\n  /** Accessible name of the read receipts under a message. */\n  readBy?: (names: string[]) => string;\n  /** Day separators for the last two days. Older days are formatted by `locale`. Defaults come from Intl for `locale`. */\n  today?: string;\n  yesterday?: string;\n  /** Screen reader announcement of who is typing. */\n  typing?: (names: string[]) => string;\n  /** The pill when new messages land out of view. The count inside it rolls. */\n  newMessages?: (count: number) => string;\n  jumpToLatest?: string;\n  /** Name shown for a message whose author is not in `participants`. */\n  unknownAuthor?: string;\n  addReaction?: string;\n  /** Accessible name of the reaction picker and of the reactions under a message. */\n  reactions?: string;\n  /** Accessible name of an emoji in the picker. */\n  reactWith?: (emoji: string) => string;\n  /** Accessible name of a reaction under a message. */\n  reactionCount?: (emoji: string, count: number, includesYou: boolean) => string;\n}\n\nconst COMPOSER_LABELS: Required<ChatComposerLabels> = {\n  send: \"Send\",\n  attach: \"Attach files\",\n  attachments: \"Attachments\",\n  removeFile: name => `Remove ${name}`,\n};\n\nconst firstName = (name: string) => name.split(\" \")[0];\nconst THREAD_LABELS: Omit<Required<ChatThreadLabels>, \"today\" | \"yesterday\"> = {\n  ...COMPOSER_LABELS,\n  sending: \"Sending\",\n  sent: \"Sent\",\n  delivered: \"Delivered\",\n  read: \"Read\",\n  failed: \"Not delivered\",\n  retry: \"Retry\",\n  readBy: names => `Read by ${names.join(\", \")}`,\n  typing: names => names.length === 1 ? `${firstName(names[0]!)} is typing` : names.length === 2 ? `${firstName(names[0]!)} and ${firstName(names[1]!)} are typing` : \"Several people are typing\",\n  newMessages: count => `${count} new ${count === 1 ? \"message\" : \"messages\"}`,\n  jumpToLatest: \"Jump to latest\",\n  unknownAuthor: \"Unknown\",\n  addReaction: \"Add reaction\",\n  reactions: \"Reactions\",\n  reactWith: emoji => `React with ${emoji}`,\n  reactionCount: (emoji, count, includesYou) => `${emoji} ${count}${includesYou ? \", including you\" : \"\"}`,\n};\n\n/** A key passed as undefined keeps its default too. */\nconst withDefaults = <T extends object, D extends object>(defaults: D, given?: T): D =>\n  ({ ...defaults, ...Object.fromEntries(Object.entries(given ?? {}).filter(([, value]) => value !== undefined)) });\n\n/** \"Today\" and \"Yesterday\" in the thread's language, from Intl, with a capital first letter. */\nfunction relativeDay(locale: string, offset: 0 | -1) {\n  const word = new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" }).format(offset, \"day\");\n  return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);\n}\n\nexport interface ChatThreadHandle {\n  scrollToBottom: (smooth?: boolean) => void;\n  focusComposer: () => void;\n}\n\nexport interface ChatThreadProps {\n  participants: ChatParticipant[];\n  currentUserId: string;\n  /** Controlled messages, oldest first. Leave it out to let the thread keep its own list. */\n  messages?: ChatMessage[];\n  defaultMessages?: ChatMessage[];\n  /** Participant ids currently typing. */\n  typing?: string[];\n  /** The last message each participant has read, by participant id. Their avatar sits under that message and glides as it moves. */\n  readBy?: Record<string, string>;\n  /** Called with the composer's text and files. Uncontrolled threads also append the message themselves. */\n  onSend?: (draft: ChatDraft) => void | Promise<void>;\n  onReact?: (messageId: string, emoji: string) => void;\n  onRetry?: (messageId: string) => void;\n  /** Emoji offered by the reaction picker. */\n  reactions?: string[];\n  placeholder?: string;\n  /** Show the composer. Defaults to true. */\n  composer?: boolean;\n  allowAttachments?: boolean;\n  /** File types the attach button offers, as for `<input accept>`. */\n  accept?: string;\n  /** Messages from one person closer together than this, in ms, share a group. Defaults to five minutes. */\n  groupWindow?: number;\n  locale?: string;\n  /** Accessible name of the message log. */\n  label?: string;\n  /** Words for localization. Leave out any key to keep its English default. */\n  labels?: ChatThreadLabels;\n  className?: string;\n}\n\ntype Row =\n  | { type: \"day\"; key: string; label: string }\n  | { type: \"message\"; key: string; message: ChatMessage; author: ChatParticipant; mine: boolean; first: boolean; last: boolean; index: number };\n\nconst { spring, duration, ease, blur } = motionTokens;\nconst enter = [...ease.enter] as [number, number, number, number];\nconst standard = [...ease.standard] as [number, number, number, number];\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = 2 * Math.PI / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst SETTLE = physical(.44, 0), GROW = physical(spring.smooth.visualDuration, 0), POP = physical(.3, 0), ARRIVE = physical(.46, 0);\nconst DEFAULT_REACTIONS = [\"👍\", \"❤️\", \"😂\", \"🎉\", \"👀\", \"🙏\"];\nconst STICK_DISTANCE = 48, FAR_DISTANCE = 180;\n\nconst subscribe = () => () => {};\nfunction useHydrated() {\n  return useSyncExternalStore(subscribe, () => true, () => false);\n}\n\nconst toDate = (value: ChatMessage[\"createdAt\"]) => value instanceof Date ? value : new Date(value);\nconst dayKey = (date: Date) => `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;\n\nfunction dayLabel(date: Date, locale: string, words: { today: string; yesterday: string }) {\n  const today = new Date();\n  const start = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();\n  const days = Math.round((start(today) - start(date)) / 86_400_000);\n  if (days === 0) return words.today;\n  if (days === 1) return words.yesterday;\n  if (days > 1 && days < 7) return date.toLocaleDateString(locale, { weekday: \"long\" });\n  return date.toLocaleDateString(locale, { weekday: \"short\", month: \"short\", day: \"numeric\", year: date.getFullYear() === today.getFullYear() ? undefined : \"numeric\" });\n}\n\n/** Puts `node` where `value` appears in a translated sentence, so a rolling count sits wherever the language puts the number. */\nfunction withCount(text: string, value: number, node: ReactNode) {\n  const digits = String(value), at = text.indexOf(digits);\n  return at < 0 ? text : <>{text.slice(0, at)}{node}{text.slice(at + digits.length)}</>;\n}\n\nexport function formatBytes(bytes = 0) {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 ** 2) return `${Math.round(bytes / 1024)} KB`;\n  return `${(bytes / 1024 ** 2).toFixed(1)} MB`;\n}\n\nfunction initials(name: string) {\n  return name.split(/\\s+/).filter(Boolean).slice(0, 2).map(part => part[0]?.toUpperCase()).join(\"\");\n}\n\nfunction Avatar({ person, size }: { person: ChatParticipant; size: number }) {\n  return <span className={styles.avatar} style={{ width: size, height: size, fontSize: size * .38 }} aria-hidden=\"true\">\n    {person.avatar\n      // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic\n      ? <img src={person.avatar} alt=\"\" width={size} height={size} loading=\"lazy\" />\n      : initials(person.name)}\n  </span>;\n}\n\n/** A count that rolls up or down when it changes. */\nfunction Count({ value, reduced }: { value: number; reduced: boolean }) {\n  const [last, setLast] = useState({ value, direction: 1 });\n  if (last.value !== value) setLast({ value, direction: value > last.value ? 1 : -1 });\n  const direction = last.value !== value ? (value > last.value ? 1 : -1) : last.direction;\n  return <span className={styles.countRoll}>\n    <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n      <motion.span key={value} custom={direction}\n        variants={{ in: (dir: number) => ({ opacity: 0, y: reduced ? 0 : dir * 8 }), rest: { opacity: 1, y: 0 }, out: (dir: number) => ({ opacity: 0, y: reduced ? 0 : dir * -8 }) }}\n        initial=\"in\" animate=\"rest\" exit=\"out\" transition={reduced ? { duration: duration.fast } : spring.snappy}>{value}</motion.span>\n    </AnimatePresence>\n  </span>;\n}\n\n/** Three dots that breathe in sequence. Reduced motion shows them still. */\nfunction TypingDots() {\n  return <span className={styles.dots} aria-hidden=\"true\"><i /><i /><i /></span>;\n}\n\nfunction useAutoHeight(mirror: RefObject<HTMLElement | null>, maxHeight: number, reduced: boolean) {\n  const height = useMotionValue<number | \"auto\">(\"auto\");\n  const measured = useRef(false);\n  useLayoutEffect(() => {\n    const node = mirror.current;\n    if (!node) return;\n    const fit = () => {\n      const target = Math.min(maxHeight, node.offsetHeight);\n      if (!measured.current || reduced) { height.jump(target); measured.current = true; return; }\n      animate(height, target, GROW);\n    };\n    fit();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(fit);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [height, maxHeight, mirror, reduced]);\n  return height;\n}\n\ntype Pending = { id: string; file: File; url?: string };\n\nexport interface ChatComposerProps {\n  onSend: (draft: ChatDraft) => void;\n  placeholder?: string;\n  allowAttachments?: boolean;\n  accept?: string;\n  disabled?: boolean;\n  /** Words for localization. Leave out any key to keep its English default. */\n  labels?: ChatComposerLabels;\n  className?: string;\n}\n\nexport interface ChatComposerHandle { focus: () => void }\n\n/** The message box on its own: autosizing text, attachments by button, paste, or drop, and a send button that morphs when there is something to send. */\nexport const ChatComposer = forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer({ onSend, placeholder = \"Message\", allowAttachments = true, accept, disabled, labels, className }, ref) {\n  const t = withDefaults(COMPOSER_LABELS, labels);\n  const reduced = !!useReducedMotion();\n  const [text, setText] = useState(\"\");\n  const [files, setFiles] = useState<Pending[]>([]);\n  const [dragging, setDragging] = useState(false);\n  const [sent, setSent] = useState(0);\n  const areaRef = useRef<HTMLTextAreaElement>(null);\n  const pickerRef = useRef<HTMLInputElement>(null);\n  const mirrorRef = useRef<HTMLDivElement>(null);\n  const height = useAutoHeight(mirrorRef, 6 * 22 + 18, reduced);\n  const ready = !disabled && (text.trim().length > 0 || files.length > 0);\n  const counter = useRef(0);\n\n  useImperativeHandle(ref, () => ({ focus: () => areaRef.current?.focus() }), []);\n\n  const filesRef = useRef(files);\n  useEffect(() => { filesRef.current = files; }, [files]);\n  useEffect(() => () => filesRef.current.forEach(entry => entry.url && URL.revokeObjectURL(entry.url)), []);\n\n  const add = (list: FileList | File[] | null) => {\n    if (!allowAttachments || !list) return;\n    const next = Array.from(list).slice(0, 10).map(file => ({ id: `file-${++counter.current}`, file, url: file.type.startsWith(\"image/\") ? URL.createObjectURL(file) : undefined }));\n    if (next.length) setFiles(current => [...current, ...next]);\n  };\n  const remove = (id: string) => setFiles(current => current.filter(entry => {\n    if (entry.id === id && entry.url) URL.revokeObjectURL(entry.url);\n    return entry.id !== id;\n  }));\n\n  const send = () => {\n    if (!ready) return;\n    onSend({ text: text.trim(), files: files.map(entry => entry.file) });\n    files.forEach(entry => entry.url && URL.revokeObjectURL(entry.url));\n    setFiles([]);\n    setText(\"\");\n    setSent(count => count + 1);\n    areaRef.current?.focus();\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {\n    if (event.key === \"Enter\" && !event.shiftKey && !event.nativeEvent.isComposing) {\n      event.preventDefault();\n      send();\n    }\n  };\n  const onPaste = (event: ReactClipboardEvent<HTMLTextAreaElement>) => {\n    if (!allowAttachments || !event.clipboardData.files.length) return;\n    event.preventDefault();\n    add(event.clipboardData.files);\n  };\n  const onDrop = (event: ReactDragEvent<HTMLDivElement>) => {\n    if (!allowAttachments) return;\n    event.preventDefault();\n    setDragging(false);\n    add(event.dataTransfer.files);\n  };\n\n  const tray = reduced ? { opacity: 0 } : { opacity: 0, scale: .9, filter: `blur(${blur.subtle}px)` };\n\n  return <div className={[styles.composer, className].filter(Boolean).join(\" \")} data-dragging={dragging || undefined}\n    onDragOver={event => { if (allowAttachments && event.dataTransfer.types.includes(\"Files\")) { event.preventDefault(); setDragging(true); } }}\n    onDragLeave={event => { if (!event.currentTarget.contains(event.relatedTarget as Node)) setDragging(false); }}\n    onDrop={onDrop}>\n    <AnimatePresence initial={false}>\n      {files.length > 0 && <motion.div key=\"tray\" className={styles.trayClip}\n        initial={{ height: 0, opacity: 0 }} animate={{ height: \"auto\", opacity: 1 }} exit={{ height: 0, opacity: 0 }}\n        transition={reduced ? { duration: 0 } : { height: GROW, opacity: { duration: duration.fast } }}>\n        <ul className={styles.tray} aria-label={t.attachments}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {files.map(entry => <motion.li key={entry.id} className={styles.pending} layout={!reduced}\n              initial={tray} animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }} exit={{ ...tray, transition: { duration: duration.exit, ease: standard } }} transition={POP}>\n              {entry.url\n                // eslint-disable-next-line @next/next/no-img-element -- local object URL preview\n                ? <img className={styles.pendingImage} src={entry.url} alt=\"\" />\n                : <span className={styles.pendingFile}><FileText size={16} strokeWidth={1.75} aria-hidden=\"true\" /><span className={styles.pendingName}>{entry.file.name}</span><span className={styles.pendingSize}>{formatBytes(entry.file.size)}</span></span>}\n              <button type=\"button\" className={styles.remove} aria-label={t.removeFile(entry.file.name)} onClick={() => remove(entry.id)}><X size={12} strokeWidth={2.25} /></button>\n            </motion.li>)}\n          </AnimatePresence>\n        </ul>\n      </motion.div>}\n    </AnimatePresence>\n    <div className={styles.inputRow}>\n      {allowAttachments && <>\n        <button type=\"button\" className={styles.attach} aria-label={t.attach} disabled={disabled} onClick={() => pickerRef.current?.click()}><Paperclip size={18} strokeWidth={1.75} /></button>\n        <input ref={pickerRef} type=\"file\" multiple accept={accept} hidden onChange={event => { add(event.target.files); event.target.value = \"\"; }} />\n      </>}\n      <div className={styles.box}>\n      <motion.div className={styles.field} style={{ height }}>\n        <div ref={mirrorRef} className={styles.mirror} aria-hidden=\"true\">{text}{\"\\u200b\"}</div>\n        <textarea ref={areaRef} className={styles.textarea} value={text} rows={1} placeholder={placeholder} disabled={disabled} aria-label={placeholder}\n          onChange={event => setText(event.target.value)} onKeyDown={onKeyDown} onPaste={onPaste} />\n      </motion.div>\n      <button type=\"button\" className={styles.send} data-ready={ready || undefined} aria-label={t.send} aria-disabled={!ready} onClick={send}>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span key={sent} className={styles.sendIcon}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, y: 14 }} animate={{ opacity: 1, y: 0 }} exit={reduced ? { opacity: 0 } : { opacity: 0, y: -18, transition: { duration: duration.fast, ease: standard } }}\n            transition={reduced ? { duration: duration.fast } : spring.snappy}>\n            <ArrowUp size={16} strokeWidth={2.25} />\n          </motion.span>\n        </AnimatePresence>\n      </button>\n      </div>\n    </div>\n  </div>;\n});\n\n/**\n * A chat or support thread: grouped messages with day separators, reactions, read receipts that glide to the latest read message,\n * a typing indicator, and a composer. The list stays pinned to the newest message and offers a pill when new messages land out of view.\n */\nexport const ChatThread = forwardRef<ChatThreadHandle, ChatThreadProps>(function ChatThread({\n  participants, currentUserId, messages: messagesProp, defaultMessages, typing = [], readBy, onSend, onReact, onRetry,\n  reactions = DEFAULT_REACTIONS, placeholder = \"Message\", composer = true, allowAttachments = true, accept,\n  groupWindow = 5 * 60_000, locale = \"en-US\", label = \"Conversation\", labels, className,\n}, ref) {\n  const t = useMemo(() => withDefaults({ ...THREAD_LABELS, today: relativeDay(locale, 0), yesterday: relativeDay(locale, -1) }, labels), [labels, locale]);\n  const reduced = !!useReducedMotion();\n  const hydrated = useHydrated();\n  const uid = useId();\n  const [inner, setInner] = useState<ChatMessage[]>(defaultMessages ?? []);\n  const messages = messagesProp ?? inner;\n  const controlled = messagesProp !== undefined;\n  const people = useMemo(() => new Map(participants.map(entry => [entry.id, entry])), [participants]);\n  const composerRef = useRef<ChatComposerHandle>(null);\n\n  const rows = useMemo<Row[]>(() => {\n    const out: Row[] = [];\n    let lastDay = \"\";\n    messages.forEach((message, index) => {\n      const date = toDate(message.createdAt);\n      const key = dayKey(date);\n      if (key !== lastDay) { out.push({ type: \"day\", key: `day-${key}`, label: hydrated ? dayLabel(date, locale, t) : \"\" }); lastDay = key; }\n      const joins = (other: ChatMessage | undefined) => !!other && other.authorId === message.authorId && dayKey(toDate(other.createdAt)) === key && Math.abs(toDate(other.createdAt).getTime() - date.getTime()) <= groupWindow;\n      out.push({\n        type: \"message\", key: message.id, message, index,\n        author: people.get(message.authorId) ?? { id: message.authorId, name: t.unknownAuthor },\n        mine: message.authorId === currentUserId,\n        first: !joins(messages[index - 1]), last: !joins(messages[index + 1]),\n      });\n    });\n    return out;\n  }, [currentUserId, groupWindow, hydrated, locale, messages, people, t]);\n\n  const receipts = useMemo(() => {\n    const map = new Map<string, ChatParticipant[]>();\n    for (const [personId, messageId] of Object.entries(readBy ?? {})) {\n      const who = people.get(personId);\n      const read = messages.find(message => message.id === messageId);\n      // A reader's avatar under their own message says nothing new, so it stays hidden until they read past it.\n      if (!who || personId === currentUserId || !read || read.authorId === personId) continue;\n      map.set(messageId, [...(map.get(messageId) ?? []), who]);\n    }\n    return map;\n  }, [currentUserId, messages, people, readBy]);\n  const readIndex = useMemo(() => {\n    let furthest = -1;\n    receipts.forEach((_, messageId) => { furthest = Math.max(furthest, messages.findIndex(message => message.id === messageId)); });\n    return furthest;\n  }, [messages, receipts]);\n  const lastMine = useMemo(() => { for (let index = messages.length - 1; index >= 0; index--) if (messages[index]!.authorId === currentUserId) return index; return -1; }, [currentUserId, messages]);\n\n  const time = useMemo(() => new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\" }), [locale]);\n\n  /* Pinning. While the reader is at the bottom, every growth of the content keeps the newest message in view:\n     the scroll jumps to the end before paint and the content springs up from where it was, so nothing snaps. */\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const y = useMotionValue(0);\n  const stick = useRef(true);\n  const lastHeight = useRef(0);\n  const reducedRef = useRef(reduced);\n  useEffect(() => { reducedRef.current = reduced; }, [reduced]);\n  const [atBottom, setAtBottom] = useState(true);\n  const [far, setFar] = useState(false);\n  const [seen, setSeen] = useState(() => messages.length);\n\n  useLayoutEffect(() => {\n    const scroller = scrollRef.current, content = contentRef.current;\n    if (!scroller || !content) return;\n    lastHeight.current = content.offsetHeight;\n    scroller.scrollTop = scroller.scrollHeight;\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const height = content.offsetHeight, delta = height - lastHeight.current;\n      lastHeight.current = height;\n      if (!stick.current) return;\n      scroller.scrollTop = scroller.scrollHeight;\n      if (delta > 0 && delta < scroller.clientHeight && !reducedRef.current) {\n        y.jump(y.get() + delta);\n        animate(y, 0, SETTLE);\n      }\n    });\n    observer.observe(content);\n    return () => observer.disconnect();\n  }, [y]);\n\n  // Your own message always brings the thread back to the bottom.\n  const known = useRef(new Set(messages.map(message => message.id)));\n  useLayoutEffect(() => {\n    const fresh = messages.filter(message => !known.current.has(message.id));\n    fresh.forEach(message => known.current.add(message.id));\n    if (fresh.some(message => message.authorId === currentUserId)) stick.current = true;\n  }, [currentUserId, messages]);\n\n  const onScroll = () => {\n    const scroller = scrollRef.current;\n    if (!scroller) return;\n    const distance = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;\n    stick.current = distance < STICK_DISTANCE;\n    setAtBottom(stick.current);\n    setFar(distance > FAR_DISTANCE);\n    if (stick.current) setSeen(messages.length);\n  };\n\n  const scrollToBottom = useCallback((smooth = true) => {\n    const scroller = scrollRef.current;\n    if (!scroller) return;\n    stick.current = true;\n    scroller.scrollTo({ top: scroller.scrollHeight, behavior: smooth && !reducedRef.current ? \"smooth\" : \"auto\" });\n  }, []);\n\n  useImperativeHandle(ref, () => ({ scrollToBottom, focusComposer: () => composerRef.current?.focus() }), [scrollToBottom]);\n\n  const unread = atBottom ? 0 : messages.slice(seen).filter(message => message.authorId !== currentUserId).length;\n  const pill = !atBottom && (unread > 0 || far);\n\n  /* Reactions and the picker. */\n  const [picker, setPicker] = useState<string | null>(null);\n  const react = (messageId: string, emoji: string) => {\n    setPicker(null);\n    onReact?.(messageId, emoji);\n    if (controlled) return;\n    setInner(current => current.map(message => {\n      if (message.id !== messageId) return message;\n      const list = [...(message.reactions ?? [])];\n      const at = list.findIndex(entry => entry.emoji === emoji);\n      const found = list[at];\n      if (!found) list.push({ emoji, count: 1, mine: true });\n      else if (found.mine) list[at] = { ...found, count: found.count - 1, mine: false };\n      else list[at] = { ...found, count: found.count + 1, mine: true };\n      return { ...message, reactions: list.filter(entry => entry.count > 0) };\n    }));\n  };\n\n  const counter = useRef(0);\n  const send = (draft: ChatDraft) => {\n    stick.current = true;\n    const result = onSend?.(draft);\n    if (controlled) return result;\n    const attachments: ChatAttachment[] = draft.files.map((file, index) => ({\n      id: `local-file-${++counter.current}-${index}`, name: file.name, size: file.size,\n      kind: file.type.startsWith(\"image/\") ? \"image\" : \"file\",\n      url: URL.createObjectURL(file),\n    }));\n    setInner(current => [...current, { id: `local-${Date.now()}-${++counter.current}`, authorId: currentUserId, text: draft.text || undefined, attachments, createdAt: new Date(), status: \"sent\" }]);\n  };\n\n  const typers = typing.map(id => people.get(id)).filter((entry): entry is ChatParticipant => !!entry && entry.id !== currentUserId);\n  const typingLabel = typers.length ? t.typing(typers.map(entry => entry.name)) : \"\";\n\n  const arrive = (mine: boolean) => reduced ? { opacity: 0 } : { opacity: 0, scale: .92, y: 14, x: mine ? 8 : -8, filter: `blur(${blur.subtle}px)` };\n\n  return <div className={[styles.root, className].filter(Boolean).join(\" \")}>\n    <div className={styles.viewport}>\n      <div ref={scrollRef} className={styles.scroller} onScroll={onScroll} data-chat-scroller=\"\">\n        <div className={styles.track}>\n          <motion.div ref={contentRef} className={styles.content} style={{ y }}>\n            <ol className={styles.list} role=\"log\" aria-label={label} aria-live=\"polite\" aria-relevant=\"additions\">\n              <AnimatePresence initial={false}>\n                {rows.map(row => {\n                  if (row.type === \"day\") return <li key={row.key} className={styles.day}><span suppressHydrationWarning>{row.label}</span></li>;\n                  const { message, author, mine, first, last, index } = row;\n                  const date = toDate(message.createdAt);\n                  const seenBy = receipts.get(message.id) ?? [];\n                  const showStatus = index === lastMine && mine;\n                  const status = message.status === \"failed\" ? \"failed\" : message.status === \"sending\" ? \"sending\" : readIndex >= index ? \"read\" : message.status ?? \"sent\";\n                  const images = message.attachments?.filter(entry => entry.kind === \"image\" && entry.url) ?? [];\n                  const files = message.attachments?.filter(entry => entry.kind !== \"image\" || !entry.url) ?? [];\n                  return <motion.li key={row.key} className={styles.row} data-mine={mine || undefined} data-first={first || undefined} data-last={last || undefined} data-failed={message.status === \"failed\" || undefined}\n                    initial={arrive(mine)} animate={{ opacity: 1, scale: 1, y: 0, x: 0, filter: \"blur(0px)\" }} exit={{ opacity: 0, transition: { duration: duration.exit } }}\n                    transition={reduced ? { duration: duration.fast } : { ...ARRIVE, opacity: { duration: duration.standard, ease: enter }, filter: { duration: duration.standard, ease: enter } }}\n                    style={{ transformOrigin: mine ? \"100% 100%\" : \"0 100%\" }}>\n                    {!mine && <div className={styles.gutter} />}\n                    <div className={styles.stack}>\n                      {first && <p className={styles.meta}>\n                        {!mine && <span className={styles.author}>{author.name}</span>}\n                        <time dateTime={date.toISOString()} suppressHydrationWarning>{hydrated ? time.format(date) : \"\"}</time>\n                      </p>}\n                      <div className={styles.line}>\n                        {!mine && last && <span className={styles.face}><Avatar person={author} size={28} /></span>}\n                        <div className={styles.parts}>\n                          {images.length > 0 && <div className={styles.gallery} data-count={Math.min(images.length, 4)}>\n                            {images.slice(0, 4).map((image, at) => <a key={image.id} className={styles.image} href={image.url} target=\"_blank\" rel=\"noreferrer\"\n                              style={images.length === 1 && image.width && image.height ? { aspectRatio: `${image.width} / ${image.height}` } : undefined}>\n                              {/* eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic */}\n                              <img src={image.url} alt={image.alt ?? image.name} loading=\"lazy\" />\n                              {at === 3 && images.length > 4 && <span className={styles.more}>+{images.length - 4}</span>}\n                            </a>)}\n                          </div>}\n                          {files.map(file => <a key={file.id} className={styles.file} href={file.url} download={file.name} target=\"_blank\" rel=\"noreferrer\">\n                            <FileText size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n                            <span className={styles.fileText}><span className={styles.fileName}>{file.name}</span>{file.size !== undefined && <span className={styles.fileSize}>{formatBytes(file.size)}</span>}</span>\n                          </a>)}\n                          {message.text && <div className={styles.bubble} title={hydrated ? date.toLocaleString(locale, { dateStyle: \"medium\", timeStyle: \"short\" }) : undefined}>{message.text}</div>}\n                        </div>\n                        <ReactionPicker open={picker === message.id} mine={mine} reactions={reactions} reduced={reduced} labels={t} id={`${uid}-picker-${message.id}`}\n                          onToggle={() => setPicker(current => current === message.id ? null : message.id)} onClose={() => setPicker(null)} onPick={emoji => react(message.id, emoji)} />\n                      </div>\n                      <AnimatePresence initial={false}>\n                        {!!message.reactions?.length && <motion.div key=\"reactions\" className={styles.reactionClip}\n                          initial={{ height: 0, opacity: 0 }} animate={{ height: \"auto\", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={reduced ? { duration: 0 } : { height: GROW, opacity: { duration: duration.fast } }}>\n                          <ul className={styles.reactions} aria-label={t.reactions}>\n                            <AnimatePresence initial={false} mode=\"popLayout\">\n                              {message.reactions.map(reaction => <motion.li key={reaction.emoji} layout={!reduced}\n                                initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .6 }} transition={POP}>\n                                <button type=\"button\" className={styles.reaction} aria-pressed={!!reaction.mine} aria-label={t.reactionCount(reaction.emoji, reaction.count, !!reaction.mine)} onClick={() => react(message.id, reaction.emoji)}>\n                                  <span aria-hidden=\"true\">{reaction.emoji}</span><Count value={reaction.count} reduced={reduced} />\n                                </button>\n                              </motion.li>)}\n                            </AnimatePresence>\n                          </ul>\n                        </motion.div>}\n                      </AnimatePresence>\n                      {(showStatus || seenBy.length > 0) && <div className={styles.receipts}>\n                        {showStatus && <span className={styles.status} data-status={status}>\n                          {status === \"failed\" ? <>{t.failed}{onRetry && <button type=\"button\" className={styles.retry} onClick={() => onRetry(message.id)}><RotateCw size={12} strokeWidth={2} aria-hidden=\"true\" />{t.retry}</button>}</>\n                            : t[status]}\n                        </span>}\n                        {seenBy.length > 0 && <span className={styles.readers} aria-label={t.readBy(seenBy.map(entry => entry.name))}>\n                          {seenBy.map(entry => <motion.span key={entry.id} layoutId={reduced ? undefined : `${uid}-receipt-${entry.id}`} className={styles.reader} transition={SETTLE}>\n                            <Avatar person={entry} size={16} />\n                          </motion.span>)}\n                        </span>}\n                      </div>}\n                    </div>\n                  </motion.li>;\n                })}\n                {typers.length > 0 && <motion.li key=\"typing\" className={styles.row} data-first data-last aria-hidden=\"true\"\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .9, y: 8 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .9, transition: { duration: duration.exit, ease: standard } }}\n                  transition={reduced ? { duration: duration.fast } : spring.snappy} style={{ transformOrigin: \"0 100%\" }}>\n                  <div className={styles.gutter} />\n                  <div className={styles.stack}><div className={styles.line}><span className={styles.face}><Avatar person={typers[0]!} size={28} /></span><div className={`${styles.bubble} ${styles.typing}`}><TypingDots /></div></div></div>\n                </motion.li>}\n              </AnimatePresence>\n            </ol>\n          </motion.div>\n        </div>\n      </div>\n      <span className={styles.srOnly} role=\"status\">{typingLabel}</span>\n      <AnimatePresence>\n        {pill && <motion.button key=\"pill\" type=\"button\" className={styles.pill} onClick={() => scrollToBottom()} layout={!reduced} style={{ borderRadius: 16 }}\n          initial={reduced ? { opacity: 0 } : { opacity: 0, y: 12, scale: .94 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={reduced ? { opacity: 0 } : { opacity: 0, y: 12, scale: .94, transition: { duration: duration.exit, ease: standard } }}\n          transition={reduced ? { duration: duration.fast } : { ...spring.morph, opacity: { duration: duration.fast } }}>\n          {/* Icon and label hold their own size while the pill's width morphs, so the text never stretches. */}\n          <motion.span className={styles.pillIcon} layout={reduced ? false : \"position\"} transition={spring.morph}><ArrowDown size={14} strokeWidth={2} aria-hidden=\"true\" /></motion.span>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span key={unread > 0 ? \"new\" : \"latest\"} layout={reduced ? false : \"position\"} initial={{ opacity: 0, filter: `blur(${blur.subtle}px)` }} animate={{ opacity: 1, filter: \"blur(0px)\" }} exit={{ opacity: 0, filter: `blur(${blur.subtle}px)` }} transition={{ duration: duration.fast }}>\n              {unread > 0 ? withCount(t.newMessages(unread), unread, <Count value={unread} reduced={reduced} />) : t.jumpToLatest}\n            </motion.span>\n          </AnimatePresence>\n        </motion.button>}\n      </AnimatePresence>\n    </div>\n    {composer && <ChatComposer ref={composerRef} onSend={send} placeholder={placeholder} allowAttachments={allowAttachments} accept={accept} labels={t} />}\n  </div>;\n});\n\nfunction ReactionPicker({ open, mine, reactions, reduced, labels, id, onToggle, onClose, onPick }: { open: boolean; mine: boolean; reactions: string[]; reduced: boolean; labels: Pick<Required<ChatThreadLabels>, \"addReaction\" | \"reactions\" | \"reactWith\">; id: string; onToggle: () => void; onClose: () => void; onPick: (emoji: string) => void }) {\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const [active, setActive] = useState(0);\n  const [below, setBelow] = useState(false);\n  /** The picker opens above the bubble unless that would run past the top of the thread. */\n  const toggle = () => {\n    const wrap = wrapRef.current, scroller = wrap?.closest(\"[data-chat-scroller]\");\n    if (!open && wrap && scroller) setBelow(wrap.getBoundingClientRect().top - scroller.getBoundingClientRect().top < 56);\n    onToggle();\n  };\n\n  const closeRef = useRef(onClose);\n  useEffect(() => { closeRef.current = onClose; }, [onClose]);\n  useEffect(() => {\n    if (!open) return;\n    const down = (event: PointerEvent) => { if (!wrapRef.current?.contains(event.target as Node)) closeRef.current(); };\n    document.addEventListener(\"pointerdown\", down);\n    const frame = requestAnimationFrame(() => wrapRef.current?.querySelector<HTMLElement>(\"[data-emoji-index='0']\")?.focus({ preventScroll: true }));\n    return () => { document.removeEventListener(\"pointerdown\", down); cancelAnimationFrame(frame); };\n  }, [open]);\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\") { event.preventDefault(); event.stopPropagation(); onClose(); triggerRef.current?.focus(); return; }\n    const step = event.key === \"ArrowRight\" ? 1 : event.key === \"ArrowLeft\" ? -1 : 0;\n    if (!step) return;\n    event.preventDefault();\n    const next = (active + step + reactions.length) % reactions.length;\n    setActive(next);\n    wrapRef.current?.querySelector<HTMLElement>(`[data-emoji-index='${next}']`)?.focus();\n  };\n\n  return <div ref={wrapRef} className={styles.pickerWrap} data-open={open || undefined} data-below={below || undefined}>\n    <button ref={triggerRef} type=\"button\" className={styles.reactButton} aria-label={labels.addReaction} aria-expanded={open} aria-controls={open ? id : undefined} onClick={toggle}>\n      <SmilePlus size={16} strokeWidth={1.75} />\n    </button>\n    <AnimatePresence>\n      {open && <motion.div id={id} role=\"group\" aria-label={labels.reactions} className={styles.picker} data-mine={mine || undefined} onKeyDown={onKeyDown}\n        initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .85, y: below ? -6 : 6 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .9, y: below ? -4 : 4, transition: { duration: duration.exit, ease: standard } }}\n        transition={reduced ? { duration: duration.fast } : POP}>\n        {reactions.map((emoji, index) => <motion.button key={emoji} type=\"button\" className={styles.emoji} aria-label={labels.reactWith(emoji)} data-emoji-index={index} tabIndex={index === active ? 0 : -1}\n          initial={reduced ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ ...POP, delay: reduced ? 0 : index * .025 }}\n          onFocus={() => setActive(index)} onClick={() => onPick(emoji)}>{emoji}</motion.button>)}\n      </motion.div>}\n    </AnimatePresence>\n  </div>;\n}\n\nexport default ChatThread;\n"
    },
    {
      "path": "registry/components/chat-thread/chat-thread.module.css",
      "type": "registry:component",
      "target": "@components/xzero/chat-thread/chat-thread.module.css",
      "content": ".root {\n  display: flex;\n  height: 100%;\n  min-height: 0;\n  flex-direction: column;\n  color: var(--foreground);\n  font-family: var(--font-body);\n  font-size: var(--text-sm);\n  letter-spacing: var(--tracking-body);\n  line-height: var(--leading-body);\n}\n\n.viewport { position: relative; display: flex; min-height: 0; flex: 1; flex-direction: column; }\n.scroller { min-height: 0; flex: 1; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-gutter: stable; }\n/* The content springs up from where it was when the thread grows; the clip keeps that travel out of the scroll range. */\n.track { overflow: clip; }\n.content { padding: 12px 16px 14px; will-change: transform; }\n\n.list { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; }\n\n.day { display: flex; align-items: center; gap: 12px; padding: 20px 4px 4px; color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; }\n.day::before, .day::after { height: 1px; flex: 1; background: var(--border-subtle); content: \"\"; }\n.day:first-child { padding-top: 4px; }\n.day > span:empty::before { content: \"\\00a0\"; }\n\n.row { position: relative; display: flex; align-items: flex-end; gap: 8px; margin-top: 2px; }\n.row[data-first] { margin-top: 14px; }\n.day + .row { margin-top: 10px; }\n.row[data-mine] { justify-content: flex-end; }\n\n.gutter { width: 28px; flex: none; align-self: flex-end; }\n.stack { display: flex; min-width: 0; max-width: min(78%, 34rem); flex-direction: column; align-items: flex-start; gap: 4px; }\n.row[data-mine] .stack { align-items: flex-end; }\n\n.meta { display: flex; align-items: baseline; gap: 6px; margin: 0 0 2px; padding: 0 12px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.author { color: var(--text-secondary); font-weight: 500; }\n\n.line { position: relative; display: flex; max-width: 100%; align-items: center; gap: 6px; }\n/* The author's face sits in the gutter, level with the bottom of their last bubble rather than the reactions under it. */\n.face { position: absolute; bottom: 0; left: -36px; display: flex; }\n.row[data-mine] .line { flex-direction: row-reverse; }\n.parts { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; gap: 4px; }\n.row[data-mine] .parts { align-items: flex-end; }\n\n/* Bubbles in a group share a soft edge on the author's side, so a run of messages reads as one voice. */\n.bubble {\n  --r: 18px;\n  --tight: 6px;\n  max-width: 100%;\n  padding: 8px 13px 9px;\n  border-radius: var(--r);\n  background: var(--surface-muted);\n  color: var(--foreground);\n  font-size: var(--text-sm);\n  overflow-wrap: anywhere;\n  white-space: pre-wrap;\n}\n.row:not([data-mine]):not([data-first]) .bubble { border-top-left-radius: var(--tight); }\n.row:not([data-mine]):not([data-last]) .bubble { border-bottom-left-radius: var(--tight); }\n.row[data-mine] .bubble { background: var(--accent); color: var(--accent-foreground); }\n:global(:root[data-theme=\"dark\"]) .row:not([data-mine]) .bubble { background: var(--surface-raised); }\n.row[data-mine]:not([data-first]) .bubble { border-top-right-radius: var(--tight); }\n.row[data-mine]:not([data-last]) .bubble { border-bottom-right-radius: var(--tight); }\n.row[data-failed] .bubble { opacity: .6; }\n\n.gallery { display: grid; width: min(240px, 100%); gap: 3px; grid-template-columns: 1fr; }\n.gallery[data-count=\"2\"],\n.gallery[data-count=\"3\"],\n.gallery[data-count=\"4\"] { grid-template-columns: 1fr 1fr; }\n.gallery[data-count=\"3\"] > :first-child { grid-column: span 2; aspect-ratio: 2 / 1; }\n.image { position: relative; display: block; overflow: hidden; aspect-ratio: 1; border-radius: 16px; background: var(--surface-muted); }\n.gallery[data-count=\"1\"] .image { aspect-ratio: 4 / 3; }\n.gallery:not([data-count=\"1\"]) .image { border-radius: 6px; }\n.gallery:not([data-count=\"1\"]) { overflow: hidden; border-radius: 16px; }\n.image img { display: block; width: 100%; height: 100%; object-fit: cover; }\n.more { position: absolute; inset: 0; display: grid; place-items: center; background: oklch(0% 0 0 / .45); color: #fff; font-size: var(--text-lg); font-weight: 500; }\n\n.file {\n  display: flex; max-width: 100%; align-items: center; gap: 10px; padding: 9px 14px 9px 11px;\n  border: 1px solid var(--border); border-radius: 16px; background: var(--surface); color: var(--foreground); text-decoration: none;\n  transition: background-color var(--duration-fast) var(--ease-standard);\n}\n.file > svg { flex: none; color: var(--text-secondary); }\n.fileText { display: grid; min-width: 0; }\n.fileName { overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.fileSize { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n@media (hover: hover) and (pointer: fine) { .file:hover { background: var(--surface-muted); } }\n\n/* The reaction button waits beside the bubble and shows on hover or keyboard focus. */\n/* The picker positions against the whole line, so it opens over the bubble and stays inside the thread. */\n.pickerWrap { position: static; flex: none; }\n.reactButton {\n  display: grid; width: 28px; height: 28px; place-items: center; padding: 0;\n  border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); cursor: pointer; opacity: 0;\n  -webkit-tap-highlight-color: transparent;\n  transition: opacity var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);\n}\n.row:hover .reactButton,\n.row:focus-within .reactButton,\n.pickerWrap[data-open] .reactButton { opacity: 1; }\n.pickerWrap[data-open] .reactButton { background: var(--surface-muted); color: var(--foreground); }\n@media (hover: hover) and (pointer: fine) { .reactButton:hover { background: var(--surface-muted); color: var(--foreground); } }\n@media (hover: none) { .reactButton { opacity: .7; } }\n\n.picker {\n  position: absolute; z-index: 5; bottom: calc(100% + 6px); right: -4px;\n  display: flex; gap: 2px; padding: 4px;\n  border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); box-shadow: var(--shadow-floating);\n  transform-origin: calc(100% - 18px) 100%;\n}\n.picker[data-mine] { right: auto; left: -4px; transform-origin: 18px 100%; }\n.pickerWrap[data-below] .picker { top: calc(100% + 6px); bottom: auto; transform-origin: calc(100% - 18px) 0; }\n.pickerWrap[data-below] .picker[data-mine] { transform-origin: 18px 0; }\n.emoji {\n  display: grid; width: 34px; height: 34px; place-items: center; padding: 0;\n  border: 0; border-radius: var(--radius-pill); background: transparent; cursor: pointer; font-size: 19px; line-height: 1;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);\n}\n.emoji:focus-visible { background: var(--surface-muted); }\n.emoji:active { transform: scale(.9); }\n@media (hover: hover) and (pointer: fine) { .emoji:hover { background: var(--surface-muted); transform: scale(1.12); } }\n\n.reactionClip { overflow: hidden; margin: -2px -4px; padding: 2px 4px 2px 8px; }\n.row[data-mine] .reactionClip { padding: 2px 8px 2px 4px; }\n.reactions { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; padding: 0; list-style: none; }\n.row[data-mine] .reactions { justify-content: flex-end; }\n.reaction {\n  display: inline-flex; height: 26px; align-items: center; gap: 5px; padding: 0 9px 0 7px;\n  border: 1px solid var(--border-subtle); border-radius: var(--radius-pill); background: var(--surface); color: var(--text-secondary); cursor: pointer;\n  font: inherit; font-size: var(--text-xs); font-weight: 500; line-height: 1; font-variant-numeric: tabular-nums;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);\n}\n.reaction > span:first-child { font-size: 14px; }\n.reaction[aria-pressed=\"true\"] { border-color: color-mix(in oklab, var(--accent) 28%, transparent); background: color-mix(in oklab, var(--accent) 8%, var(--surface)); color: var(--foreground); }\n@media (hover: hover) and (pointer: fine) { .reaction:hover { border-color: var(--border-strong); } }\n.reaction:active { transform: scale(.95); }\n.countRoll { display: inline-grid; overflow: hidden; font-variant-numeric: tabular-nums; }\n.countRoll > span { grid-area: 1 / 1; }\n\n.receipts { display: flex; min-height: 16px; align-items: center; gap: 6px; padding: 1px 6px 0; color: var(--text-muted); font-size: var(--text-xs); }\n.readers { display: inline-flex; }\n.reader { display: inline-flex; border-radius: 50%; box-shadow: 0 0 0 2px var(--surface); }\n.reader + .reader { margin-left: -4px; }\n.status[data-status=\"failed\"] { display: inline-flex; align-items: center; gap: 8px; color: var(--danger); }\n.retry {\n  display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px;\n  border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface); color: var(--foreground); cursor: pointer;\n  font: inherit; font-weight: 500; line-height: 16px;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);\n}\n.retry > svg { color: var(--text-secondary); }\n@media (hover: hover) and (pointer: fine) { .retry:hover { background: var(--surface-muted); border-color: var(--border-strong); } }\n\n.avatar { display: inline-grid; flex: none; overflow: hidden; place-items: center; border-radius: 50%; background: var(--surface-muted); color: var(--text-secondary); font-weight: 500; }\n.avatar img { width: 100%; height: 100%; object-fit: cover; }\n\n.typing { display: grid; height: 36px; box-sizing: border-box; place-items: center; padding: 0 14px; }\n.dots { display: inline-flex; gap: 4px; }\n.dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted); animation: breathe 1.2s var(--ease-in-out) infinite; }\n.dots i:nth-child(2) { animation-delay: .16s; }\n.dots i:nth-child(3) { animation-delay: .32s; }\n@keyframes breathe {\n  0%, 60%, 100% { opacity: .35; transform: translateY(0); }\n  30% { opacity: 1; transform: translateY(-3px); }\n}\n@media (prefers-reduced-motion: reduce) { .dots i { animation: none; opacity: .6; } }\n\n.pill {\n  position: absolute; z-index: 6; bottom: 12px; left: 50%; translate: -50% 0;\n  display: inline-flex; height: 32px; align-items: center; gap: 6px; padding: 0 14px 0 11px;\n  border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); box-shadow: var(--shadow-floating); color: var(--foreground); cursor: pointer;\n  font: inherit; font-size: var(--text-xs); font-weight: 500; white-space: nowrap;\n  -webkit-tap-highlight-color: transparent;\n}\n.pill > span { display: inline-flex; gap: .3em; }\n.pill > .pillIcon { display: inline-grid; place-items: center; }\n.pill:active { scale: .97; }\n\n/* Composer. */\n.composer { display: grid; padding: 10px 12px 12px; border-top: 1px solid var(--border-subtle); transition: background-color var(--duration-fast) var(--ease-standard); }\n.composer[data-dragging] { background: var(--accent-subtle); }\n.trayClip { overflow: hidden; }\n.tray { display: flex; gap: 8px; margin: 0; padding: 8px 8px 10px 4px; overflow-x: auto; list-style: none; scrollbar-width: none; }\n.pending { position: relative; flex: none; }\n.pendingImage { display: block; width: 56px; height: 56px; border-radius: 12px; object-fit: cover; }\n.pendingFile { display: grid; width: 168px; height: 56px; box-sizing: border-box; align-content: center; column-gap: 8px; padding: 0 12px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); grid-template-columns: auto minmax(0, 1fr); }\n.pendingFile > svg { grid-row: span 2; align-self: center; color: var(--text-secondary); }\n.pendingName { overflow: hidden; font-size: var(--text-xs); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.pendingSize { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.remove {\n  position: absolute; top: -6px; right: -6px; display: grid; width: 20px; height: 20px; place-items: center; padding: 0;\n  border: 2px solid var(--surface); border-radius: 50%; background: var(--foreground); color: var(--background); cursor: pointer;\n}\n\n.inputRow { display: flex; align-items: flex-end; gap: 6px; }\n.attach {\n  display: grid; width: 40px; height: 40px; flex: none; place-items: center; margin-bottom: 1px; padding: 0;\n  border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); cursor: pointer;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);\n}\n.attach:active { transform: scale(.94); }\n@media (hover: hover) and (pointer: fine) { .attach:hover { background: var(--surface-muted); color: var(--foreground); } }\n\n/* The field and the send button share one rounded box; the button rides the bottom edge as the text grows. */\n.box {\n  position: relative; display: flex; min-width: 0; flex: 1; align-items: flex-end;\n  border: 1px solid var(--border); border-radius: 21px; background: var(--surface);\n  transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);\n}\n.box:focus-within { border-color: var(--border-strong); }\n.composer[data-dragging] .box { border-color: var(--accent); border-style: dashed; }\n.field { position: relative; min-width: 0; flex: 1; overflow: hidden; border-radius: 20px; }\n.mirror,\n.textarea {\n  box-sizing: border-box; width: 100%; margin: 0; padding: 9px 6px 9px 16px; border: 0;\n  font: inherit; font-size: var(--text-base); letter-spacing: var(--tracking-body); line-height: 22px;\n  overflow-wrap: break-word; white-space: pre-wrap; word-break: break-word;\n}\n.mirror { position: absolute; top: 0; left: 0; visibility: hidden; pointer-events: none; }\n.textarea { position: absolute; inset: 0; height: 100%; display: block; resize: none; overflow-y: auto; background: transparent; color: var(--foreground); outline: none; scrollbar-width: none; }\n.textarea::placeholder { color: var(--text-muted); }\n\n.send {\n  position: relative; display: grid; width: 32px; height: 32px; flex: none; place-items: center; margin: 0 4px 4px 0; overflow: hidden; padding: 0;\n  border: 0; border-radius: 50%; background: transparent; color: var(--text-muted); cursor: default;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);\n}\n.send[data-ready] { background: var(--accent); color: var(--accent-foreground); cursor: pointer; }\n.send[data-ready]:active { transform: scale(.92); }\n.sendIcon { display: grid; grid-area: 1 / 1; place-items: center; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (max-width: 420px) {\n  .content { padding-inline: 12px; }\n  .stack { max-width: 84%; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/chat-thread",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/chat-thread",
    "markdown": "https://ui.xzero.co.uk/components/chat-thread/markdown"
  }
}