Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/components/Markdown.tsx | 12++++++++----
Acommon/components/citations/CitationCard.tsx | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/citations/CitedMarkdown.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/citations/InlineCite.tsx | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/citations/ReferenceList.tsx | 43+++++++++++++++++++++++++++++++++++++++++++
Acommon/components/citations/VerificationBadge.tsx | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/citations/citations.test.ts | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/report/VerdictChip.tsx | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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&#x27;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> + ); +}