{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comment-thread",
  "type": "registry:ui",
  "title": "Comment thread",
  "description": "Threaded comments with replies, reactions, mentions, inline edit, and resolve.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/comment-thread/comment-thread.tsx",
      "type": "registry:component",
      "target": "@components/xzero/comment-thread/comment-thread.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { Check, ChevronRight, CornerDownRight, RotateCcw, SmilePlus, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./comment-thread.module.css\";\n\nexport interface CommentAuthor {\n  id: string;\n  name: string;\n  /** Square image URL. Initials are shown without one. */\n  avatar?: string;\n}\n\nexport interface CommentReaction {\n  emoji: string;\n  /** Ids of the people who reacted. */\n  users: string[];\n}\n\nexport interface ThreadComment {\n  id: string;\n  author: CommentAuthor;\n  /** Plain text. \"@Full Name\" mentions of known people are highlighted. */\n  body: string;\n  /** Display label such as \"2h\" or \"Sep 18\". */\n  createdAt: string;\n  edited?: boolean;\n  /** A deleted comment that still has replies keeps its place as a quiet placeholder. */\n  deleted?: boolean;\n  reactions?: CommentReaction[];\n  replies?: ThreadComment[];\n}\n\n/** What changed, for syncing to a server. `comments` on `onCommentsChange` is always the full next tree. */\nexport type CommentThreadEvent =\n  | { type: \"reply\"; comment: ThreadComment; parentId: string | null }\n  | { type: \"edit\"; id: string; body: string }\n  | { type: \"delete\"; id: string }\n  | { type: \"react\"; id: string; emoji: string; added: boolean };\n\n/** Every word the thread shows or announces. Each one has an English default; words with a count or a name are functions, so an app can apply its own plural rules. */\nexport interface CommentThreadLabels {\n  /** Accessible name of the whole thread. */\n  thread?: string;\n  /** Header and resolved chip, such as \"3 comments\". */\n  commentCount?: (count: number) => string;\n  /** Resolved chip, such as \"2 people\". */\n  peopleCount?: (count: number) => string;\n  resolve?: string;\n  /** Title of the resolved chip. */\n  resolved?: string;\n  reopen?: string;\n  /** Shown when there are no comments. */\n  empty?: string;\n  reply?: string;\n  edit?: string;\n  delete?: string;\n  cancel?: string;\n  send?: string;\n  /** Saves an edit. */\n  save?: string;\n  /** Placeholder and accessible name of the edit field. */\n  editComment?: string;\n  /** After the time of an edited comment. */\n  edited?: string;\n  deleteConfirm?: string;\n  /** Placeholder text of a deleted comment that still has replies. */\n  deletedText?: string;\n  /** Accessible name of that placeholder. */\n  deletedComment?: string;\n  /** Above the composer while replying. The name inside it is highlighted. */\n  replyingTo?: (name: string) => string;\n  cancelReply?: string;\n  showReplies?: (count: number) => string;\n  hideReplies?: string;\n  addReaction?: string;\n  closeReactions?: string;\n  /** Accessible name of the reaction picker. */\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 comment. */\n  reactionCount?: (emoji: string, count: number, includesYou: boolean) => string;\n  /** Accessible name of the @mention suggestions. */\n  mentions?: string;\n}\n\nconst DEFAULT_LABELS: Required<CommentThreadLabels> = {\n  thread: \"Comment thread\",\n  commentCount: count => `${count} ${count === 1 ? \"comment\" : \"comments\"}`,\n  peopleCount: count => `${count} ${count === 1 ? \"person\" : \"people\"}`,\n  resolve: \"Resolve\",\n  resolved: \"Resolved\",\n  reopen: \"Reopen\",\n  empty: \"No comments yet. Start the conversation below.\",\n  reply: \"Reply\",\n  edit: \"Edit\",\n  delete: \"Delete\",\n  cancel: \"Cancel\",\n  send: \"Send\",\n  save: \"Save\",\n  editComment: \"Edit comment\",\n  edited: \"edited\",\n  deleteConfirm: \"Delete this comment?\",\n  deletedText: \"This comment was deleted\",\n  deletedComment: \"Deleted comment\",\n  replyingTo: name => `Replying to ${name}`,\n  cancelReply: \"Cancel reply\",\n  showReplies: count => `Show ${count} ${count === 1 ? \"reply\" : \"replies\"}`,\n  hideReplies: \"Hide replies\",\n  addReaction: \"Add reaction\",\n  closeReactions: \"Close reactions\",\n  reactions: \"Reactions\",\n  reactWith: emoji => `React with ${emoji}`,\n  reactionCount: (emoji, count, includesYou) => `${emoji} ${count}${includesYou ? \", including you\" : \"\"}`,\n  mentions: \"People\",\n};\n\n/** A key passed as undefined keeps its default too. */\nconst withDefaults = <T extends object>(defaults: Required<T>, given?: T): Required<T> =>\n  ({ ...defaults, ...Object.fromEntries(Object.entries(given ?? {}).filter(([, value]) => value !== undefined)) });\n\n/**\n * A threaded discussion: replies nest under their comment and collapse, reactions toggle with a count that rolls,\n * authors edit and delete their own comments in place, mentions autocomplete in the composer, and resolving folds the\n * whole thread into a single chip that reopens it. Controlled with `comments` and `resolved`, or uncontrolled.\n */\nexport interface CommentThreadProps {\n  comments?: ThreadComment[];\n  defaultComments?: ThreadComment[];\n  onCommentsChange?: (comments: ThreadComment[], event: CommentThreadEvent) => void;\n  /** The person writing. Their comments can be edited and deleted. */\n  currentUser: CommentAuthor;\n  /** People who can be mentioned. Defaults to everyone in the thread. */\n  people?: CommentAuthor[];\n  resolved?: boolean;\n  defaultResolved?: boolean;\n  onResolvedChange?: (resolved: boolean) => void;\n  /** What the thread is about, shown in its header, such as \"Hero headline\". */\n  title?: ReactNode;\n  /** Emoji offered by the reaction picker. */\n  reactions?: string[];\n  placeholder?: string;\n  /** Replies deeper than this attach to the deepest allowed parent. Defaults to 2. */\n  maxDepth?: number;\n  /** Label for comments written now. */\n  nowLabel?: string;\n  /** Words for localization. Leave out any key to keep its English default. */\n  labels?: CommentThreadLabels;\n  className?: string;\n}\n\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\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 HEIGHT = physical(.42, 0);\nconst DEFAULT_REACTIONS = [\"👍\", \"❤️\", \"🎉\", \"👀\", \"🚀\", \"✅\"];\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\n/* Tree helpers. Every change returns a new tree so controlled parents can store it as is. */\nconst mapTree = (list: ThreadComment[], id: string, fn: (comment: ThreadComment) => ThreadComment | null): ThreadComment[] =>\n  list.flatMap(comment => {\n    if (comment.id === id) { const next = fn(comment); return next ? [next] : []; }\n    return comment.replies?.length ? [{ ...comment, replies: mapTree(comment.replies, id, fn) }] : [comment];\n  });\nconst countTree = (list: ThreadComment[]): number => list.reduce((sum, comment) => sum + (comment.deleted ? 0 : 1) + countTree(comment.replies ?? []), 0);\nconst authorsOf = (list: ThreadComment[], into = new Map<string, CommentAuthor>()) => {\n  list.forEach(comment => { if (!comment.deleted) into.set(comment.author.id, comment.author); authorsOf(comment.replies ?? [], into); });\n  return into;\n};\n/** Path of ids from the root to `id`. */\nconst pathTo = (list: ThreadComment[], id: string, trail: string[] = []): string[] | null => {\n  for (const comment of list) {\n    if (comment.id === id) return [...trail, id];\n    const found = pathTo(comment.replies ?? [], id, [...trail, comment.id]);\n    if (found) return found;\n  }\n  return null;\n};\nconst find = (list: ThreadComment[], id: string): ThreadComment | null => {\n  for (const comment of list) {\n    if (comment.id === id) return comment;\n    const found = find(comment.replies ?? [], id);\n    if (found) return found;\n  }\n  return null;\n};\n/** Wraps the first `part` inside `text`, so a translated sentence keeps its highlighted name wherever the language puts it. */\nconst highlight = (text: string, part: string, className?: string): ReactNode => {\n  const at = part ? text.indexOf(part) : -1;\n  return at < 0 ? text : <>{text.slice(0, at)}<span className={className}>{part}</span>{text.slice(at + part.length)}</>;\n};\nconst initials = (name: string) => name.split(/\\s+/).map(part => part[0]).slice(0, 2).join(\"\").toUpperCase();\nlet serial = 0;\nconst newId = () => `c-${Date.now().toString(36)}-${(serial++).toString(36)}`;\n\nfunction Avatar({ author, size = 28 }: { author: CommentAuthor; size?: number }) {\n  return author.avatar\n    // eslint-disable-next-line @next/next/no-img-element\n    ? <img className={styles.avatar} src={author.avatar} alt=\"\" width={size} height={size} style={{ width: size, height: size }} />\n    : <span className={styles.avatar} style={{ width: size, height: size, fontSize: Math.round(size * .38) }} aria-hidden=\"true\">{initials(author.name)}</span>;\n}\n\n/** Highlights \"@Full Name\" for known people. */\nfunction Body({ text, people }: { text: string; people: CommentAuthor[] }) {\n  const parts = useMemo(() => {\n    const names = people.map(person => person.name).sort((a, b) => b.length - a.length).map(name => name.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"));\n    if (!names.length) return [text];\n    return text.split(new RegExp(`(@(?:${names.join(\"|\")}))`, \"g\"));\n  }, [people, text]);\n  return <p className={styles.body}>{parts.map((part, index) => index % 2 ? <span key={index} className={styles.mention}>{part}</span> : part)}</p>;\n}\n\nconst roll: Variants = {\n  hidden: (dir: number) => ({ y: `${dir * 70}%`, opacity: 0 }),\n  shown: { y: 0, opacity: 1 },\n  gone: (dir: number) => ({ y: `${dir * -70}%`, opacity: 0 }),\n};\nconst rollReduced: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1 }, gone: { opacity: 0 } };\n\n/** Rolls a count up or down in place. */\nfunction Count({ value, reduced }: { value: number; reduced: boolean }) {\n  const [last, setLast] = useState(value);\n  const [dir, setDir] = useState(1);\n  if (value !== last) { setDir(value > last ? 1 : -1); setLast(value); }\n  return <span className={styles.count}>\n    <AnimatePresence mode=\"popLayout\" initial={false} custom={dir}>\n      <motion.span key={value} custom={dir} variants={reduced ? rollReduced : roll} initial=\"hidden\" animate=\"shown\" exit=\"gone\"\n        transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}>{value}</motion.span>\n    </AnimatePresence>\n  </span>;\n}\n\n/** Follows its content's height exactly, except when `morphKey` changes: then it springs, so the thread and the resolved chip morph into each other. */\nfunction AutoHeight({ children, reduced, morphKey }: { children: ReactNode; reduced: boolean; morphKey: string }) {\n  const inner = useRef<HTMLDivElement>(null);\n  const height = useMotionValue(0);\n  const [measured, setMeasured] = useState(false);\n  const morphing = useRef(false);\n  const lastKey = useRef(morphKey);\n  useLayoutEffect(() => {\n    if (lastKey.current !== morphKey) { lastKey.current = morphKey; morphing.current = true; }\n  }, [morphKey]);\n  useLayoutEffect(() => {\n    const node = inner.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    // The border box keeps subpixel line heights, so the frame never clips a fraction of the last line.\n    const observer = new ResizeObserver(([entry]) => {\n      const next = entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight;\n      if (morphing.current && !reduced) {\n        morphing.current = false;\n        animate(height, next, HEIGHT);\n        return;\n      }\n      if (height.isAnimating()) { animate(height, next, HEIGHT); return; }\n      height.jump(next);\n      setMeasured(true);\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [height, reduced]);\n  return <motion.div className={styles.autoHeight} style={{ height: measured ? height : \"auto\" }}><div ref={inner} className={styles.autoInner}>{children}</div></motion.div>;\n}\n\ninterface ComposerProps {\n  people: CommentAuthor[];\n  initial?: string;\n  placeholder: string;\n  submitLabel: string;\n  autoFocus?: boolean;\n  focusKey?: number;\n  onSubmit: (body: string) => void;\n  onCancel?: () => void;\n  leading?: ReactNode;\n  compact?: boolean;\n  cancelLabel: string;\n  mentionsLabel: string;\n}\n\n/** A growing text field with @mention suggestions. ⌘ or Ctrl + Enter sends; Escape cancels. */\nfunction Composer({ people, initial = \"\", placeholder, submitLabel, autoFocus, focusKey, onSubmit, onCancel, leading, compact, cancelLabel, mentionsLabel }: ComposerProps) {\n  const uid = useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const reduced = useReducedFlag();\n  const field = useRef<HTMLTextAreaElement>(null);\n  const [text, setText] = useState(initial);\n  const [query, setQuery] = useState<{ start: number; term: string } | null>(null);\n  const [active, setActive] = useState(0);\n  const suggestions = useMemo(() => {\n    if (!query) return [];\n    const term = query.term.toLowerCase();\n    return people.filter(person => person.name.toLowerCase().split(\" \").some(part => part.startsWith(term)) || person.name.toLowerCase().startsWith(term)).slice(0, 5);\n  }, [people, query]);\n  const open = !!query && suggestions.length > 0;\n\n  useEffect(() => {\n    if (!autoFocus && !focusKey) return;\n    const node = field.current;\n    if (!node) return;\n    node.focus({ preventScroll: true });\n    node.setSelectionRange(node.value.length, node.value.length);\n  }, [autoFocus, focusKey]);\n\n  const readQuery = (value: string, caret: number) => {\n    const match = /(^|\\s)@([\\p{L}]*)$/u.exec(value.slice(0, caret));\n    setQuery(match ? { start: caret - match[2]!.length - 1, term: match[2]! } : null);\n    setActive(0);\n  };\n\n  const insert = (person: CommentAuthor) => {\n    const node = field.current;\n    if (!node || !query) return;\n    const caret = node.selectionStart ?? text.length;\n    const next = `${text.slice(0, query.start)}@${person.name} ${text.slice(caret)}`;\n    const at = query.start + person.name.length + 2;\n    setText(next);\n    setQuery(null);\n    requestAnimationFrame(() => { node.focus(); node.setSelectionRange(at, at); });\n  };\n\n  const send = () => {\n    const body = text.trim();\n    if (!body) return;\n    onSubmit(body);\n    setText(\"\");\n    setQuery(null);\n  };\n\n  const onKeyDown = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {\n    if (open) {\n      if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") { event.preventDefault(); setActive(index => (index + (event.key === \"ArrowDown\" ? 1 : -1) + suggestions.length) % suggestions.length); return; }\n      if (event.key === \"Enter\" || event.key === \"Tab\") { event.preventDefault(); insert(suggestions[active]!); return; }\n      if (event.key === \"Escape\") { event.preventDefault(); event.stopPropagation(); setQuery(null); return; }\n    }\n    if (event.key === \"Enter\" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); send(); return; }\n    if (event.key === \"Escape\" && onCancel) { event.preventDefault(); event.stopPropagation(); onCancel(); }\n  };\n\n  return <div className={styles.composer} data-compact={compact || undefined}>\n    {leading}\n    <div className={styles.fieldWrap}>\n      <textarea ref={field} className={styles.field} value={text} placeholder={placeholder} rows={1} aria-label={placeholder}\n        role=\"combobox\" aria-autocomplete=\"list\" aria-expanded={open} aria-controls={`${uid}-people`} aria-activedescendant={open ? `${uid}-person-${active}` : undefined}\n        onChange={event => { setText(event.target.value); readQuery(event.target.value, event.target.selectionStart); }}\n        onSelect={event => readQuery(event.currentTarget.value, event.currentTarget.selectionStart)}\n        onBlur={() => setQuery(null)} onKeyDown={onKeyDown} />\n      <AnimatePresence>\n        {open && <motion.ul key=\"people\" id={`${uid}-people`} role=\"listbox\" aria-label={mentionsLabel} className={styles.suggestions}\n          initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }} animate={{ opacity: 1, height: \"auto\" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, height: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }} transition={reduced ? { duration: 0 } : { height: HEIGHT, opacity: { duration: motionTokens.duration.fast, ease: enter } }}>\n          {suggestions.map((person, index) => <li key={person.id} id={`${uid}-person-${index}`} role=\"option\" aria-selected={index === active} className={styles.suggestion}\n            onPointerDown={event => event.preventDefault()} onPointerMove={() => setActive(index)} onClick={() => insert(person)}>\n            <Avatar author={person} size={20} />{person.name}\n          </li>)}\n        </motion.ul>}\n      </AnimatePresence>\n    </div>\n    <div className={styles.composerActions}>\n      {onCancel && <button type=\"button\" className={styles.ghost} onClick={onCancel}>{cancelLabel}</button>}\n      <button type=\"button\" className={styles.send} disabled={!text.trim() || text.trim() === initial.trim()} onClick={send}>{submitLabel}</button>\n    </div>\n  </div>;\n}\n\ntype Ui = { editing: string | null; confirming: string | null; picker: string | null; collapsed: Record<string, boolean> };\n\nexport const CommentThread = forwardRef<HTMLElement, CommentThreadProps>(function CommentThread({\n  comments, defaultComments = [], onCommentsChange, currentUser, people, resolved, defaultResolved = false, onResolvedChange,\n  title, reactions = DEFAULT_REACTIONS, placeholder = \"Reply, or @mention someone\", maxDepth = 2, nowLabel = \"Just now\", labels, className,\n}, forwardedRef) {\n  const t = withDefaults(DEFAULT_LABELS, labels);\n  const reduced = useReducedFlag();\n  const rootRef = useRef<HTMLElement>(null);\n  useImperativeHandle(forwardedRef, () => rootRef.current as HTMLElement);\n  const [internal, setInternal] = useState(defaultComments);\n  const list = comments ?? internal;\n  const [internalResolved, setInternalResolved] = useState(defaultResolved);\n  const isResolved = resolved ?? internalResolved;\n  const [ui, setUi] = useState<Ui>({ editing: null, confirming: null, picker: null, collapsed: {} });\n  const [replyTo, setReplyTo] = useState<{ id: string; name: string } | null>(null);\n  const [focusKey, setFocusKey] = useState(0);\n\n  const mentionable = useMemo(() => people ?? [...authorsOf(list, new Map([[currentUser.id, currentUser]])).values()], [currentUser, list, people]);\n  const total = countTree(list);\n  const participants = useMemo(() => [...authorsOf(list).values()], [list]);\n\n  const commit = useCallback((next: ThreadComment[], event: CommentThreadEvent) => {\n    if (comments === undefined) setInternal(next);\n    onCommentsChange?.(next, event);\n  }, [comments, onCommentsChange]);\n\n  const setResolved = (next: boolean) => {\n    if (resolved === undefined) setInternalResolved(next);\n    onResolvedChange?.(next);\n    setUi(current => ({ ...current, editing: null, confirming: null, picker: null }));\n    setReplyTo(null);\n    requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(next ? \"[data-reopen]\" : \"[data-resolve]\")?.focus({ preventScroll: true }));\n  };\n\n  const addReply = (body: string) => {\n    const comment: ThreadComment = { id: newId(), author: currentUser, body, createdAt: nowLabel, reactions: [] };\n    let parentId: string | null = null;\n    if (replyTo) {\n      const path = pathTo(list, replyTo.id) ?? [];\n      parentId = path[Math.min(path.length, maxDepth) - 1] ?? null;\n    }\n    const next = parentId ? mapTree(list, parentId, parent => ({ ...parent, replies: [...(parent.replies ?? []), comment] })) : [...list, comment];\n    if (parentId) setUi(current => ({ ...current, collapsed: { ...current.collapsed, [parentId!]: false } }));\n    commit(next, { type: \"reply\", comment, parentId });\n    setReplyTo(null);\n  };\n\n  const edit = (id: string, body: string) => {\n    commit(mapTree(list, id, comment => ({ ...comment, body, edited: true })), { type: \"edit\", id, body });\n    setUi(current => ({ ...current, editing: null }));\n  };\n\n  const remove = (id: string) => {\n    commit(mapTree(list, id, comment => comment.replies?.length ? { ...comment, deleted: true, body: \"\", reactions: [] } : null), { type: \"delete\", id });\n    setUi(current => ({ ...current, confirming: null }));\n    if (replyTo?.id === id) setReplyTo(null);\n  };\n\n  const react = (id: string, emoji: string) => {\n    const target = find(list, id);\n    if (!target) return;\n    const existing = target.reactions?.find(reaction => reaction.emoji === emoji);\n    const added = !existing?.users.includes(currentUser.id);\n    const nextReactions = existing\n      ? (target.reactions ?? []).map(reaction => reaction.emoji !== emoji ? reaction : { ...reaction, users: added ? [...reaction.users, currentUser.id] : reaction.users.filter(user => user !== currentUser.id) }).filter(reaction => reaction.users.length)\n      : [...(target.reactions ?? []), { emoji, users: [currentUser.id] }];\n    commit(mapTree(list, id, comment => ({ ...comment, reactions: nextReactions })), { type: \"react\", id, emoji, added });\n    setUi(current => ({ ...current, picker: null }));\n  };\n\n  const grow = reduced\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: motionTokens.duration.instant } }\n    : { initial: { height: 0, opacity: 0 }, animate: { height: \"auto\", opacity: 1 }, exit: { height: 0, opacity: 0 }, transition: { height: HEIGHT, opacity: { duration: motionTokens.duration.standard, ease: enter } } };\n\n  const swap = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: { duration: reduced ? 0 : motionTokens.duration.instant, ease: standard } }, transition: { duration: reduced ? 0 : motionTokens.duration.fast, ease: enter } };\n\n  const renderComment = (comment: ThreadComment, depth: number): ReactNode => {\n    const mine = comment.author.id === currentUser.id;\n    const replies = comment.replies ?? [];\n    const collapsed = !!ui.collapsed[comment.id];\n    const editing = ui.editing === comment.id, confirming = ui.confirming === comment.id, picking = ui.picker === comment.id;\n    return <motion.li key={comment.id} className={styles.item} {...grow}>\n      <article className={styles.comment} aria-label={comment.deleted ? t.deletedComment : `${comment.author.name}, ${comment.createdAt}`}>\n        {comment.deleted ? <span className={styles.deletedDot} aria-hidden=\"true\" /> : <Avatar author={comment.author} />}\n        <div className={styles.main}>\n          {comment.deleted\n            ? <p className={styles.deleted}>{t.deletedText}</p>\n            : <>\n              <header className={styles.meta}>\n                <span className={styles.name}>{comment.author.name}</span>\n                <span className={styles.time}>{comment.createdAt}{comment.edited && ` · ${t.edited}`}</span>\n              </header>\n              {/* Editing swaps the body for a composer in place: the two cross-fade while the comment's height springs between them.\n                  The picker and delete prompt can wrap onto a new line on narrow screens, so they spring the height too. */}\n              <AutoHeight reduced={reduced} morphKey={editing ? \"edit\" : confirming ? \"confirm\" : picking ? \"picker\" : \"view\"}>\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                {editing\n                ? <motion.div key=\"edit\" {...swap}><Composer people={mentionable} initial={comment.body} placeholder={t.editComment} submitLabel={t.save} cancelLabel={t.cancel} mentionsLabel={t.mentions} autoFocus compact onSubmit={body => edit(comment.id, body)} onCancel={() => setUi(current => ({ ...current, editing: null }))} /></motion.div>\n                : <motion.div key=\"view\" {...swap}>\n                <Body text={comment.body} people={mentionable} />\n\n              <div className={styles.footer}>\n                {(comment.reactions ?? []).length > 0 && <div className={styles.reactions}>\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    {(comment.reactions ?? []).map(reaction => {\n                      const pressed = reaction.users.includes(currentUser.id);\n                      return <motion.button key={reaction.emoji} type=\"button\" layout={!reduced} className={styles.reaction} aria-pressed={pressed}\n                        aria-label={t.reactionCount(reaction.emoji, reaction.users.length, pressed)} onClick={() => react(comment.id, reaction.emoji)}\n                        initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .7 }} animate={{ opacity: 1, scale: 1 }} exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .7 }}\n                        transition={reduced ? { duration: 0 } : motionTokens.spring.snappy}>\n                        <span aria-hidden=\"true\">{reaction.emoji}</span><Count value={reaction.users.length} reduced={reduced} />\n                      </motion.button>;\n                    })}\n                  </AnimatePresence>\n                </div>}\n\n                <AnimatePresence mode=\"wait\" initial={false}>\n                  {confirming\n                    ? <motion.div key=\"confirm\" className={styles.actions} data-open=\"\" initial={reduced ? { opacity: 0 } : { opacity: 0, x: 6 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, transition: { duration: reduced ? 0 : motionTokens.duration.instant, ease: standard } }} transition={{ duration: reduced ? 0 : motionTokens.duration.fast, ease: enter }}\n                      onKeyDown={event => { if (event.key === \"Escape\") { event.stopPropagation(); setUi(current => ({ ...current, confirming: null })); } }}>\n                      <span className={styles.prompt}>{t.deleteConfirm}</span>\n                      <button type=\"button\" className={styles.action} onClick={() => setUi(current => ({ ...current, confirming: null }))}>{t.cancel}</button>\n                      <button type=\"button\" className={styles.action} data-tone=\"danger\" autoFocus onClick={() => remove(comment.id)}>{t.delete}</button>\n                    </motion.div>\n                    : picking\n                    ? <motion.div key=\"picker\" role=\"group\" aria-label={t.reactions} className={styles.actions} data-open=\"\"\n                      onKeyDown={event => { if (event.key === \"Escape\") { event.stopPropagation(); setUi(current => ({ ...current, picker: null })); } }}\n                      initial={reduced ? { opacity: 0 } : { opacity: 0, x: -6 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, transition: { duration: reduced ? 0 : motionTokens.duration.instant, ease: standard } }} transition={{ duration: reduced ? 0 : motionTokens.duration.fast, ease: enter }}>\n                      {reactions.map((emoji, index) => <motion.button key={emoji} type=\"button\" className={styles.emoji} autoFocus={index === 0} aria-label={t.reactWith(emoji)}\n                        aria-pressed={!!comment.reactions?.find(reaction => reaction.emoji === emoji)?.users.includes(currentUser.id)} onClick={() => react(comment.id, emoji)}\n                        initial={reduced ? false : { opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} transition={reduced ? { duration: 0 } : { ...motionTokens.spring.snappy, delay: index * motionTokens.stagger.item }}>{emoji}</motion.button>)}\n                      <button type=\"button\" className={styles.iconAction} aria-label={t.closeReactions} onClick={() => setUi(current => ({ ...current, picker: null }))}><X size={14} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n                    </motion.div>\n                    : <motion.div key=\"actions\" className={styles.actions} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: reduced ? 0 : motionTokens.duration.instant, ease: standard }}>\n                      <button type=\"button\" className={styles.iconAction} aria-label={t.addReaction} onClick={() => setUi(current => ({ ...current, picker: comment.id, confirming: null }))}>\n                        <SmilePlus size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n                      </button>\n                      <button type=\"button\" className={styles.action} onClick={() => { setReplyTo({ id: comment.id, name: comment.author.name }); setFocusKey(key => key + 1); }}>{t.reply}</button>\n                      {mine && <button type=\"button\" className={styles.action} onClick={() => setUi(current => ({ ...current, editing: comment.id, confirming: null, picker: null }))}>{t.edit}</button>}\n                      {mine && <button type=\"button\" className={styles.action} onClick={() => setUi(current => ({ ...current, confirming: comment.id, editing: null, picker: null }))}>{t.delete}</button>}\n                    </motion.div>}\n                </AnimatePresence>\n              </div>\n                </motion.div>}\n                </AnimatePresence>\n              </AutoHeight>\n            </>}\n\n          {replies.length > 0 && <button type=\"button\" className={styles.toggle} aria-expanded={!collapsed}\n            onClick={() => setUi(current => ({ ...current, collapsed: { ...current.collapsed, [comment.id]: !collapsed } }))}>\n            <motion.span className={styles.toggleIcon} initial={false} animate={{ rotate: collapsed ? 0 : 90 }} transition={reduced ? { duration: 0 } : motionTokens.spring.snappy} aria-hidden=\"true\">\n              <ChevronRight size={13} strokeWidth={1.75} />\n            </motion.span>\n            {collapsed ? t.showReplies(countTree(replies)) : t.hideReplies}\n            {collapsed && <span className={styles.stack} aria-hidden=\"true\">{[...authorsOf(replies).values()].slice(0, 3).map(author => <Avatar key={author.id} author={author} size={18} />)}</span>}\n          </button>}\n        </div>\n      </article>\n\n      <AnimatePresence initial={false}>\n        {replies.length > 0 && !collapsed && <motion.div key=\"replies\" className={styles.repliesWrap} {...grow}>\n          <ul className={styles.replies} data-depth={depth + 1}>\n            <AnimatePresence initial={false}>{replies.map(reply => renderComment(reply, depth + 1))}</AnimatePresence>\n          </ul>\n        </motion.div>}\n      </AnimatePresence>\n    </motion.li>;\n  };\n\n  const face = { initial: reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, filter: \"blur(0px)\" }, exit: { opacity: 0, filter: reduced ? \"blur(0px)\" : `blur(${motionTokens.blur.subtle}px)` }, transition: { duration: reduced ? 0 : motionTokens.duration.standard, ease: enter } };\n\n  return <section ref={rootRef} className={[styles.root, className].filter(Boolean).join(\" \")} data-resolved={isResolved || undefined} aria-label={t.thread}>\n    <AutoHeight reduced={reduced} morphKey={isResolved ? \"resolved\" : \"thread\"}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {isResolved\n          ? <motion.div key=\"resolved\" className={styles.resolved} {...face}>\n            <span className={styles.resolvedIcon} aria-hidden=\"true\"><Check size={14} strokeWidth={2} /></span>\n            <span className={styles.resolvedText}>\n              <span className={styles.resolvedTitle}>{t.resolved}{title ? <> · <span className={styles.resolvedSubject}>{title}</span></> : null}</span>\n              <span className={styles.resolvedMeta}>{t.commentCount(total)} · {t.peopleCount(participants.length)}</span>\n            </span>\n            <span className={styles.stack} aria-hidden=\"true\">{participants.slice(0, 3).map(author => <Avatar key={author.id} author={author} size={22} />)}</span>\n            <button type=\"button\" className={styles.reopen} data-reopen onClick={() => setResolved(false)}><RotateCcw size={14} strokeWidth={1.75} aria-hidden=\"true\" />{t.reopen}</button>\n          </motion.div>\n          : <motion.div key=\"thread\" className={styles.thread} {...face}>\n            <header className={styles.header}>\n              <div className={styles.heading}>\n                {title && <h3 className={styles.title}>{title}</h3>}\n                <span className={styles.subtitle}>{t.commentCount(total)}</span>\n              </div>\n              <button type=\"button\" className={styles.resolve} data-resolve disabled={!total} onClick={() => setResolved(true)}><Check size={15} strokeWidth={1.75} aria-hidden=\"true\" />{t.resolve}</button>\n            </header>\n\n            {list.length > 0\n              ? <ul className={styles.list}><AnimatePresence initial={false}>{list.map(comment => renderComment(comment, 0))}</AnimatePresence></ul>\n              : <p className={styles.empty}>{t.empty}</p>}\n\n            <div className={styles.composerArea}>\n              <AnimatePresence initial={false}>\n                {replyTo && <motion.div key=\"replying\" className={styles.replying} {...grow}>\n                  <span className={styles.replyingInner}>\n                    <CornerDownRight size={13} strokeWidth={1.75} aria-hidden=\"true\" />\n                    <span>{highlight(t.replyingTo(replyTo.name), replyTo.name, styles.replyingName)}</span>\n                    <button type=\"button\" className={styles.iconAction} aria-label={t.cancelReply} onClick={() => setReplyTo(null)}><X size={14} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n                  </span>\n                </motion.div>}\n              </AnimatePresence>\n              <Composer people={mentionable} placeholder={placeholder} submitLabel={t.send} cancelLabel={t.cancel} mentionsLabel={t.mentions} focusKey={focusKey} onSubmit={addReply}\n                onCancel={replyTo ? () => setReplyTo(null) : undefined} leading={<Avatar author={currentUser} />} />\n            </div>\n          </motion.div>}\n      </AnimatePresence>\n    </AutoHeight>\n  </section>;\n});\n\nexport default CommentThread;\n"
    },
    {
      "path": "registry/components/comment-thread/comment-thread.module.css",
      "type": "registry:component",
      "target": "@components/xzero/comment-thread/comment-thread.module.css",
      "content": ".root {\n  width: 100%;\n  min-width: 0;\n  border: 1px solid var(--border);\n  border-radius: var(--radius-panel);\n  background: var(--surface);\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  transition: border-radius var(--duration-spring) var(--ease-spring);\n}\n.root *, .root *::before, .root *::after { box-sizing: border-box; }\n.root[data-resolved] { border-radius: 22px; }\n.autoHeight { overflow: hidden; border-radius: inherit; }\n.autoInner { position: relative; }\n\n/* Header */\n.thread { display: grid; }\n.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 12px 8px 18px; }\n.heading { display: grid; min-width: 0; }\n.title { margin: 0; overflow: hidden; font-size: var(--text-sm); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.subtitle { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.resolve,\n.reopen {\n  display: inline-flex;\n  height: 32px;\n  flex: none;\n  align-items: center;\n  gap: 6px;\n  padding: 0 12px 0 10px;\n  border: 1px solid var(--border);\n  border-radius: var(--radius-pill);\n  background: var(--surface);\n  color: var(--foreground);\n  cursor: pointer;\n  font: inherit;\n  font-size: var(--text-xs);\n  font-weight: 500;\n  transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);\n}\n.resolve > svg { color: var(--success); }\n.resolve:active:not(:disabled), .reopen:active { transform: scale(.97); }\n.resolve:disabled { opacity: .45; cursor: default; }\n\n/* Comments */\n.list, .replies { display: grid; margin: 0; padding: 0; list-style: none; }\n.list { padding: 0 8px; }\n.item { overflow: clip; overflow-clip-margin: 4px; }\n.repliesWrap { overflow: clip; }\n/* Replies hang from a quiet rail under their parent's avatar. */\n.replies { position: relative; margin-left: 24px; padding-left: 12px; }\n.replies::before { content: \"\"; position: absolute; top: 2px; bottom: 14px; left: 0; width: 1px; background: var(--border); }\n\n.comment { display: flex; gap: 10px; padding: 10px; border-radius: 16px; }\n.avatar { display: inline-grid; flex: none; place-items: center; overflow: hidden; border-radius: 50%; background: var(--surface-muted); color: var(--text-secondary); font-size: var(--text-xs); font-weight: 500; letter-spacing: 0; object-fit: cover; }\n.deletedDot { width: 28px; height: 28px; flex: none; border: 1px dashed var(--border-strong); border-radius: 50%; }\n.main { display: grid; min-width: 0; flex: 1; gap: 2px; }\n.meta { display: flex; min-width: 0; align-items: baseline; gap: 8px; }\n.name { overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.time { flex: none; color: var(--text-muted); font-size: var(--text-xs); }\n.body { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; white-space: pre-wrap; text-wrap: pretty; }\n.mention { color: var(--accent-strong); font-weight: 500; }\n.deleted { margin: 4px 0 0; color: var(--text-muted); font-style: italic; }\n\n.footer { display: flex; min-height: 30px; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 4px; }\n.reactions { display: flex; flex-wrap: wrap; gap: 4px; }\n.reaction {\n  display: inline-flex;\n  height: 26px;\n  align-items: center;\n  gap: 5px;\n  padding: 0 9px 0 7px;\n  border: 1px solid var(--border);\n  border-radius: var(--radius-pill);\n  background: var(--surface);\n  color: var(--text-secondary);\n  cursor: pointer;\n  font: inherit;\n  font-size: var(--text-xs);\n  font-weight: 500;\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);\n}\n.reaction[aria-pressed=\"true\"] { border-color: color-mix(in oklab, var(--accent) 45%, var(--border)); background: var(--accent-subtle); color: var(--foreground); }\n.reaction:active { transform: scale(.94); }\n.count { position: relative; display: inline-grid; min-width: 1ch; overflow: hidden; font-variant-numeric: tabular-nums; }\n.count > span { grid-area: 1 / 1; }\n\n/* Actions sit quietly after the reactions and come forward on hover or focus; touch screens always show them. */\n.actions { display: flex; min-height: 28px; flex-wrap: wrap; align-items: center; gap: 2px; transition: opacity var(--duration-fast) var(--ease-standard); }\n.action,\n.iconAction {\n  display: inline-flex;\n  height: 28px;\n  align-items: center;\n  justify-content: center;\n  padding: 0 8px;\n  border: 0;\n  border-radius: var(--radius-pill);\n  background: transparent;\n  color: var(--text-muted);\n  cursor: pointer;\n  font: inherit;\n  font-size: var(--text-xs);\n  font-weight: 500;\n  -webkit-tap-highlight-color: transparent;\n  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);\n}\n.iconAction { width: 28px; padding: 0; }\n.action[data-tone=\"danger\"] { color: var(--danger); }\n.prompt { margin-right: 4px; color: var(--foreground); font-size: var(--text-xs); }\n.emoji { display: grid; width: 28px; height: 28px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; cursor: pointer; font-size: var(--text-base); line-height: 1; transition: background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n.emoji[aria-pressed=\"true\"] { background: var(--accent-subtle); }\n.emoji:active { transform: scale(.88); }\n\n.toggle { display: inline-flex; width: fit-content; height: 28px; align-items: center; gap: 6px; margin: 2px 0 0 -6px; padding: 0 8px 0 6px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-xs); font-weight: 500; transition: background-color var(--duration-fast) var(--ease-standard); }\n.toggleIcon { display: grid; place-items: center; color: var(--text-muted); }\n.stack { display: inline-flex; flex: none; padding-left: 4px; }\n.stack > * { margin-left: -5px; box-shadow: 0 0 0 2px var(--surface); }\n\n.empty { margin: 0; padding: 8px 18px 16px; color: var(--text-muted); }\n\n/* Composer */\n.composerArea { display: grid; margin-top: 4px; padding: 10px 12px 12px; border-top: 1px solid var(--border-subtle); }\n.replying { overflow: clip; }\n.replyingInner { display: flex; align-items: center; gap: 6px; padding: 0 2px 8px 38px; color: var(--text-secondary); font-size: var(--text-xs); }\n.replyingInner .iconAction { width: 22px; height: 22px; margin-left: 2px; }\n.replyingName { color: var(--foreground); font-weight: 500; }\n\n.composer { display: grid; align-items: start; gap: 8px 10px; grid-template-columns: auto minmax(0, 1fr) auto; }\n.composer > .avatar { margin-top: 5px; }\n.composer[data-compact] { grid-template-columns: minmax(0, 1fr); margin-top: 4px; }\n.fieldWrap { display: grid; min-width: 0; }\n.field {\n  width: 100%;\n  min-height: 38px;\n  max-height: 160px;\n  padding: 8px 12px;\n  border: 1px solid var(--border);\n  border-radius: 14px;\n  background: var(--surface);\n  color: var(--foreground);\n  font: inherit;\n  resize: none;\n  field-sizing: content;\n  transition: border-color var(--duration-fast) var(--ease-standard);\n}\n.field::placeholder { color: var(--text-muted); }\n.field:focus { border-color: var(--border-strong); }\n.composerActions { display: flex; align-items: center; gap: 4px; }\n.composer[data-compact] .composerActions { justify-content: flex-end; }\n.send,\n.ghost { height: 38px; padding: 0 14px; border: 0; border-radius: var(--radius-pill); cursor: pointer; font: inherit; font-weight: 500; transition: background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n.composer[data-compact] :is(.send, .ghost) { height: 32px; font-size: var(--text-xs); }\n.send { background: var(--foreground); color: var(--background); }\n.send:disabled { opacity: .35; cursor: default; }\n.send:active:not(:disabled), .ghost:active { transform: scale(.97); }\n.ghost { background: transparent; color: var(--text-secondary); }\n\n.suggestions { display: grid; margin: 6px 0 0; padding: 4px; overflow: clip; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); list-style: none; }\n.suggestion { display: flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 8px; border-radius: 10px; cursor: pointer; }\n.suggestion[aria-selected=\"true\"] { background: var(--surface-muted); }\n\n/* Resolved chip */\n.resolved { display: flex; align-items: center; gap: 12px; padding: 10px 10px 10px 14px; }\n.resolvedIcon { display: grid; width: 24px; height: 24px; flex: none; place-items: center; border-radius: 50%; background: color-mix(in oklab, var(--success) 14%, transparent); color: var(--success); }\n.resolvedText { display: grid; min-width: 0; flex: 1; }\n.resolvedTitle { overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.resolvedSubject { color: var(--text-secondary); font-weight: 400; }\n.resolvedMeta { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n\n@media (hover: hover) and (pointer: fine) {\n  .comment .actions:not([data-open]) { opacity: 0; }\n  .comment:hover .actions,\n  .comment:focus-within .actions { opacity: 1; }\n  .action:hover, .iconAction:hover, .toggle:hover, .ghost:hover { background: var(--surface-muted); color: var(--foreground); }\n  .action[data-tone=\"danger\"]:hover { background: color-mix(in oklab, var(--danger) 9%, transparent); color: var(--danger); }\n  .reaction:hover { border-color: var(--border-strong); }\n  .emoji:hover { background: var(--surface-muted); }\n  .resolve:hover:not(:disabled), .reopen:hover { border-color: var(--border-strong); background: var(--surface-muted); }\n  .send:hover:not(:disabled) { background: color-mix(in oklab, var(--foreground) 86%, var(--background)); }\n}\n.action:focus-visible, .iconAction:focus-visible, .toggle:focus-visible, .emoji:focus-visible { background: var(--surface-muted); color: var(--foreground); }\n\n@media (max-width: 420px) {\n  .composer { grid-template-columns: minmax(0, 1fr) auto; }\n  .composer > .avatar { display: none; }\n  .replyingInner { padding-left: 2px; }\n  .replies { margin-left: 12px; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .root, .actions, .reaction, .action, .iconAction, .emoji, .send, .ghost, .resolve, .reopen { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/comment-thread",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/comment-thread",
    "markdown": "https://ui.xzero.co.uk/components/comment-thread/markdown"
  }
}