← ui

comment-thread

ui

a comment section: replies nested and folded, voted or liked, answered inline

npx shadcn add @ag/comment-thread
Already installed? Preview an update
npx shadcn add @ag/comment-thread --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-29
  • New: a comment section. Comments nested under what they answer, each with its face, name, time (its permalink), words in markdown or your own rendered content (a forum's HTML), a vote or likes, and reply, copy link and a menu of the rest. A comment folds by a press on its thread line, which becomes a plus where the line was, the head staying where it was. Replies are written inline in the editor and sent with ⌘↩. Keys: j and k between comments, p the parent, Enter folds, r replies, a and z vote.
  • The parts are the API (Comments, Comment, CommentHead, CommentBody, CommentActions, CommentReplies, CommentComposer and the rest); CommentThread is the preset: flat comments in, sorted old, new, top or hot, at most page replies a level then "N more", past maxDepth "continue this thread".
  • lib/comment-thread-core builds the tree without React: a post quoting several earlier posts is drawn under the latest and links the others ("also replying to"), a quoted post outside the list is linked, never invented.
Updated 2026-10-29 · release notes

also called comments, discussion, replies

shelf social & links

uses avatar, button, copy, editor, key-router, markdown, material, menu, person, quote, reactions, relative-time, tooltip, vote

the demo above, verbatim

comment-thread.demo.tsx
"use client"

import { Flag, Quote } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import type { ThreadSort } from "@/registry/base-nova/lib/comment-thread-core"
import type { Person } from "@/registry/base-nova/lib/person"
import {
  Comment,
  CommentActions,
  CommentAuthor,
  CommentBody,
  type CommentData,
  CommentFace,
  CommentHead,
  CommentLink,
  CommentReplies,
  Comments,
  CommentThread,
  CommentTime,
} from "@/registry/base-nova/ui/comment-thread"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import {
  ToggleGroup,
  ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
import { Vote, type VoteValue } from "@/registry/base-nova/ui/vote"

// #region people
// Defined once (lib/person). Those with no picture wear their initial.
const PEOPLE: Record<string, Person> = {
  twain: { name: "Mark Twain", avatar: "/mark-twain.png" },
  franklin: { name: "Benjamin Franklin", avatar: "/benjamin-franklin.png" },
  confucius: { name: "Confucius", avatar: "/confucius.png" },
  adrian: { name: "adrian", avatar: "/adriangalilea.jpg" },
  aldrin: { name: "Buzz Aldrin", avatar: "/apollo-11/aldrin.png" },
  lark: { name: "lark" },
  wren: { name: "wren" },
  tern: { name: "tern" },
  ibis: { name: "ibis" },
  kite: { name: "kite" },
}
// #endregion

// #region time
/** On the hour, so the server's render and the browser's agree on every instant. */
const NOW = Math.floor(Date.now() / 3_600_000) * 3_600_000
const ago = (minutes: number) => NOW - minutes * 60_000
// #endregion

// #region thread
/** A Reddit-shaped thread: one parent each, scores voted, markdown words. Oldest
 *  first, as an API lists them. */
const THREAD: CommentData[] = [
  {
    id: "1",
    from: "twain",
    at: ago(600),
    score: 412,
    markdown:
      "The secret of getting ahead is getting started. The secret of getting started is breaking your complex overwhelming tasks into small manageable tasks, and then starting on the first one.",
  },
  {
    id: "2",
    from: "lark",
    at: ago(590),
    score: 88,
    markdown:
      "Does that hold for a comment section too? It feels like the one thing every site rebuilds from scratch.",
  },
  {
    id: "3",
    from: "franklin",
    at: ago(580),
    score: 57,
    parents: ["1"],
    markdown: "Well done is better than well said.",
  },
  {
    id: "4",
    from: "adrian",
    at: ago(570),
    score: 131,
    parents: ["2"],
    markdown: [
      "It does. The parts are already here, which is the point of composing it:",
      "",
      "- the face is `avatar`, the time `relative-time`",
      "- the words `markdown`, the standing `vote` or `reactions`",
      "- the reply box `editor`, the keys `key-router`",
      "",
      "Nothing in it draws its own button.",
    ].join("\n"),
  },
  {
    id: "5",
    from: "wren",
    at: ago(560),
    score: 24,
    parents: ["4"],
    markdown:
      "And the tree? Building one from flat rows is where mine always went wrong.",
  },
  {
    id: "6",
    from: "adrian",
    at: ago(550),
    score: 40,
    parents: ["5"],
    markdown: [
      "A lib, no React in it:",
      "",
      "```ts",
      'const thread = buildThread(posts, { sort: "top" })',
      "thread.roots // sorted, each with its replies and their count",
      "```",
      "",
      "Posts come oldest first, so a parent is always known before its replies.",
    ].join("\n"),
  },
  {
    id: "7",
    from: "tern",
    at: ago(540),
    score: 12,
    parents: ["6"],
    markdown: "What happens with a cycle?",
  },
  {
    id: "8",
    from: "adrian",
    at: ago(530),
    score: 19,
    parents: ["7"],
    markdown:
      "It can't be written: a reply that names a parent listed after it is refused, with the reason.",
  },
  {
    id: "9",
    from: "tern",
    at: ago(520),
    score: 9,
    parents: ["8"],
    markdown: "So deep threads stay honest. How deep does it draw?",
  },
  {
    id: "10",
    from: "ibis",
    at: ago(510),
    score: 6,
    parents: ["9"],
    markdown: "This deep, apparently. Then it offers to continue the thread.",
  },
  {
    id: "11",
    from: "kite",
    at: ago(500),
    score: 4,
    parents: ["10"],
    markdown: "Hello from below the fold.",
  },
  {
    id: "12",
    from: "confucius",
    at: ago(490),
    score: 77,
    parents: ["1"],
    markdown:
      "It does not matter how slowly you go as long as you do not stop.",
  },
  {
    id: "13",
    from: "aldrin",
    at: ago(300),
    score: 230,
    markdown: [
      "Long one, sorry. Every comment system I've used fails the same three ways:",
      "",
      "1. **Folding moves things.** You collapse a thread and the line you were reading jumps.",
      "2. **Deep threads squeeze.** Ten levels in, the text is a column of three words.",
      "3. **Long threads are slow.** Four thousand replies, all in the DOM, on a phone.",
      "",
      "> Here the head stays put when it folds, past a depth the thread continues on its own, and past a page of replies the rest wait behind a button.",
      "",
      "Which is the whole list.",
    ].join("\n"),
  },
  ...Array.from({ length: 9 }, (_, i) => ({
    id: `13.${i + 1}`,
    from: ["lark", "wren", "tern", "ibis", "kite"][i % 5] as string,
    at: ago(290 - i * 20),
    score: [41, 3, 17, 0, -4, 8, 22, 1, 5][i] as number,
    parents: ["13"],
    markdown: [
      "Number two is the one that gets me.",
      "+1, especially on a phone.",
      "Folding without the jump is underrated.",
      "Does it virtualize?",
      "Hot take: threads deeper than five are a smell.",
      "Collapsed by default past a page is the right call.",
      "The `j` and `k` keys are a nice touch.",
      "What about quotes of several posts?",
      "Bookmarking this.",
    ][i] as string,
  })),
  {
    id: "14",
    from: "lark",
    at: ago(200),
    score: 2,
    parents: ["13.4"],
    markdown: "No: what is not drawn is what was folded, paged or cut.",
  },
  {
    id: "15",
    from: "franklin",
    at: ago(120),
    score: 15,
    parents: ["13.8"],
    markdown: "See the forum below: a post quoting three earlier posts.",
  },
  {
    id: "16",
    from: "kite",
    at: ago(45),
    score: 3,
    markdown: "Short and sweet.",
  },
  {
    id: "17",
    from: "wren",
    at: ago(12),
    score: 1,
    markdown: "Folded by default, and its replies with it.",
  },
  {
    id: "18",
    from: "tern",
    at: ago(8),
    score: 0,
    parents: ["17"],
    markdown: "You can't see me until it opens.",
  },
]
// #endregion

// #region votes
/** Your votes, held here; a score moves with yours (vote's optimistic rule). */
function useVotes(comments: readonly CommentData[]) {
  const [votes, setVotes] = React.useState<Record<string, VoteValue>>({})
  const voted = React.useMemo(
    () =>
      comments.map((c) => {
        const vote = votes[c.id] ?? 0
        return vote ? { ...c, vote, score: (c.score ?? 0) + vote } : c
      }),
    [comments, votes],
  )
  const onVote = (id: string, value: VoteValue) =>
    setVotes((all) => ({ ...all, [id]: value }))
  return [voted, onVote] as const
}
// #endregion

// #region reply
/** A stand-in server: a reply takes a moment, and one that says "fail" is refused. */
function useReplies(start: readonly CommentData[]) {
  const [comments, setComments] = React.useState(start)
  const onReply = (parent: string | null, markdown: string) =>
    new Promise<void>((done, refuse) =>
      setTimeout(() => {
        if (/fail/i.test(markdown))
          return refuse(new Error("the server said no"))
        setComments((all) => [
          ...all,
          {
            id: `new-${all.length}`,
            from: "adrian",
            at: Date.now(),
            score: 1,
            vote: 1,
            parents: parent ? [parent] : undefined,
            markdown,
          },
        ])
        done()
      }, 600),
    )
  return [comments, onReply] as const
}
// #endregion

// #region reddit
function Reddit() {
  const [sort, setSort] = React.useState<ThreadSort>("top")
  const [comments, onReply] = useReplies(THREAD)
  const [voted, onVote] = useVotes(comments)
  return (
    <div className="flex flex-col gap-4">
      <ToggleGroup
        size="sm"
        aria-label="sort comments"
        value={sort}
        onValueChange={(v) => setSort(v as ThreadSort)}
        className="w-fit"
      >
        <ToggleGroupItem value="top">top</ToggleGroupItem>
        <ToggleGroupItem value="hot">hot</ToggleGroupItem>
        <ToggleGroupItem value="new">new</ToggleGroupItem>
        <ToggleGroupItem value="old">old</ToggleGroupItem>
      </ToggleGroup>
      <CommentThread
        comments={voted}
        people={PEOPLE}
        sort={sort}
        now={NOW}
        domId={(id) => `reddit-${id}`}
        onVote={onVote}
        onReply={onReply}
        menu={() => [{ icon: <Flag />, label: "report", onSelect: () => {} }]}
        page={5}
        defaultCollapsed={["17"]}
      />
    </div>
  )
}
// #endregion

// #region forum
// A forum's replies, as a reader of one has them: numbered posts, each quoting any
// earlier posts it answers (several, sometimes), likes counted with the first few
// names, and words that are the forum's HTML, rendered to React already.
const FORUM_PEOPLE: Record<string, Person> = {
  ...PEOPLE,
  op: { name: "nightjar" },
}

/** A quoted post, the way the forum's own HTML draws one. */
const Quoted = ({ who, children }: { who: string; children: string }) => (
  <blockquote>
    <p>
      <strong>{who} wrote:</strong> {children}
    </p>
  </blockquote>
)

const FORUM: CommentData[] = [
  {
    id: "201",
    from: "op",
    at: ago(4000),
    likes: { count: 31, mine: false, who: ["lark", "wren", "tern", "ibis"] },
    body: (
      <p>
        Moving the old reader to a reply tree. Posts quote each other freely, so
        one post can answer three. How should that draw?
      </p>
    ),
  },
  {
    id: "202",
    from: "lark",
    at: ago(3900),
    parents: ["201"],
    likes: { count: 4, mine: false, who: ["nightjar", "wren"] },
    body: (
      <p>Under the latest one it quotes. That is the exchange it continues.</p>
    ),
  },
  {
    id: "203",
    from: "wren",
    at: ago(3800),
    parents: ["201"],
    likes: { count: 2, mine: true, who: ["adrian", "tern"] },
    body: (
      <p>
        And link the others, so nothing is lost. Reddit never had to solve this.
      </p>
    ),
  },
  {
    id: "204",
    from: "tern",
    at: ago(3700),
    parents: ["202", "203"],
    likes: { count: 9, mine: false, who: ["lark", "wren", "ibis"] },
    body: (
      <>
        <Quoted who="lark">Under the latest one it quotes.</Quoted>
        <Quoted who="wren">And link the others, so nothing is lost.</Quoted>
        <p>Both, then. Drawn under wren's, with a link back to lark's.</p>
      </>
    ),
  },
  {
    id: "205",
    from: "ibis",
    at: ago(3600),
    parents: ["212", "204"],
    likes: { count: 0, mine: false },
    body: (
      <>
        <Quoted who="kite">the old reader is fine as it is</Quoted>
        <p>
          That was on the previous page. Its link goes there, since it is not in
          this thread.
        </p>
      </>
    ),
  },
  {
    id: "206",
    from: "kite",
    at: ago(3500),
    parents: ["201", "202", "204"],
    likes: {
      count: 14,
      mine: false,
      who: ["nightjar", "lark", "tern", "ibis", "wren"],
    },
    body: (
      <>
        <Quoted who="tern">Both, then.</Quoted>
        <p>
          Quoting the first post adds nothing here: it is right above. lark's is
          elsewhere, so it gets a link.
        </p>
      </>
    ),
  },
  {
    id: "207",
    from: "op",
    at: ago(3000),
    likes: { count: 3, mine: false, who: ["kite"] },
    body: (
      <p>
        Thanks all. <a href="#forum-201">Back to the top</a>.
      </p>
    ),
  },
]

function Forum() {
  const [posts, setPosts] = React.useState(FORUM)
  const onLike = (id: string) =>
    setPosts((all) =>
      all.map((p) =>
        p.id === id && p.likes
          ? {
              ...p,
              likes: {
                ...p.likes,
                mine: !p.likes.mine,
                count: p.likes.count + (p.likes.mine ? -1 : 1),
                who: p.likes.mine
                  ? p.likes.who?.filter((w) => w !== "adrian")
                  : ["adrian", ...(p.likes.who ?? [])],
              },
            }
          : p,
      ),
    )
  return (
    <CommentThread
      label="replies"
      comments={posts}
      people={FORUM_PEOPLE}
      domId={(id) => `forum-${id}`}
      href={(id) =>
        posts.some((p) => p.id === id) ? `#forum-${id}` : `?page=11#post-${id}`
      }
      onLike={onLike}
      like="👍"
      menu={() => [{ icon: <Quote />, label: "quote", onSelect: () => {} }]}
    />
  )
}
// #endregion

// #region small
const SMALL: CommentData[] = [
  {
    id: "a",
    from: "confucius",
    at: ago(90),
    score: 64,
    markdown: "Real knowledge is to know the extent of one's ignorance.",
  },
  {
    id: "b",
    from: "lark",
    at: ago(60),
    score: 12,
    parents: ["a"],
    markdown: "Which is why I read the comments.",
  },
  {
    id: "c",
    from: "twain",
    at: ago(30),
    score: 30,
    parents: ["b"],
    markdown: "A brave choice.",
  },
]

function Small({ prefix }: { prefix: string }) {
  const [voted, onVote] = useVotes(SMALL)
  return (
    <CommentThread
      comments={voted}
      people={PEOPLE}
      domId={(id) => `${prefix}-${id}`}
      onVote={onVote}
    />
  )
}
// #endregion

// #region parts
/** The parts by hand: a comment with a badge by the name and a vote beside it, its
 *  one reply, no reply box. What the preset composes, laid out yourself. */
function Parts() {
  const [vote, setVote] = React.useState<VoteValue>(0)
  const op = PEOPLE.franklin as Person
  const reply = PEOPLE.wren as Person
  return (
    <Comments label="one comment">
      <Comment
        id="parts-1"
        author={op.name}
        face={<CommentFace person={op} />}
        replies={1}
      >
        <CommentHead>
          <CommentAuthor person={op}>
            <span className="rounded bg-current/8 px-1 font-medium text-[10px] uppercase tracking-wide">
              op
            </span>
          </CommentAuthor>
          <CommentTime at={ago(15)} href="#parts-1" />
        </CommentHead>
        <CommentBody markdown="An investment in knowledge pays the best interest." />
        <CommentActions>
          <Vote
            compact
            size="sm"
            orientation="horizontal"
            value={vote}
            score={7 + vote}
            onValueChange={setVote}
            aria-label="vote on Franklin's comment"
          />
          <CommentLink href="#parts-1" />
        </CommentActions>
        <CommentReplies>
          <Comment
            id="parts-2"
            author={reply.name}
            face={<CommentFace person={reply} />}
          >
            <CommentHead>
              <CommentAuthor person={reply} />
              <CommentTime at={ago(5)} />
            </CommentHead>
            <CommentBody>
              <p>
                Rendered already: <em>this body is React</em>, worn in
                markdown's look.
              </p>
            </CommentBody>
          </Comment>
        </CommentReplies>
      </Comment>
    </Comments>
  )
}
// #endregion

export default function CommentThreadDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="glance"
        glance="large"
        title="a short thread"
        with="people time votes small"
      >
        <div className="w-[26rem] max-w-full">
          <Small prefix="glance" />
        </div>
      </Sample>
      <Sample
        name="reddit"
        title="votes, sorts, replies"
        description="thirty comments, eight levels deep: past six it continues the thread, past five replies the rest wait behind a button, one folded from the start. sort it, vote, fold by the line, reply (a reply saying fail is refused). keys: j, k, p, enter, r, a, z."
        with="people time thread votes reply reddit"
      >
        <Reddit />
      </Sample>
      <Sample
        name="forum"
        title="a forum: quotes of several"
        description="a post quoting several earlier posts is drawn under the latest and links the others, unless one is right above it; a quote of a post on another page links there. likes are a count and the first few names, the words the forum's own HTML."
        with="people time forum"
      >
        <Forum />
      </Sample>
      <Sample
        name="materials"
        title="plain and glass"
        with="people time votes small"
      >
        <div className="grid gap-4 lg:grid-cols-2">
          <Panel className="p-5">
            <Small prefix="plain" />
          </Panel>
          <div className="rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-4">
            <MaterialProvider material="glass" scheme="dark">
              <Panel className="p-5">
                <Small prefix="glass" />
              </Panel>
            </MaterialProvider>
          </div>
        </div>
      </Sample>
      <Sample name="parts" title="the parts by hand" with="people time parts">
        <Parts />
      </Sample>
    </div>
  )
}

