AI

Research Report

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Ban,
  BookMarked,
  ChevronDown,
  Clock,
  FileSearch,
  FileText,
  FlaskConical,
  Globe,
  ListTree,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/research-report.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ResearchReport" block (zod for the
contract, lucide-react for icons; no other dependencies) — the finished output
of a deep-research run: a title, a meta bar, an outline that follows the
reader, sections whose prose carries citation markers, per-section source
chips, and an expandable methodology footer.

Contract
- research-report.contract.ts exports zod schemas + inferred types.
  ResearchSource   = { id; title; url?; domain?;
                       kind?: "web" | "paper" | "file" | "internal" }
  ResearchSection  = { id; heading; paragraphs: string[]; citations: string[] }
  ResearchQuery    = { text; engine?; results? }
  ResearchRejection= { id; title; reason (REQUIRED); url?; domain? }
  ResearchMeta     = { sourcesConsulted?; durationMs?;
                       confidence?: "high" | "medium" | "low";
                       confidenceNote?; model?; generatedAt? (ISO 8601) }
  Envelope         = { status: "loading" | "empty" | "error" | "ready";
                       title; meta; sections; sources;
                       methodology?: { queries; rejected; notes? } }
- Component props = z.infer of the envelope, plus locale = "en-US",
  timeZone = "UTC", offset = 96, actions?: ReactNode,
  onNavigate?: (sectionId) => void, onRetry?, defaultMethodologyOpen = false,
  skeletonSections = 3, unresolvedCitations: "mark" | "hide" = "mark", and the
  rest of React.HTMLAttributes<HTMLElement> minus children/title.
  forwardRef<HTMLElement> onto the root <article>.
- NO FIELD CARRIES A CITATION NUMBER, and none may be added. `title` is present
  in every state — the question is known before the run starts.

Behavior — numbering is derived, never authored
- Walk `sections` in reading order. Within a section, collect the ids of the
  inline `[^id]` markers in paragraph order, then append that section's
  marker-less `citations`. Skip ids already seen in the section, skip ids that
  are not in `sources`, and hand out 1, 2, 3… to each id the first time the
  whole report references it.
- ONE walk produces both the inline `[n]` in the prose and the `[n]` on the
  chips under the section, which is the only reason the two can never
  disagree — not because a caller kept two numberings in sync. A model that
  rewrites a paragraph on a retry therefore cannot renumber anything.
- Duplicate ids in `sources`: the first record wins. Sources the report never
  references get no number and no chip; count them and say so in the
  methodology footer instead of dropping them silently.

Behavior — the prose is untrusted text
- Paragraphs are PLAIN TEXT. The only markup understood is `[^source-id]`.
  Render it structurally: split the paragraph with one capturing group
  (`/(\[\^[^\]\s]+\])/`) and turn the odd indices into elements. No
  dangerouslySetInnerHTML anywhere, so a `<script>` string a crawler swallowed
  prints as those characters.
- The id pattern forbids whitespace and `]`, so an ordinary bracket in prose
  ("see table [2] below") is never mistaken for a marker.
- A marker whose id is missing from `sources` does NOT become a dead link: with
  unresolvedCitations="mark" it renders a muted, inert `[?]` plus an sr-only
  "citation X is missing from this report's sources"; with "hide" it is dropped.

Behavior — marker to chip
- A marker is an <a href="#chipId"> whose target really exists (DOM ids are
  built from indices + the derived number, never from payload ids, which may
  contain anything). Its click calls preventDefault: the component NEVER writes
  to location — the host owns history.
- The click highlights the matching chip (ring), focuses it (the chip is
  tabIndex={-1}) and scrolls ONLY IF the chip is off screen — a jump that
  changes nothing is disorienting. The highlight clears on blur, so there is no
  timer to leak.
- Chips carry data-source-id, so a host can find one from the outside.

Behavior — the outline
- Derived from `sections`, sticky at `offset`, and rendered only when there is
  more than one section: a rail that always points at where you already are is
  a dead control.
- Active section: a rAF-throttled scroll+resize listener scans the section
  rects and takes the LAST heading that has passed the offset line. Rect
  scanning, not an IntersectionObserver band, because the two hard cases cross
  no boundary at all — a section taller than the viewport, and the last section
  at the bottom of the page. Add the end-of-document rule explicitly: when the
  page is scrolled to the bottom, the last section wins, otherwise it could
  never light up.
- Click: set the active entry immediately, smooth-scroll to
  rect.top + scrollY − offset, focus the section with { preventScroll: true }
  (a plain focus() jumps instantly and cancels the smooth scroll), and fire
  onNavigate(section.id).
- ONE-SHOT LOCK: a click owns the highlight until the scroll it started lands
  on that section, or 1200ms passes — otherwise every section the page flies
  past would flash active on the way. Release on arrival OR by the timer;
  clear the timer on unmount. Same for the rAF handle and both listeners.

Behavior — the meta bar tells the truth
- Print the gap: "6 cited of 118 sources consulted". If the payload claims
  fewer consulted than cited it disagrees with itself — print only the half
  provable from the sections. A run that cited nothing still prints
  "47 consulted · none cited", which is the difference between "found nothing"
  and "never looked".
- confidence has NO DEFAULT and is told apart by ICON + WORD before colour
  (ShieldCheck / ShieldAlert / ShieldQuestion, solid tint / neutral fill /
  dashed outline). A run that did not grade itself renders no grade: an
  invented "high" sits in the same pixel as a measured one. confidenceNote is
  printed from the payload and never generated.
