commit bd910cbe90fb2d025a84edde75abc33b87bdd106
parent 711ae6f0a3861df3093e236940d2b8bc062345fe
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 14:42:00 -0400
common, export: the article as slides — the shared slides, the view switch, the reader (release 22 E2)
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 — and "Read this in the article" to its anchor), ReportSlides (one
16:9 stage letterboxed in its box, the canvas scaled as one piece, reflowed
on a narrow portrait screen; ← → PgUp PgDn Home End, Esc, swipe; a live
"Slide n of N" label; a rail), ViewSwitch (Article | Slides, a group of
aria-pressed toggles), SlidesHost (what differs by host: markdown, a
citation's marker, the way back), useReaderPlace (the `rv` param and the
hash, pushed on a switch, replaced on a step). common/styles/slides.css
draws them from the site's tokens (accent, light and dark).
The export site's report header carries the switch (ReportReader): Slides
lays the slides over the page, built in the browser from page.json; the
article stays the static page. The place is kept both ways — the part of
the article in view opens its slide; Esc or "Read this in the article"
lands on the slide's anchor, scrolled to and focused. The page gives every
place a slide stands for an id (#report-head, #in-brief, #report-end).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
14 files changed, 2577 insertions(+), 38 deletions(-)
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/ReportReader.tsx b/common/components/report/slides/ReportReader.tsx
@@ -0,0 +1,228 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { CitationsProvider } from "../../citations/CitationsContext";
+import {
+ buildReportSlides,
+ reportPageAnchorIds,
+ slideIndexForPlace,
+ type ReaderView,
+ type SlideView,
+} from "../../../lib/report/slides";
+import type { ReportPageView } from "../../../lib/report/views";
+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 });
+}
+
+const E2_VIEWS: readonly ReaderView[] = ["article", "slides"];
+
+type Loaded = { view: ReportPageView; slides: SlideView[] } | { error: string };
+
+export function ReportReader({
+ pagePath,
+ title,
+ series,
+ views = E2_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}>
+ <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}
+ />
+ </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/slides.test.ts b/common/lib/report/slides.test.ts
@@ -11,6 +11,7 @@ import {
reportPageAnchorIds,
slideAnchorId,
slideForAnchor,
+ slideIndexForPlace,
slideLint,
slidePointText,
type ClaimSlide,
@@ -292,6 +293,19 @@ test("the reader's place in the URL: the view, the slide, the anchor; the defaul
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 };
diff --git a/common/lib/report/slides.ts b/common/lib/report/slides.ts
@@ -442,6 +442,14 @@ export function parseReaderPlace(search: string, hash: string): ReaderPlace {
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);
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/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";
@@ -246,7 +257,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 +276,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 +303,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 +419,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
+ 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"],
);
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/slides.spec.ts b/export/e2e-report/slides.spec.ts
@@ -0,0 +1,162 @@
+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"]);
+ 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);
+});