Archilyzer · Source

archilyzer

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

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:
Mcommon/components/report/slides/ReportReader.tsx | 14+++++++++-----
Mexport/CHANGELOG.md | 3+--
Mexport/e2e-report/slides.spec.ts | 13+++++++++++++
Mplans/release-22.md | 6++++++
Mumtool/components/articles/ArticleReader.tsx | 13++++++++++++-
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; }