- durationMs formats compactly (58s, 4m 24s, 1h 12m); negative or non-finite
  renders nothing. generatedAt is formatted with Intl in an explicit timeZone
  (default UTC) so SSR and CSR cannot disagree; unparseable instants render
  nothing rather than "Invalid Date".

Behavior — the four states
- loading: the title stays (it is the question), a "Researching…" status line
  with a pulsing dot that motion-reduce switches off, and an aria-hidden
  skeleton whose silhouette matches a real section (heading, three lines, two
  chips) repeated skeletonSections times, clamped 1–6.
- empty: the run finished and cited nothing. It keeps the meta bar AND the
  methodology footer — that reader most needs to see what was tried. Its copy
  changes depending on whether a methodology block exists, so it never points
  at something that is not there.
- error: the service failed; retry renders only when onRetry is passed.
- ready: outline + sections + chips + footer. `status: "ready"` with zero
  sections degrades to the empty branch instead of rendering an outline over
  nothing.

Behavior — methodology footer
- A disclosure button (aria-expanded + aria-controls) over a panel that stays
  MOUNTED and uses the `hidden` attribute, so aria-controls always resolves.
  Chevron rotates; motion-reduce switches the transition off.
- Lists the queries (text, engine, hits before filtering), the rejected sources
  each with its REQUIRED reason — "4 rejected" is a number, "rejected because
  it re-quotes the vendor" is evidence — free-form limit notes, and the count of
  pool sources no section referenced.

Rendering & styling
- Semantic tokens only: bg-card, bg-muted, bg-muted/40, bg-primary,
  bg-primary/10, border, border-dashed, border-primary, text-foreground,
  text-muted-foreground, text-primary, text-destructive, ring-ring. No
  hex/rgb/oklch anywhere.
- Layout: rounded-xl card, header / body / footer separated by borders; on lg
  the body is grid-cols-[13rem_minmax(0,1fr)] with the rail sticky at `offset`
  and the prose column min-w-0. Below lg the outline stacks above the prose.
- wrap-anywhere on every text box (headings, prose, chips, queries): it drops
  each box's min-content width to one character, so a long unbreakable URL or
  query string cannot widen the card. Nothing is line-clamped — a truncated
  citation is a citation you cannot check.
- Accessibility: root <article> labelled by the title (or aria-label when the
  title is empty) and aria-busy while loading; sections are <section> elements
  labelled by their heading with tabIndex={-1} (scroll destination, not a tab
  stop); the outline is a <nav> with aria-current="location"; the chip list has
  an aria-label naming its section; every icon is aria-hidden; the number badge
  carries an sr-only "Source " prefix; focus-visible rings everywhere; cn()
  merges className. Tailwind's preflight strips list markers and the list
  semantics some screen readers infer with them, so role="list" is restored on
  the outline, the chip rows and the methodology lists.

Customization levers
- Marker syntax: `[^id]` is two regexes (a capturing split and a global scan) —
  swap them for `[[id]]` or `【id】` and nothing else changes.
- Outline side/behaviour: move the rail to column 2 for a right-hand TOC, drop
  the `showOutline` guard's threshold, or delete the nav entirely for a plain
  report; the numbering does not depend on it.
- Scroll root: the rect scan reads window/documentElement. To live inside a
  scrolling panel, swap those for a containerRef's scrollTop/clientHeight — the
  offset prop keeps the same meaning.
- Section anatomy: the chip row, the meta bar items and the methodology footer
  are independently droppable; removing any one is a local edit that cannot
  affect numbering.
- Density: px-5 py-6, gap-8 between sections and gap-1.5 between chips are the
  knobs; use text-sm/text-base on the prose column for a compact or a reading
  variant.
- Chip content: currently icon + [n] + title + domain. Swap the title for the
  domain for a denser row, but keep the visible text inside the accessible
  name.
- Confidence vocabulary: CONFIDENCE_META is a plain record — rename the tiers,
  swap the glyphs, add a fourth. Keep icon + word as the primary channel and
  keep "no value" meaning "no badge".
- Export: `actions` is a slot rendered only on ready. Print/PDF/share buttons
  go there with real handlers; the block deliberately implements none of them.

Concepts

  • Derived numbering, not authored numbering — no field in the contract carries a citation number. One walk of sections hands out 1, 2, 3… on first appearance, and both the inline marker and the chip under that section read from it. Two numbers produced by one walk cannot drift; two numbers a model wrote into a payload can.
  • The marker resolves locally — clicking [3] does not send the reader to a bibliography at the bottom of the page. It highlights and focuses the chip under the section being read, and only scrolls if that chip is off screen, so the answer to "which source is this?" arrives without losing the reader's place.
  • One-shot lock — a click on the outline owns the highlight until the smooth scroll it started actually lands, or a timeout gives up. Without it, the sections the page flies past would each flash active on the way, and the rail would appear to fight the click.
  • Consulted minus cited is the interesting number — the meta bar prints both, and the methodology footer explains the gap with the queries that were run and the sources that were rejected, each with a required reason. A rejection count is a number; a rejection reason is an audit trail.
  • Absent means absent — confidence, duration, consulted count and the whole methodology block are optional with no defaults, because the writer is a model. A fabricated "high confidence" occupies the same pixel as a measured one, and the reader has no way to tell them apart.
  • Honest absence over dead ends — an internal doc or an uploaded file has no URL, so its chip is text rather than an anchor to nowhere; a marker pointing at a source that is not in the pool degrades to an inert [?] with an sr-only explanation instead of a link that resolves to nothing.

On This Page