commit 194a70a2aeca8d5a3a4b6937c58eabda8a9fddda
parent da4a15b51190437f3f97c179e42cc07dc0845cfd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 21:15:19 -0400
reports: near the end, the place the URL names is the part being read while it is on screen
The last places of a report cannot scroll up to the reading line, so a switch
after a jump to the last claim opened the slide before it. Both readers now
prefer the URL's place when it is on screen below the line. The export
changelog's sticky-switch bullet folds into release 22's (both unreleased).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 41 insertions(+), 8 deletions(-)
diff --git a/common/components/report/slides/ReportReader.tsx b/common/components/report/slides/ReportReader.tsx
@@ -46,19 +46,23 @@ const PLACE_SELECTOR = "#report-head, #in-brief, #found, [data-section], article
// (the site's header and this switch), where a jump to a place lands it (its
// scroll-margin-top) — give or take a line; the first place, at the top. So a
// jump to a section and a switch opens that section's slide, however short
-// the section's lead before its first claim.
+// the section's lead before its first claim. Near the end, where the last
+// places cannot scroll up that far, the place the URL names (a jump, Esc
+// from a slide) wins while it is still on screen below that line.
const READING_SLACK = 32;
function anchorInView(): string | null {
const places = Array.from(document.querySelectorAll<HTMLElement>(PLACE_SELECTOR));
const landed = places[0] ? parseFloat(getComputedStyle(places[0]).scrollMarginTop) || 0 : 0;
const line = landed + READING_SLACK;
- let best: string | null = null;
- for (const el of places) {
+ let best = -1;
+ for (const [i, el] of places.entries()) {
if (!el.id) continue;
- if (best === null || el.getBoundingClientRect().top <= line) best = el.id;
+ if (best < 0 || el.getBoundingClientRect().top <= line) best = i;
else break;
}
- return best;
+ const named = places.findIndex((el) => el.id !== "" && `#${el.id}` === decodeURIComponent(window.location.hash));
+ if (named > best && places[named].getBoundingClientRect().top < window.innerHeight) best = named;
+ return best < 0 ? null : places[best].id;
}
// Scroll the article to an anchor and put the focus there.
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -2,8 +2,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 reads as an article, as slides, or in an overview of both.** Under a report page's header, a switch, **Article · Slides · Overview**, sticks just below the site's header as the article scrolls, on a phone too. **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 being read opens its slide, and coming back from a slide lands that part just below the switch, uncovered. 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 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/e2e-report/slides.spec.ts b/export/e2e-report/slides.spec.ts
@@ -68,6 +68,19 @@ test("slides: the switch opens the slide for the part in view, and Esc goes back
await expect(page.locator("main")).not.toHaveAttribute("inert", /.*/);
});
+test("near the end, a place that cannot scroll up to the bars is still the one the switch opens", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 2400 });
+ await page.goto(`${REPORT}#claim-5`);
+ await expect(page.locator("article#claim-5")).toBeInViewport();
+ // The page has run out: claim-5 sits well below where a jump would land it.
+ const bar = (await page.locator("[data-report-views]").boundingBox())!;
+ const claim = (await page.locator("article#claim-5").boundingBox())!;
+ expect(claim.y).toBeGreaterThan(bar.y + bar.height + 64);
+ await switchIn(page, "article").getByRole("button", { name: "Slides" }).click();
+ await expect(page).toHaveURL(`${REPORT}?rv=slides#s-12`);
+ await expect(stage(page).locator('[data-slide="claim:claim-5"]')).toBeVisible();
+});
+
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, "article").getByRole("button", { name: "Slides" }).click();
diff --git a/plans/release-22.md b/plans/release-22.md
@@ -247,6 +247,12 @@ Specs: `slides.spec.ts` asserts the switch in the viewport and the claim below i
`</header>`, sticky. Export `CHANGELOG.md` one bullet. **Final: export report suite 45/45, umtool article specs
21/21**, `pnpm --filter export test` 118/118, tsc clean (common, export, umtool).
+Merged into `r19/integration` at `3c445f6e`. Then, on the tip: near the end of a report the last places cannot
+scroll up to the reading line, so a switch from a jump to the last claim opened the slide before it. In both readers
+the place the URL names (a jump, Esc from a slide) now wins while it is on screen below the line; a reader scrolling
+by hand without one keeps the line rule. `slides.spec.ts` covers it (`#claim-5` in a 2400 px window → `#s-12`).
+The changelog's two bullets fold into E2's (the switch is unreleased; it reads as shipped).
+
## Rollout
Rebuild and deploy each site with reports (the slides view is in the bundle, and `reports prepare` re-exports to
diff --git a/umtool/components/articles/ArticleReader.tsx b/umtool/components/articles/ArticleReader.tsx
@@ -141,12 +141,23 @@ export default function ArticleReader({
const top = main?.getBoundingClientRect().top ?? 0;
const first = slides.length > 0 ? placeEl(bodyRef.current, slides[0].anchorId) : null;
const line = top + (first ? parseFloat(getComputedStyle(first).scrollMarginTop) || 0 : 0) + 32;
+ const named = place.anchor;
anchor = null;
+ let namedLater = false;
for (const s of slides) {
const el = placeEl(bodyRef.current, s.anchorId);
if (!el) continue;
- if (anchor === null || el.getBoundingClientRect().top <= line) anchor = s.anchorId;
+ if (anchor === null || el.getBoundingClientRect().top <= line) {
+ anchor = s.anchorId;
+ namedLater = false;
+ } else if (s.anchorId === named) {
+ namedLater = el.getBoundingClientRect().top < (main?.getBoundingClientRect().bottom ?? window.innerHeight);
+ }
}
+ // Near the end, where the last places cannot scroll up to that line,
+ // the place the URL names (a jump, Esc from a slide) wins while it is
+ // still on screen below it.
+ if (namedLater) anchor = named;
} else if (shape === "slides") {
anchor = slides[slideIndexForPlace(slides, place, order)]?.anchorId ?? null;
}