Archilyzer · Source

archilyzer

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

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:
Mcommon/components/citations/CitationCard.tsx | 4++--
Acommon/components/report/slides/ReportReader.tsx | 228+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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/slides.test.ts | 14++++++++++++++
Mcommon/lib/report/slides.ts | 8++++++++
Acommon/styles/slides.css | 1303+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/components/reports/ReportArticle.tsx | 95++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mexport/app/globals.css | 1+
Mexport/app/lib/reports.test.ts | 7++++++-
Aexport/e2e-report/slides.spec.ts | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +});