{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reading-trail",
  "title": "Reading trail",
  "description": "Keep your place on a long page: its sections by number and name, set as a contents page, the one you are in carrying the accent while its dotted leader inks as you read. Or the page's own rail, with that section running down it like the title on a spine.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/item.json",
    "https://thedirectors.agency/ui/r/scrollbar.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/reading-trail.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { useComposedRefs } from \"@/lib/0nlytype/refs\"\nimport { cn } from \"@/lib/utils\"\nimport { Item, ItemActions, ItemContent, ItemGroup, ItemTitle } from \"@/components/ui/item\"\nimport { Scrollbar } from \"@/components/ui/scrollbar\"\n\ntype TrailSection = {\n  /** The id of the element the section begins at. */\n  id: string\n  name: string\n  /** Its number on the trail. Defaults to its place: 01, 02. */\n  num?: string\n}\n\ntype ReadingTrailProps = Omit<React.ComponentProps<\"nav\">, \"children\"> & {\n  sections: TrailSection[]\n  /**\n   * contents: the page's contents, set as a contents page: each section's number and name, a dotted leader, and\n   * where on the page it is reached (00 to 100); the leader of the one you are in inks as you read it. rail: the page's\n   * scrollbar at the window's edge, with the section you are in running down the rail beside it like the title on a\n   * book's spine; the contents stay for the keyboard and come out beside the rail when focus reaches them.\n   */\n  variant?: \"contents\" | \"rail\"\n  /** Names the trail for assistive technology, and heads the contents. */\n  label?: string\n  /** Pins where you are instead of reading the page, for documentation: the section's place and how much of it is read (0 to 1). */\n  pinned?: { now: number; read?: number }\n}\n\nconst two = (n: number) => String(n).padStart(2, \"0\")\nconst clamp = (v: number) => Math.min(1, Math.max(0, v))\n\n/**\n * Where on the page (0 to 1 of the way down) each section is reached: when its top meets the middle of the window,\n * as the scrollbar marks it. Those too near the end to reach the middle share the last stretch, so each still\n * has its turn before the page ends.\n */\nfunction reaches(tops: number[], max: number) {\n  const half = innerHeight / 2\n  const r = tops.map((t) => clamp((t - half) / max))\n  const k = r.findIndex((v) => v >= 1)\n  if (k >= 0) {\n    const a = k > 0 ? r[k - 1] : 0, t = r.length - k\n    for (let j = 0; j < t; j++) r[k + j] = a + ((1 - a) * (j + 1)) / t\n  }\n  return r\n}\n\ntype Place = { now: number; folios: number[] }\n\n/**\n * A trail through a long page: its sections by number and name, the one you are in carrying the accent, and a dotted\n * leader that inks as you read it. Reads the window's scroll; links are the page's own anchors.\n */\nfunction ReadingTrail({ sections, variant = \"contents\", label = \"On this page\", pinned, className, style, ref: forwardedRef, ...props }: ReadingTrailProps) {\n  const key = JSON.stringify(sections.map((s) => s.id))\n  const [el, setEl] = React.useState<HTMLElement | null>(null)\n  const composedRef = useComposedRefs(setEl, forwardedRef)\n  const [place, setPlace] = React.useState<Place>({ now: pinned?.now ?? -1, folios: [] })\n  // The spine's head turns only when you move it, never on arrival.\n  const [turned, setTurned] = React.useState(false)\n\n  React.useEffect(() => {\n    if (!el || pinned) return\n    const ids = JSON.parse(key) as string[]\n    // The sections' places are read when the layout can change (a resize, the fonts arriving); a scroll only moves\n    // where you are among them, from the offset the scroll event read while the page was still laid out. Scrolling\n    // reads no layout, and the trail re-renders only when the section you're in changes.\n    let frame = 0, dirty = true, last = -2, y = scrollY, max = 0, read = \"\", placed = \"\"\n    let r: number[] = []\n    const update = () => {\n      const p = max < 1 ? 0 : clamp(y / max)\n      let now = -1\n      r.forEach((v, i) => { if (v <= p + 1e-4) now = i })\n      const end = r[now + 1] ?? 1\n      const share = now < 0 ? \"0\" : end > r[now] ? clamp((p - r[now]) / (end - r[now])).toFixed(3) : \"1\"\n      if (share !== read) el.style.setProperty(\"--ot-trail-read\", (read = share))\n      const folios = r.map((v) => Math.round(v * 100))\n      if (last !== -2 && last !== now) setTurned(true)\n      last = now\n      const k = `${now}:${folios.join()}`\n      if (k !== placed) {\n        placed = k\n        setPlace({ now, folios })\n      }\n    }\n    const measure = () => {\n      const doc = document.documentElement\n      y = scrollY\n      max = doc.scrollHeight - innerHeight\n      const tops = ids.map((id) => {\n        const s = document.getElementById(id)\n        return s ? s.getBoundingClientRect().top + y : Infinity\n      })\n      r = max < 1 ? ids.map((_, i) => (i ? 1 : 0)) : reaches(tops, max)\n      update()\n    }\n    const run = () => {\n      frame = 0\n      if (!dirty) return update()\n      dirty = false\n      measure()\n    }\n    const soon = () => {\n      dirty = true\n      if (!frame) frame = requestAnimationFrame(run)\n    }\n    const onScroll = () => {\n      y = scrollY\n      if (!frame) frame = requestAnimationFrame(run)\n    }\n    const resize = new ResizeObserver(soon)\n    resize.observe(document.body)\n    addEventListener(\"scroll\", onScroll, { passive: true })\n    addEventListener(\"resize\", soon)\n    document.fonts?.ready.then(soon)\n    dirty = false\n    measure()\n    return () => {\n      cancelAnimationFrame(frame)\n      resize.disconnect()\n      removeEventListener(\"scroll\", onScroll)\n      removeEventListener(\"resize\", soon)\n    }\n  }, [el, key, pinned])\n\n  const now = pinned ? pinned.now : place.now\n  // Pinned, the sections are spread evenly down the page.\n  const folios = pinned ? sections.map((_, i) => Math.round((i / sections.length) * 100)) : place.folios\n  const numOf = (s: TrailSection, i: number) => s.num ?? two(i + 1)\n  const head = sections[now]\n  const rail = variant === \"rail\"\n\n  // With no sections there is nothing to name: the rail alone, not an empty landmark.\n  const Root = sections.length ? \"nav\" : \"div\"\n  return (\n    <Root\n      ref={composedRef}\n      data-slot=\"reading-trail\"\n      data-variant={variant}\n      aria-label={sections.length ? label : undefined}\n      className={cn(\"ot-trail\", className)}\n      style={pinned ? ({ \"--ot-trail-read\": pinned.read ?? 0, ...style } as React.CSSProperties) : style}\n      {...props}\n    >\n      {!rail && sections.length > 0 && <p className=\"ot-trail-caption\" aria-hidden=\"true\">{label}</p>}\n      {sections.length > 0 && <ItemGroup className=\"ot-trail-list\">\n        {sections.map((s, i) => (\n          <Item key={s.id} data-slot=\"reading-trail-step\" data-state={i < now ? \"read\" : i === now ? \"now\" : \"ahead\"} className=\"ot-trail-step\">\n            <ItemContent>\n              <ItemTitle>\n                <a href={`#${s.id}`} className=\"ot-trail-link\" aria-current={i === now ? \"location\" : undefined}>\n                  <span className=\"ot-trail-num\">{numOf(s, i)}</span>\n                  <span className=\"ot-trail-name\">{s.name}</span>\n                </a>\n              </ItemTitle>\n            </ItemContent>\n            <ItemActions aria-hidden=\"true\">{folios[i] === undefined ? \"\" : two(folios[i])}</ItemActions>\n          </Item>\n        ))}\n      </ItemGroup>}\n      {rail && head && (\n        <p className=\"ot-trail-head\" aria-hidden=\"true\">\n          <span key={now} className=\"ot-trail-head-in\" data-turn={turned || undefined}>\n            <span className=\"ot-trail-num\">{numOf(head, now)}</span>\n            <span dir=\"auto\">{head.name}</span>\n          </span>\n        </p>\n      )}\n      {rail && <Scrollbar variant=\"page\" sections={sections.map((s, i) => ({ id: s.id, num: numOf(s, i), name: s.name }))} />}\n    </Root>\n  )\n}\n\nexport { ReadingTrail, type ReadingTrailProps, type TrailSection }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/reading-trail.css",
      "content": "@layer components {\n  /* ── ot-trail ── a trail through a long page, set as a contents page: each section's number and name, a dotted leader\n     (item's), and where on the page it is reached, 00 to 100. What you've read keeps its leader in ink, the section you\n     are in carries the accent and its leader inks as you read it, what is to come waits on a rule. Script sets\n     --ot-trail-read (0 to 1, how much of the section you are in is read) and each step's data-state. */\n  .ot-trail { --ot-trail-read: 0; min-width: 0; }\n  .ot-trail-caption { margin: 0 0 var(--ot-space-3); font-size: var(--ot-pp); line-height: var(--ot-pp-lh); letter-spacing: var(--ot-pp-tr); color: var(--ot-pencil); }\n  .ot-trail-list.ot-items { border-top: 0; }\n  .ot-trail-step.ot-item { gap: var(--ot-space-3); padding-block: var(--ot-space-2); border-bottom: 0; }\n  .ot-trail-link { display: inline-flex; align-items: baseline; gap: var(--ot-space-3); color: var(--ot-graphite); text-decoration: none; transition: color var(--ot-allegro); }\n  .ot-trail-num { flex: none; min-width: 2ch; font-size: var(--ot-pp); line-height: var(--ot-pp-lh); font-variant-numeric: tabular-nums lining-nums; color: var(--ot-pencil); transition: color var(--ot-moderato) var(--ot-breath); }\n  .ot-trail-step .ot-item-end { font-size: var(--ot-pp); line-height: var(--ot-pp-lh); color: var(--ot-pencil); }\n  .ot-trail-step:is(:hover, [data-force~=\"hover\"]) .ot-trail-link, .ot-trail[data-force~=\"hover\"] .ot-trail-step:last-child .ot-trail-link { color: var(--ot-ink); }\n  .ot-trail[data-force~=\"hover\"] .ot-trail-step:last-child .ot-item-leader::after { mask-position: 0 0; mask-size: 100% 100%; }\n\n  /* Read: the name steps back, its leader stays inked. Now: the accent on its number, ink on its name and its place,\n     and the leader inked as far as you have read (it follows the scroll, so it doesn't ease). */\n  .ot-trail-step[data-state=\"read\"] .ot-trail-link { color: var(--ot-pencil); }\n  .ot-trail-step[data-state=\"read\"] .ot-item-leader::after { mask-position: 0 0; mask-size: 100% 100%; }\n  .ot-trail-step[data-state=\"now\"] .ot-trail-link { color: var(--ot-ink); }\n  .ot-trail-step[data-state=\"now\"] .ot-trail-num { color: var(--ot-accent); }\n  .ot-trail-step[data-state=\"now\"] .ot-item-end { color: var(--ot-ink); }\n  .ot-trail-step[data-state=\"now\"] .ot-item-leader::after { mask-position: 0 0; mask-size: calc(var(--ot-trail-read) * 100%) 100%; transition: none; }\n  :is([dir=\"rtl\"], [dir=\"rtl\"] *).ot-trail-step:is([data-state=\"read\"], [data-state=\"now\"]) .ot-item-leader::after { mask-position: 100% 0; }\n\n  /* ── rail ── the page's scrollbar at the window's edge, and beside it, running down the rail like the title on a book's\n     spine, the section you are in: its number in the accent, then its name. Physical sides, since the head is set\n     vertically. It shows only while the rail runs, and steps aside while you point at the rail (its numbers arrive there)\n     or while the contents are out. --ot-trail-top keeps it clear of a fixed bar. */\n  .ot-trail[data-variant=\"rail\"] > .ot-scrollbar { top: var(--ot-trail-top, 0px); }\n  .ot-trail-head {\n    position: fixed; z-index: 40; top: calc(var(--ot-trail-top, 0px) + var(--ot-space-5)); right: 1.25rem; margin: 0;\n    display: none; writing-mode: vertical-rl; direction: ltr; unicode-bidi: isolate; pointer-events: none;\n    font-size: var(--ot-pp); line-height: 1; letter-spacing: 0.02em; white-space: nowrap; color: var(--ot-graphite);\n    transition: opacity var(--ot-allegro) var(--ot-exhale);\n  }\n  :is([dir=\"rtl\"], [dir=\"rtl\"] *).ot-trail-head { right: auto; left: 1.25rem; }\n  .ot-trail-head-in { display: flex; gap: var(--ot-space-2); }\n  .ot-trail-head .ot-trail-num { min-width: 0; line-height: 1; color: var(--ot-accent); }\n  .ot-trail-head-in[data-turn] { animation: ot-trail-turn var(--ot-moderato) var(--ot-exhale) backwards; }\n  @keyframes ot-trail-turn { from { opacity: 0; translate: 0 -0.75em; } }\n  @supports (animation-timeline: scroll()) {\n    @media (pointer: fine) {\n      .ot-trail[data-variant=\"rail\"]:has(> .ot-scrollbar[data-ready]:not([data-idle])) > .ot-trail-head { display: block; }\n    }\n  }\n  .ot-trail[data-variant=\"rail\"]:is(:has(> .ot-scrollbar:hover), :focus-within) > .ot-trail-head { opacity: 0; }\n\n  /* The contents, for the keyboard: out of sight until focus reaches them, then out beside the rail on the page's paper. */\n  .ot-trail[data-variant=\"rail\"] > .ot-trail-list {\n    position: fixed; z-index: 41; top: calc(var(--ot-trail-top, 0px) + var(--ot-space-5)); inset-inline-end: 2.5rem;\n    inline-size: max-content; max-inline-size: min(22rem, calc(100vw - 5rem)); max-block-size: calc(100dvh - var(--ot-trail-top, 0px) - var(--ot-space-7)); overflow: auto;\n    padding: var(--ot-space-3) var(--ot-space-5); background: var(--ot-paper); border-inline-start: var(--ot-hairline) solid var(--ot-rule);\n    transition: opacity var(--ot-allegro) var(--ot-exhale), translate var(--ot-moderato) var(--ot-exhale);\n  }\n  .ot-trail[data-variant=\"rail\"]:not(:focus-within) > .ot-trail-list { opacity: 0; translate: 0 -0.5em; pointer-events: none; }\n\n  @media (forced-colors: active) {\n    .ot-trail-step[data-state=\"now\"] .ot-trail-num, .ot-trail-head .ot-trail-num { color: Highlight; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/reading-trail.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/reading-trail.css\"": {}
  },
  "meta": {
    "movement": "VIII",
    "underneath": "hook",
    "contract": "ot-trail"
  },
  "type": "registry:ui"
}
