commit 3b89d6b8919da1921df3864ab7486b5d9dfa4ce8
parent b2d1ae21259ae52ebe3eb532708881175b936c47
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 03:01:06 -0400
common: shared citation components — InlineCite with preview, CitationCard, ReferenceList, VerificationBadge, CitedMarkdown; VerdictChip and VerdictTally
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 881 insertions(+), 4 deletions(-)
diff --git a/common/components/Markdown.tsx b/common/components/Markdown.tsx
@@ -73,18 +73,22 @@ const OVERRIDES = {
// spacing is consistent regardless of content shape. `linkComponent` optionally
// overrides how `<a>` renders (e.g. to make in-page citation anchors scroll
// instead of opening a new tab); when omitted, links use the default external
-// styling above.
-export function Markdown({
+// styling above. `linkProps` are extra props every `<a>` override receives
+// beside the link's own (e.g. a citation map), so the link component can stay
+// one stable component instead of a closure made per render.
+export function Markdown<P extends object = object>({
children,
className,
linkComponent,
+ linkProps,
}: {
children: string;
className?: string;
- linkComponent?: ComponentType<AnchorHTMLAttributes<HTMLAnchorElement>>;
+ linkComponent?: ComponentType<AnchorHTMLAttributes<HTMLAnchorElement> & P>;
+ linkProps?: P;
}) {
const overrides = linkComponent
- ? { ...OVERRIDES, a: { component: linkComponent } }
+ ? { ...OVERRIDES, a: { component: linkComponent, ...(linkProps ? { props: linkProps } : {}) } }
: OVERRIDES;
return (
<div className={className}>
diff --git a/common/components/citations/CitationCard.tsx b/common/components/citations/CitationCard.tsx
@@ -0,0 +1,250 @@
+import { Archive, ExternalLink, FileText, Globe, MessageSquareQuote, Mic, Play, Video } from "lucide-react";
+import type { CitationKind } from "../../lib/citations/schema";
+import {
+ CITATION_KIND_LABELS,
+ spanLabel,
+ type CitationView,
+ type PageCitationView,
+ type PostCitationView,
+ type SourceCitationView,
+ type SpanCitationView,
+} from "../../lib/report/views";
+import { cn } from "../../lib/utils";
+import { VerificationBadge } from "./VerificationBadge";
+
+// ONE CITATION, as a reader sees it — kind-aware, from a resolved view
+// (lib/report/views.ts), fetching nothing:
+//
+// video / audio the record's title, channel, date, the span ("52:06–52:31"),
+// the speaker and quote, a poster when the view has one, and
+// a play affordance that opens the moment page (its clip)
+// post the author, date, quote (and the post's text when it says
+// more), the captured shot when the view has one
+// source the document's sentence: its still, the quote, and the
+// document's archive links in context
+// page the page's title and URL, its archived copy
+//
+// Three densities: `card` (a claim's evidence list), `preview` (InlineCite's
+// hover/tap card: the same facts, a smaller picture, the quote clamped) and
+// `reference` (a ReferenceList entry: no picture, the number is the list's).
+//
+// Pure/presentational: server-rendered or in a client tree alike.
+
+export type CitationCardVariant = "card" | "preview" | "reference";
+
+const KIND_ICON: Record<CitationKind, typeof Video> = {
+ video: Video,
+ audio: Mic,
+ post: MessageSquareQuote,
+ source: FileText,
+ page: Globe,
+};
+
+const linkClass =
+ "inline-flex items-center gap-1 text-brand underline decoration-brand/40 underline-offset-2 hover:decoration-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm";
+
+function hostOf(url: string): string {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return url;
+ }
+}
+
+function ExternalA({ href, children, title }: { href: string; children: React.ReactNode; title?: string }) {
+ return (
+ <a href={href} target="_blank" rel="noopener noreferrer" title={title} className={linkClass}>
+ {children}
+ <ExternalLink className="size-3 shrink-0" aria-hidden />
+ </a>
+ );
+}
+
+// The title line and its meta, per kind.
+function heading(c: CitationView): { title: string; meta: string[] } {
+ switch (c.kind) {
+ case "video":
+ case "audio":
+ return {
+ title: c.record.title ?? `${c.record.channel}/${c.record.id}`,
+ meta: [c.record.channelTitle ?? c.record.channel, c.date ?? c.record.date, spanLabel(c.start, c.end)].filter(
+ (x): x is string => !!x,
+ ),
+ };
+ case "post":
+ return {
+ title: c.author ?? c.record.channelTitle ?? c.record.channel,
+ meta: [c.author ? (c.record.channelTitle ?? c.record.channel) : undefined, c.date ?? c.record.date].filter(
+ (x): x is string => !!x,
+ ),
+ };
+ case "source":
+ return {
+ title: c.source.title,
+ meta: [c.source.publisher, c.source.author, c.date ?? c.source.date].filter((x): x is string => !!x),
+ };
+ case "page":
+ return { title: c.title ?? hostOf(c.href), meta: [hostOf(c.href), c.date].filter((x): x is string => !!x) };
+ }
+}
+
+function Picture({ c, variant }: { c: CitationView; variant: CitationCardVariant }) {
+ if (variant === "reference") return null;
+ const size = variant === "preview" ? "max-h-28" : "max-h-48";
+ if (c.kind === "video" || c.kind === "audio") {
+ if (!c.poster) return null;
+ return (
+ <a href={c.href} className="group relative block overflow-hidden rounded-md border border-border bg-muted">
+ <img src={c.poster} alt="" loading="lazy" className={cn("w-full object-cover", size)} />
+ <span className="absolute inset-0 flex items-center justify-center bg-black/10 transition-colors group-hover:bg-black/25">
+ <span className="flex size-10 items-center justify-center rounded-full bg-background/90 text-foreground shadow">
+ <Play className="size-4 translate-x-px" aria-hidden />
+ </span>
+ </span>
+ <span className="sr-only">Play {spanLabel(c.start, c.end)}</span>
+ </a>
+ );
+ }
+ if (c.kind === "post" && c.shot) {
+ return (
+ <img
+ src={c.shot}
+ alt={`Screenshot of the post${c.author ? ` by ${c.author}` : ""}`}
+ loading="lazy"
+ className={cn("w-full rounded-md border border-border object-contain object-left-top", size)}
+ />
+ );
+ }
+ if (c.kind === "source" && c.image) {
+ return (
+ <img
+ src={c.image}
+ alt={`The sentence as ${c.source.title} shows it: ${c.quote}`}
+ loading="lazy"
+ className={cn("w-full rounded-md border border-border bg-white object-contain object-left-top", size)}
+ />
+ );
+ }
+ return null;
+}
+
+function SpanLinks({ c }: { c: SpanCitationView }) {
+ return (
+ <>
+ <a href={c.href} className={linkClass}>
+ <Play className="size-3 shrink-0" aria-hidden />
+ Play {spanLabel(c.start, c.end)}
+ </a>
+ {c.record.originalUrl && <ExternalA href={c.record.originalUrl}>Original</ExternalA>}
+ {c.record.corpusUrl && (
+ <a href={c.record.corpusUrl} className={linkClass}>
+ Transcript
+ </a>
+ )}
+ </>
+ );
+}
+
+function PostLinks({ c }: { c: PostCitationView }) {
+ return (
+ <>
+ <a href={c.href} className={linkClass}>
+ Open post
+ </a>
+ {c.record.originalUrl && <ExternalA href={c.record.originalUrl}>Original</ExternalA>}
+ </>
+ );
+}
+
+function SourceLinks({ c }: { c: SourceCitationView }) {
+ return (
+ <>
+ {c.href && <ExternalA href={c.href}>{c.source.publisher ?? hostOf(c.href)}</ExternalA>}
+ {c.source.archives.map((a) => (
+ <ExternalA key={a.url} href={a.url} title={a.context}>
+ <Archive className="size-3 shrink-0" aria-hidden />
+ {a.label}
+ </ExternalA>
+ ))}
+ </>
+ );
+}
+
+function PageLinks({ c }: { c: PageCitationView }) {
+ return (
+ <>
+ <ExternalA href={c.href}>{hostOf(c.href)}</ExternalA>
+ {c.archiveUrl && (
+ <ExternalA href={c.archiveUrl}>
+ <Archive className="size-3 shrink-0" aria-hidden />
+ Archived copy
+ </ExternalA>
+ )}
+ </>
+ );
+}
+
+export function CitationCard({
+ citation: c,
+ variant = "card",
+ className,
+}: {
+ citation: CitationView;
+ variant?: CitationCardVariant;
+ className?: string;
+}) {
+ const Icon = KIND_ICON[c.kind];
+ const { title, meta } = heading(c);
+ const showNumber = variant !== "reference" && c.number !== undefined;
+ const postText = c.kind === "post" && c.text && c.text.trim() !== c.quote.trim() ? c.text : null;
+ return (
+ <div
+ data-citation={c.id}
+ data-citation-kind={c.kind}
+ className={cn(
+ "flex min-w-0 flex-col gap-2 text-sm",
+ variant === "card" && "rounded-lg border border-border bg-card p-3 sm:p-4",
+ variant === "preview" && "gap-1.5",
+ className,
+ )}
+ >
+ <div className="flex min-w-0 items-start gap-2">
+ {showNumber && (
+ <span className="mt-px shrink-0 rounded bg-brand-soft px-1.5 font-mono text-xs font-semibold leading-5 text-brand-strong">
+ {c.number}
+ </span>
+ )}
+ <div className="min-w-0 flex-1">
+ <div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
+ <Icon className="size-3 shrink-0" aria-hidden />
+ {CITATION_KIND_LABELS[c.kind]}
+ {c.label && <span className="truncate normal-case tracking-normal">· {c.label}</span>}
+ </div>
+ <div className="font-medium leading-snug text-foreground break-words">{title}</div>
+ {meta.length > 0 && <div className="font-mono text-xs text-muted-foreground">{meta.join(" · ")}</div>}
+ </div>
+ </div>
+ <Picture c={c} variant={variant} />
+ <blockquote
+ className={cn(
+ "border-l-2 border-brand/50 pl-3 leading-relaxed text-foreground",
+ variant === "preview" && "line-clamp-4",
+ )}
+ >
+ {c.speaker && <span className="font-medium text-muted-foreground">{c.speaker}: </span>}
+ <q className="italic">{c.quote}</q>
+ </blockquote>
+ {postText && variant !== "preview" && (
+ <p className="line-clamp-4 whitespace-pre-line text-muted-foreground">{postText}</p>
+ )}
+ {c.note && variant !== "preview" && <p className="text-xs text-muted-foreground">{c.note}</p>}
+ <VerificationBadge verification={c.verification} />
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
+ {(c.kind === "video" || c.kind === "audio") && <SpanLinks c={c} />}
+ {c.kind === "post" && <PostLinks c={c} />}
+ {c.kind === "source" && <SourceLinks c={c} />}
+ {c.kind === "page" && <PageLinks c={c} />}
+ </div>
+ </div>
+ );
+}
diff --git a/common/components/citations/CitedMarkdown.tsx b/common/components/citations/CitedMarkdown.tsx
@@ -0,0 +1,57 @@
+import type { AnchorHTMLAttributes } from "react";
+import { CITE_SCHEME } from "../../lib/citations/inline";
+import type { CitationView } from "../../lib/report/views";
+import { Markdown } from "../Markdown";
+import { InlineCite } from "./InlineCite";
+
+// Report markdown with its citations live: every `[label](cite:<id>)` link
+// (lib/citations/inline.ts) becomes an InlineCite for the citation's view;
+// every other link renders as Markdown's own external link. The house markdown
+// setup (Markdown.tsx — markdown-to-jsx, raw HTML escaped, token-styled) is
+// unchanged; this only swaps its link component. A `cite:` link inside code is
+// code, never a citation: markdown-to-jsx makes no link there.
+//
+// Pure: the citations come in as props, keyed by id.
+
+const EXTERNAL_LINK_CLASS = "text-brand underline decoration-brand/40 hover:decoration-brand";
+
+export function citeIdOf(href: string | undefined): string | null {
+ return href?.startsWith(CITE_SCHEME) ? href.slice(CITE_SCHEME.length).trim() : null;
+}
+
+type Citations = Readonly<Record<string, CitationView>>;
+
+// The `<a>` of cited markdown: one stable component, the citations passed in
+// as a prop (Markdown `linkProps`).
+function CiteLink({
+ href,
+ children: label,
+ citations,
+ ...rest
+}: AnchorHTMLAttributes<HTMLAnchorElement> & { citations: Citations }) {
+ const id = citeIdOf(href);
+ if (id !== null) {
+ return <InlineCite citation={Object.hasOwn(citations, id) ? citations[id] : undefined}>{label}</InlineCite>;
+ }
+ return (
+ <a {...rest} href={href} target="_blank" rel="noopener noreferrer" className={EXTERNAL_LINK_CLASS}>
+ {label}
+ </a>
+ );
+}
+
+export function CitedMarkdown({
+ children,
+ citations,
+ className,
+}: {
+ children: string;
+ citations: Citations;
+ className?: string;
+}) {
+ return (
+ <Markdown className={className} linkComponent={CiteLink} linkProps={{ citations }}>
+ {children}
+ </Markdown>
+ );
+}
diff --git a/common/components/citations/InlineCite.tsx b/common/components/citations/InlineCite.tsx
@@ -0,0 +1,192 @@
+"use client";
+
+import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
+import { createPortal } from "react-dom";
+import { citationAnchor } from "../../lib/citations/inline";
+import { CITATION_KIND_LABELS, type CitationView } from "../../lib/report/views";
+import { CitationCard } from "./CitationCard";
+
+// AN INLINE CITATION — what a `[label](cite:<id>)` link in report markdown
+// renders as (CitedMarkdown): the label, linked to what it cites (the moment
+// page of a span or a post, the source's or the page's URL), then the
+// citation's number in its report as a superscript, linked to its reference
+// entry (`#c-<id>`).
+//
+// THE PREVIEW: hovering the marker, or focusing the number, opens the
+// citation's card (CitationCard `preview`) beside it without leaving the text.
+// On touch the first tap on the number opens the preview and the second
+// follows the link, so a phone reader can peek before jumping. Escape, a tap
+// outside, or focus leaving the marker closes it.
+//
+// The card is PORTALLED to <body>, never rendered inside the marker: the
+// marker sits inside a markdown <p>, and a card's blocks (blockquote, div)
+// inside a <p> are invalid HTML the parser would re-nest — a hydration error.
+// It is positioned against the document (so it scrolls with the text), under
+// the number, or above it when there is no room below, and kept inside the
+// viewport's width. The number is the keyboard's way in; its reference entry
+// carries the same card with every link.
+//
+// A ref the page could not resolve (no view for the id — validation keeps a
+// published report from having one) renders its label alone.
+
+const CLOSE_DELAY_MS = 150;
+const EDGE_PX = 8;
+const GAP_PX = 6;
+
+function isExternal(c: CitationView): boolean {
+ return c.kind === "source" || c.kind === "page";
+}
+
+export function InlineCite({ citation, children }: { citation: CitationView | undefined; children: ReactNode }) {
+ const previewId = useId();
+ const [open, setOpen] = useState(false);
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
+ const wrapRef = useRef<HTMLSpanElement>(null);
+ const numberRef = useRef<HTMLAnchorElement>(null);
+ const cardRef = useRef<HTMLDivElement>(null);
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
+ const lastPointer = useRef<string>("mouse");
+
+ const cancelClose = useCallback(() => {
+ if (closeTimer.current) clearTimeout(closeTimer.current);
+ closeTimer.current = null;
+ }, []);
+ const show = useCallback(() => {
+ cancelClose();
+ setOpen(true);
+ }, [cancelClose]);
+ const hide = useCallback(() => {
+ cancelClose();
+ setOpen(false);
+ setPos(null);
+ }, [cancelClose]);
+ const hideSoon = useCallback(() => {
+ cancelClose();
+ closeTimer.current = setTimeout(hide, CLOSE_DELAY_MS);
+ }, [cancelClose, hide]);
+
+ // Place the card once it has a size: under the number, or above it when the
+ // viewport has no room below; clamped to the viewport's width.
+ useLayoutEffect(() => {
+ if (!open || !cardRef.current) return;
+ const anchor = (numberRef.current ?? wrapRef.current)?.getBoundingClientRect();
+ if (!anchor) return;
+ const card = cardRef.current.getBoundingClientRect();
+ const below = anchor.bottom + GAP_PX;
+ const top =
+ below + card.height > window.innerHeight - EDGE_PX && anchor.top - GAP_PX - card.height > EDGE_PX
+ ? anchor.top - GAP_PX - card.height
+ : below;
+ const maxLeft = Math.max(EDGE_PX, window.innerWidth - card.width - EDGE_PX);
+ const left = Math.min(Math.max(EDGE_PX, anchor.left - 12), maxLeft);
+ setPos({ top: top + window.scrollY, left: left + window.scrollX });
+ }, [open]);
+
+ // A tap or click outside the marker and the card closes it; so does a resize.
+ useEffect(() => {
+ if (!open) return;
+ const onDown = (e: PointerEvent) => {
+ const t = e.target as Node;
+ if (wrapRef.current?.contains(t) || cardRef.current?.contains(t)) return;
+ hide();
+ };
+ document.addEventListener("pointerdown", onDown);
+ window.addEventListener("resize", hide);
+ return () => {
+ document.removeEventListener("pointerdown", onDown);
+ window.removeEventListener("resize", hide);
+ };
+ }, [open, hide]);
+
+ useEffect(() => cancelClose, [cancelClose]);
+
+ if (!citation) return <>{children}</>;
+ const c = citation;
+ const external = isExternal(c);
+ const n = c.number;
+
+ return (
+ <span
+ ref={wrapRef}
+ data-inline-cite={c.id}
+ onMouseEnter={() => {
+ if (lastPointer.current !== "touch") show();
+ }}
+ onMouseLeave={() => {
+ if (lastPointer.current !== "touch") hideSoon();
+ }}
+ onFocus={show}
+ onBlur={(e) => {
+ const next = e.relatedTarget as Node | null;
+ if (!wrapRef.current?.contains(next) && !cardRef.current?.contains(next)) hideSoon();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Escape" && open) {
+ e.stopPropagation();
+ hide();
+ }
+ }}
+ onPointerDown={(e) => {
+ lastPointer.current = e.pointerType;
+ }}
+ >
+ {c.href ? (
+ <a
+ href={c.href}
+ {...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
+ className="text-brand underline decoration-brand/40 underline-offset-2 hover:decoration-brand"
+ >
+ {children}
+ </a>
+ ) : (
+ children
+ )}
+ {n !== undefined && (
+ <sup className="ml-0.5 font-mono text-[0.72em] font-semibold">
+ <a
+ ref={numberRef}
+ href={`#${citationAnchor(c.id)}`}
+ data-cite-number={n}
+ aria-label={`Citation ${n}, ${CITATION_KIND_LABELS[c.kind].toLowerCase()}`}
+ aria-expanded={open}
+ aria-controls={open ? previewId : undefined}
+ onClick={(e) => {
+ // First tap on touch peeks; the second follows the link.
+ if (lastPointer.current === "touch" && !open) {
+ e.preventDefault();
+ show();
+ }
+ }}
+ className="rounded-sm px-0.5 text-brand-strong no-underline hover:bg-brand-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ >
+ [{n}]
+ </a>
+ </sup>
+ )}
+ {open &&
+ createPortal(
+ <div
+ ref={cardRef}
+ id={previewId}
+ role="group"
+ aria-label={n !== undefined ? `Citation ${n}` : "Citation"}
+ data-cite-preview={c.id}
+ style={{
+ position: "absolute",
+ top: pos?.top ?? 0,
+ left: pos?.left ?? 0,
+ visibility: pos ? "visible" : "hidden",
+ }}
+ className="z-50 w-[min(22rem,calc(100vw-1rem))] rounded-lg border border-border bg-popover p-3 text-left text-popover-foreground shadow-lg"
+ onMouseEnter={cancelClose}
+ onMouseLeave={() => {
+ if (lastPointer.current !== "touch") hideSoon();
+ }}
+ >
+ <CitationCard citation={c} variant="preview" />
+ </div>,
+ document.body,
+ )}
+ </span>
+ );
+}
diff --git a/common/components/citations/ReferenceList.tsx b/common/components/citations/ReferenceList.tsx
@@ -0,0 +1,43 @@
+import { citationAnchor } from "../../lib/citations/inline";
+import type { CitationView } from "../../lib/report/views";
+import { cn } from "../../lib/utils";
+import { CitationCard } from "./CitationCard";
+
+// A NUMBERED REFERENCE LIST — a report's citations (or one section's), in
+// number order, each entry a CitationCard (`reference`) under the number the
+// inline markers show. The report-wide list carries the anchors `c-<id>` the
+// markers jump to; a per-section list passes `anchors={false}` so an id is on
+// the page once.
+//
+// Pure: the citations come in as props, already numbered (lib/report/views.ts
+// orderedCitations / sectionCitations).
+
+export function ReferenceList({
+ citations,
+ anchors = true,
+ className,
+}: {
+ citations: readonly CitationView[];
+ anchors?: boolean;
+ className?: string;
+}) {
+ if (citations.length === 0) return null;
+ return (
+ <ol data-reference-list="" className={cn("flex flex-col divide-y divide-border", className)}>
+ {citations.map((c) => (
+ <li
+ key={c.id}
+ id={anchors ? citationAnchor(c.id) : undefined}
+ value={c.number}
+ data-reference={c.id}
+ className="flex scroll-mt-20 gap-3 py-3 target:bg-brand-soft/60 target:rounded-md"
+ >
+ <span className="w-7 shrink-0 pt-0.5 text-right font-mono text-sm font-semibold text-brand-strong">
+ {c.number !== undefined ? `${c.number}.` : "·"}
+ </span>
+ <CitationCard citation={c} variant="reference" className="flex-1" />
+ </li>
+ ))}
+ </ol>
+ );
+}
diff --git a/common/components/citations/VerificationBadge.tsx b/common/components/citations/VerificationBadge.tsx
@@ -0,0 +1,71 @@
+import { BadgeCheck, Mic } from "lucide-react";
+import type { CitationVerification } from "../../lib/citations/schema";
+import { cn } from "../../lib/utils";
+
+// What checking a citation found (its COMPUTED `verification` block, written by
+// compose — lib/citations/schema.ts): the quote's match against the record's
+// transcript as a percentage, whether the voice was checked, and when. Renders
+// nothing when the citation was never checked, so an unchecked citation reads
+// as unchecked rather than as a zero.
+//
+// Pure/presentational: server-rendered or in a client tree alike.
+
+export const QUOTE_SCORE_TOOLTIP =
+ "How closely the quoted words match the record's transcript at the cited place: checked against the transcript, 100% is verbatim.";
+
+export function quoteScorePercent(score: number): number {
+ return Math.round(Math.min(1, Math.max(0, score)) * 100);
+}
+
+// `2026-10-04T12:00:00Z` → `2026-10-04`; anything else as given.
+function dateOf(v: string): string {
+ return /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : v;
+}
+
+export function VerificationBadge({
+ verification,
+ className,
+}: {
+ verification: CitationVerification | undefined;
+ className?: string;
+}) {
+ const v = verification;
+ if (!v) return null;
+ const hasScore = typeof v.quoteScore === "number" && Number.isFinite(v.quoteScore);
+ if (!hasScore && !v.voiceChecked) return null;
+ const checked = v.quoteCheckedAt ? dateOf(v.quoteCheckedAt) : null;
+ return (
+ <span
+ data-verification=""
+ className={cn(
+ "inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11px] text-muted-foreground",
+ className,
+ )}
+ >
+ {hasScore && (
+ <span
+ title={`${QUOTE_SCORE_TOOLTIP}${v.method ? ` Method: ${v.method}.` : ""}`}
+ data-quote-score={quoteScorePercent(v.quoteScore!)}
+ className="inline-flex items-center gap-1 rounded border border-success/40 bg-success-soft px-1.5 py-0.5 text-success"
+ >
+ <BadgeCheck className="size-3" aria-hidden />
+ <span>
+ Quote {quoteScorePercent(v.quoteScore!)}%
+ <span className="sr-only"> match, checked against the transcript</span>
+ </span>
+ </span>
+ )}
+ {v.voiceChecked && (
+ <span
+ title="The speaker's voice in the cited span was checked."
+ data-voice-checked=""
+ className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5"
+ >
+ <Mic className="size-3" aria-hidden />
+ Voice checked
+ </span>
+ )}
+ {checked && <span>checked {checked}</span>}
+ </span>
+ );
+}
diff --git a/common/components/citations/citations.test.ts b/common/components/citations/citations.test.ts
@@ -0,0 +1,202 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import * as React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import type { CitationView, PageCitationView, PostCitationView, SourceCitationView, SpanCitationView } from "../../lib/report/views";
+import { CitationCard } from "./CitationCard";
+import { CitedMarkdown, citeIdOf } from "./CitedMarkdown";
+import { InlineCite } from "./InlineCite";
+import { ReferenceList } from "./ReferenceList";
+import { QUOTE_SCORE_TOOLTIP, VerificationBadge, quoteScorePercent } from "./VerificationBadge";
+
+// common's tsconfig has `jsx: "preserve"` (Next compiles it), so tsx falls
+// back to the classic transform — `React.createElement` on a free `React`.
+(globalThis as { React?: typeof React }).React = React;
+const h = React.createElement;
+const render = (el: Parameters<typeof renderToStaticMarkup>[0]) => renderToStaticMarkup(el);
+
+const video: SpanCitationView = {
+ kind: "video",
+ id: "c01",
+ number: 1,
+ quote: "The bridge opened in the spring.",
+ speaker: "The host",
+ moment: "demo-channel/abc123/3126.00-3151.00",
+ href: "/m/demo-channel/abc123/3126.00-3151.00/",
+ start: 3126,
+ end: 3151,
+ record: {
+ channel: "demo-channel",
+ channelTitle: "Demo Channel",
+ id: "abc123",
+ title: "Demo stream",
+ date: "2026-01-10",
+ originalUrl: "https://media.example.org/demo-channel/abc123?t=3126",
+ },
+ poster: "/poster.png",
+ verification: { quoteScore: 0.974, quoteCheckedAt: "2026-10-04T12:00:00Z", voiceChecked: true, method: "cue-window v1" },
+};
+
+const audio: SpanCitationView = {
+ ...video,
+ kind: "audio",
+ id: "au1",
+ number: 2,
+ poster: undefined,
+ verification: undefined,
+ speaker: undefined,
+ moment: "demo-podcast/ep-042/610.50-628.00",
+ href: "/m/demo-podcast/ep-042/610.50-628.00/",
+ start: 610.5,
+ end: 628,
+ record: { channel: "demo-podcast", id: "ep-042", title: "Episode 42" },
+};
+
+const post: PostCitationView = {
+ kind: "post",
+ id: "p01",
+ number: 3,
+ quote: "Never said I would move.",
+ moment: "demo-social/123",
+ href: "/m/demo-social/123/",
+ record: { channel: "demo-social", channelTitle: "Demo Social", id: "123", date: "2025-03-14" },
+ author: "@demo_account",
+ text: "Never said I would move.\n\nPosted to settle it.",
+ shot: "/media/posts/demo-social/123/shot.png",
+};
+
+const source: SourceCitationView = {
+ kind: "source",
+ id: "a01",
+ number: 4,
+ quote: "He opened the bridge himself.",
+ source: {
+ id: "s0",
+ kind: "article",
+ title: "An example article",
+ url: "https://example.org/a",
+ publisher: "Example Gazette",
+ archives: [{ label: "archive.org", url: "https://web.archive.org/x", context: "as published" }],
+ },
+ image: "/reports/demo/stills/a01.png",
+ href: "https://example.org/a",
+};
+
+const page: PageCitationView = {
+ kind: "page",
+ id: "w01",
+ number: 5,
+ quote: "Opened to traffic: April 2019.",
+ href: "https://example.org/city/history",
+ title: "City records",
+ archiveUrl: "https://web.archive.org/y",
+ note: "The city's own record.",
+};
+
+const all: CitationView[] = [video, audio, post, source, page];
+const byId = Object.fromEntries(all.map((c) => [c.id, c]));
+
+test("a video card: title, channel, date, span, speaker, quote, poster and play", () => {
+ const html = render(h(CitationCard, { citation: video }));
+ for (const s of ["Demo stream", "Demo Channel", "2026-01-10", "52:06–52:31", "The host", "The bridge opened in the spring."]) {
+ assert.ok(html.includes(s), s);
+ }
+ assert.match(html, /<img src="\/poster.png"/);
+ assert.match(html, /href="\/m\/demo-channel\/abc123\/3126.00-3151.00\/"[^>]*>.*Play 52:06–52:31/);
+ assert.match(html, /href="https:\/\/media.example.org\/demo-channel\/abc123\?t=3126" target="_blank"/);
+ assert.match(html, /data-citation-kind="video"/);
+});
+
+test("an audio card has no picture without a poster, and still plays its span", () => {
+ const html = render(h(CitationCard, { citation: audio }));
+ assert.ok(!html.includes("<img"));
+ assert.ok(html.includes("10:10–10:28"));
+ assert.ok(html.includes("Audio"));
+});
+
+test("a post card: author, date, quote, the post's fuller text, the shot", () => {
+ const html = render(h(CitationCard, { citation: post }));
+ for (const s of ["@demo_account", "Demo Social", "2025-03-14", "Never said I would move.", "Posted to settle it.", "Open post"]) {
+ assert.ok(html.includes(s), s);
+ }
+ assert.match(html, /<img src="\/media\/posts\/demo-social\/123\/shot.png"/);
+});
+
+test("a source card: the still, the quote, the document's archive links in context", () => {
+ const html = render(h(CitationCard, { citation: source }));
+ assert.match(html, /<img src="\/reports\/demo\/stills\/a01.png" alt="The sentence as An example article shows it: He opened the bridge himself."/);
+ assert.match(html, /href="https:\/\/web.archive.org\/x"[^>]*title="as published"/);
+ assert.ok(html.includes("Example Gazette"));
+});
+
+test("a page card: title, host, the archived copy, the note", () => {
+ const html = render(h(CitationCard, { citation: page }));
+ for (const s of ["City records", "example.org", "Archived copy", "The city's own record."]) assert.ok(html.includes(s), s);
+});
+
+test("the preview density keeps the facts, clamps the quote and drops the note and post text", () => {
+ const html = render(h(CitationCard, { citation: post, variant: "preview" }));
+ assert.ok(html.includes("line-clamp-4"));
+ assert.ok(!html.includes("Posted to settle it."));
+ assert.ok(!render(h(CitationCard, { citation: page, variant: "preview" })).includes("own record"));
+ // the number shows on a card and a preview, never on a reference entry (the list's)
+ assert.match(html, />3<\/span>/);
+ assert.ok(!render(h(CitationCard, { citation: video, variant: "reference" })).includes("<img"));
+});
+
+test("the verification badge: a rounded percentage, voice, date; nothing when unchecked", () => {
+ const html = render(h(VerificationBadge, { verification: video.verification }));
+ assert.match(html, /data-quote-score="97"/);
+ assert.ok(html.includes("Quote 97%"));
+ assert.ok(html.includes("Voice checked"));
+ assert.ok(html.includes("checked 2026-10-04"));
+ assert.ok(html.includes("checked against the transcript"));
+ assert.ok(html.includes("Method: cue-window v1."));
+ assert.ok(QUOTE_SCORE_TOOLTIP.includes("checked against the transcript"));
+ assert.equal(render(h(VerificationBadge, { verification: undefined })), "");
+ assert.equal(render(h(VerificationBadge, { verification: { method: "x" } })), "");
+ assert.equal(quoteScorePercent(1.2), 100);
+ assert.equal(quoteScorePercent(-1), 0);
+});
+
+test("an inline cite: the label links to what it cites, the number to its reference entry", () => {
+ const html = render(h(InlineCite, { citation: video }, "on stream"));
+ assert.match(html, /<a href="\/m\/demo-channel\/abc123\/3126.00-3151.00\/"[^>]*>on stream<\/a>/);
+ assert.match(html, /<sup[^>]*><a href="#c-c01" data-cite-number="1" aria-label="Citation 1, video" aria-expanded="false"/);
+ assert.ok(html.includes("[1]"));
+ // closed: no preview in the server markup (it is portalled when opened)
+ assert.ok(!html.includes("data-cite-preview"));
+ // a page or a source opens off-site
+ assert.match(render(h(InlineCite, { citation: page }, "records")), /href="https:\/\/example.org\/city\/history" target="_blank" rel="noopener noreferrer"/);
+ // an unresolved ref is its label alone
+ assert.equal(render(h(InlineCite, { citation: undefined }, "label")), "label");
+});
+
+test("cited markdown: cite links become inline cites, other links stay links, code stays code", () => {
+ const md = "He said so [on stream](cite:c01), the [city](cite:w01) agrees; see [elsewhere](https://example.org/z) and `[x](cite:c01)`.";
+ const html = render(h(CitedMarkdown, { citations: byId }, md));
+ assert.match(html, /data-inline-cite="c01"/);
+ assert.match(html, /data-inline-cite="w01"/);
+ assert.match(html, /\[1\]/);
+ assert.match(html, /\[5\]/);
+ assert.match(html, /<a href="https:\/\/example.org\/z" target="_blank" rel="noopener noreferrer"[^>]*>elsewhere<\/a>/);
+ assert.match(html, /<code[^>]*>\[x\]\(cite:c01\)<\/code>/);
+ // an id the map lacks renders as its label
+ assert.ok(render(h(CitedMarkdown, { citations: {} }, "[gone](cite:zz)")).includes("gone"));
+ assert.equal(citeIdOf("cite:c01"), "c01");
+ assert.equal(citeIdOf("https://x"), null);
+});
+
+test("the reference list: number order as given, anchors c-<id>, none when asked not to", () => {
+ const html = render(h(ReferenceList, { citations: all }));
+ const anchors = [...html.matchAll(/<li id="(c-[^"]+)" value="(\d+)"/g)].map((m) => [m[1], m[2]]);
+ assert.deepEqual(anchors, [
+ ["c-c01", "1"],
+ ["c-au1", "2"],
+ ["c-p01", "3"],
+ ["c-a01", "4"],
+ ["c-w01", "5"],
+ ]);
+ assert.ok(!render(h(ReferenceList, { citations: all, anchors: false })).includes('id="c-'));
+ assert.equal(render(h(ReferenceList, { citations: [] })), "");
+});
diff --git a/common/components/report/VerdictChip.tsx b/common/components/report/VerdictChip.tsx
@@ -0,0 +1,58 @@
+import type { VerdictCount } from "../../lib/report/views";
+import type { Verdict, VerdictStyle } from "../../lib/report/verdicts";
+import { cn } from "../../lib/utils";
+
+// A fact-check's verdict, from the shared vocabulary (lib/report/verdicts.mjs)
+// with the report's overrides applied — the one place a verdict is drawn, so
+// the export site, the editor's previews and umtool's report preview agree.
+// The colour marks the chip (its dot and edge); the label's text stays in the
+// foreground colour so it reads on every base whatever the colour is.
+
+export function VerdictChip({
+ verdict,
+ style: s,
+ count,
+ className,
+}: {
+ verdict: Verdict;
+ style: VerdictStyle;
+ count?: number;
+ className?: string;
+}) {
+ return (
+ <span
+ data-verdict={verdict}
+ style={{ "--verdict": s.color } as React.CSSProperties}
+ className={cn(
+ "inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[color:var(--verdict)] bg-[color-mix(in_oklab,var(--verdict)_14%,transparent)] px-2.5 py-0.5 text-xs font-semibold text-foreground",
+ className,
+ )}
+ >
+ <span aria-hidden className="size-2 rounded-full bg-[color:var(--verdict)]" />
+ {s.label}
+ {count !== undefined && <span className="font-mono tabular-nums text-muted-foreground">{count}</span>}
+ </span>
+ );
+}
+
+// A fact-check's tally: one chip per verdict a claim carries, with its count.
+export function VerdictTally({
+ tally,
+ styles,
+ className,
+}: {
+ tally: readonly VerdictCount[];
+ styles: Readonly<Record<Verdict, VerdictStyle>>;
+ className?: string;
+}) {
+ if (tally.length === 0) return null;
+ return (
+ <ul data-verdict-tally="" className={cn("flex flex-wrap gap-2", className)}>
+ {tally.map((t) => (
+ <li key={t.verdict}>
+ <VerdictChip verdict={t.verdict} style={styles[t.verdict]} count={t.count} />
+ </li>
+ ))}
+ </ul>
+ );
+}