{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"doc-comments","type":"registry:component","title":"Doc comments","description":"Review threads beside the text they quote: the one you choose lines up with its line, and the rest make way.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency"],"files":[{"path":"registry/doc-comments.tsx","type":"registry:component","content":"// Hairline UI · doc-comments · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Check, MessageSquare, MessageSquarePlus } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\n\n/* ─────────────────────────────────────────────────────────\n * DOC COMMENTS: review threads beside the text they're about\n *\n *   margin    every thread sits level with the line it quotes;\n *             threads that would overlap stack downwards\n *   focus     choose a thread (or its highlighted text) and it\n *             lines up exactly with its line while the others\n *             slide up and down out of its way\n *   comment   select text and a Comment pill appears in the\n *             margin beside it; the pill grows into the\n *             composer, and the thread takes its place\n *   reply     replies rise in and the count rolls; a reply that\n *             fails keeps its words and says Try again\n *   resolve   the highlight fades and the card holds \"Resolved ·\n *             Undo\" over a draining hairline, then folds away\n *   narrow    no margin: a count sits at the end of the line,\n *             and the paragraph opens a gap for the thread under\n *             itself\n *\n * Threads find their text by quoting it ({ quote, prefix,\n * suffix }), so they survive edits around them. Highlights are\n * drawn under the text, never written into it: any content works.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CommentAuthor {\n  id: string;\n  name: string;\n  image?: string;\n}\n\nexport interface DocComment {\n  id: string;\n  author: CommentAuthor;\n  body: string;\n  createdAt: Date | string | number;\n}\n\n/** The text a thread is about: the words themselves, and a little of what comes before and after to tell repeats apart. */\nexport interface CommentAnchor {\n  quote: string;\n  prefix?: string;\n  suffix?: string;\n}\n\nexport interface DocThread {\n  id: string;\n  anchor: CommentAnchor;\n  comments: DocComment[];\n  resolved?: boolean;\n}\n\nexport interface DocCommentsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\"> {\n  /** The document. Any markup: it's read, never changed. */\n  children: React.ReactNode;\n  threads?: DocThread[];\n  defaultThreads?: DocThread[];\n  onThreadsChange?: (threads: DocThread[]) => void;\n  /** Who is commenting. */\n  user: CommentAuthor;\n  /** Turns on commenting on a selection. Resolve with the stored thread to take its ids; throw to keep the words and say Try again. */\n  onCreate?: (anchor: CommentAnchor, body: string) => Promise<DocThread | void> | DocThread | void;\n  /** Turns on replies. Resolve with the stored comment; throw to keep the words and say Try again. */\n  onReply?: (thread: DocThread, body: string) => Promise<DocComment | void> | DocComment | void;\n  /** Turns on Resolve. Runs once the chance to undo is over; throw to open the thread again. */\n  onResolve?: (thread: DocThread) => Promise<void> | void;\n  undoFor?: number;\n  locales?: string;\n  label?: string;\n}\n\ntype Box = { top: number; left: number; width: number; height: number };\ntype Found = { id: string; rects: Box[]; top: number; bottom: number; order: number; end: [Text, number] };\ntype Draft = { id: string; anchor: CommentAnchor; status: ActionStatus };\ntype Pending = { anchor: CommentAnchor; top: number; bottom: number; left: number };\ntype TextIndex = { text: string; nodes: { node: Text; start: number }[] };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst GLIDE = \"cubic-bezier(0.77,0,0.175,1)\";\nconst GAP = 8;\nconst MARGIN_W = 256;\nconst NARROW = 620;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst NO_THREADS: DocThread[] = [];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nlet seq = 0;\nconst newId = (p: string) => `${p}${Date.now().toString(36)}${(seq++).toString(36)}`;\n\nfunction indexText(root: HTMLElement): TextIndex {\n  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);\n  const nodes: TextIndex[\"nodes\"] = [];\n  let text = \"\";\n  for (let n = walker.nextNode() as Text | null; n; n = walker.nextNode() as Text | null) {\n    nodes.push({ node: n, start: text.length });\n    text += n.data;\n  }\n  return { text, nodes };\n}\n\n/** The text node and offset at a place in the text; an end prefers the node it closes. */\nfunction locate(index: TextIndex, offset: number, isEnd: boolean): [Text, number] | null {\n  for (const { node, start } of index.nodes) {\n    const end = start + node.data.length;\n    if (isEnd ? offset > start && offset <= end : offset >= start && offset < end) return [node, offset - start];\n  }\n  return null;\n}\n\n/** Where the quote is: the occurrence whose surroundings match the anchor best. */\nfunction findAnchor({ text }: TextIndex, anchor: CommentAnchor): [number, number] | null {\n  const q = anchor.quote;\n  if (!q) return null;\n  const p = anchor.prefix ?? \"\";\n  const s = anchor.suffix ?? \"\";\n  let best = -1;\n  let bestScore = -1;\n  for (let at = text.indexOf(q); at >= 0; at = text.indexOf(q, at + 1)) {\n    let score = 0;\n    for (let k = 1; k <= p.length && text[at - k] === p[p.length - k]; k++) score++;\n    for (let k = 0; k < s.length && text[at + q.length + k] === s[k]; k++) score++;\n    if (score > bestScore) {\n      best = at;\n      bestScore = score;\n    }\n  }\n  return best < 0 ? null : [best, best + q.length];\n}\n\n/** One box per line of a range, in the content's coordinates. */\nfunction lineBoxes(range: Range, origin: DOMRect): Box[] {\n  const out: Box[] = [];\n  for (const r of range.getClientRects()) {\n    if (r.width < 0.5 || r.height < 1) continue;\n    const top = r.top - origin.top;\n    const last = out[out.length - 1];\n    if (last && Math.abs(last.top - top) < 3) {\n      const right = Math.max(last.left + last.width, r.right - origin.left);\n      last.left = Math.min(last.left, r.left - origin.left);\n      last.width = right - last.left;\n      last.height = Math.max(last.height, r.height);\n    } else out.push({ top, left: r.left - origin.left, width: r.width, height: r.height });\n  }\n  return out;\n}\n\n/** Cards in the margin: each wants to sit at its line; the chosen one does, the rest make way above and below it. */\nfunction solve(items: { id: string; want: number }[], height: (id: string) => number, active: string | null) {\n  const pos = new Map<string, number>();\n  const down = (from: number, floor: number) => {\n    let y = floor;\n    for (let i = from; i < items.length; i++) {\n      const top = Math.max(items[i].want, y);\n      pos.set(items[i].id, top);\n      y = top + height(items[i].id) + GAP;\n    }\n  };\n  const a = items.findIndex((i) => i.id === active);\n  if (a < 0) {\n    down(0, 0);\n    return pos;\n  }\n  pos.set(items[a].id, items[a].want);\n  down(a + 1, items[a].want + height(items[a].id) + GAP);\n  let ceiling = items[a].want - GAP;\n  for (let i = a - 1; i >= 0; i--) {\n    const top = Math.min(items[i].want, ceiling - height(items[i].id));\n    pos.set(items[i].id, top);\n    ceiling = top - GAP;\n  }\n  // No room above the chosen one: stack from the top instead.\n  if (items.length && (pos.get(items[0].id) ?? 0) < 0) down(0, 0);\n  return pos;\n}\n\nfunction Avatar({ author }: { author: CommentAuthor }) {\n  return author.image ? (\n    // eslint-disable-next-line @next/next/no-img-element\n    <img src={author.image} alt=\"\" className=\"size-5 shrink-0 rounded-full object-cover shadow-[0_0_0_1px_var(--border)]\" />\n  ) : (\n    <span aria-hidden=\"true\" className=\"flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[9.5px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\">\n      {author.name\n        .split(/\\s+/)\n        .map((p) => p[0])\n        .slice(0, 2)\n        .join(\"\")}\n    </span>\n  );\n}\n\n/** A textarea that grows with what's typed. */\nfunction Grow({ value, onChange, onKeyDown, placeholder, label, autoFocus }: { value: string; onChange: (v: string) => void; onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; placeholder: string; label: string; autoFocus?: boolean }) {\n  const ref = React.useRef<HTMLTextAreaElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    el.style.height = \"auto\";\n    el.style.height = `${el.scrollHeight}px`;\n  }, [value]);\n  React.useEffect(() => {\n    if (autoFocus) ref.current?.focus({ preventScroll: true });\n  }, [autoFocus]);\n  return (\n    <textarea\n      ref={ref}\n      rows={1}\n      value={value}\n      aria-label={label}\n      placeholder={placeholder}\n      onChange={(e) => onChange(e.target.value)}\n      onKeyDown={onKeyDown}\n      className=\"block max-h-40 w-full resize-none bg-transparent text-[13px] leading-[1.45] outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]\"\n    />\n  );\n}\n\ntype CardActions = {\n  activate: (id: string) => void;\n  reply: (thread: DocThread, body: string) => Promise<boolean>;\n  resolve: (thread: DocThread) => void;\n  undo: (thread: DocThread) => void;\n  finish: (thread: DocThread) => void;\n  create: (draft: Draft, body: string) => void;\n  cancel: () => void;\n  height: (id: string, h: number) => void;\n};\n\n/** One thread (or the draft of one): its outer box eases to the height of what's inside, measured once per change. */\nfunction Card({\n  id,\n  thread,\n  draft,\n  active,\n  resolving,\n  canReply,\n  canResolve,\n  user,\n  when,\n  note,\n  holdKey,\n  undoFor,\n  grow,\n  reduced,\n  actions,\n}: {\n  id: string;\n  thread?: DocThread;\n  draft?: Draft;\n  active: boolean;\n  resolving: number | null;\n  canReply: boolean;\n  canResolve: boolean;\n  user: CommentAuthor;\n  when: (d: DocComment[\"createdAt\"]) => string;\n  note?: string;\n  holdKey?: number;\n  undoFor: number;\n  grow: boolean;\n  reduced: boolean;\n  actions: React.RefObject<CardActions>;\n}) {\n  const outer = React.useRef<HTMLElement>(null);\n  const inner = React.useRef<HTMLDivElement>(null);\n  const [h, setH] = React.useState<number | null>(null);\n  const [text, setText] = React.useState(\"\");\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const [hold, setHold] = React.useState(false);\n  const mounted = React.useRef(false);\n  // A draft that became its thread: the composer starts empty for replies.\n  const wasDraft = React.useRef(!!draft);\n  if (wasDraft.current && !draft) {\n    wasDraft.current = false;\n    if (text) setText(\"\");\n  }\n\n  React.useLayoutEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const measure = () => {\n      const next = el.offsetHeight;\n      setH((p) => (p === next ? p : next));\n      actions.current.height(id, next);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [id, actions]);\n\n  // The Comment pill grows into the composer: one clip, once.\n  React.useLayoutEffect(() => {\n    if (grow && !reduced)\n      outer.current?.animate(\n        [\n          { clipPath: \"inset(0 calc(100% - 104px) calc(100% - 28px) 0 round 14px)\", opacity: 0.7 },\n          { clipPath: \"inset(0 0 0 0 round 14px)\", opacity: 1 },\n        ],\n        { duration: 320, easing: EASE },\n      );\n    mounted.current = true;\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const comments = thread?.comments ?? [];\n  const first = comments[0];\n  const replies = Math.max(0, comments.length - 1);\n  const quote = draft?.anchor.quote ?? thread?.anchor.quote ?? \"\";\n  const send = async () => {\n    const body = text.trim();\n    if (!body || status === \"pending\") return;\n    if (draft) {\n      actions.current.create(draft, body);\n      return;\n    }\n    if (!thread) return;\n    setStatus(\"pending\");\n    const ok = await actions.current.reply(thread, body);\n    setStatus(ok ? \"success\" : \"error\");\n    if (ok) setText(\"\");\n  };\n  const keys = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n      e.preventDefault();\n      send();\n    }\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      e.stopPropagation();\n      if (draft) actions.current.cancel();\n      else (outer.current as HTMLElement | null)?.focus();\n    }\n  };\n\n  return (\n    <article\n      ref={outer}\n      tabIndex={draft ? -1 : 0}\n      onKeyDown={(e) => {\n        if (e.target !== e.currentTarget || !thread) return;\n        if (e.key === \"Enter\" || e.key === \" \") {\n          e.preventDefault();\n          actions.current.activate(thread.id);\n        }\n        if (e.key === \"Escape\") actions.current.activate(\"\");\n      }}\n      aria-label={draft ? `New comment on “${quote}”` : `${first?.author.name ?? \"Comment\"} on “${quote}”${replies ? `, ${replies} ${replies === 1 ? \"reply\" : \"replies\"}` : \"\"}`}\n      onClick={() => thread && !active && actions.current.activate(thread.id)}\n      className={`overflow-clip rounded-[14px] bg-background text-left ${active ? \"\" : \"cursor-pointer\"} ${FOCUS}`}\n      style={{\n        height: h ?? undefined,\n        boxShadow: active ? \"inset 0 0 0 1px var(--ring), 0 0 0 0 transparent\" : \"inset 0 0 0 1px var(--border)\",\n        transition: reduced || h === null ? \"none\" : `height 260ms ${EASE}, box-shadow 200ms ${EASE}`,\n      }}\n    >\n      <div ref={inner} className=\"p-3\">\n        {draft ? (\n          <>\n            <div className=\"flex items-center gap-2 text-[12.5px] font-medium\">\n              <Avatar author={user} />\n              {user.name}\n            </div>\n            <div className=\"mt-2\">\n              <Grow value={text} onChange={setText} onKeyDown={keys} placeholder=\"Add a comment…\" label={`Comment on “${quote}”`} autoFocus />\n            </div>\n            <div className=\"mt-2.5 flex items-center justify-end gap-1\">\n              <button type=\"button\" onClick={() => actions.current.cancel()} className={`inline-flex h-7 cursor-pointer items-center rounded-full px-2.5 text-[12.5px] text-muted-foreground hover:bg-accent hover:text-foreground ${FOCUS}`}>\n                Cancel\n              </button>\n              <StatusButton size=\"sm\" status={draft.status} labels={{ idle: \"Comment\", pending: \"Posting\", success: \"Posted\", error: \"Try again\" }} disabled={!text.trim()} onClick={send} />\n            </div>\n          </>\n        ) : resolving !== null && thread ? (\n          <div className=\"relative flex h-7 items-center gap-2 text-[12.5px]\" onPointerEnter={() => setHold(true)} onPointerLeave={() => setHold(false)}>\n            <span className=\"flex size-5 items-center justify-center text-emerald-600 dark:text-emerald-400\" aria-hidden=\"true\">\n              <Check className=\"size-3.5\" strokeWidth={2} />\n            </span>\n            <span className=\"min-w-0 flex-1 truncate text-muted-foreground\">Resolved</span>\n            <button\n              type=\"button\"\n              onClick={(e) => {\n                e.stopPropagation();\n                actions.current.undo(thread);\n              }}\n              className={`inline-flex h-7 shrink-0 cursor-pointer items-center rounded-full px-2.5 font-medium hover:bg-accent ${FOCUS}`}\n            >\n              Undo\n            </button>\n            <span\n              key={holdKey}\n              aria-hidden=\"true\"\n              className=\"absolute inset-x-0 -bottom-3 h-px origin-left bg-foreground/40\"\n              style={{ animation: `ui-progress ${undoFor}ms linear reverse forwards`, animationPlayState: hold ? \"paused\" : \"running\" }}\n              onAnimationEnd={() => actions.current.finish(thread)}\n            />\n          </div>\n        ) : (\n          <>\n            {comments.map((c, i) => (\n              <CommentItem key={c.id} comment={c} when={when} clamp={!active && i === 0} hidden={!active && i > 0} enter={mounted.current} reduced={reduced}>\n                {i === 0 && active && canResolve && thread && (\n                  <button\n                    type=\"button\"\n                    aria-label={`Resolve the thread on “${quote}”`}\n                    title=\"Resolve\"\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      actions.current.resolve(thread);\n                    }}\n                    className={`-my-1 -mr-1 ml-auto flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${FOCUS}`}\n                  >\n                    <Check className=\"size-3.5\" strokeWidth={2} />\n                  </button>\n                )}\n              </CommentItem>\n            ))}\n            {!active && replies > 0 && (\n              <p className=\"mt-1.5 text-[12px] tabular-nums text-muted-foreground\">\n                <NumberRoll value={replies} /> {replies === 1 ? \"reply\" : \"replies\"}\n              </p>\n            )}\n            {active && canReply && thread && (\n              <div className=\"mt-2.5 border-t border-border pt-2.5\" onClick={(e) => e.stopPropagation()}>\n                <Grow value={text} onChange={(v) => (setText(v), status === \"error\" && setStatus(\"idle\"))} onKeyDown={keys} placeholder=\"Reply…\" label={`Reply to ${first?.author.name ?? \"the thread\"}`} />\n                <div className=\"grid transition-[grid-template-rows] duration-200\" style={{ gridTemplateRows: text.trim() || status !== \"idle\" ? \"1fr\" : \"0fr\", transitionTimingFunction: EASE }}>\n                  <div className=\"min-h-0 overflow-hidden\">\n                    <div className=\"flex justify-end pt-2\">\n                      <StatusButton size=\"sm\" status={status} labels={{ idle: \"Reply\", pending: \"Sending\", success: \"Sent\", error: \"Try again\" }} onClick={send} onReset={() => setStatus(\"idle\")} resetAfter={1200} />\n                    </div>\n                  </div>\n                </div>\n              </div>\n            )}\n            {note && <p className=\"mt-2 text-[12px] text-red-600 dark:text-red-400\">{note}</p>}\n          </>\n        )}\n      </div>\n    </article>\n  );\n}\n\nfunction CommentItem({ comment, when, clamp, hidden, enter, reduced, children }: { comment: DocComment; when: (d: DocComment[\"createdAt\"]) => string; clamp: boolean; hidden: boolean; enter: boolean; reduced: boolean; children?: React.ReactNode }) {\n  // Decided once: a reply that arrives after the card is on screen rises in; the ones it opened with don't.\n  const [isNew] = React.useState(enter);\n  return (\n    <div\n      className={hidden ? \"hidden\" : \"mt-2.5 first:mt-0\"}\n      ref={(el) => {\n        if (!el || !isNew || reduced || el.dataset.in) return;\n        el.dataset.in = \"1\";\n        el.animate(\n          [\n            { opacity: 0, transform: \"translateY(4px)\", filter: \"blur(3px)\" },\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n          ],\n          { duration: 320, easing: EASE, fill: \"backwards\" },\n        );\n      }}\n    >\n      <div className=\"flex items-center gap-2 text-[12.5px]\">\n        <Avatar author={comment.author} />\n        <span className=\"min-w-0 truncate font-medium\">{comment.author.name}</span>\n        <span className=\"shrink-0 text-[11.5px] text-muted-foreground\">{when(comment.createdAt)}</span>\n        {children}\n      </div>\n      <p className={`mt-1 whitespace-pre-wrap break-words text-[13px] leading-[1.45] ${clamp ? \"line-clamp-3\" : \"\"}`}>{comment.body}</p>\n    </div>\n  );\n}\n\nconst MemoCard = React.memo(Card);\n\nexport function DocComments({\n  children,\n  threads: controlled,\n  defaultThreads = NO_THREADS,\n  onThreadsChange,\n  user,\n  onCreate,\n  onReply,\n  onResolve,\n  undoFor = 4000,\n  locales,\n  label = \"Comments\",\n  className = \"\",\n  ...props\n}: DocCommentsProps) {\n  const reduced = useReducedMotion();\n  const [own, setOwn] = React.useState<DocThread[]>(defaultThreads);\n  const threads = controlled ?? own;\n  const threadsRef = React.useRef(threads);\n  threadsRef.current = threads;\n  const [active, setActive] = React.useState<string | null>(null);\n  const [hover, setHover] = React.useState<string | null>(null);\n  const [found, setFound] = React.useState<Found[]>([]);\n  const [heights, setHeights] = React.useState<ReadonlyMap<string, number>>(() => new Map());\n  const [draft, setDraft] = React.useState<Draft | null>(null);\n  const [pending, setPending] = React.useState<Pending | null>(null);\n  const [resolving, setResolving] = React.useState<Record<string, number>>({});\n  const [notes, setNotes] = React.useState<Record<string, string>>({});\n  const [narrow, setNarrow] = React.useState(false);\n  const [placed, setPlaced] = React.useState(false);\n  const [now, setNow] = React.useState<number | null>(null);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const [growing, setGrowing] = React.useState<string | null>(null);\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const innerRef = React.useRef<HTMLDivElement>(null);\n  const indexRef = React.useRef<TextIndex | null>(null);\n  const frame = React.useRef(0);\n  const pendingRef = React.useRef<Pending | null>(null);\n  pendingRef.current = pending;\n  const draftRef = React.useRef<Draft | null>(null);\n  draftRef.current = draft;\n  const heightsRef = React.useRef(new Map<string, number>());\n  const focusCard = React.useRef<string | null>(null);\n\n  const say = (text: string) => setAnnounce((a) => (a === text ? `${text} ` : text));\n  const commit = (next: DocThread[]) => {\n    threadsRef.current = next;\n    if (controlled === undefined) setOwn(next);\n    onThreadsChange?.(next);\n  };\n\n  React.useEffect(() => {\n    setNow(Date.now());\n    const t = window.setInterval(() => setNow(Date.now()), 60_000);\n    return () => window.clearInterval(t);\n  }, []);\n  const dateFmt = React.useMemo(() => new Intl.DateTimeFormat(locales, { month: \"short\", day: \"numeric\" }), [locales]);\n  const when = React.useCallback(\n    (d: DocComment[\"createdAt\"]) => {\n      if (now === null) return \"\";\n      const t = new Date(d).getTime();\n      const s = (now - t) / 1000;\n      if (s < 60) return \"now\";\n      if (s < 3600) return `${Math.floor(s / 60)}m`;\n      if (s < 86400) return `${Math.floor(s / 3600)}h`;\n      if (s < 172800) return \"Yesterday\";\n      return dateFmt.format(t);\n    },\n    [now, dateFmt],\n  );\n\n  // ── Where each thread's text is ────────────────────────\n  const shown = React.useMemo(() => threads.filter((t) => !t.resolved), [threads]);\n  const measure = React.useCallback(() => {\n    const inner = innerRef.current;\n    if (!inner) return;\n    const index = (indexRef.current ??= indexText(inner));\n    const origin = inner.getBoundingClientRect();\n    const out: Found[] = [];\n    const anchors: { id: string; anchor: CommentAnchor }[] = threadsRef.current.filter((t) => !t.resolved).map((t) => ({ id: t.id, anchor: t.anchor }));\n    if (draftRef.current) anchors.push({ id: draftRef.current.id, anchor: draftRef.current.anchor });\n    for (const { id, anchor } of anchors) {\n      const span = findAnchor(index, anchor);\n      const a = span && locate(index, span[0], false);\n      const b = span && locate(index, span[1], true);\n      if (!span || !a || !b) continue;\n      const range = document.createRange();\n      range.setStart(a[0], a[1]);\n      range.setEnd(b[0], b[1]);\n      const rects = lineBoxes(range, origin);\n      if (!rects.length) continue;\n      const last = rects[rects.length - 1];\n      out.push({ id, rects, top: rects[0].top, bottom: last.top + last.height, order: span[0], end: b });\n    }\n    out.sort((x, y) => x.order - y.order);\n    setFound((prev) =>\n      prev.length === out.length && prev.every((p, i) => p.id === out[i].id && p.rects.length === out[i].rects.length && p.rects.every((r, k) => Math.abs(r.top - out[i].rects[k].top) < 0.5 && Math.abs(r.left - out[i].rects[k].left) < 0.5 && Math.abs(r.width - out[i].rects[k].width) < 0.5))\n        ? prev\n        : out,\n    );\n  }, []);\n  const schedule = React.useCallback(() => {\n    cancelAnimationFrame(frame.current);\n    frame.current = requestAnimationFrame(measure);\n  }, [measure]);\n\n  React.useLayoutEffect(() => {\n    measure();\n  }, [threads, draft, measure]);\n\n  React.useEffect(() => {\n    const inner = innerRef.current;\n    const root = rootRef.current;\n    if (!inner || !root) return;\n    const resize = new ResizeObserver(() => {\n      setNarrow(root.offsetWidth < NARROW);\n      schedule();\n    });\n    resize.observe(inner);\n    resize.observe(root);\n    const mutate = new MutationObserver(() => {\n      indexRef.current = null;\n      schedule();\n    });\n    mutate.observe(inner, { childList: true, subtree: true, characterData: true });\n    document.fonts?.ready.then(schedule);\n    return () => {\n      resize.disconnect();\n      mutate.disconnect();\n      cancelAnimationFrame(frame.current);\n    };\n  }, [schedule]);\n\n  // ── The margin ─────────────────────────────────────────\n  const heightOf = React.useCallback((id: string) => heights.get(id) ?? 72, [heights]);\n  const positions = React.useMemo(() => solve(found.map((f) => ({ id: f.id, want: Math.max(0, f.top - 10) })), heightOf, active), [found, heightOf, active]);\n  const marginHeight = found.reduce((m, f) => Math.max(m, (positions.get(f.id) ?? 0) + heightOf(f.id)), 0);\n  // Cards slide only once they've been placed: the first layout lands without a glide.\n  React.useEffect(() => {\n    if (!placed && found.length && heights.size) {\n      const t = requestAnimationFrame(() => setPlaced(true));\n      return () => cancelAnimationFrame(t);\n    }\n  }, [placed, found.length, heights.size]);\n\n  // ── Selecting text to comment on ───────────────────────\n  React.useEffect(() => {\n    if (!onCreate) return;\n    let raf = 0;\n    let clear = 0;\n    const read = () => {\n      const inner = innerRef.current;\n      const sel = window.getSelection();\n      if (!inner || draftRef.current) return;\n      const range = sel && sel.rangeCount && !sel.isCollapsed ? sel.getRangeAt(0) : null;\n      if (!range || !inner.contains(range.commonAncestorContainer)) {\n        // Hidden a moment later, so a tap on the pill (which can take the selection away first) still lands.\n        window.clearTimeout(clear);\n        clear = window.setTimeout(() => setPending(null), 220);\n        return;\n      }\n      window.clearTimeout(clear);\n      const before = document.createRange();\n      before.setStart(inner, 0);\n      before.setEnd(range.startContainer, range.startOffset);\n      const text = (indexRef.current ??= indexText(inner)).text;\n      let s = before.toString().length;\n      let e = s + range.toString().length;\n      while (s < e && /\\s/.test(text[s])) s++;\n      while (e > s && /\\s/.test(text[e - 1])) e--;\n      if (e <= s) return setPending(null);\n      const origin = inner.getBoundingClientRect();\n      const boxes = lineBoxes(range, origin);\n      if (!boxes.length) return;\n      const last = boxes[boxes.length - 1];\n      setPending({\n        anchor: { quote: text.slice(s, e), prefix: text.slice(Math.max(0, s - 32), s), suffix: text.slice(e, e + 32) },\n        top: boxes[0].top,\n        bottom: last.top + last.height,\n        left: boxes[0].left,\n      });\n    };\n    const onChange = () => {\n      cancelAnimationFrame(raf);\n      raf = requestAnimationFrame(read);\n    };\n    document.addEventListener(\"selectionchange\", onChange);\n    return () => {\n      document.removeEventListener(\"selectionchange\", onChange);\n      cancelAnimationFrame(raf);\n      window.clearTimeout(clear);\n    };\n  }, [onCreate]);\n\n  const openDraft = React.useCallback(() => {\n    const p = pendingRef.current;\n    if (!p || draftRef.current) return;\n    const d = { id: newId(\"t\"), anchor: p.anchor, status: \"idle\" as ActionStatus };\n    setDraft(d);\n    setGrowing(d.id);\n    setActive(d.id);\n    setPending(null);\n    window.getSelection()?.removeAllRanges();\n  }, []);\n\n  // Ctrl+Alt+M comments on the selection, as in most editors.\n  React.useEffect(() => {\n    if (!onCreate) return;\n    const onKey = (e: KeyboardEvent) => {\n      if ((e.ctrlKey || e.metaKey) && e.altKey && e.code === \"KeyM\" && pendingRef.current) {\n        e.preventDefault();\n        openDraft();\n      }\n    };\n    document.addEventListener(\"keydown\", onKey);\n    return () => document.removeEventListener(\"keydown\", onKey);\n  }, [onCreate, openDraft]);\n\n  // A press outside the comments lets the chosen thread go (a draft with words in it stays).\n  React.useEffect(() => {\n    if (!active) return;\n    const onDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node) && !draftRef.current) setActive(null);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [active]);\n\n  // ── Threads ────────────────────────────────────────────\n  const hit = (e: React.PointerEvent | React.MouseEvent) => {\n    const inner = innerRef.current;\n    if (!inner) return null;\n    const o = inner.getBoundingClientRect();\n    const x = e.clientX - o.left;\n    const y = e.clientY - o.top;\n    for (let i = found.length - 1; i >= 0; i--) if (found[i].rects.some((r) => x >= r.left - 2 && x <= r.left + r.width + 2 && y >= r.top - 2 && y <= r.top + r.height + 2)) return found[i].id;\n    return null;\n  };\n\n  const create = async (d: Draft, body: string) => {\n    setDraft({ ...d, status: \"pending\" });\n    const local: DocThread = { id: d.id, anchor: d.anchor, comments: [{ id: newId(\"c\"), author: user, body, createdAt: new Date() }] };\n    try {\n      const saved = (await onCreate?.(d.anchor, body)) || local;\n      commit([...threadsRef.current, saved]);\n      setDraft(null);\n      setActive(saved.id);\n      focusCard.current = saved.id;\n      say(\"Comment added.\");\n    } catch {\n      setDraft((x) => (x?.id === d.id ? { ...x, status: \"error\" } : x));\n      say(\"Couldn't add the comment. Your words are still there.\");\n    }\n  };\n\n  const reply = async (thread: DocThread, body: string) => {\n    try {\n      const saved = (await onReply?.(thread, body)) || { id: newId(\"c\"), author: user, body, createdAt: new Date() };\n      commit(threadsRef.current.map((t) => (t.id === thread.id ? { ...t, comments: [...t.comments, saved] } : t)));\n      say(\"Reply added.\");\n      return true;\n    } catch {\n      say(\"Couldn't send the reply. Your words are still there.\");\n      return false;\n    }\n  };\n\n  const finish = async (thread: DocThread) => {\n    setResolving(({ [thread.id]: _, ...rest }) => rest);\n    setActive((a) => (a === thread.id ? null : a));\n    commit(threadsRef.current.map((t) => (t.id === thread.id ? { ...t, resolved: true } : t)));\n    try {\n      await onResolve?.(thread);\n    } catch {\n      commit(threadsRef.current.map((t) => (t.id === thread.id ? { ...t, resolved: false } : t)));\n      setNotes((n) => ({ ...n, [thread.id]: \"Couldn't resolve this. It's open again.\" }));\n      window.setTimeout(() => setNotes(({ [thread.id]: _, ...rest }) => rest), 3600);\n      say(\"Couldn't resolve the thread. It's open again.\");\n    }\n  };\n\n  // What the memoised cards call, through one ref.\n  const actions = React.useRef<CardActions>(null!);\n  actions.current = {\n    activate: (id) => setActive(id || null),\n    reply,\n    resolve: (thread) => {\n      setResolving((r) => ({ ...r, [thread.id]: Date.now() }));\n      say(\"Resolved. Undo is in the card.\");\n    },\n    undo: (thread) => {\n      setResolving(({ [thread.id]: _, ...rest }) => rest);\n      say(\"Thread open again.\");\n    },\n    finish,\n    create,\n    cancel: () => {\n      setDraft(null);\n      setActive(null);\n    },\n    height: (id, h) => {\n      if (heightsRef.current.get(id) === h) return;\n      heightsRef.current.set(id, h);\n      setHeights(new Map(heightsRef.current));\n    },\n  };\n\n  React.useEffect(() => {\n    const id = focusCard.current;\n    if (!id) return;\n    focusCard.current = null;\n    rootRef.current?.querySelector<HTMLElement>(`[data-card=\"${CSS.escape(id)}\"] > article`)?.focus({ preventScroll: true });\n  });\n\n  // ── Narrow: the paragraph opens a gap under itself for the chosen thread ──\n  const openId = draft?.id ?? active;\n  const openFound = openId ? found.find((f) => f.id === openId) : undefined;\n  const [gap, setGap] = React.useState<{ id: string; top: number } | null>(null);\n  /** The paragraph holding the gap: found once per thread opened, its own margin kept to put back. */\n  const gapEl = React.useRef<{ id: string; el: HTMLElement; was: string; base: number } | null>(null);\n  const restoreGap = React.useCallback(() => {\n    const g = gapEl.current;\n    if (!g) return;\n    gapEl.current = null;\n    g.el.style.marginBottom = g.was;\n    window.setTimeout(() => {\n      if (gapEl.current?.el !== g.el) g.el.style.transition = \"\";\n    }, 320);\n  }, []);\n  React.useLayoutEffect(() => {\n    const want = narrow && openFound ? openFound.id : null;\n    if (gapEl.current && gapEl.current.id !== want) restoreGap();\n    if (!want || !openFound) {\n      setGap(null);\n      return;\n    }\n    const inner = innerRef.current;\n    if (!gapEl.current) {\n      let block: HTMLElement | null = openFound.end[0].parentElement;\n      while (block && block !== inner && block.parentElement !== inner && !/^(block|list-item|flex|grid|table)$/.test(getComputedStyle(block).display)) block = block.parentElement;\n      if (!block || block === inner) return;\n      gapEl.current = { id: want, el: block, was: block.style.marginBottom, base: parseFloat(getComputedStyle(block).marginBottom) || 0 };\n      block.style.transition = reduced ? \"\" : `margin-bottom 280ms ${EASE}`;\n    }\n    const g = gapEl.current;\n    const margin = `${g.base + (heights.get(want) ?? 120) + 16}px`;\n    if (g.el.style.marginBottom !== margin) g.el.style.marginBottom = margin;\n    // The paragraph's own box doesn't move as its margin grows, so the card's place is read once per change.\n    const top = g.el.offsetTop + g.el.offsetHeight + 8;\n    setGap((x) => (x?.id === want && x.top === top ? x : { id: want, top }));\n  }, [narrow, openFound, heights, reduced, restoreGap]);\n  React.useEffect(() => restoreGap, [restoreGap]);\n\n  // ── Render ─────────────────────────────────────────────\n  const threadOf = (id: string) => shown.find((t) => t.id === id);\n  const tone = (id: string) => {\n    const on = id === openId;\n    const fading = resolving[id] !== undefined;\n    return {\n      background: `color-mix(in oklab, var(--primary) ${on ? 26 : hover === id ? 20 : 13}%, transparent)`,\n      boxShadow: on ? \"inset 0 -1.5px 0 var(--primary)\" : \"inset 0 -1.5px 0 transparent\",\n      opacity: fading ? 0 : 1,\n    };\n  };\n\n  const card = (id: string) => {\n    const t = threadOf(id);\n    const isDraft = draft?.id === id;\n    if (!t && !isDraft) return null;\n    return (\n      <MemoCard\n        id={id}\n        thread={t}\n        draft={isDraft ? draft! : undefined}\n        active={id === openId}\n        resolving={resolving[id] ?? null}\n        canReply={!!onReply}\n        canResolve={!!onResolve}\n        user={user}\n        when={when}\n        note={notes[id]}\n        holdKey={resolving[id]}\n        undoFor={undoFor}\n        grow={growing === id}\n        reduced={reduced}\n        actions={actions}\n      />\n    );\n  };\n\n  return (\n    <div ref={rootRef} role=\"group\" aria-label={label} className={`relative text-foreground ${className}`} {...props}>\n      <div className={narrow ? \"\" : \"flex items-start gap-6\"}>\n        {/* The document, with the highlights drawn under it. */}\n        <div className={`relative min-w-0 flex-1 ${narrow ? \"pr-9\" : \"\"}`}>\n          <div\n            ref={innerRef}\n            className=\"relative\"\n            onPointerMove={(e) => {\n              if (e.pointerType !== \"mouse\") return;\n              const id = hit(e);\n              if (id !== hover) setHover(id);\n            }}\n            onPointerLeave={() => hover && setHover(null)}\n            onClick={(e) => {\n              if (window.getSelection()?.isCollapsed === false) return;\n              const id = hit(e);\n              if (id && id !== draft?.id) setActive((a) => (a === id ? null : id));\n              else if (!draft) setActive(null);\n            }}\n            style={{ cursor: hover ? \"pointer\" : undefined }}\n          >\n            <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n              {found.map((f) =>\n                f.rects.map((r, i) => (\n                  <span\n                    key={`${f.id}-${i}`}\n                    className=\"absolute rounded-[3px]\"\n                    style={{ top: r.top - 1, left: r.left - 1, width: r.width + 2, height: r.height + 2, ...tone(f.id), transition: reduced ? \"none\" : `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}, opacity 320ms ${EASE}` }}\n                  />\n                )),\n              )}\n            </div>\n            <div className=\"relative\">{children}</div>\n\n            {/* Narrow: a count at the end of each thread's first line, and the open thread in the gap its paragraph made. */}\n            {narrow &&\n              found.map((f) => {\n                const t = threadOf(f.id);\n                if (!t) return null;\n                return (\n                  <button\n                    key={f.id}\n                    type=\"button\"\n                    aria-label={`${t.comments.length} ${t.comments.length === 1 ? \"comment\" : \"comments\"} on “${t.anchor.quote}”`}\n                    aria-expanded={openId === f.id}\n                    onClick={(e) => {\n                      e.stopPropagation();\n                      setActive((a) => (a === f.id ? null : f.id));\n                    }}\n                    className={`absolute -right-9 flex h-6 cursor-pointer items-center gap-1 rounded-full px-1.5 text-[11px] tabular-nums transition-colors ${openId === f.id ? \"bg-accent text-foreground\" : \"text-muted-foreground hover:bg-accent hover:text-foreground\"} ${FOCUS}`}\n                    style={{ top: f.top - 2 }}\n                  >\n                    <MessageSquare className=\"size-3\" strokeWidth={2} aria-hidden=\"true\" />\n                    {t.comments.length}\n                  </button>\n                );\n              })}\n            {narrow && gap && openId === gap.id && (\n              <div key={gap.id} data-card={gap.id} className=\"absolute inset-x-0 z-10\" style={{ top: gap.top }} onClick={(e) => e.stopPropagation()}>\n                {card(gap.id)}\n              </div>\n            )}\n\n            {/* The Comment pill: in the margin beside the selection, or under it when narrow. */}\n            {pending && !draft && (\n              <button\n                type=\"button\"\n                onPointerDown={(e) => e.preventDefault()}\n                onClick={(e) => {\n                  e.stopPropagation();\n                  openDraft();\n                }}\n                aria-keyshortcuts=\"Control+Alt+M\"\n                className={`absolute z-20 inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-full bg-background px-2.5 text-[12.5px] font-medium shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent ${FOCUS}`}\n                style={\n                  narrow\n                    ? { top: pending.bottom + 6, left: Math.max(0, Math.min(pending.left, (innerRef.current?.offsetWidth ?? 300) - 110)) }\n                    : { top: pending.top - 4, left: `calc(100% + 24px)` }\n                }\n                ref={(el) => {\n                  if (!el || el.dataset.in || reduced) return;\n                  el.dataset.in = \"1\";\n                  el.animate(\n                    [\n                      { opacity: 0, transform: \"scale(0.9)\", filter: \"blur(3px)\" },\n                      { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n                    ],\n                    { duration: 220, easing: EASE },\n                  );\n                }}\n              >\n                <MessageSquarePlus className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n                Comment\n              </button>\n            )}\n          </div>\n        </div>\n\n        {/* Wide: the margin, every card level with its line. */}\n        {!narrow && (\n          <aside aria-label=\"Threads\" className=\"relative shrink-0\" style={{ width: MARGIN_W, minHeight: marginHeight }}>\n            {found.map((f) => (\n              <div\n                key={f.id}\n                data-card={f.id}\n                className=\"absolute inset-x-0 top-0\"\n                style={{ transform: `translateY(${positions.get(f.id) ?? 0}px)`, transition: placed && !reduced ? `transform 360ms ${GLIDE}` : \"none\", zIndex: f.id === openId ? 2 : 1 }}\n              >\n                {card(f.id)}\n              </div>\n            ))}\n          </aside>\n        )}\n      </div>\n\n      <p className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </p>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-progress":{"from":{"transform":"scaleX(0)"},"to":{"transform":"scaleX(1)"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/doc-comments. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}