the source, every file an install writes

comment-thread.tsx
"use client"

import {
  ArrowLeft,
  ChevronRight,
  CornerDownRight,
  Ellipsis,
  Minus,
  Plus,
  Reply,
} from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  buildThread,
  nodeOf,
  pathTo,
  type Thread,
  type ThreadNode,
  type ThreadPost,
  type ThreadSort,
} from "@/registry/base-nova/lib/comment-thread-core"
import type { Person } from "@/registry/base-nova/lib/person"
import { Avatar } from "@/registry/base-nova/ui/avatar"
import { Button } from "@/registry/base-nova/ui/button"
import { Copy } from "@/registry/base-nova/ui/copy"
import { Editor } from "@/registry/base-nova/ui/editor"
import { useEscape, useKeys } from "@/registry/base-nova/ui/key-router"
import { MARKDOWN, Markdown } from "@/registry/base-nova/ui/markdown"
import {
  type Material,
  MaterialProvider,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { schemeOf } from "@/registry/base-nova/ui/material-glass"
import { Menu, type MenuEntry } from "@/registry/base-nova/ui/menu"
import { QuoteMarkMask } from "@/registry/base-nova/ui/quote-mark"
import { Reactions } from "@/registry/base-nova/ui/reactions"
import { RelativeTime } from "@/registry/base-nova/ui/relative-time"
import { Tip } from "@/registry/base-nova/ui/tooltip"
import { Vote, type VoteValue } from "@/registry/base-nova/ui/vote"

// A COMMENT SECTION (Reddit, Hacker News, a forum's replies): comments nested under
// what they answer, each with its author, when, its words, its standing and what to do
// with it. Composed, not drawn: the face is `avatar`, the time `relative-time`, the
// words `markdown` (or the consumer's own rendered content, in markdown's look), the
// standing `vote` or `reactions` (a forum's likes, with who gave them), the verbs
// `button`, `copy` and `menu`, the reply box `editor`, the keys `key-router`.
//
// THE PARTS ARE THE API, shadcn's way:
//
//   <Comments label="comments">
//     <Comment id="c1" author="ana" face={<CommentFace person={ana} />} replies={2}>
//       <CommentHead>
//         <CommentAuthor person={ana} />
//         <CommentTime at={c.at} href="#c1" />
//       </CommentHead>
//       <CommentBody markdown={c.text} />        or  <CommentBody>{html}</CommentBody>
//       <CommentActions>
//         <Vote compact size="sm" orientation="horizontal" … />
//         <CommentReply onClick={…} />
//         <CommentLink href="#c1" />
//       </CommentActions>
//       <CommentReplies>…<Comment>…</CommentReplies>
//     </Comment>
//   </Comments>
//
// `CommentThread` is the preset over them: flat comments in (lib/comment-thread-core
// builds the tree, quotes of several posts included), people, a sort, and handlers.
//
// COLLAPSE is the comment's own: a press on its thread line (under the face, Reddit's),
// which folded is a plus where the line was. Collapsed, the head stays exactly where it
// was and says how many replies it hides; everything else (the words, the verbs, the
// replies) is not rendered at all, so a long thread folded is a short DOM. A part
// reads the state from its comment, so a consumer's composition folds the same way.
//
// THE DOM STAYS BOUNDED in the preset: `page` replies a level are drawn, then "N more
// replies"; past `maxDepth` levels a comment offers "continue this thread", which
// draws that comment alone as the thread (or goes to `continueHref`). Nothing is
// virtualized: a thread is read in the page's own scroll, and what is not drawn is
// what was folded, paged or cut.
//
// KEYS (Reddit Enhancement Suite's, which a reader of threads already knows), on the
// key router, taken while focus or the pointer is in the section: j and k the next
// and previous comment, p its parent, Enter folds or opens it, r replies, a and z vote
// up and down (a like, where it is likes). Each key presses the comment's own control,
// so a key does exactly what the hand would and a composition without a reply button
// has no r. The comment's menu (⋯) lists them.
//
// A comment is an `article` named by its head; replies are a `group` named for whose
// they are; the toggle says `aria-expanded`. Focus on a comment rings its head, never
// the whole subtree under it.

/** The words' voice: the meta line small and quiet, the name at full ink. */
const META = "text-[12px] text-current/55"
const NAME = "font-medium text-[13px] text-current/90"

// ── the section ──────────────────────────────────────────────────────────────────

/** Every comment in the section, in reading order: what j and k walk. */
const COMMENT = "[data-slot=comment]"

/** The part of `comment` that is its own, never a reply's below it. */
function ownPart(comment: Element, selector: string): HTMLElement | null {
  for (const el of comment.querySelectorAll<HTMLElement>(selector))
    if (el.closest(COMMENT) === comment) return el
  return null
}

/** Focus a comment (its article), keeping it in view. */
export function focusComment(el: HTMLElement | null) {
  if (!el) return
  el.focus({ preventScroll: true })
  el.scrollIntoView({ block: "nearest" })
}

export interface CommentsProps
  extends Omit<React.ComponentProps<"section">, "aria-label"> {
  /** The section's name: "comments", "replies to this post". */
  label?: string
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
}

/** The section every comment stands in: its name, its material, its keys. */
export function Comments({
  label = "comments",
  material,
  scheme,
  className,
  children,
  ref,
  ...props
}: CommentsProps) {
  const m = useMaterial(material)
  const [s, here] = useSurfaceScheme<HTMLElement>(scheme, ref)
  const root = React.useRef<HTMLElement | null>(null)
  const attach = React.useCallback(
    (el: HTMLElement | null) => {
      root.current = el
      here(el)
    },
    [here],
  )
  useKeys(
    (event) => {
      const section = root.current
      if (!section || event.metaKey || event.ctrlKey || event.altKey)
        return false
      // The section's keys while the hand is in it: focus, or the pointer over it.
      const active = document.activeElement
      if (!section.contains(active) && !section.matches(":hover")) return false
      const all = [...section.querySelectorAll<HTMLElement>(COMMENT)]
      const near = active?.closest<HTMLElement>(COMMENT) ?? null
      const current = near && section.contains(near) ? near : null
      const press = (selector: string) => {
        const control = current && ownPart(current, selector)
        if (!control) return false
        control.click()
        return true
      }
      switch (event.key) {
        case "j":
        case "k": {
          if (all.length === 0) return false
          const at = current ? all.indexOf(current) : -1
          const to =
            event.key === "j"
              ? Math.min(all.length - 1, at + 1)
              : Math.max(0, at === -1 ? 0 : at - 1)
          focusComment(all[to] ?? null)
          return true
        }
        case "p": {
          const parent =
            current?.parentElement?.closest<HTMLElement>(COMMENT) ?? null
          if (!parent || !section.contains(parent)) return false
          focusComment(parent)
          return true
        }
        // Enter on the comment itself; on one of its controls it is the control's.
        case "Enter":
          return active === current && press("[data-slot=comment-toggle]")
        case "r":
          return press("[data-slot=comment-reply]")
        case "a":
          return press(
            "[data-slot=vote-up], [data-slot=reactions-pill]:is(button)",
          )
        case "z":
          return press("[data-slot=vote-down]")
        default:
          return false
      }
    },
    { root },
  )
  return (
    <MaterialProvider material={m} scheme={s}>
      <section
        ref={attach}
        aria-label={label}
        data-slot="comments"
        data-material={m}
        className={cn("flex flex-col gap-5", schemeOf(s), className)}
        {...props}
      >
        <QuoteMarkMask />
        {children}
      </section>
    </MaterialProvider>
  )
}

// ── a comment ────────────────────────────────────────────────────────────────────

interface CommentState {
  id: string
  author: string
  replies: number
  collapsed: boolean
  setCollapsed: (collapsed: boolean) => void
  headId: string
}

const CommentContext = React.createContext<CommentState | null>(null)

/** The comment a part stands in; null outside one (a composer for a new comment). */
export function useComment(): CommentState | null {
  return React.useContext(CommentContext)
}

function useOwnComment(part: string): CommentState {
  const c = React.useContext(CommentContext)
  if (!c) throw new Error(`${part}: it is a part of a <Comment>`)
  return c
}

export interface CommentProps
  extends Omit<React.ComponentProps<"article">, "id"> {
  /** The comment's own id: its element's is `domId` (default the same). */
  id: string
  /** The element's id, what a permalink's fragment names. */
  domId?: string
  /** Its author's name, for what the controls say ("collapse ana's comment"). */
  author: string
  /** In the gutter, above the thread line: a `CommentFace`. */
  face?: React.ReactNode
  /** Every reply under it, at any depth: what a fold says it hides. */
  replies?: number
  collapsed?: boolean
  defaultCollapsed?: boolean
  onCollapsedChange?: (collapsed: boolean) => void
}

/** One comment: the face and its thread line in the gutter, the parts beside them. */
export function Comment({
  id,
  domId = id,
  author,
  face,
  replies = 0,
  collapsed: controlled,
  defaultCollapsed = false,
  onCollapsedChange,
  className,
  children,
  ...props
}: CommentProps) {
  if (!Number.isInteger(replies) || replies < 0)
    throw new Error(`Comment ${id}: replies is a count, got ${replies}`)
  const [own, setOwn] = React.useState(defaultCollapsed)
  const collapsed = controlled ?? own
  const headId = `${React.useId()}-head`
  const state = React.useMemo<CommentState>(
    () => ({
      id,
      author,
      replies,
      collapsed,
      headId,
      setCollapsed: (next) => {
        if (controlled === undefined) setOwn(next)
        onCollapsedChange?.(next)
      },
    }),
    [id, author, replies, collapsed, headId, controlled, onCollapsedChange],
  )
  return (
    <CommentContext.Provider value={state}>
      <article
        id={domId}
        tabIndex={-1}
        aria-labelledby={headId}
        data-slot="comment"
        data-collapsed={collapsed || undefined}
        className={cn(
          // The ring is the head's and the line lights, never a ring round the subtree.
          "group/comment grid grid-cols-[1.5rem_minmax(0,1fr)] gap-x-2.5 outline-none",
          "[&:focus-visible>[data-slot=comment-content]>[data-slot=comment-head]]:focus-ring",
          "[&:focus-visible>[data-slot=comment-gutter]>*>[data-slot=comment-toggle]]:text-current/45 [&:focus-visible>[data-slot=comment-gutter]>[data-slot=comment-toggle]]:text-current/45",
          className,
        )}
        {...props}
      >
        <div
          data-slot="comment-gutter"
          className="flex min-h-7 flex-col items-center pt-0.5"
        >
          {face}
          <CommentLine />
        </div>
        <div data-slot="comment-content" className="min-w-0">
          {children}
        </div>
        {collapsed && <CommentUnfold />}
      </article>
    </CommentContext.Provider>
  )
}

/** THE FOLD IS THE THREAD LINE (Adrian, 2026-10-29: no minus, no mark while open).
 *  Open, the line runs from under the face to the last reply, and a press anywhere on
 *  it folds the comment (Reddit's). Folded, there is no line and no gap where it was:
 *  the comment is its head, which ends in a small dim plus and how many replies it
 *  hides (`CommentUnfold`). Bespoke, as a tree row's marks are: a button's box would
 *  break the line. */
function CommentLine() {
  const c = useOwnComment("CommentLine")
  if (c.collapsed) return null
  return (
    <Tip content="collapse" keys="enter" side="left">
      <button
        type="button"
        aria-expanded
        aria-label={`collapse ${c.author}'s comment`}
        data-slot="comment-toggle"
        onClick={() => c.setCollapsed(true)}
        className="group/line relative mt-1 w-full flex-1 origin-top cursor-pointer text-current/14 transition-[color,scale] duration-200 ease-out hover:text-current/45 motion-reduce:transition-none starting:scale-y-0"
      >
        <span
          aria-hidden
          className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 rounded-full bg-current group-hover/line:w-0.5"
        />
      </button>
    </Tip>
  )
}

/** Folded: under the face, where the line was, the line shrunk into a small dim plus
 *  (its upright the line drawn in, its crossbar growing out), and right beside it what
 *  the fold hides. One press, the whole row, opens it; the line grows back down. */
function CommentUnfold() {
  const c = useOwnComment("CommentUnfold")
  const what = c.replies === 1 ? "1 reply" : `${c.replies} replies`
  return (
    <button
      type="button"
      aria-expanded={false}
      aria-label={`expand ${c.author}'s comment${c.replies > 0 ? `, ${what} hidden` : ""}`}
      data-slot="comment-toggle"
      onClick={() => c.setCollapsed(false)}
      className={cn(
        "group/unfold col-span-2 grid w-fit cursor-pointer grid-cols-[1.5rem_auto] items-center gap-x-2.5 text-current/40 transition-colors hover:text-current/80",
        META,
        "text-current/40",
      )}
    >
      <span aria-hidden className="relative mx-auto size-3">
        <span className="absolute inset-y-0 left-1/2 w-px origin-top -translate-x-1/2 rounded-full bg-current transition-transform duration-200 ease-out motion-reduce:transition-none starting:scale-y-[4]" />
        <span className="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 rounded-full bg-current transition-transform duration-200 ease-out motion-reduce:transition-none starting:scale-x-0" />
      </span>
      {c.replies > 0 && (
        <span data-slot="comment-hidden" className="text-left">
          {what} hidden
        </span>
      )}
    </button>
  )
}

/** A person's face in the gutter: their picture, or their initial (avatar). The name
 *  stands beside it, so it is decorative. */
export function CommentFace({
  person,
  className,
}: {
  person: Person
  className?: string
}) {
  return (
    <Avatar
      src={person.avatar}
      alt=""
      name={person.name}
      size="sm"
      className={cn("shrink-0", className)}
    />
  )
}

/** The comment's first line: who and when. Folded, it is all that is drawn, and says
 *  at its end how many replies the fold hides; nothing on it moves. */
export function CommentHead({
  className,
  children,
  ...props
}: React.ComponentProps<"header">) {
  const c = useOwnComment("CommentHead")
  return (
    <header
      id={c.headId}
      data-slot="comment-head"
      className={cn(
        "flex min-h-7 flex-wrap items-center gap-x-1.5 rounded-md",
        className,
      )}
      {...props}
    >
      {children}
    </header>
  )
}

/** Who wrote it: the name at full ink, and what stands beside it (`children`: a
 *  badge, "OP", a flair). */
export function CommentAuthor({
  person,
  className,
  children,
}: {
  person: Person
  className?: string
  children?: React.ReactNode
}) {
  return (
    <span
      data-slot="comment-author"
      className={cn("inline-flex items-center gap-1.5", className)}
    >
      <span className={NAME}>{person.name}</span>
      {children}
    </span>
  )
}

/** When, said from now; with `href` it is the comment's permalink, as on every
 *  forum. */
export function CommentTime({
  at,
  href,
  className,
}: {
  at: number
  href?: string
  className?: string
}) {
  const time = <RelativeTime at={at} />
  return (
    <span data-slot="comment-time" className={cn(META, className)}>
      <span aria-hidden>· </span>
      {href ? (
        <a
          href={href}
          className="rounded-sm underline-offset-2 hover:text-current/80 hover:underline"
        >
          {time}
        </a>
      ) : (
        time
      )}
    </span>
  )
}

/** One more post a comment answers: what a link to it says and where it goes. */
export interface CommentLinkTo {
  id: string
  label: string
  href: string
}

/** The other posts it answers, where it quotes several and is drawn under one
 *  (lib/comment-thread-core's rule): "also replying to ana, ben". A press follows the
 *  link, or `onFollow` (a thread revealing a comment folded or paged away). */
export function CommentAlso({
  to,
  onFollow,
  className,
}: {
  to: readonly CommentLinkTo[]
  onFollow?: (id: string) => void
  className?: string
}) {
  const c = useOwnComment("CommentAlso")
  if (c.collapsed || to.length === 0) return null
  return (
    <p
      data-slot="comment-also"
      className={cn("flex flex-wrap items-center gap-x-1", META, className)}
    >
      <CornerDownRight aria-hidden className="size-3 text-current/45" />
      also replying to
      {to.map((t, i) => (
        <React.Fragment key={t.id}>
          <a
            href={t.href}
            onClick={
              onFollow
                ? (event) => {
                    event.preventDefault()
                    onFollow(t.id)
                  }
                : undefined
            }
            className="rounded-sm text-current/75 underline decoration-current/25 underline-offset-2 hover:text-current hover:decoration-current/60"
          >
            {t.label}
          </a>
          {i < to.length - 1 && <span aria-hidden>,</span>}
        </React.Fragment>
      ))}
    </p>
  )
}

/** The words: markdown text (drawn by `markdown`, nothing embedded from another
 *  host), or content the consumer rendered already (a forum's HTML as React), worn
 *  in markdown's look so both read the same. One or the other. */
export function CommentBody({
  markdown,
  children,
  className,
}: {
  markdown?: string
  children?: React.ReactNode
  className?: string
}) {
  const c = useOwnComment("CommentBody")
  const given = (markdown !== undefined ? 1 : 0) + (children != null ? 1 : 0)
  if (given !== 1)
    throw new Error(
      `CommentBody ${c.id}: markdown or children, one of them (${given} given)`,
    )
  if (c.collapsed) return null
  return (
    <div data-slot="comment-body" className={cn("mt-0.5", className)}>
      {markdown !== undefined ? (
        <Markdown
          embeds={keepLinks}
          components={{ blockquote: CommentQuote }}
          className={BODY}
        >
          {markdown}
        </Markdown>
      ) : (
        <div className={cn(MARKDOWN, BODY)}>{children}</div>
      )}
    </div>
  )
}

/** Text anyone wrote loads nothing from another host: every link stays a link. */
const keepLinks = () => null

/** A comment's words: smaller than a page's, and a QUOTE IS THE CLASSIC INDENTED ONE
 *  (Adrian, 2026-10-29: markdown's quote, the serif on a surface with its mark, "def
 *  not this" in a comment): a hairline at the left, the quoted words a step dimmer, in
 *  the comment's own voice. Written on markdown's own blockquote rule, the same
 *  variant, so these win over it (tailwind-merge keeps the later of two of a kind)
 *  for markdown's quotes and for a forum's own `<blockquote>` alike. */
const BODY = [
  "text-[14px] leading-[1.6]",
  "[&_blockquote:not([data-slot=quote-body])]:my-[0.75em] [&_blockquote:not([data-slot=quote-body])]:rounded-none [&_blockquote:not([data-slot=quote-body])]:border-l-2 [&_blockquote:not([data-slot=quote-body])]:border-current/20 [&_blockquote:not([data-slot=quote-body])]:bg-transparent [&_blockquote:not([data-slot=quote-body])]:py-0 [&_blockquote:not([data-slot=quote-body])]:pr-0 [&_blockquote:not([data-slot=quote-body])]:pl-[0.85em]",
  "[&_blockquote:not([data-slot=quote-body])]:font-[family-name:inherit] [&_blockquote:not([data-slot=quote-body])]:text-[1em] [&_blockquote:not([data-slot=quote-body])]:text-current/60 [&_blockquote:not([data-slot=quote-body])]:before:content-none",
].join(" ")

/** A quote in a comment's markdown: the words and, when its last line names them,
 *  who said it. Drawn plain; the look is the body's (`BODY`). */
export function CommentQuote({
  author,
  children,
}: {
  author?: { name: string; href?: string | null } | null
  children?: React.ReactNode
}) {
  return (
    <blockquote data-slot="comment-quote">
      {children}
      {author && (
        <p className="text-[0.85em] text-current/50">
          {author.href ? <a href={author.href}>{author.name}</a> : author.name}
        </p>
      )}
    </blockquote>
  )
}

/** What to do with it, under the words: the standing (`Vote`, `Reactions`), reply,
 *  link, more. */
export function CommentActions({
  className,
  ...props
}: React.ComponentProps<"div">) {
  const c = useOwnComment("CommentActions")
  if (c.collapsed) return null
  return (
    <div
      data-slot="comment-actions"
      className={cn(
        "-ml-1.5 mt-1 flex flex-wrap items-center gap-1",
        className,
      )}
      {...props}
    />
  )
}

/** Reply: opens the box under the comment (`CommentComposer`), pressed while open. */
export function CommentReply({
  open = false,
  onClick,
}: {
  open?: boolean
  onClick: () => void
}) {
  const c = useOwnComment("CommentReply")
  return (
    <Tip content={`reply to ${c.author}`} keys="r">
      <Button
        variant="ghost"
        size="sm"
        icon={<Reply />}
        pressed={open}
        aria-label={`reply to ${c.author}`}
        data-slot="comment-reply"
        onClick={onClick}
        className="text-current/60 hover:text-current"
      >
        reply
      </Button>
    </Tip>
  )
}

/** Copies the comment's permalink, made whole from where the page is. */
export function CommentLink({ href }: { href: string }) {
  return (
    <Copy
      value={() => new URL(href, window.location.href).href}
      tip="copy a link to this comment"
      className="text-current/60 hover:text-current"
    />
  )
}

/** More verbs, under ⋯ (menu): yours (`entries`), and below them the comment's own,
 *  each with its key. */
export function CommentMenu({
  entries = [],
  onReply,
  onParent,
}: {
  entries?: readonly MenuEntry[]
  /** Given, "reply" is listed (r). */
  onReply?: () => void
  /** Given, "go to the parent" is listed (p). */
  onParent?: () => void
}) {
  const c = useOwnComment("CommentMenu")
  const [anchor, setAnchor] = React.useState<Element | null>(null)
  const button = React.useRef<HTMLButtonElement>(null)
  const own: MenuEntry[] = [
    ...(onReply
      ? [{ icon: <Reply />, label: "reply", hint: "r", onSelect: onReply }]
      : []),
    ...(onParent
      ? [
          {
            icon: <CornerDownRight className="-scale-y-100" />,
            label: "go to the parent",
            hint: "p",
            onSelect: onParent,
          },
        ]
      : []),
    {
      icon: <Minus />,
      label: "collapse",
      hint: "enter",
      onSelect: () => c.setCollapsed(true),
    },
  ]
  return (
    <>
      <Button
        ref={button}
        variant="ghost"
        size="icon-sm"
        aria-label={`more on ${c.author}'s comment`}
        aria-haspopup="menu"
        aria-expanded={anchor !== null}
        data-slot="comment-menu"
        onClick={(event) => setAnchor(event.currentTarget)}
        className="text-current/60 hover:text-current"
      >
        <Ellipsis aria-hidden />
      </Button>
      <Menu
        entries={entries.length ? [...entries, "separator", ...own] : own}
        anchor={anchor}
        onClose={() => setAnchor(null)}
        returnTo={() => button.current}
      />
    </>
  )
}

/** The replies under a comment, a group named for whose they are. */
export function CommentReplies({
  className,
  ...props
}: React.ComponentProps<"div">) {
  const c = useOwnComment("CommentReplies")
  if (c.collapsed) return null
  return (
    // biome-ignore lint/a11y/useSemanticElements: a group of articles, not a fieldset of fields
    <div
      role="group"
      aria-label={`replies to ${c.author}`}
      data-slot="comment-replies"
      className={cn("mt-3 flex flex-col gap-4", className)}
      {...props}
    />
  )
}

/** What is not drawn yet: "N more replies" (`more`), or "continue this thread" past
 *  the depth a thread is drawn to (`continue`), a link when given `href`. */
export function CommentMore({
  kind = "more",
  count,
  href,
  onClick,
  className,
}: {
  kind?: "more" | "continue"
  /** How many it would show. */
  count: number
  href?: string
  onClick?: () => void
  className?: string
}) {
  if (!Number.isInteger(count) || count < 1)
    throw new Error(`CommentMore: count is at least 1, got ${count}`)
  const replies = count === 1 ? "1 reply" : `${count} replies`
  const words =
    kind === "more"
      ? `${count} more ${count === 1 ? "reply" : "replies"}`
      : `continue this thread (${replies})`
  return (
    <Button
      variant="ghost"
      size="sm"
      data-slot="comment-more"
      data-kind={kind}
      icon={kind === "more" ? <Plus /> : undefined}
      iconEnd={kind === "continue" ? <ChevronRight /> : undefined}
      // biome-ignore lint/a11y/useAnchorContent: the button's words are the link's content
      render={href ? <a href={href} /> : undefined}
      onClick={onClick}
      className={cn(
        "-ml-1.5 w-fit text-current/65 hover:text-current",
        className,
      )}
    >
      {words}
    </Button>
  )
}

/** Once the editor has read what was typed into the page: it takes in input on its
 *  own update, a frame after the keys at most, so two frames are past it. */
const afterEditor = () =>
  new Promise<void>((done) =>
    requestAnimationFrame(() => requestAnimationFrame(() => done())),
  )

/** The reply box, opened inline: the editor as a field, written in markdown, sent
 *  with ⌘↩ or its button, put away with Esc or cancel. While it sends, nothing more
 *  can be typed into it (what is sent is what was written); refused, the text stays
 *  and says why. */
export function CommentComposer({
  label,
  placeholder = "write a reply",
  submit = "reply",
  onSubmit,
  onClose,
  className,
}: {
  /** The field's name: "reply to ana". */
  label: string
  placeholder?: string
  /** The send button's word. */
  submit?: string
  /** The markdown written; a promise returned holds the box until it settles, a
   *  rejection keeps the text and says why. */
  onSubmit: (markdown: string) => unknown
  /** Cancelled, or sent. */
  onClose: () => void
  className?: string
}) {
  const c = useComment()
  const [text, setText] = React.useState("")
  const [sending, setSending] = React.useState(false)
  const [refused, setRefused] = React.useState<string | null>(null)
  const form = React.useRef<HTMLFormElement>(null)
  // What is sent is what the editor holds at the press, read from these, never from a
  // render that has not caught up with the last keystroke; one send at a time.
  const words = React.useRef("")
  const busy = React.useRef(false)
  const empty = text.trim() === ""
  useEscape(onClose, { active: !sending && !c?.collapsed, root: form })
  if (c?.collapsed) return null
  const write = (value: string) => {
    words.current = value
    setText(value)
  }
  const send = async () => {
    const sent = words.current
    if (sent.trim() === "" || busy.current) return
    busy.current = true
    setSending(true)
    setRefused(null)
    try {
      await onSubmit(sent)
      onClose()
    } catch (cause) {
      busy.current = false
      setRefused(cause instanceof Error ? cause.message : String(cause))
      setSending(false)
      // Back to the words, to change them or send again.
      requestAnimationFrame(() =>
        form.current?.querySelector<HTMLElement>("[role=textbox]")?.focus(),
      )
    }
  }
  return (
    <form
      ref={form}
      aria-label={label}
      data-slot="comment-composer"
      className={cn("mt-2 flex flex-col gap-2", className)}
      onSubmit={(event) => {
        event.preventDefault()
        void send()
      }}
      // ⌘↩ (⌃↩) sends, as every comment box does. Taken from the editor's own keys
      // (prevented), yet let through to it, and the send waits for the editor's next
      // update: it reads what was typed into the page a frame later (WebKit later than
      // Chromium), and a send at the key itself went without the last characters.
      onKeyDownCapture={(event) => {
        if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return
        event.preventDefault()
        void afterEditor().then(send)
      }}
    >
      <div inert={sending}>
        <Editor
          variant="field"
          format="markdown"
          images={false}
          rows={3}
          autoFocus
          label={label}
          placeholder={placeholder}
          onChange={write}
        />
      </div>
      <div className="flex flex-wrap items-center justify-end gap-2">
        {refused && (
          <p role="alert" className="mr-auto text-[12px] text-tone-bad">
            not sent: {refused}
          </p>
        )}
        <Button variant="ghost" size="sm" onClick={onClose} disabled={sending}>
          cancel
        </Button>
        <Tip content={submit} keys="mod+enter">
          <Button
            type="submit"
            size="sm"
            loading={sending}
            disabled={empty}
            data-slot="comment-send"
          >
            {submit}
          </Button>
        </Tip>
      </div>
    </form>
  )
}

// ── the preset ───────────────────────────────────────────────────────────────────

/** One comment as data: a post of lib/comment-thread-core, who wrote it, its words, and
 *  its standing as a vote or as likes. */
export interface CommentData extends Omit<ThreadPost, "score"> {
  /** A key into `people`. */
  from: string
  /** Markdown text, drawn by markdown. */
  markdown?: string
  /** Or content rendered already (a forum's HTML as React), worn in markdown's look. */
  body?: React.ReactNode
  /** A score that is voted up and down (Reddit): drawn by `vote`. */
  score?: number
  /** Yours, with `score`: 1, -1, 0. */
  vote?: VoteValue
  /** Or likes (a forum): how many, whether yours is among them, who gave them, as
   *  many names as are known. Drawn by `reactions`, sorted by the count. */
  likes?: { count: number; mine: boolean; who?: readonly string[] }
}

/** A comment as the tree holds it: its standing, whichever it is, as the sort's
 *  score. */
type Post = CommentData & { score?: number }

export interface CommentThreadProps {
  /** Every comment, oldest first: a parent before its replies (lib/comment-thread-core). */
  comments: readonly CommentData[]
  /** Everyone who wrote, by the key a comment names them with. */
  people: Record<string, Person>
  /** Each level's order: `old` (as given, a forum's), `new`, `top`, `hot`. */
  sort?: ThreadSort
  /** The instant `hot` ages comments from. */
  now?: number
  /** A comment's element id; its permalink's fragment. Default `comment-<id>`. */
  domId?: (id: string) => string
  /** A comment's permalink, any id including those outside the thread (a quoted post
   *  on another page). Default `#<domId>`. */
  href?: (id: string) => string
  /** Where "continue this thread" goes. Absent, the thread draws that comment alone
   *  in place, with the way back. */
  continueHref?: (id: string) => string
  onVote?: (id: string, value: VoteValue) => void
  onLike?: (id: string) => void
  /** A reply written (`parent` null: a new comment); a promise holds the box. Absent,
   *  nothing offers to reply. */
  onReply?: (parent: string | null, markdown: string) => unknown
  /** Your verbs for a comment, in its ⋯ menu (report, quote, edit). */
  menu?: (comment: CommentData) => readonly MenuEntry[]
  /** Levels drawn before "continue this thread". */
  maxDepth?: number
  /** Comments drawn a level (the top included) before "N more". */
  page?: number
  /** Comments folded when the thread is first drawn. */
  defaultCollapsed?: readonly string[]
  /** A like's glyph. */
  like?: string
  label?: string
  material?: Material
  scheme?: Scheme
  className?: string
}

/** What a view shows: which comments are folded, how many of each level's replies
 *  are drawn (`""` the top), the comment drawn as the whole thread (continued), the
 *  open reply box, and the comment to take focus once drawn. */
interface View {
  folded: ReadonlySet<string>
  shown: Readonly<Record<string, number>>
  alone: string | null
  replying: { to: string | null } | null
  focus: string | null
}

/** A discussion from data: the tree built (a comment quoting several drawn under the
 *  latest, the rest linked), each comment composed of the parts, the DOM bounded by
 *  `page` and `maxDepth`. */
export function CommentThread({
  comments,
  people,
  sort = "old",
  now,
  domId = (id) => `comment-${id}`,
  href,
  continueHref,
  onVote,
  onLike,
  onReply,
  menu,
  maxDepth = 6,
  page = 8,
  defaultCollapsed = [],
  like = "❤️",
  label = "comments",
  material,
  scheme,
  className,
}: CommentThreadProps) {
  if (!Number.isInteger(maxDepth) || maxDepth < 1)
    throw new Error(`CommentThread: maxDepth is at least 1, got ${maxDepth}`)
  if (!(page >= 1))
    throw new Error(`CommentThread: page is at least 1, got ${page}`)
  const thread = React.useMemo(() => {
    const posts: Post[] = comments.map((c) => {
      if (!people[c.from])
        throw new Error(`CommentThread: "${c.from}" (${c.id}) is not in people`)
      if (c.likes && c.score !== undefined)
        throw new Error(`CommentThread: ${c.id} has a score and likes; one`)
      if (c.vote !== undefined && c.score === undefined)
        throw new Error(`CommentThread: ${c.id} has your vote and no score`)
      return c.likes ? { ...c, score: c.likes.count } : c
    })
    return buildThread(posts, { sort, now })
  }, [comments, people, sort, now])
  const linkOf = href ?? ((id: string) => `#${domId(id)}`)
  const [view, setView] = React.useState<View>(() => ({
    folded: new Set(defaultCollapsed),
    shown: {},
    alone: null,
    replying: null,
    focus: null,
  }))

  // A comment asked for (a link followed, a thread left, a box put away) takes focus
  // once it is drawn.
  React.useEffect(() => {
    if (!view.focus) return
    focusComment(document.getElementById(domId(view.focus)))
    setView((v) => ({ ...v, focus: null }))
  }, [view.focus, domId])

  const base = view.alone ? nodeOf(thread, view.alone).depth : 0
  const reveal = (id: string) =>
    setView((v) => revealed(thread, v, id, page, maxDepth))

  const draw = (node: ThreadNode<Post>): React.ReactNode => {
    const c = node.post
    const person = people[c.from] as Person
    const depth = node.depth - base
    const shown = view.shown[node.id] ?? page
    const replying = view.replying?.to === node.id
    const fold = (folded: boolean) =>
      setView((v) => {
        const next = new Set(v.folded)
        if (folded) next.add(node.id)
        else next.delete(node.id)
        return { ...v, folded: next }
      })
    const reply = onReply
      ? () =>
          setView((v) => ({
            ...v,
            replying: replying ? null : { to: node.id },
          }))
      : undefined
    const also = [
      ...node.also.map((id) => ({
        id,
        label: (people[nodeOf(thread, id).post.from] as Person).name,
        href: linkOf(id),
      })),
      ...node.outside.map((id) => ({ id, label: `#${id}`, href: linkOf(id) })),
    ]
    return (
      <Comment
        key={node.id}
        id={node.id}
        domId={domId(node.id)}
        author={person.name}
        face={<CommentFace person={person} />}
        replies={node.replies}
        collapsed={view.folded.has(node.id)}
        onCollapsedChange={fold}
      >
        <CommentHead>
          <CommentAuthor person={person} />
          <CommentTime at={c.at} href={linkOf(node.id)} />
        </CommentHead>
        <CommentAlso
          to={also}
          onFollow={(id) => (thread.byId.has(id) ? reveal(id) : undefined)}
        />
        {c.markdown !== undefined ? (
          <CommentBody markdown={c.markdown} />
        ) : (
          <CommentBody>{c.body}</CommentBody>
        )}
        <CommentActions>
          {c.likes ? (
            <Reactions
              aria-label={`likes on ${person.name}'s comment`}
              reactions={[{ emoji: like, label: "like", ...c.likes }]}
              onToggle={onLike ? () => onLike(node.id) : undefined}
            />
          ) : (
            c.score !== undefined && (
              <Vote
                compact
                size="sm"
                orientation="horizontal"
                aria-label={`vote on ${person.name}'s comment`}
                value={c.vote ?? 0}
                score={c.score}
                readOnly={!onVote}
                onValueChange={(v) => onVote?.(node.id, v)}
              />
            )
          )}
          {reply && <CommentReply open={replying} onClick={reply} />}
          <CommentLink href={linkOf(node.id)} />
          <CommentMenu
            entries={menu?.(c) ?? []}
            onReply={reply}
            onParent={
              node.parent !== null && depth > 0
                ? () => setView((v) => ({ ...v, focus: node.parent }))
                : undefined
            }
          />
        </CommentActions>
        {replying && onReply && (
          <CommentComposer
            label={`reply to ${person.name}`}
            onSubmit={(markdown) => onReply(node.id, markdown)}
            onClose={() =>
              setView((v) => ({ ...v, replying: null, focus: node.id }))
            }
          />
        )}
        {node.children.length > 0 && (
          <CommentReplies>
            {depth >= maxDepth - 1 ? (
              <CommentMore
                kind="continue"
                count={node.replies}
                href={continueHref?.(node.id)}
                onClick={
                  continueHref
                    ? undefined
                    : () => setView((v) => ({ ...v, alone: node.id }))
                }
              />
            ) : (
              <>
                {node.children.slice(0, shown).map(draw)}
                {node.children.length > shown && (
                  <CommentMore
                    count={Math.min(page, node.children.length - shown)}
                    onClick={() => setView((v) => more(v, node.id, page))}
                  />
                )}
              </>
            )}
          </CommentReplies>
        )}
      </Comment>
    )
  }

  const top = view.alone ? [nodeOf(thread, view.alone)] : thread.roots
  const topShown = view.alone ? 1 : (view.shown[""] ?? page)
  const writingNew = view.replying !== null && view.replying.to === null
  return (
    <Comments
      label={label}
      material={material}
      scheme={scheme}
      className={className}
      data-sort={thread.sort}
    >
      {view.alone && (
        <div
          data-slot="comment-thread-alone"
          className="flex items-center gap-2"
        >
          <Button
            variant="ghost"
            size="sm"
            icon={<ArrowLeft />}
            onClick={() =>
              setView((v) => ({ ...v, alone: null, focus: v.alone }))
            }
            className="-ml-1.5 text-current/70 hover:text-current"
          >
            every comment
          </Button>
          <span className={META}>one thread, continued</span>
        </div>
      )}
      {onReply && !view.alone && (
        <div data-slot="comment-thread-new">
          {writingNew ? (
            <CommentComposer
              label="a new comment"
              placeholder="add a comment"
              submit="comment"
              onSubmit={(markdown) => onReply(null, markdown)}
              onClose={() => setView((v) => ({ ...v, replying: null }))}
            />
          ) : (
            <Button
              variant="outline"
              size="sm"
              icon={<Plus />}
              onClick={() => setView((v) => ({ ...v, replying: { to: null } }))}
            >
              add a comment
            </Button>
          )}
        </div>
      )}
      {top.length === 0 && <p className={META}>no comments yet</p>}
      {top.slice(0, topShown).map(draw)}
      {top.length > topShown && (
        <CommentMore
          count={Math.min(page, top.length - topShown)}
          onClick={() => setView((v) => more(v, "", page))}
        />
      )}
    </Comments>
  )
}

/** One more page of a level's replies (`""` the top). */
function more(v: View, level: string, page: number): View {
  return {
    ...v,
    shown: { ...v.shown, [level]: (v.shown[level] ?? page) + page },
  }
}

/** The view that shows comment `id` and focuses it: every comment above it unfolded,
 *  every level's replies drawn past its place, and, when it sits deeper than the
 *  thread draws, the thread continued from the ancestor that brings it within reach. */
function revealed(
  thread: Thread<Post>,
  v: View,
  id: string,
  page: number,
  maxDepth: number,
): View {
  const path = pathTo(thread, id)
  const node = nodeOf(thread, id)
  const folded = new Set(v.folded)
  const shown = { ...v.shown }
  for (const step of path) {
    const level = step.parent ?? ""
    shown[level] = Math.max(shown[level] ?? page, step.index + 1)
    if (step.parent !== null) folded.delete(step.parent)
  }
  // Drawn from `alone`, if it is above the comment and close enough; else from the
  // ancestor maxDepth - 1 levels up, or the top.
  const within = (from: string | null) =>
    from === null
      ? node.depth <= maxDepth - 1
      : path.some((s) => s.parent === from) &&
        node.depth - nodeOf(thread, from).depth <= maxDepth - 1
  const ancestorAt = (depth: number) => {
    let at = node
    while (at.depth > depth) at = nodeOf(thread, at.parent as string)
    return at.id
  }
  const keep = v.alone !== null && (v.alone === id || within(v.alone))
  const alone = keep
    ? v.alone
    : within(null)
      ? null
      : ancestorAt(node.depth - (maxDepth - 1))
  return { ...v, folded, shown, alone, focus: id }
}
comment-thread-core.ts
// A DISCUSSION'S SHAPE, framework-free: flat posts in, the tree a comment section draws
// out. A server can build it, a test can read it, ui/comment-thread draws it.
//
//   const thread = buildThread(posts, { sort: "top", now: Date.now() })
//   thread.roots            the top-level comments, sorted
//   thread.byId.get(id)     any comment: its replies, its depth, what else it answers
//   pathTo(thread, id)      where it sits, for revealing it inside collapsed or paged
//                           replies
//
// A POST NAMES ITS PARENTS, any number of them. Reddit and Hacker News give one; a
// forum post that quotes three earlier posts answers all three, so a discussion is a
// graph (a DAG), not a tree. A tree view still draws each post once, so:
//
//   THE RULE: a post is drawn under its LATEST parent, the one that comes last in the
//   posts' order (the most recent post it answers). Every other parent it names is
//   listed in `also`, for a "also replying to" link, except a parent that is already
//   an ancestor of where it is drawn (answering #3 and #7 where #7 answers #3: #3 is
//   right above it, a link would point at what the reader just read). A parent that
//   is not among the posts (deleted, on another page of the forum) is in `outside`,
//   never invented; a post whose parents are all outside is drawn at the top.
//
// Why the latest: it is the exchange the post continues, it is the deepest of the
// places it could go when the parents are one chain, and under it the post is read
// after everything it answers.
//
// POSTS COME OLDEST FIRST, the order a forum or an API lists them in: every parent
// present comes before the post that names it. That is asserted, and it is what makes
// a cycle impossible to write. `at` (ms since the epoch) is when it was written and
// `score` its standing (votes, likes), both read only by the sort.
//
// THE SORT is the consumer's choice, applied to every set of siblings: `old` (the
// posts' order, a forum's), `new`, `top` (score, then older first) and `hot`
// (Hacker News's gravity: score over age, so a fresh good reply climbs past an old
// one; it reads `now`).

export type ThreadSort = "old" | "new" | "top" | "hot"

export interface ThreadPost {
  readonly id: string
  /** The posts it answers or quotes, in the post's own order. None: top level. */
  readonly parents?: readonly string[]
  /** When it was written, ms since the epoch. */
  readonly at: number
  /** Its standing (a vote score, a like count), for `top` and `hot`. Default 0. */
  readonly score?: number
}

export interface ThreadNode<P extends ThreadPost> {
  readonly post: P
  readonly id: string
  /** The parent it is drawn under; null at the top. */
  readonly parent: string | null
  /** The other parents it answers that are in the thread and not above it. */
  readonly also: readonly string[]
  /** The parents it names that are not in the thread. */
  readonly outside: readonly string[]
  /** 0 at the top. */
  readonly depth: number
  /** Its direct replies, sorted. */
  readonly children: readonly ThreadNode<P>[]
  /** Every reply under it, at any depth. */
  readonly replies: number
}

export interface Thread<P extends ThreadPost> {
  readonly roots: readonly ThreadNode<P>[]
  readonly byId: ReadonlyMap<string, ThreadNode<P>>
  readonly sort: ThreadSort
}

/** Where a post is drawn and what else it answers, decided before the tree is built. */
type Placement = {
  parent: string | null
  also: readonly string[]
  outside: readonly string[]
}

/** Hacker News's gravity: how fast age outweighs score. */
const GRAVITY = 1.8
const HOUR = 3_600_000

/** A post's heat at `now`: its score over its age in hours (plus two, so the first
 *  hours do not divide by nothing), raised to the gravity. */
export function heat(post: ThreadPost, now: number): number {
  const hours = Math.max(0, now - post.at) / HOUR
  return (post.score ?? 0) / (hours + 2) ** GRAVITY
}

/** Builds the tree (see the header for the rule). `now` is read by `hot` alone, and
 *  required there: a sort that read the clock itself would order a server's render and
 *  the browser's differently. */
export function buildThread<P extends ThreadPost>(
  posts: readonly P[],
  { sort = "old", now }: { sort?: ThreadSort; now?: number } = {},
): Thread<P> {
  if (sort === "hot" && now === undefined)
    throw new Error("comment-thread: the hot sort reads `now`; pass it")
  const all = new Set(posts.map((p) => p.id))
  const order = new Map<string, number>()
  posts.forEach((p, i) => {
    if (!p.id) throw new Error(`comment-thread: post ${i} has no id`)
    if (order.has(p.id))
      throw new Error(`comment-thread: two posts are "${p.id}"`)
    if (!Number.isFinite(p.at))
      throw new Error(`comment-thread: "${p.id}" has no instant (at ${p.at})`)
    if (p.score !== undefined && !Number.isFinite(p.score))
      throw new Error(`comment-thread: "${p.id}" scores ${p.score}`)
    const parents = p.parents ?? []
    if (new Set(parents).size !== parents.length)
      throw new Error(`comment-thread: "${p.id}" names a parent twice`)
    for (const parent of parents) {
      if (parent === p.id)
        throw new Error(`comment-thread: "${p.id}" answers itself`)
      // Absent from what came before: either outside the thread, or listed after
      // the post that answers it, which is the out-of-order list the header forbids.
      if (!order.has(parent) && all.has(parent))
        throw new Error(
          `comment-thread: "${p.id}" answers "${parent}", which comes after it; posts come oldest first`,
        )
    }
    order.set(p.id, i)
  })

  // Where each post is drawn, decided in order: a parent's place is known before
  // any post that answers it.
  const placed = new Map<string, string | null>()
  const ancestors = (id: string | null): Set<string> => {
    const out = new Set<string>()
    for (let at = id; at !== null; at = placed.get(at) ?? null) out.add(at)
    return out
  }
  const facts = new Map<string, Placement>()
  const kids = new Map<string | null, P[]>()
  for (const p of posts) {
    const parents = p.parents ?? []
    const present = parents.filter((id) => order.has(id))
    const outside = parents.filter((id) => !order.has(id))
    const parent =
      present.length === 0
        ? null
        : present.reduce((a, b) =>
            (order.get(a) as number) > (order.get(b) as number) ? a : b,
          )
    const above = ancestors(parent)
    const also = present.filter((id) => id !== parent && !above.has(id))
    placed.set(p.id, parent)
    facts.set(p.id, { parent, also, outside })
    // Local to the build: each list is gathered here once and never seen half-made.
    const siblings = kids.get(parent)
    if (siblings) siblings.push(p)
    else kids.set(parent, [p])
  }

  const rank = ranker(sort, order, now ?? 0)
  const byId = new Map<string, ThreadNode<P>>()
  const make = (post: P, depth: number): ThreadNode<P> => {
    const f = facts.get(post.id) as Placement
    const children = (kids.get(post.id) ?? [])
      .toSorted(rank)
      .map((c) => make(c, depth + 1))
    const node: ThreadNode<P> = {
      post,
      id: post.id,
      parent: f.parent,
      also: f.also,
      outside: f.outside,
      depth,
      children,
      replies: children.reduce((n, c) => n + 1 + c.replies, 0),
    }
    byId.set(post.id, node)
    return node
  }
  const roots = (kids.get(null) ?? []).toSorted(rank).map((p) => make(p, 0))
  if (byId.size !== posts.length)
    throw new Error(
      `comment-thread: ${posts.length} posts drew ${byId.size} comments`,
    )
  return { roots, byId, sort }
}

/** Siblings' order under each sort; the posts' own order breaks every tie, so the
 *  same posts always come out the same way. */
function ranker(
  sort: ThreadSort,
  order: ReadonlyMap<string, number>,
  now: number,
): (a: ThreadPost, b: ThreadPost) => number {
  const place = (p: ThreadPost) => order.get(p.id) as number
  switch (sort) {
    case "old":
      return (a, b) => place(a) - place(b)
    case "new":
      return (a, b) => b.at - a.at || place(b) - place(a)
    case "top":
      return (a, b) =>
        (b.score ?? 0) - (a.score ?? 0) || a.at - b.at || place(a) - place(b)
    case "hot":
      return (a, b) => heat(b, now) - heat(a, now) || place(b) - place(a)
  }
}

/** One step down to a comment: whose child it is (null: the top level) and its place
 *  among those siblings, as sorted. */
export interface ThreadStep {
  readonly parent: string | null
  readonly index: number
}

/** The steps from the top down to `id`, the last being its own place: what a view
 *  opens (each parent expanded, each list of replies shown past that place) to show
 *  a comment hidden inside collapsed or paged replies. */
export function pathTo<P extends ThreadPost>(
  thread: Thread<P>,
  id: string,
): ThreadStep[] {
  const node = thread.byId.get(id)
  if (!node) throw new Error(`comment-thread: no comment "${id}"`)
  const up: ThreadStep[] = []
  for (let at: ThreadNode<P> | undefined = node; at; ) {
    const parent: string | null = at.parent
    const siblings =
      parent === null ? thread.roots : nodeOf(thread, parent).children
    const index = siblings.indexOf(at)
    if (index < 0)
      throw new Error(`comment-thread: "${at.id}" is not among its siblings`)
    up.push({ parent, index })
    at = parent === null ? undefined : nodeOf(thread, parent)
  }
  return up.reverse()
}

/** The comment `id`, which must be in the thread. */
export function nodeOf<P extends ThreadPost>(
  thread: Thread<P>,
  id: string,
): ThreadNode<P> {
  const node = thread.byId.get(id)
  if (!node) throw new Error(`comment-thread: no comment "${id}"`)
  return node
}