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:
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="[^"]*\[&_\[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 }) => {