commit f7f6e72cd1db508accbec878f55c2b519c59ac78
parent a7f8c246dd3b4ec28a0a577fd9bc45f6c58ff945
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 14:13:45 -0400
reports: a fact-check reads in three tiers — the quick take, what the check found, every claim
- report `method` (markdown, cites nothing) and claim `gist` (one line, ≤240)
in the schema, validation and views; REPORT.md regenerated
- views.ts (pure, for report.html too): FOUND_VERDICT_ORDER, foundGroups,
wordCount, readingMinutes (230 wpm, rounded up), reportTierMinutes
- ReportArticle: tier 1 (tally, summary, jump links), tier 2 "What the check
found" (verdict groups, always open, one linked row per claim with gist and
flag; fact-check only), tier 3 "Every claim, with its evidence" opening on
"How it was checked"; each tier opens on a hairline marker with depth dots,
its reading time and a spoken line (data-report-tier, data-tier-marker);
the flag pill is one component; #downloads sits at the end of the references
- fixture: method, gists; unit + report-site e2e; changelog, plan
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
12 files changed, 390 insertions(+), 51 deletions(-)
diff --git a/REPORT.md b/REPORT.md
@@ -22,6 +22,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| `title` | yes | The report's title. |
| `subtitle` | no | A line under the title. |
| `summary` | no | The report's summary, in markdown, shown before the sections. May cite inline: `[label](cite:<id>)`. |
+| `method` | no | How the report was checked, in markdown (short; it cites nothing): shown under "How it was checked" at the start of the claims in full. |
| `published` | no | When the report was published: `YYYY-MM-DD` or an ISO 8601 date-time with a zone. |
| `updated` | no | When it was last changed, in the same form; not before `published`. |
| `subject` | no | The document under review, when the report reviews one: `{ "source": "<id>" }`, an id in `sources`. |
@@ -47,6 +48,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| `title` | no | A short headline for the claim (plain text, e.g. a phrase it turns on), shown above its text. Absent = the text alone. |
| `text` | yes | The claim, as stated by the document under review (plain text). |
| `verdict` | no | The ruling on the claim: `CORROBORATED`, `PARTLY`, `CONTRADICTED`, `NOT_FOUND`, `UNTESTABLE`. A fact-check's claim may leave it out (not yet ruled); a sweep's carries none. |
+| `gist` | no | The claim's finding in one line (plain text, at most 240 characters), shown beside its title in the overview of what the check found. Absent = the title alone. |
| `flag` | no | A short mark on the claim (plain text, one line, at most 60 characters), shown as a pill on its card: e.g. that the document gives no source for it. Absent = none. |
| `sourceQuote` | no | The document's own sentence making the claim: `{ "citation": "<id>" }`, naming a `source` citation (its still is shown with the claim). |
| `findings` | no | What the evidence shows, in markdown, citing inline: `[label](cite:<id>)`. |
diff --git a/common/lib/report/schema.ts b/common/lib/report/schema.ts
@@ -39,12 +39,15 @@ const verdict = z.enum(VERDICTS as unknown as [Verdict, ...Verdict[]]);
// A claim's flag is a short mark, shown as a pill on the claim's card.
export const CLAIM_FLAG_MAX = 60;
+// A claim's gist is its one line in the overview of what the check found.
+export const CLAIM_GIST_MAX = 240;
export const claimSchema = z.strictObject({
id: text,
title: text.optional(),
text,
verdict: verdict.optional(),
+ gist: text.max(CLAIM_GIST_MAX).optional(),
flag: text.max(CLAIM_FLAG_MAX).optional(),
sourceQuote: z.strictObject({ citation: text }).optional(),
findings: text.optional(),
@@ -69,6 +72,7 @@ export const reportSchema = z.strictObject({
title: text,
subtitle: text.optional(),
summary: text.optional(),
+ method: text.optional(),
published: text.optional(),
updated: text.optional(),
subject: z.strictObject({ source: text }).optional(),
@@ -101,6 +105,8 @@ export const REPORT_FIELD_DOCS: FieldDocs<Report> = {
title: "The report's title.",
subtitle: "A line under the title.",
summary: "The report's summary, in markdown, shown before the sections. May cite inline: `[label](cite:<id>)`.",
+ method:
+ "How the report was checked, in markdown (short; it cites nothing): shown under \"How it was checked\" at the start of the claims in full.",
published: "When the report was published: `YYYY-MM-DD` or an ISO 8601 date-time with a zone.",
updated: "When it was last changed, in the same form; not before `published`.",
subject:
@@ -124,6 +130,7 @@ export const CLAIM_FIELD_DOCS: FieldDocs<Claim> = {
title: "A short headline for the claim (plain text, e.g. a phrase it turns on), shown above its text. Absent = the text alone.",
text: "The claim, as stated by the document under review (plain text).",
verdict: `The ruling on the claim: ${VERDICTS.map((v) => `\`${v}\``).join(", ")}. A fact-check's claim may leave it out (not yet ruled); a sweep's carries none.`,
+ gist: `The claim's finding in one line (plain text, at most ${CLAIM_GIST_MAX} characters), shown beside its title in the overview of what the check found. Absent = the title alone.`,
flag: `A short mark on the claim (plain text, one line, at most ${CLAIM_FLAG_MAX} characters), shown as a pill on its card: e.g. that the document gives no source for it. Absent = none.`,
sourceQuote:
"The document's own sentence making the claim: `{ \"citation\": \"<id>\" }`, naming a `source` citation (its still is shown with the claim).",
diff --git a/common/lib/report/validate.ts b/common/lib/report/validate.ts
@@ -31,6 +31,7 @@ import {
type PathSegment,
type Problem,
} from "../citations/validate";
+import { extractCiteRefs } from "../citations/inline";
import { isRefId } from "../citations/schema";
import { reportSchema, isReportId, type Report } from "./schema";
import { reportCitationUses } from "./uses";
@@ -95,6 +96,9 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[]
}
out.push(...citationMapProblems(citations, report.sources));
+ if (report.method !== undefined && extractCiteRefs(report.method).length > 0) {
+ out.push(problem(["method"], "cites nothing: a citation belongs in the summary, a section or a claim"));
+ }
if (!report.subject) {
for (const [id, c] of Object.entries(citations)) {
if (c.origin !== undefined) {
@@ -121,6 +125,10 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[]
const cp: PathSegment[] = [...sp, "claims", ci];
anchor(claim.id, cp);
if (blank(claim.text)) out.push(problem([...cp, "text"], "must not be blank"));
+ if (claim.gist !== undefined) {
+ if (blank(claim.gist)) out.push(problem([...cp, "gist"], "must not be blank"));
+ else if (/[\r\n]/.test(claim.gist)) out.push(problem([...cp, "gist"], "must be one line"));
+ }
if (claim.flag !== undefined) {
if (blank(claim.flag)) out.push(problem([...cp, "flag"], "must not be blank"));
else if (/[\r\n]/.test(claim.flag)) out.push(problem([...cp, "flag"], "must be one line"));
diff --git a/common/lib/report/views.test.ts b/common/lib/report/views.test.ts
@@ -15,6 +15,10 @@ import {
claimArchives,
citedInViews,
evidenceClipPath,
+ foundGroups,
+ readingMinutes,
+ reportTierMinutes,
+ wordCount,
momentViewPath,
orderedCitations,
reportAssetPath,
@@ -371,3 +375,62 @@ test("a citation's origin rides its view; a report with no subject carries none"
(bad.citations![ids[0]] as Record<string, unknown>).origin = "found";
assert.ok(validateReport(bad).length > 0);
});
+
+test("reading time: words (a cite link is its label) at 230 a minute, rounded up", () => {
+ assert.equal(wordCount("He says [so on stream](cite:c01), twice."), 6);
+ assert.equal(wordCount("**Bold** and `code` — it's 2019."), 5);
+ assert.equal(wordCount(undefined), 0);
+ assert.equal(readingMinutes(0), 0);
+ assert.equal(readingMinutes(1), 1);
+ assert.equal(readingMinutes(230), 1);
+ assert.equal(readingMinutes(231), 2);
+ const long = structuredClone(report);
+ long.method = "word ".repeat(460);
+ const m = reportTierMinutes(buildReportPageView(long, { record: (c) => record(c.channel, c.id) }));
+ assert.ok(m.claims >= 3, `the method is read with the claims: ${m.claims}`);
+ assert.equal(m.quick, reportTierMinutes(view).quick, "the method is not in the quick take");
+ const sweep = structuredClone(report);
+ sweep.kind = "sweep";
+ for (const s of sweep.sections) for (const c of s.claims ?? []) delete c.verdict;
+ assert.equal(reportTierMinutes(buildReportPageView(sweep, { record: (c) => record(c.channel, c.id) })).found, undefined);
+});
+
+test("what the check found: ruled claims by verdict, changed first, confirmed last; gist and method ride the view", () => {
+ const r = structuredClone(report);
+ r.method = "Searched the transcripts.";
+ r.sections[0].claims![0].gist = "One line.";
+ const v = buildReportPageView(r, { record: (c) => record(c.channel, c.id) });
+ assert.equal(v.method, "Searched the transcripts.");
+ assert.equal(v.sections[0].claims[0].gist, "One line.");
+ assert.equal("method" in view, false);
+ const order = ["CONTRADICTED", "NOT_FOUND", "PARTLY", "UNTESTABLE", "CORROBORATED"];
+ const groups = foundGroups({
+ sections: [
+ {
+ id: "s",
+ title: "S",
+ claims: [
+ { id: "a", text: "a", verdict: "CORROBORATED", citations: [] },
+ { id: "b", text: "b", verdict: "CONTRADICTED", citations: [] },
+ { id: "c", text: "c", citations: [] },
+ { id: "d", text: "d", verdict: "PARTLY", citations: [] },
+ { id: "e", text: "e", verdict: "CONTRADICTED", citations: [] },
+ ],
+ },
+ ],
+ });
+ assert.deepEqual(groups.map((g) => [g.verdict, g.claims.map((c) => c.id)]), [
+ ["CONTRADICTED", ["b", "e"]],
+ ["PARTLY", ["d"]],
+ ["CORROBORATED", ["a"]],
+ ]);
+ assert.ok(groups.every((g, i) => i === 0 || order.indexOf(g.verdict) > order.indexOf(groups[i - 1].verdict)));
+ // gist: one line of at most 240; method cites nothing
+ const bad = structuredClone(r);
+ bad.sections[0].claims![0].gist = "two\nlines";
+ bad.method = "See [this](cite:v1).";
+ assert.deepEqual(validateReport(bad).map((p) => p.path).sort(), ["method", "sections[0].claims[0].gist"]);
+ const long = structuredClone(r);
+ long.sections[0].claims![0].gist = "x".repeat(241);
+ assert.ok(validateReport(long).some((p) => p.path === "sections[0].claims[0].gist"));
+});
diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts
@@ -213,6 +213,8 @@ export type ClaimView = {
// The claim, as the document under review states it.
text: string;
verdict?: Verdict;
+ // The claim's finding in one line, for the overview of what the check found.
+ gist?: string;
// A short mark on the claim (e.g. that the document gives no source),
// shown as a pill on its card.
flag?: string;
@@ -244,6 +246,8 @@ export type ReportPageView = {
title: string;
subtitle?: string;
summary?: string;
+ // How it was checked (markdown, cites nothing).
+ method?: string;
published?: string;
updated?: string;
// The document under review: its id in `sources`.
@@ -486,6 +490,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver)
title: report.title,
subtitle: report.subtitle,
summary: report.summary,
+ method: report.method,
published: report.published,
updated: report.updated,
subject: subjectId,
@@ -503,6 +508,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver)
title: c.title,
text: c.text,
verdict: c.verdict,
+ gist: c.gist,
flag: c.flag,
sourceQuote: c.sourceQuote?.citation,
archives: archivesFor(c),
@@ -616,6 +622,73 @@ export function reportByline(view: Pick<ReportPageView, "subject" | "sources">):
});
}
+// ─── The report page's tiers: the quick take, what the check found, every claim ───
+
+// The order "what the check found" groups claims in: what the check changed
+// about the document first, what it confirmed last.
+export const FOUND_VERDICT_ORDER: readonly Verdict[] = ["CONTRADICTED", "NOT_FOUND", "PARTLY", "UNTESTABLE", "CORROBORATED"];
+
+export type FoundGroup = { verdict: Verdict; claims: ClaimView[] };
+
+// A fact-check's claims by verdict, in FOUND_VERDICT_ORDER; a verdict no
+// claim carries is left out, and so is a claim with no verdict.
+export function foundGroups(view: Pick<ReportPageView, "sections">): FoundGroup[] {
+ const claims = view.sections.flatMap((s) => s.claims);
+ return FOUND_VERDICT_ORDER.map((verdict) => ({ verdict, claims: claims.filter((c) => c.verdict === verdict) })).filter(
+ (g) => g.claims.length > 0,
+ );
+}
+
+export const READING_WPM = 230;
+
+// The words a reader reads in a text: a cite link counts as its label, and
+// markdown's punctuation as nothing.
+export function wordCount(text: string | undefined): number {
+ if (!text) return 0;
+ return (text.replace(/\]\([^)]*\)/g, "]").match(/[\p{L}\p{N}][\p{L}\p{N}'’.-]*/gu) ?? []).length;
+}
+
+// Minutes to read so many words, rounded up; nothing to read is no minutes.
+export function readingMinutes(words: number): number {
+ return words > 0 ? Math.ceil(words / READING_WPM) : 0;
+}
+
+export type ReportTierMinutes = { quick: number; found?: number; claims: number };
+
+// Each tier's reading time: the quick take (the tally and the summary), what
+// the check found (each row's title and gist; a fact-check with verdicts
+// only), and every claim in full (the method, section bodies, each claim's
+// text and findings, and the quote of every citation they show, once).
+export function reportTierMinutes(view: ReportPageView): ReportTierMinutes {
+ const sum = (xs: (string | undefined)[]) => xs.reduce((n, x) => n + wordCount(x), 0);
+ const tally =
+ view.kind === "factcheck"
+ ? verdictTally(view).map((t) => `${view.verdicts[t.verdict]?.label ?? t.verdict} ${t.count}`)
+ : [];
+ const groups = view.kind === "factcheck" ? foundGroups(view) : [];
+ const claims = view.sections.flatMap((s) => s.claims);
+ const quoted = new Set<string>();
+ for (const c of claims) {
+ if (c.sourceQuote) quoted.add(c.sourceQuote);
+ for (const id of c.citations) quoted.add(id);
+ }
+ return defined({
+ quick: readingMinutes(sum([view.summary, ...tally])),
+ found:
+ groups.length > 0
+ ? readingMinutes(sum(groups.flatMap((g) => g.claims.flatMap((c) => [c.title ?? c.text, c.gist]))))
+ : undefined,
+ claims: readingMinutes(
+ sum([
+ view.method,
+ ...view.sections.map((s) => s.body),
+ ...claims.flatMap((c) => [c.title, c.text, c.findings]),
+ ...[...quoted].map((id) => view.citations[id]?.quote),
+ ]),
+ ),
+ });
+}
+
// A report kind's label: "Fact-check" or "Report".
export function reportKindLabel(kind: ReportKind): string {
return kind === "factcheck" ? "Fact-check" : "Report";
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,7 +1,7 @@
# Changelog
## [Unreleased]
-- **A report's claim can carry a flag, its title a byline, and a site with one report names it in the browser tab.** `report.json` claim `flag` (one line, at most 60 characters) shows as a small pill in the accent colour beside the claim's verdict, e.g. "No source given". On a report-only site with one report, the home page's tab title is the report's, as on the report's own page, where it was the site's title alone. A report's page follows its title with a byline from the document under review, "by <author> · <publisher>", in the same line when it fits; the publisher (or, with none, the author) links to the document. Under it, a step apart, the page names its own: "Fact-check by <site title>" ("Report by …"), then the dates, then the subtitle; the kind's label no longer sits above the title on the report's page. A claim's sentence from the document under review no longer links up to the document's box: it sits on a rail in the document's colour, and the box's left edge wears the same colour (a source's `accent`, `"#rrggbb"`; without one, the border colour). A sentence of another document keeps its "from <title>" link. A report's citation can say where its evidence came from (`origin`: `"subject"`, the document under review gave it; `"added"`, the report's author found it). A claim lists what the report added first, each card marked with the Archilyzer mark and "Not in the article" ("Not in the source"), then evidence of unknown origin, then what the document gave itself folded under "In the article (n)"; the reference list marks an added citation with the mark alone, and a claim's flag pill wears the same mark. Needs a rebuild and deploy of the site.
+- **A report's claim can carry a flag, its title a byline, and a site with one report names it in the browser tab.** `report.json` claim `flag` (one line, at most 60 characters) shows as a small pill in the accent colour beside the claim's verdict, e.g. "No source given". On a report-only site with one report, the home page's tab title is the report's, as on the report's own page, where it was the site's title alone. A report's page follows its title with a byline from the document under review, "by <author> · <publisher>", in the same line when it fits; the publisher (or, with none, the author) links to the document. Under it, a step apart, the page names its own: "Fact-check by <site title>" ("Report by …"), then the dates, then the subtitle; the kind's label no longer sits above the title on the report's page. A claim's sentence from the document under review no longer links up to the document's box: it sits on a rail in the document's colour, and the box's left edge wears the same colour (a source's `accent`, `"#rrggbb"`; without one, the border colour). A sentence of another document keeps its "from <title>" link. A report's citation can say where its evidence came from (`origin`: `"subject"`, the document under review gave it; `"added"`, the report's author found it). A claim lists what the report added first, each card marked with the Archilyzer mark and "Not in the article" ("Not in the source"), then evidence of unknown origin, then what the document gave itself folded under "In the article (n)"; the reference list marks an added citation with the mark alone, and a claim's flag pill wears the same mark. A fact-check's page reads in three tiers, each opened by a hairline with one, two or three dots and its reading time (at 230 words a minute): the quick take (the tally, the summary, and links to what the check found, every claim and the downloads); **What the check found**, every ruled claim grouped by verdict (contradicted, not found, partly, untestable, corroborated), one line each linking to the claim, with its `gist` (a new optional claim field, one line, at most 240 characters) and its flag; and **Every claim, with its evidence**, which opens with **How it was checked** (`method`, a new optional report field in markdown). A report of kind `sweep` has the first and last tiers only. Needs a rebuild and deploy of the site.
- **A report-only site with one report opens on that report.** Its home page is the report itself, its header links nothing, and `/reports/` forwards home: there is no index of one. With more reports the home page is the list, without repeating the site's title under the header; a list entry is the report's name, subtitle and dates (its counts and tally are on its page). Pages a report-only site does not have link home.
- **A report can belong to a series.** `report.json` `series` is shown on its own line above the report's title, in the accent colour, in place of the kind's label ("Fact-check"); a page title, a cited-in link, `llms.txt` and the MCP name it `<series>: <title>`.
- **`pnpm start:export` serves a built site's moment pages.** It used `serve`, which listed a video or audio moment's directory (`3126.00-3151.00`) instead of serving its page; it now runs `export/scripts/serve-out.mjs`, which serves directories as Cloudflare Pages does, on `EXPORT_DEV_PORT` (3000).
diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx
@@ -6,8 +6,10 @@ import { ReferenceList } from "yt-dlp-transcript-common/components/citations/Ref
import { VerdictChip, VerdictTally } from "yt-dlp-transcript-common/components/report/VerdictChip";
import type { SourceArchive } from "yt-dlp-transcript-common/lib/citations/schema";
import {
+ foundGroups,
orderedCitations,
reportAttribution,
+ reportTierMinutes,
reportByline,
sourceAnchor,
verdictTally,
@@ -81,6 +83,50 @@ function SourceSentence({
);
}
+// A claim's flag: a quiet pill in the accent, behind the project's mark (the
+// mark added evidence wears: the two read as the report's own marks).
+function FlagPill({ flag, className }: { flag: string; className?: string }) {
+ return (
+ <span
+ data-claim-flag=""
+ className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border border-brand/40 bg-brand-soft px-2.5 py-0.5 text-xs font-medium text-brand${className ? ` ${className}` : ""}`}
+ >
+ <AddedMark className="size-3" />
+ {flag}
+ </span>
+ );
+}
+
+// Where a tier of the page begins: a hairline with, at its left, how deep it
+// goes (one, two or three dots filled in the accent) and how long it takes to
+// read. Read aloud as one plain line ("Next: more detail, about 6 minutes").
+function TierMarker({ depth, minutes, spoken }: { depth: 1 | 2 | 3; minutes: number; spoken: string }) {
+ const time = `${Math.max(1, minutes)} min`;
+ const said = `${spoken}, about ${Math.max(1, minutes)} minute${Math.max(1, minutes) === 1 ? "" : "s"}`;
+ return (
+ <div data-tier-marker={depth} className="flex items-center gap-2.5">
+ <span aria-hidden="true" className="flex items-center gap-1">
+ {[1, 2, 3].map((i) => (
+ <span
+ key={i}
+ data-depth-dot={i <= depth ? "filled" : "empty"}
+ className={
+ i <= depth
+ ? "size-1.5 rounded-full bg-brand"
+ : "size-1.5 rounded-full border border-muted-foreground/60"
+ }
+ />
+ ))}
+ </span>
+ <span aria-hidden="true" className="font-mono text-xs text-muted-foreground">
+ {time}
+ </span>
+ <span aria-hidden="true" className="h-px flex-1 bg-border" />
+ <span className="sr-only">{said}</span>
+ </div>
+ );
+}
+
// The byline after the report's title, from the document under review:
// "by <author> · <publisher>", the document's URL on the publisher (else the
// author). Each part keeps its words together; the line breaks between them.
@@ -142,15 +188,7 @@ function Claim({
>
<div className="flex flex-wrap items-start gap-x-3 gap-y-2">
{claim.verdict && <VerdictChip verdict={claim.verdict} style={view.verdicts[claim.verdict]} className="mt-0.5" />}
- {claim.flag && (
- <span
- data-claim-flag=""
- className="mt-0.5 inline-flex shrink-0 items-center gap-1.5 rounded-full border border-brand/40 bg-brand-soft px-2.5 py-0.5 text-xs font-medium text-brand"
- >
- <AddedMark className="size-3" />
- {claim.flag}
- </span>
- )}
+ {claim.flag && <FlagPill flag={claim.flag} className="mt-0.5" />}
<h3 className="min-w-0 flex-1 basis-60 font-display text-lg font-semibold leading-snug text-foreground">
<a href={`#${claim.id}`} className="hover:text-brand transition-colors">
{claim.title ?? claim.text}
@@ -213,6 +251,13 @@ export default function ReportArticle({ view, siteTitle }: { view: ReportPageVie
view.updated && view.updated !== view.published ? `Updated ${dateLabel(view.updated)}` : null,
].filter(Boolean);
const claimCount = view.sections.reduce((n, s) => n + s.claims.length, 0);
+ const groups = isFactcheck ? foundGroups(view) : [];
+ const minutes = reportTierMinutes(view);
+ const jumps = [
+ ...(groups.length > 0 ? [{ href: "#found", label: "What the check found" }] : []),
+ { href: "#claims", label: "Every claim" },
+ ...(view.downloads?.json || view.downloads?.csv ? [{ href: "#downloads", label: "Downloads" }] : []),
+ ];
// Documents quoted that are not the subject, each shown once before the
// references.
const otherSources = Object.values(view.sources).filter((s) => s.id !== view.subject);
@@ -222,7 +267,7 @@ export default function ReportArticle({ view, siteTitle }: { view: ReportPageVie
// serialized once, not once per marker.
<CitationsProvider citations={view.citations}>
<article data-report={view.id} className="mx-auto flex w-full max-w-3xl flex-col gap-8">
- <header className="flex flex-col gap-3 border-b border-border pb-6">
+ <header className="flex flex-col gap-3">
<h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground sm:text-4xl">
<ReportName series={view.series} title={view.title}>
{byline && <Byline byline={byline} />}
@@ -238,6 +283,11 @@ export default function ReportArticle({ view, siteTitle }: { view: ReportPageVie
</div>
{view.subtitle && <p className="text-lg text-muted-foreground">{view.subtitle}</p>}
{subject && <SourceBlock source={subject} label="Under review" />}
+ </header>
+
+ {/* The quick take: the tally and the summary, then where to go next. */}
+ <section data-report-tier="1" aria-label="In brief" className="flex flex-col gap-4">
+ <TierMarker depth={1} minutes={minutes.quick} spoken="In brief" />
{tally.length > 0 && (
<div className="flex flex-col gap-2">
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
@@ -246,46 +296,106 @@ export default function ReportArticle({ view, siteTitle }: { view: ReportPageVie
<VerdictTally tally={tally} styles={view.verdicts} />
</div>
)}
- </header>
-
- {view.summary && (
- <CitedMarkdown className={proseClass}>
- {view.summary}
- </CitedMarkdown>
- )}
+ {view.summary && (
+ <CitedMarkdown className={proseClass}>
+ {view.summary}
+ </CitedMarkdown>
+ )}
+ <p data-report-jumps="" className="flex flex-wrap gap-x-2 gap-y-1 text-sm">
+ {jumps.map((j, i) => (
+ <span key={j.href} className="whitespace-nowrap">
+ {i > 0 && <span aria-hidden="true" className="mr-2 text-muted-foreground">·</span>}
+ <a href={j.href} className={textLink}>
+ {j.label}
+ </a>
+ </span>
+ ))}
+ </p>
+ </section>
- {view.sections.length > 1 && (
- <nav aria-label="Sections" className="rounded-lg border border-border p-4 text-sm">
- <ol className="flex list-decimal flex-col gap-1 pl-5 marker:text-muted-foreground">
- {view.sections.map((s) => (
- <li key={s.id}>
- <a href={`#${s.id}`} className={textLink}>
- {s.title}
- </a>
- {s.claims.length > 0 && (
- <span className="ml-2 font-mono text-xs text-muted-foreground">
- {s.claims.length} claim{s.claims.length === 1 ? "" : "s"}
- </span>
- )}
- </li>
- ))}
- </ol>
- </nav>
+ {/* What the check found: every ruled claim by verdict, one line each. */}
+ {groups.length > 0 && (
+ <section id="found" data-report-tier="2" aria-labelledby="found-heading" className="flex scroll-mt-20 flex-col gap-4">
+ <TierMarker depth={2} minutes={minutes.found ?? 0} spoken="Next: more detail" />
+ <h2 id="found-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground">
+ What the check found
+ </h2>
+ {groups.map((g) => (
+ <div key={g.verdict} data-found-group={g.verdict} className="flex flex-col gap-2">
+ <h3>
+ <VerdictChip verdict={g.verdict} style={view.verdicts[g.verdict]} count={g.claims.length} />
+ </h3>
+ <ul className="flex flex-col gap-1.5 pl-1 text-sm">
+ {g.claims.map((c) => (
+ <li key={c.id} data-found-row={c.id} className="leading-snug">
+ <a href={`#${c.id}`} className="font-medium text-foreground hover:text-brand transition-colors">
+ {c.title ?? c.text}
+ </a>
+ {c.gist && <span className="text-muted-foreground"> — {c.gist}</span>}
+ {c.flag && (
+ <>
+ {" "}
+ <FlagPill flag={c.flag} className="ml-1 align-middle" />
+ </>
+ )}
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </section>
)}
- {view.sections.map((s) => (
- <section key={s.id} id={s.id} data-section={s.id} className="flex scroll-mt-20 flex-col gap-4">
- <h2 className="font-display text-2xl font-semibold tracking-tight text-foreground">{s.title}</h2>
- {s.body && (
- <CitedMarkdown className={proseClass}>
- {s.body}
- </CitedMarkdown>
+ {/* Every claim, with its evidence. */}
+ <div id="claims" data-report-tier="3" className="flex scroll-mt-20 flex-col gap-8">
+ <div className="flex flex-col gap-4">
+ <TierMarker depth={3} minutes={minutes.claims} spoken="Next: every claim in full" />
+ <h2 className="font-display text-2xl font-semibold tracking-tight text-foreground">
+ Every claim, with its evidence
+ </h2>
+ {view.method && (
+ <div data-report-method="" className="flex flex-col gap-1">
+ <h3 className="font-mono text-xs uppercase tracking-[0.16em] text-muted-foreground">How it was checked</h3>
+ <CitedMarkdown className={proseClass}>
+ {view.method}
+ </CitedMarkdown>
+ </div>
)}
- {s.claims.map((c) => (
- <Claim key={c.id} claim={c} view={view} subjectLabel={subjectLabel} subjectNoun={subjectNoun} />
- ))}
- </section>
- ))}
+ </div>
+
+ {view.sections.length > 1 && (
+ <nav aria-label="Sections" className="rounded-lg border border-border p-4 text-sm">
+ <ol className="flex list-decimal flex-col gap-1 pl-5 marker:text-muted-foreground">
+ {view.sections.map((s) => (
+ <li key={s.id}>
+ <a href={`#${s.id}`} className={textLink}>
+ {s.title}
+ </a>
+ {s.claims.length > 0 && (
+ <span className="ml-2 font-mono text-xs text-muted-foreground">
+ {s.claims.length} claim{s.claims.length === 1 ? "" : "s"}
+ </span>
+ )}
+ </li>
+ ))}
+ </ol>
+ </nav>
+ )}
+
+ {view.sections.map((s) => (
+ <section key={s.id} id={s.id} data-section={s.id} className="flex scroll-mt-20 flex-col gap-4">
+ <h2 className="font-display text-2xl font-semibold tracking-tight text-foreground">{s.title}</h2>
+ {s.body && (
+ <CitedMarkdown className={proseClass}>
+ {s.body}
+ </CitedMarkdown>
+ )}
+ {s.claims.map((c) => (
+ <Claim key={c.id} claim={c} view={view} subjectLabel={subjectLabel} subjectNoun={subjectNoun} />
+ ))}
+ </section>
+ ))}
+ </div>
{otherSources.length > 0 && (
<section aria-labelledby="sources-heading" className="flex flex-col gap-3 border-t border-border pt-6">
@@ -304,6 +414,8 @@ export default function ReportArticle({ view, siteTitle }: { view: ReportPageVie
References
</h2>
<ReferenceList citations={references} addedLabel={`Not in the ${subjectNoun}`} />
+ {/* The jump links' "Downloads": the citation files follow the references. */}
+ <span id="downloads" className="scroll-mt-20" />
</section>
)}
diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts
@@ -140,7 +140,34 @@ test("the report page: header, tally, sections and claims, inline cites, referen
const refList = html.slice(html.indexOf("data-reference-list"));
assert.match(refList, /<li id="c-c01"[^]*?<span data-citation-added="" title="Not in the article"[^>]*><svg/);
assert.equal([...refList.matchAll(/data-citation-added=/g)].length, 1);
- assert.equal([...html.matchAll(/data-claim-flag=/g)].length, 1);
+ assert.equal([...html.matchAll(/data-claim-flag=/g)].length, 2, "the claim's card and its row in what the check found");
+ // three tiers, in order, each opened by its marker: the quick take, what the
+ // check found, every claim; nothing in the first two is folded away
+ const tiers = [...html.matchAll(/data-report-tier="(\d)"/g)].map((m) => m[1]);
+ assert.deepEqual(tiers, ["1", "2", "3"]);
+ const tier1 = html.slice(html.indexOf('data-report-tier="1"'), html.indexOf('data-report-tier="2"'));
+ const tier2 = html.slice(html.indexOf('data-report-tier="2"'), html.indexOf('data-report-tier="3"'));
+ const tier3 = html.slice(html.indexOf('data-report-tier="3"'));
+ assert.doesNotMatch(tier1 + tier2, /<details/);
+ assert.match(tier1, /data-tier-marker="1"[^]*?<span class="sr-only">In brief, about 1 minute<\/span>/);
+ assert.match(tier2, /data-tier-marker="2"[^]*?<span class="sr-only">Next: more detail, about 1 minute<\/span>/);
+ assert.match(tier3, /data-tier-marker="3"[^]*?<span class="sr-only">Next: every claim in full, about 1 minute<\/span>/);
+ assert.equal([...tier2.matchAll(/data-depth-dot="filled"/g)].length, 2);
+ assert.match(tier1, /data-verdict-tally=""[^]*?The article makes four claims/);
+ const jumps = [...tier1.slice(tier1.indexOf("data-report-jumps")).matchAll(/<a href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => `${m[1]} ${m[2]}`);
+ assert.deepEqual(jumps, ["#found What the check found", "#claims Every claim", "#downloads Downloads"]);
+ assert.match(html, /id="downloads"/);
+ // what the check found: groups in a fixed order, every row a link to its claim
+ assert.match(tier2, /<h2 id="found-heading"[^>]*>What the check found<\/h2>/);
+ const groups = [...tier2.matchAll(/data-found-group="([A-Z_]+)"/g)].map((m) => m[1]);
+ assert.deepEqual(groups, ["CONTRADICTED", "PARTLY", "UNTESTABLE", "CORROBORATED"]);
+ const rows = [...tier2.matchAll(/data-found-row="([^"]+)"/g)].map((m) => m[1]);
+ assert.deepEqual(rows, ["claim-1", "claim-3", "claim-2", "claim-4", "claim-5"]);
+ assert.match(tier2, /<li data-found-row="claim-1"[^>]*><a href="#claim-1"[^>]*>“He opened it himself”<\/a><span[^>]*> — It opened in 2019/);
+ assert.match(tier2, /<li data-found-row="claim-5"[^>]*><a href="#claim-5"[^>]*>He was at the opening.<\/a><\/li>/, "no gist: the title alone");
+ assert.match(tier2, /<li data-found-row="claim-4"[^]*?data-claim-flag=""/);
+ // every claim: the tier's heading, then how it was checked — plain, not folded
+ assert.match(tier3, /<h2[^>]*>Every claim, with its evidence<\/h2><div data-report-method=""[^>]*><h3[^>]*>How it was checked<\/h3>[^]*?searched for in the channel/);
});
test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => {
diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts
@@ -70,7 +70,7 @@ test("the report: verdict chips, the tally, the source's sentence as a still, nu
}
// A claim's flag is a pill on its card; an unflagged claim has none.
await expect(page.locator("article#claim-4 [data-claim-flag]")).toHaveText("No source given");
- await expect(page.locator("[data-claim-flag]")).toHaveCount(1);
+ await expect(page.locator("article[data-claim] [data-claim-flag]")).toHaveCount(1);
await expect(page).toHaveTitle("Checking an example article — Demo Reports");
// The claim's sentence in the article, as the reviewed page showed it.
@@ -94,6 +94,45 @@ test("the report: verdict chips, the tally, the source's sentence as a still, nu
}
});
+test("three tiers, each marked by its depth and reading time; nothing above the claims is folded away", async ({ page }) => {
+ await page.goto(REPORT);
+ const tiers = page.locator("[data-report-tier]");
+ expect(await tiers.evaluateAll((els) => els.map((e) => e.getAttribute("data-report-tier")))).toEqual(["1", "2", "3"]);
+ for (const depth of [1, 2, 3]) {
+ const marker = page.locator(`[data-report-tier="${depth}"] [data-tier-marker="${depth}"]`);
+ await expect(marker).toBeVisible();
+ await expect(marker.locator('[data-depth-dot="filled"]')).toHaveCount(depth);
+ await expect(marker).toContainText(/\d+ min/);
+ }
+ await expect(page.locator('[data-report-tier="1"] details, [data-report-tier="2"] details')).toHaveCount(0);
+
+ // The quick take ends on where to go next.
+ const jumps = page.locator("[data-report-jumps] a");
+ await expect(jumps).toHaveText(["What the check found", "Every claim", "Downloads"]);
+
+ // What the check found: groups in a fixed order, every row shown, each a link to its claim.
+ const found = page.locator('[data-report-tier="2"]');
+ expect(await found.locator("[data-found-group]").evaluateAll((els) => els.map((e) => e.getAttribute("data-found-group")))).toEqual([
+ "CONTRADICTED",
+ "PARTLY",
+ "UNTESTABLE",
+ "CORROBORATED",
+ ]);
+ const rows = found.locator("[data-found-row]");
+ await expect(rows).toHaveCount(5);
+ for (const row of await rows.all()) await expect(row).toBeVisible();
+ await expect(found.locator('[data-found-row="claim-3"]')).toContainText("He denies ever saying it.");
+ await found.locator('[data-found-row="claim-2"] a').click();
+ await expect(page).toHaveURL(`${REPORT}#claim-2`);
+ await expect(page.locator("article#claim-2")).toBeInViewport();
+
+ // Every claim: how it was checked, plain, at its start.
+ const method = page.locator('[data-report-tier="3"] [data-report-method]');
+ await expect(method).toBeVisible();
+ await expect(method).toContainText("How it was checked");
+ await expect(method.locator("details")).toHaveCount(0);
+});
+
test("a claim's evidence: what the report added first and marked, the article's own folded away", async ({ page }) => {
await page.goto(REPORT);
const claim = page.locator("article#claim-1");
diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json
@@ -6,6 +6,7 @@
"title": "Checking an example article",
"subtitle": "Four claims about a demo channel, tested against what was said on air",
"summary": "The article makes four claims. The recordings support one, partly support another, and contradict a third; the fourth cannot be tested. The host said the opening date out loud [on stream](cite:c01), and later [repeated it](cite:au1).",
+ "method": "Each claim was searched for in the channel's transcripts and the guest's podcast, and checked against the city's public records.",
"published": "2026-10-01",
"updated": "2026-10-04",
"subject": "s0",
@@ -166,6 +167,7 @@
"title": "“He opened it himself”",
"text": "He opened the bridge himself in 2018.",
"verdict": "CONTRADICTED",
+ "gist": "It opened in 2019, as he says on stream and the city's record shows.",
"sourceQuote": "a01",
"findings": "He was there, but the bridge opened in 2019: he says so [on stream](cite:c01), and the [city's record](cite:w01) agrees.",
"citations": [
@@ -178,6 +180,7 @@
"id": "claim-2",
"text": "He went back to the bridge every year.",
"verdict": "PARTLY",
+ "gist": "Once is on record; every year is not.",
"findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.",
"citations": [
"au1"
@@ -193,6 +196,7 @@
"id": "claim-3",
"text": "He has said many times that he would move away.",
"verdict": "CONTRADICTED",
+ "gist": "He denies ever saying it.",
"sourceQuote": "a02",
"findings": "The article [puts it as a habit](cite:a02); he [denies it outright](cite:p01).",
"citations": [
diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json
@@ -6,6 +6,7 @@
"title": "Checking an example article",
"subtitle": "Four claims about a demo channel, tested against what was said on air",
"summary": "The article makes four claims. The recordings support one, partly support another, and contradict a third; the fourth cannot be tested. The host said the opening date out loud [on stream](cite:c01), and later [repeated it](cite:au1).",
+ "method": "Each claim was searched for in the channel's transcripts and the guest's podcast, and checked against the city's public records.",
"published": "2026-10-01",
"updated": "2026-10-04",
"subject": { "source": "s0" },
@@ -90,6 +91,7 @@
"title": "“He opened it himself”",
"text": "He opened the bridge himself in 2018.",
"verdict": "CONTRADICTED",
+ "gist": "It opened in 2019, as he says on stream and the city's record shows.",
"sourceQuote": { "citation": "a01" },
"findings": "He was there, but the bridge opened in 2019: he says so [on stream](cite:c01), and the [city's record](cite:w01) agrees.",
"citations": ["c01", "w01", "au1"]
@@ -98,6 +100,7 @@
"id": "claim-2",
"text": "He went back to the bridge every year.",
"verdict": "PARTLY",
+ "gist": "Once is on record; every year is not.",
"findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.",
"citations": ["au1"]
}
@@ -111,6 +114,7 @@
"id": "claim-3",
"text": "He has said many times that he would move away.",
"verdict": "CONTRADICTED",
+ "gist": "He denies ever saying it.",
"sourceQuote": { "citation": "a02" },
"findings": "The article [puts it as a habit](cite:a02); he [denies it outright](cite:p01).",
"citations": ["p01"]
diff --git a/plans/report-sites.md b/plans/report-sites.md
@@ -28,7 +28,7 @@ on existing full sites) need configuration, not code.
```jsonc
{ "format": "archilyzer-report", "version": 1,
- "id", "kind": "factcheck" | "sweep", "title", "subtitle", "summary" /* md */, "published", "updated",
+ "id", "kind": "factcheck" | "sweep", "title", "subtitle", "summary" /* md */, "method" /* md, optional: "How it was checked" */, "published", "updated",
"subject": { "source": "s0" }, // the document under review (optional)
"verdicts": { "PARTLY": { "label": "…" } }, // optional label/colour overrides of the shared vocab
"sources": { "s0": { "kind": "article", "title", "url", "publisher", "author", "date",
@@ -39,7 +39,7 @@ on existing full sites) need configuration, not code.
"p01": { "kind": "post", "channel", "id", "quote" },
"a01": { "kind": "source", "source": "s0", "quote", "image": "stills/a01.png" } },
"sections": [ { "id", "title", "body" /* md */,
- "claims": [ { "id", "text", "verdict" /* optional */, "flag" /* optional, ≤60 chars: a pill on the card */,
+ "claims": [ { "id", "text", "verdict" /* optional */, "gist" /* optional, ≤240: its line in "What the check found" */, "flag" /* optional, ≤60 chars: a pill on the card */,
"sourceQuote": { "citation": "a01" },
"findings": "md with [label](cite:c01) refs",
"citations": ["c01", "p01"] } ] } ] }