commit da4a15b51190437f3f97c179e42cc07dc0845cfd
parent 2c850a8dc7dfd95f8e9ace36b916250c974680c2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 21:11:35 -0400
Merge r19/shapes-fix (release 22's fix round: the sticky view switch, the part being read, umtool's slide URLs, two spec races) into r19/integration
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
11 files changed, 128 insertions(+), 36 deletions(-)
diff --git a/common/components/report/slides/ReportReader.tsx b/common/components/report/slides/ReportReader.tsx
@@ -21,16 +21,17 @@ 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).
//
// THE PLACE IS KEPT. From the article, the slide is the one for the part of
-// the article in view (the last anchor at or above the top third); from a
-// slide or the overview, the article opens at its anchor, scrolled to and
-// focused. The view and the place are the URL's — `#s-<n>` in the slides, an
+// the article being read (the last anchor at the top of the reading area);
+// from a slide or the overview, the article opens at its anchor, scrolled to
+// and focused. The view and the place are the URL's — `#s-<n>` in the slides, an
// anchor id elsewhere — so a shared link opens the same view at the same
// place, and Back undoes a switch.
//
@@ -40,12 +41,19 @@ import { ViewSwitch } from "./ViewSwitch";
// The article's places, in document order: what each slide stands for.
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).
+// The anchor of the part of the article being read: the last place whose top
+// has reached the top of the reading area — just under the page's sticky bars
+// (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.
+const READING_SLACK = 32;
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 = landed + READING_SLACK;
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]");
@@ -31,6 +31,8 @@ test("overview: each part beside its slide; arrows walk the pairs; a card opens
// 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");
+ // The focus moves on the next frame; Enter opens what has it.
+ await expect(ov.locator('[data-pair="claim:claim-2"] [data-ov-card]')).toBeFocused();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(`${REPORT}?rv=slides#s-8`);
await expect(stage(page).locator('[data-slide="claim:claim-2"]')).toBeVisible();
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/plans/release-22.md b/plans/release-22.md
@@ -219,6 +219,34 @@ would render 100+ small slides — not measured). (3) The keyboard-arrival heuri
overview mounted (a keydown anywhere counts). (4) `report.json`'s change summary (revision history) does not name a
slide-field edit ("Other edits"). (5) slides.html's light base only (no dark variant), as report.html.
+#### Fix round, as it went
+
+Branch `r19/shapes-fix`, off `acda58f9`. The specs' first run (export report suite 42/45, umtool article specs
+18/21) and the operator's ruling of 2026-10-09: **the view switch is sticky** — Article | Slides | Overview stays in
+reach while the article scrolls, on desktop and phones, on the export site and in umtool. On the export site the
+switch leaves the report's `<header>` for a strip right after it (`[data-report-views]`, `sticky top-14` under the
+site header, within the article's box; frosted like the site header, no label); in umtool it sticks to the top of
+the reading column (`[data-article-views]`). Every anchor lands below the bars (`[&_[id]]:scroll-mt-32` on the
+export article, `scroll-mt-16` on umtool's blocks and claims). The failures:
+
+- `overview.spec.ts:15` and `slides.spec.ts:39` (the switch in the header scrolled away; a click scrolled to the
+ top, so the head was the place) — fixed by the sticky switch. Under it a second cause showed: the part being read
+ was the last place above the top third, so a jump to a section with a short lead (`#bridge`) and a switch opened
+ its first claim. The line is now the top of the reading area — where a jump lands a place (its
+ `scroll-margin-top`) plus 32 px — in both readers. Then `overview.spec.ts` pressed Enter before ArrowRight's focus
+ move (next frame) had landed; it now waits for the card's focus, as after every other key.
+- `overview.spec.ts:68` (phone) — passes as fixed in `acda58f9`.
+- `article-slides.spec.ts:30` and `:62` — umtool's `ArticleShapes` never rewrote a slides URL naming a place to the
+ slide's hash; it now does, replacing the entry, as `ReportReader` does.
+- `article-evidence.spec.ts:51` — not a regression (the walk is unchanged in release 22): the trace shows `j`
+ pressed ~30 ms before the walk hydrated. The spec repeats the press until the walk moves (safe: `j` at the last
+ citation stays there).
+
+Specs: `slides.spec.ts` asserts the switch in the viewport and the claim below it after the jump to `#claim-3`;
+`article-slides.spec.ts` the same after Esc lands on a section; `reports.test.ts` holds the switch after
+`</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).
+
## 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
@@ -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";
@@ -132,10 +134,13 @@ export default function ArticleReader({
(next: ReaderView) => {
let anchor: string | null = place.anchor;
if (shape === "article" && bodyRef.current) {
- // The part of the text in view: the last place at or above the top third.
+ // The part of the text being read: the last place at the top of the
+ // column, under the sticky switch, where a jump lands it (its
+ // scroll-margin-top), give or take a line — as the export site's reader.
const main = bodyRef.current.closest("main");
const top = main?.getBoundingClientRect().top ?? 0;
- const line = top + Math.min(((main?.clientHeight ?? window.innerHeight) / 3), 280);
+ const first = slides.length > 0 ? placeEl(bodyRef.current, slides[0].anchorId) : null;
+ const line = top + (first ? parseFloat(getComputedStyle(first).scrollMarginTop) || 0 : 0) + 32;
anchor = null;
for (const s of slides) {
const el = placeEl(bodyRef.current, s.anchorId);
@@ -346,12 +351,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/components/articles/ArticleShapes.tsx b/umtool/components/articles/ArticleShapes.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useContext, useMemo, type ReactNode } from "react";
+import { useContext, useEffect, useMemo, type ReactNode } from "react";
import type { ReportPageView } from "yt-dlp-transcript-common/lib/report/views";
import {
buildReportSlides,
@@ -68,6 +68,14 @@ export default function ArticleShapes({
const slides = useMemo(() => buildReportSlides(view), [view]);
const order = useMemo(() => reportPageAnchorIds(view), [view]);
const counts = useMemo(() => openNotesBySlide(notes, slides), [notes, slides]);
+ const index = slideIndexForPlace(slides, place, order);
+
+ // A slides URL that names a place, not a slide (a switch from the text, a
+ // link to a section): the slide for that place, in the URL — as the export
+ // site's reader does (ReportReader).
+ useEffect(() => {
+ if (place.view === "slides" && place.slide === null && slides.length > 0) go({ view: "slides", slide: index + 1 }, false);
+ }, [place, index, slides.length, go]);
const host = useMemo<SlidesHost>(
() => ({
@@ -105,7 +113,7 @@ export default function ArticleShapes({
<ReportSlides
slides={slides}
view={view}
- index={slideIndexForPlace(slides, place, order)}
+ index={index}
onIndex={(i) => go({ view: "slides", slide: i + 1 }, false)}
onExit={(s) => onArticle(s.anchorId)}
/>
diff --git a/umtool/e2e/article-evidence.spec.ts b/umtool/e2e/article-evidence.spec.ts
@@ -53,8 +53,13 @@ test("the walk: one citation per screen, j/k, n notes it, the URL follows", asyn
await page.goto("/sites/priv/polemic-alpha/evidence");
await expect(page.locator("[data-walk='c1']")).toBeVisible();
await expect(page.locator("[data-walk-position]")).toHaveText("1 / 2");
- await page.keyboard.press("j");
- await expect(page.locator("[data-walk='c2']")).toBeVisible();
+ // The keys are live once the walk has hydrated, and `goto` resolves before
+ // that: a press that lands first is lost. j at the last citation stays
+ // there, so pressing again until it moves is safe.
+ await expect(async () => {
+ await page.keyboard.press("j");
+ await expect(page.locator("[data-walk='c2']")).toBeVisible({ timeout: 500 });
+ }).toPass({ timeout: 10_000 });
await expect(page).toHaveURL(/\?c=c2$/);
await page.keyboard.press("n");
await page.getByLabel("note text").fill("Needs a clip.");
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 }) => {