{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "thread",
  "title": "Thread",
  "description": "A message scroller that keeps to the latest while you are at the end. Scrolled back, it stays put and counts what is new in an ink pill.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/marker.json",
    "https://thedirectors.agency/ui/r/scroll-area.json",
    "https://thedirectors.agency/ui/r/scrollbar.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/thread.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { roll } from \"@/lib/0nlytype/roll\"\nimport { Marker, type MarkerProps } from \"@/components/ui/marker\"\nimport { Scrollbar } from \"@/components/ui/scrollbar\"\n\ntype ThreadProps = Omit<React.ComponentProps<\"div\">, \"aria-label\"> & {\n  /** Names the log: \"Halden thread\". */\n  label: string\n  /** The word after the count in the jump: \"2 new\". */\n  newLabel?: string\n  /**\n   * rests: the space between two messages is the silence between them, and a pause of an hour or more is said in\n   * the silence, as a lapse marker says it (\"3 hours later\"). running: scrolled back, a running head on the top edge says which day you are in.\n   */\n  variant?: \"default\" | \"rests\" | \"running\"\n}\n\n/** Within this many px of the end still counts as being at the end. */\nconst NEAR = 40\n\n/** A pause long enough to be marked, in minutes. */\nconst LONG_REST = 60\n\n/** How a long rest reads, in the marker lapse's words: \"3 hours later\", or \"2 days later\" across days with no divider. */\nconst restLabel = (mins: number) => {\n  const [n, unit] = mins < 1440 ? [Math.round(mins / 60), \"hour\"] : [Math.round(mins / 1440), \"day\"]\n  return n === 1 ? `A${unit === \"hour\" ? \"n\" : \"\"} ${unit} later` : `${n} ${unit}s later`\n}\n/** How far the lapse spreads its letters, 0 to 1: the marker's own scale, a minute (a breath) to a week (the widest). */\nconst lapseOf = (mins: number) => Math.min(1, Math.log1p(mins) / Math.log1p(7 * 1440))\n\n/**\n * Rests: each message's gap grows with the log of the minutes since the one before (read from its <time dateTime>).\n * A day divider breaks the count, since it already says the time has passed.\n */\nfunction measureRests(scroller: HTMLElement) {\n  let prev: number | undefined\n  for (const n of Array.from(scroller.children)) {\n    if (!(n instanceof HTMLElement)) continue\n    if (n.matches('[data-slot=\"thread-day\"]')) {\n      prev = undefined\n      continue\n    }\n    if (!n.matches('[data-slot=\"message\"]')) continue\n    const t = Date.parse(n.querySelector(\"time\")?.dateTime ?? \"\")\n    const mins = prev === undefined || Number.isNaN(t) ? undefined : (t - prev) / 60000\n    if (!Number.isNaN(t)) prev = t\n    if (mins === undefined || mins < 0) {\n      n.style.removeProperty(\"--ot-rest\")\n      n.style.removeProperty(\"--ot-lapse\")\n      delete n.dataset.rest\n      continue\n    }\n    n.style.setProperty(\"--ot-rest\", Math.log2(1 + mins).toFixed(2))\n    n.style.setProperty(\"--ot-lapse\", lapseOf(mins).toFixed(3))\n    if (mins >= LONG_REST) n.dataset.rest = restLabel(mins)\n    else delete n.dataset.rest\n  }\n}\n\ntype Head = { day: string; time: string }\n\n/** Running head: the day of the first message in view, once that day's divider has scrolled away above it. */\nfunction readHead(scroller: HTMLElement, headHeight: number): Head | null {\n  const top = scroller.getBoundingClientRect().top + headHeight\n  let day: string | null = null\n  for (const n of Array.from(scroller.children)) {\n    if (!(n instanceof HTMLElement)) continue\n    const isDay = n.matches('[data-slot=\"thread-day\"]')\n    if (n.getBoundingClientRect().bottom > top) {\n      if (isDay || day === null) return null // the divider is in view and says it itself\n      return { day, time: n.querySelector(\"time\")?.textContent ?? \"\" }\n    }\n    if (isDay) day = n.textContent ?? \"\"\n  }\n  return null\n}\n\n/**\n * A message scroller. Give it a height. It keeps to the latest message while you are at the end; scrolled back\n * to read, it stays where you are and counts what arrives in a pill you can press to go down.\n * The messages are its direct children. The scroller takes focus, so the keyboard can scroll it.\n */\nfunction Thread({ label, newLabel = \"new\", variant = \"default\", className, children, ...props }: ThreadProps) {\n  const scroller = React.useRef<HTMLDivElement>(null)\n  const counter = React.useRef<HTMLSpanElement>(null)\n  const headEl = React.useRef<HTMLParagraphElement>(null)\n  const headDay = React.useRef<HTMLSpanElement>(null)\n  const [head, setHead] = React.useState<Head | null>(null)\n  // Whether the reader was at the end before anything arrived: the scroll position after it arrives can't say.\n  const atEnd = React.useRef(true)\n  const unread = React.useRef(0)\n  const [shown, setShown] = React.useState(0)\n\n  React.useEffect(() => {\n    const el = scroller.current\n    if (!el) return\n    const toEnd = (behavior: ScrollBehavior = \"auto\") => el.scrollTo({ top: el.scrollHeight, behavior })\n    // The running head rolls its day word, forward when a later day comes up, back when an earlier one does.\n    let shownHead: Head | null = null\n    const updateHead = () => {\n      if (variant !== \"running\") return\n      const next = readHead(el, headEl.current?.offsetHeight ?? 0)\n      if (next?.day === shownHead?.day && next?.time === shownHead?.time) return\n      const turn = next && shownHead && next.day !== shownHead.day && headDay.current\n      const later = turn && !!shownHead && el.scrollTop > lastTop\n      shownHead = next\n      if (turn) roll(turn, () => setHead(next), \"0.5em\", later ? 1 : -1)\n      else setHead(next)\n    }\n    let lastTop = el.scrollTop\n    if (variant === \"rests\") measureRests(el)\n    toEnd()\n    updateHead()\n    const onScroll = () => {\n      updateHead()\n      lastTop = el.scrollTop\n      atEnd.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR\n      if (atEnd.current && unread.current) {\n        unread.current = 0\n        setShown(0)\n      }\n    }\n    // ponytail: following is instant; smooth-scrolling to each arrival would fight the reader's own scrolling.\n    const watch = new MutationObserver((records) => {\n      if (variant === \"rests\") measureRests(el)\n      let theirs = 0\n      let yours = false\n      for (const r of records)\n        if (r.target === el)\n          r.addedNodes.forEach((n) => {\n            if (!(n instanceof HTMLElement && n.matches('[data-slot=\"message\"]'))) return\n            if (n.dataset.from === \"you\") yours = true\n            else theirs++\n          })\n      if (atEnd.current || yours) {\n        toEnd() // what you send takes you down; a reply being written in is followed too\n        return updateHead()\n      }\n      if (!theirs) return\n      const next = (unread.current += theirs)\n      if (counter.current) roll(counter.current, () => setShown(next), \"0.5em\")\n      else setShown(next)\n    })\n    watch.observe(el, { childList: true, subtree: true, characterData: true })\n    el.addEventListener(\"scroll\", onScroll, { passive: true })\n    return () => {\n      watch.disconnect()\n      el.removeEventListener(\"scroll\", onScroll)\n    }\n  }, [variant])\n\n  const jump = () => {\n    const reduced = matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    scroller.current?.focus({ preventScroll: true })\n    scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: reduced ? \"auto\" : \"smooth\" })\n  }\n\n  return (\n    <div data-slot=\"thread\" data-variant={variant === \"default\" ? undefined : variant} className={cn(\"ot-thread\", className)} {...props}>\n      <div\n        ref={scroller}\n        data-slot=\"thread-scroll\"\n        data-lenis-prevent=\"\"\n        role=\"log\"\n        aria-label={label}\n        tabIndex={0}\n        className=\"ot-thread-scroll ot-scroll\"\n      >\n        {children}\n        <Scrollbar />\n      </div>\n      {variant === \"running\" && (\n        // The log already says every day and time; the head repeats them for the eye.\n        <p ref={headEl} data-slot=\"thread-head\" className=\"ot-thread-head\" aria-hidden=\"true\" data-shown={head ? \"\" : undefined}>\n          <span ref={headDay}>{head?.day}</span>\n          <time>{head?.time}</time>\n        </p>\n      )}\n      {shown > 0 && (\n        <button type=\"button\" data-slot=\"thread-latest\" className=\"ot-thread-latest\" onClick={jump}>\n          <span aria-hidden=\"true\">↓</span>\n          <span ref={counter} className=\"ot-thread-new\">{shown}</span> {newLabel}\n          <span className=\"ot-sr\"> messages: go to the latest</span>\n        </button>\n      )}\n    </div>\n  )\n}\n\n/** Where a day begins: a word with a rule drawn out to each side. */\nfunction ThreadDay(props: Omit<MarkerProps, \"variant\">) {\n  return <Marker data-slot=\"thread-day\" variant=\"divider\" {...props} />\n}\n\nexport { Thread, ThreadDay, type ThreadProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/thread.css",
      "content": "@layer components {\n  /* ── ot-thread ── message scroller: it keeps to the latest; scrolled back, it counts what's new.\n     Give .ot-thread a height (26rem here). It borrows ot-scroll (edge rules), ot-msg and ot-marker. */\n  .ot-thread { position: relative; display: grid; grid-template-rows: minmax(0, 1fr); height: 26rem; }\n  .ot-thread-scroll {\n    display: grid; align-content: safe end; gap: var(--ot-space-6);\n    padding-block: var(--ot-space-5); padding-inline-end: var(--ot-space-3);\n  }\n  .ot-thread-latest {\n    position: absolute; inset-block-end: var(--ot-space-4); inset-inline: 0; margin-inline: auto; width: fit-content;\n    display: inline-flex; align-items: baseline; gap: 0.35em; appearance: none; border: 0; margin-block: 0; padding: 0.3em 0.95em 0.36em; border-radius: 1em;\n    background: var(--ot-ink); color: var(--ot-paper); font: 500 var(--ot-pp) / var(--ot-pp-lh) var(--ot-voice); cursor: pointer;\n    animation: ot-land var(--ot-moderato) var(--ot-spiccato);\n  }\n  .ot-thread-new { display: inline-block; font-variant-numeric: tabular-nums; }\n\n  /* ── rests ── the space between two messages is the silence between them: its gap grows with the log of the minutes\n     (script sets --ot-rest). A pause of an hour or more is said in that silence the way the marker's lapse says it:\n     \"3 hours later\", in pencil, centred, its letters spread by how long it went quiet (--ot-lapse, the lapse's scale). */\n  .ot-thread[data-variant=\"rests\"] .ot-thread-scroll { gap: var(--ot-space-3); }\n  .ot-thread[data-variant=\"rests\"] .ot-thread-scroll > .ot-msg {\n    --ot-rest-gap: min(var(--ot-space-9), calc(var(--ot-rest, 0) * var(--ot-space-2) * 1.25));\n    position: relative; margin-block-start: var(--ot-rest-gap);\n  }\n  .ot-thread[data-variant=\"rests\"] .ot-thread-scroll > [data-slot=\"thread-day\"] { margin-block: var(--ot-space-5); }\n  .ot-thread[data-variant=\"rests\"] .ot-msg[data-rest]::after {\n    --ot-rest-tr: calc(var(--ot-pp-tr) + var(--ot-lapse, 0) * 0.36em);\n    content: attr(data-rest) / \"\"; position: absolute; inset-inline: 0; margin-inline: auto; width: fit-content; pointer-events: none;\n    inset-block-start: calc((var(--ot-rest-gap) + var(--ot-space-3)) / -2); translate: 0 -50%;\n    font: 400 var(--ot-pp) / 1 var(--ot-voice); letter-spacing: var(--ot-rest-tr); padding-inline-start: var(--ot-rest-tr); white-space: nowrap; color: var(--ot-pencil); direction: ltr; unicode-bidi: isolate; /* the words are the library's English */\n  }\n\n  /* ── running ── scrolled back, the top edge carries the day you are in and the time of the first message in view,\n     on a hairline between them (the SHAPES / GRADIENTS frame row). Its day rolls as the days change. */\n  .ot-thread-head {\n    position: absolute; inset-block-start: 0; inset-inline: 0 var(--ot-space-4); z-index: 1; margin: 0;\n    display: flex; align-items: center; gap: var(--ot-space-3); padding-block: var(--ot-space-2);\n    background: var(--ot-paper); font-size: var(--ot-pp); line-height: var(--ot-pp-lh); color: var(--ot-graphite);\n    transition: opacity var(--ot-moderato) var(--ot-exhale);\n  }\n  .ot-thread-head:not([data-shown]) { opacity: 0; visibility: hidden; }\n  .ot-thread-head > span { display: inline-block; }\n  .ot-thread-head::after { content: \"\"; order: 1; flex: 1; height: var(--ot-hairline); background: var(--ot-rule-strong); }\n  .ot-thread-head > time { order: 2; color: var(--ot-pencil); font-variant-numeric: tabular-nums; }\n  @media (forced-colors: active) {\n    .ot-thread-head::after { background: CanvasText; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/thread.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/thread.css\"": {}
  },
  "meta": {
    "movement": "XI",
    "underneath": "native",
    "contract": "ot-thread"
  },
  "type": "registry:ui"
}
