{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "footnote",
  "type": "registry:ui",
  "title": "Footnote",
  "description": "An AI answer that shows its work. It reads its sources, dealing a chip for each one, then writes the answer, and as each claim lands its source's number lifts out of the chip and drops in at the end of the sentence. Point at a sentence and its source lights up; point at a source and the sentences it backs light up, and resting on it opens the passage the answer leans on.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "ai",
    "text",
    "hover"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/footnote",
  "dependencies": [
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/footnote.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useReducedMotion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - Every word, marker and chip is in the markup from the start and only shown as the run reaches\n *   it, so playing never changes the card's height.\n * - The run is planned up front in ms (`buildPlan`), and one timer jumps a clock kept outside React\n *   from event to event; off screen or in a hidden tab it stops and picks up where it was.\n * - Writing never waits for the numbers. A flying number re-measures its marker every frame and\n *   hands off to it in the frame it lands, and a slower flight pushes the finish back to match.\n * - A link holds for a moment between claims, so moving across lines doesn't flicker; a source opens\n *   after the hover intent and closes only once the pointer has left both chip and card.\n * - Reduced motion, read only after hydration, shows the finished answer at once with no dealing,\n *   streaming or flights. Links and source cards still work, and cards fade without growing.\n */\nconst TIMING = {\n  /** After play, before the first source is read, in ms: the status line arrives with it. */\n  start: 0,\n  /** What the reading aims to take, whatever the count, in ms. */\n  spread: 900,\n  /** Between reads, at least and at most, in ms. */\n  step: [35, 90],\n  /** Between the last read and the first word, in ms. */\n  hold: 240,\n  /** How fast the answer writes itself, in words a second (the `speed` prop). */\n  speed: 45,\n  /** A word fading in, in seconds; the run waits for the last one. */\n  word: 0.12,\n  /** The same fade, as Tailwind classes. Keep it equal to `word`. */\n  wordFade: \"duration-[120ms] ease-out\",\n  /** After a claim's last word, before its number lifts off, in ms. */\n  lag: 60,\n  /** Between the numbers of one claim, in ms. */\n  stagger: 70,\n  /** From the last lift-off until the run counts as done, at the default flight, in ms. */\n  settle: 520,\n  /** A number flying into its marker (the `flightDuration` and `flightBounce` props). */\n  fly: { type: \"spring\", visualDuration: 0.45, bounce: 0.16, restDelta: 0.002, restSpeed: 0.02 },\n  /** The ring on a chip as its number lifts off. */\n  pulse: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },\n  /** The question and the status line rising in. */\n  rise: { duration: 0.3, ease: [0.22, 1, 0.36, 1] },\n  /** The old answer leaving for good. */\n  fold: { duration: 0.2, ease: [0.55, 0, 0.75, 0.4] },\n  /** The card easing to the new answer's height. */\n  height: { type: \"spring\", visualDuration: 0.34, bounce: 0 },\n  /** A link holding before it clears, in ms. */\n  linger: 100,\n  /** On a chip before its source opens, in ms. */\n  intent: 120,\n  /** After leaving chip and card, before the source closes, in ms. */\n  leave: 250,\n  /** A source card growing out of its chip. */\n  open: { type: \"spring\", visualDuration: 0.3, bounce: 0.1 },\n  /** A source card closing, and its fade in and out under reduced motion. */\n  close: { duration: 0.15, ease: \"easeOut\" },\n  /** A claim lighting up or dimming (`on`) and clearing (`off`), as Tailwind classes. */\n  link: { ease: \"ease-out\", on: \"duration-75\", off: \"duration-150\" },\n  /** A chip lighting up or dimming (`on`), and being dealt or clearing (`off`), as Tailwind classes. */\n  chip: { ease: \"ease-out\", on: \"duration-75\", off: \"duration-200\" },\n  /** The \"+N more\" chip being dealt, as Tailwind classes. */\n  more: \"duration-200 ease-out\",\n  /** Markers, chips and their numbers changing tint, as a Tailwind class. */\n  tint: \"duration-150\",\n  /** The footer's rule showing with the first read, as a Tailwind class. */\n  rule: \"duration-200\",\n  /** The placeholder lines fading as the first word lands, as Tailwind classes. */\n  skeleton: \"duration-200 ease-out\",\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * FOOTNOTE STORYBOARD\n *\n *  rest     the question with a small badge, three faint lines\n *           where the answer will be, and an empty footer. Every\n *           word is already in the page, hidden: playing only shows\n *           them, so the card never changes height. Without play\n *           (or with reduced motion) the finished answer shows\n *  view     a third of it in view: the status line rises 6px with\n *           a spinner and \"Read 1 source\". Sources are read one at\n *           a time, 35–90ms apart (about 0.9s in all): each deals\n *           its chip into the footer (0.96 → 1 and a fade, 0.2s)\n *           and ticks the count. Reads past the last chip only\n *           tick \"+N more\"\n *  write    0.24s after the last read the faint lines fade and the\n *           answer streams in at 45 words a second, each word\n *           fading in over 0.12s\n *  cite     60ms after a cited claim's last word lands, the number\n *           in its source's chip lights up and lifts out: a ring\n *           pulses on the chip (0.5s) while the number flies up in\n *           an arc that peaks 26px over the straight line, grows\n *           22% at the top and settles into the claim's marker on\n *           a 0.45s spring with a little give. Two sources on one\n *           claim leave 70ms apart. Writing never waits for them\n *  done     the spinner turns into a check\n *  link     point at a claim or its marker: the claim takes an\n *           accent tint, its chips rise 2px and ring in the accent,\n *           and every other claim fades to 45%. Point at a chip and\n *           the claims it backs light up instead. On in 75ms, off\n *           in 150ms, and it holds 100ms between claims so moving\n *           across lines doesn't flicker\n *  preview  rest on a chip for 120ms (or focus it, or tap it) and\n *           a source card grows out of it (0.92 → 1, 0.3s spring,\n *           a touch of give): the site, the title and the passage\n *           with the quoted part marked. It opens above the chip\n *           when there's room and below when there isn't, and\n *           closes 250ms after the pointer leaves chip and card\n *  again    a new question folds the old answer up and out as one\n *           block (4px, 0.2s), the card eases to its new height\n *           and the new question rises in and plays from reading\n *  touch    tap a claim to link it, tap a chip or a marker to open\n *           its source, tap outside to clear\n *  keys     Tab walks the markers in reading order, then the\n *           chips. Enter on a marker moves focus to its chip and\n *           opens its source; focusing a chip opens it too, and\n *           Space toggles it. Esc closes and keeps focus on the chip\n *  sleep    off screen or in a hidden tab the clock stops and\n *           picks up where it was\n *  reduced  no dealing, streaming or flights: the finished answer\n *           shows at once. Links and source cards still work, and\n *           cards fade in without growing\n * ───────────────────────────────────────────────────────── */\n\nconst NOTE = {\n  arc: 26, // px the flight peaks over a straight line\n  grow: 0.22, // extra scale at the top of the arc\n  lift: 6, // px the question and status rise in from\n  gap: 8, // px between a chip and its open source\n} as const;\n\nconst clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));\nconst smoothstep = (a: number, b: number, v: number) => {\n  const x = clamp((v - a) / (b - a), 0, 1);\n  return x * x * (3 - 2 * x);\n};\nconst monogram = (site: string) =>\n  site\n    .replace(/^(https?:\\/\\/)?(www\\.)?/, \"\")\n    .charAt(0)\n    .toUpperCase();\nconst plural = (n: number, word: string) => `${n} ${word}${n === 1 ? \"\" : \"s\"}`;\nconst SKELETON = [\"94%\", \"100%\", \"58%\"]; // widths of the lines held for the answer while it reads\n\n/* ── Types ──────────────────────────────────────────────── */\n\nexport interface FootnoteSource {\n  /** What a claim's `cite` points at. */\n  id: string;\n  /** The site's name or domain, shown on the chip: \"docs.plinth.so\". */\n  site: string;\n  /** The page's title, shown when the source opens. */\n  title: string;\n  /** Where the source lives. The chip and the open card's title link to it. */\n  href?: string;\n  /** The passage the answer leans on, shown when the source opens. */\n  snippet?: string;\n  /** The words of `snippet` to mark, when they appear in it. */\n  quote?: string;\n}\n\nexport interface FootnoteClaim {\n  /** A sentence or a clause of the answer. */\n  text: string;\n  /** The `id` of the source that backs it, or several. Leave it out for a claim with no source. */\n  cite?: string | string[];\n}\n\nexport interface FootnoteProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** The question, at the top of the card. A new question folds the old answer away and plays the new one. */\n  question: string;\n  /** The answer, claim by claim, in reading order. Each claim gets a marker for every source it cites. */\n  answer: FootnoteClaim[];\n  /** The sources it read. Cited ones are numbered in the order the answer first cites them; the rest follow with a monogram. */\n  sources: FootnoteSource[];\n  /** How many sources it says it read, when that's more than you list. The extra ones show as \"+N more\". */\n  read?: number;\n  /** When it plays: `\"view\"` once a third of it is on screen, `true` as soon as it mounts, `false` never (the finished answer shows). */\n  play?: boolean | \"view\";\n  /** How fast the answer writes itself, in words a second. */\n  speed?: number;\n  /** Pixels each source's number arcs up over a straight line on its way to the claim. */\n  arc?: number;\n  /** Seconds a number takes to fly from its chip to the claim. */\n  flightDuration?: number;\n  /** How much a number overshoots its marker and settles, from 0 (none) up. */\n  flightBounce?: number;\n  /** The badge beside the question, like your product's mark. A small spark when unset. */\n  avatar?: React.ReactNode;\n  /** The status line's text, from the number of sources read so far and whether reading has finished. */\n  status?: (read: number, done: boolean) => React.ReactNode;\n  /** Called when an answer has finished: written, every number landed. At once when it doesn't play. */\n  onComplete?: () => void;\n}\n\nconst defaultStatus = (read: number) => `Read ${plural(read, \"source\")}`;\n\n/* ── Plan ───────────────────────────────────────────────── */\n\n// Everything the run does, as times in ms from the moment it starts playing.\ntype Mark = { id: string; num: number };\ntype PlanClaim = { words: string[]; start: number; marks: Mark[] };\ntype PlanFlight = { claim: number; id: string; num: number; at: number };\ntype Plan = {\n  claims: PlanClaim[];\n  order: { source: FootnoteSource; num?: number }[];\n  words: number;\n  reads: number;\n  readAt: number[];\n  writeAt: number;\n  per: number;\n  flights: PlanFlight[];\n  doneAt: number;\n  events: number[];\n};\n\nfunction buildPlan(\n  answer: FootnoteClaim[],\n  sources: FootnoteSource[],\n  read: number | undefined,\n  speed: number,\n  settle: number,\n): Plan {\n  const known = new Set(sources.map((s) => s.id));\n  const nums = new Map<string, number>();\n  let words = 0;\n  const claims = answer.map((claim) => {\n    const ids = (Array.isArray(claim.cite) ? claim.cite : claim.cite ? [claim.cite] : []).filter(\n      (id, i, all) => known.has(id) && all.indexOf(id) === i,\n    );\n    const marks = ids.map((id) => {\n      if (!nums.has(id)) nums.set(id, nums.size + 1);\n      return { id, num: nums.get(id)! };\n    });\n    const list = claim.text.trim().split(/\\s+/).filter(Boolean);\n    const planned = { words: list, start: words, marks };\n    words += list.length;\n    return planned;\n  });\n  const order = [\n    ...sources.filter((s) => nums.has(s.id)).sort((a, b) => nums.get(a.id)! - nums.get(b.id)!),\n    ...sources.filter((s) => !nums.has(s.id)),\n  ].map((source) => ({ source, num: nums.get(source.id) }));\n\n  const reads = Math.max(read ?? 0, sources.length);\n  const step = clamp(TIMING.spread / Math.max(1, reads), TIMING.step[0], TIMING.step[1]);\n  const readAt = Array.from({ length: reads }, (_, k) => Math.round(TIMING.start + k * step));\n  const writeAt = (readAt[reads - 1] ?? TIMING.start) + TIMING.hold;\n  const per = 1000 / Math.max(1, speed);\n\n  const flights: PlanFlight[] = [];\n  claims.forEach((claim, i) => {\n    if (!claim.words.length) return;\n    const landed = writeAt + (claim.start + claim.words.length - 1) * per;\n    claim.marks.forEach((mark, m) => {\n      flights.push({ claim: i, id: mark.id, num: mark.num, at: Math.round(landed + TIMING.lag + m * TIMING.stagger) });\n    });\n  });\n  const lastWord = writeAt + Math.max(0, words - 1) * per + TIMING.word * 1000;\n  const doneAt = Math.round(Math.max(lastWord, ...flights.map((f) => f.at + settle)));\n\n  const events = new Set<number>([...readAt, ...flights.map((f) => f.at), doneAt]);\n  for (let g = 0; g < words; g++) events.add(Math.round(writeAt + g * per));\n  return {\n    claims,\n    order,\n    words,\n    reads,\n    readAt,\n    writeAt,\n    per,\n    flights,\n    doneAt,\n    events: [...events].sort((a, b) => a - b),\n  };\n}\n\n/* ── Run store ──────────────────────────────────────────── */\n\n// The clock, the numbers in flight and the ones that have landed, for the answer on screen. It\n// lives outside React so timers and animation callbacks can move it without effects setting state.\ntype Point = { x: number; y: number };\ntype Flight = { key: string; num: number; from: Point; to: Point };\ntype Content = { key: string; question: string; answer: FootnoteClaim[]; sources: FootnoteSource[]; read?: number };\ntype RunState = {\n  run: number;\n  key: string;\n  data: Content;\n  t: number;\n  landed: ReadonlySet<string>;\n  flying: readonly Flight[];\n  folding: boolean;\n};\n\nfunction createRun(initial: Content) {\n  let state: RunState = { run: 0, key: initial.key, data: initial, t: 0, landed: new Set(), flying: [], folding: false };\n  const listeners = new Set<() => void>();\n  const emit = (next: RunState) => {\n    state = next;\n    listeners.forEach((listener) => listener());\n  };\n  return {\n    get: () => state,\n    subscribe(listener: () => void) {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n    advance(t: number, launch: Flight[], landNow: string[]) {\n      emit({\n        ...state,\n        t,\n        flying: launch.length ? [...state.flying, ...launch] : state.flying,\n        landed: landNow.length ? new Set([...state.landed, ...landNow]) : state.landed,\n      });\n    },\n    land(key: string) {\n      if (!state.flying.some((flight) => flight.key === key)) return;\n      emit({\n        ...state,\n        flying: state.flying.filter((flight) => flight.key !== key),\n        landed: new Set([...state.landed, key]),\n      });\n    },\n    fold() {\n      emit({ ...state, folding: true });\n    },\n    swap(data: Content) {\n      emit({ run: state.run + 1, key: data.key, data, t: 0, landed: new Set(), flying: [], folding: false });\n    },\n  };\n}\n\nconst subscribeNever = () => () => {};\n\nfunction usePageVisible() {\n  return React.useSyncExternalStore(\n    (onChange) => {\n      document.addEventListener(\"visibilitychange\", onChange);\n      return () => document.removeEventListener(\"visibilitychange\", onChange);\n    },\n    () => document.visibilityState === \"visible\",\n    () => true,\n  );\n}\n\n// A box relative to the root's padding box, in the root's own pixels even when an ancestor scales it.\nfunction boxIn(el: Element, root: HTMLElement) {\n  const box = el.getBoundingClientRect();\n  const frame = root.getBoundingClientRect();\n  const scale = frame.width / (root.offsetWidth || frame.width) || 1;\n  return {\n    x: (box.left - frame.left) / scale - root.clientLeft,\n    y: (box.top - frame.top) / scale - root.clientTop,\n    w: box.width / scale,\n    h: box.height / scale,\n  };\n}\n\nconst FOCUS = \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--footnote-accent)\";\n\n/* ── Footnote ───────────────────────────────────────────── */\n\ntype Link = { run: number; kind: \"claim\"; index: number } | { run: number; kind: \"source\"; id: string };\ntype Preview = { run: number; id: string; anchor: { x: number; y: number; w: number; h: number }; width: number };\n\nexport function Footnote({\n  question,\n  answer,\n  sources,\n  read,\n  play = \"view\",\n  speed = TIMING.speed,\n  arc = NOTE.arc,\n  flightDuration = TIMING.fly.visualDuration,\n  flightBounce = TIMING.fly.bounce,\n  avatar,\n  status = defaultStatus,\n  onComplete,\n  className,\n  onKeyDown,\n  ...props\n}: FootnoteProps) {\n  const uid = React.useId();\n  // useReducedMotion is already true in the hydration render, so anything drawn from it waits for hydration.\n  const hydrated = React.useSyncExternalStore(subscribeNever, () => true, () => false);\n  const reduced = !!useReducedMotion() && hydrated;\n  const full = play === false || reduced;\n\n  const key = `${question}␞${answer\n    .map((claim) => `${claim.text}␟${[claim.cite ?? []].flat().join(\",\")}`)\n    .join(\"␝\")}␞${sources.map((s) => s.id).join(\",\")}␞${read ?? \"\"}`;\n  const live: Content = { key, question, answer, sources, read };\n\n  const [store] = React.useState(() => createRun(live));\n  const state = React.useSyncExternalStore(store.subscribe, store.get, store.get);\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const seen = useInView(rootRef, { once: true, amount: 0.35 });\n  const inView = useInView(rootRef, { amount: 0.05 });\n  const visible = usePageVisible();\n\n  const playing = !full && (play === true ? hydrated : play === \"view\" && seen);\n  // A new question folds the old answer away only once something has played; otherwise it just swaps.\n  const foldable = playing;\n  const data = state.key === key || !foldable ? live : state.data;\n  // A slower flight holds the finish back by as much, so the check still waits for the last number.\n  const settle = TIMING.settle * (Math.max(0, flightDuration) / TIMING.fly.visualDuration);\n  const plan = buildPlan(data.answer, data.sources, data.read, speed, settle);\n  const run = state.run;\n  const t = state.t;\n\n  const latest = React.useRef({ live, plan, onComplete });\n  React.useLayoutEffect(() => {\n    latest.current = { live, plan, onComplete };\n  });\n\n  /* Swapping in a new answer. */\n  const heightFrom = React.useRef<number | null>(null);\n  React.useEffect(() => {\n    const now = store.get();\n    if (now.key === key || now.folding) return;\n    if (!foldable) {\n      store.swap(latest.current.live);\n      return;\n    }\n    store.fold();\n    heightFrom.current = rootRef.current?.offsetHeight ?? null;\n    const content = contentRef.current;\n    if (!content) return store.swap(latest.current.live);\n    // Driven as a plain number, so nothing is written to the element after the swap clears it. Not\n    // stopped on a re-run either: a newer question arriving mid-fold is picked up when it ends.\n    animate(0, 1, {\n      ...TIMING.fold,\n      onUpdate: (p) => {\n        content.style.opacity = String(1 - p);\n        content.style.transform = `translateY(${-(NOTE.lift - 2) * p}px)`;\n      },\n    }).then(() => store.swap(latest.current.live));\n  }, [key, foldable, store]);\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    const content = contentRef.current;\n    if (content) {\n      content.style.opacity = \"\";\n      content.style.transform = \"\";\n    }\n    const from = heightFrom.current;\n    heightFrom.current = null;\n    if (from === null || !root) return;\n    const to = root.offsetHeight;\n    if (Math.abs(to - from) < 1) return;\n    const controls = animate(from, to, {\n      ...TIMING.height,\n      onUpdate: (height) => {\n        root.style.height = `${height}px`;\n      },\n    });\n    controls.then(() => {\n      root.style.height = \"\";\n    });\n    return () => {\n      controls.stop();\n      root.style.height = \"\";\n    };\n  }, [run]);\n\n  /* The clock: jump from event to event while it's playing and in sight. */\n  const done = full || t >= plan.doneAt;\n  const running = playing && !done && !state.folding && inView && visible;\n  React.useEffect(() => {\n    if (!running) return;\n    const from = store.get();\n    const next = latest.current.plan.events.find((at) => at > from.t);\n    if (next === undefined) return;\n    const timer = window.setTimeout(() => {\n      const now = store.get();\n      const root = rootRef.current;\n      if (now.run !== from.run || !root) return;\n      const launch: Flight[] = [];\n      const landNow: string[] = [];\n      for (const flight of latest.current.plan.flights) {\n        if (flight.at <= from.t || flight.at > next) continue;\n        const markKey = `${now.run}:${flight.claim}:${flight.num}`;\n        const chip = root.querySelector(`[data-footnote-num=\"${CSS.escape(flight.id)}\"]`);\n        const mark = root.querySelector(`[data-footnote-mark=\"${markKey}\"]`);\n        if (!chip || !mark) {\n          landNow.push(markKey);\n          continue;\n        }\n        const ring = root.querySelector(`[data-footnote-ring=\"${CSS.escape(flight.id)}\"]`);\n        if (ring) animate(ring, { opacity: [0.9, 0], transform: [\"scale(1)\", \"scale(1.12)\"] }, TIMING.pulse);\n        const a = boxIn(chip, root);\n        const b = boxIn(mark, root);\n        launch.push({ key: markKey, num: flight.num, from: { x: a.x, y: a.y }, to: { x: b.x, y: b.y } });\n      }\n      store.advance(next, launch, landNow);\n    }, next - from.t);\n    return () => window.clearTimeout(timer);\n  }, [running, t, run, store]);\n\n  // Once per answer, written and every number landed (or straight away when it doesn't play).\n  const completed = React.useRef(-1);\n  const finished = done && state.flying.length === 0 && !state.folding && state.key === key;\n  React.useEffect(() => {\n    if (!finished || completed.current === run) return;\n    if (!full && !playing) return;\n    completed.current = run;\n    latest.current.onComplete?.();\n  }, [finished, run, full, playing]);\n\n  /* What shows at this moment. */\n  const started = full || playing;\n  const readCount = full ? plan.reads : playing ? plan.readAt.filter((at) => at <= t).length : 0;\n  const readDone = readCount >= plan.reads;\n  const shownWords = full\n    ? plan.words\n    : playing && t >= plan.writeAt\n      ? clamp(Math.floor((t - plan.writeAt) / plan.per + 1e-6) + 1, 0, plan.words)\n      : 0;\n  const more = Math.max(0, readCount - plan.order.length);\n  const markShown = (claim: number, num: number) =>\n    full || (state.landed.has(`${run}:${claim}:${num}`) && !state.folding) || (done && state.flying.length === 0);\n\n  /* Links between claims and sources. */\n  const [link, setLink] = React.useState<Link | null>(null);\n  const [preview, setPreview] = React.useState<Preview | null>(null);\n  const linger = React.useRef(0);\n  const intent = React.useRef(0);\n  const leave = React.useRef(0);\n  const pointer = React.useRef<string>(\"mouse\");\n  React.useEffect(\n    () => () => {\n      window.clearTimeout(linger.current);\n      window.clearTimeout(intent.current);\n      window.clearTimeout(leave.current);\n    },\n    [],\n  );\n\n  const activeLink = link && link.run === run ? link : null;\n  const activePreview = preview && preview.run === run ? preview : null;\n  const previewSource = activePreview ? data.sources.find((s) => s.id === activePreview.id) : undefined;\n\n  const holdLink = (next: Link) => {\n    window.clearTimeout(linger.current);\n    setLink(next);\n  };\n  const releaseLink = () => {\n    window.clearTimeout(linger.current);\n    linger.current = window.setTimeout(() => setLink(null), TIMING.linger);\n  };\n\n  const litClaims = new Set<number>();\n  const litSources = new Set<string>();\n  if (activeLink?.kind === \"claim\") {\n    litClaims.add(activeLink.index);\n    plan.claims[activeLink.index]?.marks.forEach((mark) => litSources.add(mark.id));\n  } else if (activeLink?.kind === \"source\") {\n    litSources.add(activeLink.id);\n    plan.claims.forEach((claim, i) => {\n      if (claim.marks.some((mark) => mark.id === activeLink.id)) litClaims.add(i);\n    });\n  }\n  const linking = activeLink !== null && (litClaims.size > 0 || litSources.size > 0);\n\n  const chipOf = (id: string) =>\n    rootRef.current?.querySelector<HTMLElement>(`[data-footnote-chip=\"${CSS.escape(id)}\"]`) ?? null;\n\n  const openPreview = (id: string) => {\n    window.clearTimeout(intent.current);\n    window.clearTimeout(leave.current);\n    const root = rootRef.current;\n    const chip = chipOf(id);\n    if (!root || !chip) return;\n    const anchor = boxIn(chip, root);\n    setPreview({ run, id, anchor, width: Math.min(288, root.clientWidth - 16) });\n  };\n  const closePreview = (later: boolean) => {\n    window.clearTimeout(intent.current);\n    window.clearTimeout(leave.current);\n    if (later) leave.current = window.setTimeout(() => setPreview(null), TIMING.leave);\n    else setPreview(null);\n  };\n  const keepPreview = () => window.clearTimeout(leave.current);\n\n  // A tap outside clears a tapped link or source.\n  const tapped = activeLink !== null || activePreview !== null;\n  React.useEffect(() => {\n    if (!tapped) return;\n    const onDown = (event: PointerEvent) => {\n      if (event.pointerType === \"mouse\") return;\n      if (rootRef.current?.contains(event.target as Node)) return;\n      setLink(null);\n      setPreview(null);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [tapped]);\n\n  const statusText = status(readCount, readDone);\n  const finalStatus = status(plan.reads, true);\n  const reading = !done;\n  const showStatus = started && plan.reads > 0;\n  const enter = run > 0 && !full;\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"article\"\n      aria-labelledby={`${uid}-q`}\n      className={cn(\n        \"@container/footnote relative w-full rounded-[20px] bg-white p-4 text-zinc-950 shadow-sm ring-1 ring-zinc-950/10 @[28rem]/footnote:p-5\",\n        \"[--footnote-accent:#5b54d6] dark:bg-zinc-900 dark:text-white dark:ring-white/10 dark:[--footnote-accent:#a49dff]\",\n        className,\n      )}\n      onPointerDownCapture={(event) => {\n        pointer.current = event.pointerType;\n      }}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (event.key !== \"Escape\" || !activePreview) return;\n        event.preventDefault();\n        const chip = chipOf(activePreview.id);\n        closePreview(false);\n        if (chip && !chip.contains(document.activeElement)) chip.focus();\n      }}\n      {...props}\n    >\n      <div ref={contentRef}>\n        <div className=\"flex items-start gap-2.5\">\n          <span\n            aria-hidden\n            className=\"mt-px grid size-6 shrink-0 place-items-center rounded-full bg-(--footnote-accent)/12 text-(--footnote-accent)\"\n          >\n            {avatar ?? <SparkIcon className=\"size-3.5\" />}\n          </span>\n          <motion.p\n            key={run}\n            id={`${uid}-q`}\n            initial={enter ? { opacity: 0, transform: `translateY(${NOTE.lift}px)` } : false}\n            animate={{ opacity: 1, transform: \"translateY(0px)\" }}\n            transition={TIMING.rise}\n            className=\"min-w-0 pt-0.5 text-[15px]/5 font-medium tracking-[-0.01em] text-balance\"\n          >\n            {data.question}\n          </motion.p>\n        </div>\n\n        {plan.reads > 0 && (\n          <motion.div\n            key={`status-${run}`}\n            aria-hidden\n            initial={enter ? { opacity: 0, transform: `translateY(${NOTE.lift}px)` } : false}\n            animate={\n              showStatus\n                ? { opacity: 1, transform: \"translateY(0px)\" }\n                : { opacity: 0, transform: `translateY(${NOTE.lift}px)` }\n            }\n            transition={full ? { duration: 0 } : TIMING.rise}\n            className=\"mt-2.5 flex h-4 items-center gap-1.5 pl-[34px] text-xs text-current/60\"\n          >\n            {reading ? <SpinnerIcon className=\"size-3 motion-safe:animate-spin\" /> : <CheckIcon className=\"size-3\" />}\n            <span className=\"truncate tabular-nums\">{statusText}</span>\n          </motion.div>\n        )}\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {done && plan.reads > 0 ? finalStatus : \"\"}\n        </span>\n\n        <p\n          key={`answer-${run}`}\n          aria-busy={playing && !done}\n          className=\"relative mt-3 text-[14px]/[1.65] text-pretty @[28rem]/footnote:text-[15px]/[1.65]\"\n        >\n          {!full && (\n            // Three lines held for the answer while the sources are read, gone as the first word lands.\n            <span\n              aria-hidden\n              className={cn(\n                `pointer-events-none absolute inset-0 overflow-hidden transition-opacity ${TIMING.skeleton}`,\n                shownWords > 0 && \"opacity-0\",\n              )}\n            >\n              {SKELETON.map((width, i) => (\n                <span\n                  key={i}\n                  className=\"absolute left-0 h-[0.62em] rounded-full bg-current/[0.06] motion-safe:animate-pulse\"\n                  style={{ top: `${i * 1.65 + 0.52}em`, width }}\n                />\n              ))}\n            </span>\n          )}\n          {plan.claims.map((claim, i) => {\n            const lit = litClaims.has(i);\n            const dim = linking && !lit;\n            // Hidden words aren't there to point at yet.\n            const written = claim.start + claim.words.length <= shownWords;\n            const toggleClaim = () => {\n              if (pointer.current === \"mouse\" || !written) return;\n              setLink(activeLink?.kind === \"claim\" && activeLink.index === i ? null : { run, kind: \"claim\", index: i });\n            };\n            return (\n              <React.Fragment key={i}>\n                {i > 0 && \" \"}\n                <span\n                  className={cn(\n                    `rounded-[4px] box-decoration-clone transition-[opacity,background-color,color] ${TIMING.link.ease}`,\n                    lit || dim ? TIMING.link.on : TIMING.link.off,\n                    lit ? \"bg-(--footnote-accent)/12 text-current\" : \"text-current/80\",\n                    dim && \"opacity-45\",\n                  )}\n                  onPointerEnter={(event) => {\n                    if (event.pointerType === \"mouse\" && written) holdLink({ run, kind: \"claim\", index: i });\n                  }}\n                  onPointerLeave={(event) => {\n                    if (event.pointerType === \"mouse\") releaseLink();\n                  }}\n                  onClick={toggleClaim}\n                >\n                  {claim.words.map((word, j) => {\n                    const shown = claim.start + j < shownWords;\n                    const text = (\n                      <span\n                        className={full ? undefined : `transition-opacity ${TIMING.wordFade}`}\n                        style={shown ? undefined : { opacity: 0 }}\n                      >\n                        {word}\n                      </span>\n                    );\n                    if (j < claim.words.length - 1) {\n                      return (\n                        <React.Fragment key={j}>\n                          {text}{\" \"}\n                        </React.Fragment>\n                      );\n                    }\n                    // The last word and its markers never wrap apart.\n                    return (\n                      <span key={j} className=\"whitespace-nowrap\">\n                        {text}\n                        {claim.marks.map((mark) => {\n                          const source = data.sources.find((s) => s.id === mark.id)!;\n                          const on = markShown(i, mark.num);\n                          return (\n                            <button\n                              key={mark.num}\n                              type=\"button\"\n                              data-footnote-mark={`${run}:${i}:${mark.num}`}\n                              tabIndex={on ? 0 : -1}\n                              aria-label={`Source ${mark.num}: ${source.site}`}\n                              className={cn(\n                                `relative -top-px ml-[3px] inline-flex h-4 min-w-4 cursor-pointer items-center justify-center rounded-full px-1 align-middle text-[10px]/none font-semibold tabular-nums transition-[background-color,color] ${TIMING.tint}`,\n                                lit\n                                  ? \"bg-(--footnote-accent) text-white dark:text-zinc-950\"\n                                  : \"bg-(--footnote-accent)/12 text-(--footnote-accent) hover:bg-(--footnote-accent) hover:text-white dark:hover:text-zinc-950\",\n                                FOCUS,\n                              )}\n                              style={on ? undefined : { opacity: 0, pointerEvents: \"none\" }}\n                              onFocus={() => holdLink({ run, kind: \"claim\", index: i })}\n                              onBlur={releaseLink}\n                              onClick={(event) => {\n                                event.stopPropagation();\n                                holdLink({ run, kind: \"claim\", index: i });\n                                openPreview(mark.id);\n                                chipOf(mark.id)?.focus({ preventScroll: true });\n                              }}\n                            >\n                              {mark.num}\n                            </button>\n                          );\n                        })}\n                      </span>\n                    );\n                  })}\n                </span>\n              </React.Fragment>\n            );\n          })}\n        </p>\n\n        {plan.order.length > 0 && (\n          <div\n            className={cn(\n              `mt-4 border-t border-current/[0.08] pt-3 transition-[border-color] ${TIMING.rule}`,\n              readCount === 0 && \"border-transparent\",\n            )}\n          >\n            <ul aria-label=\"Sources\" className=\"flex flex-wrap gap-1.5\">\n              {plan.order.map(({ source, num }, i) => {\n                const dealt = i < readCount;\n                const lit = litSources.has(source.id);\n                const dim = linking && !lit;\n                const open = activePreview?.id === source.id;\n                const Tag = source.href ? \"a\" : \"button\";\n                return (\n                  <li\n                    key={source.id}\n                    className={cn(\n                      `flex min-w-0 transition-[opacity,translate,scale] ${TIMING.chip.ease}`,\n                      lit || dim ? TIMING.chip.on : TIMING.chip.off,\n                      !dealt ? \"pointer-events-none scale-[0.96] opacity-0\" : dim ? \"opacity-50\" : \"opacity-100\",\n                      lit && \"-translate-y-0.5\",\n                    )}\n                  >\n                    <Tag\n                      {...(source.href\n                        ? { href: source.href, target: \"_blank\", rel: \"noreferrer\" }\n                        : { type: \"button\" as const, \"aria-expanded\": open })}\n                      data-footnote-chip={source.id}\n                      tabIndex={dealt ? undefined : -1}\n                      aria-describedby={open ? `${uid}-source` : undefined}\n                      className={cn(\n                        `relative flex h-7 min-w-0 max-w-[13rem] cursor-pointer items-center gap-1.5 rounded-full pr-2.5 pl-1.5 text-xs ring-1 transition-[background-color,box-shadow,color] ${TIMING.tint}`,\n                        lit || open\n                          ? \"bg-(--footnote-accent)/10 text-current ring-(--footnote-accent)/45\"\n                          : \"bg-current/[0.04] text-current/75 ring-current/10 hover:bg-current/[0.07] hover:text-current\",\n                        FOCUS,\n                      )}\n                      onPointerEnter={(event: React.PointerEvent) => {\n                        if (event.pointerType !== \"mouse\") return;\n                        holdLink({ run, kind: \"source\", id: source.id });\n                        window.clearTimeout(leave.current);\n                        window.clearTimeout(intent.current);\n                        if (!open) intent.current = window.setTimeout(() => openPreview(source.id), TIMING.intent);\n                      }}\n                      onPointerLeave={(event: React.PointerEvent) => {\n                        if (event.pointerType !== \"mouse\") return;\n                        releaseLink();\n                        closePreview(true);\n                      }}\n                      onFocus={(event: React.FocusEvent<HTMLElement>) => {\n                        holdLink({ run, kind: \"source\", id: source.id });\n                        if (event.currentTarget.matches(\":focus-visible\")) openPreview(source.id);\n                      }}\n                      onBlur={() => {\n                        releaseLink();\n                        closePreview(true);\n                      }}\n                      onKeyDown={(event: React.KeyboardEvent) => {\n                        if (event.key !== \" \" || !source.href) return;\n                        event.preventDefault();\n                        if (open) closePreview(false);\n                        else openPreview(source.id);\n                      }}\n                      onClick={(event: React.MouseEvent) => {\n                        const touch = pointer.current !== \"mouse\" && event.detail > 0;\n                        if (!source.href || touch) {\n                          event.preventDefault();\n                          if (open) {\n                            closePreview(false);\n                            setLink(null);\n                          } else {\n                            openPreview(source.id);\n                            if (touch) setLink({ run, kind: \"source\", id: source.id });\n                          }\n                        }\n                      }}\n                    >\n                      <span\n                        data-footnote-num={num === undefined ? undefined : source.id}\n                        className={cn(\n                          `grid h-4 min-w-4 shrink-0 place-items-center rounded-full px-1 text-[10px]/none font-semibold tabular-nums transition-[background-color,color] ${TIMING.tint}`,\n                          lit\n                            ? \"bg-(--footnote-accent) text-white dark:text-zinc-950\"\n                            : \"bg-current/[0.08] text-current/70\",\n                        )}\n                        aria-hidden\n                      >\n                        {num ?? monogram(source.site)}\n                      </span>\n                      <span className=\"truncate\">{source.site}</span>\n                      <span\n                        data-footnote-ring={source.id}\n                        aria-hidden\n                        className=\"pointer-events-none absolute inset-0 rounded-full opacity-0 ring-2 ring-(--footnote-accent)\"\n                      />\n                    </Tag>\n                  </li>\n                );\n              })}\n              {plan.reads > plan.order.length && (\n                <li\n                  className={cn(\n                    `flex h-7 items-center rounded-full px-2.5 text-xs text-current/55 tabular-nums transition-[opacity,scale] ${TIMING.more}`,\n                    more > 0 ? \"opacity-100\" : \"scale-[0.96] opacity-0\",\n                  )}\n                >\n                  +{full ? plan.reads - plan.order.length : more} more\n                  <span className=\"sr-only\"> sources</span>\n                </li>\n              )}\n            </ul>\n          </div>\n        )}\n      </div>\n\n      <div aria-hidden className=\"pointer-events-none absolute inset-0 z-10\">\n        {state.flying.map((flight) => (\n          <FlyingNumber\n            key={flight.key}\n            flight={flight}\n            arc={arc}\n            duration={flightDuration}\n            bounce={flightBounce}\n            onLand={store.land}\n          />\n        ))}\n      </div>\n\n      <AnimatePresence>\n        {activePreview && previewSource && (\n          <SourceCard\n            key={`${activePreview.run}:${activePreview.id}`}\n            id={`${uid}-source`}\n            source={previewSource}\n            preview={activePreview}\n            reduced={reduced}\n            onPointerEnter={keepPreview}\n            onPointerLeave={() => closePreview(true)}\n          />\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/* ── FlyingNumber ───────────────────────────────────────── */\n\n// A copy of a chip's number on its way to the claim's marker. It leaves lit in the accent and\n// lands looking exactly like the marker, which shows in the same frame this copy goes away. The\n// marker is measured again each frame, so a reflow mid-flight (a resize, a late font) still lands.\nfunction FlyingNumber({\n  flight,\n  arc,\n  duration,\n  bounce,\n  onLand,\n}: {\n  flight: Flight;\n  arc: number;\n  duration: number;\n  bounce: number;\n  onLand: (key: string) => void;\n}) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const lit = React.useRef<HTMLSpanElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    const root = el?.parentElement?.parentElement;\n    if (!el || !root) return;\n    const mark = root.querySelector(`[data-footnote-mark=\"${flight.key}\"]`);\n    const { from } = flight;\n    const place = (p: number) => {\n      const to = mark ? boxIn(mark, root) : flight.to;\n      const peak = { x: (from.x + to.x) / 2, y: Math.min(from.y, to.y) - arc };\n      const u = 1 - p;\n      const x = u * u * from.x + 2 * u * p * peak.x + p * p * to.x;\n      const y = u * u * from.y + 2 * u * p * peak.y + p * p * to.y;\n      const scale = 1 + NOTE.grow * Math.sin(Math.PI * clamp(p, 0, 1));\n      el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;\n      if (lit.current) lit.current.style.opacity = String(1 - smoothstep(0.45, 0.95, p));\n    };\n    place(0);\n    const controls = animate(0, 1, {\n      ...TIMING.fly,\n      visualDuration: duration,\n      bounce,\n      onUpdate: place,\n      onComplete: () => onLand(flight.key),\n    });\n    return () => controls.stop();\n  }, [flight, onLand, arc, duration, bounce]);\n  return (\n    <span\n      ref={ref}\n      className=\"absolute top-0 left-0 grid h-4 min-w-4 origin-center place-items-center rounded-full bg-(--footnote-accent)/12 px-1 text-[10px]/none font-semibold text-(--footnote-accent) tabular-nums will-change-transform\"\n    >\n      {flight.num}\n      <span\n        ref={lit}\n        className=\"absolute inset-0 grid place-items-center rounded-full bg-(--footnote-accent) text-white shadow-sm dark:text-zinc-950\"\n      >\n        {flight.num}\n      </span>\n    </span>\n  );\n}\n\n/* ── SourceCard ─────────────────────────────────────────── */\n\n// The source, grown out of its chip: above it when there's room in the card, below it when not.\nfunction SourceCard({\n  id,\n  source,\n  preview,\n  reduced,\n  onPointerEnter,\n  onPointerLeave,\n}: {\n  id: string;\n  source: FootnoteSource;\n  preview: Preview;\n  reduced: boolean;\n  onPointerEnter: () => void;\n  onPointerLeave: () => void;\n}) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const { anchor, width } = preview;\n\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    const root = el?.offsetParent as HTMLElement | null;\n    if (!el || !root) return;\n    const left = clamp(anchor.x - 6, 8, Math.max(8, root.clientWidth - width - 8));\n    const height = el.offsetHeight;\n    const above = anchor.y - NOTE.gap - height >= 4;\n    el.style.left = `${left}px`;\n    el.style.top = `${above ? anchor.y - NOTE.gap - height : anchor.y + anchor.h + NOTE.gap}px`;\n    el.style.transformOrigin = `${anchor.x + Math.min(anchor.w, 28) / 2 - left}px ${above ? \"100%\" : \"0%\"}`;\n  }, [anchor, width]);\n\n  const passage = source.snippet ? markQuote(source.snippet, source.quote) : null;\n  return (\n    <motion.div\n      ref={ref}\n      id={id}\n      initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.92 }}\n      animate={{ opacity: 1, scale: 1, transition: reduced ? TIMING.close : TIMING.open }}\n      exit={reduced ? { opacity: 0, transition: TIMING.close } : { opacity: 0, scale: 0.96, transition: TIMING.close }}\n      // Top, left and the origin are set before paint, once the card's height is known.\n      style={{ width }}\n      className=\"absolute z-20 rounded-2xl bg-white p-3 text-left text-zinc-950 shadow-lg ring-1 shadow-zinc-950/10 ring-zinc-950/10 dark:bg-zinc-800 dark:text-white dark:shadow-black/40 dark:ring-white/10\"\n      onPointerEnter={(event) => {\n        if (event.pointerType === \"mouse\") onPointerEnter();\n      }}\n      onPointerLeave={(event) => {\n        if (event.pointerType === \"mouse\") onPointerLeave();\n      }}\n    >\n      <div className=\"flex items-center gap-1.5 text-[11px] text-zinc-500 dark:text-zinc-400\">\n        <span\n          aria-hidden\n          className=\"grid size-4 shrink-0 place-items-center rounded-full bg-zinc-950/[0.06] text-[9px] font-semibold text-zinc-600 dark:bg-white/10 dark:text-zinc-300\"\n        >\n          {monogram(source.site)}\n        </span>\n        <span className=\"truncate\">{source.site}</span>\n      </div>\n      <p className=\"mt-1.5 text-[13px]/5 font-medium tracking-[-0.005em]\">\n        {source.href ? (\n          <a\n            href={source.href}\n            target=\"_blank\"\n            rel=\"noreferrer\"\n            tabIndex={-1}\n            className=\"underline-offset-2 hover:underline\"\n          >\n            {source.title}\n            <ArrowIcon className=\"ml-1 inline size-3 -translate-y-px text-zinc-400\" />\n          </a>\n        ) : (\n          source.title\n        )}\n      </p>\n      {passage && (\n        <p className=\"mt-1.5 line-clamp-4 text-xs/[1.55] text-zinc-600 dark:text-zinc-300\">\n          “{passage.before}\n          {passage.quote && (\n            <mark className=\"rounded-[3px] bg-(--footnote-accent)/15 px-0.5 text-zinc-950 box-decoration-clone dark:text-white\">\n              {passage.quote}\n            </mark>\n          )}\n          {passage.after}”\n        </p>\n      )}\n    </motion.div>\n  );\n}\n\nfunction markQuote(snippet: string, quote?: string) {\n  const at = quote ? snippet.toLowerCase().indexOf(quote.toLowerCase()) : -1;\n  if (!quote || at < 0) return { before: snippet, quote: \"\", after: \"\" };\n  return { before: snippet.slice(0, at), quote: snippet.slice(at, at + quote.length), after: snippet.slice(at + quote.length) };\n}\n\n/* ── Icons ──────────────────────────────────────────────── */\n\nfunction SparkIcon({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" className={className} aria-hidden>\n      <path\n        d=\"M8 1.5c.3 2.9 1.1 4.6 2.2 5.4 1 .8 2.4 1.1 4.3 1.1-1.9 0-3.3.3-4.3 1.1C9.1 9.9 8.3 11.6 8 14.5c-.3-2.9-1.1-4.6-2.2-5.4C4.8 8.3 3.4 8 1.5 8c1.9 0 3.3-.3 4.3-1.1C6.9 6.1 7.7 4.4 8 1.5Z\"\n        fill=\"currentColor\"\n      />\n    </svg>\n  );\n}\n\nfunction SpinnerIcon({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 12 12\" className={className} fill=\"none\" aria-hidden>\n      <circle cx=\"6\" cy=\"6\" r=\"4.75\" stroke=\"currentColor\" strokeOpacity={0.25} strokeWidth={1.5} />\n      <path d=\"M6 1.25A4.75 4.75 0 0 1 10.75 6\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 12 12\" className={className} fill=\"none\" aria-hidden>\n      <circle cx=\"6\" cy=\"6\" r=\"5.25\" fill=\"currentColor\" fillOpacity={0.14} />\n      <path d=\"m3.8 6.1 1.5 1.5 2.9-3.1\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction ArrowIcon({ className }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 12 12\" className={className} fill=\"none\" aria-hidden>\n      <path d=\"M4 8l4-4M4.5 4H8v3.5\" stroke=\"currentColor\" strokeWidth={1.3} strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n"
    }
  ]
}