Archilyzer · Source

archilyzer

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

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:
Mcommon/components/report/slides/ReportReader.tsx | 26+++++++++++++++++---------
Mexport/CHANGELOG.md | 1+
Mexport/app/components/reports/ReportArticle.tsx | 28+++++++++++++++++++---------
Mexport/app/lib/reports.test.ts | 15++++++++++++---
Mexport/e2e-report/overview.spec.ts | 4+++-
Mexport/e2e-report/slides.spec.ts | 18++++++++++++------
Mplans/release-22.md | 28++++++++++++++++++++++++++++
Mumtool/components/articles/ArticleReader.tsx | 18++++++++++++++----
Mumtool/components/articles/ArticleShapes.tsx | 12++++++++++--
Mumtool/e2e/article-evidence.spec.ts | 9+++++++--
Mumtool/e2e/article-slides.spec.ts | 5+++++
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="[^"]*\[&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]"); @@ -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 }) => {