Archilyzer · Source

archilyzer

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

commit 8932ad0dd46a349cdb457f9abffa21202105b2e3
parent bd910cbe90fb2d025a84edde75abc33b87bdd106
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  9 Oct 2026 14:42:52 -0400

common, export: the isometric overview — each part of the article beside its slide (release 22 E3)

ReportOverview lays the article and its slides side by side on one plane,
tilted isometrically (rotateX(55deg) rotateZ(-45deg), the plane's box
sized to the tilt so it scrolls whole): the article's parts as a column of
page blocks, each slide beside its part as a 16:9 card, a line between
them. The pair in force is lit and lifted; pointing at a pair lights both;
a block opens the article there, a card the slides. ↑ ↓ (Home, End) walk
the pairs, ← → the block and the card, Enter opens, Esc goes back to the
article at the pair; only the pair in force is in the tab order.

The tilt is never the only way to read it: the same ordered list lies flat
(two columns; stacked in a narrow box) for reduced motion, a narrow box, a
keyboard arrival, or the "Flat" toggle (remembered in this browser). The
switch gains Overview (`?rv=iso`, the pair's anchor in the hash); the
scroller alone moves to the pair, never the article behind.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Acommon/components/report/slides/ReportOverview.tsx | 346+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/report/slides/ReportReader.tsx | 37+++++++++++++++++++++++++------------
Mexport/app/lib/reports.test.ts | 4++--
Aexport/e2e-report/overview.spec.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e-report/slides.spec.ts | 2+-
5 files changed, 453 insertions(+), 15 deletions(-)

diff --git a/common/components/report/slides/ReportOverview.tsx b/common/components/report/slides/ReportOverview.tsx @@ -0,0 +1,346 @@ +"use client"; + +import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { Rows3 } from "lucide-react"; +import { slidePointText, type SlideView } from "../../../lib/report/slides"; +import type { ReportPageView } from "../../../lib/report/views"; +import { Slide, verdictStyleOf } from "./Slide"; +import { SlidesHostProvider, useSlidesHost, type SlidesHost } from "./SlidesHost"; + +// THE OVERVIEW — the article and its slides side by side on one plane, tilted +// isometrically: the article's parts as a column of page blocks, each slide +// beside its part as a 16:9 card, a line between the two. One pair is the +// reader's place (lit, lifted); pointing at a pair lights it. Choosing a block +// opens the article there; choosing a card opens the slides there. +// +// THE TILT IS NEVER THE ONLY WAY TO READ IT. The same list of pairs lies flat +// (two columns; a narrow screen stacks each pair) when motion is reduced, the +// box is narrow, the reader came in by keyboard, or they asked for it (the +// "Flat" toggle, remembered in this browser). Flat or tilted, the DOM is the +// same ordered list, and the keys are the same: +// +// ↑ ↓ the pair before / after (Home, End: the first, the last) +// ← → the block / the card of this pair +// Enter Space open what is focused +// Esc back to the article at this pair (`onExit`) +// +// The place is the caller's (`focusAnchor`, the URL's hash); walking pairs +// reports it through `onFocusAnchor`. + +const CARD_W = 300; +const THUMB_SCALE = CARD_W / 1280; +const NARROW_PX = 900; +const TILT_KEY = "archilyzer:overview-tilt"; + +// How the reader last moved: a keyboard user gets the flat plane. +let lastInput: "keyboard" | "pointer" = "pointer"; +if (typeof window !== "undefined") { + window.addEventListener("keydown", () => (lastInput = "keyboard"), true); + window.addEventListener("pointerdown", () => (lastInput = "pointer"), true); +} + +function storedTilt(): boolean | null { + try { + const v = window.localStorage.getItem(TILT_KEY); + return v === "flat" ? false : v === "iso" ? true : null; + } catch { + return null; + } +} +function storeTilt(tilt: boolean) { + try { + window.localStorage.setItem(TILT_KEY, tilt ? "iso" : "flat"); + } catch { + // no storage here: the choice lasts this visit + } +} + +function kindName(s: SlideView): string { + switch (s.kind) { + case "title": + return "The head"; + case "summary": + return "In brief"; + case "found": + return "What the check found"; + case "section": + return `Section ${s.index}`; + case "claim": + return "Claim"; + case "sources": + return "Sources"; + } +} + +// A part of the article as a page block: what the reader would find there, +// in the article's own type. +function BlockFace({ s, view }: { s: SlideView; view: ReportPageView }) { + let line: string | undefined; + let mark: string | undefined; + switch (s.kind) { + case "title": + line = [s.series, s.dates.join(" · ")].filter(Boolean).join(" · "); + break; + case "summary": + line = s.text ? slidePointText(s.text) : s.points?.map(slidePointText).join(" · "); + break; + case "found": + line = s.groups.map((g) => `${g.claims.length} ${verdictStyleOf(view, g.verdict).label}`).join(" · "); + break; + case "section": + line = s.text ? slidePointText(s.text) : s.points ? s.points.map(slidePointText).join(" · ") : `${s.claims.length} claims`; + break; + case "claim": + if (s.verdict) mark = verdictStyleOf(view, s.verdict).color; + line = s.gist ?? s.text; + break; + case "sources": + line = `${s.references} references`; + break; + } + const verdict = s.kind === "claim" && s.verdict ? verdictStyleOf(view, s.verdict).label : undefined; + return ( + <span className="rs-ov-face" data-kind={s.kind} style={mark ? ({ "--rs-mark": mark } as CSSProperties) : undefined}> + <span className="rs-ov-kind"> + {kindName(s)} + {verdict ? <b> · {verdict}</b> : null} + </span> + <span className="rs-ov-title">{s.title}</span> + {line && <span className="rs-ov-line">{line}</span>} + </span> + ); +} + +// A thumbnail renders its slide's words as text: nothing in it is a link. +const thumbHost = (base: SlidesHost): SlidesHost => ({ + ...base, + md: (text) => <span className="rs-md">{slidePointText(text)}</span>, + cite: (_id, children) => children, + onRead: undefined, + badge: undefined, +}); + +export const ReportOverview = forwardRef< + HTMLElement, + { + slides: readonly SlideView[]; + view: ReportPageView; + // The pair in force: an anchor id (the URL's hash). + focusAnchor: string | null; + onFocusAnchor: (anchorId: string) => void; + onOpenArticle: (slide: SlideView) => void; + onOpenSlide: (slide: SlideView) => void; + onExit?: (slide: SlideView | undefined) => void; + keyScope?: "window" | "stage"; + } +>(function ReportOverview({ slides, view, focusAnchor, onFocusAnchor, onOpenArticle, onOpenSlide, onExit, keyScope = "window" }, ref) { + const host = useSlidesHost(); + const thumbs = useMemo(() => thumbHost(host), [host]); + const active = Math.max( + 0, + slides.findIndex((s) => s.anchorId === focusAnchor), + ); + const [side, setSide] = useState<"block" | "card">("card"); + const scrollerRef = useRef<HTMLDivElement>(null); + const planeRef = useRef<HTMLOListElement>(null); + const stageRef = useRef<HTMLElement | null>(null); + + // Tilted or flat: the reader's choice, else flat for reduced motion, a + // narrow box or a keyboard arrival, else tilted. + const [tilt, setTilt] = useState<boolean | null>(null); + const [narrow, setNarrow] = useState(false); + useLayoutEffect(() => { + const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + setTilt(storedTilt() ?? !(reduce || lastInput === "keyboard")); + const el = scrollerRef.current; + if (!el) return; + const measure = () => setNarrow(el.clientWidth < NARROW_PX); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, []); + const iso = tilt === true && !narrow; + + // The tilted plane's box: rotateX(55deg) rotateZ(-45deg) of a W × H plane + // spans 0.7071(W + H) across and 0.4056(W + H) down, its top corner raised + // 0.4056 W — the sizer holds exactly that, so the plane scrolls whole. + const [plane, setPlane] = useState<{ w: number; h: number } | null>(null); + useLayoutEffect(() => { + const el = planeRef.current; + if (!el) return; + const measure = () => setPlane((p) => (p && p.w === el.offsetWidth && p.h === el.offsetHeight ? p : { w: el.offsetWidth, h: el.offsetHeight })); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [iso]); + + // The place in view: the pair in force, centred. + useEffect(() => { + const pair = planeRef.current?.querySelector<HTMLElement>(`[data-pair-index="${active}"]`); + const scroller = scrollerRef.current; + if (!pair || !scroller) return; + // The scroller alone moves (never the article behind): by the pair's + // drawn box, which the tilt has moved. + const reduce = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; + const p = pair.getBoundingClientRect(); + const s = scroller.getBoundingClientRect(); + scroller.scrollTo({ + left: scroller.scrollLeft + (p.left + p.width / 2) - (s.left + s.width / 2), + top: scroller.scrollTop + (p.top + p.height / 2) - (s.top + s.height / 2), + behavior: reduce ? "auto" : "smooth", + }); + }, [active, iso, plane]); + + const focusPair = useCallback( + (i: number, which: "block" | "card") => { + const at = Math.min(Math.max(i, 0), slides.length - 1); + setSide(which); + onFocusAnchor(slides[at].anchorId); + requestAnimationFrame(() => { + planeRef.current?.querySelector<HTMLElement>(`[data-pair-index="${at}"] [data-ov-${which}]`)?.focus({ preventScroll: true }); + }); + }, + [slides, onFocusAnchor], + ); + + const onKey = useCallback( + (e: KeyboardEvent | React.KeyboardEvent) => { + if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return; + const t = e.target as HTMLElement | null; + if (t?.closest?.("input, textarea, select, [contenteditable=true]")) return; + // Inside the plane the focused side is the one moved from; elsewhere + // (the toggle, the stage itself) the last one. + const from = (t?.closest?.("[data-ov-block]") ? "block" : t?.closest?.("[data-ov-card]") ? "card" : side) as "block" | "card"; + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + focusPair(active + 1, from); + break; + case "ArrowUp": + e.preventDefault(); + focusPair(active - 1, from); + break; + case "Home": + e.preventDefault(); + focusPair(0, from); + break; + case "End": + e.preventDefault(); + focusPair(slides.length - 1, from); + break; + case "ArrowLeft": + e.preventDefault(); + focusPair(active, "block"); + break; + case "ArrowRight": + e.preventDefault(); + focusPair(active, "card"); + break; + case "Escape": + if (onExit) { + e.preventDefault(); + onExit(slides[active]); + } + break; + } + }, + [active, focusPair, onExit, side, slides], + ); + useEffect(() => { + if (keyScope !== "window") return; + const h = (e: KeyboardEvent) => onKey(e); + window.addEventListener("keydown", h); + return () => window.removeEventListener("keydown", h); + }, [keyScope, onKey]); + + const sizer: CSSProperties | undefined = + iso && plane ? { width: 0.7071 * (plane.w + plane.h) + 160, height: 0.4056 * (plane.w + plane.h) + 200 } : undefined; + const planeStyle: CSSProperties | undefined = + iso && plane ? { transform: `translate(80px, ${0.4056 * plane.w + 100}px) rotateX(55deg) rotateZ(-45deg)` } : undefined; + + return ( + <section + ref={(el) => { + stageRef.current = el; + if (typeof ref === "function") ref(el); + else if (ref) ref.current = el; + }} + className="rs-root rs-ov" + aria-roledescription="overview" + aria-label={`Overview: ${view.title}, each part of the article beside its slide`} + tabIndex={-1} + data-report-overview={iso ? "iso" : "flat"} + onKeyDown={keyScope === "stage" ? onKey : undefined} + > + <div className="rs-ov-tools"> + <p className="rs-ov-hint" aria-hidden="true"> + <kbd>↑</kbd> <kbd>↓</kbd> walk · <kbd>←</kbd> article <kbd>→</kbd> slide · <kbd>Enter</kbd> open + </p> + {!narrow && ( + <button + type="button" + className="rs-btn" + aria-pressed={!iso} + data-ov-flat="" + onClick={() => { + const next = !iso; + setTilt(next); + storeTilt(next); + }} + > + <Rows3 aria-hidden="true" width={15} height={15} /> + Flat + </button> + )} + </div> + <div ref={scrollerRef} className="rs-ov-scroll"> + <div className="rs-ov-sizer" style={sizer}> + <ol ref={planeRef} className="rs-ov-plane" style={planeStyle} aria-label="The article and its slides"> + {slides.map((s, i) => { + const here = i === active; + return ( + <li key={s.key} className="rs-ov-pair" data-pair={s.key} data-pair-index={i} data-active={here ? "true" : undefined}> + <div className="rs-ov-block"> + <BlockFace s={s} view={view} /> + <button + type="button" + data-ov-block={s.anchorId} + tabIndex={here ? 0 : -1} + aria-label={`Read in the article: ${kindName(s)}, ${s.title}`} + onFocus={() => { + if (!here) onFocusAnchor(s.anchorId); + setSide("block"); + }} + onClick={() => onOpenArticle(s)} + /> + </div> + <span className="rs-ov-link" aria-hidden="true" /> + <div className="rs-ov-card"> + <div className="rs-ov-thumb" aria-hidden="true" inert> + <SlidesHostProvider host={thumbs}> + <Slide slide={s} view={view} total={slides.length} hideRead style={{ transform: `scale(${THUMB_SCALE})` }} /> + </SlidesHostProvider> + </div> + <button + type="button" + data-ov-card={s.n} + tabIndex={here ? 0 : -1} + aria-label={`Open slide ${s.n} of ${slides.length}: ${s.title}`} + onFocus={() => { + if (!here) onFocusAnchor(s.anchorId); + setSide("card"); + }} + onClick={() => onOpenSlide(s)} + /> + </div> + </li> + ); + })} + </ol> + </div> + </div> + </section> + ); +}); diff --git a/common/components/report/slides/ReportReader.tsx b/common/components/report/slides/ReportReader.tsx @@ -5,12 +5,14 @@ import { createPortal } from "react-dom"; import { CitationsProvider } from "../../citations/CitationsContext"; import { buildReportSlides, + READER_VIEWS, reportPageAnchorIds, slideIndexForPlace, type ReaderView, type SlideView, } from "../../../lib/report/slides"; import type { ReportPageView } from "../../../lib/report/views"; +import { ReportOverview } from "./ReportOverview"; import { ReportSlides } from "./ReportSlides"; import { defaultSlidesHost, SlidesHostProvider, type SlidesHost } from "./SlidesHost"; import { readerUrl, useReaderPlace } from "./useReaderPlace"; @@ -60,15 +62,13 @@ function landOn(anchorId: string) { 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, + views = READER_VIEWS, }: { // The report's page view (/reports/<id>/page.json). pagePath: string; @@ -203,15 +203,28 @@ export function ReportReader({ ) : ( <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} - /> + {place.view === "slides" ? ( + <ReportSlides + ref={stageRef} + slides={loaded.slides} + view={loaded.view} + index={slideIndex} + onIndex={(i) => go({ view: "slides", slide: i + 1 }, false)} + onExit={(s) => toArticle(s.anchorId)} + pageUrl={pageUrl} + /> + ) : ( + <ReportOverview + ref={stageRef} + slides={loaded.slides} + view={loaded.view} + focusAnchor={place.anchor} + onFocusAnchor={(a) => go({ view: "iso", anchor: a }, false)} + onOpenArticle={(s) => toArticle(s.anchorId)} + onOpenSlide={(s) => go({ view: "slides", slide: s.n }, true)} + onExit={(s) => toArticle(s?.anchorId ?? place.anchor)} + /> + )} </SlidesHostProvider> </CitationsProvider> )} diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts @@ -94,10 +94,10 @@ test("the report page: header, tally, sections and claims, inline cites, referen 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 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 + // the view switch (release 22): Article pressed, then Slides and Overview assert.deepEqual( [...header.matchAll(/<button type="button" aria-pressed="(true|false)" data-view="(\w+)"/g)].map((m) => `${m[2]}:${m[1]}`), - ["article:true", "slides:false"], + ["article:true", "slides:false", "iso:false"], ); assert.match(header, /<\/div><p[^>]*>Four claims about a demo channel[^<]*<\/p>$/, "the subtitle, under the card"); assert.doesNotMatch(header, /data-report-attribution|data-report-byline|Fact-check|Under review|The article/); diff --git a/export/e2e-report/overview.spec.ts b/export/e2e-report/overview.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from "./helpers"; +import type { Page } from "@playwright/test"; + +// THE OVERVIEW (release 22): the article's parts and their slides side by +// side on one plane — tilted isometrically for a pointer, flat for reduced +// motion, a narrow screen, a keyboard arrival or the reader's choice — over +// the cited fixture site (stage.ts; its deck is listed in slides.spec.ts). + +const REPORT = "/reports/demo-factcheck/"; + +const switchIn = (page: Page) => page.locator("header [data-view-switch]"); +const stage = (page: Page) => page.locator("[data-report-slides]"); +const overview = (page: Page) => page.locator("[data-report-overview]"); + +test("overview: each part beside its slide; arrows walk the pairs; a card opens its slide, a block the article", async ({ page }) => { + await page.goto(`${REPORT}#bridge`); + await switchIn(page).getByRole("button", { name: "Overview" }).click(); + await expect(page).toHaveURL(`${REPORT}?rv=iso#bridge`); + const ov = overview(page); + await expect(ov).toHaveAttribute("aria-roledescription", "overview"); + await expect(ov.locator("[data-pair]")).toHaveCount(11); + await expect(ov.locator('[data-pair="section:bridge"]')).toHaveAttribute("data-active", "true"); + await ov.locator('[data-pair="section:bridge"] [data-ov-card]').focus(); + await page.keyboard.press("ArrowDown"); + await expect(page).toHaveURL(`${REPORT}?rv=iso#claim-1`); + await expect(ov.locator('[data-pair="claim:claim-1"] [data-ov-card]')).toBeFocused(); + await page.keyboard.press("ArrowLeft"); + await expect(ov.locator('[data-pair="claim:claim-1"] [data-ov-block]')).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(ov.locator('[data-pair="claim:claim-2"] [data-ov-block]')).toBeFocused(); + // Only the pair in force is in the tab order. + await expect(ov.locator('[data-ov-block][tabindex="0"], [data-ov-card][tabindex="0"]')).toHaveCount(2); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(`${REPORT}?rv=slides#s-6`); + await expect(stage(page).locator('[data-slide="claim:claim-2"]')).toBeVisible(); + + await page.goBack(); + await expect(page).toHaveURL(`${REPORT}?rv=iso#claim-2`); + await overview(page).locator('[data-pair="section:moving"] [data-ov-block]').focus(); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(`${REPORT}#moving`); + await expect(page.locator("section#moving")).toBeInViewport(); +}); + +test("overview: tilted for a pointer; flat for reduced motion; the Flat toggle lays it down", async ({ page, browser }) => { + await page.goto(`${REPORT}?rv=iso#claim-1`); + await expect(overview(page)).toHaveAttribute("data-report-overview", "iso"); + const plane = page.locator(".rs-ov-plane"); + expect(await plane.evaluate((el) => getComputedStyle(el).transform)).not.toBe("none"); + await page.getByRole("button", { name: "Flat" }).click(); + await expect(overview(page)).toHaveAttribute("data-report-overview", "flat"); + expect(await plane.evaluate((el) => getComputedStyle(el).transform)).toBe("none"); + await expect(page.getByRole("button", { name: "Flat" })).toHaveAttribute("aria-pressed", "true"); + + const reduced = await browser.newContext({ reducedMotion: "reduce" }); + const p2 = await reduced.newPage(); + await p2.goto(`${REPORT}?rv=iso#claim-1`); + await expect(overview(p2)).toHaveAttribute("data-report-overview", "flat"); + expect(await p2.locator(".rs-ov-plane").evaluate((el) => getComputedStyle(el).transform)).toBe("none"); + // Flat, the pairs read as two columns: each block beside its card. + const block = (await p2.locator('[data-pair="claim:claim-1"] .rs-ov-block').boundingBox())!; + const card = (await p2.locator('[data-pair="claim:claim-1"] .rs-ov-card').boundingBox())!; + expect(card.x).toBeGreaterThan(block.x + block.width); + await reduced.close(); +}); + +test("a phone: the overview lies flat, each part stacked over its slide; nothing scrolls sideways", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 800 }); + await page.goto(`${REPORT}?rv=slides#s-5`); + await page.locator("[data-reader-overlay] [data-view-switch]").getByRole("button", { name: "Overview" }).click(); + await expect(page).toHaveURL(`${REPORT}?rv=iso#claim-1`); + await expect(overview(page)).toHaveAttribute("data-report-overview", "flat"); + const block = (await page.locator('[data-pair="claim:claim-1"] .rs-ov-block').boundingBox())!; + const card = (await page.locator('[data-pair="claim:claim-1"] .rs-ov-card').boundingBox())!; + expect(card.y).toBeGreaterThanOrEqual(block.y + block.height); + expect(card.x + card.width).toBeLessThanOrEqual(375); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375); +}); diff --git a/export/e2e-report/slides.spec.ts b/export/e2e-report/slides.spec.ts @@ -26,7 +26,7 @@ test("the switch: Article pressed in the header; every place a slide stands for 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")).toHaveText(["Article", "Slides", "Overview"]); await expect(sw.getByRole("button", { name: "Article" })).toHaveAttribute("aria-pressed", "true"); await expect(sw.getByRole("button", { name: "Slides" })).toHaveAttribute("aria-pressed", "false"); for (const id of ["report-head", "in-brief", "found", "bridge", "claim-1", "claim-2", "moving", "claim-3", "claim-4", "claim-5", "report-end"]) {