Archilyzer · Source

archilyzer

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

commit 01a66959299e7308c3fd6bba1e219b7e90e2c245
parent d1dfeed4415f0a5a819faafb91010e2db0ffd191
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  9 Oct 2026 20:55:00 -0400

reports: the view switch is sticky — under the site's header on the export site, atop the reading column in umtool

The operator's ruling: switching keeps the place, for a real reader, so
Article | Slides | Overview stays in reach while the article scrolls. On the
export site it leaves the report's <header> for a strip right after it,
`sticky top-14` within the article's box (the site header is 56 px + its
border); in umtool it sticks to the top of the reading column. Nothing
scrolls on a click any more, so the part in view is the part read.

Every anchor in the article lands below the bars (`[&_[id]]:scroll-mt-32`
on the export article, `scroll-mt-16` on umtool's blocks and claims), and
anchorInView's line is never above a landed anchor's scroll-margin-top.
Specs: the switch is `[data-report-views] [data-view-switch]`; after the
jump to a claim the switch is in the viewport and the claim below it.

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

Diffstat:
Mcommon/components/report/slides/ReportReader.tsx | 15++++++++++-----
Mexport/CHANGELOG.md | 1+
Mexport/app/components/reports/ReportArticle.tsx | 28+++++++++++++++++++---------
Mexport/app/lib/reports.test.ts | 15++++++++++++---
Mexport/e2e-report/overview.spec.ts | 2+-
Mexport/e2e-report/slides.spec.ts | 18++++++++++++------
Mumtool/components/articles/ArticleReader.tsx | 11+++++++++--
Mumtool/e2e/article-slides.spec.ts | 5+++++
8 files changed, 69 insertions(+), 26 deletions(-)

diff --git a/common/components/report/slides/ReportReader.tsx b/common/components/report/slides/ReportReader.tsx @@ -21,8 +21,9 @@ 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` — +// and a reader with no script get). This island is the view switch (the +// host keeps it in reach — the export site sticks it under 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). @@ -41,11 +42,15 @@ import { ViewSwitch } from "./ViewSwitch"; 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). +// at or above the top third of the window (the first, at the top) — never +// above where a landed anchor sits (its scroll-margin-top, which keeps it +// below the page's sticky bars: the site's header and this switch). function anchorInView(): string | null { - const line = Math.min(window.innerHeight / 3, 280); + const places = Array.from(document.querySelectorAll<HTMLElement>(PLACE_SELECTOR)); + const landed = places[0] ? parseFloat(getComputedStyle(places[0]).scrollMarginTop) || 0 : 0; + const line = Math.max(Math.min(window.innerHeight / 3, 280), landed + 2); let best: string | null = null; - for (const el of Array.from(document.querySelectorAll<HTMLElement>(PLACE_SELECTOR))) { + for (const el of places) { if (!el.id) continue; if (best === null || el.getBoundingClientRect().top <= line) best = el.id; else break; diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - **A report can keep a timeline, and a reader can subscribe to it.** `report.json` `entries` (each `{ "id", "date", "title", "body" }`, optionally `updated`) shows as **Timeline** after In brief: dated entries, newest first, each date linking to the entry's own place on the page (`#<id>`), each body citing as a section's does. The report counts as updated at its newest entry, in its header and the report list. Each entry is a slide too, and the HTML, PDF and Markdown downloads carry the Timeline. On a site with a public URL, the report publishes its timeline as feeds — `feed.xml` (RSS) and `feed.json` (JSON Feed) beside its page — and its header reads **Subscribe: RSS · JSON**; a feed reader finds them from the page too. A site with no public URL publishes no feed. The MCP's `get_report` reads the timeline, and its `section` may name an entry. Needs a rebuild and deploy of each site with reports. - **A report reads as an article, as slides, or in an overview of both.** A report page's header has a switch, **Article · Slides · Overview**. **Slides** shows the report one 16:9 slide at a time, fitted to the screen in the site's accent and light or dark: a title slide, In brief, What the check found, one slide per section and per claim — its verdict, its finding and the evidence it rests on (a clip's still, a post's screenshot, the article's sentence, with its quote) — and a sources slide. ← → (and Page Up, Page Down, Home, End) or a swipe step through them; a citation's number opens its card; every slide has **Read this in the article**, and Esc goes back to the article where the slide stands. On a phone a slide reads top to bottom, its evidence under its words. **Overview** lays the article's parts and their slides side by side on one tilted plane, each part beside its slide: ↑ ↓ walk them, a part opens the article there and a slide opens the slides there; with reduced motion, on a narrow screen, arriving by keyboard or with **Flat**, the same pairs lie flat. Switching keeps your place: the part of the article on screen opens its slide, and back. The view and the place are in the address (`?rv=slides#s-4`, `?rv=iso#<part>`), so a shared link opens the same slide; the article itself is unchanged for search engines and readers without script. A report's author can shape its slides in `report.json` (`slides`, and a section's or claim's `slide`: its own title, up to five short points, which evidence to show, a layout, or hidden); a report without them still has slides, made from its text. Needs a rebuild and deploy of each site with reports. +- **A report's view switch stays in reach.** **Article · Slides · Overview** sits under the report's header and sticks just below the site's header while the article scrolls, on a phone too, so switching from the middle of a report opens the slide for the part being read, and coming back from a slide lands that part just below the switch, uncovered. Needs a rebuild and deploy of each site with reports. - **A report can be saved as slides.** The download line adds **Slides** (`slides.html`, one file that opens with no network: the slides with their pictures inside, the arrow keys stepping through them, each citation numbered to a reference list) and **Slides PDF** (one page per slide), and the evidence pack carries the slides too. Needs `reports export` (or prepare) and a rebuild and deploy of each site with reports. - **Posts withdrawn from a site leave a stand-in, not a stale copy.** When an X channel's posts stop being published on a site — while X posts are private, say — the site's build ships an empty posts manifest and an empty page at every address they were served from, sent with `Cache-Control: no-store`, so a reader (or the hub) asking for them gets "no posts" instead of the copy Cloudflare's edge kept for up to a week. The hub does the same for every X channel a public site carries. - **A quote with an editorial insertion is still a citation.** An inline citation whose quoted words hold brackets, `[“told [the mayor] so”](cite:id)`, is numbered, previews and links like any other, is listed in the reference list and its section's, and validation names its id when the report lacks it; it used to render as plain text. A label with a lone `]` is still not a link. The MCP's report reader counts it too. Needs `reports prepare` and a rebuild and deploy of each site with reports. diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx @@ -43,9 +43,12 @@ import { ArchiveList, ReportName, SourceBlock, SubjectCard, textLink } from "./p // (common/lib/report/feeds.ts): the header links them ("Subscribe"), and the // page's metadata names them (lib/reports.ts reportMetadata). // -// 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 +// ONE ARTICLE, THREE SHAPES (release 22): the view switch (Article | Slides | +// Overview — common/components/report/slides/ReportReader) follows the +// header and sticks under the site's header while the article scrolls, so a +// switch keeps the place the reader is at; every anchor in the article lands +// below the two bars (`scroll-mt-32`: the site header's 57 px and the +// switch's 54). 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. @@ -277,6 +280,7 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { return href ? [[key, label, href] as const] : []; }); const groups = isFactcheck ? foundGroups(view) : []; + const slides = hasSlides(view); const jumps = [ ...(groups.length > 0 ? [{ href: "#found", label: "What the check found" }] : []), { href: "#claims", label: "Every claim" }, @@ -290,7 +294,10 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { // One citation map for every inline marker on the page (CitationsContext): // serialized once, not once per marker. <CitationsProvider citations={view.citations}> - <article data-report={view.id} className="mx-auto flex w-full max-w-3xl flex-col gap-8"> + <article + data-report={view.id} + className={`mx-auto flex w-full max-w-3xl flex-col gap-8${slides ? " [&_[id]]:scroll-mt-32" : ""}`} + > {/* 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. */} @@ -326,15 +333,18 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { </a> </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> + {/* The view switch: sticky just under the site's header (Header.tsx, + `sticky top-0`, 56 px and its border), within the article's box. */} + {slides && ( + <div data-report-views="" className="sticky top-14 z-10 flex items-center bg-background/80 py-2 backdrop-blur-md"> + <ReportReader pagePath={reportViewPath(view.id)} title={view.title} series={view.series} /> + </div> + )} + {/* The report as a video, when it has one. */} {view.video && ( <figure data-report-video="" className="flex flex-col gap-2"> diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts @@ -93,11 +93,20 @@ 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><p data-report-feeds=""[^>]*>[^]*?Subscribe:[^]*?<\/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"/, + /<\/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><p data-report-feeds=""[^>]*>[^]*?Subscribe:[^]*?<\/p><div id="source-s0" data-subject-source="s0"/, ); - // the view switch (release 22): Article pressed, then Slides and Overview + // the view switch (release 22): right after the header, sticky under the + // site's header (so it is in reach wherever the article is scrolled); + // Article pressed, then Slides and Overview + const views = html.slice(html.indexOf("</header>"), html.indexOf("</header>") + 2000); + assert.match( + views, + /^<\/header><div data-report-views="" class="sticky top-14 [^"]*"><div role="group" aria-label="Read as" data-view-switch="article"/, + ); + assert.match(html, /<article data-report="demo-factcheck" class="[^"]*\[&amp;_\[id\]\]:scroll-mt-32/, "every anchor lands below the two bars"); + assert.doesNotMatch(header, /data-view-switch/); assert.deepEqual( - [...header.matchAll(/<button type="button" aria-pressed="(true|false)" data-view="(\w+)"/g)].map((m) => `${m[2]}:${m[1]}`), + [...views.matchAll(/<button type="button" aria-pressed="(true|false)" data-view="(\w+)"/g)].map((m) => `${m[2]}:${m[1]}`), ["article:true", "slides:false", "iso:false"], ); assert.match(header, /<\/div><p[^>]*>Four claims about a demo channel[^<]*<\/p>$/, "the subtitle, under the card"); diff --git a/export/e2e-report/overview.spec.ts b/export/e2e-report/overview.spec.ts @@ -8,7 +8,7 @@ import type { Page } from "@playwright/test"; const REPORT = "/reports/demo-factcheck/"; -const switchIn = (page: Page) => page.locator("header [data-view-switch]"); +const switchIn = (page: Page) => page.locator("[data-report-views] [data-view-switch]"); const stage = (page: Page) => page.locator("[data-report-slides]"); const overview = (page: Page) => page.locator("[data-report-overview]"); diff --git a/export/e2e-report/slides.spec.ts b/export/e2e-report/slides.spec.ts @@ -20,13 +20,14 @@ import type { Page } from "@playwright/test"; 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]"); +// The article's switch (sticky under the site's header) or the overlay's. +const switchIn = (page: Page, where: "article" | "overlay") => + where === "article" ? page.locator("[data-report-views] [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 }) => { +test("the switch: Article pressed; every place a slide stands for has its id", async ({ page }) => { await page.goto(REPORT); - const sw = switchIn(page, "header"); + const sw = switchIn(page, "article"); await expect(sw).toHaveAttribute("role", "group"); await expect(sw.getByRole("button")).toHaveText(["Article", "Slides", "Overview"]); await expect(sw.getByRole("button", { name: "Article" })).toHaveAttribute("aria-pressed", "true"); @@ -39,7 +40,12 @@ test("the switch: Article pressed in the header; every place a slide stands for 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(); + // The switch stays in reach, and the claim lands below it, uncovered. + await expect(switchIn(page, "article")).toBeInViewport(); + const bar = (await page.locator("[data-report-views]").boundingBox())!; + const claim = (await page.locator("article#claim-3").boundingBox())!; + expect(claim.y).toBeGreaterThanOrEqual(bar.y + bar.height); + await switchIn(page, "article").getByRole("button", { name: "Slides" }).click(); await expect(page).toHaveURL(`${REPORT}?rv=slides#s-10`); await expect(stage(page)).toHaveAttribute("data-report-slides", "10"); await expect(stage(page).locator('[data-slide="claim:claim-3"]')).toBeVisible(); @@ -64,7 +70,7 @@ test("slides: the switch opens the slide for the part in view, and Esc goes back 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 switchIn(page, "article").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"); diff --git a/umtool/components/articles/ArticleReader.tsx b/umtool/components/articles/ArticleReader.tsx @@ -36,7 +36,9 @@ import { Composer, NoteCard, anchorLabel } from "./NoteCards"; // THE ARTICLE'S OTHER SHAPES (release 22): Article | Slides | Overview, the // published page's switch (`?rv=`, the place in the hash), so a draft is read // as slides before it ships. The text stays mounted (hidden) under them, its -// marks with it; the notes rail stays beside them (ArticleShapes). +// marks with it; the notes rail stays beside them (ArticleShapes). The switch +// sticks to the top of the reading column while the text scrolls, so a switch +// keeps the place the reader is at; a place lands below it (`scroll-mt-16`). type Filter = "open" | "resolved" | "all"; @@ -346,12 +348,17 @@ export default function ArticleReader({ <div className={shape === "article" ? "mx-auto max-w-[72ch] space-y-4" : "mx-auto max-w-[1180px] space-y-4"}> {meta} {viewError && <p className="text-[12px] text-[var(--color-bad)]">citations not shown: {viewError}</p>} - {shapes && <ViewSwitch value={shape} onChange={switchTo} />} + {shapes && ( + <div data-article-views="" className="sticky top-0 z-10 bg-[var(--color-ink)] py-2"> + <ViewSwitch value={shape} onChange={switchTo} /> + </div> + )} <ArticleActionsContext.Provider value={actions}> {shape !== "article" && <ArticleShapes view={view} notes={notes} place={place} go={go} onArticle={toArticle} />} <div ref={bodyRef} hidden={shape !== "article"} + className="[&_[data-block]]:scroll-mt-16 [&_[data-claim]]:scroll-mt-16" onMouseUp={() => setTimeout(readSelection, 0)} onKeyUp={(e) => e.shiftKey && readSelection()} onMouseOver={(e) => { diff --git a/umtool/e2e/article-slides.spec.ts b/umtool/e2e/article-slides.spec.ts @@ -57,6 +57,11 @@ test("the switch: slides beside the notes rail, keys step, a citation opens its await expect(page).not.toHaveURL(/rv=slides/); await expect(page.locator("[data-article-body]")).toBeVisible(); await expect(page.locator('[data-block="first"]')).toBeInViewport(); + // The switch stays in reach (sticky over the column), the place below it. + await expect(sw).toBeInViewport(); + const bar = (await page.locator("[data-article-views]").boundingBox())!; + const first = (await page.locator('[data-block="first"]').boundingBox())!; + expect(first.y).toBeGreaterThanOrEqual(bar.y + bar.height); }); test("a section's open notes are counted on its slide", async ({ page }) => {