commit 320b844af53698185305ad5a1218670672cc499f
parent 657bf290795b2eece02e63b0e876da3c64c8c5d9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 14:52:53 -0400
Merge track E (release 22 E1–E6: slide fields, the slides view and isometric overview, umtool's switch, slides exports) into r19/integration
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
48 files changed, 5344 insertions(+), 93 deletions(-)
diff --git a/COMMANDS.md b/COMMANDS.md
@@ -30,7 +30,7 @@ Run from the repo root (in the container: `docker compose exec editor pnpm archi
| `archilyzer build hub` | | compose:hub + INSTANCE\_MODE=hub next build into export/out — a raw build, unstamped; to deploy the hub build it with `publish hub` |
| `archilyzer build homepage` | `[--no-source]` | compose + source publish + next build in homepage/ (reads the index as it stands); --no-source removes the published source instead |
| `archilyzer reports prepare` | `<id>` | cut every clip and copy every post capture the site's published reports cite into its report-media cache, before its build, then export the reports as files (reports export) when nothing is missing (exit 1 when a citation lacks media or an export fails; default id: SITE\_ID) |
-| `archilyzer reports export` | `<id> [--report <reportId>] [--formats html,pdf,md,zip] [--allow-missing-media]` | write the site's published reports as files (report.html, report.pdf, report.md, evidence-pack.zip) into its report-exports staging, where compose publishes them from (exit 1 on a problem; a PDF skipped for want of a browser is a note; default id: SITE\_ID) |
+| `archilyzer reports export` | `<id> [--report <reportId>] [--formats html,pdf,md,slides,slides-pdf,zip] [--allow-missing-media]` | write the site's published reports as files (report.html, report.pdf, report.md, slides.html, slides.pdf, evidence-pack.zip) into its report-exports staging, where compose publishes them from (exit 1 on a problem; a PDF skipped for want of a browser is a note; default id: SITE\_ID) |
| `archilyzer reports check` | `<id> [--reports <a,b>] [--allow-missing-media]` | what compose would say about the site's reports, with no build: each report validated, every cited quote checked against its record, every cited moment's prepared media present and current, the report video under the publish limit (exit 1 with the list; --reports checks those, drafts included; default id: SITE\_ID) |
| `archilyzer reports verify-quotes` | `<report.json> [--json]` | every video, audio and post quote of one report against its record with compose's own check: the best score and track, and the en-orig track's score where the record has one — a quote that matches a served `en` rewrite and not en-orig is reported (exit 1 when any quote drifted) |
| `archilyzer reports attach-video` | `<report.json> <video> [--poster <image>] [--caption <line>]` | the report's video: remuxed (an H.264 mp4 under the limit) or encoded to fit the 24 MiB publish limit, written beside report.json as video.mp4 with a poster (given, kept, or a frame of the video), and `video` set in report.json |
@@ -105,7 +105,7 @@ Drives a running editor over HTTP (`/api/ops/*`, the same actions its pages run)
| `relocate-back` | — | |
| `evict-clips` | — | |
| `reports-prepare` | reports-prepare cuts every clip and copies every post capture a site's published reports cite into its report-media cache, before its build: {"siteId"}. The job fails, naming each one, when a citation lacks media. When nothing is missing it then exports the reports, as reports-export. | `pnpm ops reports-prepare --json '{"siteId":"demo-site"}' --wait` |
-| `reports-export` | reports-export writes each published report as report.html, report.pdf, report.md and evidence-pack.zip for the site's build to publish: {"siteId", "reportId"?, "formats"?: \["html","pdf","md","zip"\]}. | `pnpm ops reports-export --json '{"siteId":"demo-site","formats":["html","md"]}' --wait` |
+| `reports-export` | reports-export writes each published report as report.html, report.pdf, report.md, slides.html, slides.pdf and evidence-pack.zip for the site's build to publish: {"siteId", "reportId"?, "formats"?: \["html","pdf","md", "slides","slides-pdf","zip"\]}. | `pnpm ops reports-export --json '{"siteId":"demo-site","formats":["html","md"]}' --wait` |
| `lane` | — | `pnpm ops lane --json '{"lane":"download","held":true}'` |
| `tags` | — | `pnpm ops tags --json '{"op":"define","tag":{"id":"eva-collab","label":"Collab"}}'` |
| `tag-videos` | — | `pnpm ops tag-videos --file ids.json` |
diff --git a/PUBLISH.md b/PUBLISH.md
@@ -83,7 +83,12 @@ resolve it, to `.export-index/sites/<id>/report-exports/<report>/` as `report.ht
(one self-contained file — inline style, no script, its stills and post screenshots
inlined; clips linked on the site), `report.pdf` (that page printed by headless
Chromium; skipped with a note on a host without Playwright's browser), `report.md`
-(plain Markdown with numbered references) and `evidence-pack.zip` (the page with its
+(plain Markdown with numbered references), `slides.html` (the report as slides — the
+same slides the page's Slides view shows, `common/lib/report/slides.ts` — one
+self-contained file: the site's slide stylesheet in its accent, every picture inlined,
+a few lines of script for the keys), `slides.pdf` (those slides printed, one 1280 × 720
+page per slide; skipped like `report.pdf`; neither slides file for a report whose
+`slides.hide` says so) and `evidence-pack.zip` (the page and the slides with its
clips, stills and screenshots as files, so the clips play offline, plus the Markdown
and the citations; packed by the system `zip`, which a host must have), with an
`export.json` naming each file's size and checksum and the hash of the report.json it
@@ -106,7 +111,8 @@ and `export.json` records it.
The build's compose then writes the reports from what prepare left
(`common/publish/composeReports.ts`): each report's page, its citations as
`citations.json` and `citations.csv`, its exports (`report.html`, `report.pdf`,
-`report.md`, `evidence-pack.zip` — only those made from the report.json as it is now,
+`report.md`, `slides.html`, `slides.pdf`, `evidence-pack.zip` — only those made from the
+report.json as it is now,
and only a file of at most 24 MiB: a larger pack stays on the host; the page's
download line lists what was published), its cited stills (never a saved source copy),
one page per cited moment with the transcript lines around it, the prepared clips
@@ -283,7 +289,7 @@ the same lock.
| The source mirror alone | — (every homepage build runs it) | — | `source publish [--force] [--check] [--keep-scratch]`, `source audit [<git dir>]` |
| Fetch the clip windows a site's reports cite and the disk lacks | its **Reports** tab → *Fetch missing evidence* (*Preview missing evidence* lists them) | `fetch-windows` (`{"siteId", "dryRun"?}`) | — |
| A site's report evidence media (then its exports) | its **Reports** tab → *Prepare evidence media* | `reports-prepare` (`{"siteId"}`) | `reports prepare <id>` |
-| A site's report exports (HTML, PDF, Markdown, evidence pack) | its **Reports** tab → *Export reports* | `reports-export` (`{"siteId", "reportId"?, "formats"?}`) | `reports export <id> [--report <rid>] [--formats html,pdf,md,zip]` |
+| A site's report exports (HTML, PDF, Markdown, slides, evidence pack) | its **Reports** tab → *Export reports* | `reports-export` (`{"siteId", "reportId"?, "formats"?}`) | `reports export <id> [--report <rid>] [--formats html,pdf,md,slides,slides-pdf,zip]` |
| A report from a /sweep report, an /ask answer or a report-to-video manifest, and a starter manifest from a report | — | — | `reports convert <sweep\|ask\|manifest> <in> --out <report.json> [--channels-dir <dir>]`, `reports to-manifest <report.json> --out <manifest.json>` |
**The editor.** The /sites **Publish** panel has a row per site, then the hub and the
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/bin/archilyzer.ts b/common/bin/archilyzer.ts
@@ -262,7 +262,7 @@ export const COMMANDS: Command[] = [
{
path: ["reports", "export"],
usage:
- "<id> [--report <reportId>] [--formats html,pdf,md,zip] [--allow-missing-media] write the site's published reports as files (report.html, report.pdf, report.md, evidence-pack.zip) into its report-exports staging, where compose publishes them from (exit 1 on a problem; a PDF skipped for want of a browser is a note; default id: SITE_ID)",
+ "<id> [--report <reportId>] [--formats html,pdf,md,slides,slides-pdf,zip] [--allow-missing-media] write the site's published reports as files (report.html, report.pdf, report.md, slides.html, slides.pdf, evidence-pack.zip) into its report-exports staging, where compose publishes them from (exit 1 on a problem; a PDF skipped for want of a browser is a note; default id: SITE_ID)",
flags: { report: "string", formats: "string", "allow-missing-media": "boolean" },
maxPositionals: 1,
run: async ({ positionals, flags, env }) => {
diff --git a/common/bin/reports-export.ts b/common/bin/reports-export.ts
@@ -1,6 +1,6 @@
-// `archilyzer reports export <siteId> [--report <id>] [--formats html,pdf,md,zip]`
+// `archilyzer reports export <siteId> [--report <id>] [--formats html,pdf,md,slides,slides-pdf,zip]`
// — write a site's published reports as files (report.html, report.pdf,
-// report.md, evidence-pack.zip) into
+// report.md, slides.html, slides.pdf, evidence-pack.zip) into
// `.export-index/sites/<siteId>/report-exports/<reportId>/`, where compose
// publishes them from. The work is publish/reportExports.ts's, the same the
// editor's `reports-export` job runs; this file prints its log and problems.
diff --git a/common/components/citations/CitationCard.tsx b/common/components/citations/CitationCard.tsx
@@ -65,7 +65,7 @@ function ExternalA({ href, children, title }: { href: string; children: React.Re
}
// The title line and its meta, per kind.
-function heading(c: CitationView): { title: string; meta: string[] } {
+export function citationHeading(c: CitationView): { title: string; meta: string[] } {
switch (c.kind) {
case "video":
case "audio":
@@ -221,7 +221,7 @@ export function CitationCard({
}) {
const added = c.origin === "added";
const Icon = KIND_ICON[c.kind];
- const { title, meta } = heading(c);
+ const { title, meta } = citationHeading(c);
const showNumber = variant !== "reference" && c.number !== undefined;
const markUnder = added && variant === "card";
const postText = c.kind === "post" && c.text && c.text.trim() !== c.quote.trim() ? c.text : null;
diff --git a/common/components/report/slides/ReportOverview.tsx b/common/components/report/slides/ReportOverview.tsx
@@ -0,0 +1,346 @@
+"use client";
+
+import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
+import { Rows3 } from "lucide-react";
+import { slidePointText, type SlideView } from "../../../lib/report/slides";
+import type { ReportPageView } from "../../../lib/report/views";
+import { Slide, verdictStyleOf } from "./Slide";
+import { SlidesHostProvider, useSlidesHost, type SlidesHost } from "./SlidesHost";
+
+// THE OVERVIEW — the article and its slides side by side on one plane, tilted
+// isometrically: the article's parts as a column of page blocks, each slide
+// beside its part as a 16:9 card, a line between the two. One pair is the
+// reader's place (lit, lifted); pointing at a pair lights it. Choosing a block
+// opens the article there; choosing a card opens the slides there.
+//
+// THE TILT IS NEVER THE ONLY WAY TO READ IT. The same list of pairs lies flat
+// (two columns; a narrow screen stacks each pair) when motion is reduced, the
+// box is narrow, the reader came in by keyboard, or they asked for it (the
+// "Flat" toggle, remembered in this browser). Flat or tilted, the DOM is the
+// same ordered list, and the keys are the same:
+//
+// ↑ ↓ the pair before / after (Home, End: the first, the last)
+// ← → the block / the card of this pair
+// Enter Space open what is focused
+// Esc back to the article at this pair (`onExit`)
+//
+// The place is the caller's (`focusAnchor`, the URL's hash); walking pairs
+// reports it through `onFocusAnchor`.
+
+const CARD_W = 300;
+const THUMB_SCALE = CARD_W / 1280;
+const NARROW_PX = 900;
+const TILT_KEY = "archilyzer:overview-tilt";
+
+// How the reader last moved: a keyboard user gets the flat plane.
+let lastInput: "keyboard" | "pointer" = "pointer";
+if (typeof window !== "undefined") {
+ window.addEventListener("keydown", () => (lastInput = "keyboard"), true);
+ window.addEventListener("pointerdown", () => (lastInput = "pointer"), true);
+}
+
+function storedTilt(): boolean | null {
+ try {
+ const v = window.localStorage.getItem(TILT_KEY);
+ return v === "flat" ? false : v === "iso" ? true : null;
+ } catch {
+ return null;
+ }
+}
+function storeTilt(tilt: boolean) {
+ try {
+ window.localStorage.setItem(TILT_KEY, tilt ? "iso" : "flat");
+ } catch {
+ // no storage here: the choice lasts this visit
+ }
+}
+
+function kindName(s: SlideView): string {
+ switch (s.kind) {
+ case "title":
+ return "The head";
+ case "summary":
+ return "In brief";
+ case "found":
+ return "What the check found";
+ case "section":
+ return `Section ${s.index}`;
+ case "claim":
+ return "Claim";
+ case "sources":
+ return "Sources";
+ }
+}
+
+// A part of the article as a page block: what the reader would find there,
+// in the article's own type.
+function BlockFace({ s, view }: { s: SlideView; view: ReportPageView }) {
+ let line: string | undefined;
+ let mark: string | undefined;
+ switch (s.kind) {
+ case "title":
+ line = [s.series, s.dates.join(" · ")].filter(Boolean).join(" · ");
+ break;
+ case "summary":
+ line = s.text ? slidePointText(s.text) : s.points?.map(slidePointText).join(" · ");
+ break;
+ case "found":
+ line = s.groups.map((g) => `${g.claims.length} ${verdictStyleOf(view, g.verdict).label}`).join(" · ");
+ break;
+ case "section":
+ line = s.text ? slidePointText(s.text) : s.points ? s.points.map(slidePointText).join(" · ") : `${s.claims.length} claims`;
+ break;
+ case "claim":
+ if (s.verdict) mark = verdictStyleOf(view, s.verdict).color;
+ line = s.gist ?? s.text;
+ break;
+ case "sources":
+ line = `${s.references} references`;
+ break;
+ }
+ const verdict = s.kind === "claim" && s.verdict ? verdictStyleOf(view, s.verdict).label : undefined;
+ return (
+ <span className="rs-ov-face" data-kind={s.kind} style={mark ? ({ "--rs-mark": mark } as CSSProperties) : undefined}>
+ <span className="rs-ov-kind">
+ {kindName(s)}
+ {verdict ? <b> · {verdict}</b> : null}
+ </span>
+ <span className="rs-ov-title">{s.title}</span>
+ {line && <span className="rs-ov-line">{line}</span>}
+ </span>
+ );
+}
+
+// A thumbnail renders its slide's words as text: nothing in it is a link.
+const thumbHost = (base: SlidesHost): SlidesHost => ({
+ ...base,
+ md: (text) => <span className="rs-md">{slidePointText(text)}</span>,
+ cite: (_id, children) => children,
+ onRead: undefined,
+ badge: undefined,
+});
+
+export const ReportOverview = forwardRef<
+ HTMLElement,
+ {
+ slides: readonly SlideView[];
+ view: ReportPageView;
+ // The pair in force: an anchor id (the URL's hash).
+ focusAnchor: string | null;
+ onFocusAnchor: (anchorId: string) => void;
+ onOpenArticle: (slide: SlideView) => void;
+ onOpenSlide: (slide: SlideView) => void;
+ onExit?: (slide: SlideView | undefined) => void;
+ keyScope?: "window" | "stage";
+ }
+>(function ReportOverview({ slides, view, focusAnchor, onFocusAnchor, onOpenArticle, onOpenSlide, onExit, keyScope = "window" }, ref) {
+ const host = useSlidesHost();
+ const thumbs = useMemo(() => thumbHost(host), [host]);
+ const active = Math.max(
+ 0,
+ slides.findIndex((s) => s.anchorId === focusAnchor),
+ );
+ const [side, setSide] = useState<"block" | "card">("card");
+ const scrollerRef = useRef<HTMLDivElement>(null);
+ const planeRef = useRef<HTMLOListElement>(null);
+ const stageRef = useRef<HTMLElement | null>(null);
+
+ // Tilted or flat: the reader's choice, else flat for reduced motion, a
+ // narrow box or a keyboard arrival, else tilted.
+ const [tilt, setTilt] = useState<boolean | null>(null);
+ const [narrow, setNarrow] = useState(false);
+ useLayoutEffect(() => {
+ const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
+ setTilt(storedTilt() ?? !(reduce || lastInput === "keyboard"));
+ const el = scrollerRef.current;
+ if (!el) return;
+ const measure = () => setNarrow(el.clientWidth < NARROW_PX);
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+ const iso = tilt === true && !narrow;
+
+ // The tilted plane's box: rotateX(55deg) rotateZ(-45deg) of a W × H plane
+ // spans 0.7071(W + H) across and 0.4056(W + H) down, its top corner raised
+ // 0.4056 W — the sizer holds exactly that, so the plane scrolls whole.
+ const [plane, setPlane] = useState<{ w: number; h: number } | null>(null);
+ useLayoutEffect(() => {
+ const el = planeRef.current;
+ if (!el) return;
+ const measure = () => setPlane((p) => (p && p.w === el.offsetWidth && p.h === el.offsetHeight ? p : { w: el.offsetWidth, h: el.offsetHeight }));
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, [iso]);
+
+ // The place in view: the pair in force, centred.
+ useEffect(() => {
+ const pair = planeRef.current?.querySelector<HTMLElement>(`[data-pair-index="${active}"]`);
+ const scroller = scrollerRef.current;
+ if (!pair || !scroller) return;
+ // The scroller alone moves (never the article behind): by the pair's
+ // drawn box, which the tilt has moved.
+ const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
+ const p = pair.getBoundingClientRect();
+ const s = scroller.getBoundingClientRect();
+ scroller.scrollTo({
+ left: scroller.scrollLeft + (p.left + p.width / 2) - (s.left + s.width / 2),
+ top: scroller.scrollTop + (p.top + p.height / 2) - (s.top + s.height / 2),
+ behavior: reduce ? "auto" : "smooth",
+ });
+ }, [active, iso, plane]);
+
+ const focusPair = useCallback(
+ (i: number, which: "block" | "card") => {
+ const at = Math.min(Math.max(i, 0), slides.length - 1);
+ setSide(which);
+ onFocusAnchor(slides[at].anchorId);
+ requestAnimationFrame(() => {
+ planeRef.current?.querySelector<HTMLElement>(`[data-pair-index="${at}"] [data-ov-${which}]`)?.focus({ preventScroll: true });
+ });
+ },
+ [slides, onFocusAnchor],
+ );
+
+ const onKey = useCallback(
+ (e: KeyboardEvent | React.KeyboardEvent) => {
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
+ const t = e.target as HTMLElement | null;
+ if (t?.closest?.("input, textarea, select, [contenteditable=true]")) return;
+ // Inside the plane the focused side is the one moved from; elsewhere
+ // (the toggle, the stage itself) the last one.
+ const from = (t?.closest?.("[data-ov-block]") ? "block" : t?.closest?.("[data-ov-card]") ? "card" : side) as "block" | "card";
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault();
+ focusPair(active + 1, from);
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ focusPair(active - 1, from);
+ break;
+ case "Home":
+ e.preventDefault();
+ focusPair(0, from);
+ break;
+ case "End":
+ e.preventDefault();
+ focusPair(slides.length - 1, from);
+ break;
+ case "ArrowLeft":
+ e.preventDefault();
+ focusPair(active, "block");
+ break;
+ case "ArrowRight":
+ e.preventDefault();
+ focusPair(active, "card");
+ break;
+ case "Escape":
+ if (onExit) {
+ e.preventDefault();
+ onExit(slides[active]);
+ }
+ break;
+ }
+ },
+ [active, focusPair, onExit, side, slides],
+ );
+ useEffect(() => {
+ if (keyScope !== "window") return;
+ const h = (e: KeyboardEvent) => onKey(e);
+ window.addEventListener("keydown", h);
+ return () => window.removeEventListener("keydown", h);
+ }, [keyScope, onKey]);
+
+ const sizer: CSSProperties | undefined =
+ iso && plane ? { width: 0.7071 * (plane.w + plane.h) + 160, height: 0.4056 * (plane.w + plane.h) + 200 } : undefined;
+ const planeStyle: CSSProperties | undefined =
+ iso && plane ? { transform: `translate(80px, ${0.4056 * plane.w + 100}px) rotateX(55deg) rotateZ(-45deg)` } : undefined;
+
+ return (
+ <section
+ ref={(el) => {
+ stageRef.current = el;
+ if (typeof ref === "function") ref(el);
+ else if (ref) ref.current = el;
+ }}
+ className="rs-root rs-ov"
+ aria-roledescription="overview"
+ aria-label={`Overview: ${view.title}, each part of the article beside its slide`}
+ tabIndex={-1}
+ data-report-overview={iso ? "iso" : "flat"}
+ onKeyDown={keyScope === "stage" ? onKey : undefined}
+ >
+ <div className="rs-ov-tools">
+ <p className="rs-ov-hint" aria-hidden="true">
+ <kbd>↑</kbd> <kbd>↓</kbd> walk · <kbd>←</kbd> article <kbd>→</kbd> slide · <kbd>Enter</kbd> open
+ </p>
+ {!narrow && (
+ <button
+ type="button"
+ className="rs-btn"
+ aria-pressed={!iso}
+ data-ov-flat=""
+ onClick={() => {
+ const next = !iso;
+ setTilt(next);
+ storeTilt(next);
+ }}
+ >
+ <Rows3 aria-hidden="true" width={15} height={15} />
+ Flat
+ </button>
+ )}
+ </div>
+ <div ref={scrollerRef} className="rs-ov-scroll">
+ <div className="rs-ov-sizer" style={sizer}>
+ <ol ref={planeRef} className="rs-ov-plane" style={planeStyle} aria-label="The article and its slides">
+ {slides.map((s, i) => {
+ const here = i === active;
+ return (
+ <li key={s.key} className="rs-ov-pair" data-pair={s.key} data-pair-index={i} data-active={here ? "true" : undefined}>
+ <div className="rs-ov-block">
+ <BlockFace s={s} view={view} />
+ <button
+ type="button"
+ data-ov-block={s.anchorId}
+ tabIndex={here ? 0 : -1}
+ aria-label={`Read in the article: ${kindName(s)}, ${s.title}`}
+ onFocus={() => {
+ if (!here) onFocusAnchor(s.anchorId);
+ setSide("block");
+ }}
+ onClick={() => onOpenArticle(s)}
+ />
+ </div>
+ <span className="rs-ov-link" aria-hidden="true" />
+ <div className="rs-ov-card">
+ <div className="rs-ov-thumb" aria-hidden="true" inert>
+ <SlidesHostProvider host={thumbs}>
+ <Slide slide={s} view={view} total={slides.length} hideRead style={{ transform: `scale(${THUMB_SCALE})` }} />
+ </SlidesHostProvider>
+ </div>
+ <button
+ type="button"
+ data-ov-card={s.n}
+ tabIndex={here ? 0 : -1}
+ aria-label={`Open slide ${s.n} of ${slides.length}: ${s.title}`}
+ onFocus={() => {
+ if (!here) onFocusAnchor(s.anchorId);
+ setSide("card");
+ }}
+ onClick={() => onOpenSlide(s)}
+ />
+ </div>
+ </li>
+ );
+ })}
+ </ol>
+ </div>
+ </div>
+ </section>
+ );
+});
diff --git a/common/components/report/slides/ReportReader.tsx b/common/components/report/slides/ReportReader.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { CitationsProvider } from "../../citations/CitationsContext";
+import {
+ buildReportSlides,
+ READER_VIEWS,
+ reportPageAnchorIds,
+ slideIndexForPlace,
+ type ReaderView,
+ type SlideView,
+} from "../../../lib/report/slides";
+import type { ReportPageView } from "../../../lib/report/views";
+import { ReportOverview } from "./ReportOverview";
+import { ReportSlides } from "./ReportSlides";
+import { defaultSlidesHost, SlidesHostProvider, type SlidesHost } from "./SlidesHost";
+import { readerUrl, useReaderPlace } from "./useReaderPlace";
+import { ViewSwitch } from "./ViewSwitch";
+
+// ONE ARTICLE, THREE SHAPES — the export site's reader of a report page.
+//
+// The article is the page as it was built (static HTML: what search engines
+// and a reader with no script get). This island adds the view switch to its
+// header and, when the URL asks for another view (`?rv=slides`, `?rv=iso` —
+// ./useReaderPlace.ts), lays that view over the page, built in the browser
+// from the report's own page.json (fetched once, on demand — the article's
+// HTML never carries the slides twice).
+//
+// THE PLACE IS KEPT. From the article, the slide is the one for the part of
+// the article in view (the last anchor at or above the top third); from a
+// slide or the overview, the article opens at its anchor, scrolled to and
+// focused. The view and the place are the URL's — `#s-<n>` in the slides, an
+// anchor id elsewhere — so a shared link opens the same view at the same
+// place, and Back undoes a switch.
+//
+// While a view is laid over the article, the article is inert and does not
+// scroll; a citation's card (InlineCite, portalled to <body>) opens above.
+
+// The article's places, in document order: what each slide stands for.
+const PLACE_SELECTOR = "#report-head, #in-brief, #found, [data-section], article[data-claim], #report-end";
+
+// The anchor of the part of the article in view: the last place whose top is
+// at or above the top third of the window (the first, at the top).
+function anchorInView(): string | null {
+ const line = Math.min(window.innerHeight / 3, 280);
+ let best: string | null = null;
+ for (const el of Array.from(document.querySelectorAll<HTMLElement>(PLACE_SELECTOR))) {
+ if (!el.id) continue;
+ if (best === null || el.getBoundingClientRect().top <= line) best = el.id;
+ else break;
+ }
+ return best;
+}
+
+// Scroll the article to an anchor and put the focus there.
+function landOn(anchorId: string) {
+ const el = document.getElementById(anchorId);
+ if (!el) return;
+ el.scrollIntoView({ block: "start" });
+ if (!el.matches("a, button, input, select, textarea, [tabindex]")) el.setAttribute("tabindex", "-1");
+ el.focus({ preventScroll: true });
+}
+
+type Loaded = { view: ReportPageView; slides: SlideView[] } | { error: string };
+
+export function ReportReader({
+ pagePath,
+ title,
+ series,
+ views = READER_VIEWS,
+}: {
+ // The report's page view (/reports/<id>/page.json).
+ pagePath: string;
+ title: string;
+ series?: string;
+ views?: readonly ReaderView[];
+}) {
+ const { place, go } = useReaderPlace(views);
+ const open = place.view !== "article";
+ const [loaded, setLoaded] = useState<Loaded | null>(null);
+ const overlayRef = useRef<HTMLDivElement>(null);
+ const stageRef = useRef<HTMLElement>(null);
+ const landing = useRef<string | null>(null);
+
+ // The report's view, once, when a view needs it.
+ useEffect(() => {
+ if (!open || loaded) return;
+ let live = true;
+ fetch(pagePath)
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))))
+ .then((view: ReportPageView) => {
+ if (live) setLoaded({ view, slides: buildReportSlides(view) });
+ })
+ .catch(() => {
+ if (live) setLoaded({ error: "The slides could not be loaded." });
+ });
+ return () => {
+ live = false;
+ };
+ }, [open, loaded, pagePath]);
+
+ const data = loaded && "view" in loaded ? loaded : null;
+ const order = useMemo(() => (data ? reportPageAnchorIds(data.view) : []), [data]);
+ const slideIndex = data && place.view === "slides" ? slideIndexForPlace(data.slides, place, order) : 0;
+
+ // A slides URL that names a place, not a slide (a switch from the article,
+ // a shared link to a section): the slide for that place, in the URL.
+ useEffect(() => {
+ if (data && place.view === "slides" && place.slide === null && data.slides.length > 0) {
+ go({ view: "slides", slide: slideIndex + 1 }, false);
+ }
+ }, [data, place, slideIndex, go]);
+
+ // While a view is open: the article is inert and still.
+ useLayoutEffect(() => {
+ if (!open) return;
+ const overlay = overlayRef.current;
+ const made: Element[] = [];
+ for (const el of Array.from(document.body.children)) {
+ if (el === overlay || el.hasAttribute("inert") || el.tagName === "SCRIPT") continue;
+ el.setAttribute("inert", "");
+ made.push(el);
+ }
+ const root = document.documentElement;
+ const overflow = root.style.overflow;
+ root.style.overflow = "hidden";
+ return () => {
+ for (const el of made) el.removeAttribute("inert");
+ root.style.overflow = overflow;
+ };
+ }, [open]);
+
+ // The focus follows the view: into it when it opens (and when its slides
+ // arrive, and on a switch between views); back to the article's place
+ // when it closes.
+ useEffect(() => {
+ if (open) (stageRef.current ?? overlayRef.current)?.focus({ preventScroll: true });
+ }, [open, data, place.view]);
+ useEffect(() => {
+ if (open || !landing.current) return;
+ const id = landing.current;
+ landing.current = null;
+ requestAnimationFrame(() => landOn(id));
+ }, [open]);
+
+ const toArticle = useCallback(
+ (anchorId: string | null) => {
+ landing.current = anchorId;
+ go({ view: "article", anchor: anchorId }, true);
+ },
+ [go],
+ );
+
+ const switchTo = useCallback(
+ (next: ReaderView) => {
+ if (next === place.view) return;
+ // The place now: the article's part in view, a slide's anchor, the overview's.
+ const anchor =
+ place.view === "article"
+ ? anchorInView()
+ : place.view === "slides" && data
+ ? (data.slides[slideIndex]?.anchorId ?? null)
+ : place.anchor;
+ if (next === "article") toArticle(anchor);
+ else go({ view: next, anchor }, true);
+ },
+ [place, data, slideIndex, toArticle, go],
+ );
+
+ const host = useMemo<SlidesHost>(
+ () => ({
+ ...defaultSlidesHost,
+ readHref: (s) => readerUrl({ view: "article", anchor: s.anchorId }),
+ onRead: (s) => toArticle(s.anchorId),
+ }),
+ [toArticle],
+ );
+
+ const pageUrl = typeof window === "undefined" ? undefined : `${window.location.origin}${window.location.pathname}`;
+
+ const overlay = open ? (
+ <div ref={overlayRef} className="rs-root rs-overlay" data-reader-overlay={place.view} tabIndex={-1}>
+ <div className="rs-bar">
+ <p className="rs-bar-title">
+ {series && <span>{series}: </span>}
+ {title}
+ </p>
+ <ViewSwitch value={place.view} onChange={switchTo} views={views} />
+ </div>
+ <div className="rs-overlay-body">
+ {!loaded ? (
+ <p className="rs-note" role="status">
+ Loading…
+ </p>
+ ) : "error" in loaded ? (
+ <p className="rs-note" role="alert">
+ {loaded.error}{" "}
+ <button type="button" className="rs-btn" onClick={() => toArticle(place.anchor)}>
+ Read the article
+ </button>
+ </p>
+ ) : (
+ <CitationsProvider citations={loaded.view.citations}>
+ <SlidesHostProvider host={host}>
+ {place.view === "slides" ? (
+ <ReportSlides
+ ref={stageRef}
+ slides={loaded.slides}
+ view={loaded.view}
+ index={slideIndex}
+ onIndex={(i) => go({ view: "slides", slide: i + 1 }, false)}
+ onExit={(s) => toArticle(s.anchorId)}
+ pageUrl={pageUrl}
+ />
+ ) : (
+ <ReportOverview
+ ref={stageRef}
+ slides={loaded.slides}
+ view={loaded.view}
+ focusAnchor={place.anchor}
+ onFocusAnchor={(a) => go({ view: "iso", anchor: a }, false)}
+ onOpenArticle={(s) => toArticle(s.anchorId)}
+ onOpenSlide={(s) => go({ view: "slides", slide: s.n }, true)}
+ onExit={(s) => toArticle(s?.anchorId ?? place.anchor)}
+ />
+ )}
+ </SlidesHostProvider>
+ </CitationsProvider>
+ )}
+ </div>
+ </div>
+ ) : null;
+
+ return (
+ <>
+ <ViewSwitch value="article" onChange={switchTo} views={views} />
+ {overlay && createPortal(overlay, document.body)}
+ </>
+ );
+}
diff --git a/common/components/report/slides/ReportSlides.tsx b/common/components/report/slides/ReportSlides.tsx
@@ -0,0 +1,243 @@
+"use client";
+
+import { forwardRef, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import type { SlideView } from "../../../lib/report/slides";
+import type { ReportPageView } from "../../../lib/report/views";
+import { Slide, type SlideFit } from "./Slide";
+
+// THE SLIDES VIEW — one slide at a time on a 16:9 stage, letterboxed in
+// whatever box it is given (the export site's overlay fills the viewport;
+// umtool's sits in the reading column). The canvas (1280 × 720) is scaled as
+// one piece to fit; a narrow portrait box reflows the slide instead, its
+// evidence under its words.
+//
+// Keys (not while typing): ← → and PgUp PgDn step, Home End jump, Esc leaves
+// (`onExit`: the caller goes back to the article at this slide's anchor).
+// Touch: a swipe steps. The rail under the stage is one tick per slide.
+//
+// THE LIVE LABEL ("Slide 4 of 9: <title>") is what a screen reader hears on
+// every step; the stage is a region named for the report. Which slide is
+// shown is the caller's (`index`, from the URL's `#s-<n>`); the stage asks
+// for a change through `onIndex`.
+
+const CANVAS_W = 1280;
+const CANVAS_H = 720;
+const SWIPE_PX = 48;
+
+function kindLabel(s: SlideView): string {
+ switch (s.kind) {
+ case "title":
+ return "Title";
+ case "summary":
+ return "In brief";
+ case "found":
+ return "Findings";
+ case "section":
+ return "Section";
+ case "claim":
+ return "Claim";
+ case "sources":
+ return "Sources";
+ }
+}
+
+export function slideLabel(s: SlideView, total: number): string {
+ return `Slide ${s.n} of ${total}: ${kindLabel(s)} — ${s.title}`;
+}
+
+function isTyping(t: EventTarget | null): boolean {
+ const el = t as HTMLElement | null;
+ return !!el?.closest?.("input, textarea, select, [contenteditable=true]");
+}
+
+export const ReportSlides = forwardRef<
+ HTMLElement,
+ {
+ slides: readonly SlideView[];
+ view: ReportPageView;
+ // 0-based.
+ index: number;
+ onIndex: (index: number) => void;
+ onExit?: (slide: SlideView) => void;
+ pageUrl?: string;
+ // Keys are listened for on the window (the overlay owns the page); a host
+ // that shares the page with other keys (umtool) scopes them to the stage.
+ keyScope?: "window" | "stage";
+ }
+>(function ReportSlides({ slides, view, index, onIndex, onExit, pageUrl, keyScope = "window" }, ref) {
+ const total = slides.length;
+ const at = Math.min(Math.max(index, 0), Math.max(total - 1, 0));
+ const slide = slides[at];
+ const viewportRef = useRef<HTMLDivElement>(null);
+ const stageRef = useRef<HTMLElement | null>(null);
+ const [box, setBox] = useState<{ w: number; h: number } | null>(null);
+ const prevAt = useRef(at);
+ const dir = at > prevAt.current ? "next" : at < prevAt.current ? "prev" : undefined;
+ useEffect(() => {
+ prevAt.current = at;
+ }, [at]);
+
+ // The stage's size: the scale, and whether to reflow.
+ useLayoutEffect(() => {
+ const el = viewportRef.current;
+ if (!el) return;
+ const measure = () => {
+ const cs = getComputedStyle(el);
+ const w = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
+ const h = el.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
+ setBox((b) => (b && b.w === w && b.h === h ? b : { w, h }));
+ };
+ measure();
+ const ro = new ResizeObserver(measure);
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ const fit: SlideFit = box && box.w < 640 && box.h > box.w ? "reflow" : "canvas";
+ const scale = box ? Math.max(0.1, Math.min(box.w / CANVAS_W, box.h / CANVAS_H)) : 0;
+
+ const go = useCallback((i: number) => onIndex(Math.min(Math.max(i, 0), total - 1)), [onIndex, total]);
+
+ const onKey = useCallback(
+ (e: KeyboardEvent | React.KeyboardEvent) => {
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || isTyping(e.target)) return;
+ switch (e.key) {
+ case "ArrowRight":
+ case "PageDown":
+ e.preventDefault();
+ go(at + 1);
+ break;
+ case "ArrowLeft":
+ case "PageUp":
+ e.preventDefault();
+ go(at - 1);
+ break;
+ case "Home":
+ e.preventDefault();
+ go(0);
+ break;
+ case "End":
+ e.preventDefault();
+ go(total - 1);
+ break;
+ case "Escape":
+ // A citation's open preview takes Escape first (InlineCite).
+ if (onExit && slide) {
+ e.preventDefault();
+ onExit(slide);
+ }
+ break;
+ }
+ },
+ [at, go, onExit, slide, total],
+ );
+
+ useEffect(() => {
+ if (keyScope !== "window") return;
+ const h = (e: KeyboardEvent) => onKey(e);
+ window.addEventListener("keydown", h);
+ return () => window.removeEventListener("keydown", h);
+ }, [keyScope, onKey]);
+
+ // A swipe steps.
+ const swipe = useRef<{ x: number; y: number; id: number } | null>(null);
+
+ if (!slide) return null;
+ const label = slideLabel(slide, total);
+
+ return (
+ <section
+ ref={(el) => {
+ stageRef.current = el;
+ if (typeof ref === "function") ref(el);
+ else if (ref) ref.current = el;
+ }}
+ className="rs-root rs-stage"
+ aria-roledescription="slides"
+ aria-label={`Slides: ${view.title}`}
+ tabIndex={-1}
+ data-report-slides={at + 1}
+ onKeyDown={keyScope === "stage" ? onKey : undefined}
+ >
+ <div
+ ref={viewportRef}
+ className="rs-viewport"
+ data-fit={fit}
+ onPointerDown={(e) => {
+ if (e.pointerType === "mouse") return;
+ swipe.current = { x: e.clientX, y: e.clientY, id: e.pointerId };
+ }}
+ onPointerUp={(e) => {
+ const s = swipe.current;
+ swipe.current = null;
+ if (!s || s.id !== e.pointerId) return;
+ const dx = e.clientX - s.x;
+ const dy = e.clientY - s.y;
+ if (Math.abs(dx) > SWIPE_PX && Math.abs(dx) > Math.abs(dy) * 1.5) go(dx < 0 ? at + 1 : at - 1);
+ }}
+ onPointerCancel={() => {
+ swipe.current = null;
+ }}
+ onClickCapture={(e) => {
+ // A citation's number opens its card here (focus does): its
+ // reference list is the article's, behind the slides.
+ const a = (e.target as HTMLElement).closest?.("a[data-cite-number]") as HTMLAnchorElement | null;
+ if (a) {
+ e.preventDefault();
+ a.focus();
+ }
+ }}
+ >
+ {box &&
+ (fit === "canvas" ? (
+ <div
+ key={slide.key}
+ className="rs-scaled"
+ data-dir={dir}
+ style={{ width: CANVAS_W * scale, height: CANVAS_H * scale }}
+ >
+ <Slide slide={slide} view={view} total={total} pageUrl={pageUrl} style={{ transform: `scale(${scale})` }} />
+ </div>
+ ) : (
+ <div key={slide.key} className="rs-scaled" data-dir={dir}>
+ <Slide slide={slide} view={view} total={total} pageUrl={pageUrl} fit="reflow" />
+ </div>
+ ))}
+ </div>
+ <div className="rs-controls">
+ <button type="button" className="rs-btn" onClick={() => go(at - 1)} disabled={at === 0} aria-label="Previous slide">
+ <ChevronLeft aria-hidden="true" width={16} height={16} />
+ <span className="rs-btn-text">Previous</span>
+ </button>
+ <p className="rs-label" aria-live="polite" aria-atomic="true" data-slide-label="">
+ <span className="sr-only">{label}</span>
+ <span aria-hidden="true">
+ <b>
+ {at + 1} / {total}
+ </b>{" "}
+ · {slide.title}
+ </span>
+ </p>
+ <button type="button" className="rs-btn" onClick={() => go(at + 1)} disabled={at === total - 1} aria-label="Next slide">
+ <span className="rs-btn-text">Next</span>
+ <ChevronRight aria-hidden="true" width={16} height={16} />
+ </button>
+ </div>
+ <div className="rs-rail" aria-label="All slides">
+ {slides.map((s, i) => (
+ <button
+ key={s.key}
+ type="button"
+ tabIndex={-1}
+ data-state={i < at ? "past" : i === at ? "current" : "next"}
+ aria-label={`Go to slide ${s.n}: ${s.title}`}
+ aria-current={i === at ? "step" : undefined}
+ title={`${s.n}. ${s.title}`}
+ onClick={() => go(i)}
+ />
+ ))}
+ </div>
+ </section>
+ );
+});
diff --git a/common/components/report/slides/Slide.tsx b/common/components/report/slides/Slide.tsx
@@ -0,0 +1,399 @@
+import type { CSSProperties, ReactNode } from "react";
+import { citationHeading } from "../../citations/CitationCard";
+import { VerdictChip, VerdictTally } from "../VerdictChip";
+import { reportFullTitle, CITATION_KIND_LABELS, type CitationView, type ReportPageView } from "../../../lib/report/views";
+import { VERDICT_DEFAULTS, type Verdict, type VerdictStyle } from "../../../lib/report/verdicts";
+import type {
+ ClaimSlide,
+ FoundSlide,
+ SectionSlide,
+ SlideView,
+ SourcesSlide,
+ SummarySlide,
+ TitleSlide,
+} from "../../../lib/report/slides";
+import { useSlidesHost } from "./SlidesHost";
+
+// ONE SLIDE (lib/report/slides.ts SlideView), as the reader sees it in the
+// Slides view, a card in the Overview, and a page of slides.html/slides.pdf.
+// Presentational: no state, no effects — rendered on a server (slides.html)
+// or in the browser alike. Styles: common/styles/slides.css (`.rs-*`).
+//
+// Every slide is three rows: where it is (its kind and place, its number),
+// what it says (by layout), and the way back — "Read this in the article",
+// to its anchor. Its left edge is its MARK: a claim's verdict colour, else
+// the accent.
+
+export type SlideFit = "canvas" | "reflow";
+
+export function verdictStyleOf(view: Pick<ReportPageView, "verdicts">, v: Verdict): VerdictStyle {
+ return view.verdicts?.[v] ?? VERDICT_DEFAULTS[v];
+}
+
+function markOf(slide: SlideView, view: ReportPageView): string | undefined {
+ return slide.kind === "claim" && slide.verdict ? verdictStyleOf(view, slide.verdict).color : undefined;
+}
+
+function Eyebrow({ slide, total }: { slide: SlideView; total: number }) {
+ let kind: string;
+ let place: string | undefined;
+ switch (slide.kind) {
+ case "title":
+ kind = "Article";
+ break;
+ case "summary":
+ kind = "In brief";
+ break;
+ case "found":
+ kind = "Findings";
+ place = `${slide.claimCount} claim${slide.claimCount === 1 ? "" : "s"} ruled`;
+ break;
+ case "section":
+ kind = `Section ${slide.index} of ${slide.count}`;
+ break;
+ case "claim":
+ kind = "Claim";
+ place = slide.section.title;
+ break;
+ case "sources":
+ kind = "Sources";
+ break;
+ }
+ return (
+ <p className="rs-eyebrow">
+ <span className="rs-eyebrow-place">
+ <b>{kind}</b>
+ {place ? ` · ${place}` : null}
+ </span>
+ <span className="rs-eyebrow-n" aria-hidden="true">
+ {String(slide.n).padStart(2, "0")} / {String(total).padStart(2, "0")}
+ </span>
+ </p>
+ );
+}
+
+function Points({ points }: { points: readonly string[] }) {
+ const host = useSlidesHost();
+ return (
+ <ul className="rs-points">
+ {points.map((p, i) => (
+ <li key={i}>{host.md(p)}</li>
+ ))}
+ </ul>
+ );
+}
+
+// The picture a citation brings: a clip's poster, a post's capture, a
+// document's still of the sentence.
+function pictureOf(c: CitationView): { src: string; alt: string } | undefined {
+ if ((c.kind === "video" || c.kind === "audio") && c.poster) return { src: c.poster, alt: "" };
+ if (c.kind === "post" && c.shot) return { src: c.shot, alt: `Screenshot of the post${c.author ? ` by ${c.author}` : ""}` };
+ if (c.kind === "source" && c.image) return { src: c.image, alt: `The sentence as ${c.sourceTitle} shows it: ${c.quote}` };
+ return undefined;
+}
+
+// The evidence a slide shows: the picture (when there is one), the quote on
+// a rail, what it is and its marker.
+export function SlideEvidence({ c, quote = true }: { c: CitationView; quote?: boolean }) {
+ const host = useSlidesHost();
+ const pic = pictureOf(c);
+ const { title, meta } = citationHeading(c);
+ const rail = c.kind === "source" && c.sourceAccent ? ({ "--rs-ev-rail": c.sourceAccent } as CSSProperties) : undefined;
+ return (
+ <figure className="rs-ev" data-slide-evidence={c.id}>
+ {pic && (
+ // eslint-disable-next-line @next/next/no-img-element -- a published still of unknown size
+ <img className="rs-ev-pic" data-kind={c.kind} src={pic.src} alt={pic.alt} loading="lazy" />
+ )}
+ {quote && !(c.kind === "source" && pic) && (
+ <blockquote className="rs-ev-quote" style={rail}>
+ {c.quote}
+ </blockquote>
+ )}
+ <figcaption className="rs-ev-cap">
+ <span className="rs-ev-kind">
+ {CITATION_KIND_LABELS[c.kind]}
+ {meta.length > 0 ? ` · ${meta.join(" · ")}` : ""}
+ </span>
+ <span className="rs-ev-title">{host.cite(c.id, title)}</span>
+ </figcaption>
+ </figure>
+ );
+}
+
+function TitleBody({ slide }: { slide: TitleSlide }) {
+ return (
+ <>
+ {slide.series && <p className="rs-title-series">{slide.series}</p>}
+ <h2 className="rs-h rs-title-h">{slide.title}</h2>
+ {slide.line && <p className="rs-sub">{slide.line}</p>}
+ {slide.dates.length > 0 && <p className="rs-dates">{slide.dates.join(" · ")}</p>}
+ {slide.subject && (
+ <div
+ className="rs-subject"
+ style={slide.subject.accent ? ({ "--rs-subject": slide.subject.accent } as CSSProperties) : undefined}
+ >
+ <b>{slide.subject.title}</b>
+ {slide.subject.meta.length > 0 && <span>{slide.subject.meta.join(" · ")}</span>}
+ </div>
+ )}
+ </>
+ );
+}
+
+function SummaryBody({ slide, view }: { slide: SummarySlide; view: ReportPageView }) {
+ const host = useSlidesHost();
+ return (
+ <>
+ <h2 className="rs-h rs-h-sm">{slide.title}</h2>
+ {slide.points ? <Points points={slide.points} /> : slide.text ? <div className="rs-statement">{host.md(slide.text)}</div> : null}
+ {slide.tally && (
+ <div className="rs-chip">
+ <VerdictTally tally={slide.tally} styles={view.verdicts} />
+ </div>
+ )}
+ </>
+ );
+}
+
+function FoundBody({ slide, view }: { slide: FoundSlide; view: ReportPageView }) {
+ return (
+ <>
+ <h2 className="rs-h rs-h-sm">{slide.title}</h2>
+ <div className="rs-found" style={{ "--rs-cols": Math.min(slide.groups.length, 5) } as CSSProperties}>
+ {slide.groups.map((g) => {
+ const s = verdictStyleOf(view, g.verdict);
+ return (
+ <div key={g.verdict} className="rs-found-group" data-found-group={g.verdict} style={{ "--rs-verdict": s.color } as CSSProperties}>
+ <span className="rs-found-n">{g.claims.length}</span>
+ <span className="rs-found-label">{s.label}</span>
+ <ul className="rs-found-claims">
+ {g.claims.map((c) => (
+ <li key={c.id}>{c.title}</li>
+ ))}
+ </ul>
+ </div>
+ );
+ })}
+ </div>
+ </>
+ );
+}
+
+function SectionBody({ slide, view }: { slide: SectionSlide; view: ReportPageView }) {
+ const host = useSlidesHost();
+ const cited = slide.cite ? view.citations[slide.cite] : undefined;
+ const words = slide.points ? (
+ <Points points={slide.points} />
+ ) : slide.text ? (
+ <div className="rs-statement">{host.md(slide.text)}</div>
+ ) : slide.claims.length > 0 ? (
+ <ul className="rs-claimlist">
+ {slide.claims.map((c) => (
+ <li
+ key={c.id}
+ style={c.verdict ? ({ "--rs-verdict": verdictStyleOf(view, c.verdict).color } as CSSProperties) : undefined}
+ >
+ <i aria-hidden="true" />
+ <span>
+ {c.title}
+ {c.verdict && <span className="sr-only"> ({verdictStyleOf(view, c.verdict).label})</span>}
+ </span>
+ </li>
+ ))}
+ </ul>
+ ) : null;
+ if (slide.layout === "quote" && cited) {
+ return (
+ <>
+ <h2 className="rs-h rs-h-sm">{slide.title}</h2>
+ <PullQuote c={cited} />
+ </>
+ );
+ }
+ if (cited) {
+ return (
+ <div className="rs-split">
+ <div className="rs-col">
+ <h2 className="rs-h">{slide.title}</h2>
+ {words}
+ </div>
+ <SlideEvidence c={cited} />
+ </div>
+ );
+ }
+ return (
+ <>
+ <h2 className="rs-h">{slide.title}</h2>
+ {words}
+ </>
+ );
+}
+
+function PullQuote({ c }: { c: CitationView }) {
+ const host = useSlidesHost();
+ const { title, meta } = citationHeading(c);
+ return (
+ <figure className="rs-ev" data-slide-evidence={c.id}>
+ <blockquote className="rs-pull">{c.quote}</blockquote>
+ <figcaption className="rs-ev-cap" style={{ paddingLeft: "64px" }}>
+ <span className="rs-ev-kind">
+ {CITATION_KIND_LABELS[c.kind]}
+ {meta.length > 0 ? ` · ${meta.join(" · ")}` : ""}
+ </span>
+ <span className="rs-ev-title">{host.cite(c.id, title)}</span>
+ </figcaption>
+ </figure>
+ );
+}
+
+function ClaimHead({ slide, view, small }: { slide: ClaimSlide; view: ReportPageView; small?: boolean }) {
+ return (
+ <>
+ {(slide.verdict || slide.flag) && (
+ <div className="rs-chips rs-chip">
+ {slide.verdict && <VerdictChip verdict={slide.verdict} style={verdictStyleOf(view, slide.verdict)} />}
+ {slide.flag && <span className="rs-flag">{slide.flag}</span>}
+ </div>
+ )}
+ <h2 className={small ? "rs-h rs-h-sm" : "rs-h"}>{slide.title}</h2>
+ </>
+ );
+}
+
+function ClaimBody({ slide, view }: { slide: ClaimSlide; view: ReportPageView }) {
+ const cited = slide.cite ? view.citations[slide.cite] : undefined;
+ const finding = slide.points ? <Points points={slide.points} /> : slide.gist ? <p className="rs-gist">{slide.gist}</p> : null;
+ const text = slide.text ? <p className="rs-claimtext">{slide.text}</p> : null;
+ if (slide.layout === "quote" && cited) {
+ return (
+ <>
+ <ClaimHead slide={slide} view={view} small />
+ <PullQuote c={cited} />
+ </>
+ );
+ }
+ if (slide.layout === "evidence" && cited) {
+ return (
+ <div className="rs-split">
+ <div className="rs-col">
+ <ClaimHead slide={slide} view={view} small />
+ {text}
+ {finding}
+ </div>
+ <SlideEvidence c={cited} />
+ </div>
+ );
+ }
+ return (
+ <>
+ <ClaimHead slide={slide} view={view} />
+ {text}
+ {finding}
+ </>
+ );
+}
+
+function SourcesBody({ slide, pageUrl }: { slide: SourcesSlide; pageUrl?: string }) {
+ return (
+ <>
+ <h2 className="rs-h">{slide.title}</h2>
+ <div className="rs-figures">
+ <div className="rs-figure">
+ <b>{slide.references}</b>
+ <span>{slide.references === 1 ? "reference" : "references"}</span>
+ </div>
+ {slide.documents > 0 && (
+ <div className="rs-figure">
+ <b>{slide.documents}</b>
+ <span>{slide.documents === 1 ? "document quoted" : "documents quoted"}</span>
+ </div>
+ )}
+ </div>
+ {slide.closing && <p className="rs-sub">{slide.closing}</p>}
+ {pageUrl && <p className="rs-url">{pageUrl}</p>}
+ </>
+ );
+}
+
+export function Slide({
+ slide,
+ view,
+ total,
+ fit = "canvas",
+ pageUrl,
+ style,
+ id,
+ hideRead,
+}: {
+ slide: SlideView;
+ view: ReportPageView;
+ total: number;
+ fit?: SlideFit;
+ // The article's address, shown on the sources slide.
+ pageUrl?: string;
+ style?: CSSProperties;
+ id?: string;
+ // A thumbnail (the Overview's card) has no way back of its own.
+ hideRead?: boolean;
+}) {
+ const host = useSlidesHost();
+ const mark = markOf(slide, view);
+ let body: ReactNode;
+ switch (slide.kind) {
+ case "title":
+ body = <TitleBody slide={slide} />;
+ break;
+ case "summary":
+ body = <SummaryBody slide={slide} view={view} />;
+ break;
+ case "found":
+ body = <FoundBody slide={slide} view={view} />;
+ break;
+ case "section":
+ body = <SectionBody slide={slide} view={view} />;
+ break;
+ case "claim":
+ body = <ClaimBody slide={slide} view={view} />;
+ break;
+ case "sources":
+ body = <SourcesBody slide={slide} pageUrl={pageUrl} />;
+ break;
+ }
+ return (
+ <article
+ id={id}
+ className="rs-slide"
+ data-slide={slide.key}
+ data-slide-kind={slide.kind}
+ data-layout={slide.layout}
+ data-fit={fit}
+ style={{ ...(mark ? ({ "--rs-mark": mark } as CSSProperties) : {}), ...style }}
+ >
+ <span className="rs-band" aria-hidden="true" />
+ <Eyebrow slide={slide} total={total} />
+ <div className="rs-main">{body}</div>
+ <footer className="rs-foot">
+ <span className="rs-foot-name">{reportFullTitle(view)}</span>
+ {host.badge?.(slide)}
+ {!hideRead && (
+ <a
+ className="rs-read"
+ href={host.readHref(slide)}
+ data-slide-read={slide.anchorId}
+ onClick={
+ host.onRead
+ ? (e) => {
+ e.preventDefault();
+ host.onRead!(slide);
+ }
+ : undefined
+ }
+ >
+ Read this in the article <span aria-hidden="true">→</span>
+ </a>
+ )}
+ </footer>
+ </article>
+ );
+}
diff --git a/common/components/report/slides/SlidesHost.tsx b/common/components/report/slides/SlidesHost.tsx
@@ -0,0 +1,44 @@
+import { createContext, useContext, type ReactNode } from "react";
+import { CitedMarkdown } from "../../citations/CitedMarkdown";
+import { InlineCite } from "../../citations/InlineCite";
+import type { SlideView } from "../../../lib/report/slides";
+
+// WHAT A SLIDE NEEDS FROM WHERE IT IS SHOWN. The slides (Slide.tsx) are the
+// same everywhere; three things differ by host, and come from here:
+//
+// md a slide's markdown (a point, the summary's first paragraph, a
+// section's first sentences), its `cite:` links live
+// cite a citation's marker beside the evidence it shows
+// readHref where "Read this in the article" goes for a slide
+// onRead what following it does instead of navigating (the reader keeps
+// its place by switching views in the page)
+// badge something to show on a slide (umtool: its notes)
+//
+// The default is the export site's: CitedMarkdown and InlineCite, whose
+// numbers open the citation's card, inside the page's CitationsProvider.
+// umtool passes its own (a citation opens its evidence rail); slides.html its
+// static links.
+
+export type SlidesHost = {
+ md: (text: string) => ReactNode;
+ cite: (id: string, children: ReactNode) => ReactNode;
+ readHref: (slide: SlideView) => string;
+ onRead?: (slide: SlideView) => void;
+ badge?: (slide: SlideView) => ReactNode;
+};
+
+export const defaultSlidesHost: SlidesHost = {
+ md: (text) => <CitedMarkdown className="rs-md">{text}</CitedMarkdown>,
+ cite: (id, children) => <InlineCite id={id}>{children}</InlineCite>,
+ readHref: (slide) => `#${slide.anchorId}`,
+};
+
+const SlidesHostContext = createContext<SlidesHost>(defaultSlidesHost);
+
+export function SlidesHostProvider({ host, children }: { host: SlidesHost; children: ReactNode }) {
+ return <SlidesHostContext.Provider value={host}>{children}</SlidesHostContext.Provider>;
+}
+
+export function useSlidesHost(): SlidesHost {
+ return useContext(SlidesHostContext);
+}
diff --git a/common/components/report/slides/ViewSwitch.tsx b/common/components/report/slides/ViewSwitch.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { forwardRef } from "react";
+import { FileText, Layers, Presentation } from "lucide-react";
+import { READER_VIEWS, type ReaderView } from "../../../lib/report/slides";
+
+// THE VIEW SWITCH — Article | Slides | Overview, a segmented control: one
+// group of toggle buttons, the one in force pressed. The view is the URL's
+// (`?rv=`); the caller says what pressing one does. Styles: slides.css
+// `.rs-switch`, so the export site and umtool draw the same control.
+
+const VIEWS: Readonly<Record<ReaderView, { label: string; Icon: typeof FileText }>> = {
+ article: { label: "Article", Icon: FileText },
+ slides: { label: "Slides", Icon: Presentation },
+ iso: { label: "Overview", Icon: Layers },
+};
+
+export const ViewSwitch = forwardRef<
+ HTMLDivElement,
+ {
+ value: ReaderView;
+ onChange: (view: ReaderView) => void;
+ // The views offered, in READER_VIEWS order (default all).
+ views?: readonly ReaderView[];
+ className?: string;
+ }
+>(function ViewSwitch({ value, onChange, views = READER_VIEWS, className }, ref) {
+ return (
+ <div
+ ref={ref}
+ role="group"
+ aria-label="Read as"
+ data-view-switch={value}
+ className={`rs-root rs-switch${className ? ` ${className}` : ""}`}
+ >
+ {READER_VIEWS.filter((v) => views.includes(v)).map((view) => {
+ const { label, Icon } = VIEWS[view];
+ return (
+ <button
+ key={view}
+ type="button"
+ aria-pressed={value === view}
+ data-view={view}
+ onClick={() => {
+ if (view !== value) onChange(view);
+ }}
+ >
+ <Icon aria-hidden="true" />
+ {label}
+ </button>
+ );
+ })}
+ </div>
+ );
+});
diff --git a/common/components/report/slides/useReaderPlace.tsx b/common/components/report/slides/useReaderPlace.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useCallback, useMemo, useSyncExternalStore } from "react";
+import { parseReaderPlace, readerPlaceUrl, READER_VIEWS, type ReaderPlace, type ReaderView } from "../../../lib/report/slides";
+
+// THE READER'S PLACE, FROM THE URL — which view (`?rv=`) and where in it (the
+// hash), as lib/report/slides.ts parses it; the export site's reader and
+// umtool's both read and write it here. A switch pushes an entry (Back undoes
+// it); a step within a view replaces it. Back and a hash change re-read it.
+
+const listeners = new Set<() => void>();
+function subscribe(cb: () => void) {
+ listeners.add(cb);
+ window.addEventListener("popstate", cb);
+ window.addEventListener("hashchange", cb);
+ return () => {
+ listeners.delete(cb);
+ window.removeEventListener("popstate", cb);
+ window.removeEventListener("hashchange", cb);
+ };
+}
+const snapshot = () => `${window.location.search}${window.location.hash}`;
+const serverSnapshot = () => "";
+
+export type PlaceLike = { view: ReaderView; slide?: number | null; anchor?: string | null };
+
+export function readerUrl(place: PlaceLike): string {
+ return readerPlaceUrl(window.location.pathname, window.location.search, place);
+}
+
+export function goToPlace(place: PlaceLike, push: boolean): void {
+ const url = readerUrl(place);
+ const here = `${window.location.pathname}${window.location.search}${window.location.hash}`;
+ if (url === here) return;
+ window.history[push ? "pushState" : "replaceState"](window.history.state, "", url);
+ for (const l of listeners) l();
+}
+
+// The place in force; a view not offered (`views`) reads as the article.
+export function useReaderPlace(views: readonly ReaderView[] = READER_VIEWS): {
+ place: ReaderPlace;
+ go: (place: PlaceLike, push: boolean) => void;
+} {
+ const url = useSyncExternalStore(subscribe, snapshot, serverSnapshot);
+ const place = useMemo<ReaderPlace>(() => {
+ const at = url.indexOf("#");
+ const p = parseReaderPlace(at < 0 ? url : url.slice(0, at), at < 0 ? "" : url.slice(at));
+ return views.includes(p.view) ? p : { view: "article", slide: null, anchor: p.anchor };
+ }, [url, views]);
+ const go = useCallback((p: PlaceLike, push: boolean) => goToPlace(p, push), []);
+ return { place, go };
+}
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/exportSlides.test.ts b/common/lib/report/exportSlides.test.ts
@@ -0,0 +1,136 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import * as React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import type { Report } from "./schema";
+import { validateReport } from "./validate";
+import { buildReportPageView, type ReportPageView } from "./views";
+import { buildReportSlides } from "./slides";
+import { reportSlidesHtml, slidesImagePaths, SLIDES_EXPORT_CSS } from "./exportSlidesHtml";
+import { Slide } from "../../components/report/slides/Slide";
+
+(globalThis as { React?: typeof React }).React = React;
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const CSS = readFileSync(path.join(HERE, "..", "..", "styles", "slides.css"), "utf8");
+
+function report(): Report {
+ return {
+ format: "archilyzer-report",
+ version: 1,
+ id: "demo",
+ kind: "factcheck",
+ series: "Demo Checks",
+ title: "A demo <fact-check>",
+ summary: "One thing [right](cite:v1).",
+ published: "2026-10-01",
+ subject: { source: "s0" },
+ sources: { s0: { kind: "article", title: "An article", accent: "#8a6fb0" } },
+ citations: {
+ v1: { kind: "video", channel: "demo-channel", id: "abc123", start: 10, end: 20, quote: "one" },
+ p1: { kind: "post", channel: "demo-social", id: "123", quote: "three" },
+ a1: { kind: "source", source: "s0", quote: "the claim", image: "stills/a1.png" },
+ },
+ slides: { closing: "The end." },
+ sections: [
+ {
+ id: "ch1",
+ title: "Chapter one",
+ body: "Opening. Second.",
+ slide: { points: ["A point [citing](cite:v1)"], cite: "a1" },
+ claims: [
+ { id: "k1", text: "Claim one.", verdict: "CONTRADICTED", gist: "No.", sourceQuote: { citation: "a1" }, citations: ["a1", "v1"] },
+ { id: "k2", text: "Claim two.", verdict: "PARTLY", citations: ["p1"], slide: { layout: "quote" } },
+ { id: "k3", text: "Claim three.", flag: "No source given" },
+ ],
+ },
+ ],
+ };
+}
+
+function view(r = report()): ReportPageView {
+ assert.deepEqual(validateReport(r), []);
+ return buildReportPageView(r, {
+ record: (c) => ({ channel: c.channel, id: c.id, title: `Record ${c.id}` }),
+ poster: () => "/media/posters/abc123.png",
+ post: () => ({ author: "@demo", shot: "/media/posts/demo-social/123/shot.png" }),
+ });
+}
+
+const FOOTER = { revision: 2, date: "2026-10-04", reportSha256: "ab".repeat(32) };
+const images: Record<string, string> = {
+ "/media/posters/abc123.png": "data:image/png;base64,AAA",
+ "/reports/demo/stills/a1.png": "data:image/png;base64,BBB",
+ "/media/posts/demo-social/123/shot.png": "data:image/png;base64,CCC",
+};
+const html = (v = view(), siteUrl = "https://site.example/") =>
+ reportSlidesHtml(v, { css: CSS, accent: "#6a4bc4", siteUrl, footer: FOOTER, image: (p) => images[p] });
+
+test("one page per slide, numbered as the site's hash numbers them, in the slides' order", () => {
+ const v = view();
+ const slides = buildReportSlides(v);
+ const out = html(v);
+ const pages = [...out.matchAll(/<section class="rs-page" id="s-(\d+)"/g)].map((m) => Number(m[1]));
+ assert.deepEqual(pages, slides.map((s) => s.n));
+ const keys = [...out.matchAll(/<article class="rs-slide" data-slide="([^"]+)"/g)].map((m) => m[1]);
+ assert.deepEqual(keys, slides.map((s) => s.key));
+});
+
+test("one file: the stylesheet and the script inline, every picture the resolver's, nothing fetched", () => {
+ const out = html();
+ assert.ok(out.includes(CSS.slice(0, 200)));
+ assert.ok(out.includes(SLIDES_EXPORT_CSS.slice(0, 80)));
+ assert.equal([...out.matchAll(/<script>/g)].length, 1);
+ assert.doesNotMatch(out, /<script [^>]*src=|<link /);
+ const srcs = [...out.matchAll(/<img [^>]*src="([^"]+)"/g)].map((m) => m[1]);
+ // The clip's poster on k1's evidence, the still on the section's; k2's
+ // quote shows its post's words, not its capture.
+ assert.deepEqual(srcs.sort(), ["data:image/png;base64,AAA", "data:image/png;base64,BBB"]);
+ assert.deepEqual(slidesImagePaths(view()), ["/media/posters/abc123.png", "/reports/demo/stills/a1.png"]);
+ // The site's accent, on the light base.
+ assert.match(out, /<html lang="en" class="rs-root" style="--brand:#6a4bc4;/);
+ // Escaped, as everywhere.
+ assert.match(out, /A demo <fact-check>/);
+});
+
+test("the way back: each slide links to its place in the article on the site; with no site, nowhere", () => {
+ const out = html();
+ assert.match(out, /href="https:\/\/site\.example\/reports\/demo\/#k1" data-slide-read="k1"/);
+ assert.match(out, /href="https:\/\/site\.example\/reports\/demo\/#report-end" data-slide-read="report-end"/);
+ assert.doesNotMatch(html(view(), ""), /data-slide-read=/);
+ // A citation's number goes to the reference list under the deck, which links out.
+ assert.match(out, /<a href="#c-v1">\[1\]<\/a>/);
+ assert.match(out, /<li id="c-v1" value="1">/);
+ assert.match(out, /href="https:\/\/site\.example\/m\/demo-channel\/abc123\/10\.00-20\.00\/"/);
+});
+
+test("the sources slide names which document this is", () => {
+ assert.match(html(), /data-export-footer="">Revision 2 · 2026-10-04 · report sha256 abababababab</);
+ assert.match(html(), /The end\./);
+});
+
+test("the string slides mirror Slide.tsx: the same `rs-` classes, slide by slide", () => {
+ const v = view();
+ const slides = buildReportSlides(v);
+ // The export's own line on the sources slide (which document this is) is
+ // the one difference.
+ const out = html(v).replace(/<p class="rs-dates" data-export-footer="">[^<]*<\/p>/, "");
+ const classes = (markup: string) =>
+ [...new Set([...markup.matchAll(/class="([^"]*)"/g)].flatMap((m) => m[1].split(/\s+/)).filter((c) => c.startsWith("rs-") && c !== "rs-n"))].sort();
+ const articles = out.split(/(?=<article class="rs-slide")/).slice(1).map((a) => a.slice(0, a.indexOf("</article>") + 10));
+ slides.forEach((s, i) => {
+ const react = renderToStaticMarkup(
+ React.createElement(Slide, { slide: s, view: v, total: slides.length, pageUrl: "https://site.example/reports/demo/" }),
+ );
+ assert.deepEqual(classes(articles[i]), classes(react), s.key);
+ });
+});
+
+test("every `rs-` class a slide uses has a rule in slides.css", () => {
+ const used = new Set([...html().matchAll(/class="([^"]*)"/g)].flatMap((m) => m[1].split(/\s+/)).filter((c) => c.startsWith("rs-")));
+ const styled = new Set([...(CSS + SLIDES_EXPORT_CSS).matchAll(/\.(rs-[a-z-]+)/g)].map((m) => m[1]));
+ assert.deepEqual([...used].filter((c) => !styled.has(c)), []);
+});
diff --git a/common/lib/report/exportSlidesHtml.ts b/common/lib/report/exportSlidesHtml.ts
@@ -0,0 +1,416 @@
+// A REPORT'S SLIDES AS ONE HTML FILE — `slides.html`, and printed one 16:9
+// page per slide, `slides.pdf` (publish/reportExports.ts). The deck is
+// lib/report/slides.ts buildReportSlides of the report's page view — the same
+// slides the site's Slides view shows — drawn with the same stylesheet
+// (common/styles/slides.css, passed in as `css`, inlined whole).
+//
+// THE MARKUP MIRRORS common/components/report/slides/Slide.tsx, class for
+// class, as a string: this file is built by publish/reportExports.ts, which
+// the editor's server bundles, where React's server renderer is not to be
+// imported. Change one, change the other (exportSlides.test.ts holds the
+// classes the stylesheet styles).
+//
+// ONE FILE, NO NETWORK: its stylesheet and a few lines of script (fit each
+// slide to the window; ← → PgUp PgDn Home End step) inline; every picture
+// (a clip's poster, a post's capture, a sentence's still) whatever `image`
+// answers — a data: URI, or a relative path in the evidence pack. Colours are
+// the light base's, in the site's accent (`accent`, its light value). Each
+// citation's number links to the reference list under the deck (on screen;
+// the print is the slides alone); "Read this in the article" goes to the
+// article on the site, when it has a public URL.
+//
+// PURE: no I/O, deterministic for its input.
+
+import { citationAnchor } from "../citations/inline";
+import { escapeHtml, markdownToHtml, reportExportFooterLine, siteLink, type ReportExportFooter } from "./exportHtml";
+import {
+ buildReportSlides,
+ slidePointText,
+ type ClaimSlide,
+ type FoundSlide,
+ type SectionSlide,
+ type SlideView,
+ type SourcesSlide,
+ type SummarySlide,
+ type TitleSlide,
+} from "./slides";
+import { VERDICT_DEFAULTS, type Verdict, type VerdictStyle } from "./verdicts";
+import {
+ CITATION_KIND_LABELS,
+ orderedCitations,
+ reportFullTitle,
+ reportPagePath,
+ spanLabel,
+ type CitationView,
+ type ReportPageView,
+} from "./views";
+
+export type ReportSlidesHtmlOptions = {
+ // common/styles/slides.css, whole.
+ css: string;
+ // The site's accent on the light base (lib/accent.ts resolveAccent().light).
+ accent: string;
+ siteUrl?: string;
+ footer: ReportExportFooter;
+ // A site-root image path → the <img src>, or undefined to leave it out.
+ image: (sitePath: string) => string | undefined;
+};
+
+const style = (vars: Record<string, string | undefined>) => {
+ const s = Object.entries(vars)
+ .filter(([, v]) => v !== undefined)
+ .map(([k, v]) => `${k}:${v}`)
+ .join(";");
+ return s ? ` style="${escapeHtml(s)}"` : "";
+};
+
+const verdictStyleOf = (view: ReportPageView, v: Verdict): VerdictStyle => view.verdicts?.[v] ?? VERDICT_DEFAULTS[v];
+
+// The site-root paths of the pictures the slides show (each slide's cited
+// evidence): with viewImagePaths', what an export must inline or pack.
+export function slidesImagePaths(view: ReportPageView): string[] {
+ const out = new Set<string>();
+ for (const s of buildReportSlides(view)) {
+ // A quote shows its words, never its picture.
+ const id = (s.kind === "claim" || s.kind === "section") && s.layout !== "quote" ? s.cite : undefined;
+ const c = id ? view.citations[id] : undefined;
+ const pic = c ? pictureOf(c) : undefined;
+ if (pic) out.add(pic.src);
+ }
+ return [...out].sort();
+}
+
+function pictureOf(c: CitationView): { src: string; alt: string } | undefined {
+ if ((c.kind === "video" || c.kind === "audio") && c.poster) return { src: c.poster, alt: "" };
+ if (c.kind === "post" && c.shot) return { src: c.shot, alt: `Screenshot of the post${c.author ? ` by ${c.author}` : ""}` };
+ if (c.kind === "source" && c.image) return { src: c.image, alt: `The sentence as ${c.sourceTitle} shows it: ${c.quote}` };
+ return undefined;
+}
+
+// What CitationCard's heading says: a citation's title and its meta.
+function heading(c: CitationView): { title: string; meta: string[] } {
+ const host = (url: string) => {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return url;
+ }
+ };
+ switch (c.kind) {
+ case "video":
+ case "audio":
+ return {
+ title: c.record.title ?? `${c.record.channel}/${c.record.id}`,
+ meta: [c.record.channelTitle ?? c.record.channel, c.date ?? c.record.date, spanLabel(c.start, c.end)].filter(
+ (x): x is string => !!x,
+ ),
+ };
+ case "post":
+ return {
+ title: c.author ?? c.record.channelTitle ?? c.record.channel,
+ meta: [c.author ? (c.record.channelTitle ?? c.record.channel) : undefined, c.date ?? c.record.date].filter(
+ (x): x is string => !!x,
+ ),
+ };
+ case "source":
+ return { title: c.sourceTitle, meta: [c.date].filter((x): x is string => !!x) };
+ case "page":
+ return { title: c.title ?? host(c.href), meta: [host(c.href), c.date].filter((x): x is string => !!x) };
+ }
+}
+
+// Where a citation is, off this file: its moment page on the site, the
+// document, the web page.
+function citationUrl(c: CitationView, siteUrl: string | undefined): string | undefined {
+ if (c.kind === "video" || c.kind === "audio" || c.kind === "post") return siteLink(siteUrl, c.href) ?? c.record.originalUrl;
+ return c.href;
+}
+
+type Ctx = { view: ReportPageView; opts: ReportSlidesHtmlOptions; total: number };
+
+function md(text: string, x: Ctx): string {
+ const cite = (id: string) => {
+ const c = x.view.citations[id];
+ return c ? { number: c.number, anchor: citationAnchor(id) } : undefined;
+ };
+ return `<div class="rs-md">${markdownToHtml(text, cite, { siteUrl: x.opts.siteUrl })}</div>`;
+}
+
+function citeLabel(c: CitationView, title: string): string {
+ const n = c.number !== undefined ? `<sup class="rs-n"><a href="#${escapeHtml(citationAnchor(c.id))}">[${c.number}]</a></sup>` : "";
+ return `${escapeHtml(title)}${n}`;
+}
+
+function points(ps: readonly string[], x: Ctx): string {
+ return `<ul class="rs-points">${ps.map((p) => `<li>${md(p, x)}</li>`).join("")}</ul>`;
+}
+
+function chip(view: ReportPageView, v: Verdict, count?: number): string {
+ const s = verdictStyleOf(view, v);
+ return (
+ `<span data-verdict="${escapeHtml(v)}"${style({ "--verdict": s.color })}><span aria-hidden="true"></span>${escapeHtml(s.label)}` +
+ `${count !== undefined ? `<span>${count}</span>` : ""}</span>`
+ );
+}
+
+function evidence(c: CitationView, x: Ctx): string {
+ const pic = pictureOf(c);
+ const src = pic ? x.opts.image(pic.src) : undefined;
+ const { title, meta } = heading(c);
+ const rail = c.kind === "source" ? c.sourceAccent : undefined;
+ return (
+ `<figure class="rs-ev" data-slide-evidence="${escapeHtml(c.id)}">` +
+ (src ? `<img class="rs-ev-pic" data-kind="${c.kind}" src="${escapeHtml(src)}" alt="${escapeHtml(pic!.alt)}">` : "") +
+ (!(c.kind === "source" && src) ? `<blockquote class="rs-ev-quote"${style({ "--rs-ev-rail": rail })}>${escapeHtml(c.quote)}</blockquote>` : "") +
+ `<figcaption class="rs-ev-cap"><span class="rs-ev-kind">${escapeHtml(
+ [CITATION_KIND_LABELS[c.kind], ...meta].join(" · "),
+ )}</span><span class="rs-ev-title">${citeLabel(c, title)}</span></figcaption></figure>`
+ );
+}
+
+function pull(c: CitationView): string {
+ const { title, meta } = heading(c);
+ return (
+ `<figure class="rs-ev" data-slide-evidence="${escapeHtml(c.id)}"><blockquote class="rs-pull">${escapeHtml(c.quote)}</blockquote>` +
+ `<figcaption class="rs-ev-cap" style="padding-left:64px"><span class="rs-ev-kind">${escapeHtml(
+ [CITATION_KIND_LABELS[c.kind], ...meta].join(" · "),
+ )}</span><span class="rs-ev-title">${citeLabel(c, title)}</span></figcaption></figure>`
+ );
+}
+
+function titleBody(s: TitleSlide): string {
+ return (
+ (s.series ? `<p class="rs-title-series">${escapeHtml(s.series)}</p>` : "") +
+ `<h2 class="rs-h rs-title-h">${escapeHtml(s.title)}</h2>` +
+ (s.line ? `<p class="rs-sub">${escapeHtml(s.line)}</p>` : "") +
+ (s.dates.length ? `<p class="rs-dates">${escapeHtml(s.dates.join(" · "))}</p>` : "") +
+ (s.subject
+ ? `<div class="rs-subject"${style({ "--rs-subject": s.subject.accent })}><b>${escapeHtml(s.subject.title)}</b>` +
+ `${s.subject.meta.length ? `<span>${escapeHtml(s.subject.meta.join(" · "))}</span>` : ""}</div>`
+ : "")
+ );
+}
+
+function summaryBody(s: SummarySlide, x: Ctx): string {
+ return (
+ `<h2 class="rs-h rs-h-sm">${escapeHtml(s.title)}</h2>` +
+ (s.points ? points(s.points, x) : s.text ? `<div class="rs-statement">${md(s.text, x)}</div>` : "") +
+ (s.tally
+ ? `<div class="rs-chip"><ul data-verdict-tally="">${s.tally.map((t) => `<li>${chip(x.view, t.verdict, t.count)}</li>`).join("")}</ul></div>`
+ : "")
+ );
+}
+
+function foundBody(s: FoundSlide, x: Ctx): string {
+ return (
+ `<h2 class="rs-h rs-h-sm">${escapeHtml(s.title)}</h2>` +
+ `<div class="rs-found"${style({ "--rs-cols": String(Math.min(s.groups.length, 5)) })}>` +
+ s.groups
+ .map((g) => {
+ const v = verdictStyleOf(x.view, g.verdict);
+ return (
+ `<div class="rs-found-group" data-found-group="${escapeHtml(g.verdict)}"${style({ "--rs-verdict": v.color })}>` +
+ `<span class="rs-found-n">${g.claims.length}</span><span class="rs-found-label">${escapeHtml(v.label)}</span>` +
+ `<ul class="rs-found-claims">${g.claims.map((c) => `<li>${escapeHtml(c.title)}</li>`).join("")}</ul></div>`
+ );
+ })
+ .join("") +
+ `</div>`
+ );
+}
+
+function sectionBody(s: SectionSlide, x: Ctx): string {
+ const cited = s.cite ? x.view.citations[s.cite] : undefined;
+ const words = s.points
+ ? points(s.points, x)
+ : s.text
+ ? `<div class="rs-statement">${md(s.text, x)}</div>`
+ : s.claims.length
+ ? `<ul class="rs-claimlist">${s.claims
+ .map((c) => {
+ const v = c.verdict ? verdictStyleOf(x.view, c.verdict) : undefined;
+ return `<li${style({ "--rs-verdict": v?.color })}><i aria-hidden="true"></i><span>${escapeHtml(c.title)}${
+ v ? `<span class="sr-only"> (${escapeHtml(v.label)})</span>` : ""
+ }</span></li>`;
+ })
+ .join("")}</ul>`
+ : "";
+ if (s.layout === "quote" && cited) return `<h2 class="rs-h rs-h-sm">${escapeHtml(s.title)}</h2>${pull(cited)}`;
+ if (cited) {
+ return `<div class="rs-split"><div class="rs-col"><h2 class="rs-h">${escapeHtml(s.title)}</h2>${words}</div>${evidence(cited, x)}</div>`;
+ }
+ return `<h2 class="rs-h">${escapeHtml(s.title)}</h2>${words}`;
+}
+
+function claimHead(s: ClaimSlide, x: Ctx, small: boolean): string {
+ const chips =
+ s.verdict || s.flag
+ ? `<div class="rs-chips rs-chip">${s.verdict ? chip(x.view, s.verdict) : ""}${s.flag ? `<span class="rs-flag">${escapeHtml(s.flag)}</span>` : ""}</div>`
+ : "";
+ return `${chips}<h2 class="${small ? "rs-h rs-h-sm" : "rs-h"}">${escapeHtml(s.title)}</h2>`;
+}
+
+function claimBody(s: ClaimSlide, x: Ctx): string {
+ const cited = s.cite ? x.view.citations[s.cite] : undefined;
+ const finding = s.points ? points(s.points, x) : s.gist ? `<p class="rs-gist">${escapeHtml(s.gist)}</p>` : "";
+ const text = s.text ? `<p class="rs-claimtext">${escapeHtml(s.text)}</p>` : "";
+ if (s.layout === "quote" && cited) return `${claimHead(s, x, true)}${pull(cited)}`;
+ if (s.layout === "evidence" && cited) {
+ return `<div class="rs-split"><div class="rs-col">${claimHead(s, x, true)}${text}${finding}</div>${evidence(cited, x)}</div>`;
+ }
+ return `${claimHead(s, x, false)}${text}${finding}`;
+}
+
+function sourcesBody(s: SourcesSlide, x: Ctx): string {
+ const pageUrl = siteLink(x.opts.siteUrl, reportPagePath(x.view.id));
+ return (
+ `<h2 class="rs-h">${escapeHtml(s.title)}</h2><div class="rs-figures">` +
+ `<div class="rs-figure"><b>${s.references}</b><span>${s.references === 1 ? "reference" : "references"}</span></div>` +
+ (s.documents > 0
+ ? `<div class="rs-figure"><b>${s.documents}</b><span>${s.documents === 1 ? "document quoted" : "documents quoted"}</span></div>`
+ : "") +
+ `</div>` +
+ (s.closing ? `<p class="rs-sub">${escapeHtml(s.closing)}</p>` : "") +
+ (pageUrl ? `<p class="rs-url"><a href="${escapeHtml(pageUrl)}">${escapeHtml(pageUrl)}</a></p>` : "") +
+ `<p class="rs-dates" data-export-footer="">${escapeHtml(reportExportFooterLine(x.opts.footer))}</p>`
+ );
+}
+
+function eyebrow(s: SlideView, total: number): string {
+ let kind = "";
+ let place: string | undefined;
+ switch (s.kind) {
+ case "title":
+ kind = "Article";
+ break;
+ case "summary":
+ kind = "In brief";
+ break;
+ case "found":
+ kind = "Findings";
+ place = `${s.claimCount} claim${s.claimCount === 1 ? "" : "s"} ruled`;
+ break;
+ case "section":
+ kind = `Section ${s.index} of ${s.count}`;
+ break;
+ case "claim":
+ kind = "Claim";
+ place = s.section.title;
+ break;
+ case "sources":
+ kind = "Sources";
+ break;
+ }
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return (
+ `<p class="rs-eyebrow"><span class="rs-eyebrow-place"><b>${escapeHtml(kind)}</b>${place ? ` · ${escapeHtml(place)}` : ""}</span>` +
+ `<span class="rs-eyebrow-n" aria-hidden="true">${pad(s.n)} / ${pad(total)}</span></p>`
+ );
+}
+
+function slideHtml(s: SlideView, x: Ctx): string {
+ let body: string;
+ switch (s.kind) {
+ case "title":
+ body = titleBody(s);
+ break;
+ case "summary":
+ body = summaryBody(s, x);
+ break;
+ case "found":
+ body = foundBody(s, x);
+ break;
+ case "section":
+ body = sectionBody(s, x);
+ break;
+ case "claim":
+ body = claimBody(s, x);
+ break;
+ case "sources":
+ body = sourcesBody(s, x);
+ break;
+ }
+ const mark = s.kind === "claim" && s.verdict ? verdictStyleOf(x.view, s.verdict).color : undefined;
+ const article = siteLink(x.opts.siteUrl, `${reportPagePath(x.view.id)}#${s.anchorId}`);
+ return (
+ `<article class="rs-slide" data-slide="${escapeHtml(s.key)}" data-slide-kind="${s.kind}" data-layout="${s.layout}" data-fit="canvas"${style({ "--rs-mark": mark })}>` +
+ `<span class="rs-band" aria-hidden="true"></span>${eyebrow(s, x.total)}<div class="rs-main">${body}</div>` +
+ `<footer class="rs-foot"><span class="rs-foot-name">${escapeHtml(reportFullTitle(x.view))}</span>` +
+ (article ? `<a class="rs-read" href="${escapeHtml(article)}" data-slide-read="${escapeHtml(s.anchorId)}">Read this in the article <span aria-hidden="true">→</span></a>` : "") +
+ `</footer></article>`
+ );
+}
+
+function referencesHtml(x: Ctx): string {
+ const refs = orderedCitations(x.view);
+ if (refs.length === 0) return "";
+ return (
+ `<section class="rs-refs" aria-labelledby="rs-refs-h"><h2 id="rs-refs-h">References</h2><ol>` +
+ refs
+ .map((c) => {
+ const { title, meta } = heading(c);
+ const url = citationUrl(c, x.opts.siteUrl);
+ const t = url ? `<a href="${escapeHtml(url)}">${escapeHtml(title)}</a>` : escapeHtml(title);
+ return (
+ `<li id="${escapeHtml(citationAnchor(c.id))}" value="${c.number ?? ""}"><span class="rs-ev-kind">${escapeHtml(
+ [CITATION_KIND_LABELS[c.kind], ...meta].join(" · "),
+ )}</span> ${t}<q>${escapeHtml(c.quote)}</q></li>`
+ );
+ })
+ .join("") +
+ `</ol></section>`
+ );
+}
+
+// The deck around the slides: one screen per slide, snapped; in print, one
+// 1280 × 720 page per slide and nothing else.
+export const SLIDES_EXPORT_CSS = `
+html{scroll-snap-type:y mandatory;background:var(--rs-ground);-webkit-text-size-adjust:100%}
+body{margin:0;background:var(--rs-ground);color:var(--rs-ink);font-family:var(--rs-sans)}
+.rs-page{height:100vh;height:100dvh;display:flex;align-items:center;justify-content:center;scroll-snap-align:start;overflow:hidden}
+.rs-page-box{position:relative;flex:none;width:calc(1280px * var(--k, 0.5));height:calc(720px * var(--k, 0.5));border-radius:12px;box-shadow:var(--rs-shadow)}
+.rs-page-box>.rs-slide{position:absolute;left:0;top:0;transform:scale(var(--k, 0.5));transform-origin:0 0}
+.rs-n{font-family:var(--rs-mono);font-size:.7em;margin-left:2px}
+.rs-n a,.rs-md a{color:var(--rs-accent)}
+.rs-md sup.cite{font-family:var(--rs-mono);font-size:.62em}
+.rs-url a{color:inherit}
+.rs-refs{max-width:46rem;margin:0 auto;padding:3rem 1rem 4rem;scroll-snap-align:start;font-size:15px;line-height:1.5}
+.rs-refs h2{font-family:var(--rs-display);font-size:1.5rem;margin:0 0 1rem}
+.rs-refs ol{margin:0;padding-left:2.2rem;display:flex;flex-direction:column;gap:.8rem}
+.rs-refs li .rs-ev-kind{display:block}
+.rs-refs q{display:block;color:var(--rs-muted);font-style:italic}
+.rs-refs a{color:var(--rs-accent)}
+@page{size:1280px 720px;margin:0}
+@media print{
+html,body{background:var(--rs-paper);scroll-snap-type:none}
+.rs-page{width:1280px;height:720px;break-after:page;page-break-after:always}
+.rs-page:last-of-type{break-after:auto;page-break-after:auto}
+.rs-page-box{width:1280px;height:720px;box-shadow:none;border-radius:0}
+.rs-page-box>.rs-slide{transform:none;border-radius:0}
+.rs-refs{display:none}
+}
+`.trim();
+
+// Fit each slide to the window; step with the keys.
+const SLIDES_SCRIPT = `(function(){var d=document,r=d.documentElement;function fit(){r.style.setProperty("--k",String(Math.min(innerWidth/1280,innerHeight/720)*0.96))}fit();addEventListener("resize",fit);addEventListener("keydown",function(e){if(e.altKey||e.ctrlKey||e.metaKey)return;var p=[].slice.call(d.querySelectorAll(".rs-page"));var y=scrollY+innerHeight/2,i=0;for(var j=0;j<p.length;j++)if(p[j].offsetTop<=y)i=j;var m={ArrowRight:1,PageDown:1,ArrowDown:1,ArrowLeft:-1,PageUp:-1,ArrowUp:-1,Home:-i,End:p.length-1-i}[e.key];if(m===undefined||!p.length)return;e.preventDefault();var t=p[Math.max(0,Math.min(p.length-1,i+m))];t.scrollIntoView();history.replaceState(null,"","#"+t.id)})})();`;
+
+export function reportSlidesHtml(view: ReportPageView, opts: ReportSlidesHtmlOptions): string {
+ const slides = buildReportSlides(view);
+ const x: Ctx = { view, opts, total: slides.length };
+ const pages = slides
+ .map(
+ (s) =>
+ `<section class="rs-page" id="s-${s.n}" aria-label="${escapeHtml(`Slide ${s.n} of ${slides.length}: ${slidePointText(s.title)}`)}">` +
+ `<div class="rs-page-box">${slideHtml(s, x)}</div></section>`,
+ )
+ .join("\n");
+ const root = `--brand:${opts.accent};--brand-soft:color-mix(in oklab, ${opts.accent} 12%, transparent);color-scheme:light`;
+ return (
+ `<!doctype html>\n<html lang="en" class="rs-root" style="${escapeHtml(root)}">\n<head>\n<meta charset="utf-8">\n` +
+ `<meta name="viewport" content="width=device-width, initial-scale=1">\n` +
+ `<title>${escapeHtml(`${reportFullTitle(view)} — slides`)}</title>\n` +
+ `<meta name="generator" content="Archilyzer">\n` +
+ `<style>\n${opts.css}\n${SLIDES_EXPORT_CSS}\n</style>\n</head>\n<body>\n<main data-report-slides="${escapeHtml(view.id)}">\n` +
+ `${pages}\n${referencesHtml(x)}\n</main>\n<script>${SLIDES_SCRIPT}</script>\n</body>\n</html>\n`
+ );
+}
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,316 @@
+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,
+ slideIndexForPlace,
+ 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("slideIndexForPlace: the slide number clamped, else the anchor's slide", () => {
+ const view = viewOf(base());
+ const slides = buildReportSlides(view);
+ const order = reportPageAnchorIds(view);
+ const at = (search: string, hash: string) => slideIndexForPlace(slides, parseReaderPlace(search, hash), order);
+ assert.equal(at("?rv=slides", "#s-4"), 3);
+ assert.equal(at("?rv=slides", "#s-99"), slides.length - 1);
+ assert.equal(at("?rv=slides", "#s-0"), 0);
+ assert.equal(at("?rv=slides", "#k2"), 5);
+ assert.equal(at("?rv=slides", ""), 0);
+ assert.equal(slideIndexForPlace([], parseReaderPlace("?rv=slides", "#s-2"), order), 0);
+});
+
+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,462 @@
+// 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 slide (0-based) a slides place shows: its `#s-<n>`, clamped, else the
+// slide for its anchor (slideForAnchor), else the first.
+export function slideIndexForPlace(slides: readonly SlideView[], place: ReaderPlace, order?: readonly string[]): number {
+ if (slides.length === 0) return 0;
+ if (place.slide !== null) return Math.min(Math.max(place.slide - 1, 0), slides.length - 1);
+ return (slideForAnchor(slides, place.anchor, order)?.n ?? 1) - 1;
+}
+
+// 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";
@@ -92,14 +92,18 @@ export function reportCitationsDownloadPath(reportId: string, format: "json" | "
}
// A report's exports (publish/reportExports.ts): the whole report as files a
-// reader can save and host again. Their names, and where compose publishes
-// them beside the report's page.
-export const REPORT_EXPORT_FORMATS = ["html", "pdf", "md", "zip"] as const;
+// reader can save and host again — the article (html, pdf, md), its slides
+// (lib/report/slides.ts: `slides` is slides.html, `slides-pdf` one 16:9 page
+// per slide; none for a report whose `slides.hide` says so) and the evidence
+// pack. Their names, and where compose publishes them beside the report's page.
+export const REPORT_EXPORT_FORMATS = ["html", "pdf", "md", "slides", "slides-pdf", "zip"] as const;
export type ReportExportFormat = (typeof REPORT_EXPORT_FORMATS)[number];
export const REPORT_EXPORT_FILENAMES: Readonly<Record<ReportExportFormat, string>> = {
html: "report.html",
pdf: "report.pdf",
md: "report.md",
+ slides: "slides.html",
+ "slides-pdf": "slides.pdf",
zip: "evidence-pack.zip",
};
@@ -255,6 +259,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 +268,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 +306,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 +562,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 +575,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/common/publish/composeReports.test.ts b/common/publish/composeReports.test.ts
@@ -614,9 +614,24 @@ test("reports export: every format from the resolved view, a self-contained HTML
const r = await exportSiteReports({ siteId: "cited", paths, now, openPdfPrinter: fakePrinter(printed), onLog: () => {} });
assert.deepEqual(r.problems, []);
const dir = reportExportDir(paths, "cited", REPORT);
- assert.deepEqual(readdirSync(dir).sort(), ["evidence-pack.zip", "export.json", "report.html", "report.md", "report.pdf"]);
+ assert.deepEqual(readdirSync(dir).sort(), [
+ "evidence-pack.zip",
+ "export.json",
+ "report.html",
+ "report.md",
+ "report.pdf",
+ "slides.html",
+ "slides.pdf",
+ ]);
const html = readFileSync(path.join(dir, "report.html"), "utf8");
- assert.deepEqual(printed, [html], "the PDF is the HTML, printed");
+ const slides = readFileSync(path.join(dir, "slides.html"), "utf8");
+ assert.deepEqual(printed, [html, slides], "each PDF is its HTML, printed");
+ // The slides: one file, the site's stylesheet and its few lines of script
+ // inline, every picture inlined, one page per slide.
+ assert.match(slides, /\.rs-slide\s*\{/);
+ assert.equal([...slides.matchAll(/<script>/g)].length, 1);
+ for (const m of slides.matchAll(/<img [^>]*src="([^"]+)"/g)) assert.match(m[1], /^data:image\//);
+ assert.ok([...slides.matchAll(/class="rs-page" id="s-\d+"/g)].length >= 4);
assert.doesNotMatch(html, /<script/i);
const imgs = [...html.matchAll(/<img [^>]*src="([^"]+)"/g)].map((m) => m[1]);
assert.equal(imgs.length, 2, "the claim's still and the post's screenshot");
@@ -632,7 +647,7 @@ test("reports export: every format from the resolved view, a self-contained HTML
path.join(dir, "export.json"),
);
assert.equal(manifest.reportSha256, sha(path.join(paths.sitesDir, "cited", "reports", REPORT, "report.json")));
- assert.deepEqual(Object.keys(manifest.files), ["html", "pdf", "md", "zip"]);
+ assert.deepEqual(Object.keys(manifest.files), ["html", "pdf", "md", "slides", "slides-pdf", "zip"]);
assert.equal(manifest.files.html.sha256, sha(path.join(dir, "report.html")));
assert.deepEqual(manifest.notes, []);
assert.match(html, new RegExp(`report sha256 ${manifest.reportSha256.slice(0, 12)}`));
@@ -649,20 +664,21 @@ test("reports export: every format from the resolved view, a self-contained HTML
`${REPORT}/media/report/stills/a01.png`,
`${REPORT}/report.html`,
`${REPORT}/report.md`,
+ `${REPORT}/slides.html`,
]);
const packed = execFileSync("unzip", ["-p", zip, `${REPORT}/report.html`], { encoding: "utf8" });
assert.match(packed, /<video controls preload="none" src="media\/clips\/demo-channel\/abc123\/10\.00-20\.00\.mp4">/);
assert.match(packed, /<img src="media\/report\/stills\/a01\.png"/);
// The same report exports to the same bytes.
- const before = { html: sha(path.join(dir, "report.html")), zip: sha(zip) };
+ const before = { html: sha(path.join(dir, "report.html")), slides: sha(path.join(dir, "slides.html")), zip: sha(zip) };
await exportSiteReports({ siteId: "cited", paths, now, openPdfPrinter: fakePrinter([]), onLog: () => {} });
- assert.deepEqual({ html: sha(path.join(dir, "report.html")), zip: sha(zip) }, before);
+ assert.deepEqual({ html: sha(path.join(dir, "report.html")), slides: sha(path.join(dir, "slides.html")), zip: sha(zip) }, before);
});
test("compose publishes the exports beside the report and lists them as downloads", async () => {
await compose("cited");
- for (const f of ["report.html", "report.pdf", "report.md", "evidence-pack.zip"]) {
+ for (const f of ["report.html", "report.pdf", "report.md", "slides.html", "slides.pdf", "evidence-pack.zip"]) {
assert.equal(sha(pub("reports", REPORT, f)), sha(path.join(reportExportDir(paths, "cited", REPORT), f)), f);
}
const view = readJson<{ downloads: Record<string, string> }>(pub("reports", REPORT, "page.json"));
@@ -670,6 +686,8 @@ test("compose publishes the exports beside the report and lists them as download
html: `/reports/${REPORT}/report.html`,
pdf: `/reports/${REPORT}/report.pdf`,
md: `/reports/${REPORT}/report.md`,
+ slides: `/reports/${REPORT}/slides.html`,
+ "slides-pdf": `/reports/${REPORT}/slides.pdf`,
zip: `/reports/${REPORT}/evidence-pack.zip`,
json: `/reports/${REPORT}/citations.json`,
csv: `/reports/${REPORT}/citations.csv`,
@@ -761,7 +779,7 @@ test("an export of another version of the report is not published; a pack over t
assert.ok(existsSync(pub("reports", REPORT, "report.html")));
assert.ok(!existsSync(pub("reports", REPORT, "evidence-pack.zip")));
const view = readJson<{ downloads: Record<string, string> }>(pub("reports", REPORT, "page.json"));
- assert.deepEqual(Object.keys(view.downloads), ["html", "pdf", "md", "json", "csv"]);
+ assert.deepEqual(Object.keys(view.downloads), ["html", "pdf", "md", "slides", "slides-pdf", "json", "csv"]);
});
test("reports export: no browser skips the PDF with a note; no zip fails the pack, naming it", async () => {
@@ -773,8 +791,11 @@ test("reports export: no browser skips the PDF with a note; no zip fails the pac
onLog: () => {},
});
const dir = reportExportDir(paths, "cited", REPORT);
- assert.deepEqual(readdirSync(dir).sort(), ["export.json", "report.html", "report.md"]);
- assert.deepEqual(r.exported[0].manifest.notes, ["report.pdf skipped: Playwright is not available on this host"]);
+ assert.deepEqual(readdirSync(dir).sort(), ["export.json", "report.html", "report.md", "slides.html"]);
+ assert.deepEqual(r.exported[0].manifest.notes, [
+ "report.pdf skipped: Playwright is not available on this host",
+ "slides.pdf skipped: Playwright is not available on this host",
+ ]);
assert.equal(r.problems.length, 1);
assert.equal(r.problems[0].format, "zip");
assert.match(r.problems[0].message, /needs the `zip` program/);
diff --git a/common/publish/reportExports.ts b/common/publish/reportExports.ts
@@ -23,7 +23,15 @@
// with a note — never a failure
// report.md plain Markdown, numbered references
// (lib/report/exportMarkdown.ts)
-// evidence-pack.zip `<reportId>/report.html` pointing at its own media/
+// slides.html the report as slides (lib/report/slides.ts), ONE
+// self-contained file like report.html
+// (lib/report/exportSlidesHtml.ts): the site's slide
+// stylesheet inline, a few lines of script for the keys,
+// every picture a data: URI. None for a report whose
+// `slides.hide` says so
+// slides.pdf those slides printed by headless Chromium, one
+// 1280 × 720 page per slide; skipped like report.pdf
+// evidence-pack.zip `<reportId>/report.html` (and slides.html) pointing at its own media/
// (the clips play offline, the stills and screenshots
// as files), report.md, citations.json and .csv. Packed
// by the system `zip`, deterministically (sorted names,
@@ -65,6 +73,9 @@ import { parseMomentKey, type SpanMoment } from "../lib/citations/moments";
import type { Report } from "../lib/report/schema";
import { reportExportHtml, type ExportClip, type ReportExportFooter } from "../lib/report/exportHtml";
import { reportExportMarkdown } from "../lib/report/exportMarkdown";
+import { reportSlidesHtml, slidesImagePaths } from "../lib/report/exportSlidesHtml";
+import { hasSlides } from "../lib/report/slides";
+import { resolveAccent } from "../lib/accent";
import {
REPORT_EXPORT_FILENAMES,
REPORT_EXPORT_FORMATS,
@@ -113,9 +124,12 @@ export type ReportExportProblem = {
message: string;
};
-// Prints HTML to PDF. One is opened per run and closed at its end.
+// Prints HTML to PDF — A4, or a page size (`size`, slides.pdf's 1280 × 720).
+// One is opened per run and closed at its end.
+export type PdfPageSize = { width: string; height: string };
+export const SLIDES_PDF_PAGE: PdfPageSize = { width: "1280px", height: "720px" };
export type PdfPrinter = {
- print: (html: string) => Promise<Uint8Array>;
+ print: (html: string, size?: PdfPageSize) => Promise<Uint8Array>;
close: () => Promise<void>;
};
@@ -194,13 +208,13 @@ export const openPlaywrightPdfPrinter: OpenPdfPrinter = async () => {
}
const b = browser;
return {
- print: async (html) => {
+ print: async (html, size) => {
const context = await b.newContext({});
try {
const page = await context.newPage();
if (!page.setContent || !page.pdf) throw new Error("this Playwright cannot print a page");
await page.setContent(html, { waitUntil: "load" });
- return await page.pdf({ format: "A4", printBackground: true });
+ return await page.pdf(size ? { ...size, printBackground: true } : { format: "A4", printBackground: true });
} finally {
await context.close();
}
@@ -268,6 +282,23 @@ function viewImagePaths(view: ReportPageView): string[] {
return [...out].sort();
}
+// The pictures only the slides show (a clip's poster on a claim's slide):
+// inlined when this host has the file, else the slide shows its quote alone —
+// never a problem, a poster may be a link elsewhere.
+function slidesOnlyImagePaths(view: ReportPageView): string[] {
+ if (!hasSlides(view)) return [];
+ const named = new Set(viewImagePaths(view));
+ return slidesImagePaths(view).filter((p) => p.startsWith("/") && !named.has(p));
+}
+
+// The slides' stylesheet, the one the sites draw them with: inlined into slides.html.
+export async function readSlidesCss(paths: Pick<Paths, "monorepoRoot"> = getPaths()): Promise<string> {
+ return readFile(path.join(/* turbopackIgnore: true */ paths.monorepoRoot, "common", "styles", "slides.css"), "utf8");
+}
+
+// What slides.html is drawn with: the stylesheet, and the site's accent on the light base.
+type SlidesLook = { css: string; accent: string };
+
// ─── Where a view's files are on this host ───
export type ReportFiles = {
@@ -344,6 +375,8 @@ async function buildEvidencePack(o: {
markdown: string;
outFile: string;
zipBin: string;
+ // The slides' look, when the pack carries slides.html.
+ slides?: SlidesLook;
}): Promise<void> {
const tmp = await mkdtemp(path.join(os.tmpdir(), "report-evidence-pack-"));
try {
@@ -356,7 +389,7 @@ async function buildEvidencePack(o: {
names.push(`${top}/${rel}`);
};
const images = new Map<string, string>();
- for (const p of viewImagePaths(o.view)) {
+ for (const p of [...viewImagePaths(o.view), ...(o.slides ? slidesOnlyImagePaths(o.view) : [])]) {
const local = o.files.local(p);
const rel = packPathOf(o.view, p);
if (!local || !rel) continue;
@@ -383,6 +416,17 @@ async function buildEvidencePack(o: {
clip: (c) => clips.get(c.moment),
});
await put(REPORT_EXPORT_FILENAMES.html, html);
+ if (o.slides) {
+ await put(
+ REPORT_EXPORT_FILENAMES.slides,
+ reportSlidesHtml(o.view, {
+ ...o.slides,
+ siteUrl: o.site.siteUrl || undefined,
+ footer: o.footer,
+ image: (p) => images.get(p),
+ }),
+ );
+ }
await put(REPORT_EXPORT_FILENAMES.md, o.markdown);
await put("citations.json", `${JSON.stringify(citationSet(o.report, o.view), null, 2)}\n`);
await put("citations.csv", citationsCsv(o.view));
@@ -432,7 +476,7 @@ async function exportOneReport(o: {
// changed report.json is committed as the next one.
export async function writeReportExports(o: {
dir: string;
- site: Pick<Site, "siteId" | "siteUrl" | "siteTitle">;
+ site: Pick<Site, "siteId" | "siteUrl" | "siteTitle"> & { accent?: unknown };
report: Report;
view: ReportPageView;
reportSha256: string;
@@ -445,6 +489,8 @@ export async function writeReportExports(o: {
log: (line: string) => void;
signal?: AbortSignal;
history?: { gitDir: string; reportJson: Uint8Array };
+ // The slides' stylesheet; default common/styles/slides.css (readSlidesCss).
+ slidesCss?: string;
}): Promise<{ result: ReportExportResult; problems: ReportExportProblem[] }> {
const { site, report, view, files } = o;
const problems: ReportExportProblem[] = [];
@@ -480,6 +526,22 @@ export async function writeReportExports(o: {
},
};
const html = reportExportHtml(view, htmlOpts);
+ // The slides, when the report has them and a format asks for them.
+ const wantsSlides = o.formats.some((f) => f === "slides" || f === "slides-pdf" || f === "zip");
+ const slidesLook: SlidesLook | undefined =
+ hasSlides(view) && wantsSlides
+ ? { css: o.slidesCss ?? (await readSlidesCss()), accent: resolveAccent(site.accent).light }
+ : undefined;
+ if (slidesLook) {
+ for (const p of slidesOnlyImagePaths(view)) {
+ const local = files.local(p);
+ const uri = local ? await exportImageDataUri(local, o.ffmpegBin, o.signal) : null;
+ if (uri) dataUris.set(p, uri);
+ }
+ }
+ const slidesHtml = slidesLook
+ ? reportSlidesHtml(view, { ...slidesLook, siteUrl, footer, image: (p) => dataUris.get(p) })
+ : undefined;
// The PDF prints what the page folds away (the evidence the document under
// review gave itself) open.
const printHtml = reportExportHtml(view, { ...htmlOpts, print: true });
@@ -511,6 +573,34 @@ export async function writeReportExports(o: {
await writeFileAtomic(out, markdown);
await record(f);
break;
+ case "slides":
+ if (!slidesHtml) {
+ o.log(` - ${report.id}/slides.html: the report publishes no slides`);
+ break;
+ }
+ await writeFileAtomic(out, slidesHtml);
+ await record(f);
+ break;
+ case "slides-pdf": {
+ if (!slidesHtml) {
+ o.log(` - ${report.id}/slides.pdf: the report publishes no slides`);
+ break;
+ }
+ const printer = await o.printer();
+ if ("missing" in printer) {
+ notes.push(`slides.pdf skipped: ${printer.missing}`);
+ o.log(` - ${report.id}/slides.pdf skipped: ${printer.missing}`);
+ break;
+ }
+ try {
+ await writeFileAtomic(out, Buffer.from(await printer.print(slidesHtml, SLIDES_PDF_PAGE)));
+ await record(f);
+ } catch (e) {
+ notes.push(`slides.pdf skipped: printing failed (${firstLine(e)})`);
+ o.log(` - ${report.id}/slides.pdf skipped: printing failed (${firstLine(e)})`);
+ }
+ break;
+ }
case "pdf": {
const printer = await o.printer();
if ("missing" in printer) {
@@ -537,7 +627,7 @@ export async function writeReportExports(o: {
break;
}
try {
- await buildEvidencePack({ report, view, files, site, footer, markdown, outFile: out, zipBin: o.zipBin });
+ await buildEvidencePack({ report, view, files, site, footer, markdown, outFile: out, zipBin: o.zipBin, slides: slidesLook });
await record(f);
} catch (e) {
problems.push({ report: report.id, format: "zip", message: `the evidence pack could not be packed: ${firstLine(e)}` });
diff --git a/common/publish/reportMedia.test.ts b/common/publish/reportMedia.test.ts
@@ -248,8 +248,8 @@ test("a clean prepare ends by exporting the reports; a host with no browser skip
);
assert.equal(code, 0, err.join("\n"));
const dir = path.join(paths.exportSitesIndexDir, SITE, "report-exports", "r2");
- assert.deepEqual(readdirSync(dir).sort(), ["evidence-pack.zip", "export.json", "report.html", "report.md"]);
+ assert.deepEqual(readdirSync(dir).sort(), ["evidence-pack.zip", "export.json", "report.html", "report.md", "slides.html"]);
const manifest = JSON.parse(readFileSync(path.join(dir, "export.json"), "utf8"));
- assert.deepEqual(manifest.notes, ["report.pdf skipped: no browser here"]);
+ assert.deepEqual(manifest.notes, ["report.pdf skipped: no browser here", "slides.pdf skipped: no browser here"]);
assert.ok(out.some((l) => l.includes("report.pdf skipped: no browser here")));
});
diff --git a/common/social/playwrightRuntime.ts b/common/social/playwrightRuntime.ts
@@ -54,6 +54,9 @@ export type PageLike = {
setContent?: (html: string, opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle" }) => Promise<void>;
pdf?: (opts?: {
format?: string;
+ // A page size instead of a format (slides.pdf: 1280px × 720px).
+ width?: string;
+ height?: string;
printBackground?: boolean;
margin?: { top?: string; right?: string; bottom?: string; left?: string };
}) => Promise<Uint8Array>;
diff --git a/common/styles/slides.css b/common/styles/slides.css
@@ -0,0 +1,1303 @@
+/* =============================================================================
+ REPORT SLIDES AND THE OVERVIEW (release 22) — common/components/report/slides/.
+
+ Plain CSS, imported by every app that shows an article as slides (export,
+ umtool) right after its tokens, and inlined whole into `slides.html`. It is
+ NOT in a Tailwind layer, so it wins over a utility a shared component (the
+ markdown's paragraphs, a verdict chip) carries inside a slide.
+
+ COLOUR comes from the host's tokens through `--rs-*`, set on `.rs-root`
+ below from the site's (tokens.css: the base in force and the site's
+ accent); umtool's globals.css points them at its own palette. A slide's
+ MARK (`--rs-mark`, set inline) is the colour of what it says: a claim's
+ verdict, else the accent. It runs down the slide's left edge, so a deck
+ read quickly still reads its rulings.
+
+ THE CANVAS is 1280 × 720 design pixels, scaled as one piece to fit its
+ stage (`data-fit="canvas"`). A narrow portrait screen reflows the same
+ slide instead (`data-fit="reflow"`): readable type, the evidence under the
+ words.
+ ========================================================================== */
+
+.rs-root {
+ --rs-ground: var(--surface, #e8edef);
+ --rs-paper: var(--card, #ffffff);
+ --rs-ink: var(--foreground, #161c21);
+ --rs-muted: var(--muted-foreground, #55646e);
+ --rs-faint: var(--faint, #78868f);
+ --rs-line: var(--border, #d2dade);
+ --rs-line-strong: var(--border-strong, #aeb9c0);
+ --rs-accent: var(--brand, #2f7d74);
+ --rs-accent-soft: var(--brand-soft, rgba(47, 125, 116, 0.12));
+ --rs-focus: var(--ring, var(--rs-accent));
+ --rs-display: var(--font-display, ui-sans-serif), ui-sans-serif, system-ui, sans-serif;
+ --rs-sans: var(--font-sans, ui-sans-serif), ui-sans-serif, system-ui, sans-serif;
+ --rs-mono: var(--font-mono, ui-monospace), ui-monospace, "SF Mono", Menlo, monospace;
+ --rs-shadow: 0 1px 2px rgb(0 0 0 / 0.06), 0 12px 32px -12px rgb(0 0 0 / 0.22);
+}
+
+/* ─── The slide (canvas) ─────────────────────────────────────────────────── */
+
+.rs-slide {
+ --rs-mark: var(--rs-accent);
+ position: relative;
+ box-sizing: border-box;
+ width: 1280px;
+ height: 720px;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ gap: 28px;
+ padding: 48px 72px 36px 104px;
+ overflow: hidden;
+ background: var(--rs-paper);
+ color: var(--rs-ink);
+ font-family: var(--rs-sans);
+ font-size: 24px;
+ line-height: 1.35;
+ text-align: left;
+ border-radius: 12px;
+}
+.rs-slide *,
+.rs-slide *::before,
+.rs-slide *::after {
+ box-sizing: border-box;
+}
+
+/* The mark: the slide's left edge, in the colour of what it says. */
+.rs-band {
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 18px;
+ background: var(--rs-mark);
+}
+.rs-band::after {
+ /* where the band meets the paper: a hairline of the mark, lighter */
+ content: "";
+ position: absolute;
+ inset: 0 -6px 0 auto;
+ width: 6px;
+ background: color-mix(in oklab, var(--rs-mark) 22%, transparent);
+}
+
+.rs-eyebrow {
+ display: flex;
+ align-items: baseline;
+ gap: 24px;
+ margin: 0;
+ font-family: var(--rs-mono);
+ font-size: 15px;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--rs-muted);
+}
+.rs-eyebrow-place {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.rs-eyebrow-place b {
+ font-weight: 600;
+ color: var(--rs-mark);
+}
+.rs-eyebrow-n {
+ margin-left: auto;
+ flex: none;
+ letter-spacing: 0.08em;
+ color: var(--rs-faint);
+ font-variant-numeric: tabular-nums;
+}
+
+.rs-main {
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 24px;
+}
+
+.rs-foot {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ font-size: 16px;
+ color: var(--rs-faint);
+}
+.rs-foot-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--rs-mono);
+ font-size: 14px;
+ letter-spacing: 0.04em;
+}
+.rs-read {
+ margin-left: auto;
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--rs-accent);
+ font-weight: 600;
+ text-decoration: underline;
+ text-decoration-color: color-mix(in oklab, var(--rs-accent) 40%, transparent);
+ text-underline-offset: 4px;
+ border-radius: 4px;
+}
+.rs-read:hover {
+ text-decoration-color: var(--rs-accent);
+}
+.rs-read:focus-visible,
+.rs-slide a:focus-visible,
+.rs-slide button:focus-visible {
+ outline: 3px solid var(--rs-focus);
+ outline-offset: 3px;
+}
+
+/* ─── Type ───────────────────────────────────────────────────────────────── */
+
+.rs-h {
+ margin: 0;
+ font-family: var(--rs-display);
+ font-stretch: 112%;
+ font-weight: 650;
+ font-size: 54px;
+ line-height: 1.1;
+ letter-spacing: -0.012em;
+ /* room for the last line's descenders under the clamp */
+ padding-bottom: 0.08em;
+ overflow-wrap: anywhere;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ overflow: hidden;
+}
+.rs-h-sm {
+ font-size: 42px;
+}
+.rs-sub {
+ margin: 0;
+ font-size: 26px;
+ line-height: 1.3;
+ color: var(--rs-muted);
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ overflow: hidden;
+}
+.rs-claimtext {
+ margin: 0;
+ padding-left: 18px;
+ border-left: 3px solid var(--rs-line-strong);
+ font-size: 24px;
+ font-style: italic;
+ color: var(--rs-muted);
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ overflow: hidden;
+}
+.rs-statement {
+ margin: 0;
+ font-size: 34px;
+ line-height: 1.32;
+ max-width: 30em;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 7;
+ line-clamp: 7;
+ overflow: hidden;
+}
+.rs-gist {
+ margin: 0;
+ font-size: 28px;
+ line-height: 1.32;
+}
+
+/* Markdown inside a slide: a run of text, never a block with its own margins. */
+.rs-md p,
+.rs-md div {
+ margin: 0;
+ line-height: inherit;
+}
+.rs-md p + p {
+ margin-top: 0.5em;
+}
+
+/* Points: the slide's own words. A short bar in the mark leads each. */
+.rs-points {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ font-size: 30px;
+ line-height: 1.28;
+}
+.rs-points > li {
+ position: relative;
+ padding-left: 40px;
+}
+.rs-points > li::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0.62em;
+ width: 22px;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--rs-mark);
+}
+
+/* A verdict chip (VerdictChip), at slide scale — whole here, so a slide needs
+ no utility classes (slides.html has none). */
+.rs-chip [data-verdict-tally] {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+.rs-chip [data-verdict] {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 18px;
+ border: 2px solid var(--verdict);
+ border-radius: 999px;
+ background: color-mix(in oklab, var(--verdict) 14%, transparent);
+ color: var(--rs-ink);
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 1.3;
+}
+.rs-chip [data-verdict] > span:first-child {
+ display: block;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--verdict);
+}
+.rs-chip [data-verdict] > span:last-child:not(:first-child) {
+ font-family: var(--rs-mono);
+ color: var(--rs-muted);
+}
+.rs-root .sr-only,
+.rs-slide .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+.rs-chips {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+}
+.rs-flag {
+ display: inline-flex;
+ align-items: center;
+ padding: 5px 16px;
+ border: 2px solid color-mix(in oklab, var(--rs-accent) 45%, transparent);
+ border-radius: 999px;
+ background: var(--rs-accent-soft);
+ color: var(--rs-accent);
+ font-size: 18px;
+ font-weight: 600;
+}
+
+/* Two columns: the words, the evidence. */
+.rs-split {
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
+ gap: 56px;
+ align-items: center;
+}
+.rs-col {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 22px;
+}
+
+/* ─── Evidence ───────────────────────────────────────────────────────────── */
+
+.rs-ev {
+ min-width: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.rs-ev-pic {
+ display: block;
+ width: 100%;
+ max-height: 330px;
+ object-fit: contain;
+ object-position: left top;
+ border: 1px solid var(--rs-line);
+ border-radius: 8px;
+ background: var(--rs-ground);
+}
+.rs-ev-pic[data-kind="source"] {
+ background: #ffffff;
+}
+.rs-ev-quote {
+ margin: 0;
+ padding-left: 20px;
+ border-left: 4px solid var(--rs-ev-rail, var(--rs-line-strong));
+ font-size: 24px;
+ line-height: 1.35;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 5;
+ line-clamp: 5;
+ overflow: hidden;
+}
+.rs-ev-cap {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-size: 17px;
+ color: var(--rs-muted);
+}
+.rs-ev-kind {
+ font-family: var(--rs-mono);
+ font-size: 13px;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--rs-faint);
+}
+.rs-ev-title {
+ color: var(--rs-ink);
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* The pull quote: the evidence's own words, large. */
+.rs-pull {
+ margin: 0;
+ position: relative;
+ padding-left: 64px;
+ font-family: var(--rs-display);
+ font-weight: 500;
+ font-size: 50px;
+ line-height: 1.14;
+ letter-spacing: -0.01em;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 5;
+ line-clamp: 5;
+ overflow: hidden;
+}
+.rs-pull::before {
+ content: "“";
+ position: absolute;
+ left: 0;
+ top: -0.12em;
+ font-size: 1.9em;
+ line-height: 1;
+ color: var(--rs-mark);
+}
+
+/* ─── Kinds ──────────────────────────────────────────────────────────────── */
+
+.rs-title-series {
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ font-family: var(--rs-display);
+ font-stretch: 112%;
+ font-weight: 650;
+ font-size: 30px;
+ color: var(--rs-accent);
+}
+.rs-title-series::before {
+ content: "";
+ width: 56px;
+ height: 6px;
+ border-radius: 3px;
+ background: var(--rs-accent);
+}
+.rs-title-h {
+ font-size: 76px;
+ line-height: 1.08;
+}
+.rs-dates {
+ margin: 0;
+ font-family: var(--rs-mono);
+ font-size: 17px;
+ color: var(--rs-faint);
+}
+.rs-subject {
+ max-width: 820px;
+ padding: 14px 22px;
+ border-left: 6px solid var(--rs-subject, var(--rs-line-strong));
+ border-radius: 0 8px 8px 0;
+ background: var(--rs-ground);
+ font-size: 20px;
+}
+.rs-subject b {
+ display: block;
+ font-weight: 600;
+}
+.rs-subject span {
+ color: var(--rs-muted);
+ font-size: 17px;
+}
+
+.rs-found {
+ display: grid;
+ grid-template-columns: repeat(var(--rs-cols, 3), minmax(0, 1fr));
+ gap: 22px;
+ align-items: start;
+}
+.rs-found-group {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding-top: 16px;
+ border-top: 5px solid var(--rs-verdict, var(--rs-line-strong));
+}
+.rs-found-n {
+ font-family: var(--rs-display);
+ font-weight: 650;
+ font-size: 64px;
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+}
+.rs-found-label {
+ font-weight: 600;
+ font-size: 22px;
+}
+.rs-found-claims {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-size: 18px;
+ line-height: 1.3;
+ color: var(--rs-muted);
+}
+.rs-found-claims li {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ overflow: hidden;
+}
+
+.rs-claimlist {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ font-size: 26px;
+}
+.rs-claimlist li {
+ display: flex;
+ align-items: baseline;
+ gap: 16px;
+}
+.rs-claimlist i {
+ flex: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--rs-verdict, var(--rs-line-strong));
+ transform: translateY(-2px);
+}
+
+.rs-figures {
+ display: flex;
+ gap: 64px;
+}
+.rs-figure b {
+ display: block;
+ font-family: var(--rs-display);
+ font-weight: 650;
+ font-size: 88px;
+ line-height: 1;
+ color: var(--rs-ink);
+ font-variant-numeric: tabular-nums;
+}
+.rs-figure span {
+ font-family: var(--rs-mono);
+ font-size: 16px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--rs-muted);
+}
+.rs-url {
+ font-family: var(--rs-mono);
+ font-size: 20px;
+ color: var(--rs-accent);
+ overflow-wrap: anywhere;
+}
+
+/* ─── The reflowed slide (a narrow portrait screen) ──────────────────────── */
+
+.rs-slide[data-fit="reflow"] {
+ width: 100%;
+ height: auto;
+ min-height: 100%;
+ gap: 18px;
+ padding: 22px 18px 18px 34px;
+ font-size: 16px;
+ border-radius: 10px;
+}
+.rs-slide[data-fit="reflow"] .rs-band {
+ width: 10px;
+}
+.rs-slide[data-fit="reflow"] .rs-band::after {
+ width: 4px;
+ inset: 0 -4px 0 auto;
+}
+.rs-slide[data-fit="reflow"] .rs-eyebrow {
+ font-size: 11px;
+ gap: 12px;
+}
+.rs-slide[data-fit="reflow"] .rs-main {
+ justify-content: flex-start;
+ gap: 16px;
+}
+.rs-slide[data-fit="reflow"] .rs-h {
+ font-size: 28px;
+ -webkit-line-clamp: unset;
+ line-clamp: none;
+}
+.rs-slide[data-fit="reflow"] .rs-title-h {
+ font-size: 34px;
+}
+.rs-slide[data-fit="reflow"] .rs-title-series {
+ font-size: 18px;
+ gap: 10px;
+}
+.rs-slide[data-fit="reflow"] .rs-title-series::before {
+ width: 28px;
+ height: 4px;
+}
+.rs-slide[data-fit="reflow"] .rs-sub,
+.rs-slide[data-fit="reflow"] .rs-claimtext,
+.rs-slide[data-fit="reflow"] .rs-statement,
+.rs-slide[data-fit="reflow"] .rs-ev-quote,
+.rs-slide[data-fit="reflow"] .rs-pull,
+.rs-slide[data-fit="reflow"] .rs-found-claims li {
+ -webkit-line-clamp: unset;
+ line-clamp: none;
+}
+.rs-slide[data-fit="reflow"] .rs-sub {
+ font-size: 17px;
+}
+.rs-slide[data-fit="reflow"] .rs-claimtext {
+ font-size: 16px;
+ padding-left: 12px;
+}
+.rs-slide[data-fit="reflow"] .rs-statement {
+ font-size: 19px;
+}
+.rs-slide[data-fit="reflow"] .rs-gist {
+ font-size: 18px;
+}
+.rs-slide[data-fit="reflow"] .rs-points {
+ font-size: 18px;
+ gap: 12px;
+}
+.rs-slide[data-fit="reflow"] .rs-points > li {
+ padding-left: 26px;
+}
+.rs-slide[data-fit="reflow"] .rs-points > li::before {
+ width: 14px;
+ height: 3px;
+}
+.rs-slide[data-fit="reflow"] .rs-split {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 20px;
+}
+.rs-slide[data-fit="reflow"] .rs-pull {
+ font-size: 24px;
+ padding-left: 34px;
+}
+.rs-slide[data-fit="reflow"] .rs-ev-quote {
+ font-size: 16px;
+}
+.rs-slide[data-fit="reflow"] .rs-ev-cap {
+ font-size: 13px;
+}
+.rs-slide[data-fit="reflow"] .rs-chip [data-verdict] {
+ font-size: 14px;
+ padding: 3px 12px;
+ gap: 8px;
+}
+.rs-slide[data-fit="reflow"] .rs-chip [data-verdict] > span:first-child {
+ width: 10px;
+ height: 10px;
+}
+.rs-slide[data-fit="reflow"] .rs-flag {
+ font-size: 13px;
+ padding: 3px 10px;
+}
+.rs-slide[data-fit="reflow"] .rs-found {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 14px;
+}
+.rs-slide[data-fit="reflow"] .rs-found-n {
+ font-size: 34px;
+}
+.rs-slide[data-fit="reflow"] .rs-found-label {
+ font-size: 16px;
+}
+.rs-slide[data-fit="reflow"] .rs-found-claims {
+ font-size: 14px;
+}
+.rs-slide[data-fit="reflow"] .rs-claimlist {
+ font-size: 17px;
+}
+.rs-slide[data-fit="reflow"] .rs-claimlist i {
+ width: 10px;
+ height: 10px;
+}
+.rs-slide[data-fit="reflow"] .rs-subject {
+ font-size: 15px;
+ padding: 10px 14px;
+}
+.rs-slide[data-fit="reflow"] .rs-subject span,
+.rs-slide[data-fit="reflow"] .rs-dates {
+ font-size: 13px;
+}
+.rs-slide[data-fit="reflow"] .rs-figures {
+ gap: 32px;
+}
+.rs-slide[data-fit="reflow"] .rs-figure b {
+ font-size: 44px;
+}
+.rs-slide[data-fit="reflow"] .rs-figure span {
+ font-size: 11px;
+}
+.rs-slide[data-fit="reflow"] .rs-url {
+ font-size: 14px;
+}
+.rs-slide[data-fit="reflow"] .rs-foot {
+ font-size: 14px;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+}
+.rs-slide[data-fit="reflow"] .rs-foot-name {
+ font-size: 11px;
+}
+.rs-slide[data-fit="reflow"] .rs-ev-pic {
+ max-height: none;
+}
+.rs-slide[data-fit="reflow"] .rs-ev-title {
+ white-space: normal;
+}
+
+/* ─── The stage: one slide letterboxed, the controls, the rail ───────────── */
+
+.rs-stage {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ height: 100%;
+ background: var(--rs-ground);
+ color: var(--rs-ink);
+ outline: none;
+}
+.rs-viewport {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px 24px 8px;
+ overflow: hidden;
+ touch-action: pan-y;
+}
+.rs-viewport[data-fit="reflow"] {
+ align-items: stretch;
+ justify-content: stretch;
+ padding: 12px 12px 4px;
+ overflow-y: auto;
+}
+.rs-scaled {
+ position: relative;
+ flex: none;
+ border-radius: 12px;
+ box-shadow: var(--rs-shadow);
+}
+.rs-scaled > .rs-slide {
+ position: absolute;
+ left: 0;
+ top: 0;
+ transform-origin: 0 0;
+}
+.rs-viewport[data-fit="reflow"] .rs-scaled {
+ width: 100%;
+ min-height: 100%;
+}
+.rs-viewport[data-fit="reflow"] .rs-scaled > .rs-slide {
+ position: relative;
+}
+
+@keyframes rs-enter-next {
+ from {
+ opacity: 0;
+ translate: 24px 0;
+ }
+}
+@keyframes rs-enter-prev {
+ from {
+ opacity: 0;
+ translate: -24px 0;
+ }
+}
+.rs-scaled[data-dir="next"] {
+ animation: rs-enter-next 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.rs-scaled[data-dir="prev"] {
+ animation: rs-enter-prev 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.rs-controls {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 6px 16px 10px;
+}
+.rs-label {
+ flex: 1 1 auto;
+ min-width: 0;
+ text-align: center;
+ font-size: 13px;
+ color: var(--rs-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.rs-label b {
+ font-family: var(--rs-mono);
+ font-weight: 500;
+ color: var(--rs-ink);
+ font-variant-numeric: tabular-nums;
+}
+.rs-btn {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-width: 44px;
+ height: 36px;
+ padding: 0 12px;
+ border: 1px solid var(--rs-line);
+ border-radius: 999px;
+ background: var(--rs-paper);
+ color: var(--rs-ink);
+ font: inherit;
+ font-size: 13px;
+ cursor: pointer;
+}
+.rs-btn:hover:not(:disabled) {
+ border-color: var(--rs-accent);
+ color: var(--rs-accent);
+}
+.rs-btn[aria-pressed="true"] {
+ border-color: var(--rs-accent);
+ background: var(--rs-accent-soft);
+ color: var(--rs-accent);
+}
+.rs-btn:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+.rs-btn:focus-visible,
+.rs-rail button:focus-visible,
+.rs-switch button:focus-visible {
+ outline: 2px solid var(--rs-focus);
+ outline-offset: 2px;
+}
+
+/* The progress rail: one tick per slide, the passed ones in the accent. */
+.rs-rail {
+ flex: none;
+ display: flex;
+ gap: 3px;
+ padding: 0 16px 12px;
+}
+.rs-rail button {
+ flex: 1 1 0;
+ min-width: 0;
+ height: 14px;
+ padding: 5px 0;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+}
+.rs-rail button::before {
+ content: "";
+ display: block;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--rs-line);
+ transition: background 160ms;
+}
+.rs-rail button[data-state="past"]::before {
+ background: color-mix(in oklab, var(--rs-accent) 55%, var(--rs-line));
+}
+.rs-rail button[data-state="current"]::before {
+ background: var(--rs-accent);
+}
+.rs-rail button:hover::before {
+ background: var(--rs-ink);
+}
+
+/* ─── The overview: the article and its slides, pair by pair ─────────────── */
+
+.rs-ov {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ height: 100%;
+ background: var(--rs-ground);
+ color: var(--rs-ink);
+ outline: none;
+}
+.rs-ov-tools {
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 16px;
+ padding: 10px 16px 0;
+}
+.rs-ov-hint {
+ margin: 0;
+ font-size: 12px;
+ color: var(--rs-muted);
+}
+.rs-ov-hint kbd {
+ display: inline-block;
+ min-width: 1.6em;
+ padding: 0 4px;
+ border: 1px solid var(--rs-line);
+ border-bottom-width: 2px;
+ border-radius: 4px;
+ background: var(--rs-paper);
+ font-family: var(--rs-mono);
+ font-size: 11px;
+ text-align: center;
+}
+.rs-ov-scroll {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: auto;
+ overscroll-behavior: contain;
+ container: rs-ov / inline-size;
+}
+.rs-ov-sizer {
+ position: relative;
+ margin: 0 auto;
+}
+.rs-ov-plane {
+ margin: 0 auto;
+ padding: 24px 16px 48px;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+ width: max-content;
+ max-width: 100%;
+}
+.rs-ov-pair {
+ display: grid;
+ grid-template-columns: 360px 88px 300px;
+ align-items: stretch;
+ border-radius: 10px;
+}
+
+/* The article side: the page, in blocks that run on from one another. */
+.rs-ov-block {
+ position: relative;
+ min-width: 0;
+ border: 1px solid var(--rs-line);
+ border-radius: 8px;
+ background: var(--rs-paper);
+ transition: border-color 160ms, box-shadow 160ms, transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.rs-ov-face {
+ --rs-mark: var(--rs-line-strong);
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 14px 16px 14px 20px;
+ border-left: 4px solid var(--rs-mark);
+ border-radius: 8px 0 0 8px;
+ overflow: hidden;
+}
+.rs-ov-face[data-kind="title"],
+.rs-ov-face[data-kind="section"] {
+ --rs-mark: var(--rs-accent);
+}
+.rs-ov-kind {
+ font-family: var(--rs-mono);
+ font-size: 10.5px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--rs-faint);
+}
+.rs-ov-kind b {
+ font-weight: 600;
+ color: var(--rs-mark);
+}
+.rs-ov-title {
+ font-family: var(--rs-display);
+ font-stretch: 112%;
+ font-weight: 600;
+ font-size: 16px;
+ line-height: 1.2;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ overflow: hidden;
+}
+.rs-ov-face[data-kind="title"] .rs-ov-title,
+.rs-ov-face[data-kind="section"] .rs-ov-title {
+ font-size: 19px;
+}
+.rs-ov-line {
+ font-size: 13px;
+ line-height: 1.4;
+ color: var(--rs-muted);
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ overflow: hidden;
+}
+
+/* The line between a part and its slide. */
+.rs-ov-link {
+ position: relative;
+ align-self: center;
+ height: 2px;
+ margin: 0 6px;
+ background: repeating-linear-gradient(90deg, var(--rs-line-strong) 0 6px, transparent 6px 11px);
+ transition: background 160ms;
+}
+.rs-ov-link::before,
+.rs-ov-link::after {
+ content: "";
+ position: absolute;
+ top: -4px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--rs-paper);
+ border: 2px solid var(--rs-line-strong);
+}
+.rs-ov-link::before {
+ left: -6px;
+}
+.rs-ov-link::after {
+ right: -6px;
+}
+
+/* The slide side: the slide itself, small. */
+.rs-ov-card {
+ position: relative;
+ width: 300px;
+ height: 168.75px;
+ border-radius: 8px;
+ background: var(--rs-paper);
+ box-shadow: 0 0 0 1px var(--rs-line);
+ transition: box-shadow 160ms, transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.rs-ov-thumb {
+ position: absolute;
+ inset: 0;
+ overflow: hidden;
+ border-radius: 8px;
+ pointer-events: none;
+}
+.rs-ov-thumb > .rs-slide {
+ position: absolute;
+ left: 0;
+ top: 0;
+ transform-origin: 0 0;
+ border-radius: 0;
+}
+
+/* Each side is one button laid over it. */
+.rs-ov-block > button,
+.rs-ov-card > button {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ cursor: pointer;
+}
+.rs-ov-block > button:focus-visible,
+.rs-ov-card > button:focus-visible {
+ outline: 3px solid var(--rs-focus);
+ outline-offset: 3px;
+}
+
+/* A pair lit: pointed at, or the reader's place. */
+.rs-ov-pair:is(:hover, :focus-within, [data-active="true"]) .rs-ov-block {
+ border-color: var(--rs-accent);
+ box-shadow: 0 0 0 1px var(--rs-accent), var(--rs-shadow);
+}
+.rs-ov-pair:is(:hover, :focus-within, [data-active="true"]) .rs-ov-card {
+ box-shadow: 0 0 0 2px var(--rs-accent), var(--rs-shadow);
+}
+.rs-ov-pair:is(:hover, :focus-within, [data-active="true"]) .rs-ov-link {
+ background: var(--rs-accent);
+}
+.rs-ov-pair:is(:hover, :focus-within, [data-active="true"]) .rs-ov-link::before,
+.rs-ov-pair:is(:hover, :focus-within, [data-active="true"]) .rs-ov-link::after {
+ border-color: var(--rs-accent);
+ background: var(--rs-accent);
+}
+
+/* TILTED: one plane, rotated isometrically; the pair in force lifts off it. */
+.rs-ov[data-report-overview="iso"] .rs-ov-plane {
+ position: absolute;
+ left: 0;
+ top: 0;
+ margin: 0;
+ max-width: none;
+ padding: 28px;
+ border-radius: 18px;
+ background:
+ linear-gradient(var(--rs-line) 1px, transparent 1px) 0 0 / 100% 48px,
+ color-mix(in oklab, var(--rs-paper) 55%, var(--rs-ground));
+ box-shadow: 0 0 0 1px var(--rs-line);
+ transform-origin: 0 0;
+ transform-style: preserve-3d;
+}
+.rs-ov[data-report-overview="iso"] .rs-ov-pair {
+ transform-style: preserve-3d;
+}
+.rs-ov[data-report-overview="iso"] .rs-ov-pair:is(:hover, :focus-within, [data-active="true"]) .rs-ov-block,
+.rs-ov[data-report-overview="iso"] .rs-ov-pair:is(:hover, :focus-within, [data-active="true"]) .rs-ov-card {
+ transform: translateZ(22px);
+}
+.rs-ov[data-report-overview="iso"] .rs-ov-scroll {
+ background: radial-gradient(ellipse at 50% 40%, color-mix(in oklab, var(--rs-accent) 7%, transparent), transparent 70%);
+}
+
+/* FLAT, in a narrow box: each pair stacked, the part over its slide. */
+@container rs-ov (max-width: 780px) {
+ .rs-ov[data-report-overview="flat"] .rs-ov-plane {
+ width: auto;
+ gap: 22px;
+ }
+ .rs-ov[data-report-overview="flat"] .rs-ov-pair {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 8px;
+ justify-items: start;
+ }
+ .rs-ov[data-report-overview="flat"] .rs-ov-block {
+ width: 100%;
+ }
+ .rs-ov[data-report-overview="flat"] .rs-ov-link {
+ display: none;
+ }
+}
+@media (max-width: 720px) {
+ .rs-ov-hint {
+ display: none;
+ }
+}
+
+/* ─── The view switch: Article | Slides | Overview ───────────────────────── */
+
+.rs-switch {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 3px;
+ border: 1px solid var(--rs-line);
+ border-radius: 999px;
+ background: var(--rs-ground);
+ font-family: var(--rs-sans);
+}
+.rs-switch button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 30px;
+ padding: 0 14px;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--rs-muted);
+ font: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 140ms, color 140ms;
+}
+.rs-switch button:hover {
+ color: var(--rs-ink);
+}
+.rs-switch button[aria-pressed="true"] {
+ background: var(--rs-paper);
+ color: var(--rs-ink);
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.12), inset 0 0 0 1px var(--rs-line);
+}
+.rs-switch button[aria-pressed="true"] svg {
+ color: var(--rs-accent);
+}
+.rs-switch svg {
+ width: 15px;
+ height: 15px;
+ flex: none;
+}
+
+/* ─── The reader's overlay (the export site) ─────────────────────────────── */
+
+.rs-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ display: flex;
+ flex-direction: column;
+ background: var(--rs-ground);
+ color: var(--rs-ink);
+}
+.rs-bar {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 16px;
+ padding-top: max(10px, env(safe-area-inset-top, 0px));
+ border-bottom: 1px solid var(--rs-line);
+ background: var(--rs-paper);
+}
+.rs-bar-title {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--rs-display);
+ font-stretch: 112%;
+ font-weight: 600;
+ font-size: 15px;
+}
+.rs-bar-title span {
+ color: var(--rs-accent);
+}
+.rs-overlay-body {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+.rs-note {
+ margin: auto;
+ padding: 24px;
+ text-align: center;
+ color: var(--rs-muted);
+}
+@media (max-width: 560px) {
+ .rs-bar-title {
+ display: none;
+ }
+ .rs-bar {
+ justify-content: space-between;
+ }
+ .rs-btn-text {
+ display: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .rs-scaled[data-dir] {
+ animation: none;
+ }
+ .rs-rail button::before,
+ .rs-switch button,
+ .rs-ov-block,
+ .rs-ov-card,
+ .rs-ov-link {
+ transition: none;
+ }
+}
+
+/* ─── Hosts' small parts ─────────────────────────────────────────────────── */
+
+/* Markdown a host renders with its own classes (umtool's) takes the slide's
+ type, not the host's body size. */
+.rs-md > div,
+.rs-md > div > div {
+ font-size: inherit;
+ line-height: inherit;
+ color: inherit;
+}
+/* A citation as a button (umtool: it opens the evidence rail). */
+.rs-cite {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ text-decoration: underline;
+ text-decoration-style: dotted;
+ text-decoration-color: var(--rs-accent);
+ text-underline-offset: 4px;
+ cursor: pointer;
+}
+.rs-cite sup {
+ margin-left: 2px;
+ font-family: var(--rs-mono);
+ color: var(--rs-accent);
+}
+/* A slide's badge (umtool: its open notes). */
+.rs-notes {
+ flex: none;
+ padding: 3px 12px;
+ border: 1px solid var(--rs-accent);
+ border-radius: 999px;
+ font-family: var(--rs-mono);
+ font-size: 14px;
+ color: var(--rs-accent);
+}
+.rs-slide[data-fit="reflow"] .rs-notes {
+ font-size: 11px;
+ padding: 1px 8px;
+}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,8 @@
# Changelog
## [Unreleased]
+- **A report reads as an article, as slides, or in an overview of both.** A report page's header has a switch, **Article · Slides · Overview**. **Slides** shows the report one 16:9 slide at a time, fitted to the screen in the site's accent and light or dark: a title slide, In brief, What the check found, one slide per section and per claim — its verdict, its finding and the evidence it rests on (a clip's still, a post's screenshot, the article's sentence, with its quote) — and a sources slide. ← → (and Page Up, Page Down, Home, End) or a swipe step through them; a citation's number opens its card; every slide has **Read this in the article**, and Esc goes back to the article where the slide stands. On a phone a slide reads top to bottom, its evidence under its words. **Overview** lays the article's parts and their slides side by side on one tilted plane, each part beside its slide: ↑ ↓ walk them, a part opens the article there and a slide opens the slides there; with reduced motion, on a narrow screen, arriving by keyboard or with **Flat**, the same pairs lie flat. Switching keeps your place: the part of the article on screen opens its slide, and back. The view and the place are in the address (`?rv=slides#s-4`, `?rv=iso#<part>`), so a shared link opens the same slide; the article itself is unchanged for search engines and readers without script. A report's author can shape its slides in `report.json` (`slides`, and a section's or claim's `slide`: its own title, up to five short points, which evidence to show, a layout, or hidden); a report without them still has slides, made from its text. Needs a rebuild and deploy of each site with reports.
+- **A report can be saved as slides.** The download line adds **Slides** (`slides.html`, one file that opens with no network: the slides with their pictures inside, the arrow keys stepping through them, each citation numbered to a reference list) and **Slides PDF** (one page per slide), and the evidence pack carries the slides too. Needs `reports export` (or prepare) and a rebuild and deploy of each site with reports.
- **Posts withdrawn from a site leave a stand-in, not a stale copy.** When an X channel's posts stop being published on a site — while X posts are private, say — the site's build ships an empty posts manifest and an empty page at every address they were served from, sent with `Cache-Control: no-store`, so a reader (or the hub) asking for them gets "no posts" instead of the copy Cloudflare's edge kept for up to a week. The hub does the same for every X channel a public site carries.
- **A quote with an editorial insertion is still a citation.** An inline citation whose quoted words hold brackets, `[“told [the mayor] so”](cite:id)`, is numbered, previews and links like any other, is listed in the reference list and its section's, and validation names its id when the report lacks it; it used to render as plain text. A label with a lone `]` is still not a link. The MCP's report reader counts it too. Needs `reports prepare` and a rebuild and deploy of each site with reports.
- **A report can carry a video.** `report.json` `video` (`{ "src": "video.mp4", "poster": "poster.jpg", "caption": "…" }`, files in the report's directory: an mp4, a png/jpg/webp poster, a one-line caption) plays at the head of the report's page, under its header. Composing the site refuses a report whose video or poster is missing, or whose video is over the 24 MiB publish limit. Needs a rebuild and deploy of the site.
diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx
@@ -4,12 +4,16 @@ import { CitationsProvider } from "yt-dlp-transcript-common/components/citations
import { CitedMarkdown } from "yt-dlp-transcript-common/components/citations/CitedMarkdown";
import { ReferenceList } from "yt-dlp-transcript-common/components/citations/ReferenceList";
import { VerdictChip, VerdictTally } from "yt-dlp-transcript-common/components/report/VerdictChip";
+import { ReportReader } from "yt-dlp-transcript-common/components/report/slides/ReportReader";
+import { REPORT_PAGE_ANCHOR_IDS } from "yt-dlp-transcript-common/lib/report/slideRules";
+import { hasSlides } from "yt-dlp-transcript-common/lib/report/slides";
import type { SourceArchive } from "yt-dlp-transcript-common/lib/citations/schema";
import {
foundGroups,
orderedCitations,
reportDateParts,
reportRevisionLabel,
+ reportViewPath,
sourceAnchor,
verdictTally,
type CitationView,
@@ -31,6 +35,13 @@ import { ArchiveList, ReportName, SourceBlock, SubjectCard, textLink } from "./p
// last) — then the numbered reference list the inline markers jump to, and
// the downloads: the report as files (HTML, PDF, Markdown, the evidence pack)
// and its citations as data.
+//
+// ONE ARTICLE, THREE SHAPES (release 22): the header carries the view switch
+// (Article | Slides | Overview — common/components/report/slides/ReportReader),
+// and every place a slide stands for has its id here: the header, the quick
+// take, what the check found, each section and claim, and the end matter
+// (lib/report/slideRules.ts REPORT_PAGE_ANCHOR_IDS). The article itself stays
+// this static page; the other views are built in the browser from page.json.
const proseClass = "text-[0.95rem] leading-relaxed text-foreground";
@@ -38,6 +49,8 @@ const DOWNLOADS: readonly (readonly [keyof NonNullable<ReportPageView["downloads
["html", "HTML"],
["pdf", "PDF"],
["md", "Markdown"],
+ ["slides", "Slides"],
+ ["slides-pdf", "Slides PDF"],
["zip", "Evidence pack"],
["json", "Citations JSON"],
["csv", "CSV"],
@@ -246,7 +259,7 @@ export default function ReportArticle({ view }: { view: ReportPageView }) {
{/* The report's name (its series on one line, its title on the next;
without a series, the title alone), one line of dates and the
revision, the document under review, the subtitle. */}
- <header className="flex flex-col gap-3">
+ <header id={REPORT_PAGE_ANCHOR_IDS.head} className="flex scroll-mt-20 flex-col gap-3">
<h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground [overflow-wrap:anywhere] sm:text-4xl">
<ReportName series={view.series} title={view.title} />
</h1>
@@ -265,6 +278,11 @@ export default function ReportArticle({ view }: { view: ReportPageView }) {
)}
</p>
)}
+ {hasSlides(view) && (
+ <div data-report-views="" className="flex flex-wrap items-center gap-3">
+ <ReportReader pagePath={reportViewPath(view.id)} title={view.title} series={view.series} />
+ </div>
+ )}
{subject && <SubjectCard source={subject} />}
{view.subtitle && <p className="text-lg text-muted-foreground">{view.subtitle}</p>}
</header>
@@ -287,7 +305,12 @@ export default function ReportArticle({ view }: { view: ReportPageView }) {
)}
{/* 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">
+ <section
+ id={REPORT_PAGE_ANCHOR_IDS.summary}
+ data-report-tier="1"
+ aria-label="In brief"
+ className="flex scroll-mt-20 flex-col gap-4"
+ >
<TierMarker depth={1} />
{tally.length > 0 && (
<div className="flex flex-col gap-2">
@@ -398,41 +421,45 @@ export default function ReportArticle({ view }: { view: ReportPageView }) {
))}
</div>
- {otherSources.length > 0 && (
- <section aria-labelledby="sources-heading" className="flex flex-col gap-3 border-t border-border pt-6">
- <h2 id="sources-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground">
- Sources
- </h2>
- {otherSources.map((s) => (
- <SourceBlock key={s.id} source={s} label="Quoted" />
- ))}
- </section>
- )}
+ {/* The end matter: the documents quoted, the references, the
+ downloads — the sources slide's place. */}
+ <div id={REPORT_PAGE_ANCHOR_IDS.end} className="flex scroll-mt-20 flex-col gap-8 empty:hidden">
+ {otherSources.length > 0 && (
+ <section aria-labelledby="sources-heading" className="flex flex-col gap-3 border-t border-border pt-6">
+ <h2 id="sources-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground">
+ Sources
+ </h2>
+ {otherSources.map((s) => (
+ <SourceBlock key={s.id} source={s} label="Quoted" />
+ ))}
+ </section>
+ )}
- {references.length > 0 && (
- <section id="references" aria-labelledby="references-heading" className="flex scroll-mt-20 flex-col gap-2 border-t border-border pt-6">
- <h2 id="references-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground">
- References
- </h2>
- <ReferenceList citations={references} addedLabel={`Not in the ${subjectNoun}`} />
- </section>
- )}
+ {references.length > 0 && (
+ <section id="references" aria-labelledby="references-heading" className="flex scroll-mt-20 flex-col gap-2 border-t border-border pt-6">
+ <h2 id="references-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground">
+ References
+ </h2>
+ <ReferenceList citations={references} addedLabel={`Not in the ${subjectNoun}`} />
+ </section>
+ )}
- {downloads.length > 0 && (
- <p
- id="downloads"
- data-citation-downloads=""
- className="flex scroll-mt-20 flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground"
- >
- <ArrowDownToLine className="size-4" aria-hidden />
- Download:
- {downloads.map(([key, label, href]) => (
- <a key={key} href={href} download data-download={key} className={textLink}>
- {label}
- </a>
- ))}
- </p>
- )}
+ {downloads.length > 0 && (
+ <p
+ id="downloads"
+ data-citation-downloads=""
+ className="flex scroll-mt-20 flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground"
+ >
+ <ArrowDownToLine className="size-4" aria-hidden />
+ Download:
+ {downloads.map(([key, label, href]) => (
+ <a key={key} href={href} download data-download={key} className={textLink}>
+ {label}
+ </a>
+ ))}
+ </p>
+ )}
+ </div>
</article>
</CitationsProvider>
);
diff --git a/export/app/globals.css b/export/app/globals.css
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "../../common/styles/tokens.css";
+@import "../../common/styles/slides.css";
@source "../../common/components";
/* Design tokens, the `dark` variant, the two bases (light / dark) and
diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts
@@ -92,7 +92,12 @@ test("the report page: header, tally, sections and claims, inline cites, referen
);
assert.match(
header,
- /<\/h1><p data-report-dates=""[^>]*>2026-10-01 · updated 2026-10-04 · <a href="\/reports\/demo-factcheck\/history\/" data-report-revision="2"[^>]*>revision 2<\/a><\/p><div id="source-s0" data-subject-source="s0"/,
+ /<\/h1><p data-report-dates=""[^>]*>2026-10-01 · updated 2026-10-04 · <a href="\/reports\/demo-factcheck\/history\/" data-report-revision="2"[^>]*>revision 2<\/a><\/p><div data-report-views=""[^>]*><div role="group" aria-label="Read as" data-view-switch="article"[^]*?<\/div><\/div><div id="source-s0" data-subject-source="s0"/,
+ );
+ // the view switch (release 22): Article pressed, then Slides and Overview
+ assert.deepEqual(
+ [...header.matchAll(/<button type="button" aria-pressed="(true|false)" data-view="(\w+)"/g)].map((m) => `${m[2]}:${m[1]}`),
+ ["article:true", "slides:false", "iso:false"],
);
assert.match(header, /<\/div><p[^>]*>Four claims about a demo channel[^<]*<\/p>$/, "the subtitle, under the card");
assert.doesNotMatch(header, /data-report-attribution|data-report-byline|Fact-check|Under review|The article/);
diff --git a/export/e2e-report/audit.spec.ts b/export/e2e-report/audit.spec.ts
@@ -31,6 +31,8 @@ test("it holds the reports, the moments, the cited media and the contract", () =
"reports/demo-factcheck/report.html",
"reports/demo-factcheck/report.pdf",
"reports/demo-factcheck/report.md",
+ "reports/demo-factcheck/slides.html",
+ "reports/demo-factcheck/slides.pdf",
"reports/demo-factcheck/evidence-pack.zip",
"reports/demo-factcheck/stills/a01.png",
"reports/demo-factcheck/history/index.html",
diff --git a/export/e2e-report/overview.spec.ts b/export/e2e-report/overview.spec.ts
@@ -0,0 +1,79 @@
+import { expect, test } from "./helpers";
+import type { Page } from "@playwright/test";
+
+// THE OVERVIEW (release 22): the article's parts and their slides side by
+// side on one plane — tilted isometrically for a pointer, flat for reduced
+// motion, a narrow screen, a keyboard arrival or the reader's choice — over
+// the cited fixture site (stage.ts; its deck is listed in slides.spec.ts).
+
+const REPORT = "/reports/demo-factcheck/";
+
+const switchIn = (page: Page) => page.locator("header [data-view-switch]");
+const stage = (page: Page) => page.locator("[data-report-slides]");
+const overview = (page: Page) => page.locator("[data-report-overview]");
+
+test("overview: each part beside its slide; arrows walk the pairs; a card opens its slide, a block the article", async ({ page }) => {
+ await page.goto(`${REPORT}#bridge`);
+ await switchIn(page).getByRole("button", { name: "Overview" }).click();
+ await expect(page).toHaveURL(`${REPORT}?rv=iso#bridge`);
+ const ov = overview(page);
+ await expect(ov).toHaveAttribute("aria-roledescription", "overview");
+ await expect(ov.locator("[data-pair]")).toHaveCount(11);
+ await expect(ov.locator('[data-pair="section:bridge"]')).toHaveAttribute("data-active", "true");
+ await ov.locator('[data-pair="section:bridge"] [data-ov-card]').focus();
+ await page.keyboard.press("ArrowDown");
+ await expect(page).toHaveURL(`${REPORT}?rv=iso#claim-1`);
+ await expect(ov.locator('[data-pair="claim:claim-1"] [data-ov-card]')).toBeFocused();
+ await page.keyboard.press("ArrowLeft");
+ await expect(ov.locator('[data-pair="claim:claim-1"] [data-ov-block]')).toBeFocused();
+ await page.keyboard.press("ArrowDown");
+ await expect(ov.locator('[data-pair="claim:claim-2"] [data-ov-block]')).toBeFocused();
+ // Only the pair in force is in the tab order.
+ await expect(ov.locator('[data-ov-block][tabindex="0"], [data-ov-card][tabindex="0"]')).toHaveCount(2);
+ await page.keyboard.press("ArrowRight");
+ await page.keyboard.press("Enter");
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-6`);
+ await expect(stage(page).locator('[data-slide="claim:claim-2"]')).toBeVisible();
+
+ await page.goBack();
+ await expect(page).toHaveURL(`${REPORT}?rv=iso#claim-2`);
+ await overview(page).locator('[data-pair="section:moving"] [data-ov-block]').focus();
+ await page.keyboard.press("Enter");
+ await expect(page).toHaveURL(`${REPORT}#moving`);
+ await expect(page.locator("section#moving")).toBeInViewport();
+});
+
+test("overview: tilted for a pointer; flat for reduced motion; the Flat toggle lays it down", async ({ page, browser }) => {
+ await page.goto(`${REPORT}?rv=iso#claim-1`);
+ await expect(overview(page)).toHaveAttribute("data-report-overview", "iso");
+ const plane = page.locator(".rs-ov-plane");
+ expect(await plane.evaluate((el) => getComputedStyle(el).transform)).not.toBe("none");
+ await page.getByRole("button", { name: "Flat" }).click();
+ await expect(overview(page)).toHaveAttribute("data-report-overview", "flat");
+ expect(await plane.evaluate((el) => getComputedStyle(el).transform)).toBe("none");
+ await expect(page.getByRole("button", { name: "Flat" })).toHaveAttribute("aria-pressed", "true");
+
+ const reduced = await browser.newContext({ reducedMotion: "reduce" });
+ const p2 = await reduced.newPage();
+ await p2.goto(`${REPORT}?rv=iso#claim-1`);
+ await expect(overview(p2)).toHaveAttribute("data-report-overview", "flat");
+ expect(await p2.locator(".rs-ov-plane").evaluate((el) => getComputedStyle(el).transform)).toBe("none");
+ // Flat, the pairs read as two columns: each block beside its card.
+ const block = (await p2.locator('[data-pair="claim:claim-1"] .rs-ov-block').boundingBox())!;
+ const card = (await p2.locator('[data-pair="claim:claim-1"] .rs-ov-card').boundingBox())!;
+ expect(card.x).toBeGreaterThan(block.x + block.width);
+ await reduced.close();
+});
+
+test("a phone: the overview lies flat, each part stacked over its slide; nothing scrolls sideways", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 800 });
+ await page.goto(`${REPORT}?rv=slides#s-5`);
+ await page.locator("[data-reader-overlay] [data-view-switch]").getByRole("button", { name: "Overview" }).click();
+ await expect(page).toHaveURL(`${REPORT}?rv=iso#claim-1`);
+ await expect(overview(page)).toHaveAttribute("data-report-overview", "flat");
+ const block = (await page.locator('[data-pair="claim:claim-1"] .rs-ov-block').boundingBox())!;
+ const card = (await page.locator('[data-pair="claim:claim-1"] .rs-ov-card').boundingBox())!;
+ expect(card.y).toBeGreaterThanOrEqual(block.y + block.height);
+ expect(card.x + card.width).toBeLessThanOrEqual(375);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
+});
diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts
@@ -245,18 +245,20 @@ test("a citation's number jumps to its entry in the reference list", async ({ pa
await expect(page.locator("#c-w01")).toContainText("Opened to traffic: April 2019.");
});
-test("the report downloads as HTML, PDF, Markdown and an evidence pack, its citations as JSON and CSV", async ({
+test("the report downloads as HTML, PDF, Markdown, slides and an evidence pack, its citations as JSON and CSV", async ({
page,
request,
}) => {
await page.goto(REPORT);
const links = page.locator("[data-citation-downloads] a[download]");
- await expect(links).toHaveText(["HTML", "PDF", "Markdown", "Evidence pack", "Citations JSON", "CSV"]);
+ await expect(links).toHaveText(["HTML", "PDF", "Markdown", "Slides", "Slides PDF", "Evidence pack", "Citations JSON", "CSV"]);
const hrefs = await links.evaluateAll((els) => els.map((el) => el.getAttribute("href")!));
expect(hrefs).toEqual([
`${REPORT}report.html`,
`${REPORT}report.pdf`,
`${REPORT}report.md`,
+ `${REPORT}slides.html`,
+ `${REPORT}slides.pdf`,
`${REPORT}evidence-pack.zip`,
`${REPORT}citations.json`,
`${REPORT}citations.csv`,
@@ -267,6 +269,7 @@ test("the report downloads as HTML, PDF, Markdown and an evidence pack, its cita
const body = await res.body();
if (href.endsWith("citations.json")) expect(JSON.parse(body.toString()).format).toBe("archilyzer-citations");
else if (href.endsWith(".csv")) expect(body.toString()).toContain("c01");
+ else if (href.endsWith("slides.html")) expect(body.toString()).toContain("<title>Demo Checks: Checking an example article — slides</title>");
else if (href.endsWith(".html")) expect(body.toString()).toContain("<title>Demo Checks: Checking an example article</title>");
else if (href.endsWith(".pdf")) expect(body.subarray(0, 5).toString()).toBe("%PDF-");
else if (href.endsWith(".md")) expect(body.toString()).toContain("**Demo Checks**\n\n# Checking an example article\n");
diff --git a/export/e2e-report/slides.spec.ts b/export/e2e-report/slides.spec.ts
@@ -0,0 +1,188 @@
+import { expect, test } from "./helpers";
+import type { Page } from "@playwright/test";
+
+// ONE ARTICLE, THREE SHAPES (release 22): the report read as the article and
+// as slides (the overview: overview.spec.ts), over the cited fixture site
+// (stage.ts). The
+// fixture's deck, from its report.json and its slide fields:
+//
+// 1 title 2 In brief 3 What the check found
+// 4 The bridge (points, the city's record as its evidence)
+// 5 claim-1 (evidence: the stream) 6 claim-2 ("Every year?", points)
+// 7 Moving away (no body: its claims)
+// 8 claim-3 (quote: the post) 9 claim-4 (statement) 10 claim-5
+// 11 sources (the closing line)
+//
+// Every test also holds the page's requests to the cited promise (helpers.ts):
+// the slides are built from the report's own page.json, nothing else.
+
+const REPORT = "/reports/demo-factcheck/";
+
+const switchIn = (page: Page, where: "header" | "overlay") =>
+ where === "header" ? page.locator("header [data-view-switch]") : page.locator("[data-reader-overlay] [data-view-switch]");
+const stage = (page: Page) => page.locator("[data-report-slides]");
+
+test("the switch: Article pressed in the header; every place a slide stands for has its id", async ({ page }) => {
+ await page.goto(REPORT);
+ const sw = switchIn(page, "header");
+ await expect(sw).toHaveAttribute("role", "group");
+ await expect(sw.getByRole("button")).toHaveText(["Article", "Slides", "Overview"]);
+ await expect(sw.getByRole("button", { name: "Article" })).toHaveAttribute("aria-pressed", "true");
+ await expect(sw.getByRole("button", { name: "Slides" })).toHaveAttribute("aria-pressed", "false");
+ for (const id of ["report-head", "in-brief", "found", "bridge", "claim-1", "claim-2", "moving", "claim-3", "claim-4", "claim-5", "report-end"]) {
+ await expect(page.locator(`[id="${id}"]`), id).toHaveCount(1);
+ }
+});
+
+test("slides: the switch opens the slide for the part in view, and Esc goes back there", async ({ page }) => {
+ await page.goto(`${REPORT}#claim-3`);
+ await expect(page.locator("article#claim-3")).toBeInViewport();
+ await switchIn(page, "header").getByRole("button", { name: "Slides" }).click();
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-8`);
+ await expect(stage(page)).toHaveAttribute("data-report-slides", "8");
+ await expect(stage(page).locator('[data-slide="claim:claim-3"]')).toBeVisible();
+ // A region with a live label; the focus is in it; the article behind is inert.
+ await expect(stage(page)).toHaveAttribute("aria-roledescription", "slides");
+ await expect(stage(page)).toBeFocused();
+ await expect(page.locator("[data-slide-label]")).toHaveAttribute("aria-live", "polite");
+ await expect(page.locator("[data-slide-label]")).toContainText("Slide 8 of 11: Claim — He has said many times that he would move away.");
+ await expect(page.locator("main")).toHaveAttribute("inert", "");
+ await expect(switchIn(page, "overlay").getByRole("button", { name: "Slides" })).toHaveAttribute("aria-pressed", "true");
+
+ // The quote layout: the post's own words, its marker opening its card.
+ await expect(stage(page).locator('[data-slide-evidence="p01"] .rs-pull')).toHaveText("Never said I would move. Not once.");
+
+ await page.keyboard.press("Escape");
+ await expect(page).toHaveURL(`${REPORT}#claim-3`);
+ await expect(page.locator("[data-reader-overlay]")).toHaveCount(0);
+ await expect(page.locator("article#claim-3")).toBeInViewport();
+ await expect(page.locator("article#claim-3")).toBeFocused();
+ await expect(page.locator("main")).not.toHaveAttribute("inert", /.*/);
+});
+
+test("slides: ← → PgUp PgDn Home End step; the rail and the hash follow; Back undoes the switch", async ({ page }) => {
+ await page.goto(REPORT);
+ await switchIn(page, "header").getByRole("button", { name: "Slides" }).click();
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-1`);
+ await expect(stage(page).locator('[data-slide="title"]')).toContainText("Five claims, checked against what was said on air");
+ await page.keyboard.press("ArrowRight");
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-2`);
+ await page.keyboard.press("PageDown");
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-3`);
+ await expect(stage(page).locator("[data-found-group]")).toHaveCount(4);
+ await page.keyboard.press("End");
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-11`);
+ await expect(stage(page).locator('[data-slide="sources"]')).toContainText("Every citation opens the moment it quotes.");
+ await page.keyboard.press("ArrowRight");
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-11`);
+ await page.keyboard.press("PageUp");
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-10`);
+ await page.keyboard.press("Home");
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-1`);
+ await expect(page.locator(".rs-rail button")).toHaveCount(11);
+ await page.locator(".rs-rail button").nth(5).click();
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-6`);
+ await expect(stage(page).locator('[data-slide="claim:claim-2"]')).toContainText("Every year?");
+ await page.getByRole("button", { name: "Previous slide" }).click();
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-5`);
+ // Stepping replaces the entry: one Back leaves the slides.
+ await page.goBack();
+ await expect(page).toHaveURL(REPORT);
+ await expect(page.locator("[data-reader-overlay]")).toHaveCount(0);
+});
+
+test("slides: a shared link opens the same slide; a section's link opens its slide", async ({ page }) => {
+ await page.goto(`${REPORT}?rv=slides#s-4`);
+ await expect(stage(page).locator('[data-slide="section:bridge"]')).toBeVisible();
+ await expect(stage(page).locator('[data-slide="section:bridge"] .rs-points li')).toHaveText([
+ "The article dates the opening to 2018",
+ /He says 2019 on stream, and the city's record\s*\[4\]\s*agrees/,
+ ]);
+ await page.goto(`${REPORT}?rv=slides#moving`);
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-7`);
+ await expect(stage(page).locator('[data-slide="section:moving"] .rs-claimlist li')).toHaveCount(3);
+});
+
+test("slides: a citation's number opens its card; the evidence shows the clip's poster", async ({ page }) => {
+ await page.goto(`${REPORT}?rv=slides#s-5`);
+ const slide = stage(page).locator('[data-slide="claim:claim-1"]');
+ await expect(slide.locator('[data-verdict="CONTRADICTED"]')).toBeVisible();
+ const pic = slide.locator('[data-slide-evidence="c01"] img');
+ await expect(pic).toHaveAttribute("src", "/reports/demo-factcheck/stills/poster.png");
+ await expect.poll(() => pic.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBeGreaterThan(0);
+ const number = slide.locator('[data-inline-cite="c01"] [data-cite-number]');
+ await number.click();
+ await expect(page.locator('[data-cite-preview="c01"]')).toBeVisible();
+ await expect(page.locator('[data-cite-preview="c01"]')).toContainText("The bridge opened in the spring of twenty nineteen");
+ // The click opened the card; it did not leave the slide.
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-5`);
+});
+
+test("slides: Read this in the article opens the article at the slide's place", async ({ page }) => {
+ await page.goto(`${REPORT}?rv=slides#s-6`);
+ await stage(page).locator('[data-slide="claim:claim-2"] [data-slide-read]').click();
+ await expect(page).toHaveURL(`${REPORT}#claim-2`);
+ await expect(page.locator("article#claim-2")).toBeInViewport();
+ await expect(page.locator("article#claim-2")).toBeFocused();
+});
+
+test("a phone: the slide reflows, its evidence under its words; nothing scrolls sideways", async ({ page }) => {
+ await page.setViewportSize({ width: 375, height: 800 });
+ await page.goto(`${REPORT}?rv=slides#s-5`);
+ const slide = stage(page).locator('[data-slide="claim:claim-1"]');
+ await expect(slide).toHaveAttribute("data-fit", "reflow");
+ const words = (await slide.locator("h2").boundingBox())!;
+ const ev = (await slide.locator('[data-slide-evidence="c01"]').boundingBox())!;
+ expect(ev.y).toBeGreaterThan(words.y + words.height);
+ expect(ev.x + ev.width).toBeLessThanOrEqual(375);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
+
+});
+
+test("light and dark: the slides follow the base and wear the site's accent", async ({ page }) => {
+ await page.goto(`${REPORT}?rv=slides#s-4`);
+ const slide = stage(page).locator('[data-slide="section:bridge"]');
+ const accent = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue("--brand").trim());
+ const band = await slide.locator(".rs-band").evaluate((el) => getComputedStyle(el).backgroundColor);
+ const probe = await page.evaluate((c) => {
+ const d = document.createElement("div");
+ d.style.color = c;
+ document.body.append(d);
+ const v = getComputedStyle(d).color;
+ d.remove();
+ return v;
+ }, accent);
+ expect(band).toBe(probe);
+ const lightBg = await slide.evaluate((el) => getComputedStyle(el).backgroundColor);
+ await page.evaluate(() => {
+ document.documentElement.classList.add("dark");
+ document.documentElement.setAttribute("data-base", "dark");
+ });
+ await expect.poll(() => slide.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(lightBg);
+});
+
+test("slides.html opens with no network: one page per slide, its pictures inlined, the keys step", async ({ page, baseURL }) => {
+ const url = `${baseURL}${REPORT}slides.html`;
+ const asked: string[] = [];
+ await page.route("**/*", (route) => {
+ const u = route.request().url();
+ if (u === url) return route.continue();
+ asked.push(u);
+ return route.abort();
+ });
+ await page.setViewportSize({ width: 1280, height: 720 });
+ await page.goto(url);
+ await expect(page.locator(".rs-page")).toHaveCount(11);
+ await expect(page.locator('#s-1 [data-slide="title"]')).toContainText("Checking an example article");
+ const imgs = page.locator("img");
+ expect(await imgs.count()).toBeGreaterThan(0);
+ for (const img of await imgs.all()) {
+ expect(await img.getAttribute("src")).toMatch(/^data:image\//);
+ await expect.poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBeGreaterThan(0);
+ }
+ await page.keyboard.press("ArrowRight");
+ await expect(page).toHaveURL(`${url}#s-2`);
+ await expect(page.locator("#s-2")).toBeInViewport();
+ await expect(page.locator("#s-11 [data-export-footer]")).toContainText("Revision 2");
+ expect(asked).toEqual([]);
+});
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",
@@ -228,6 +246,8 @@
"html": "/reports/demo-factcheck/report.html",
"pdf": "/reports/demo-factcheck/report.pdf",
"md": "/reports/demo-factcheck/report.md",
+ "slides": "/reports/demo-factcheck/slides.html",
+ "slides-pdf": "/reports/demo-factcheck/slides.pdf",
"zip": "/reports/demo-factcheck/evidence-pack.zip",
"json": "/reports/demo-factcheck/citations.json",
"csv": "/reports/demo-factcheck/citations.csv"
@@ -237,5 +257,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/plans/release-22.md b/plans/release-22.md
@@ -111,6 +111,114 @@ when a site is rebuilt — nothing here rewrites a report.json in the corpus.
### Track E
+Branch `worktree-agent-a4ec71c7c593a80e5`, off `fdbdd370` (`r19/integration` with this plan). Not pushed, not merged.
+
+#### E1, as shipped — the slide fields and the builder
+
+`report.json` gains `slides` (`hide`, `title`, `points`, `closing` — `points` added to the plan's three: the
+plan's "the quick take: summary's first paragraph (or `points`)" needs a place for them) and a section's or
+claim's `slide` (`title`, `points`, `cite`, `layout`, `hide`); `version` stays 1. The limits are one pure module,
+`common/lib/report/slideRules.ts` (5 points, 140 visible characters each — a citation counts as its label —, 160
+for a line), with the report page's own anchor ids (`REPORT_PAGE_ANCHOR_IDS`: `report-head`, `in-brief`, `found`,
+`claims`, `references`, `downloads`, `report-end`). The validator checks lines (one line, not blank, not too
+long), points (count, length, inline cites that name a citation the report cites ELSEWHERE — a slide never brings
+evidence of its own, which would have no number), a `cite` of the slide's own section or claim, an
+`evidence`/`quote` layout with something to show, and refuses a section or claim id that is one of the page's own
+anchors (a counts-only scan of the 26 published `report.json` files: 0 of 322 ids collide). `lib/report/slides.ts`
+`buildReportSlides(view)` is the deck (title, In brief, What the check found, each section and claim, sources),
+each slide anchored to an id the article page carries (`reportPageAnchorIds`), plus `slideForAnchor`,
+`slideIndexForPlace`, `slideLint` (umtool) and the URL place (`parseReaderPlace`/`readerPlaceUrl`: `rv`, `#s-<n>`
+or an anchor). The page view carries the fields through `buildReportPageView` (compose) and umtool's lenient
+fallback (which also now gives a claim's `sourceQuote` as its id, as the strict view does). REPORT.md regenerated
+(a Slides section, the two key tables); the demo fixture report gains `slides`, a section's points and cite, a
+claim's title and points, a claim's quote layout.
+
+#### E2, as shipped — the slides view
+
+`common/components/report/slides/`: `Slide` (one slide per kind and layout; its left edge the colour of what it
+says — a claim's verdict, else the accent; "Read this in the article" to its anchor), `ReportSlides` (a 1280 × 720
+canvas scaled as one piece to its box, reflowed on a narrow portrait box; ← → PgUp PgDn Home End, Esc, swipe; a
+region with an `aria-live` "Slide n of N: <kind> — <title>" label; a rail), `ViewSwitch` (a `role="group"` of
+`aria-pressed` toggles), `SlidesHost` (what differs by host: markdown, a citation's marker, the way back, a badge),
+`useReaderPlace` (`rv` + hash; a switch pushes, a step replaces). Styles are plain CSS, `common/styles/slides.css`
+(`.rs-*`, unlayered so it wins over a shared component's utilities), coloured from the host's tokens through
+`--rs-*`. The export site's report header carries `ReportReader`: the other views are laid over the static page,
+built in the browser from `page.json` (fetched on demand); the article behind is `inert` and still; the place is
+kept both ways (the part in view opens its slide; Esc / "Read this" land on the anchor, scrolled to and focused); a
+citation number's click inside a slide opens its card instead of jumping to the reference list behind.
+`CitationCard`'s `heading` is exported as `citationHeading`.
+
+#### E3, as shipped — the overview
+
+`ReportOverview`: the pairs as one ordered list; tilted (`rotateX(55deg) rotateZ(-45deg)`, the plane's box sized
+from the tilt's geometry so it scrolls whole, the scroller alone moved to the pair in force — never the article
+behind) or flat (two columns; stacked under 780 px of box, a container query so umtool's column stacks too). Flat
+for reduced motion, a box under 900 px, a keyboard arrival, or the **Flat** toggle (localStorage, try/catch). ↑ ↓
+Home End walk, ← → block/card, Enter opens, Esc back to the article; roving tabindex (only the pair in force is
+tabbable); a thumbnail is the real slide, `inert`, its words as text. The e2e specs were split: `slides.spec.ts`,
+`overview.spec.ts`.
+
+#### E4, as shipped — umtool
+
+`ArticleReader` gets the switch over its lenient view; `ArticleShapes` renders the shared slides/overview in the
+reading column (the text stays mounted, hidden, its note marks with it; the notes rail beside). A slide's markdown
+is umtool's `Md` (a citation opens the evidence rail), its badge the open notes on its part (`openNotesBySlide`:
+a section's on its slide, title/subtitle on the title slide, a citation's on each slide showing it). The source
+tab lists slide lint and the validator's slide problems (`data-slide-lint`), each linked to its slide. umtool's
+globals.css imports slides.css, sources `common/components/report`, and maps `--rs-*` to its bench palette (`sel`
+the accent). The sites fixture's draft `polemic-beta` gains a long body (its slide lint's case).
+`umtool/e2e/article-slides.spec.ts` (4 tests).
+
+#### E5, as shipped — exports
+
+`slides` (`slides.html`) and `slides-pdf` (`slides.pdf`) join `REPORT_EXPORT_FORMATS` (order html, pdf, md,
+slides, slides-pdf, zip), so the downloads line, compose, the cited audit's allowlist (`REPORT_EXPORT_FILENAMES`),
+the revision commit's `exports.json`, the CLI and the ops job take them unchanged. `lib/report/exportSlidesHtml.ts`
+is the slides as one file (slides.css inline, the light base in the site's accent — `resolveAccent(site.accent)
+.light` —, every picture a data: URI, a few lines of script to fit and step, numbers to a reference list under the
+deck, each slide's way back to the site, the footer line on the sources slide). It is a STRING mirror of
+`Slide.tsx`: reportExports.ts is bundled into the editor's server (its "use server" export action), where
+`react-dom/server` is not imported; `exportSlides.test.ts` holds the two to the same `rs-` classes per slide and
+every class to a rule in the stylesheet. `slides.pdf` prints it at 1280 × 720 (`PdfPrinter.print(html, size?)`,
+`playwrightRuntime`'s `pdf` type gains width/height). A poster only the slides show is inlined or packed when local,
+never a problem. A report with `slides.hide` exports neither (a log line). CLI usage, ops usage and COMMANDS.md
+updated.
+
+#### E6 — records
+
+This section; `export/CHANGELOG.md` `[Unreleased]` (two bullets); PUBLISH.md's exports paragraphs and table row;
+the ruling line above reworded as on `r19/integration`. umtool has no CHANGELOG.md (none written); the editor is
+unchanged (no editor changelog).
+
+| commit | what |
+|---|---|
+| `2734b22b` | E1 schema, validator, slideRules, buildReportSlides + tests, REPORT.md, fixture |
+| `12691d03` | E2 shared slides components, slides.css, ReportReader, article anchors, slides.spec |
+| `75f0205d` | E3 ReportOverview, the switch's Overview, overview.spec |
+| `5d0b4fbc` | E4 umtool switch, ArticleShapes, note counts, source-tab slide lint, article-slides.spec |
+| `e9ea4cdc` | E5 slides.html / slides.pdf exports, downloads, pack, tests, CLI text |
+| (this) | E6 record, changelog, PUBLISH.md |
+
+**Gates.** `pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit` clean at E1 and at E5 (E2–E4 per
+package: common, export, umtool `tsc --noEmit` clean). Unit: `common/lib/report/*.test.ts` 111/111 (slides.test 26,
+exportSlides.test 6, report.test 17 with the new slide/anchor cases); `publish/composeReports.test.ts` +
+`reportMedia.test.ts` 23/23 (export expectations updated for the two new files and the second skipped-PDF note);
+`lib/builtExport.test.ts` + `bin/cli-docs.test.ts` + `bin/_cli.test.ts` + exportSlides 62/62;
+`pnpm --filter export test` 116/116; mcp 292/292 (at E1), `mcp/src/reports.test.ts` 5/5 (at E5);
+`scripts/archilyzer-ops.test.mjs` 46/46. **Not run — the operator's P1 hold (no e2e, no builds until the
+all-clear):** the export report suite (`e2e:report`, planned at E3 and E5: `slides.spec.ts` 10, `overview.spec.ts`
+3, the downloads/audit changes), umtool's article specs (`article-slides.spec.ts` + the three existing), the umtool
+capped build, and the full common suite. The views were checked visually instead, with no build: slides.html of the
+fixture rendered and screenshotted in headless Chromium (every slide kind), and the shared components bundled with
+esbuild over the fixture's page.json (slides at 1440 × 900 and 390 × 844, the overview tilted, flat under reduced
+motion, stacked on a phone, light and dark).
+
+**Found and left.** (1) A claim's slide shows its gist when it has no points; a claim with neither shows only its
+title and text. (2) The overview's thumbnails render every slide (fine at the fixture's 11; a report of 100+ claims
+would render 100+ small slides — not measured). (3) The keyboard-arrival heuristic is the last input before the
+overview mounted (a keydown anywhere counts). (4) `report.json`'s change summary (revision history) does not name a
+slide-field edit ("Other edits"). (5) slides.html's light base only (no dark variant), as report.html.
+
## Rollout
Rebuild and deploy each site with reports (the slides view is in the bundle, and `reports prepare` re-exports to
diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs
@@ -656,8 +656,9 @@ export function usage() {
' When nothing is missing it then exports the reports, as reports-export.',
"",
'reports-export writes each published report as report.html, report.pdf,',
- ' report.md and evidence-pack.zip for the site\'s build to publish:',
- ' {"siteId", "reportId"?, "formats"?: ["html","pdf","md","zip"]}.',
+ ' report.md, slides.html, slides.pdf and evidence-pack.zip for the site\'s',
+ ' build to publish: {"siteId", "reportId"?, "formats"?: ["html","pdf","md",',
+ ' "slides","slides-pdf","zip"]}.',
"",
'settings patches settings.json through the editor\'s one writer:',
' {"patch": {"<top-level key>": value, …}} (SETTINGS.md). A block\'s keys',
diff --git a/umtool/app/globals.css b/umtool/app/globals.css
@@ -11,9 +11,14 @@
* scratch dist dir, a test artefact, a fixture or a doc can no longer poison
* the stylesheet. */
@import "tailwindcss" source(none);
+@import "../../common/styles/slides.css";
@source "../app";
@source "../components";
@source "../lib";
+/* The article's other shapes (release 22): the shared slides and overview
+ (their stylesheet is the import above) and the verdict chip they draw.
+ Named, not detected, as above. */
+@source "../../common/components/report";
/* A dark, dense, keyboard-first bench. Nothing here is deployed; the only user
is someone judging thousands of clips, or auditioning one transition, in a
@@ -159,3 +164,19 @@ kbd {
canvas.grabbable {
touch-action: none;
}
+
+/* The slides and the overview (common/styles/slides.css) in this bench's
+ palette: its ink and panels, `sel` as the accent (it is the interaction
+ colour, and every mark on a slide that is not a verdict is a way to act). */
+.rs-root {
+ --rs-ground: var(--color-ink);
+ --rs-paper: var(--color-panel);
+ --rs-ink: var(--color-text);
+ --rs-muted: var(--color-dim);
+ --rs-faint: var(--color-dim);
+ --rs-line: var(--color-line);
+ --rs-line-strong: color-mix(in srgb, var(--color-line) 55%, var(--color-dim));
+ --rs-accent: var(--color-sel);
+ --rs-accent-soft: color-mix(in srgb, var(--color-sel) 16%, transparent);
+ --rs-focus: var(--color-sel);
+}
diff --git a/umtool/app/sites/[site]/[report]/page.tsx b/umtool/app/sites/[site]/[report]/page.tsx
@@ -3,6 +3,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { readRevisionHead, REPORT_HISTORY_GIT_DIRNAME } from "yt-dlp-transcript-common/publish/reportHistory";
import { siteReportDir } from "yt-dlp-transcript-common/publish/reportMedia";
+import { hasSlides, slideLint } from "yt-dlp-transcript-common/lib/report/slides";
import BrowseHeader from "@/components/BrowseHeader";
import ArticleReader from "@/components/articles/ArticleReader";
import WorkspacePanel from "@/components/articles/WorkspacePanel";
@@ -18,8 +19,12 @@ import type { NotesRead } from "@/lib/annotations/types";
export const dynamic = "force-dynamic";
// One article: the reader with its notes (default), or `?tab=source` -- the
-// workspace files it was written from, its draft marked.
+// workspace files it was written from, its draft marked, and what would make
+// its slides read badly (lib/report/slides.ts slideLint, and the validator's
+// slide problems).
//
+// ?rv=slides|iso the article as slides, or the overview (the
+// reader's own switch; the place is the hash)
// ?status=open|resolved|all the notes filter it opens with
// ?note=<id> the note it opens on (the decisions inbox links here)
// ?ws=&rel= a workspace file open on the source tab
@@ -128,12 +133,39 @@ export default async function ArticlePage({
? await openWorkspaceFile(ws.name, path.relative(ws.dir, source.draft))
: null;
const draftRel = ws && source?.draft ? `${ws.name}/${path.relative(ws.dir, source.draft)}` : null;
+ const { view: slideView } = await articleView(r);
+ const lint = hasSlides(slideView) ? slideLint(slideView) : [];
+ const slideProblems = read.problems.filter((p) => /(^|\.)slides?(\.|\[|$)/.test(p.path ?? ""));
return (
<div className="flex h-full flex-col">
{header}
<main className="deck-main flex-1 p-4">
<div className="space-y-4">
{meta}
+ {hasSlides(slideView) && (
+ <section data-slide-lint={lint.length + slideProblems.length} className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2 text-[12px]">
+ <div className="micro">slides</div>
+ {lint.length + slideProblems.length === 0 ? (
+ <p className="mt-1 text-[var(--color-dim)]">nothing to fix</p>
+ ) : (
+ <ul className="mt-1 space-y-1">
+ {slideProblems.map((p, i) => (
+ <li key={`p${i}`} data-lint="problem" className="text-[var(--color-bad)]">
+ <code className="font-mono text-[11px]">{p.path}</code> {p.message}
+ </li>
+ ))}
+ {lint.map((l, i) => (
+ <li key={`l${i}`} data-lint={l.key}>
+ <Link href={`${base}?rv=slides#${l.anchorId}`} className="font-mono text-[11px] text-[var(--color-sel)] hover:underline">
+ {l.key}
+ </Link>{" "}
+ <span className="text-[var(--color-text)]">{l.message}</span>
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+ )}
<WorkspacePanel
workspaces={ws ? [ws] : []}
opened={opened}
diff --git a/umtool/components/articles/ArticleBody.tsx b/umtool/components/articles/ArticleBody.tsx
@@ -62,7 +62,13 @@ function CiteLink({ href, children }: AnchorHTMLAttributes<HTMLAnchorElement>) {
const MD =
"text-[14px] leading-relaxed text-[var(--color-text)] [&_p]:my-2.5 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-[var(--color-line)] [&_blockquote]:pl-3 [&_blockquote]:text-[var(--color-dim)] [&_strong]:font-semibold [&_em]:italic [&_h3]:mt-4 [&_h3]:font-semibold [&_h4]:mt-3 [&_h4]:font-semibold [&_code]:font-mono [&_code]:text-[12px] [&_hr]:my-4 [&_hr]:border-[var(--color-line)]";
-function Md({ text }: { text: string }) {
+// The article's citation map, for markdown rendered outside the body (the
+// slides): its numbers, and its quotes on hover.
+export function ArticleCitations({ citations, children }: { citations: Readonly<Record<string, CitationView>>; children: ReactNode }) {
+ return <CitationsContext.Provider value={citations}>{children}</CitationsContext.Provider>;
+}
+
+export function Md({ text }: { text: string }) {
return (
<Markdown className={MD} linkComponent={CiteLink}>
{text}
diff --git a/umtool/components/articles/ArticleReader.tsx b/umtool/components/articles/ArticleReader.tsx
@@ -2,12 +2,16 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { ReportPageView } from "yt-dlp-transcript-common/lib/report/views";
+import { buildReportSlides, hasSlides, reportPageAnchorIds, slideIndexForPlace, type ReaderView } from "yt-dlp-transcript-common/lib/report/slides";
+import { useReaderPlace } from "yt-dlp-transcript-common/components/report/slides/useReaderPlace";
+import { ViewSwitch } from "yt-dlp-transcript-common/components/report/slides/ViewSwitch";
import CopyButton from "@/components/CopyButton";
import { locateQuote, quoteAnchor } from "@/lib/annotations/anchor.mjs";
import type { Anchor, Note, NotesRead } from "@/lib/annotations/types";
import { useNotes } from "@/lib/annotations/useNotes";
import { ShareNotes } from "@/components/notes/NotesProvider";
import ArticleBody, { ArticleActionsContext, type ArticleActions } from "./ArticleBody";
+import ArticleShapes from "./ArticleShapes";
import ArticleVideo from "./ArticleVideo";
import type { VideoProjectLink } from "./AddToVideo";
import { blockText, selectionIn, unwrapMarks, wrapRange } from "./anchorDom";
@@ -28,9 +32,28 @@ import { Composer, NoteCard, anchorLabel } from "./NoteCards";
//
// Keys (not while typing): j/k move between notes, r resolves, e edits, n
// notes the selection (or the whole article), Esc closes whatever is open.
+//
+// THE ARTICLE'S OTHER SHAPES (release 22): Article | Slides | Overview, the
+// published page's switch (`?rv=`, the place in the hash), so a draft is read
+// as slides before it ships. The text stays mounted (hidden) under them, its
+// marks with it; the notes rail stays beside them (ArticleShapes).
type Filter = "open" | "resolved" | "all";
+// The article's element for a slide's anchor: a claim, a block, or the block
+// that stands for one of the page's own places.
+function placeEl(root: HTMLElement, anchorId: string): HTMLElement | null {
+ const q = (sel: string) => root.querySelector<HTMLElement>(sel);
+ if (anchorId === "report-head") return q('[data-block="title"]');
+ if (anchorId === "in-brief" || anchorId === "found") return q('[data-block="summary"]') ?? q('[data-block="title"]');
+ if (anchorId === "report-end") {
+ const sections = root.querySelectorAll<HTMLElement>("section[data-block]");
+ return q('[data-block="method"]') ?? sections[sections.length - 1] ?? null;
+ }
+ const id = CSS.escape(anchorId);
+ return q(`[data-claim="${id}"]`) ?? q(`[data-block="${id}"]`);
+}
+
const MARK_CLASS =
"rounded-sm bg-[color-mix(in_srgb,var(--color-sel)_22%,transparent)] text-inherit data-[active=true]:bg-[color-mix(in_srgb,var(--color-sel)_48%,transparent)] data-[status=resolved]:bg-transparent data-[status=resolved]:underline data-[status=resolved]:decoration-[var(--color-good)] data-[status=wontfix]:bg-transparent";
@@ -84,6 +107,49 @@ export default function ArticleReader({
const [drawer, setDrawer] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
const evidence = useEvidence(site, report, cite);
+ const shapes = hasSlides(view);
+ const { place, go } = useReaderPlace();
+ const shape: ReaderView = shapes ? place.view : "article";
+ const slides = useMemo(() => buildReportSlides(view), [view]);
+ const order = useMemo(() => reportPageAnchorIds(view), [view]);
+
+ // Back to the text at a slide's place: scrolled to, focused.
+ const toArticle = useCallback(
+ (anchorId: string | null) => {
+ go({ view: "article", anchor: anchorId }, true);
+ if (!anchorId) return;
+ requestAnimationFrame(() => {
+ const el = bodyRef.current && placeEl(bodyRef.current, anchorId);
+ if (!el) return;
+ el.scrollIntoView({ block: "start" });
+ if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
+ el.focus({ preventScroll: true });
+ });
+ },
+ [go],
+ );
+ const switchTo = useCallback(
+ (next: ReaderView) => {
+ let anchor: string | null = place.anchor;
+ if (shape === "article" && bodyRef.current) {
+ // The part of the text in view: the last place at or above the top third.
+ const main = bodyRef.current.closest("main");
+ const top = main?.getBoundingClientRect().top ?? 0;
+ const line = top + Math.min(((main?.clientHeight ?? window.innerHeight) / 3), 280);
+ anchor = null;
+ for (const s of slides) {
+ const el = placeEl(bodyRef.current, s.anchorId);
+ if (!el) continue;
+ if (anchor === null || el.getBoundingClientRect().top <= line) anchor = s.anchorId;
+ }
+ } else if (shape === "slides") {
+ anchor = slides[slideIndexForPlace(slides, place, order)]?.anchorId ?? null;
+ }
+ if (next === "article") toArticle(anchor);
+ else go({ view: next, anchor }, true);
+ },
+ [place, shape, slides, order, go, toArticle],
+ );
const titles = useMemo(() => {
const t: Record<string, string> = { title: "title", subtitle: "subtitle", summary: "summary", method: "method" };
@@ -277,12 +343,15 @@ export default function ArticleReader({
return (
<div className="flex min-h-0 flex-1">
<main className="deck-main min-w-0 flex-1 p-4">
- <div className="mx-auto max-w-[72ch] space-y-4">
+ <div className={shape === "article" ? "mx-auto max-w-[72ch] space-y-4" : "mx-auto max-w-[1180px] space-y-4"}>
{meta}
{viewError && <p className="text-[12px] text-[var(--color-bad)]">citations not shown: {viewError}</p>}
+ {shapes && <ViewSwitch value={shape} onChange={switchTo} />}
<ArticleActionsContext.Provider value={actions}>
+ {shape !== "article" && <ArticleShapes view={view} notes={notes} place={place} go={go} onArticle={toArticle} />}
<div
ref={bodyRef}
+ hidden={shape !== "article"}
onMouseUp={() => setTimeout(readSelection, 0)}
onKeyUp={(e) => e.shiftKey && readSelection()}
onMouseOver={(e) => {
diff --git a/umtool/components/articles/ArticleShapes.tsx b/umtool/components/articles/ArticleShapes.tsx
@@ -0,0 +1,129 @@
+"use client";
+
+import { useContext, useMemo, type ReactNode } from "react";
+import type { ReportPageView } from "yt-dlp-transcript-common/lib/report/views";
+import {
+ buildReportSlides,
+ reportPageAnchorIds,
+ slideIndexForPlace,
+ type ReaderPlace,
+ type SlideView,
+} from "yt-dlp-transcript-common/lib/report/slides";
+import { ReportOverview } from "yt-dlp-transcript-common/components/report/slides/ReportOverview";
+import { ReportSlides } from "yt-dlp-transcript-common/components/report/slides/ReportSlides";
+import { SlidesHostProvider, type SlidesHost } from "yt-dlp-transcript-common/components/report/slides/SlidesHost";
+import { readerUrl, type PlaceLike } from "yt-dlp-transcript-common/components/report/slides/useReaderPlace";
+import type { Note } from "@/lib/annotations/types";
+import { ArticleActionsContext, ArticleCitations, Md } from "./ArticleBody";
+
+// THE ARTICLE AS SLIDES, AND THE OVERVIEW — in umtool's reading column, so a
+// draft can be read in every shape the published page offers before it is
+// published (common/components/report/slides/; the export site lays the same
+// views over its page). The notes rail stays beside them:
+//
+// a citation opens its evidence in the rail, as in the article
+// a slide's notes the open notes on its part of the article, counted on it
+// (a section's notes on the section's slide, the title's on
+// the title slide, a citation's on each slide showing it)
+// Read this in the article, Esc back to the article at the slide's place
+
+// Which slides a note is on.
+function slideKeysOf(note: Note, slides: readonly SlideView[]): string[] {
+ const a = note.anchor;
+ if (a.kind === "text" || a.kind === "section") {
+ if (a.section === "title" || a.section === "subtitle") return ["title"];
+ if (a.section === "summary") return ["summary"];
+ return slides.some((s) => s.key === `section:${a.section}`) ? [`section:${a.section}`] : [];
+ }
+ if (a.kind === "cite") {
+ return slides.filter((s) => (s.kind === "claim" || s.kind === "section") && s.cite === a.cite).map((s) => s.key);
+ }
+ return [];
+}
+
+export function openNotesBySlide(notes: readonly Note[], slides: readonly SlideView[]): Map<string, number> {
+ const out = new Map<string, number>();
+ for (const n of notes) {
+ if (n.status !== "open") continue;
+ for (const k of slideKeysOf(n, slides)) out.set(k, (out.get(k) ?? 0) + 1);
+ }
+ return out;
+}
+
+export default function ArticleShapes({
+ view,
+ notes,
+ place,
+ go,
+ onArticle,
+}: {
+ view: ReportPageView;
+ notes: readonly Note[];
+ place: ReaderPlace;
+ go: (place: PlaceLike, push: boolean) => void;
+ // Back to the article at an anchor.
+ onArticle: (anchorId: string | null) => void;
+}) {
+ const actions = useContext(ArticleActionsContext);
+ const slides = useMemo(() => buildReportSlides(view), [view]);
+ const order = useMemo(() => reportPageAnchorIds(view), [view]);
+ const counts = useMemo(() => openNotesBySlide(notes, slides), [notes, slides]);
+
+ const host = useMemo<SlidesHost>(
+ () => ({
+ md: (text) => (
+ <div className="rs-md">
+ <Md text={text} />
+ </div>
+ ),
+ cite: (id: string, children: ReactNode) => (
+ <button type="button" data-cite={id} className="rs-cite" onClick={() => actions?.openCite(id)}>
+ {children}
+ <sup>{view.citations[id]?.number ?? "?"}</sup>
+ </button>
+ ),
+ readHref: (s) => readerUrl({ view: "article", anchor: s.anchorId }),
+ onRead: (s) => onArticle(s.anchorId),
+ badge: (s) => {
+ const n = counts.get(s.key) ?? 0;
+ return n > 0 ? (
+ <span className="rs-notes" data-slide-notes={n}>
+ {n} open note{n === 1 ? "" : "s"}
+ </span>
+ ) : null;
+ },
+ }),
+ [actions, view, counts, onArticle],
+ );
+
+ if (slides.length === 0) return null;
+ return (
+ <ArticleCitations citations={view.citations}>
+ <SlidesHostProvider host={host}>
+ {place.view === "slides" ? (
+ <div className="h-[min(74vh,660px)] overflow-hidden rounded border border-[var(--color-line)]">
+ <ReportSlides
+ slides={slides}
+ view={view}
+ index={slideIndexForPlace(slides, place, order)}
+ onIndex={(i) => go({ view: "slides", slide: i + 1 }, false)}
+ onExit={(s) => onArticle(s.anchorId)}
+ />
+ </div>
+ ) : (
+ <div className="h-[78vh] overflow-hidden rounded border border-[var(--color-line)]">
+ <ReportOverview
+ slides={slides}
+ view={view}
+ focusAnchor={place.anchor}
+ onFocusAnchor={(a) => go({ view: "iso", anchor: a }, false)}
+ onOpenArticle={(s) => onArticle(s.anchorId)}
+ onOpenSlide={(s) => go({ view: "slides", slide: s.n }, true)}
+ onExit={(s) => onArticle(s?.anchorId ?? place.anchor)}
+ />
+ </div>
+ )}
+ </SlidesHostProvider>
+ </ArticleCitations>
+ );
+}
diff --git a/umtool/e2e/article-slides.spec.ts b/umtool/e2e/article-slides.spec.ts
@@ -0,0 +1,97 @@
+import { test, expect } from "@playwright/test";
+import { rmSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { WARM_TIMEOUT, warm } from "./warm";
+
+// ---------------------------------------------------------------------------
+// An article's other shapes in umtool (release 22): the published page's
+// switch (Article | Slides | Overview) over a draft or a published article,
+// the notes rail beside them, each slide's open notes counted on it, and the
+// source tab's slide lint.
+//
+// priv/polemic-alpha a sweep: title, In brief, two sections, sources —
+// WRITES sites/priv/reports/polemic-alpha/notes.json
+// priv/polemic-beta a draft whose one section's first sentences run long
+// ---------------------------------------------------------------------------
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const NOTES = path.join(HERE, "..", ".e2e-song", "sites", "priv", "reports", "polemic-alpha", "notes.json");
+const PAGE = "/sites/priv/polemic-alpha";
+
+test.beforeAll(async ({ playwright }) => {
+ test.setTimeout(WARM_TIMEOUT);
+ await warm(playwright, [PAGE, `${PAGE}?tab=source`, "/sites/priv/polemic-beta?tab=source", "/api/notes?article=priv/polemic-alpha"]);
+});
+
+test.beforeEach(() => rmSync(NOTES, { force: true }));
+test.afterAll(() => rmSync(NOTES, { force: true }));
+
+test("the switch: slides beside the notes rail, keys step, a citation opens its evidence, Esc goes back", async ({ page }) => {
+ await page.goto(PAGE);
+ const sw = page.locator("main [data-view-switch]");
+ await expect(sw.getByRole("button")).toHaveText(["Article", "Slides", "Overview"]);
+ await expect(sw.getByRole("button", { name: "Article" })).toHaveAttribute("aria-pressed", "true");
+
+ await sw.getByRole("button", { name: "Slides" }).click();
+ await expect(page).toHaveURL(/\?rv=slides#s-1$/);
+ const stage = page.locator("[data-report-slides]");
+ await expect(stage.locator('[data-slide="title"]')).toContainText("Alpha: the rigged vote");
+ await expect(page.locator("[data-article-body]")).toBeHidden();
+ await expect(page.locator('aside[aria-label="notes"]')).toBeAttached();
+ await page.keyboard.press("End");
+ await expect(stage.locator('[data-slide="sources"]')).toBeVisible();
+ await page.keyboard.press("Home");
+ await page.keyboard.press("ArrowRight");
+ await page.keyboard.press("ArrowRight");
+ await expect(page).toHaveURL(/\?rv=slides#s-3$/);
+ const slide = stage.locator('[data-slide="section:first"]');
+ await expect(slide).toContainText("Nobody checked the claim at the time, and the clip shows it.");
+
+ // A citation in the slide's words opens its evidence in the rail.
+ await slide.locator('[data-cite="c1"]').first().click();
+ await expect(page.locator("[data-evidence-rail]")).toBeVisible();
+
+ // Esc leaves the slides, back to the text at the slide's place.
+ await page.keyboard.press("Escape");
+ await expect(page).not.toHaveURL(/rv=slides/);
+ await expect(page.locator("[data-article-body]")).toBeVisible();
+ await expect(page.locator('[data-block="first"]')).toBeInViewport();
+});
+
+test("a section's open notes are counted on its slide", async ({ page }) => {
+ await page.goto(PAGE);
+ await page.getByRole("button", { name: "note on Later" }).click();
+ await page.getByLabel("note text").fill("Check the date on this.");
+ await page.getByRole("button", { name: "save note" }).click();
+ await expect(page.locator("[data-note-card]")).toHaveCount(1);
+
+ await page.goto(`${PAGE}?rv=slides#later`);
+ await expect(page).toHaveURL(/\?rv=slides#s-4$/);
+ const slide = page.locator('[data-report-slides] [data-slide="section:later"]');
+ await expect(slide.locator("[data-slide-notes]")).toHaveText("1 open note");
+ await page.keyboard.press("ArrowLeft");
+ await expect(page.locator('[data-report-slides] [data-slide="section:first"] [data-slide-notes]')).toHaveCount(0);
+});
+
+test("the overview pairs each part with its slide; a card opens its slide", async ({ page }) => {
+ await page.goto(`${PAGE}?rv=iso#first`);
+ const ov = page.locator("[data-report-overview]");
+ await expect(ov.locator("[data-pair]")).toHaveCount(5);
+ await expect(ov.locator('[data-pair="section:first"]')).toHaveAttribute("data-active", "true");
+ await ov.locator('[data-pair="section:later"] [data-ov-card]').focus();
+ await page.keyboard.press("Enter");
+ await expect(page).toHaveURL(/\?rv=slides#s-4$/);
+});
+
+test("the source tab lints the slides: a section whose first sentences run long", async ({ page }) => {
+ await page.goto("/sites/priv/polemic-beta?tab=source");
+ const lint = page.locator("[data-slide-lint]");
+ await expect(lint).toHaveAttribute("data-slide-lint", "1");
+ await expect(lint.locator('[data-lint="section:only"]')).toContainText("no slide points");
+ await expect(lint.locator('[data-lint="section:only"] a')).toHaveAttribute("href", "/sites/priv/polemic-beta?rv=slides#only");
+
+ await page.goto(`${PAGE}?tab=source`);
+ await expect(page.locator("[data-slide-lint]")).toHaveAttribute("data-slide-lint", "0");
+ await expect(page.locator("[data-slide-lint]")).toContainText("nothing to fix");
+});
diff --git a/umtool/e2e/fixtures/sites-fixture.mjs b/umtool/e2e/fixtures/sites-fixture.mjs
@@ -62,7 +62,10 @@ export const SITE_CUES = {
const ALPHA_BODY_1 =
"She said [the vote was rigged](cite:c1) in the first stream. Nobody checked the claim at the time, and the clip shows it.\n\nThe second paragraph repeats the claim for the record.";
-const ALPHA_BODY_2 = "Two years later [she said the opposite](cite:c2), on a different show.";
+const BETA_BODY =
+ "The draft's only paragraph opens with a sentence that keeps going, clause after clause, long past the point where any slide could hold it, because nobody has trimmed it yet and the draft is still a draft. " +
+ "Its second sentence is just as long, and it too wanders on through the reasons, the caveats and the asides that belong in the article rather than on a slide. A third follows.";
+const ALPHA_BODY_2 ="Two years later [she said the opposite](cite:c2), on a different show.";
const siteJson = (siteId, title, extra) => ({
siteId,
@@ -130,7 +133,9 @@ export function makeSitesFixture({ dest, channels }) {
kind: "sweep",
title: "Beta: a draft",
summary: "A draft nobody has published.",
- sections: [{ id: "only", title: "Only section", body: "The draft's only paragraph." }],
+ // Its one section has no slide points, and its first two sentences run
+ // long for a slide: the source tab's slide lint says so.
+ sections: [{ id: "only", title: "Only section", body: BETA_BODY }],
});
put(path.join(sites, "pub", "reports", "gamma", "report.json"), {
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) };