{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "research-report",
  "title": "Research Report",
  "description": "The finished output of a deep-research run — prose whose citation numbers are derived from one walk of the data, per-section source chips, an outline that follows the reader, and a methodology footer that shows the queries run and the sources rejected.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/blocks/research-report.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AlertCircle,\n  Ban,\n  BookMarked,\n  ChevronDown,\n  Clock,\n  FileSearch,\n  FileText,\n  FlaskConical,\n  Globe,\n  ListTree,\n  Lock,\n  RotateCcw,\n  Search,\n  ShieldAlert,\n  ShieldCheck,\n  ShieldQuestion,\n  Sparkles,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport type {\n  ResearchConfidence,\n  ResearchMethodology,\n  ResearchReportData,\n  ResearchSection,\n  ResearchSource,\n  ResearchSourceKind,\n} from \"./research-report.contract\"\n\n/* ------------------------------------------------------------------ citations */\n\n/**\n * The one piece of markup a paragraph may contain: `[^source-id]`. The id may not\n * contain whitespace or `]`, which is what keeps an ordinary bracket in prose\n * (\"see table [2] below\") from being swallowed as a marker.\n *\n * Two shapes of the same pattern: a capturing split (used with `String.split`, so\n * the odd indices come back as the raw markers) and a global scan (used with\n * `matchAll` to collect ids in reading order). `matchAll` does not advance the\n * literal's own `lastIndex`, so the module-level regex is safe to share.\n */\nconst CITATION_SPLIT = /(\\[\\^[^\\]\\s]+\\])/\nconst CITATION_ONE = /\\[\\^([^\\]\\s]+)\\]/\nconst CITATION_SCAN = /\\[\\^([^\\]\\s]+)\\]/g\n\n/**\n * Three treatments that stay apart without hue — solid tint, neutral fill, dashed\n * outline — and each is announced by an ICON and a WORD before colour is\n * involved, so the grade survives greyscale and a re-themed palette.\n */\nconst CONFIDENCE_META: Record<ResearchConfidence, { className: string; icon: typeof ShieldCheck; label: string }> = {\n  high: { className: \"bg-primary/10 text-primary\", icon: ShieldCheck, label: \"High confidence\" },\n  medium: { className: \"bg-muted text-foreground\", icon: ShieldAlert, label: \"Medium confidence\" },\n  low: { className: \"border border-dashed text-muted-foreground\", icon: ShieldQuestion, label: \"Low confidence\" },\n}\n\nconst KIND_ICON: Record<ResearchSourceKind, typeof Globe> = {\n  file: FileText,\n  internal: Lock,\n  paper: BookMarked,\n  web: Globe,\n}\n\n/** Only shown when a source has no domain — it answers \"why is this one not a link?\". */\nconst KIND_LABEL: Record<ResearchSourceKind, string> = {\n  file: \"uploaded file\",\n  internal: \"internal document\",\n  paper: \"paper\",\n  web: \"web page\",\n}\n\nconst BUTTON_CLASS =\n  \"inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium \" +\n  \"transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none \" +\n  \"motion-reduce:transition-none\"\n\nconst LINK_CLASS =\n  \"rounded-sm underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n\ninterface CitationIndex {\n  /** Report-wide citation number per source id, in first-appearance order. */\n  numbers: Map<string, number>\n  /** Source lookup; the FIRST record wins if the pool repeats an id. */\n  sources: Map<string, ResearchSource>\n  /** Ordered, de-duplicated source ids for each section, by section index. */\n  perSection: string[][]\n  /** Sources in the pool that no section ever references. */\n  uncited: number\n}\n\nfunction collectMarkers(section: ResearchSection): string[] {\n  const ids: string[] = []\n  for (const paragraph of section.paragraphs) {\n    for (const match of paragraph.matchAll(CITATION_SCAN)) ids.push(match[1])\n  }\n  return ids\n}\n\n/**\n * ONE walk of the data produces both the inline numbers and the per-section\n * chips, which is the only reason the two can never disagree. Reading order\n * decides everything: the markers inside a section's paragraphs first, then that\n * section's marker-less `citations`, then on to the next section.\n */\nfunction buildIndex(sections: ResearchSection[], sources: ResearchSource[]): CitationIndex {\n  const byId = new Map<string, ResearchSource>()\n  for (const source of sources) if (!byId.has(source.id)) byId.set(source.id, source)\n\n  const numbers = new Map<string, number>()\n  const perSection: string[][] = []\n\n  for (const section of sections) {\n    const seen = new Set<string>()\n    const list: string[] = []\n    for (const id of [...collectMarkers(section), ...section.citations]) {\n      // An id the pool does not contain gets no number and no chip: the marker in\n      // the prose degrades instead of pointing at a source that is not there.\n      if (!byId.has(id) || seen.has(id)) continue\n      seen.add(id)\n      list.push(id)\n      if (!numbers.has(id)) numbers.set(id, numbers.size + 1)\n    }\n    perSection.push(list)\n  }\n\n  let uncited = 0\n  for (const id of byId.keys()) if (!numbers.has(id)) uncited += 1\n\n  return { numbers, perSection, sources: byId, uncited }\n}\n\n/* ------------------------------------------------------------------ formatting */\n\n/**\n * A source URL comes out of a crawler or a model, so `javascript:` is a live\n * possibility and must never reach the DOM. `\"\"`, whitespace and `\"#\"` mean\n * \"absent\", not \"a link that goes nowhere\".\n */\nfunction safeHref(url: string | undefined): string | null {\n  const value = url?.trim()\n  if (!value || value === \"#\") return null\n  if (value.startsWith(\"/\")) return value\n  return /^(?:https?:|mailto:)/i.test(value) ? value : null\n}\n\nfunction displayDomain(source: ResearchSource): string | null {\n  const explicit = source.domain?.trim()\n  if (explicit) return explicit\n  const href = safeHref(source.url)\n  if (!href || href.startsWith(\"/\")) return null\n  try {\n    return new URL(href).hostname.replace(/^www\\./, \"\") || null\n  } catch {\n    // Not parseable as an absolute URL — show nothing rather than a broken host.\n    return null\n  }\n}\n\n/** Epoch ms, or null when the instant cannot be parsed — never \"Invalid Date\". */\nfunction toMs(iso: string | undefined): number | null {\n  if (!iso) return null\n  const ms = Date.parse(iso)\n  return Number.isFinite(ms) ? ms : null\n}\n\n/**\n * Compact run length. Deliberately not `Intl.DurationFormat` — it is not in every\n * runtime this ships to yet, and a meta bar reads better as \"4m 24s\" than as\n * \"4 minutes, 24 seconds\".\n */\nfunction formatDuration(ms: number | undefined): string | null {\n  if (ms === undefined || !Number.isFinite(ms) || ms < 0) return null\n  const seconds = Math.round(ms / 1000)\n  if (seconds < 60) return `${seconds}s`\n  const minutes = Math.floor(seconds / 60)\n  if (minutes < 60) {\n    const rest = seconds % 60\n    return rest ? `${minutes}m ${rest}s` : `${minutes}m`\n  }\n  const hours = Math.floor(minutes / 60)\n  const rest = minutes % 60\n  return rest ? `${hours}h ${rest}m` : `${hours}h`\n}\n\n/**\n * `generatedAt` is printed in an EXPLICIT zone, never the reader's: a server\n * render and a client render must produce the same string, or hydration swaps one\n * timestamp for another.\n */\nfunction buildDateFormat(locale: string, timeZone: string): Intl.DateTimeFormat {\n  for (const tag of [locale, \"en-US\"]) {\n    try {\n      return new Intl.DateTimeFormat(tag, { dateStyle: \"medium\", timeStyle: \"short\", timeZone })\n    } catch {\n      // Malformed BCP-47 tag or unknown zone — `Intl` throws RangeError; try the next fallback.\n    }\n  }\n  return new Intl.DateTimeFormat(\"en-US\", { dateStyle: \"medium\", timeStyle: \"short\", timeZone: \"UTC\" })\n}\n\nfunction buildNumberFormat(locale: string): Intl.NumberFormat {\n  try {\n    return new Intl.NumberFormat(locale)\n  } catch {\n    // Same story: a bad tag falls back instead of throwing during render.\n    return new Intl.NumberFormat(\"en-US\")\n  }\n}\n\nfunction plural(count: number, noun: string, format: Intl.NumberFormat) {\n  return `${format.format(count)} ${noun}${count === 1 ? \"\" : \"s\"}`\n}\n\n/** DOM ids are built from indices, never from payload ids — a source id may contain anything. */\nfunction sectionDomId(uid: string, index: number) {\n  return `${uid}-s${index}`\n}\n\nfunction citationDomId(uid: string, sectionIndex: number, number: number) {\n  return `${uid}-s${sectionIndex}-c${number}`\n}\n\nfunction usePrefersReducedMotion() {\n  const [reduced, setReduced] = React.useState(false)\n  React.useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const update = () => setReduced(query.matches)\n    update()\n    query.addEventListener(\"change\", update)\n    return () => query.removeEventListener(\"change\", update)\n  }, [])\n  return reduced\n}\n\n/* ------------------------------------------------------------------ pieces */\n\nfunction SkeletonBar({ className }: { className?: string }) {\n  return <div className={cn(\"h-3 animate-pulse rounded bg-muted motion-reduce:animate-none\", className)} />\n}\n\nfunction MethodologyPanel({\n  methodology,\n  numberFormat,\n  uncited,\n}: {\n  methodology: ResearchMethodology\n  numberFormat: Intl.NumberFormat\n  uncited: number\n}) {\n  return (\n    <div className=\"flex flex-col gap-5\">\n      {methodology.queries.length > 0 && (\n        <div className=\"flex flex-col gap-2\">\n          <h4 className=\"text-xs font-medium\">{`Queries run (${numberFormat.format(methodology.queries.length)})`}</h4>\n          <ol className=\"flex flex-col gap-1.5\" role=\"list\">\n            {methodology.queries.map((query, queryIndex) => (\n              <li className=\"flex flex-wrap items-baseline gap-x-2 gap-y-1 text-xs\" key={`${query.text}-${queryIndex}`}>\n                <Search aria-hidden=\"true\" className=\"size-3 shrink-0 translate-y-0.5 text-muted-foreground\" />\n                <span className=\"rounded bg-muted px-1.5 py-0.5 font-mono wrap-anywhere\">{query.text}</span>\n                {query.engine && <span className=\"text-muted-foreground\">{query.engine}</span>}\n                {typeof query.results === \"number\" && Number.isFinite(query.results) && (\n                  <span className=\"text-muted-foreground tabular-nums\">\n                    {plural(query.results, \"hit\", numberFormat)}\n                  </span>\n                )}\n              </li>\n            ))}\n          </ol>\n        </div>\n      )}\n\n      {methodology.rejected.length > 0 && (\n        <div className=\"flex flex-col gap-2\">\n          <h4 className=\"text-xs font-medium\">\n            {`Sources rejected (${numberFormat.format(methodology.rejected.length)})`}\n          </h4>\n          <ul className=\"flex flex-col gap-2\" role=\"list\">\n            {methodology.rejected.map(item => {\n              const href = safeHref(item.url)\n              const domain = item.domain?.trim()\n              return (\n                <li className=\"flex gap-2 text-xs\" key={item.id}>\n                  <Ban aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0 text-muted-foreground\" />\n                  <div className=\"flex min-w-0 flex-col gap-0.5\">\n                    <span className=\"wrap-anywhere\">\n                      {href ? (\n                        <a className={LINK_CLASS} href={href} rel=\"noreferrer\" target=\"_blank\">\n                          {item.title}\n                        </a>\n                      ) : (\n                        item.title\n                      )}\n                      {domain && <span className=\"text-muted-foreground\">{` · ${domain}`}</span>}\n                    </span>\n                    {/* The reason is the whole point of this list: \"4 rejected\" is a\n                        number, \"rejected because it re-quotes the vendor\" is evidence. */}\n                    <span className=\"text-muted-foreground wrap-anywhere\">{item.reason}</span>\n                  </div>\n                </li>\n              )\n            })}\n          </ul>\n        </div>\n      )}\n\n      {methodology.notes && methodology.notes.length > 0 && (\n        <div className=\"flex flex-col gap-2\">\n          <h4 className=\"text-xs font-medium\">Limits</h4>\n          <ul className=\"flex flex-col gap-1\" role=\"list\">\n            {methodology.notes.map((note, noteIndex) => (\n              <li className=\"text-xs text-muted-foreground wrap-anywhere\" key={`${note}-${noteIndex}`}>\n                {note}\n              </li>\n            ))}\n          </ul>\n        </div>\n      )}\n\n      {uncited > 0 && (\n        // Payload hygiene, reported rather than swallowed: sources that arrived in\n        // the pool but were never referenced by any section.\n        <p className=\"text-xs text-muted-foreground\">\n          {`${plural(uncited, \"source\", numberFormat)} in the payload ${uncited === 1 ? \"was\" : \"were\"} never referenced by a section.`}\n        </p>\n      )}\n    </div>\n  )\n}\n\n/* ------------------------------------------------------------------ component */\n\nexport interface ResearchReportProps\n  extends ResearchReportData,\n    Omit<React.HTMLAttributes<HTMLElement>, \"children\" | \"title\"> {\n  /** BCP-47 tag for the counts and the generated-at stamp. Explicit so SSR and CSR agree. */\n  locale?: string\n  /** IANA zone `meta.generatedAt` is printed in. The reader's zone is never guessed. */\n  timeZone?: string\n  /**\n   * Height of the host's sticky header in px. It moves three things at once — the\n   * outline's active line, the scroll target of a click, and the rail's own sticky\n   * top — so they can never disagree about where \"the top\" is.\n   */\n  offset?: number\n  /**\n   * Export / share controls, rendered in the header of the READY state only. The\n   * block never fakes an export: the host ships buttons that really do something.\n   */\n  actions?: React.ReactNode\n  /** Fired after an outline click with the section's contract id. The component never writes to `location`. */\n  onNavigate?: (sectionId: string) => void\n  /** Omit to drop the retry affordance from the error state entirely. */\n  onRetry?: () => void\n  /** Start the methodology footer expanded. */\n  defaultMethodologyOpen?: boolean\n  /** Section placeholders in the loading state; clamped to 1–6. */\n  skeletonSections?: number\n  /**\n   * What to do with a `[^id]` whose id is missing from `sources`: keep it as a\n   * muted, inert `[?]` (default — the reader can see something was dropped), or\n   * remove it from the prose entirely.\n   */\n  unresolvedCitations?: \"mark\" | \"hide\"\n}\n\nexport const ResearchReport = React.forwardRef<HTMLElement, ResearchReportProps>(function ResearchReport(\n  {\n    actions,\n    className,\n    defaultMethodologyOpen = false,\n    locale = \"en-US\",\n    meta,\n    methodology,\n    offset = 96,\n    onNavigate,\n    onRetry,\n    sections,\n    skeletonSections = 3,\n    sources,\n    status,\n    timeZone = \"UTC\",\n    title,\n    unresolvedCitations = \"mark\",\n    ...props\n  },\n  ref,\n) {\n  const uid = React.useId()\n  const titleId = `${uid}-title`\n  const panelId = `${uid}-methodology`\n\n  const [activeSection, setActiveSection] = React.useState(0)\n  const [highlighted, setHighlighted] = React.useState<string | null>(null)\n  const [methodologyOpen, setMethodologyOpen] = React.useState(defaultMethodologyOpen)\n\n  const sectionNodes = React.useRef<(HTMLElement | null)[]>([])\n  const lockedSection = React.useRef<number | null>(null)\n  const lockTimer = React.useRef<number | null>(null)\n  const reduced = usePrefersReducedMotion()\n\n  const index = React.useMemo(() => buildIndex(sections, sources), [sections, sources])\n  const dateFormat = React.useMemo(() => buildDateFormat(locale, timeZone), [locale, timeZone])\n  const numberFormat = React.useMemo(() => buildNumberFormat(locale), [locale])\n\n  // \"ready\" with nothing written is the empty panel, not an empty page with an\n  // outline rail and a \"0 sources\" meta bar. A race between status and data\n  // degrades to the branch that can actually be rendered.\n  const branch = status === \"ready\" && sections.length === 0 ? \"empty\" : status\n  // A one-section report has nothing to navigate: the rail would be a control that\n  // always points at where the reader already is.\n  const showOutline = branch === \"ready\" && sections.length > 1\n  const activeIndex = Math.min(activeSection, Math.max(0, sections.length - 1))\n\n  /**\n   * Which section is being read, recomputed from rectangles on a rAF-throttled\n   * scroll listener. Rect scanning rather than an IntersectionObserver band\n   * because two of the three rules below describe positions where NOTHING crosses\n   * a boundary — a section taller than the viewport, and the last section at the\n   * bottom of the page — and those produce no observer callback at all.\n   */\n  React.useEffect(() => {\n    if (!showOutline) return\n    // Drop refs left behind by a longer previous report before anything reads them.\n    sectionNodes.current.length = sections.length\n    let frame = 0\n\n    const resolve = () => {\n      const nodes = sectionNodes.current\n      if (nodes.length === 0) return\n      const line = offset + 8\n\n      // The last heading that has passed under the sticky header wins. That also\n      // covers a section taller than the viewport: nothing has to be \"in a band\".\n      let next = 0\n      for (let i = 0; i < nodes.length; i += 1) {\n        const node = nodes[i]\n        if (node && node.getBoundingClientRect().top <= line) next = i\n      }\n\n      // End of the document beats both. The final section usually cannot reach the\n      // line — there is no scroll distance left below it — so without this it could\n      // never light up, and clicking it would bounce the highlight back one entry.\n      const doc = document.documentElement\n      const scrollable = doc.scrollHeight - window.innerHeight > 4\n      if (scrollable && window.scrollY + window.innerHeight >= doc.scrollHeight - 4) {\n        next = nodes.length - 1\n      }\n      if (next < 0) return\n\n      // One-shot lock: a click owns the highlight until the smooth scroll it started\n      // arrives, otherwise every section the page flies past would flash active on\n      // the way there. Released on arrival OR by the timer — never left set.\n      if (lockedSection.current !== null) {\n        if (lockedSection.current !== next) return\n        lockedSection.current = null\n      }\n      setActiveSection(next)\n    }\n\n    const onScroll = () => {\n      if (frame) return\n      frame = window.requestAnimationFrame(() => {\n        frame = 0\n        resolve()\n      })\n    }\n\n    resolve()\n    window.addEventListener(\"scroll\", onScroll, { passive: true })\n    window.addEventListener(\"resize\", onScroll)\n    return () => {\n      if (frame) window.cancelAnimationFrame(frame)\n      window.removeEventListener(\"scroll\", onScroll)\n      window.removeEventListener(\"resize\", onScroll)\n    }\n  }, [offset, sections, showOutline])\n\n  // The lock timer is armed from a click handler, so it outlives the effect above\n  // and gets its own unmount cleanup.\n  React.useEffect(\n    () => () => {\n      if (lockTimer.current) window.clearTimeout(lockTimer.current)\n    },\n    [],\n  )\n\n  const scrollToNode = React.useCallback(\n    (node: HTMLElement) => {\n      const top = node.getBoundingClientRect().top + window.scrollY - offset\n      window.scrollTo({ behavior: reduced ? \"auto\" : \"smooth\", top: Math.max(0, top) })\n    },\n    [offset, reduced],\n  )\n\n  const goToSection = (target: number) => {\n    const node = sectionNodes.current[target]\n    const section = sections[target]\n    if (!node || !section) return\n\n    setActiveSection(target)\n    lockedSection.current = target\n    if (lockTimer.current) window.clearTimeout(lockTimer.current)\n    lockTimer.current = window.setTimeout(() => {\n      lockedSection.current = null\n      lockTimer.current = null\n    }, 1200)\n\n    scrollToNode(node)\n    // preventScroll, or focus() would jump instantly and cancel the smooth scroll\n    // that was just started. Keyboard users still land inside the section they asked for.\n    node.focus({ preventScroll: true })\n    onNavigate?.(section.id)\n  }\n\n  const goToCitation = (chipId: string) => {\n    const node = document.getElementById(chipId)\n    if (!node) return\n    setHighlighted(chipId)\n    const rect = node.getBoundingClientRect()\n    // Most chips sit one paragraph below their marker: moving the page when the\n    // target is already on screen is disorienting, so only scroll when it is not.\n    if (rect.top < offset || rect.bottom > window.innerHeight) scrollToNode(node)\n    node.focus({ preventScroll: true })\n  }\n\n  /**\n   * Structural rendering: the paragraph is SPLIT on the marker pattern and\n   * reassembled as text nodes plus elements. Nothing is ever handed to\n   * `dangerouslySetInnerHTML`, so a crawled `<script>` string that a model copied\n   * into its prose renders as those characters instead of becoming an element.\n   */\n  const renderProse = (text: string, sectionIndex: number): React.ReactNode[] =>\n    text.split(CITATION_SPLIT).map((part, partIndex) => {\n      if (partIndex % 2 === 0) return part\n\n      const id = part.match(CITATION_ONE)?.[1] ?? \"\"\n      const number = index.numbers.get(id)\n      const source = index.sources.get(id)\n\n      if (number === undefined || !source) {\n        if (unresolvedCitations === \"hide\") return null\n        return (\n          <sup key={partIndex}>\n            <span aria-hidden=\"true\" className=\"text-muted-foreground\">\n              [?]\n            </span>\n            <span className=\"sr-only\">{`Citation \"${id}\" is missing from this report's sources`}</span>\n          </sup>\n        )\n      }\n\n      const chipId = citationDomId(uid, sectionIndex, number)\n      return (\n        <sup key={partIndex}>\n          <a\n            aria-label={`Source ${number}: ${source.title}`}\n            className={cn(\"mx-px px-0.5 font-medium text-primary tabular-nums\", LINK_CLASS)}\n            href={`#${chipId}`}\n            onClick={event => {\n              // The hash stays out of the URL — the host owns history, exactly as it\n              // does for the outline. The click still does the useful half of the default.\n              event.preventDefault()\n              goToCitation(chipId)\n            }}\n          >\n            {`[${number}]`}\n          </a>\n        </sup>\n      )\n    })\n\n  const citedCount = index.numbers.size\n  const consulted =\n    typeof meta.sourcesConsulted === \"number\" && Number.isFinite(meta.sourcesConsulted) ? meta.sourcesConsulted : null\n  // A payload claiming fewer pages read than cited disagrees with itself; print\n  // only the half that can be proved from the sections themselves.\n  const showConsulted = consulted !== null && consulted >= citedCount\n  const duration = formatDuration(meta.durationMs)\n  const confidence = meta.confidence ? CONFIDENCE_META[meta.confidence] : null\n  const ConfidenceIcon = confidence?.icon\n  const generatedAt = toMs(meta.generatedAt)\n  const skeletonCount = Number.isFinite(skeletonSections) ? Math.min(6, Math.max(1, Math.floor(skeletonSections))) : 3\n\n  /**\n   * The gap between \"consulted\" and \"cited\" is the number worth printing — it is\n   * exactly what the methodology footer then explains. A run that cited nothing\n   * still says how much it read, which is the difference between \"it found\n   * nothing\" and \"it never looked\".\n   */\n  const sourcesLabel =\n    citedCount > 0\n      ? showConsulted && consulted !== null\n        ? `${numberFormat.format(citedCount)} cited of ${plural(consulted, \"source\", numberFormat)} consulted`\n        : `${plural(citedCount, \"source\", numberFormat)} cited`\n      : consulted !== null\n        ? `${plural(consulted, \"source\", numberFormat)} consulted · none cited`\n        : null\n  const hasMetaBar =\n    (branch === \"ready\" || branch === \"empty\") &&\n    (sourcesLabel !== null || duration !== null || confidence !== null || Boolean(meta.model) || generatedAt !== null)\n\n  return (\n    <article\n      aria-busy={branch === \"loading\" || undefined}\n      aria-label={title ? undefined : \"Research report\"}\n      aria-labelledby={title ? titleId : undefined}\n      className={cn(\"w-full rounded-xl border bg-card text-card-foreground\", className)}\n      ref={ref}\n      {...props}\n    >\n      <header className=\"flex flex-col gap-3 border-b px-5 py-4\">\n        <div className=\"flex flex-wrap items-start justify-between gap-x-4 gap-y-2\">\n          {title && (\n            <h2 className=\"min-w-0 text-base font-semibold wrap-anywhere sm:text-lg\" id={titleId}>\n              {title}\n            </h2>\n          )}\n          {/* The export slot only exists on ready: there is nothing to export out of a\n              run that failed, and a live-looking button over a broken card is a lie. */}\n          {actions && branch === \"ready\" && <div className=\"flex shrink-0 flex-wrap items-center gap-2\">{actions}</div>}\n        </div>\n\n        {branch === \"loading\" && (\n          <p className=\"flex items-center gap-2 text-xs text-muted-foreground\" role=\"status\">\n            <span\n              aria-hidden=\"true\"\n              className=\"size-1.5 shrink-0 animate-pulse rounded-full bg-primary motion-reduce:animate-none\"\n            />\n            Researching — reading sources and drafting sections…\n          </p>\n        )}\n\n        {hasMetaBar && (\n          <ul className=\"flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-muted-foreground\">\n            {sourcesLabel && (\n              <li className=\"flex items-center gap-1.5\">\n                <FileSearch aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n                {sourcesLabel}\n              </li>\n            )}\n            {duration !== null && (\n              <li className=\"flex items-center gap-1.5\">\n                <Clock aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n                <span className=\"sr-only\">Run time </span>\n                <span className=\"tabular-nums\">{duration}</span>\n              </li>\n            )}\n            {meta.model && (\n              <li className=\"flex items-center gap-1.5\">\n                <Sparkles aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n                <span className=\"wrap-anywhere\">{meta.model}</span>\n              </li>\n            )}\n            {generatedAt !== null && (\n              <li className=\"flex items-center gap-1.5\">\n                <time dateTime={meta.generatedAt}>{dateFormat.format(generatedAt)}</time>\n              </li>\n            )}\n            {confidence && ConfidenceIcon && (\n              <li>\n                <span\n                  className={cn(\"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium\", confidence.className)}\n                >\n                  <ConfidenceIcon aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n                  {confidence.label}\n                </span>\n              </li>\n            )}\n          </ul>\n        )}\n\n        {(branch === \"ready\" || branch === \"empty\") && meta.confidenceNote && (\n          // Why the grade is what it is — printed from the payload, never invented.\n          <p className=\"text-xs text-muted-foreground wrap-anywhere\">{meta.confidenceNote}</p>\n        )}\n      </header>\n\n      {branch === \"loading\" && (\n        <div aria-hidden=\"true\" className=\"grid gap-6 px-5 py-6 lg:grid-cols-[13rem_minmax(0,1fr)]\">\n          <div className=\"hidden flex-col gap-2.5 lg:flex\">\n            {Array.from({ length: 4 }, (_, bar) => (\n              <SkeletonBar className={bar % 2 === 0 ? \"w-full\" : \"w-4/5\"} key={bar} />\n            ))}\n          </div>\n          {/* The silhouette of a real section — heading, three lines, two chips —\n              repeated, so the card does not jump when the prose lands. */}\n          <div className=\"flex min-w-0 flex-col gap-6\">\n            {Array.from({ length: skeletonCount }, (_, section) => (\n              <div className=\"flex flex-col gap-2\" key={section}>\n                <SkeletonBar className=\"h-4 w-2/5\" />\n                <SkeletonBar className=\"w-full\" />\n                <SkeletonBar className=\"w-11/12\" />\n                <SkeletonBar className=\"w-3/4\" />\n                <div className=\"mt-1 flex gap-1.5\">\n                  <SkeletonBar className=\"h-5 w-28 rounded-full\" />\n                  <SkeletonBar className=\"h-5 w-36 rounded-full\" />\n                </div>\n              </div>\n            ))}\n          </div>\n        </div>\n      )}\n\n      {branch === \"empty\" && (\n        <div className=\"flex flex-col items-center gap-2 px-5 py-14 text-center\">\n          <FileSearch aria-hidden=\"true\" className=\"size-8 text-muted-foreground/50\" />\n          <p className=\"text-sm font-medium\">Nothing survived the filters</p>\n          <p className=\"max-w-md text-sm text-muted-foreground\">\n            {methodology\n              ? \"The run finished without writing a section — every source it opened was rejected. What it tried is listed below.\"\n              : \"The run finished without writing a section. Widen the question or add sources, then run it again.\"}\n          </p>\n        </div>\n      )}\n\n      {branch === \"error\" && (\n        <div className=\"flex flex-col items-center gap-3 px-5 py-14 text-center\">\n          <AlertCircle aria-hidden=\"true\" className=\"size-8 text-destructive\" />\n          <div className=\"flex flex-col gap-1\">\n            <p className=\"text-sm font-medium\">Couldn&apos;t load this report</p>\n            <p className=\"text-sm text-muted-foreground\">\n              The research service didn&apos;t respond. Nothing was lost — the finished run can be re-read.\n            </p>\n          </div>\n          {/* No onRetry, no button: the error card never grows an affordance with\n              nothing behind it. */}\n          {onRetry && (\n            <button className={cn(BUTTON_CLASS, \"px-3 py-1.5 text-sm\")} onClick={onRetry} type=\"button\">\n              <RotateCcw aria-hidden=\"true\" className=\"size-3.5\" />\n              Try again\n            </button>\n          )}\n        </div>\n      )}\n\n      {branch === \"ready\" && (\n        <div className={cn(\"grid gap-6 px-5 py-6\", showOutline && \"lg:grid-cols-[13rem_minmax(0,1fr)]\")}>\n          {showOutline && (\n            <nav\n              aria-label=\"Report outline\"\n              className=\"lg:sticky lg:self-start\"\n              // Sticky top and scroll target read the same prop, so the heading a\n              // click lands on is never left under the host's own header.\n              style={{ top: offset }}\n            >\n              <p className=\"mb-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground\">\n                <ListTree aria-hidden=\"true\" className=\"size-3.5\" />\n                On this page\n              </p>\n              {/* Tailwind's preflight strips list markers, and with them the list\n                  semantics some screen readers infer — restored explicitly. */}\n              <ol className=\"flex flex-col border-l\" role=\"list\">\n                {sections.map((section, sectionIndex) => (\n                  <li key={`${section.id}-${sectionIndex}`}>\n                    <a\n                      aria-current={sectionIndex === activeIndex ? \"location\" : undefined}\n                      className={cn(\n                        \"-ml-px flex gap-2 border-l-2 py-1 pl-3 text-xs transition-colors motion-reduce:transition-none\",\n                        \"hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n                        sectionIndex === activeIndex\n                          ? \"border-primary font-medium text-foreground\"\n                          : \"border-transparent text-muted-foreground\",\n                      )}\n                      href={`#${sectionDomId(uid, sectionIndex)}`}\n                      onClick={event => {\n                        event.preventDefault()\n                        goToSection(sectionIndex)\n                      }}\n                    >\n                      <span className=\"tabular-nums\">{sectionIndex + 1}.</span>\n                      <span className=\"wrap-anywhere\">{section.heading}</span>\n                    </a>\n                  </li>\n                ))}\n              </ol>\n            </nav>\n          )}\n\n          <div className=\"flex min-w-0 flex-col gap-8\">\n            {sections.map((section, sectionIndex) => {\n              const headingId = `${sectionDomId(uid, sectionIndex)}-heading`\n              const chips = index.perSection[sectionIndex] ?? []\n\n              return (\n                <section\n                  aria-labelledby={headingId}\n                  className=\"rounded-lg focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n                  id={sectionDomId(uid, sectionIndex)}\n                  key={`${section.id}-${sectionIndex}`}\n                  ref={node => {\n                    sectionNodes.current[sectionIndex] = node\n                  }}\n                  // -1, not 0: a scroll destination, not a stop on the tab order. It\n                  // only ever receives focus programmatically.\n                  tabIndex={-1}\n                >\n                  <h3 className=\"flex gap-2 text-sm font-semibold wrap-anywhere\" id={headingId}>\n                    <span className=\"text-muted-foreground tabular-nums\">{sectionIndex + 1}.</span>\n                    {section.heading}\n                  </h3>\n\n                  <div className=\"mt-2 flex flex-col gap-3\">\n                    {section.paragraphs.map((paragraph, paragraphIndex) => (\n                      <p className=\"text-sm leading-relaxed text-muted-foreground wrap-anywhere\" key={paragraphIndex}>\n                        {renderProse(paragraph, sectionIndex)}\n                      </p>\n                    ))}\n                  </div>\n\n                  {chips.length > 0 && (\n                    <ul\n                      aria-label={`Sources for ${section.heading}`}\n                      className=\"mt-3 flex flex-wrap gap-1.5\"\n                      role=\"list\"\n                    >\n                      {chips.map(id => {\n                        const source = index.sources.get(id)\n                        const number = index.numbers.get(id)\n                        if (!source || number === undefined) return null\n\n                        const chipId = citationDomId(uid, sectionIndex, number)\n                        const href = safeHref(source.url)\n                        const domain = displayDomain(source)\n                        const Icon = KIND_ICON[source.kind ?? \"web\"]\n                        const suffix = domain ?? (href ? null : KIND_LABEL[source.kind ?? \"web\"])\n\n                        return (\n                          <li\n                            className={cn(\n                              \"flex max-w-full items-baseline gap-1.5 rounded-full border bg-muted/40 px-2.5 py-1\",\n                              \"text-xs wrap-anywhere focus-visible:outline-none\",\n                              // Where a marker click lands: this chip, not the whole row.\n                              highlighted === chipId && \"ring-2 ring-ring\",\n                            )}\n                            data-source-id={source.id}\n                            id={chipId}\n                            key={id}\n                            onBlur={() => setHighlighted(current => (current === chipId ? null : current))}\n                            tabIndex={-1}\n                          >\n                            <Icon aria-hidden=\"true\" className=\"size-3 shrink-0 translate-y-0.5 text-muted-foreground\" />\n                            <span className=\"font-medium text-muted-foreground tabular-nums\">\n                              <span className=\"sr-only\">Source </span>\n                              {`[${number}]`}\n                            </span>\n                            {href ? (\n                              <a className={LINK_CLASS} href={href} rel=\"noreferrer\" target=\"_blank\">\n                                {source.title}\n                              </a>\n                            ) : (\n                              // No usable address: plain text, never an anchor to nowhere.\n                              <span>{source.title}</span>\n                            )}\n                            {suffix && <span className=\"text-muted-foreground\">{`· ${suffix}`}</span>}\n                          </li>\n                        )\n                      })}\n                    </ul>\n                  )}\n                </section>\n              )\n            })}\n          </div>\n        </div>\n      )}\n\n      {(branch === \"ready\" || branch === \"empty\") && methodology && (\n        // The methodology stays on the empty card on purpose: a run that cited\n        // nothing is exactly the run whose reader most needs to see what it tried.\n        <footer className=\"border-t px-5 py-4\">\n          <button\n            aria-controls={panelId}\n            aria-expanded={methodologyOpen}\n            className={cn(\n              \"flex w-full cursor-pointer items-center justify-between gap-3 rounded-md text-left\",\n              \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n            )}\n            onClick={() => setMethodologyOpen(open => !open)}\n            type=\"button\"\n          >\n            <span className=\"flex items-center gap-2 text-sm font-medium\">\n              <FlaskConical aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n              How this was researched\n            </span>\n            <span className=\"flex shrink-0 items-center gap-2 text-xs text-muted-foreground\">\n              <span className=\"hidden sm:inline\">\n                {`${numberFormat.format(methodology.queries.length)} ${methodology.queries.length === 1 ? \"query\" : \"queries\"} · ${plural(methodology.rejected.length, \"source\", numberFormat)} rejected`}\n              </span>\n              <ChevronDown\n                aria-hidden=\"true\"\n                className={cn(\"size-4 transition-transform motion-reduce:transition-none\", methodologyOpen && \"rotate-180\")}\n              />\n            </span>\n          </button>\n\n          {/* Kept mounted and hidden rather than unmounted: `aria-controls` above has\n              to resolve to a real element even while the panel is collapsed. */}\n          <div className=\"pt-4\" hidden={!methodologyOpen} id={panelId}>\n            <MethodologyPanel\n              methodology={methodology}\n              numberFormat={numberFormat}\n              uncited={branch === \"ready\" ? index.uncited : 0}\n            />\n          </div>\n        </footer>\n      )}\n    </article>\n  )\n})\n\nResearchReport.displayName = \"ResearchReport\"\n\nexport default ResearchReport\n",
      "type": "registry:block"
    },
    {
      "path": "src/registry/blocks/research-report.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * The payload a deep-research run hands over when it is finished: one question,\n * one set of sources, and prose that points back into that set.\n *\n * Two rules run through the whole shape, and both exist because the writer is a\n * model rather than a person:\n *\n * 1. **Numbers are never authored.** No field in here carries a citation number.\n *    The report's `[1] [2] [3]` come from walking `sections` in reading order,\n *    so the marker in the prose and the chip under the section cannot disagree —\n *    there is only one walk. A model that renumbered a paragraph on a retry\n *    would otherwise silently point sentence three at the wrong paper.\n * 2. **Absent means absent.** `confidence`, `durationMs`, `sourcesConsulted`,\n *    `generatedAt` and the whole `methodology` block are optional and have NO\n *    defaults. A run that did not grade itself renders no grade: a fabricated\n *    \"high confidence\" sits in exactly the same pixel as a measured one, and the\n *    reader has no way to tell them apart.\n */\n\n/** Confidence tiers, strongest first. */\nexport const researchConfidenceSchema = z.enum([\"high\", \"medium\", \"low\"])\n\n/** What kind of thing was read. Picks the chip glyph, and the wording when there is no link. */\nexport const researchSourceKindSchema = z.enum([\"web\", \"paper\", \"file\", \"internal\"])\n\n/**\n * One source in the pool the report may cite. `id` is the join key: it is what\n * `[^id]` markers inside a paragraph and `section.citations` both refer to.\n *\n * `url` is optional on purpose — an internal wiki page or an uploaded PDF is a\n * legitimate source with no address the reader can open, and rendering it as a\n * dead anchor would be worse than rendering it as text.\n */\nexport const researchSourceSchema = z.object({\n  id: z.string(),\n  title: z.string(),\n  /**\n   * Absolute `http(s)`/`mailto`, or a root-relative path served by the host.\n   * Everything else — `\"\"`, `\"#\"`, `javascript:` — is treated as \"no link\".\n   * Crawler output is untrusted input, so this is validated again at render time.\n   */\n  url: z.string().optional(),\n  /** Shown after the title on the chip; derived from `url`'s host when omitted. */\n  domain: z.string().optional(),\n  kind: researchSourceKindSchema.optional(),\n})\n\n/**\n * One section of the report body.\n *\n * `paragraphs` is PLAIN TEXT, never HTML: the only markup understood is the\n * inline citation marker `[^sourceId]`, which is resolved structurally (split\n * into text nodes plus elements) so a model that echoed a `<script>` tag out of\n * a crawled page prints those characters instead of creating an element.\n *\n * `citations` carries the sources a section leans on WITHOUT an inline marker —\n * the \"this whole section is built on these two datasets\" case. They are\n * appended after the inline ones when the section's chips are built, so the\n * numbering still follows reading order.\n */\nexport const researchSectionSchema = z.object({\n  /** Stable across re-runs; handed to `onNavigate` so the host can own the URL hash. */\n  id: z.string(),\n  heading: z.string(),\n  paragraphs: z.array(z.string()),\n  citations: z.array(z.string()),\n})\n\n/** One search the run actually issued — the audit trail for \"did it look in the right place?\". */\nexport const researchQuerySchema = z.object({\n  text: z.string(),\n  /** Which tool ran it: \"web\", \"arxiv\", \"internal wiki\"… */\n  engine: z.string().optional(),\n  /** Hits the query returned BEFORE filtering, so the funnel reads honestly. */\n  results: z.number().int().nonnegative().optional(),\n})\n\n/**\n * A source the run opened and refused to cite. `reason` is REQUIRED: a rejection\n * with no reason is not an audit trail, it is a number.\n */\nexport const researchRejectionSchema = z.object({\n  id: z.string(),\n  title: z.string(),\n  reason: z.string(),\n  url: z.string().optional(),\n  domain: z.string().optional(),\n})\n\n/**\n * How the answer was reached. Optional as a whole — plenty of pipelines cannot\n * report it — but when present it is what turns the report from an assertion\n * into something a reader can check.\n */\nexport const researchMethodologySchema = z.object({\n  queries: z.array(researchQuerySchema),\n  rejected: z.array(researchRejectionSchema),\n  /** Free-form limits and caveats: language coverage, date cut-offs, paywalls hit. */\n  notes: z.array(z.string()).optional(),\n})\n\nexport const researchMetaSchema = z.object({\n  /**\n   * Pages the run OPENED, which is always ≥ the number it ended up citing. The\n   * gap between the two is the interesting number, and the methodology block is\n   * where it gets explained. A value below the cited count means the payload\n   * disagrees with itself, and the component then prints only what it can prove.\n   */\n  sourcesConsulted: z.number().int().nonnegative().optional(),\n  /** Wall-clock length of the run in milliseconds. */\n  durationMs: z.number().nonnegative().optional(),\n  /** No default. A run that did not grade itself must render no grade at all. */\n  confidence: researchConfidenceSchema.optional(),\n  /** One line saying WHY the grade is what it is — never invented by the component. */\n  confidenceNote: z.string().optional(),\n  /** Which model/agent wrote it, e.g. \"research-agent · deep\". */\n  model: z.string().optional(),\n  /** ISO 8601 instant the report was produced; printed in an explicit time zone. */\n  generatedAt: z.string().optional(),\n})\n\nexport const researchReportSchema = z.object({\n  status: z.enum([\"loading\", \"empty\", \"error\", \"ready\"]),\n  /**\n   * The question, present in EVERY state. It is known before the run starts, so\n   * the loading card can already show what is being researched and the error\n   * card can say which report failed.\n   */\n  title: z.string(),\n  meta: researchMetaSchema,\n  sections: z.array(researchSectionSchema),\n  /**\n   * The citable pool, in no particular order — reading order decides the\n   * numbering. A source no section references is dropped from the body and\n   * counted in the methodology footer instead of being silently ignored.\n   */\n  sources: z.array(researchSourceSchema),\n  methodology: researchMethodologySchema.optional(),\n})\n\nexport type ResearchConfidence = z.infer<typeof researchConfidenceSchema>\nexport type ResearchSourceKind = z.infer<typeof researchSourceKindSchema>\nexport type ResearchSource = z.infer<typeof researchSourceSchema>\nexport type ResearchSection = z.infer<typeof researchSectionSchema>\nexport type ResearchQuery = z.infer<typeof researchQuerySchema>\nexport type ResearchRejection = z.infer<typeof researchRejectionSchema>\nexport type ResearchMethodology = z.infer<typeof researchMethodologySchema>\nexport type ResearchMeta = z.infer<typeof researchMetaSchema>\nexport type ResearchReportData = z.infer<typeof researchReportSchema>\nexport type ResearchReportStatus = ResearchReportData[\"status\"]\n",
      "type": "registry:block"
    }
  ],
  "type": "registry:block"
}