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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/research-report.jsonPrompt
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
sectionshands out1, 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.
AI Answer
A search-answer section — the question, the sources it stands on, prose whose every sentence carries a numbered chip back to a source, follow-ups, and a streaming pass where sources land before the text.
Agent Dashboard
An agent-ops overview — KPI tiles whose trends are derived, a runs-over-time bar chart, a recent-runs table whose failing rows expand to the error line, and a live-agent rail that filters the table without moving the window figures.