{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comment-thread",
  "type": "registry:ui",
  "title": "Comment Thread",
  "description": "Threaded comments that fold, resolve with a drawn tick and undo deletes.",
  "categories": [
    "collaboration"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/comment-thread.tsx",
      "content": "\"use client\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUp, ChevronUp, MoreH, Pencil, Trash } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Types\n * -----------------------------------------------------------------------------------------------*/\n\nexport type CommentAuthor = { id: string; name: string; avatarUrl?: string };\n\nexport type ThreadComment = {\n  id: string;\n  author: CommentAuthor;\n  body: string;\n  createdAt: Date | string | number;\n  editedAt?: Date | string | number;\n  /** The first comment of a thread that still has replies is kept as a tombstone instead of removed. */\n  deleted?: boolean;\n};\n\nexport type Thread = {\n  root: ThreadComment;\n  replies: ThreadComment[];\n  resolved?: boolean;\n  resolvedBy?: CommentAuthor;\n  resolvedAt?: Date | string | number;\n};\n\n/* -------------------------------------------------------------------------------------------------\n * Time: one shared clock for every timestamp on the page, ticking every 30s while the tab is visible.\n * The server renders an absolute date; the client swaps in \"4m ago\" after hydration.\n * -----------------------------------------------------------------------------------------------*/\n\nlet clock = 0;\nlet ticker: number | undefined;\nconst listeners = new Set<() => void>();\nconst tick = () => {\n  if (document.hidden) return;\n  clock = Date.now();\n  listeners.forEach((l) => l());\n};\nfunction subscribeClock(cb: () => void) {\n  listeners.add(cb);\n  if (ticker === undefined) {\n    ticker = window.setInterval(tick, 30_000);\n    document.addEventListener(\"visibilitychange\", tick);\n  }\n  return () => {\n    listeners.delete(cb);\n    if (!listeners.size) {\n      window.clearInterval(ticker);\n      ticker = undefined;\n      document.removeEventListener(\"visibilitychange\", tick);\n    }\n  };\n}\nconst readClock = () => clock || (clock = Date.now());\n/** Move the shared clock to now, e.g. right after posting, so the new item reads \"just now\". */\nfunction bumpClock() {\n  clock = Date.now();\n  listeners.forEach((l) => l());\n}\n/** Milliseconds since epoch on the client, 0 on the server and during hydration. */\nexport function useNow() {\n  return useSyncExternalStore(subscribeClock, readClock, () => 0);\n}\n\nconst toMs = (d: Date | string | number) => (d instanceof Date ? d.getTime() : typeof d === \"number\" ? d : Date.parse(d));\nconst shortDate = new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\" });\nconst shortDateYear = new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\", year: \"numeric\" });\nconst fullDate = new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" });\nconst startOfDay = (ms: number) => new Date(ms).setHours(0, 0, 0, 0);\n\nfunction absolute(ms: number, now: number) {\n  return new Date(ms).getFullYear() === new Date(now || ms).getFullYear() ? shortDate.format(ms) : shortDateYear.format(ms);\n}\n\n/** \"just now\", \"4m ago\", \"2h ago\", \"Yesterday\", \"3d ago\", then \"12 Sep\". */\nexport function formatRelative(date: Date | string | number, now: number) {\n  const ms = toMs(date);\n  if (!now) return absolute(ms, now);\n  const s = Math.max(0, (now - ms) / 1000);\n  if (s < 45) return \"just now\";\n  const m = Math.round(s / 60);\n  if (m < 60) return `${m}m ago`;\n  const h = Math.round(m / 60);\n  const days = Math.round((startOfDay(now) - startOfDay(ms)) / 864e5);\n  if (h < 24 && days === 0) return `${h}h ago`;\n  if (days <= 1) return \"Yesterday\";\n  if (days < 7) return `${days}d ago`;\n  return absolute(ms, now);\n}\n\nfunction TimeAgo({ date, className }: { date: Date | string | number; className?: string }) {\n  const now = useNow();\n  const ms = toMs(date);\n  return (\n    <time dateTime={new Date(ms).toISOString()} title={fullDate.format(ms)} suppressHydrationWarning className={cn(\"tabular whitespace-nowrap\", className)}>\n      {formatRelative(ms, now)}\n    </time>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Small parts\n * -----------------------------------------------------------------------------------------------*/\n\nconst initials = (name: string) =>\n  name\n    .trim()\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase() ?? \"\")\n    .join(\"\");\n\nfunction Avatar({ author, size = 24, className }: { author: CommentAuthor; size?: number; className?: string }) {\n  const [failed, setFailed] = useState(false);\n  return (\n    <span\n      aria-hidden\n      style={{ width: size, height: size, fontSize: Math.round(size * 0.4) }}\n      className={cn(\n        \"relative grid shrink-0 select-none place-items-center overflow-hidden rounded-full bg-hover font-medium leading-none text-fg-2\",\n        \"shadow-[inset_0_0_0_1px_var(--line-2)]\",\n        className,\n      )}\n    >\n      {author.avatarUrl && !failed ? (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img src={author.avatarUrl} alt=\"\" onError={() => setFailed(true)} className=\"size-full object-cover\" />\n      ) : (\n        // Small avatars in a stack are partly covered, so they show one letter.\n        (size < 20 ? initials(author.name).slice(0, 1) : initials(author.name))\n      )}\n    </span>\n  );\n}\n\nconst iconButton = cn(\n  \"relative grid size-7 shrink-0 place-items-center rounded-md text-fg-3 outline-none\",\n  \"transition-[background-color,color,scale,opacity] duration-150 ease-out-quart hover:bg-fg/[0.06] hover:text-fg active:scale-[0.92] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n  \"data-popup-open:bg-fg/[0.08] data-popup-open:text-fg\",\n  // Draws at 28px; on touch the target grows to 44px.\n  \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n);\n\nconst textButton = cn(\n  \"relative inline-flex h-6 items-center gap-1.5 rounded-md px-1.5 -mx-1.5 text-[12px] font-medium text-fg-3 outline-none select-none\",\n  \"transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-fg/[0.06] hover:text-fg active:scale-[0.97] active:duration-75\",\n  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n  \"before:absolute before:-inset-y-2.5 before:inset-x-0 before:content-[''] pointer-fine:before:hidden\",\n);\n\nconst smallButton = (primary: boolean) =>\n  cn(\n    \"inline-flex h-7 shrink-0 select-none items-center justify-center rounded-md px-2 text-[12px] font-medium outline-none\",\n    \"transition-[background-color,border-color,color,scale,opacity] duration-150 ease-out-quart active:scale-[0.97] active:duration-75\",\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n    \"disabled:pointer-events-none disabled:opacity-40\",\n    primary ? \"bg-fg text-frame hover:bg-fg/90\" : \"text-fg-2 hover:bg-fg/[0.06] hover:text-fg\",\n  );\n\nfunction Tip({ label, children }: { label: string; children: React.ReactElement<Record<string, unknown>> }) {\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger render={children} />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"top\" sideOffset={6} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] leading-4 text-fg shadow-pop\",\n              \"origin-(--transform-origin) transition-[opacity,scale] duration-150 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100\",\n              \"data-instant:transition-none motion-reduce:data-starting-style:scale-100\",\n            )}\n          >\n            {label}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\n/** The resolve toggle: an empty ring with a faint tick that fills, pops and draws its tick when resolved. */\nfunction ResolveGlyph({ resolved, draw }: { resolved: boolean; draw: boolean }) {\n  return (\n    <motion.svg\n      key={resolved ? \"on\" : \"off\"}\n      width=\"16\"\n      height=\"16\"\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      aria-hidden\n      initial={draw ? { scale: resolved ? 0.78 : 0.9 } : false}\n      animate={{ scale: 1 }}\n      transition={spring.pop}\n    >\n      <circle\n        cx=\"8\"\n        cy=\"8\"\n        r=\"5.75\"\n        stroke=\"currentColor\"\n        strokeWidth={1.4}\n        className={cn(\"transition-[fill] duration-300 ease-out-quart\", resolved ? \"fill-success-soft\" : \"fill-transparent\")}\n      />\n      <motion.path\n        d=\"m5.5 8.25 1.75 1.75 3.25-3.75\"\n        stroke=\"currentColor\"\n        strokeWidth={1.4}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        initial={draw && resolved ? { pathLength: 0 } : false}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.32, ease: ease.out, delay: 0.06 }}\n        className={resolved ? undefined : \"opacity-50\"}\n      />\n    </motion.svg>\n  );\n}\n\n/**\n * Animates its own height to whatever its content measures, so swaps inside it\n * (an editor opening, a comment turning into \"Deleted · Undo\", a fold) move the\n * rows below smoothly instead of jumping. Padded by 4px so focus rings aren't clipped.\n */\nfunction AutoHeight({ children, reduce, className }: { children: React.ReactNode; reduce: boolean; className?: string }) {\n  const ref = useRef<HTMLDivElement>(null);\n  const height = useMotionValue<number | string>(\"auto\");\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    let last = el.offsetHeight;\n    let running: AnimationPlaybackControls | undefined;\n    const ro = new ResizeObserver(() => {\n      const next = el.offsetHeight;\n      if (next === last) return;\n      const from = last;\n      last = next;\n      running?.stop();\n      if (reduce) return height.set(\"auto\");\n      if (height.get() === \"auto\") height.set(from);\n      running = animate(height, next, { duration: 0.26, ease: ease.inOut, onComplete: () => height.set(\"auto\") });\n    });\n    ro.observe(el);\n    return () => {\n      ro.disconnect();\n      running?.stop();\n    };\n  }, [height, reduce]);\n  return (\n    <motion.div style={{ height }} className={cn(\"-m-1 overflow-hidden\", className)}>\n      <div ref={ref} className=\"relative p-1\">\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n\nconst noop = () => () => {};\nconst isApple = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);\n\n// Event-time helpers. Only ever called from handlers and timers, never while rendering.\nconst stamp = () => Date.now();\nconst newId = () => `r-${stamp().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;\n\nconst omit = <T extends Record<string, unknown>>(obj: T, key: string) => {\n  const next = { ...obj };\n  delete next[key];\n  return next;\n};\n\n/* -------------------------------------------------------------------------------------------------\n * CommentThread\n * -----------------------------------------------------------------------------------------------*/\n\nexport type CommentThreadProps = Omit<React.ComponentProps<\"article\">, \"children\" | \"defaultValue\" | \"onChange\"> & {\n  /** The thread, controlled. Pair with onThreadChange. */\n  thread?: Thread;\n  /** The thread, uncontrolled. */\n  defaultThread?: Thread;\n  /** Every change: a reply, an edit, a delete, resolve. */\n  onThreadChange?: (thread: Thread) => void;\n  /** Who is looking. Their own comments get Edit and Delete, and new replies are theirs. */\n  currentUser: CommentAuthor;\n  /** Persist a reply. It appears at once; return a promise and a rejection marks it failed with Retry. */\n  onReply?: (reply: ThreadComment) => void | Promise<unknown>;\n  /** Persist an edit. A rejection restores the original and reopens the editor with the draft. */\n  onEdit?: (id: string, body: string) => void | Promise<unknown>;\n  /** Fires once the undo window has passed, not when Delete is pressed. */\n  onDelete?: (id: string) => void;\n  onResolvedChange?: (resolved: boolean) => void;\n  /** Whether the replies and the reply field are showing. */\n  expanded?: boolean;\n  defaultExpanded?: boolean;\n  onExpandedChange?: (expanded: boolean) => void;\n  /** Milliseconds a deleted comment can be restored for. Paused while the pointer or focus is on it. */\n  undoTimeout?: number;\n  /** No replying, editing, deleting or resolving. */\n  readOnly?: boolean;\n  replyPlaceholder?: string;\n};\n\ntype Editing = { id: string; draft: string; error?: string } | null;\ntype PendingTimer = { handle?: number; left: number; at: number };\ntype Status = \"sending\" | \"failed\";\n\nexport function CommentThread({\n  thread: threadProp,\n  defaultThread,\n  onThreadChange,\n  currentUser,\n  onReply,\n  onEdit,\n  onDelete,\n  onResolvedChange,\n  expanded: expandedProp,\n  defaultExpanded = false,\n  onExpandedChange,\n  undoTimeout = 6000,\n  readOnly = false,\n  replyPlaceholder = \"Reply…\",\n  className,\n  ...rest\n}: CommentThreadProps) {\n  const reduce = !!useReducedMotion();\n  const uid = useId();\n  const [thread, setThread] = useControllableState<Thread>({\n    value: threadProp,\n    defaultValue: defaultThread ?? { root: { id: \"empty\", author: currentUser, body: \"\", createdAt: 0 }, replies: [] },\n    onChange: onThreadChange,\n  });\n  const [expanded, setExpanded] = useControllableState({ value: expandedProp, defaultValue: defaultExpanded, onChange: onExpandedChange });\n  const [editing, setEditing] = useState<Editing>(null);\n  const [status, setStatus] = useState<Record<string, Status>>({});\n  const [tombs, setTombs] = useState<string[]>([]);\n  const [draft, setDraft] = useState(\"\");\n  const [focusReply, setFocusReply] = useState(0);\n  const [focusMenu, setFocusMenu] = useState<string | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  // The tick only draws when someone resolves it here, never on first paint.\n  const [touched, setTouched] = useState(false);\n\n  // Async work (a reply that fails later, a delete that commits after the undo window)\n  // must update the latest thread, not the one captured when it started.\n  const latest = useRef(thread);\n  const callbacks = useRef({ onDelete, setThread });\n  const timers = useRef(new Map<string, PendingTimer>());\n  useEffect(() => {\n    latest.current = thread;\n    callbacks.current = { onDelete, setThread };\n  });\n\n  const update = (fn: (t: Thread) => Thread) => {\n    const next = fn(latest.current);\n    latest.current = next;\n    callbacks.current.setThread(next);\n  };\n\n  const { root, replies, resolved = false } = thread;\n  const lastReply = replies[replies.length - 1];\n  const repliers = [...new Map(replies.map((r) => [r.author.id, r.author])).values()].slice(-3);\n  const regionId = `${uid}-replies`;\n  const open = expanded && !resolved;\n\n  /* ----- resolve ----- */\n  const toggleResolved = () => {\n    const next = !resolved;\n    setTouched(true);\n    setEditing(null);\n    update((t) => ({ ...t, resolved: next, resolvedBy: next ? currentUser : undefined, resolvedAt: next ? stamp() : undefined }));\n    onResolvedChange?.(next);\n    setAnnouncement(next ? \"Thread resolved\" : \"Thread reopened\");\n  };\n\n  /* ----- replies, optimistic ----- */\n  const post = async (reply: ThreadComment) => {\n    if (!onReply) return;\n    setStatus((s) => ({ ...s, [reply.id]: \"sending\" }));\n    try {\n      await onReply(reply);\n      setStatus((s) => omit(s, reply.id));\n      setAnnouncement(\"Reply sent\");\n    } catch {\n      setStatus((s) => ({ ...s, [reply.id]: \"failed\" }));\n      setAnnouncement(\"Couldn’t send your reply\");\n    }\n  };\n\n  const send = () => {\n    const body = draft.trim();\n    if (!body) return;\n    const reply: ThreadComment = {\n      id: newId(),\n      author: currentUser,\n      body,\n      createdAt: stamp(),\n    };\n    bumpClock();\n    setDraft(\"\");\n    update((t) => ({ ...t, replies: [...t.replies, reply] }));\n    void post(reply);\n  };\n\n  const discard = (id: string) => {\n    setStatus((s) => omit(s, id));\n    update((t) => ({ ...t, replies: t.replies.filter((r) => r.id !== id) }));\n  };\n\n  /* ----- edit, optimistic with rollback ----- */\n  const patch = (t: Thread, id: string, c: Partial<ThreadComment>): Thread =>\n    t.root.id === id ? { ...t, root: { ...t.root, ...c } } : { ...t, replies: t.replies.map((r) => (r.id === id ? { ...r, ...c } : r)) };\n\n  const saveEdit = async (id: string, body: string) => {\n    const before = [latest.current.root, ...latest.current.replies].find((c) => c.id === id);\n    if (!before) return;\n    update((t) => patch(t, id, { body, editedAt: stamp() }));\n    setEditing(null);\n    setFocusMenu(id);\n    try {\n      await onEdit?.(id, body);\n    } catch {\n      update((t) => patch(t, id, { body: before.body, editedAt: before.editedAt }));\n      setEditing({ id, draft: body, error: \"Couldn’t save your edit. Try again.\" });\n      setAnnouncement(\"Couldn’t save your edit\");\n    }\n  };\n\n  /* ----- delete, with an undo window that pauses under the pointer ----- */\n  const commit = (id: string) => {\n    const entry = timers.current.get(id);\n    if (entry?.handle) window.clearTimeout(entry.handle);\n    timers.current.delete(id);\n    setTombs((t) => t.filter((x) => x !== id));\n    update((t) => (t.root.id === id ? { ...t, root: { ...t.root, deleted: true, body: \"\" } } : { ...t, replies: t.replies.filter((r) => r.id !== id) }));\n    callbacks.current.onDelete?.(id);\n  };\n  const arm = (id: string) => {\n    const entry = timers.current.get(id);\n    if (!entry || entry.handle) return;\n    entry.at = stamp();\n    entry.handle = window.setTimeout(() => commit(id), Math.max(0, entry.left));\n  };\n  const pause = (id: string) => {\n    const entry = timers.current.get(id);\n    if (!entry?.handle) return;\n    window.clearTimeout(entry.handle);\n    entry.handle = undefined;\n    entry.left -= stamp() - entry.at;\n  };\n  const remove = (id: string) => {\n    setEditing(null);\n    setTombs((t) => [...t, id]);\n    timers.current.set(id, { left: undoTimeout, at: stamp() });\n    arm(id);\n    setAnnouncement(id === root.id && replies.length === 0 ? \"Thread deleted. Undo is available.\" : \"Comment deleted. Undo is available.\");\n  };\n  const undo = (id: string) => {\n    const entry = timers.current.get(id);\n    if (entry?.handle) window.clearTimeout(entry.handle);\n    timers.current.delete(id);\n    setTombs((t) => t.filter((x) => x !== id));\n    setFocusMenu(id);\n    setAnnouncement(\"Comment restored\");\n  };\n\n  // Unmounting doesn't cancel a delete: anything still inside its undo window is committed.\n  useEffect(() => {\n    const pending = timers.current;\n    const cbs = callbacks;\n    return () => {\n      for (const [id, entry] of pending) {\n        if (entry.handle) window.clearTimeout(entry.handle);\n        cbs.current.onDelete?.(id);\n      }\n      pending.clear();\n    };\n  }, []);\n\n  const openReply = () => {\n    setExpanded(true);\n    setFocusReply((n) => n + 1);\n  };\n\n  const actions: RowActions = {\n    focused: () => setFocusMenu(null),\n    edit: (c) => setEditing({ id: c.id, draft: c.body }),\n    draft: (d) => setEditing((e) => (e ? { ...e, draft: d, error: undefined } : e)),\n    cancel: (c) => {\n      setEditing(null);\n      setFocusMenu(c.id);\n    },\n    save: (c, body) => void saveEdit(c.id, body),\n    remove: (c) => remove(c.id),\n    retry: (c) => void post(c),\n    discard: (c) => discard(c.id),\n  };\n  const rowProps = (c: ThreadComment) => ({\n    comment: c,\n    own: !readOnly && c.author.id === currentUser.id && !c.deleted,\n    status: status[c.id],\n    editing: editing?.id === c.id ? editing : null,\n    reduce,\n    focusMenu: focusMenu === c.id,\n  });\n\n  // A first comment deleted with nothing under it takes the thread with it.\n  if (root.deleted && replies.length === 0) return null;\n\n  const swap = {\n    initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.985 },\n    animate: { opacity: 1, scale: 1 },\n    exit: { opacity: 0, transition: { duration: 0.1 } },\n    transition: { duration: 0.2, ease: ease.out },\n  };\n\n  return (\n    <article\n      aria-label={`Thread by ${root.author.name}`}\n      data-state={resolved ? \"resolved\" : open ? \"open\" : \"closed\"}\n      className={cn(\"relative grid w-full min-w-0 grid-cols-[24px_minmax(0,1fr)] gap-x-2.5 text-[13px] [--thread-bg:var(--raised)]\", className)}\n      {...rest}\n    >\n      {/* The spine joins the first avatar to the replies while they're showing. */}\n      <span aria-hidden className=\"pointer-events-none relative col-start-1 row-span-3 row-start-1\">\n        <motion.span\n          initial={false}\n          animate={{ scaleY: open ? 1 : 0, opacity: open ? 1 : 0 }}\n          transition={reduce ? { duration: 0.12 } : { duration: 0.28, ease: ease.inOut }}\n          className={cn(\"absolute left-1/2 top-8 w-px -translate-x-1/2 origin-top bg-line-2\", readOnly ? \"bottom-6\" : \"bottom-11\")}\n        />\n      </span>\n\n      <AutoHeight reduce={reduce} className=\"col-span-2 col-start-1 row-start-1\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {tombs.includes(root.id) ? (\n            <Tombstone\n              key=\"tomb\"\n              label={replies.length ? \"Comment deleted\" : \"Thread deleted\"}\n              onUndo={() => undo(root.id)}\n              onPause={() => pause(root.id)}\n              onResume={() => arm(root.id)}\n              {...swap}\n            />\n          ) : (\n            <motion.div key=\"root\" className=\"grid grid-cols-[24px_minmax(0,1fr)] gap-x-2.5\" {...swap}>\n              <CommentRow\n                {...rowProps(root)}\n                actions={actions}\n                root\n                resolved={resolved}\n                resolveSlot={\n                  !readOnly && !root.deleted ? (\n                    <Tip label={resolved ? \"Reopen thread\" : \"Resolve thread\"}>\n                      <button\n                        type=\"button\"\n                        aria-pressed={resolved}\n                        aria-label={resolved ? \"Reopen thread\" : \"Resolve thread\"}\n                        onClick={toggleResolved}\n                        className={cn(iconButton, resolved && \"text-success hover:text-success\")}\n                      >\n                        <ResolveGlyph resolved={resolved} draw={touched && !reduce} />\n                      </button>\n                    </Tip>\n                  ) : resolved ? (\n                    <span role=\"img\" aria-label=\"Resolved\" className=\"grid size-7 place-items-center text-success\">\n                      <ResolveGlyph resolved draw={false} />\n                    </span>\n                  ) : null\n                }\n              />\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </AutoHeight>\n\n      {/* Under the first comment: who replied and when, or who resolved it. */}\n      <div className=\"col-start-2 row-start-2 min-w-0\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {resolved ? (\n            <motion.p\n              key=\"resolved\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.22, ease: ease.out, delay: reduce ? 0 : 0.14 }}\n              className=\"mt-1 flex h-6 min-w-0 items-center gap-1.5 text-[12px] text-fg-3\"\n            >\n              {replies.length > 0 && (\n                <>\n                  <span className=\"shrink-0 tabular\">\n                    {replies.length} {replies.length === 1 ? \"reply\" : \"replies\"}\n                  </span>\n                  <span aria-hidden className=\"text-fg-4\">\n                    ·\n                  </span>\n                </>\n              )}\n              <span className=\"truncate\">Resolved by {thread.resolvedBy?.id === currentUser.id ? \"you\" : (thread.resolvedBy?.name ?? \"a teammate\")}</span>\n              {thread.resolvedAt != null && (\n                <>\n                  <span aria-hidden className=\"text-fg-4\">\n                    ·\n                  </span>\n                  <TimeAgo date={thread.resolvedAt} className=\"shrink-0\" />\n                </>\n              )}\n            </motion.p>\n          ) : (\n            (replies.length > 0 || (!readOnly && !expanded)) && (\n              <motion.div\n                key=\"footer\"\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: 0.18 }}\n                className=\"mt-1 flex h-6 items-center gap-3\"\n              >\n                {replies.length > 0 && (\n                  <button\n                    type=\"button\"\n                    aria-expanded={expanded}\n                    aria-controls={regionId}\n                    onClick={() => setExpanded(!expanded)}\n                    className={cn(textButton, \"group/toggle\")}\n                  >\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      {expanded ? (\n                        <motion.span\n                          key=\"hide\"\n                          className=\"flex items-center gap-1\"\n                          initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(2px)\" }}\n                          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                          exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(2px)\", transition: { duration: 0.1 } }}\n                          transition={{ duration: 0.18, ease: ease.out }}\n                        >\n                          <ChevronUp size={14} />\n                          Hide replies\n                        </motion.span>\n                      ) : (\n                        <motion.span\n                          key=\"show\"\n                          className=\"flex items-center gap-2\"\n                          initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(2px)\" }}\n                          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                          exit={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(2px)\", transition: { duration: 0.1 } }}\n                          transition={{ duration: 0.18, ease: ease.out }}\n                        >\n                          <span className=\"flex -space-x-1\">\n                            {repliers.map((a) => (\n                              <Avatar key={a.id} author={a} size={18} className=\"shadow-[0_0_0_2px_var(--thread-bg),inset_0_0_0_1px_var(--line-2)]\" />\n                            ))}\n                          </span>\n                          <span className=\"inline-flex items-center gap-[0.25em] whitespace-nowrap text-fg-2 transition-colors duration-150 group-hover/toggle:text-fg\">\n                            <NumberFlow value={replies.length} className=\"tabular\" animated={!reduce} />\n                            {replies.length === 1 ? \"reply\" : \"replies\"}\n                          </span>\n                          {lastReply && (\n                            <span className=\"hidden whitespace-nowrap font-normal text-fg-3 min-[420px]:inline\">\n                              Last <TimeAgo date={lastReply.createdAt} />\n                            </span>\n                          )}\n                        </motion.span>\n                      )}\n                    </AnimatePresence>\n                  </button>\n                )}\n                {!readOnly && !expanded && (\n                  <button type=\"button\" onClick={openReply} className={textButton}>\n                    Reply\n                  </button>\n                )}\n              </motion.div>\n            )\n          )}\n        </AnimatePresence>\n      </div>\n\n      {/* Replies and the reply field fold open under the first comment. */}\n      <div id={regionId} className=\"col-start-2 row-start-3 min-w-0\">\n        <AnimatePresence initial={false}>\n          {open && (\n            <motion.div\n              key=\"region\"\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={{ height: 0, opacity: 0, transition: reduce ? { duration: 0.1 } : { height: { duration: 0.22, ease: ease.inOut }, opacity: { duration: 0.12 } } }}\n              transition={reduce ? { duration: 0.12, height: { duration: 0 } } : { height: { duration: 0.28, ease: ease.inOut }, opacity: { duration: 0.2, delay: 0.04 } }}\n              className=\"-mx-1 overflow-hidden px-1\"\n            >\n              <ul aria-label=\"Replies\" className=\"flex flex-col pt-1.5\">\n                <AnimatePresence initial={false}>\n                  {replies.map((r) => (\n                    <motion.li\n                      key={r.id}\n                      initial={{ height: 0, opacity: 0 }}\n                      animate={{ height: \"auto\", opacity: 1 }}\n                      exit={{ height: 0, opacity: 0 }}\n                      transition={reduce ? { duration: 0.12, height: { duration: 0 } } : { height: { duration: 0.24, ease: ease.inOut }, opacity: { duration: 0.16 } }}\n                      className=\"-mx-1 overflow-hidden px-1\"\n                    >\n                      <AutoHeight reduce={reduce}>\n                        <AnimatePresence initial={false} mode=\"popLayout\">\n                          {tombs.includes(r.id) ? (\n                            <Tombstone\n                              key=\"tomb\"\n                              label=\"Reply deleted\"\n                              className=\"my-1\"\n                              onUndo={() => undo(r.id)}\n                              onPause={() => pause(r.id)}\n                              onResume={() => arm(r.id)}\n                              {...swap}\n                            />\n                          ) : (\n                            <motion.div\n                              key=\"row\"\n                              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}\n                              animate={{ opacity: 1, y: 0 }}\n                              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                              transition={reduce ? { duration: 0.12 } : spring.soft}\n                              className=\"grid grid-cols-[20px_minmax(0,1fr)] gap-x-2 py-1.5\"\n                            >\n                              <CommentRow {...rowProps(r)} actions={actions} />\n                            </motion.div>\n                          )}\n                        </AnimatePresence>\n                      </AutoHeight>\n                    </motion.li>\n                  ))}\n                </AnimatePresence>\n              </ul>\n\n              {!readOnly && (\n                <ReplyField\n                  author={currentUser}\n                  value={draft}\n                  onValueChange={setDraft}\n                  onSend={send}\n                  placeholder={replyPlaceholder}\n                  focusSignal={focusReply}\n                  onEscape={() => {\n                    if (replies.length === 0 && !draft) setExpanded(false);\n                  }}\n                  reduce={reduce}\n                />\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n    </article>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * One comment: header, body (or the editor), and its menu\n * -----------------------------------------------------------------------------------------------*/\n\ntype RowActions = {\n  focused: () => void;\n  edit: (c: ThreadComment) => void;\n  draft: (draft: string) => void;\n  cancel: (c: ThreadComment) => void;\n  save: (c: ThreadComment, body: string) => void;\n  remove: (c: ThreadComment) => void;\n  retry: (c: ThreadComment) => void;\n  discard: (c: ThreadComment) => void;\n};\n\ntype RowProps = {\n  comment: ThreadComment;\n  own: boolean;\n  status?: Status;\n  editing: Editing;\n  reduce: boolean;\n  root?: boolean;\n  resolved?: boolean;\n  resolveSlot?: React.ReactNode;\n  focusMenu: boolean;\n  actions: RowActions;\n};\n\nfunction CommentRow({\n  comment: c,\n  own,\n  status,\n  editing,\n  reduce,\n  root = false,\n  resolved = false,\n  resolveSlot,\n  focusMenu,\n  actions,\n}: RowProps) {\n  const menuRef = useRef<HTMLButtonElement>(null);\n\n  // After an edit, a cancel or an undo, focus lands back on this comment's menu.\n  useEffect(() => {\n    if (!focusMenu) return;\n    menuRef.current?.focus({ preventScroll: true });\n    actions.focused();\n  }, [focusMenu, actions]);\n\n  return (\n    <>\n      <Avatar author={c.author} size={root ? 24 : 20} className={cn(!root && \"mt-px\", c.deleted && \"opacity-50\")} />\n      <div className=\"group/comment min-w-0\">\n        <div className={cn(\"relative flex min-w-0 items-center gap-1.5\", root ? \"h-6\" : \"h-[22px]\")}>\n          <span className={cn(\"min-w-0 truncate font-medium tracking-[-0.005em]\", c.deleted ? \"text-fg-3\" : \"text-fg\")}>{c.author.name}</span>\n          <span className=\"grid shrink-0 text-[12px] text-fg-3\">\n            <TimeAgo date={c.createdAt} className={cn(\"col-start-1 row-start-1 transition-opacity duration-200\", status === \"sending\" && \"opacity-0 delay-300\")} />\n            {/* \"Sending…\" only shows if it takes long enough to notice; a fast send never flashes it. */}\n            {status === \"sending\" && (\n              <motion.span\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                transition={{ duration: 0.2, delay: 0.3 }}\n                className=\"col-start-1 row-start-1 whitespace-nowrap\"\n              >\n                Sending…\n              </motion.span>\n            )}\n          </span>\n          {c.editedAt != null && !c.deleted && (\n            <span className=\"shrink-0 text-[12px] text-fg-4\" title={`Edited ${fullDate.format(toMs(c.editedAt))}`} suppressHydrationWarning>\n              (edited)\n            </span>\n          )}\n\n          <div\n            className={cn(\n              // Floats over the end of the header instead of reserving width, so names don't truncate early.\n              \"absolute right-0 top-1/2 flex -translate-y-1/2 items-center gap-0.5 pl-5\",\n              \"bg-[linear-gradient(to_right,transparent,var(--thread-bg)_16px)]\",\n              // With a pointer, the actions wait for hover or focus. The resolved tick and an open menu stay.\n              \"transition-opacity duration-150 pointer-fine:opacity-0\",\n              \"pointer-fine:group-hover/comment:opacity-100 pointer-fine:group-focus-within/comment:opacity-100\",\n              \"pointer-fine:has-[[aria-pressed=true]]:opacity-100 pointer-fine:has-data-popup-open:opacity-100\",\n            )}\n          >\n            {resolveSlot}\n            {own && !status && !editing && (\n              <Menu.Root>\n                <Menu.Trigger ref={menuRef} aria-label={`Actions for ${c.author.name}’s comment`} className={iconButton}>\n                  <MoreH />\n                </Menu.Trigger>\n                <Menu.Portal>\n                  <Menu.Positioner side=\"bottom\" align=\"end\" sideOffset={4} collisionPadding={8} className=\"z-(--z-dropdown)\">\n                    <Menu.Popup\n                      className={cn(\n                        \"min-w-36 rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none\",\n                        \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-160 ease-out-expo\",\n                        \"data-starting-style:-translate-y-1 data-starting-style:scale-96 data-starting-style:opacity-0\",\n                        \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100\",\n                        \"data-instant:transition-none motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:scale-100\",\n                      )}\n                    >\n                      <Menu.Item onClick={() => actions.edit(c)} className={menuItem}>\n                        <Pencil />\n                        Edit\n                      </Menu.Item>\n                      <Menu.Separator className=\"mx-2 my-1 h-px bg-line\" />\n                      <Menu.Item onClick={() => actions.remove(c)} className={cn(menuItem, \"text-danger data-highlighted:bg-danger-soft [&_svg]:text-danger\")}>\n                        <Trash />\n                        Delete\n                      </Menu.Item>\n                    </Menu.Popup>\n                  </Menu.Positioner>\n                </Menu.Portal>\n              </Menu.Root>\n            )}\n          </div>\n        </div>\n\n        {editing ? (\n          <EditField\n            value={editing.draft}\n            original={c.body}\n            error={editing.error}\n            onChange={actions.draft}\n            onCancel={() => actions.cancel(c)}\n            onSave={(body) => actions.save(c, body)}\n            reduce={reduce}\n          />\n        ) : c.deleted ? (\n          <p className=\"mt-0.5 text-[13px] leading-5 text-fg-3\">This comment was deleted.</p>\n        ) : (\n          <p\n            className={cn(\n              \"mt-0.5 whitespace-pre-wrap break-words text-[13px] leading-5 transition-[color,opacity] duration-200\",\n              // A resolved thread folds its first comment to one line; AutoHeight animates the fold.\n              root && resolved ? \"line-clamp-1 text-fg-3\" : \"text-fg\",\n              status === \"sending\" && \"opacity-60 delay-300\",\n            )}\n          >\n            {c.body}\n          </p>\n        )}\n\n        {status === \"failed\" && (\n          <div role=\"alert\" className=\"mt-1 flex h-6 items-center gap-3 text-[12px]\">\n            <span className=\"text-danger\">Couldn’t send.</span>\n            <button type=\"button\" onClick={() => actions.retry(c)} className={cn(textButton, \"text-fg-2\")}>\n              Retry\n            </button>\n            <button type=\"button\" onClick={() => actions.discard(c)} className={textButton}>\n              Discard\n            </button>\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n\nconst menuItem = cn(\n  \"flex h-8 cursor-default select-none items-center gap-2.5 rounded-lg px-2 text-[13px] text-fg outline-none pointer-coarse:h-10\",\n  \"transition-colors duration-75 data-highlighted:bg-fg/[0.06] [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-fg-3\",\n);\n\n/* -------------------------------------------------------------------------------------------------\n * Inline editor\n * -----------------------------------------------------------------------------------------------*/\n\nfunction EditField({\n  value,\n  original,\n  error,\n  onChange,\n  onCancel,\n  onSave,\n  reduce,\n}: {\n  value: string;\n  original: string;\n  error?: string;\n  onChange: (v: string) => void;\n  onCancel: () => void;\n  onSave: (v: string) => void;\n  reduce: boolean;\n}) {\n  const ref = useRef<HTMLTextAreaElement>(null);\n  const errorId = useId();\n  const apple = useSyncExternalStore(noop, isApple, () => true);\n  const trimmed = value.trim();\n  const canSave = trimmed.length > 0 && trimmed !== original.trim();\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    el.focus({ preventScroll: true });\n    el.setSelectionRange(el.value.length, el.value.length);\n  }, []);\n\n  return (\n    <motion.div initial={reduce ? { opacity: 0 } : { opacity: 0, y: -2 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.16, ease: ease.out }} className=\"mt-1\">\n      <textarea\n        ref={ref}\n        value={value}\n        aria-label=\"Edit comment\"\n        aria-invalid={!!error || undefined}\n        aria-describedby={error ? errorId : undefined}\n        rows={1}\n        onChange={(e) => onChange(e.target.value)}\n        onKeyDown={(e) => {\n          if (e.nativeEvent.isComposing) return;\n          if (e.key === \"Escape\") {\n            e.preventDefault();\n            e.stopPropagation();\n            onCancel();\n          }\n          if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n            e.preventDefault();\n            if (canSave) onSave(trimmed);\n          }\n        }}\n        className={cn(\n          \"block max-h-48 min-h-9 w-full resize-none rounded-lg border border-line-2 bg-frame px-2.5 py-1.5 text-base leading-5 text-fg outline-none field-sizing-content sm:text-[13px]\",\n          \"transition-[border-color,box-shadow] duration-150 focus:border-fg-4 focus:ring-3 focus:ring-fg/8\",\n          error && \"border-danger/60 focus:border-danger/70\",\n        )}\n      />\n      {error && (\n        <p id={errorId} className=\"mt-1 text-[12px] text-danger\">\n          {error}\n        </p>\n      )}\n      <div className=\"mt-1.5 flex items-center justify-end gap-1\">\n        <span className=\"mr-auto hidden text-[11px] text-fg-4 pointer-fine:inline\" suppressHydrationWarning>\n          Esc to cancel · {apple ? \"⌘↵\" : \"Ctrl+Enter\"} to save\n        </span>\n        <button type=\"button\" onClick={onCancel} className={smallButton(false)}>\n          Cancel\n        </button>\n        <button type=\"button\" disabled={!canSave} onClick={() => onSave(trimmed)} className={smallButton(true)}>\n          Save\n        </button>\n      </div>\n    </motion.div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Deleted, with undo\n * -----------------------------------------------------------------------------------------------*/\n\ntype TombstoneProps = React.ComponentProps<typeof motion.div> & {\n  label: string;\n  onUndo: () => void;\n  onPause: () => void;\n  onResume: () => void;\n};\n\nfunction Tombstone({ label, onUndo, onPause, onResume, className, ...rest }: TombstoneProps) {\n  const undoRef = useRef<HTMLButtonElement>(null);\n  // The menu that deleted it is gone, so focus comes here instead of falling to the page.\n  useEffect(() => {\n    undoRef.current?.focus({ preventScroll: true });\n  }, []);\n\n  return (\n    <motion.div\n      onPointerEnter={onPause}\n      onPointerLeave={onResume}\n      onFocus={onPause}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) onResume();\n      }}\n      className={cn(\"flex h-8 min-w-0 items-center gap-2 rounded-lg border border-dashed border-line-2 pl-2.5 pr-1 text-[12.5px] text-fg-3\", className)}\n      {...rest}\n    >\n      <Trash size={14} className=\"shrink-0 text-fg-4\" />\n      <span className=\"min-w-0 flex-1 truncate\">{label}</span>\n      <button ref={undoRef} type=\"button\" onClick={onUndo} className={cn(smallButton(false), \"h-6 text-fg\")}>\n        Undo\n      </button>\n    </motion.div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Reply field\n * -----------------------------------------------------------------------------------------------*/\n\nfunction ReplyField({\n  author,\n  value,\n  onValueChange,\n  onSend,\n  onEscape,\n  placeholder,\n  focusSignal,\n  reduce,\n}: {\n  author: CommentAuthor;\n  value: string;\n  onValueChange: (v: string) => void;\n  onSend: () => void;\n  onEscape: () => void;\n  placeholder: string;\n  focusSignal: number;\n  reduce: boolean;\n}) {\n  const ref = useRef<HTMLTextAreaElement>(null);\n  const [sent, setSent] = useState(0);\n  const empty = value.trim().length === 0;\n\n  useEffect(() => {\n    if (focusSignal) ref.current?.focus();\n  }, [focusSignal]);\n\n  const submit = () => {\n    if (empty) return;\n    setSent((n) => n + 1);\n    onSend();\n  };\n\n  return (\n    <div className=\"grid grid-cols-[20px_minmax(0,1fr)] gap-x-2 pb-1 pt-1.5\">\n      <Avatar author={author} size={20} className=\"mt-[5px]\" />\n      <div\n        className={cn(\n          \"flex min-w-0 items-end gap-1 rounded-lg border border-line-2 bg-frame py-[3px] pl-2.5 pr-[3px]\",\n          \"transition-[border-color,box-shadow] duration-150 hover:border-fg-4 focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/8\",\n        )}\n      >\n        <textarea\n          ref={ref}\n          rows={1}\n          value={value}\n          placeholder={placeholder}\n          aria-label=\"Reply\"\n          enterKeyHint=\"send\"\n          onChange={(e) => onValueChange(e.target.value)}\n          onKeyDown={(e) => {\n            if (e.nativeEvent.isComposing) return;\n            if (e.key === \"Enter\" && !e.shiftKey) {\n              e.preventDefault();\n              submit();\n            }\n            if (e.key === \"Escape\") onEscape();\n          }}\n          className=\"max-h-40 min-h-6 flex-1 resize-none self-center bg-transparent py-0.5 text-base leading-5 text-fg outline-none field-sizing-content placeholder:text-fg-4 sm:text-[13px]\"\n        />\n        <button\n          type=\"button\"\n          aria-label=\"Send reply\"\n          aria-keyshortcuts=\"Enter\"\n          disabled={empty}\n          onClick={submit}\n          className={cn(\n            \"relative grid size-6 shrink-0 place-items-center rounded-md outline-none\",\n            \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n            \"transition-[background-color,color,scale] duration-150 ease-out-quart active:scale-[0.9] active:duration-75\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n            empty ? \"bg-fg/8 text-fg-4\" : \"bg-fg text-frame hover:bg-fg/90\",\n          )}\n        >\n          {/* On send the arrow leaves upward and a fresh one rises into its place. */}\n          <span className=\"grid size-full place-items-center overflow-hidden rounded-[inherit]\">\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={sent}\n              className=\"grid place-items-center\"\n              initial={reduce ? { opacity: 0 } : { y: 12, opacity: 0 }}\n              animate={{ y: 0, opacity: 1 }}\n              exit={reduce ? { opacity: 0 } : { y: -14, opacity: 0, transition: { duration: 0.16, ease: ease.in } }}\n              transition={reduce ? { duration: 0.1 } : { ...spring.snappy, delay: 0.08 }}\n            >\n              <ArrowUp size={14} />\n            </motion.span>\n          </AnimatePresence>\n          </span>\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/comment-thread"
}
