Archilyzer · Source

archilyzer

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

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:
MCOMMANDS.md | 4++--
MPUBLISH.md | 12+++++++++---
MREPORT.md | 32+++++++++++++++++++++++++++++---
Mcommon/bin/archilyzer.ts | 2+-
Mcommon/bin/reports-export.ts | 4++--
Mcommon/components/citations/CitationCard.tsx | 4++--
Acommon/components/report/slides/ReportOverview.tsx | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/report/slides/ReportReader.tsx | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/report/slides/ReportSlides.tsx | 243+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/report/slides/Slide.tsx | 399+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/report/slides/SlidesHost.tsx | 44++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/report/slides/ViewSwitch.tsx | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/report/slides/useReaderPlace.tsx | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/docs.ts | 27+++++++++++++++++++++++++--
Acommon/lib/report/exportSlides.test.ts | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/exportSlidesHtml.ts | 416+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/report.test.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/schema.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++--
Acommon/lib/report/slideRules.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/slides.test.ts | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/report/slides.ts | 462+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/validate.ts | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcommon/lib/report/views.ts | 21+++++++++++++++++----
Mcommon/publish/composeReports.test.ts | 39++++++++++++++++++++++++++++++---------
Mcommon/publish/reportExports.ts | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcommon/publish/reportMedia.test.ts | 4++--
Mcommon/social/playwrightRuntime.ts | 3+++
Acommon/styles/slides.css | 1303+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 2++
Mexport/app/components/reports/ReportArticle.tsx | 97++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mexport/app/globals.css | 1+
Mexport/app/lib/reports.test.ts | 7++++++-
Mexport/e2e-report/audit.spec.ts | 2++
Aexport/e2e-report/overview.spec.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e-report/report-site.spec.ts | 7+++++--
Aexport/e2e-report/slides.spec.ts | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 28++++++++++++++++++++++++++--
Mexport/fixtures/report-site/source/demo-factcheck/report.json | 14++++++++++++--
Mplans/release-22.md | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/archilyzer-ops.mjs | 5+++--
Mumtool/app/globals.css | 21+++++++++++++++++++++
Mumtool/app/sites/[site]/[report]/page.tsx | 34+++++++++++++++++++++++++++++++++-
Mumtool/components/articles/ArticleBody.tsx | 8+++++++-
Mumtool/components/articles/ArticleReader.tsx | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aumtool/components/articles/ArticleShapes.tsx | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/article-slides.spec.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/sites-fixture.mjs | 9+++++++--
Mumtool/lib/articles/article.ts | 4+++-
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 &lt;fact-check&gt;/); +}); + +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) };