commit 711ae6f0a3861df3093e236940d2b8bc062345fe
parent e504009d8bc78f96f9a392095822c9845f055a8f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 14:06:17 -0400
common: report slides — the slide fields, their rules, and buildReportSlides (release 22 E1)
report.json gains `slides` ({hide, title, points, closing}) and a section's
or claim's `slide` ({title, points, cite, layout, hide}); version stays 1.
The validator holds them to slideRules.ts (lines one line, at most 5
points of at most 140 visible characters, a `cite` of the slide's own
section or claim, points citing only what the article cites, an evidence
layout with something to show) and refuses a section or claim id that is
one of the page's own anchors. lib/report/slides.ts builds the deck from
the page view — title, In brief, What the check found, each section and
claim, sources — each slide anchored to an id the article page carries;
with the reader's place in the URL (`rv`, `#s-<n>`) and a lint for
umtool. The view carries the fields through (compose and umtool's
lenient view alike); REPORT.md regenerated; the demo fixture gains them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
12 files changed, 1109 insertions(+), 16 deletions(-)
diff --git a/REPORT.md b/REPORT.md
@@ -6,7 +6,7 @@ One cited report, format `"archilyzer-report"`, version 1, persisted to `transcr
A **fact-check** (`"kind": "factcheck"`) is sections (chapters) of claims, each with a verdict and its findings. A **sweep** (`"kind": "sweep"`) is sections with no verdicts, or bodies that cite inline. Markdown fields (`summary`, a section's `body`, a claim's `findings`) cite with `[label](cite:<id>)`.
-`common/lib/report/validate.ts` reports every problem with its JSON path: an unknown key, a reference that names nothing (a listed citation, a claim's source sentence, a `cite:` link, the subject), a section or claim id used twice (they share one namespace: the report page's anchors), a sweep's claim with a verdict, `updated` before `published`, and every citation problem CITATIONS.md lists. Whether a still or the report's video exists, whether the video fits the publish limit, and whether a quote matches its cues are checked when the site is composed.
+`common/lib/report/validate.ts` reports every problem with its JSON path: an unknown key, a reference that names nothing (a listed citation, a claim's source sentence, a `cite:` link, the subject), a section or claim id used twice (they share one namespace: the report page's anchors) or named for one of the page's own anchors, a sweep's claim with a verdict, `updated` before `published`, a slide field out of bounds (below), and every citation problem CITATIONS.md lists. Whether a still or the report's video exists, whether the video fits the publish limit, and whether a quote matches its cues are checked when the site is composed.
Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts`.
@@ -31,21 +31,23 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| `sources` | no | The documents the report's `source` citations quote, by id — see [CITATIONS.md](CITATIONS.md). Absent = none. |
| `citations` | no | The report's citations, by id — see [CITATIONS.md](CITATIONS.md). A citation is cited from markdown with `[label](cite:<id>)` and listed under the claims that rest on it. Absent = none. |
| `sections` | yes | The report's sections, in order. |
+| `slides` | no | How the report reads as slides (its page's Slides and Overview views, `slides.html`, `slides.pdf`) — see `slides` below. Absent = slides derived from its text. |
#### `sections[]`
| Key | Required | Description |
|---|---|---|
-| `id` | yes | The section's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids. |
+| `id` | yes | The section's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (`report-head`, `in-brief`, `found`, `claims`, `references`, `downloads`, `report-end`). |
| `title` | yes | The section's heading. |
| `body` | no | Markdown under the heading. May cite inline. |
| `claims` | no | The section's claims, in order. Absent = none. |
+| `slide` | no | The section's slide — see `slide` below. Absent = derived from its text. |
#### `sections[].claims[]`
| Key | Required | Description |
|---|---|---|
-| `id` | yes | The claim's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids. |
+| `id` | yes | The claim's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (`report-head`, `in-brief`, `found`, `claims`, `references`, `downloads`, `report-end`). |
| `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. |
@@ -54,6 +56,30 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| `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>)`. |
| `citations` | no | The citations the claim rests on, in the order they are listed under it. Each must exist; none twice. |
+| `slide` | no | The claim's slide — see `slide` below. Absent = derived from the claim. |
+
+## Slides
+
+A report is also read as slides — its page's **Slides** view (`?rv=slides`), the **Overview** that pairs each part of the article with its slide (`?rv=iso`), `slides.html` and `slides.pdf` — built by `common/lib/report/slides.ts` from the same view as the article: a title slide, "In brief", "What the check found" (a fact-check), one slide per section and per claim, then a sources slide. With no slide fields a report still has slides, derived from its text: a section shows the first two sentences of its body (else its claims), a claim its verdict, gist and the evidence of its first listed citation. The fields below make them good. A slide's points cite only what the article cites elsewhere, and its `cite` names a citation of its own section or claim. Every slide links back to its place in the article (the section's or the claim's anchor).
+
+#### `slides`
+
+| Key | Required | Description |
+|---|---|---|
+| `hide` | no | `true` publishes no slides: the page offers no Slides or Overview view, and no `slides.html` or `slides.pdf` is exported. |
+| `title` | no | The title slide's line under the report's title (plain text, one line, at most 160 characters). Absent = the subtitle. |
+| `points` | no | The quick take's slide, as points (at most 5, each one line of at most 140 characters, the label of an inline citation counted; may cite inline a citation the report cites elsewhere). Absent = the summary's first paragraph. |
+| `closing` | no | The sources slide's closing line (plain text, one line, at most 160 characters). Absent = none. |
+
+#### `sections[].slide` and `sections[].claims[].slide`
+
+| Key | Required | Description |
+|---|---|---|
+| `title` | no | The slide's title (plain text, one line, at most 160 characters). Absent = the section's title; the claim's title, else its text. |
+| `points` | no | The slide's own words, as points (at most 5, each one line of at most 140 characters, the label of an inline citation counted). May cite inline, `[label](cite:<id>)`, a citation the report cites elsewhere. Absent = a section's slide shows the first two sentences of its body (else its claims); a claim's shows its gist. |
+| `cite` | no | The evidence the slide shows (a still, a post's capture, a clip's poster, with its quote): a citation of this section (its body or its claims) or of this claim (its sentence, findings or list). Absent = a claim's first listed citation; a section shows none. |
+| `layout` | no | How the slide is laid out: `points`, `evidence`, `quote`, `statement`. Absent = `points` for a section; `evidence` for a claim, `statement` when it has no citation to show. `evidence` and `quote` need a citation. |
+| `hide` | no | `true` leaves this section's or claim's slide out (a section's claims keep theirs). |
## The verdicts
diff --git a/common/lib/report/docs.ts b/common/lib/report/docs.ts
@@ -11,11 +11,15 @@ import {
CLAIM_FIELD_DOCS,
REPORT_FIELD_DOCS,
REPORT_FORMAT,
+ REPORT_SLIDES_FIELD_DOCS,
REPORT_VERSION,
SECTION_FIELD_DOCS,
+ SLIDE_FIELD_DOCS,
claimSchema,
reportSchema,
+ reportSlidesSchema,
sectionSchema,
+ slideSchema,
} from "./schema";
import { VERDICT_DEFAULTS, VERDICT_LABEL_MAX, VERDICTS } from "./verdicts";
@@ -48,8 +52,9 @@ export function renderReportMarkdown(): string {
"`common/lib/report/validate.ts` reports every problem with its JSON path: an " +
"unknown key, a reference that names nothing (a listed citation, a claim's " +
"source sentence, a `cite:` link, the subject), a section or claim id used " +
- "twice (they share one namespace: the report page's anchors), a sweep's claim " +
- "with a verdict, `updated` before `published`, and every citation problem " +
+ "twice (they share one namespace: the report page's anchors) or named for one of " +
+ "the page's own anchors, a sweep's claim with a verdict, `updated` before " +
+ "`published`, a slide field out of bounds (below), and every citation problem " +
"CITATIONS.md lists. Whether a still or the report's video exists, whether the " +
"video fits the publish limit, and whether a quote matches its cues are checked " +
"when the site is composed.",
@@ -61,6 +66,24 @@ export function renderReportMarkdown(): string {
renderKeyTable(out, "#### `sections[]`", sectionSchema.shape, SECTION_FIELD_DOCS);
renderKeyTable(out, "#### `sections[].claims[]`", claimSchema.shape, CLAIM_FIELD_DOCS);
+ out.push("## Slides");
+ out.push("");
+ out.push(
+ "A report is also read as slides — its page's **Slides** view (`?rv=slides`), the **Overview** " +
+ "that pairs each part of the article with its slide (`?rv=iso`), `slides.html` and `slides.pdf` " +
+ "— built by `common/lib/report/slides.ts` from the same view as the article: a title slide, " +
+ "\"In brief\", \"What the check found\" (a fact-check), one slide per section and per claim, " +
+ "then a sources slide. With no slide fields a report still has slides, derived from its text: " +
+ "a section shows the first two sentences of its body (else its claims), a claim its verdict, " +
+ "gist and the evidence of its first listed citation. The fields below make them good. A " +
+ "slide's points cite only what the article cites elsewhere, and its `cite` names a citation " +
+ "of its own section or claim. Every slide links back to its place in the article (the " +
+ "section's or the claim's anchor).",
+ );
+ out.push("");
+ renderKeyTable(out, "#### `slides`", reportSlidesSchema.shape, REPORT_SLIDES_FIELD_DOCS);
+ renderKeyTable(out, "#### `sections[].slide` and `sections[].claims[].slide`", slideSchema.shape, SLIDE_FIELD_DOCS);
+
out.push("## The verdicts");
out.push("");
out.push(
diff --git a/common/lib/report/report.test.ts b/common/lib/report/report.test.ts
@@ -270,3 +270,57 @@ test("a report's video: an mp4 and an image poster, relative to the report, and
assert.deepEqual(paths(validateReport(withVideo({ src: "video.mp4", caption: "two\nlines" }))), ["video.caption"]);
assert.equal(parseReport(withVideo({ src: "video.mp4", autoplay: true })).ok, false);
});
+
+test("slides: lines, points, a cite of its own section or claim, a point citing what the article cites", () => {
+ const f = fixture();
+ f.slides = { title: "One line", closing: "Read it on the site.", points: ["One [right](cite:c02)", "One wrong"] };
+ f.sections[0].slide = { title: "The chapter", points: ["It cites [the page](cite:w01)"], cite: "c01", layout: "evidence" };
+ f.sections[0].claims![0].slide = { cite: "p01", layout: "quote", points: ["Said [here](cite:c01)"] };
+ f.sections[0].claims![1].slide = { hide: true };
+ assert.deepEqual(validateReport(f), []);
+
+ const g = fixture();
+ g.slides = { title: "two\nlines", closing: " ", points: [] };
+ g.sections[0].slide = {
+ title: "x".repeat(161),
+ points: ["a", "b", "c", "d", "e", "f"],
+ cite: "zzz",
+ };
+ g.sections[0].claims![0].slide = {
+ points: ["y".repeat(141), "[never cited](cite:unknown-or-uncited)", "[](cite:)"],
+ // cited by the other claim, not this one
+ cite: "c02",
+ };
+ // A claim with no listed evidence, asked to show some.
+ g.sections[1].claims![0].slide = { layout: "evidence" };
+ assert.deepEqual(paths(validateReport(g)), [
+ "slides.title",
+ "slides.closing",
+ "slides.points",
+ "sections[0].claims[0].slide.points[0]",
+ "sections[0].claims[0].slide.points[1]",
+ "sections[0].claims[0].slide.points[2]",
+ "sections[0].claims[0].slide.cite",
+ "sections[0].slide.title",
+ "sections[0].slide.points",
+ "sections[0].slide.cite",
+ "sections[1].claims[0].slide.layout",
+ ]);
+ // A point is measured as it reads: a citation counts as its label.
+ const h = fixture();
+ h.sections[0].claims![0].slide = { points: [`${"z".repeat(130)} [label](cite:c01)`] };
+ assert.deepEqual(validateReport(h), []);
+});
+
+test("a section or claim may not take the page's own anchors", () => {
+ const f = fixture();
+ f.sections[1].id = "found";
+ f.sections[0].claims![1].id = "report-end";
+ assert.deepEqual(paths(validateReport(f)).sort(), ["sections[0].claims[1].id", "sections[1].id"]);
+});
+
+test("slide fields are shape-checked: an unknown layout or key is refused", () => {
+ const f = fixture();
+ (f.sections[0] as Record<string, unknown>).slide = { layout: "carousel", extra: 1 };
+ assert.equal(parseReport(f).ok, false);
+});
diff --git a/common/lib/report/schema.ts b/common/lib/report/schema.ts
@@ -27,6 +27,7 @@ import { z } from "zod";
import type { FieldDocs } from "../fieldDocs";
import { citationSchema, sourceSchema } from "../citations/schema";
import { VERDICTS, type Verdict } from "./verdicts";
+import { RESERVED_ANCHOR_IDS, SLIDE_LAYOUTS, SLIDE_LINE_MAX, SLIDE_POINT_MAX, SLIDE_POINTS_MAX } from "./slideRules";
export const REPORT_FORMAT = "archilyzer-report";
export const REPORT_VERSION = 1;
@@ -42,6 +43,27 @@ 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;
+// SLIDES (release 22). An article is also read as slides (lib/report/slides.ts
+// buildReportSlides): a title slide, the quick take, what the check found, one
+// slide per section and per claim, and a sources slide. With none of these
+// fields a report still gets slides, derived from its text; these make them
+// good. The limits are ./slideRules.ts's; counts, lengths and references are
+// checked by ./validate.ts.
+export const slideSchema = z.strictObject({
+ title: text.optional(),
+ points: z.array(text).optional(),
+ cite: text.optional(),
+ layout: z.enum(SLIDE_LAYOUTS).optional(),
+ hide: z.boolean().optional(),
+});
+
+export const reportSlidesSchema = z.strictObject({
+ hide: z.boolean().optional(),
+ title: text.optional(),
+ points: z.array(text).optional(),
+ closing: text.optional(),
+});
+
export const claimSchema = z.strictObject({
id: text,
title: text.optional(),
@@ -52,6 +74,7 @@ export const claimSchema = z.strictObject({
sourceQuote: z.strictObject({ citation: text }).optional(),
findings: text.optional(),
citations: z.array(text).optional(),
+ slide: slideSchema.optional(),
});
export const sectionSchema = z.strictObject({
@@ -59,6 +82,7 @@ export const sectionSchema = z.strictObject({
title: text,
body: text.optional(),
claims: z.array(claimSchema).optional(),
+ slide: slideSchema.optional(),
});
const verdictOverride = z.strictObject({ label: text.optional(), color: text.optional() });
@@ -81,6 +105,7 @@ export const reportSchema = z.strictObject({
sources: z.record(text, sourceSchema).optional(),
citations: z.record(text, citationSchema).optional(),
sections: z.array(sectionSchema),
+ slides: reportSlidesSchema.optional(),
});
export type Claim = z.infer<typeof claimSchema>;
@@ -88,6 +113,8 @@ export type Section = z.infer<typeof sectionSchema>;
export type Report = z.infer<typeof reportSchema>;
export type ReportSubject = NonNullable<Report["subject"]>;
export type ClaimSourceQuote = NonNullable<Claim["sourceQuote"]>;
+export type SlideSpec = z.infer<typeof slideSchema>;
+export type ReportSlidesSpec = z.infer<typeof reportSlidesSchema>;
// A report id is its directory name under `reports/` and a path segment of its
// page (`/reports/<id>/`): a lowercase slug, like a site id.
@@ -119,17 +146,35 @@ export const REPORT_FIELD_DOCS: FieldDocs<Report> = {
citations:
"The report's citations, by id — see [CITATIONS.md](CITATIONS.md). A citation is cited from markdown with `[label](cite:<id>)` and listed under the claims that rest on it. Absent = none.",
sections: "The report's sections, in order.",
+ slides:
+ "How the report reads as slides (its page's Slides and Overview views, `slides.html`, `slides.pdf`) — see `slides` below. Absent = slides derived from its text.",
+};
+
+export const REPORT_SLIDES_FIELD_DOCS: FieldDocs<ReportSlidesSpec> = {
+ hide: "`true` publishes no slides: the page offers no Slides or Overview view, and no `slides.html` or `slides.pdf` is exported.",
+ title: `The title slide's line under the report's title (plain text, one line, at most ${SLIDE_LINE_MAX} characters). Absent = the subtitle.`,
+ points: `The quick take's slide, as points (at most ${SLIDE_POINTS_MAX}, each one line of at most ${SLIDE_POINT_MAX} characters, the label of an inline citation counted; may cite inline a citation the report cites elsewhere). Absent = the summary's first paragraph.`,
+ closing: `The sources slide's closing line (plain text, one line, at most ${SLIDE_LINE_MAX} characters). Absent = none.`,
+};
+
+export const SLIDE_FIELD_DOCS: FieldDocs<SlideSpec> = {
+ title: `The slide's title (plain text, one line, at most ${SLIDE_LINE_MAX} characters). Absent = the section's title; the claim's title, else its text.`,
+ points: `The slide's own words, as points (at most ${SLIDE_POINTS_MAX}, each one line of at most ${SLIDE_POINT_MAX} characters, the label of an inline citation counted). May cite inline, \`[label](cite:<id>)\`, a citation the report cites elsewhere. Absent = a section's slide shows the first two sentences of its body (else its claims); a claim's shows its gist.`,
+ cite: "The evidence the slide shows (a still, a post's capture, a clip's poster, with its quote): a citation of this section (its body or its claims) or of this claim (its sentence, findings or list). Absent = a claim's first listed citation; a section shows none.",
+ layout: `How the slide is laid out: ${SLIDE_LAYOUTS.map((l) => `\`${l}\``).join(", ")}. Absent = \`points\` for a section; \`evidence\` for a claim, \`statement\` when it has no citation to show. \`evidence\` and \`quote\` need a citation.`,
+ hide: "`true` leaves this section's or claim's slide out (a section's claims keep theirs).",
};
export const SECTION_FIELD_DOCS: FieldDocs<Section> = {
- id: "The section's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids.",
+ id: `The section's id (letters, digits, \`_ . : -\`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (${RESERVED_ANCHOR_IDS.map((a) => `\`${a}\``).join(", ")}).`,
title: "The section's heading.",
body: "Markdown under the heading. May cite inline.",
claims: "The section's claims, in order. Absent = none.",
+ slide: "The section's slide — see `slide` below. Absent = derived from its text.",
};
export const CLAIM_FIELD_DOCS: FieldDocs<Claim> = {
- id: "The claim's id (letters, digits, `_ . : -`; at most 64): its anchor on the report page. Unique among the report's section and claim ids.",
+ id: `The claim's id (letters, digits, \`_ . : -\`; at most 64): its anchor on the report page. Unique among the report's section and claim ids, and none of the page's own anchors (${RESERVED_ANCHOR_IDS.map((a) => `\`${a}\``).join(", ")}).`,
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.`,
@@ -139,4 +184,5 @@ export const CLAIM_FIELD_DOCS: FieldDocs<Claim> = {
"The document's own sentence making the claim: `{ \"citation\": \"<id>\" }`, naming a `source` citation (its still is shown with the claim).",
findings: "What the evidence shows, in markdown, citing inline: `[label](cite:<id>)`.",
citations: "The citations the claim rests on, in the order they are listed under it. Each must exist; none twice.",
+ slide: "The claim's slide — see `slide` below. Absent = derived from the claim.",
};
diff --git a/common/lib/report/slideRules.ts b/common/lib/report/slideRules.ts
@@ -0,0 +1,44 @@
+// THE SLIDE RULES — the numbers and names report.json's slide fields
+// (./schema.ts) are held to, and the report page's own anchors. Pure, no
+// imports: the schema (server-only, zod), the validator, the slide builder
+// (./slides.ts) and the browser all read the one copy.
+
+// How a section's or a claim's slide is laid out (`slide.layout`).
+export const SLIDE_LAYOUTS = ["points", "evidence", "quote", "statement"] as const;
+export type SlideLayoutName = (typeof SLIDE_LAYOUTS)[number];
+
+// A slide shows at most this many points, each at most this long (its visible
+// text: an inline citation counts as its label).
+export const SLIDE_POINTS_MAX = 5;
+export const SLIDE_POINT_MAX = 140;
+
+// A slide's own title, and the deck's title and closing lines: one line, at
+// most this long.
+export const SLIDE_LINE_MAX = 160;
+
+// A section slide with no `points` shows the first sentences of its body; past
+// this many characters they no longer read as a slide (umtool's lint says so).
+export const SLIDE_DERIVED_TEXT_MAX = 240;
+
+// THE REPORT PAGE'S OWN ANCHORS — the ids the page gives its parts, beside a
+// section's and a claim's own id. A slide's anchor (./slides.ts) is one of
+// these or a section or claim id, so a section or claim may not take one
+// (./validate.ts): the page would carry two elements with one id.
+export const REPORT_PAGE_ANCHOR_IDS = {
+ // The header: the report's name, dates, the document under review.
+ head: "report-head",
+ // The quick take: the tally and the summary.
+ summary: "in-brief",
+ // What the check found.
+ found: "found",
+ // Every claim, with its evidence.
+ claims: "claims",
+ // The reference list.
+ references: "references",
+ // The downloads line.
+ downloads: "downloads",
+ // Everything after the claims: the documents quoted, the references, the downloads.
+ end: "report-end",
+} as const;
+
+export const RESERVED_ANCHOR_IDS: readonly string[] = Object.values(REPORT_PAGE_ANCHOR_IDS);
diff --git a/common/lib/report/slides.test.ts b/common/lib/report/slides.test.ts
@@ -0,0 +1,302 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import type { Report } from "./schema";
+import { REPORT_PAGE_ANCHOR_IDS } from "./slideRules";
+import {
+ buildReportSlides,
+ firstParagraph,
+ firstSentences,
+ parseReaderPlace,
+ readerPlaceUrl,
+ reportPageAnchorIds,
+ slideAnchorId,
+ slideForAnchor,
+ slideLint,
+ slidePointText,
+ type ClaimSlide,
+ type SectionSlide,
+ type SlideView,
+} from "./slides";
+import { validateReport } from "./validate";
+import { buildReportPageView, type ReportPageView } from "./views";
+
+// One fact-check, every slide kind: a subject, a summary, two sections (one
+// with a body, one without), claims with and without citations and verdicts.
+function base(): Report {
+ return {
+ format: "archilyzer-report",
+ version: 1,
+ id: "demo",
+ kind: "factcheck",
+ series: "Demo Checks",
+ title: "A demo fact-check",
+ subtitle: "What the article says, against the record",
+ summary: "The article gets one thing [right](cite:v1). It gets another wrong.\n\nA second paragraph that is never on a slide.",
+ published: "2026-10-01",
+ updated: "2026-10-04",
+ subject: { source: "s0" },
+ sources: {
+ s0: { kind: "article", title: "An article", author: "A. Writer", publisher: "Gazette", date: "2026-09-20", accent: "#8a6fb0" },
+ },
+ citations: {
+ v1: { kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "one" },
+ w1: { kind: "page", url: "https://example.org/p", quote: "two" },
+ p1: { kind: "post", channel: "demo-social", id: "123", quote: "three" },
+ a1: { kind: "source", source: "s0", quote: "the claim, as printed" },
+ },
+ sections: [
+ {
+ id: "ch1",
+ title: "Chapter one",
+ body: "The first sentence, citing [Mr. Example. Esq.](cite:w1). The second sentence! A third that is left off.",
+ claims: [
+ {
+ id: "k1",
+ title: "“Opened it himself”",
+ text: "He opened it himself.",
+ verdict: "CONTRADICTED",
+ gist: "It opened a year later.",
+ sourceQuote: { citation: "a1" },
+ findings: "See [the stream](cite:v1).",
+ citations: ["a1", "v1", "w1"],
+ },
+ { id: "k2", text: "He went back every year.", verdict: "PARTLY", flag: "No source given" },
+ ],
+ },
+ { id: "ch2", title: "Chapter two", claims: [{ id: "k3", text: "He denied it.", verdict: "CORROBORATED", citations: ["p1"] }] },
+ ],
+ };
+}
+
+function viewOf(report: Report): ReportPageView {
+ assert.deepEqual(validateReport(report), [], "the test report must be sound");
+ return buildReportPageView(report, { record: (c) => ({ channel: c.channel, id: c.id }) });
+}
+
+const keys = (slides: readonly SlideView[]) => slides.map((s) => s.key);
+const byKey = <K extends SlideView["kind"]>(slides: readonly SlideView[], key: string) =>
+ slides.find((s) => s.key === key) as Extract<SlideView, { kind: K }>;
+
+test("the deck, derived: title, in brief, found, each section and claim, sources — numbered from 1", () => {
+ const slides = buildReportSlides(viewOf(base()));
+ assert.deepEqual(keys(slides), [
+ "title",
+ "summary",
+ "found",
+ "section:ch1",
+ "claim:k1",
+ "claim:k2",
+ "section:ch2",
+ "claim:k3",
+ "sources",
+ ]);
+ assert.deepEqual(slides.map((s) => s.n), [1, 2, 3, 4, 5, 6, 7, 8, 9]);
+});
+
+// Every default, one row each: the slide, what it carries.
+const DEFAULTS: [string, (s: SlideView[]) => unknown, unknown][] = [
+ ["title: series, title, the subtitle as its line, dates, the subject", (s) => {
+ const t = byKey<"title">(s, "title");
+ return [t.layout, t.series, t.title, t.line, t.dates, t.subject];
+ }, ["title", "Demo Checks", "A demo fact-check", "What the article says, against the record", ["2026-10-01", "updated 2026-10-04"],
+ { title: "An article", meta: ["A. Writer", "Gazette", "2026-09-20"], accent: "#8a6fb0" }]],
+ ["summary: the summary's first paragraph, as a statement, with the tally", (s) => {
+ const t = byKey<"summary">(s, "summary");
+ return [t.layout, t.title, t.text, t.points, t.tally?.map((x) => `${x.verdict}:${x.count}`)];
+ }, ["statement", "In brief", "The article gets one thing [right](cite:v1). It gets another wrong.", undefined,
+ ["CORROBORATED:1", "PARTLY:1", "CONTRADICTED:1"]]],
+ ["found: the claims by verdict, contradicted first", (s) => {
+ const t = byKey<"found">(s, "found");
+ return [t.layout, t.claimCount, t.groups.map((g) => `${g.verdict}:${g.claims.map((c) => c.title).join("|")}`)];
+ }, ["found", 3, ["CONTRADICTED:“Opened it himself”", "PARTLY:He went back every year.", "CORROBORATED:He denied it."]]],
+ ["section: the body's first two sentences, a cite's label never split", (s) => {
+ const t = byKey<"section">(s, "section:ch1");
+ return [t.layout, t.title, t.index, t.count, t.text, t.points, t.cite, t.claims.map((c) => c.id)];
+ }, ["points", "Chapter one", 1, 2, "The first sentence, citing [Mr. Example. Esq.](cite:w1). The second sentence!", undefined, undefined, ["k1", "k2"]]],
+ ["section with no body: its claims, no text", (s) => {
+ const t = byKey<"section">(s, "section:ch2");
+ return [t.layout, t.text, t.claims];
+ }, ["points", undefined, [{ id: "k3", title: "He denied it.", verdict: "CORROBORATED" }]]],
+ ["claim: its title, its text under it, the first listed citation that is not its sentence", (s) => {
+ const t = byKey<"claim">(s, "claim:k1");
+ return [t.layout, t.title, t.text, t.verdict, t.gist, t.cite, t.sourceQuote, t.section];
+ }, ["evidence", "“Opened it himself”", "He opened it himself.", "CONTRADICTED", "It opened a year later.", "v1", "a1",
+ { id: "ch1", title: "Chapter one" }]],
+ ["claim with no citation: a statement, its text the title", (s) => {
+ const t = byKey<"claim">(s, "claim:k2");
+ return [t.layout, t.title, t.text, t.flag, t.cite];
+ }, ["statement", "He went back every year.", undefined, "No source given", undefined]],
+ ["sources: the references and documents counted, no closing", (s) => {
+ const t = byKey<"sources">(s, "sources");
+ return [t.layout, t.title, t.references, t.documents, t.closing];
+ }, ["sources", "Sources", 4, 1, undefined]],
+];
+
+for (const [name, pick, want] of DEFAULTS) {
+ test(`default — ${name}`, () => {
+ assert.deepEqual(pick(buildReportSlides(viewOf(base()))), want);
+ });
+}
+
+// Every override, one row each: the report as changed, what the slide carries.
+const OVERRIDES: [string, (r: Report) => void, (s: SlideView[]) => unknown, unknown][] = [
+ ["slides.title replaces the subtitle as the title slide's line", (r) => {
+ r.slides = { title: "One article, checked" };
+ }, (s) => byKey<"title">(s, "title").line, "One article, checked"],
+ ["slides.points make In brief points", (r) => {
+ r.slides = { points: ["One [right](cite:v1)", "One wrong"] };
+ }, (s) => {
+ const t = byKey<"summary">(s, "summary");
+ return [t.layout, t.points, t.text];
+ }, ["points", ["One [right](cite:v1)", "One wrong"], undefined]],
+ ["slides.closing on the sources slide", (r) => {
+ r.slides = { closing: "Read it all on the site." };
+ }, (s) => byKey<"sources">(s, "sources").closing, "Read it all on the site."],
+ ["section slide: title, points (no derived text), cite and layout", (r) => {
+ r.sections[0].slide = { title: "The bridge", points: ["It opened in 2019"], cite: "v1", layout: "evidence" };
+ }, (s) => {
+ const t = byKey<"section">(s, "section:ch1");
+ return [t.title, t.points, t.text, t.cite, t.layout];
+ }, ["The bridge", ["It opened in 2019"], undefined, "v1", "evidence"]],
+ ["claim slide: cite picks the evidence, layout quote", (r) => {
+ r.sections[0].claims![0].slide = { cite: "w1", layout: "quote" };
+ }, (s) => {
+ const t = byKey<"claim">(s, "claim:k1");
+ return [t.cite, t.layout];
+ }, ["w1", "quote"]],
+ ["claim slide: points and a title; the text stays when the title is not it", (r) => {
+ r.sections[0].claims![1].slide = { title: "Every year?", points: ["Once is on record"] };
+ }, (s) => {
+ const t = byKey<"claim">(s, "claim:k2");
+ return [t.title, t.text, t.points, t.layout];
+ }, ["Every year?", "He went back every year.", ["Once is on record"], "statement"]],
+ ["claim slide: layout points over the derived evidence", (r) => {
+ r.sections[0].claims![0].slide = { layout: "points", points: ["It opened in 2019"] };
+ }, (s) => byKey<"claim">(s, "claim:k1").layout, "points"],
+];
+
+for (const [name, change, pick, want] of OVERRIDES) {
+ test(`override — ${name}`, () => {
+ const r = base();
+ change(r);
+ assert.deepEqual(pick(buildReportSlides(viewOf(r))), want);
+ });
+}
+
+test("hide: a section's slide goes, its claims keep theirs; a claim's goes; slides.hide drops the deck", () => {
+ const r = base();
+ r.sections[0].slide = { hide: true };
+ r.sections[1].claims![0].slide = { hide: true };
+ assert.deepEqual(keys(buildReportSlides(viewOf(r))), ["title", "summary", "found", "claim:k1", "claim:k2", "section:ch2", "sources"]);
+ r.slides = { hide: true };
+ assert.deepEqual(buildReportSlides(viewOf(r)), []);
+});
+
+test("a sweep: no tally, no found slide; with no summary, no In brief", () => {
+ const r = base();
+ r.kind = "sweep";
+ for (const s of r.sections) for (const c of s.claims ?? []) delete c.verdict;
+ let slides = buildReportSlides(viewOf(r));
+ assert.ok(!keys(slides).includes("found"));
+ assert.equal(byKey<"summary">(slides, "summary").tally, undefined);
+ delete r.summary;
+ slides = buildReportSlides(viewOf(r));
+ assert.deepEqual(keys(slides).slice(0, 2), ["title", "section:ch1"]);
+});
+
+test("an evidence layout with nothing to show falls back: a claim to statement, a section to points", () => {
+ const view = viewOf(base());
+ // A view is read leniently in umtool: a layout the validator would refuse still builds.
+ view.sections[0].slide = { layout: "quote" };
+ view.sections[0].claims[1].slide = { layout: "evidence" };
+ const slides = buildReportSlides(view);
+ assert.equal(byKey<"section">(slides, "section:ch1").layout, "points");
+ assert.equal(byKey<"claim">(slides, "claim:k2").layout, "statement");
+});
+
+test("anchors: unique, each an id the article's page carries, in the page's order", () => {
+ const view = viewOf(base());
+ const slides = buildReportSlides(view);
+ const ids = slides.map((s) => s.anchorId);
+ assert.equal(new Set(ids).size, ids.length);
+ assert.equal(new Set(keys(slides)).size, slides.length);
+ const page = reportPageAnchorIds(view);
+ for (const s of slides) {
+ assert.ok(page.includes(s.anchorId), `${s.key} → #${s.anchorId}`);
+ assert.equal(slideAnchorId(s.anchor), s.anchorId);
+ }
+ const at = ids.map((id) => page.indexOf(id));
+ assert.deepEqual(at, [...at].sort((a, b) => a - b));
+ assert.deepEqual(
+ [ids[0], ids[1], ids[2], ids[ids.length - 1]],
+ [REPORT_PAGE_ANCHOR_IDS.head, REPORT_PAGE_ANCHOR_IDS.summary, REPORT_PAGE_ANCHOR_IDS.found, REPORT_PAGE_ANCHOR_IDS.end],
+ );
+});
+
+test("slideForAnchor: the slide anchored there, else the last before it, else the first", () => {
+ const r = base();
+ r.sections[0].claims![1].slide = { hide: true };
+ const view = viewOf(r);
+ const slides = buildReportSlides(view);
+ const order = reportPageAnchorIds(view);
+ assert.equal(slideForAnchor(slides, "k1", order)?.key, "claim:k1");
+ // k2's slide is hidden: the slide before it in the article.
+ assert.equal(slideForAnchor(slides, "k2", order)?.key, "claim:k1");
+ // `claims` (the tier heading) has no slide: the found slide is before it.
+ assert.equal(slideForAnchor(slides, REPORT_PAGE_ANCHOR_IDS.claims, order)?.key, "found");
+ assert.equal(slideForAnchor(slides, null, order)?.key, "title");
+ assert.equal(slideForAnchor(slides, "nowhere", order)?.key, "title");
+ assert.equal(slideForAnchor([], "k1", order), undefined);
+});
+
+test("text helpers: a point's visible text, the first paragraph, the first sentences", () => {
+ assert.equal(slidePointText("He said so [on stream](cite:c01), twice."), "He said so on stream, twice.");
+ assert.equal(firstParagraph("## A heading\n\n- one\n- two\n\nlater"), "A heading");
+ assert.equal(firstParagraph("> quoted\n> on two lines\n\nnext"), "quoted on two lines");
+ assert.equal(firstParagraph("\n\n"), undefined);
+ assert.equal(firstSentences("One. Two? Three!", 2), "One. Two?");
+ assert.equal(firstSentences("Only one sentence", 2), "Only one sentence");
+ assert.equal(firstSentences("He said “no.” Then left. Later.", 2), "He said “no.” Then left.");
+});
+
+test("lint: long derived text, too many or too long points, a long claim as a title", () => {
+ const view = viewOf(base());
+ assert.deepEqual(slideLint(view), []);
+ const long = "word ".repeat(60).trim();
+ view.sections[1].body = `${long}. ${long}.`;
+ view.sections[0].slide = { points: ["a", "b", "c", "d", "e", "f", "x".repeat(150)] };
+ view.sections[0].claims[1].text = "y".repeat(200);
+ const lint = slideLint(view).map((l) => `${l.key}: ${l.message.split(";")[0].split(":")[0]}`);
+ assert.deepEqual(lint, [
+ "section:ch1: 7 points",
+ "section:ch1: point 7 is 150 characters",
+ "claim:k2: its slide's title is 200 characters (over 160)",
+ "section:ch2: no slide points",
+ ]);
+});
+
+test("the reader's place in the URL: the view, the slide, the anchor; the default stays off", () => {
+ assert.deepEqual(parseReaderPlace("", ""), { view: "article", slide: null, anchor: null });
+ assert.deepEqual(parseReaderPlace("?rv=slides", "#s-4"), { view: "slides", slide: 4, anchor: null });
+ assert.deepEqual(parseReaderPlace("?rv=slides", "#k1"), { view: "slides", slide: null, anchor: "k1" });
+ assert.deepEqual(parseReaderPlace("?rv=iso", "#ch2"), { view: "iso", slide: null, anchor: "ch2" });
+ assert.deepEqual(parseReaderPlace("?rv=other", "#claim-1"), { view: "article", slide: null, anchor: "claim-1" });
+ // An article's anchor that looks like a slide is still an anchor there.
+ assert.deepEqual(parseReaderPlace("", "#s-4"), { view: "article", slide: null, anchor: "s-4" });
+
+ assert.equal(readerPlaceUrl("/reports/demo/", "", { view: "slides", slide: 3 }), "/reports/demo/?rv=slides#s-3");
+ assert.equal(readerPlaceUrl("/reports/demo/", "?rv=slides&x=1", { view: "article", anchor: "k1" }), "/reports/demo/?x=1#k1");
+ assert.equal(readerPlaceUrl("/r/", "?rv=slides", { view: "iso", anchor: "ch1" }), "/r/?rv=iso#ch1");
+ assert.equal(readerPlaceUrl("/r/", "", { view: "article" }), "/r/");
+ const round = parseReaderPlace(...(["?rv=slides", "#s-7"] as const));
+ assert.equal(readerPlaceUrl("/r/", "", round), "/r/?rv=slides#s-7");
+});
+
+test("a section slide's claims list keeps a hidden claim (it is still in the section)", () => {
+ const r = base();
+ r.sections[0].claims![1].slide = { hide: true };
+ const s = byKey<"section">(buildReportSlides(viewOf(r)), "section:ch1") as SectionSlide;
+ assert.deepEqual(s.claims.map((c) => c.id), ["k1", "k2"]);
+ const c = byKey<"claim">(buildReportSlides(viewOf(r)), "claim:k1") as ClaimSlide;
+ assert.equal(c.n, 5);
+});
diff --git a/common/lib/report/slides.ts b/common/lib/report/slides.ts
@@ -0,0 +1,454 @@
+// ONE ARTICLE, TWO SHAPES — a report's page view (./views.ts) as slides.
+//
+// `buildReportSlides(view)` is the one way a report becomes slides: the export
+// site's Slides and Overview views, umtool's, `slides.html` and `slides.pdf`
+// all render what it returns. In order:
+//
+// title the series, title, the title line (report.json `slides.title`,
+// else the subtitle), the dates, the document under review
+// summary "In brief": `slides.points`, else the summary's first paragraph
+// (and a fact-check's tally)
+// found "What the check found": the claims by verdict — a fact-check only
+// section one per section: its `slide.points`, else the first two sentences
+// of its body, else its claims
+// claim one per claim: its title (else its text), its verdict, its gist
+// (or `slide.points`) and the evidence of its `slide.cite`, else of
+// its first listed citation; a claim with none is a statement
+// sources how many references and documents, `slides.closing`
+//
+// A `hide` drops a slide (a section's claims keep theirs); `slides.hide` drops
+// them all. Long markdown never lands on a slide: a slide shows points, a gist,
+// one quote.
+//
+// EVERY SLIDE HAS AN ANCHOR: the place in the article it stands for, which is
+// also an element id on the report page (REPORT_PAGE_ANCHOR_IDS, a section's or
+// a claim's own id). It is the one mapping the view switch (keep the place),
+// the overview (pair a slide with its block) and umtool's notes share. The
+// slides are in the article's reading order, so the slide for a place in the
+// article is the last one whose anchor is at or above it.
+//
+// Pure, no imports but types and pure helpers: the browser renders from it.
+
+import type { Verdict } from "./verdicts";
+import {
+ REPORT_PAGE_ANCHOR_IDS,
+ SLIDE_DERIVED_TEXT_MAX,
+ SLIDE_LINE_MAX,
+ SLIDE_POINT_MAX,
+ SLIDE_POINTS_MAX,
+ type SlideLayoutName,
+} from "./slideRules";
+import {
+ foundGroups,
+ orderedCitations,
+ reportDateParts,
+ subjectMetaParts,
+ verdictTally,
+ type ClaimView,
+ type ReportPageView,
+ type SectionView,
+ type VerdictCount,
+} from "./views";
+
+export type SlideAnchorKind = "head" | "summary" | "found" | "section" | "claim" | "sources";
+
+// A place in the article: its kind, and the section's or claim's id.
+export type SlideAnchor = { kind: SlideAnchorKind; id?: string };
+
+type SlideBase = {
+ // The slide's place in the deck, from 1: its hash is `#s-<n>`.
+ n: number;
+ // Unique in the deck: `title`, `summary`, `found`, `section:<id>`,
+ // `claim:<id>`, `sources`.
+ key: string;
+ anchor: SlideAnchor;
+ // The anchor's element id on the report page (`#<anchorId>`).
+ anchorId: string;
+ title: string;
+};
+
+export type TitleSlide = SlideBase & {
+ kind: "title";
+ layout: "title";
+ series?: string;
+ // The line under the title: `slides.title`, else the subtitle.
+ line?: string;
+ dates: string[];
+ // The document under review: its title, "author · publisher · date", colour.
+ subject?: { title: string; meta: string[]; accent?: string };
+};
+
+export type SummarySlide = SlideBase & {
+ kind: "summary";
+ layout: "statement" | "points";
+ // The summary's first paragraph (markdown, citing inline).
+ text?: string;
+ points?: string[];
+ // A fact-check's tally.
+ tally?: VerdictCount[];
+};
+
+export type FoundSlide = SlideBase & {
+ kind: "found";
+ layout: "found";
+ claimCount: number;
+ groups: { verdict: Verdict; claims: { id: string; title: string }[] }[];
+};
+
+export type SlideClaimRef = { id: string; title: string; verdict?: Verdict };
+
+export type SectionSlide = SlideBase & {
+ kind: "section";
+ layout: SlideLayoutName;
+ // Its place among the sections ("2 of 5").
+ index: number;
+ count: number;
+ points?: string[];
+ // The body's first two sentences (markdown, citing inline), with no points.
+ text?: string;
+ claims: SlideClaimRef[];
+ // The citation the slide shows (`slide.cite`).
+ cite?: string;
+};
+
+export type ClaimSlide = SlideBase & {
+ kind: "claim";
+ layout: SlideLayoutName;
+ section: { id: string; title: string };
+ // The claim as the document states it, when the title is not already it.
+ text?: string;
+ verdict?: Verdict;
+ gist?: string;
+ flag?: string;
+ points?: string[];
+ // The citation the slide shows: `slide.cite`, else the first listed.
+ cite?: string;
+ // The document's own sentence (a `source` citation).
+ sourceQuote?: string;
+};
+
+export type SourcesSlide = SlideBase & {
+ kind: "sources";
+ layout: "sources";
+ references: number;
+ documents: number;
+ closing?: string;
+};
+
+export type SlideView = TitleSlide | SummarySlide | FoundSlide | SectionSlide | ClaimSlide | SourcesSlide;
+
+// A slide before the deck numbers it.
+type Unnumbered = SlideView extends infer S ? (S extends SlideView ? Omit<S, "n"> : never) : never;
+
+// ─── Text helpers ───
+
+const CITE_LINK_RE = /\[((?:[^[\]]|\[[^[\]]*\])*)\]\(\s*cite:([^)\s]*)\s*\)/g;
+
+// A point's text as a reader sees it: each inline citation as its label.
+export function slidePointText(point: string): string {
+ return point.replace(CITE_LINK_RE, (_m, label: string) => label).trim();
+}
+
+// The markdown's first paragraph: up to the first blank line, a heading's
+// marks, a list's or a quote's markers dropped, its lines joined.
+export function firstParagraph(md: string | undefined): string | undefined {
+ if (!md) return undefined;
+ for (const para of md.split(/\n\s*\n/)) {
+ const lines = para
+ .split("\n")
+ .map((l) => l.replace(/^\s{0,3}(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/, "").trim())
+ .filter((l) => l && !/^(?:```|~~~|---+|\*\*\*+)$/.test(l));
+ const text = lines.join(" ").trim();
+ if (text) return text;
+ }
+ return undefined;
+}
+
+// The first `n` sentences of the markdown's first paragraph. A sentence ends
+// at `.`, `!`, `?` or `…` (and a closing quote or bracket) before a space; a
+// citation's label is never split, whatever punctuation it holds.
+export function firstSentences(md: string | undefined, n: number): string | undefined {
+ const para = firstParagraph(md);
+ if (!para) return undefined;
+ // Blank each citation link so its label's punctuation ends nothing.
+ const masked = para.replace(CITE_LINK_RE, (m) => "x".repeat(m.length));
+ const ends: number[] = [];
+ const re = /[.!?…]+["”’)\]]*(?=\s+\S)/g;
+ for (let m = re.exec(masked); m; m = re.exec(masked)) {
+ ends.push(m.index + m[0].length);
+ if (ends.length === n) break;
+ }
+ return (ends.length === n ? para.slice(0, ends[n - 1]) : para).trim();
+}
+
+// ─── The anchors ───
+
+export function slideAnchorId(anchor: SlideAnchor): string {
+ switch (anchor.kind) {
+ case "head":
+ return REPORT_PAGE_ANCHOR_IDS.head;
+ case "summary":
+ return REPORT_PAGE_ANCHOR_IDS.summary;
+ case "found":
+ return REPORT_PAGE_ANCHOR_IDS.found;
+ case "sources":
+ return REPORT_PAGE_ANCHOR_IDS.end;
+ case "section":
+ case "claim":
+ return anchor.id ?? "";
+ }
+}
+
+// Every element id the report page gives a place (export ReportArticle): its
+// own parts, each section and each claim — those a page of this view
+// carries, in document order.
+export function reportPageAnchorIds(view: ReportPageView): string[] {
+ const ids: string[] = [REPORT_PAGE_ANCHOR_IDS.head, REPORT_PAGE_ANCHOR_IDS.summary];
+ if (view.kind === "factcheck" && foundGroups(view).length > 0) ids.push(REPORT_PAGE_ANCHOR_IDS.found);
+ ids.push(REPORT_PAGE_ANCHOR_IDS.claims);
+ for (const s of view.sections) {
+ ids.push(s.id);
+ for (const c of s.claims) ids.push(c.id);
+ }
+ ids.push(REPORT_PAGE_ANCHOR_IDS.end);
+ if (orderedCitations(view).length > 0) ids.push(REPORT_PAGE_ANCHOR_IDS.references);
+ if (view.downloads && Object.keys(view.downloads).length > 0) ids.push(REPORT_PAGE_ANCHOR_IDS.downloads);
+ return ids;
+}
+
+// ─── The builder ───
+
+// Whether the report has slides at all (`slides.hide` says no).
+export function hasSlides(view: Pick<ReportPageView, "slides">): boolean {
+ return view.slides?.hide !== true;
+}
+
+const claimTitle = (c: ClaimView) => c.title ?? c.text;
+
+// The citation a claim's slide shows: its `slide.cite`, else the first it
+// lists that is not its own sentence.
+function claimCite(c: ClaimView): string | undefined {
+ return c.slide?.cite ?? c.citations.find((id) => id !== c.sourceQuote);
+}
+
+function sectionSlide(s: SectionView, index: number, count: number): Omit<SectionSlide, "n"> {
+ const spec = s.slide;
+ const points = spec?.points && spec.points.length > 0 ? spec.points : undefined;
+ const cite = spec?.cite;
+ let layout: SlideLayoutName = spec?.layout ?? "points";
+ if ((layout === "evidence" || layout === "quote") && !cite) layout = "points";
+ return {
+ kind: "section",
+ key: `section:${s.id}`,
+ anchor: { kind: "section", id: s.id },
+ anchorId: s.id,
+ title: spec?.title ?? s.title,
+ layout,
+ index,
+ count,
+ ...(points ? { points } : {}),
+ ...(!points && s.body ? { text: firstSentences(s.body, 2) } : {}),
+ claims: s.claims.map((c) => ({ id: c.id, title: claimTitle(c), ...(c.verdict ? { verdict: c.verdict } : {}) })),
+ ...(cite ? { cite } : {}),
+ };
+}
+
+function claimSlide(c: ClaimView, s: SectionView): Omit<ClaimSlide, "n"> {
+ const spec = c.slide;
+ const points = spec?.points && spec.points.length > 0 ? spec.points : undefined;
+ const cite = claimCite(c);
+ let layout: SlideLayoutName = spec?.layout ?? (cite ? "evidence" : "statement");
+ if ((layout === "evidence" || layout === "quote") && !cite) layout = "statement";
+ const title = spec?.title ?? claimTitle(c);
+ return {
+ kind: "claim",
+ key: `claim:${c.id}`,
+ anchor: { kind: "claim", id: c.id },
+ anchorId: c.id,
+ title,
+ layout,
+ section: { id: s.id, title: s.title },
+ ...(title !== c.text ? { text: c.text } : {}),
+ ...(c.verdict ? { verdict: c.verdict } : {}),
+ ...(c.gist ? { gist: c.gist } : {}),
+ ...(c.flag ? { flag: c.flag } : {}),
+ ...(points ? { points } : {}),
+ ...(cite ? { cite } : {}),
+ ...(c.sourceQuote ? { sourceQuote: c.sourceQuote } : {}),
+ };
+}
+
+export function buildReportSlides(view: ReportPageView): SlideView[] {
+ if (!hasSlides(view)) return [];
+ const deck = view.slides;
+ const out: Unnumbered[] = [];
+
+ const subject = view.subject ? view.sources[view.subject] : undefined;
+ const line = deck?.title ?? view.subtitle;
+ out.push({
+ kind: "title",
+ key: "title",
+ anchor: { kind: "head" },
+ anchorId: REPORT_PAGE_ANCHOR_IDS.head,
+ title: view.title,
+ layout: "title",
+ ...(view.series ? { series: view.series } : {}),
+ ...(line ? { line } : {}),
+ dates: reportDateParts(view),
+ ...(subject
+ ? { subject: { title: subject.title, meta: subjectMetaParts(subject), ...(subject.accent ? { accent: subject.accent } : {}) } }
+ : {}),
+ } satisfies Omit<TitleSlide, "n">);
+
+ const isFactcheck = view.kind === "factcheck";
+ const tally = isFactcheck ? verdictTally(view) : [];
+ const points = deck?.points && deck.points.length > 0 ? deck.points : undefined;
+ const text = points ? undefined : firstParagraph(view.summary);
+ if (points || text || tally.length > 0) {
+ out.push({
+ kind: "summary",
+ key: "summary",
+ anchor: { kind: "summary" },
+ anchorId: REPORT_PAGE_ANCHOR_IDS.summary,
+ title: "In brief",
+ layout: points ? "points" : "statement",
+ ...(points ? { points } : {}),
+ ...(text ? { text } : {}),
+ ...(tally.length > 0 ? { tally } : {}),
+ } satisfies Omit<SummarySlide, "n">);
+ }
+
+ const groups = isFactcheck ? foundGroups(view) : [];
+ if (groups.length > 0) {
+ out.push({
+ kind: "found",
+ key: "found",
+ anchor: { kind: "found" },
+ anchorId: REPORT_PAGE_ANCHOR_IDS.found,
+ title: "What the check found",
+ layout: "found",
+ claimCount: groups.reduce((n, g) => n + g.claims.length, 0),
+ groups: groups.map((g) => ({ verdict: g.verdict, claims: g.claims.map((c) => ({ id: c.id, title: claimTitle(c) })) })),
+ } satisfies Omit<FoundSlide, "n">);
+ }
+
+ view.sections.forEach((s, i) => {
+ if (s.slide?.hide !== true) out.push(sectionSlide(s, i + 1, view.sections.length));
+ for (const c of s.claims) if (c.slide?.hide !== true) out.push(claimSlide(c, s));
+ });
+
+ out.push({
+ kind: "sources",
+ key: "sources",
+ anchor: { kind: "sources" },
+ anchorId: REPORT_PAGE_ANCHOR_IDS.end,
+ title: "Sources",
+ layout: "sources",
+ references: orderedCitations(view).length,
+ documents: Object.keys(view.sources).length,
+ ...(deck?.closing ? { closing: deck.closing } : {}),
+ } satisfies Omit<SourcesSlide, "n">);
+
+ return out.map((s, i) => ({ ...s, n: i + 1 }) as SlideView);
+}
+
+// The slide that stands for a place in the article: the one anchored there,
+// else (a hidden slide's place) the last one anchored before it in the
+// article's order `order` (reportPageAnchorIds); the first slide when none is.
+export function slideForAnchor(slides: readonly SlideView[], anchorId: string | null | undefined, order?: readonly string[]): SlideView | undefined {
+ if (slides.length === 0) return undefined;
+ if (!anchorId) return slides[0];
+ const own = slides.find((s) => s.anchorId === anchorId);
+ if (own) return own;
+ if (!order) return slides[0];
+ const at = order.indexOf(anchorId);
+ if (at < 0) return slides[0];
+ const before = new Set(order.slice(0, at));
+ let best: SlideView | undefined;
+ for (const s of slides) if (before.has(s.anchorId)) best = s;
+ return best ?? slides[0];
+}
+
+// ─── Lint (umtool's source tab) ───
+
+export type SlideLintItem = { key: string; anchorId: string; message: string };
+
+// What would make a slide read badly, before it is published: derived text
+// that runs long (give the slide its own points), a title that is a long
+// claim, too many or too long points. A draft read leniently can carry what
+// the validator refuses; this says it in a slide's terms.
+export function slideLint(view: ReportPageView): SlideLintItem[] {
+ const out: SlideLintItem[] = [];
+ const pointsLint = (key: string, anchorId: string, points: readonly string[] | undefined) => {
+ if (!points) return;
+ if (points.length > SLIDE_POINTS_MAX) out.push({ key, anchorId, message: `${points.length} points; a slide shows at most ${SLIDE_POINTS_MAX}` });
+ points.forEach((p, i) => {
+ const n = slidePointText(p).length;
+ if (n > SLIDE_POINT_MAX) out.push({ key, anchorId, message: `point ${i + 1} is ${n} characters; a point is at most ${SLIDE_POINT_MAX}` });
+ });
+ };
+ pointsLint("summary", REPORT_PAGE_ANCHOR_IDS.summary, view.slides?.points);
+ for (const slide of buildReportSlides(view)) {
+ if (slide.kind === "section") {
+ pointsLint(slide.key, slide.anchorId, slide.points);
+ if (!slide.points && slide.text) {
+ const n = slidePointText(slide.text).length;
+ if (n > SLIDE_DERIVED_TEXT_MAX) {
+ out.push({
+ key: slide.key,
+ anchorId: slide.anchorId,
+ message: `no slide points: its slide shows the body's first two sentences, ${n} characters (over ${SLIDE_DERIVED_TEXT_MAX}); give it \`slide.points\``,
+ });
+ }
+ }
+ } else if (slide.kind === "claim") {
+ pointsLint(slide.key, slide.anchorId, slide.points);
+ if (slide.title.length > SLIDE_LINE_MAX) {
+ out.push({
+ key: slide.key,
+ anchorId: slide.anchorId,
+ message: `its slide's title is ${slide.title.length} characters (over ${SLIDE_LINE_MAX}); give the claim a \`title\` or \`slide.title\``,
+ });
+ }
+ }
+ }
+ return out;
+}
+
+// ─── The reader's place, in the URL ───
+//
+// `?rv=slides` or `?rv=iso` (absent = the article — the default stays off the
+// URL, as the transcript's `vm`), and the hash: in the slides `#s-<n>`, the
+// slide; in the article and the overview, an anchor id. A shared link opens
+// the same view at the same place.
+
+export const READER_VIEWS = ["article", "slides", "iso"] as const;
+export type ReaderView = (typeof READER_VIEWS)[number];
+export const READER_VIEW_PARAM = "rv";
+
+export type ReaderPlace = { view: ReaderView; slide: number | null; anchor: string | null };
+
+export function parseReaderPlace(search: string, hash: string): ReaderPlace {
+ const rv = new URLSearchParams(search).get(READER_VIEW_PARAM);
+ const view: ReaderView = rv === "slides" ? "slides" : rv === "iso" ? "iso" : "article";
+ let h = hash.startsWith("#") ? hash.slice(1) : hash;
+ try {
+ h = decodeURIComponent(h);
+ } catch {
+ // a malformed escape: the hash as written
+ }
+ const m = /^s-(\d+)$/.exec(h);
+ if (view === "slides") return { view, slide: m ? Number(m[1]) : null, anchor: m || !h ? null : h };
+ return { view, slide: null, anchor: h || null };
+}
+
+// The URL (path, query, hash) of a place, keeping every other query param.
+export function readerPlaceUrl(pathname: string, search: string, place: { view: ReaderView; slide?: number | null; anchor?: string | null }): string {
+ const params = new URLSearchParams(search);
+ if (place.view === "article") params.delete(READER_VIEW_PARAM);
+ else params.set(READER_VIEW_PARAM, place.view);
+ const qs = params.toString();
+ const hash =
+ place.view === "slides" && place.slide != null ? `#s-${place.slide}` : place.anchor ? `#${place.anchor}` : "";
+ return `${pathname}${qs ? `?${qs}` : ""}${hash}`;
+}
diff --git a/common/lib/report/validate.ts b/common/lib/report/validate.ts
@@ -9,7 +9,9 @@
// - the dates are dates, `updated` not before `published`;
// - the verdict overrides follow the shared rule (./verdicts.mjs);
// - section and claim ids are reference ids, unique together (they are the
-// report page's anchors);
+// report page's anchors) and none of the page's own (./slideRules.ts);
+// - the slide fields are in bounds and cite what the article cites
+// (slideProblems, below);
// - a sweep's claims carry no verdict;
// - every citation and source is sound (lib/citations/validate.ts: ids,
// spans, safe paths, URLs, verification);
@@ -34,7 +36,9 @@ import {
} from "../citations/validate";
import { extractCiteRefs } from "../citations/inline";
import { isRefId } from "../citations/schema";
-import { reportSchema, isReportId, type Report } from "./schema";
+import { reportSchema, isReportId, type Claim, type Report, type SlideSpec } from "./schema";
+import { RESERVED_ANCHOR_IDS, SLIDE_LINE_MAX, SLIDE_POINT_MAX, SLIDE_POINTS_MAX } from "./slideRules";
+import { slidePointText } from "./slides";
import { reportCitationUses } from "./uses";
import { verdictOverrideProblems } from "./verdicts";
@@ -131,7 +135,9 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[]
}
const first = anchors.get(id);
if (first) out.push(problem([...at, "id"], `${JSON.stringify(id)} is already the id of ${first}`));
- else anchors.set(id, jsonPath(at));
+ else if (RESERVED_ANCHOR_IDS.includes(id)) {
+ out.push(problem([...at, "id"], `${JSON.stringify(id)} is the report page's own anchor (${RESERVED_ANCHOR_IDS.join(", ")}); pick another`));
+ } else anchors.set(id, jsonPath(at));
};
report.sections.forEach((section, si) => {
const sp: PathSegment[] = ["sections", si];
@@ -160,6 +166,8 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[]
});
});
+ out.push(...slideProblems(report));
+
for (const use of reportCitationUses(report)) {
const id = use.citationId;
if (use.field === "citations" || use.field === "sourceQuote") {
@@ -178,6 +186,99 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[]
return out;
}
+// ─── Slides ───
+//
+// The slide fields (./schema.ts `slides`, a section's or a claim's `slide`):
+// lines are one line and not too long, points are few and short, a `cite`
+// names a citation of its own section or claim, a point's inline citation
+// names one the report cites elsewhere (a slide shows the article's evidence;
+// it never brings its own, which would have no number), and a layout that
+// shows evidence has some to show.
+
+function slideLineProblems(v: string | undefined, at: PathSegment[]): Problem[] {
+ if (v === undefined) return [];
+ if (blank(v)) return [problem(at, "must not be blank")];
+ if (/[\r\n]/.test(v)) return [problem(at, "must be one line")];
+ if (v.length > SLIDE_LINE_MAX) return [problem(at, `is ${v.length} characters; a slide's line is at most ${SLIDE_LINE_MAX}`)];
+ return [];
+}
+
+function slidePointsProblems(points: readonly string[] | undefined, at: PathSegment[], cited: ReadonlySet<string>): Problem[] {
+ if (points === undefined) return [];
+ const out: Problem[] = [];
+ if (points.length === 0) out.push(problem(at, "must hold at least one point (leave it out to derive the slide)"));
+ if (points.length > SLIDE_POINTS_MAX) out.push(problem(at, `holds ${points.length} points; a slide shows at most ${SLIDE_POINTS_MAX}`));
+ points.forEach((p, i) => {
+ const pp = [...at, i];
+ if (blank(p)) return void out.push(problem(pp, "must not be blank"));
+ if (/[\r\n]/.test(p)) out.push(problem(pp, "must be one line"));
+ const visible = slidePointText(p);
+ if (visible.length > SLIDE_POINT_MAX) {
+ out.push(problem(pp, `is ${visible.length} characters; a point is at most ${SLIDE_POINT_MAX}`));
+ }
+ for (const ref of extractCiteRefs(p)) {
+ if (ref.id === "") out.push(problem(pp, `the link [${ref.label}](cite:) names no citation`));
+ else if (!cited.has(ref.id)) {
+ out.push(problem(pp, `the link [${ref.label}](cite:${ref.id}) names a citation the report does not cite elsewhere (a slide cites what the article cites)`));
+ }
+ }
+ });
+ return out;
+}
+
+function slideSpecProblems(
+ spec: SlideSpec | undefined,
+ at: PathSegment[],
+ own: ReadonlySet<string>,
+ cited: ReadonlySet<string>,
+ what: "section" | "claim",
+ hasDefaultEvidence: boolean,
+): Problem[] {
+ if (!spec) return [];
+ const out: Problem[] = [];
+ out.push(...slideLineProblems(spec.title, [...at, "title"]));
+ out.push(...slidePointsProblems(spec.points, [...at, "points"], cited));
+ if (spec.cite !== undefined && !own.has(spec.cite)) {
+ out.push(problem([...at, "cite"], `names ${JSON.stringify(spec.cite)}, which this ${what} does not cite`));
+ }
+ if ((spec.layout === "evidence" || spec.layout === "quote") && spec.cite === undefined && !hasDefaultEvidence) {
+ out.push(problem([...at, "layout"], `"${spec.layout}" shows a citation, and this ${what}'s slide has none: name one in \`cite\``));
+ }
+ return out;
+}
+
+// The citations a claim of the document cites: its sentence, its findings'
+// inline citations and its list.
+function claimCiteIds(claim: Claim): Set<string> {
+ const ids = new Set<string>();
+ if (claim.sourceQuote) ids.add(claim.sourceQuote.citation);
+ for (const r of extractCiteRefs(claim.findings)) ids.add(r.id);
+ for (const id of claim.citations ?? []) ids.add(id);
+ return ids;
+}
+
+function slideProblems(report: Report): Problem[] {
+ const out: Problem[] = [];
+ const cited = new Set(reportCitationUses(report).map((u) => u.citationId));
+ if (report.slides) {
+ out.push(...slideLineProblems(report.slides.title, ["slides", "title"]));
+ out.push(...slideLineProblems(report.slides.closing, ["slides", "closing"]));
+ out.push(...slidePointsProblems(report.slides.points, ["slides", "points"], cited));
+ }
+ report.sections.forEach((section, si) => {
+ const sp: PathSegment[] = ["sections", si];
+ const sectionIds = new Set(extractCiteRefs(section.body).map((r) => r.id));
+ (section.claims ?? []).forEach((claim, ci) => {
+ const own = claimCiteIds(claim);
+ for (const id of own) sectionIds.add(id);
+ const evidence = (claim.citations ?? []).some((id) => id !== claim.sourceQuote?.citation);
+ out.push(...slideSpecProblems(claim.slide, [...sp, "claims", ci, "slide"], own, cited, "claim", evidence));
+ });
+ out.push(...slideSpecProblems(section.slide, [...sp, "slide"], sectionIds, cited, "section", false));
+ });
+ return out;
+}
+
// A report: parsed, and every problem. `ok` means it parsed (its shape is a
// report); `problems` may still be non-empty, and a report with problems must
// not be published.
diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts
@@ -55,7 +55,7 @@ import { quoteTokens } from "../citations/verify";
import { formatTimestamp } from "../vtt";
import type { CitedIn } from "./citedIn";
import type { ReportHistoryRef } from "./revisions";
-import type { Claim, Report, ReportKind } from "./schema";
+import type { Claim, Report, ReportKind, ReportSlidesSpec, SlideSpec } from "./schema";
import { reportCitationNumbers } from "./uses";
import { resolveVerdicts, VERDICTS, type Verdict, type VerdictStyle } from "./verdicts";
@@ -255,6 +255,8 @@ export type ClaimView = {
archives?: SourceArchive[];
findings?: string;
citations: string[];
+ // How the claim reads as a slide (report.json `slide`; lib/report/slides.ts).
+ slide?: SlideSpec;
};
export type SectionView = {
@@ -262,6 +264,8 @@ export type SectionView = {
title: string;
body?: string;
claims: ClaimView[];
+ // How the section reads as a slide (report.json `slide`).
+ slide?: SlideSpec;
};
export type ReportPageView = {
@@ -298,6 +302,8 @@ export type ReportPageView = {
// Its newest revision and the history page (lib/report/revisions.ts), when
// compose published a history.
history?: ReportHistoryRef;
+ // How the report reads as slides (report.json `slides`; lib/report/slides.ts).
+ slides?: ReportSlidesSpec;
};
export type ReportVideoView = { src: string; poster?: string; caption?: string };
@@ -552,6 +558,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver)
id: s.id,
title: s.title,
body: s.body,
+ slide: s.slide,
claims: (s.claims ?? []).map((c) =>
defined({
id: c.id,
@@ -564,12 +571,14 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver)
archives: archivesFor(c),
findings: c.findings,
citations: c.citations ?? [],
+ slide: c.slide,
}),
),
}),
),
downloads: resolve.downloads,
history: resolve.history,
+ slides: report.slides,
});
}
diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json
@@ -162,6 +162,13 @@
"id": "bridge",
"title": "The bridge",
"body": "The article's first chapter is about the bridge.",
+ "slide": {
+ "points": [
+ "The article dates the opening to 2018",
+ "He says 2019 on stream, and [the city's record](cite:w01) agrees"
+ ],
+ "cite": "w01"
+ },
"claims": [
{
"id": "claim-1",
@@ -185,7 +192,14 @@
"findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.",
"citations": [
"au1"
- ]
+ ],
+ "slide": {
+ "title": "Every year?",
+ "points": [
+ "He mentions going back [once](cite:au1)",
+ "Nothing in the recordings says every year"
+ ]
+ }
}
]
},
@@ -202,7 +216,11 @@
"findings": "The article [puts it as a habit](cite:a02); he [denies it outright](cite:p01).",
"citations": [
"p01"
- ]
+ ],
+ "slide": {
+ "cite": "p01",
+ "layout": "quote"
+ }
},
{
"id": "claim-4",
@@ -237,5 +255,9 @@
"date": "2026-10-04T12:00:00Z",
"href": "/reports/demo-factcheck/history/",
"current": true
+ },
+ "slides": {
+ "title": "Five claims, checked against what was said on air",
+ "closing": "Every citation opens the moment it quotes."
}
}
diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json
@@ -11,6 +11,10 @@
"published": "2026-10-01",
"updated": "2026-10-04",
"subject": { "source": "s0" },
+ "slides": {
+ "title": "Five claims, checked against what was said on air",
+ "closing": "Every citation opens the moment it quotes."
+ },
"sources": {
"s0": {
"kind": "article",
@@ -86,6 +90,10 @@
"id": "bridge",
"title": "The bridge",
"body": "The article's first chapter is about the bridge.",
+ "slide": {
+ "points": ["The article dates the opening to 2018", "He says 2019 on stream, and [the city's record](cite:w01) agrees"],
+ "cite": "w01"
+ },
"claims": [
{
"id": "claim-1",
@@ -103,7 +111,8 @@
"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"]
+ "citations": ["au1"],
+ "slide": { "title": "Every year?", "points": ["He mentions going back [once](cite:au1)", "Nothing in the recordings says every year"] }
}
]
},
@@ -118,7 +127,8 @@
"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"]
+ "citations": ["p01"],
+ "slide": { "layout": "quote", "cite": "p01" }
},
{
"id": "claim-4",
diff --git a/umtool/lib/articles/article.ts b/umtool/lib/articles/article.ts
@@ -179,11 +179,13 @@ export async function articleView(report: Report): Promise<{ view: ReportPageVie
sources: {},
verdicts: {},
citations: {},
+ ...(report.slides ? { slides: report.slides } : {}),
sections: report.sections.map((s) => ({
id: s.id,
title: s.title,
...(s.body ? { body: s.body } : {}),
- claims: (s.claims ?? []).map((cl) => ({ ...cl, citations: cl.citations ?? [] })),
+ ...(s.slide ? { slide: s.slide } : {}),
+ claims: (s.claims ?? []).map((cl) => ({ ...cl, sourceQuote: cl.sourceQuote?.citation, citations: cl.citations ?? [] })),
})),
} as unknown as ReportPageView;
return { view, error: err instanceof Error ? err.message : String(err) };