Archilyzer · Source

archilyzer

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

commit 330ffef1294d490daa25ecc4d9a02b6550f6c2de
parent 8932ad0dd46a349cdb457f9abffa21202105b2e3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  9 Oct 2026 14:43:03 -0400

umtool: an article read as slides and in the overview, beside its notes; the source tab lints its slides (release 22 E4)

The article page's reader gets the published page's switch (Article |
Slides | Overview, `?rv=` and the hash, the shared components over the
lenient articleView), so a draft is read in every shape before it ships.
The text stays mounted (hidden) under the other views, its note marks with
it; the notes rail stays beside them. A citation on a slide opens its
evidence in the rail; each slide counts the open notes on its part of the
article (a section's on its slide, the title's on the title slide, a
citation's on each slide showing it); Esc or "Read this in the article"
goes back to the text at the slide's place. The source tab lists what
would make the slides read badly (slideLint, and the validator's slide
problems), each linked to its slide. The lenient view now carries the
slide fields and a claim's sentence as its id.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/app/globals.css | 21+++++++++++++++++++++
Mumtool/app/sites/[site]/[report]/page.tsx | 34+++++++++++++++++++++++++++++++++-
Mumtool/components/articles/ArticleBody.tsx | 8+++++++-
Mumtool/components/articles/ArticleReader.tsx | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aumtool/components/articles/ArticleShapes.tsx | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/e2e/article-slides.spec.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/sites-fixture.mjs | 9+++++++--
7 files changed, 364 insertions(+), 5 deletions(-)

diff --git a/umtool/app/globals.css b/umtool/app/globals.css @@ -11,9 +11,14 @@ * scratch dist dir, a test artefact, a fixture or a doc can no longer poison * the stylesheet. */ @import "tailwindcss" source(none); +@import "../../common/styles/slides.css"; @source "../app"; @source "../components"; @source "../lib"; +/* The article's other shapes (release 22): the shared slides and overview + (their stylesheet is the import above) and the verdict chip they draw. + Named, not detected, as above. */ +@source "../../common/components/report"; /* A dark, dense, keyboard-first bench. Nothing here is deployed; the only user is someone judging thousands of clips, or auditioning one transition, in a @@ -159,3 +164,19 @@ kbd { canvas.grabbable { touch-action: none; } + +/* The slides and the overview (common/styles/slides.css) in this bench's + palette: its ink and panels, `sel` as the accent (it is the interaction + colour, and every mark on a slide that is not a verdict is a way to act). */ +.rs-root { + --rs-ground: var(--color-ink); + --rs-paper: var(--color-panel); + --rs-ink: var(--color-text); + --rs-muted: var(--color-dim); + --rs-faint: var(--color-dim); + --rs-line: var(--color-line); + --rs-line-strong: color-mix(in srgb, var(--color-line) 55%, var(--color-dim)); + --rs-accent: var(--color-sel); + --rs-accent-soft: color-mix(in srgb, var(--color-sel) 16%, transparent); + --rs-focus: var(--color-sel); +} diff --git a/umtool/app/sites/[site]/[report]/page.tsx b/umtool/app/sites/[site]/[report]/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { readRevisionHead, REPORT_HISTORY_GIT_DIRNAME } from "yt-dlp-transcript-common/publish/reportHistory"; import { siteReportDir } from "yt-dlp-transcript-common/publish/reportMedia"; +import { hasSlides, slideLint } from "yt-dlp-transcript-common/lib/report/slides"; import BrowseHeader from "@/components/BrowseHeader"; import ArticleReader from "@/components/articles/ArticleReader"; import WorkspacePanel from "@/components/articles/WorkspacePanel"; @@ -18,8 +19,12 @@ import type { NotesRead } from "@/lib/annotations/types"; export const dynamic = "force-dynamic"; // One article: the reader with its notes (default), or `?tab=source` -- the -// workspace files it was written from, its draft marked. +// workspace files it was written from, its draft marked, and what would make +// its slides read badly (lib/report/slides.ts slideLint, and the validator's +// slide problems). // +// ?rv=slides|iso the article as slides, or the overview (the +// reader's own switch; the place is the hash) // ?status=open|resolved|all the notes filter it opens with // ?note=<id> the note it opens on (the decisions inbox links here) // ?ws=&rel= a workspace file open on the source tab @@ -128,12 +133,39 @@ export default async function ArticlePage({ ? await openWorkspaceFile(ws.name, path.relative(ws.dir, source.draft)) : null; const draftRel = ws && source?.draft ? `${ws.name}/${path.relative(ws.dir, source.draft)}` : null; + const { view: slideView } = await articleView(r); + const lint = hasSlides(slideView) ? slideLint(slideView) : []; + const slideProblems = read.problems.filter((p) => /(^|\.)slides?(\.|\[|$)/.test(p.path ?? "")); return ( <div className="flex h-full flex-col"> {header} <main className="deck-main flex-1 p-4"> <div className="space-y-4"> {meta} + {hasSlides(slideView) && ( + <section data-slide-lint={lint.length + slideProblems.length} className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2 text-[12px]"> + <div className="micro">slides</div> + {lint.length + slideProblems.length === 0 ? ( + <p className="mt-1 text-[var(--color-dim)]">nothing to fix</p> + ) : ( + <ul className="mt-1 space-y-1"> + {slideProblems.map((p, i) => ( + <li key={`p${i}`} data-lint="problem" className="text-[var(--color-bad)]"> + <code className="font-mono text-[11px]">{p.path}</code> {p.message} + </li> + ))} + {lint.map((l, i) => ( + <li key={`l${i}`} data-lint={l.key}> + <Link href={`${base}?rv=slides#${l.anchorId}`} className="font-mono text-[11px] text-[var(--color-sel)] hover:underline"> + {l.key} + </Link>{" "} + <span className="text-[var(--color-text)]">{l.message}</span> + </li> + ))} + </ul> + )} + </section> + )} <WorkspacePanel workspaces={ws ? [ws] : []} opened={opened} diff --git a/umtool/components/articles/ArticleBody.tsx b/umtool/components/articles/ArticleBody.tsx @@ -62,7 +62,13 @@ function CiteLink({ href, children }: AnchorHTMLAttributes<HTMLAnchorElement>) { const MD = "text-[14px] leading-relaxed text-[var(--color-text)] [&_p]:my-2.5 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-[var(--color-line)] [&_blockquote]:pl-3 [&_blockquote]:text-[var(--color-dim)] [&_strong]:font-semibold [&_em]:italic [&_h3]:mt-4 [&_h3]:font-semibold [&_h4]:mt-3 [&_h4]:font-semibold [&_code]:font-mono [&_code]:text-[12px] [&_hr]:my-4 [&_hr]:border-[var(--color-line)]"; -function Md({ text }: { text: string }) { +// The article's citation map, for markdown rendered outside the body (the +// slides): its numbers, and its quotes on hover. +export function ArticleCitations({ citations, children }: { citations: Readonly<Record<string, CitationView>>; children: ReactNode }) { + return <CitationsContext.Provider value={citations}>{children}</CitationsContext.Provider>; +} + +export function Md({ text }: { text: string }) { return ( <Markdown className={MD} linkComponent={CiteLink}> {text} diff --git a/umtool/components/articles/ArticleReader.tsx b/umtool/components/articles/ArticleReader.tsx @@ -2,12 +2,16 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { ReportPageView } from "yt-dlp-transcript-common/lib/report/views"; +import { buildReportSlides, hasSlides, reportPageAnchorIds, slideIndexForPlace, type ReaderView } from "yt-dlp-transcript-common/lib/report/slides"; +import { useReaderPlace } from "yt-dlp-transcript-common/components/report/slides/useReaderPlace"; +import { ViewSwitch } from "yt-dlp-transcript-common/components/report/slides/ViewSwitch"; import CopyButton from "@/components/CopyButton"; import { locateQuote, quoteAnchor } from "@/lib/annotations/anchor.mjs"; import type { Anchor, Note, NotesRead } from "@/lib/annotations/types"; import { useNotes } from "@/lib/annotations/useNotes"; import { ShareNotes } from "@/components/notes/NotesProvider"; import ArticleBody, { ArticleActionsContext, type ArticleActions } from "./ArticleBody"; +import ArticleShapes from "./ArticleShapes"; import ArticleVideo from "./ArticleVideo"; import type { VideoProjectLink } from "./AddToVideo"; import { blockText, selectionIn, unwrapMarks, wrapRange } from "./anchorDom"; @@ -28,9 +32,28 @@ import { Composer, NoteCard, anchorLabel } from "./NoteCards"; // // Keys (not while typing): j/k move between notes, r resolves, e edits, n // notes the selection (or the whole article), Esc closes whatever is open. +// +// 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). type Filter = "open" | "resolved" | "all"; +// The article's element for a slide's anchor: a claim, a block, or the block +// that stands for one of the page's own places. +function placeEl(root: HTMLElement, anchorId: string): HTMLElement | null { + const q = (sel: string) => root.querySelector<HTMLElement>(sel); + if (anchorId === "report-head") return q('[data-block="title"]'); + if (anchorId === "in-brief" || anchorId === "found") return q('[data-block="summary"]') ?? q('[data-block="title"]'); + if (anchorId === "report-end") { + const sections = root.querySelectorAll<HTMLElement>("section[data-block]"); + return q('[data-block="method"]') ?? sections[sections.length - 1] ?? null; + } + const id = CSS.escape(anchorId); + return q(`[data-claim="${id}"]`) ?? q(`[data-block="${id}"]`); +} + const MARK_CLASS = "rounded-sm bg-[color-mix(in_srgb,var(--color-sel)_22%,transparent)] text-inherit data-[active=true]:bg-[color-mix(in_srgb,var(--color-sel)_48%,transparent)] data-[status=resolved]:bg-transparent data-[status=resolved]:underline data-[status=resolved]:decoration-[var(--color-good)] data-[status=wontfix]:bg-transparent"; @@ -84,6 +107,49 @@ export default function ArticleReader({ const [drawer, setDrawer] = useState(false); const bodyRef = useRef<HTMLDivElement>(null); const evidence = useEvidence(site, report, cite); + const shapes = hasSlides(view); + const { place, go } = useReaderPlace(); + const shape: ReaderView = shapes ? place.view : "article"; + const slides = useMemo(() => buildReportSlides(view), [view]); + const order = useMemo(() => reportPageAnchorIds(view), [view]); + + // Back to the text at a slide's place: scrolled to, focused. + const toArticle = useCallback( + (anchorId: string | null) => { + go({ view: "article", anchor: anchorId }, true); + if (!anchorId) return; + requestAnimationFrame(() => { + const el = bodyRef.current && placeEl(bodyRef.current, anchorId); + if (!el) return; + el.scrollIntoView({ block: "start" }); + if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1"); + el.focus({ preventScroll: true }); + }); + }, + [go], + ); + const switchTo = useCallback( + (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. + const main = bodyRef.current.closest("main"); + const top = main?.getBoundingClientRect().top ?? 0; + const line = top + Math.min(((main?.clientHeight ?? window.innerHeight) / 3), 280); + anchor = null; + 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; + } + } else if (shape === "slides") { + anchor = slides[slideIndexForPlace(slides, place, order)]?.anchorId ?? null; + } + if (next === "article") toArticle(anchor); + else go({ view: next, anchor }, true); + }, + [place, shape, slides, order, go, toArticle], + ); const titles = useMemo(() => { const t: Record<string, string> = { title: "title", subtitle: "subtitle", summary: "summary", method: "method" }; @@ -277,12 +343,15 @@ export default function ArticleReader({ return ( <div className="flex min-h-0 flex-1"> <main className="deck-main min-w-0 flex-1 p-4"> - <div className="mx-auto max-w-[72ch] space-y-4"> + <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} />} <ArticleActionsContext.Provider value={actions}> + {shape !== "article" && <ArticleShapes view={view} notes={notes} place={place} go={go} onArticle={toArticle} />} <div ref={bodyRef} + hidden={shape !== "article"} 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 @@ -0,0 +1,129 @@ +"use client"; + +import { useContext, useMemo, type ReactNode } from "react"; +import type { ReportPageView } from "yt-dlp-transcript-common/lib/report/views"; +import { + buildReportSlides, + reportPageAnchorIds, + slideIndexForPlace, + type ReaderPlace, + type SlideView, +} from "yt-dlp-transcript-common/lib/report/slides"; +import { ReportOverview } from "yt-dlp-transcript-common/components/report/slides/ReportOverview"; +import { ReportSlides } from "yt-dlp-transcript-common/components/report/slides/ReportSlides"; +import { SlidesHostProvider, type SlidesHost } from "yt-dlp-transcript-common/components/report/slides/SlidesHost"; +import { readerUrl, type PlaceLike } from "yt-dlp-transcript-common/components/report/slides/useReaderPlace"; +import type { Note } from "@/lib/annotations/types"; +import { ArticleActionsContext, ArticleCitations, Md } from "./ArticleBody"; + +// THE ARTICLE AS SLIDES, AND THE OVERVIEW — in umtool's reading column, so a +// draft can be read in every shape the published page offers before it is +// published (common/components/report/slides/; the export site lays the same +// views over its page). The notes rail stays beside them: +// +// a citation opens its evidence in the rail, as in the article +// a slide's notes the open notes on its part of the article, counted on it +// (a section's notes on the section's slide, the title's on +// the title slide, a citation's on each slide showing it) +// Read this in the article, Esc back to the article at the slide's place + +// Which slides a note is on. +function slideKeysOf(note: Note, slides: readonly SlideView[]): string[] { + const a = note.anchor; + if (a.kind === "text" || a.kind === "section") { + if (a.section === "title" || a.section === "subtitle") return ["title"]; + if (a.section === "summary") return ["summary"]; + return slides.some((s) => s.key === `section:${a.section}`) ? [`section:${a.section}`] : []; + } + if (a.kind === "cite") { + return slides.filter((s) => (s.kind === "claim" || s.kind === "section") && s.cite === a.cite).map((s) => s.key); + } + return []; +} + +export function openNotesBySlide(notes: readonly Note[], slides: readonly SlideView[]): Map<string, number> { + const out = new Map<string, number>(); + for (const n of notes) { + if (n.status !== "open") continue; + for (const k of slideKeysOf(n, slides)) out.set(k, (out.get(k) ?? 0) + 1); + } + return out; +} + +export default function ArticleShapes({ + view, + notes, + place, + go, + onArticle, +}: { + view: ReportPageView; + notes: readonly Note[]; + place: ReaderPlace; + go: (place: PlaceLike, push: boolean) => void; + // Back to the article at an anchor. + onArticle: (anchorId: string | null) => void; +}) { + const actions = useContext(ArticleActionsContext); + const slides = useMemo(() => buildReportSlides(view), [view]); + const order = useMemo(() => reportPageAnchorIds(view), [view]); + const counts = useMemo(() => openNotesBySlide(notes, slides), [notes, slides]); + + const host = useMemo<SlidesHost>( + () => ({ + md: (text) => ( + <div className="rs-md"> + <Md text={text} /> + </div> + ), + cite: (id: string, children: ReactNode) => ( + <button type="button" data-cite={id} className="rs-cite" onClick={() => actions?.openCite(id)}> + {children} + <sup>{view.citations[id]?.number ?? "?"}</sup> + </button> + ), + readHref: (s) => readerUrl({ view: "article", anchor: s.anchorId }), + onRead: (s) => onArticle(s.anchorId), + badge: (s) => { + const n = counts.get(s.key) ?? 0; + return n > 0 ? ( + <span className="rs-notes" data-slide-notes={n}> + {n} open note{n === 1 ? "" : "s"} + </span> + ) : null; + }, + }), + [actions, view, counts, onArticle], + ); + + if (slides.length === 0) return null; + return ( + <ArticleCitations citations={view.citations}> + <SlidesHostProvider host={host}> + {place.view === "slides" ? ( + <div className="h-[min(74vh,660px)] overflow-hidden rounded border border-[var(--color-line)]"> + <ReportSlides + slides={slides} + view={view} + index={slideIndexForPlace(slides, place, order)} + onIndex={(i) => go({ view: "slides", slide: i + 1 }, false)} + onExit={(s) => onArticle(s.anchorId)} + /> + </div> + ) : ( + <div className="h-[78vh] overflow-hidden rounded border border-[var(--color-line)]"> + <ReportOverview + slides={slides} + view={view} + focusAnchor={place.anchor} + onFocusAnchor={(a) => go({ view: "iso", anchor: a }, false)} + onOpenArticle={(s) => onArticle(s.anchorId)} + onOpenSlide={(s) => go({ view: "slides", slide: s.n }, true)} + onExit={(s) => onArticle(s?.anchorId ?? place.anchor)} + /> + </div> + )} + </SlidesHostProvider> + </ArticleCitations> + ); +} diff --git a/umtool/e2e/article-slides.spec.ts b/umtool/e2e/article-slides.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from "@playwright/test"; +import { rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WARM_TIMEOUT, warm } from "./warm"; + +// --------------------------------------------------------------------------- +// An article's other shapes in umtool (release 22): the published page's +// switch (Article | Slides | Overview) over a draft or a published article, +// the notes rail beside them, each slide's open notes counted on it, and the +// source tab's slide lint. +// +// priv/polemic-alpha a sweep: title, In brief, two sections, sources — +// WRITES sites/priv/reports/polemic-alpha/notes.json +// priv/polemic-beta a draft whose one section's first sentences run long +// --------------------------------------------------------------------------- + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const NOTES = path.join(HERE, "..", ".e2e-song", "sites", "priv", "reports", "polemic-alpha", "notes.json"); +const PAGE = "/sites/priv/polemic-alpha"; + +test.beforeAll(async ({ playwright }) => { + test.setTimeout(WARM_TIMEOUT); + await warm(playwright, [PAGE, `${PAGE}?tab=source`, "/sites/priv/polemic-beta?tab=source", "/api/notes?article=priv/polemic-alpha"]); +}); + +test.beforeEach(() => rmSync(NOTES, { force: true })); +test.afterAll(() => rmSync(NOTES, { force: true })); + +test("the switch: slides beside the notes rail, keys step, a citation opens its evidence, Esc goes back", async ({ page }) => { + await page.goto(PAGE); + const sw = page.locator("main [data-view-switch]"); + await expect(sw.getByRole("button")).toHaveText(["Article", "Slides", "Overview"]); + await expect(sw.getByRole("button", { name: "Article" })).toHaveAttribute("aria-pressed", "true"); + + await sw.getByRole("button", { name: "Slides" }).click(); + await expect(page).toHaveURL(/\?rv=slides#s-1$/); + const stage = page.locator("[data-report-slides]"); + await expect(stage.locator('[data-slide="title"]')).toContainText("Alpha: the rigged vote"); + await expect(page.locator("[data-article-body]")).toBeHidden(); + await expect(page.locator('aside[aria-label="notes"]')).toBeAttached(); + await page.keyboard.press("End"); + await expect(stage.locator('[data-slide="sources"]')).toBeVisible(); + await page.keyboard.press("Home"); + await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowRight"); + await expect(page).toHaveURL(/\?rv=slides#s-3$/); + const slide = stage.locator('[data-slide="section:first"]'); + await expect(slide).toContainText("Nobody checked the claim at the time, and the clip shows it."); + + // A citation in the slide's words opens its evidence in the rail. + await slide.locator('[data-cite="c1"]').first().click(); + await expect(page.locator("[data-evidence-rail]")).toBeVisible(); + + // Esc leaves the slides, back to the text at the slide's place. + await page.keyboard.press("Escape"); + await expect(page).not.toHaveURL(/rv=slides/); + await expect(page.locator("[data-article-body]")).toBeVisible(); + await expect(page.locator('[data-block="first"]')).toBeInViewport(); +}); + +test("a section's open notes are counted on its slide", async ({ page }) => { + await page.goto(PAGE); + await page.getByRole("button", { name: "note on Later" }).click(); + await page.getByLabel("note text").fill("Check the date on this."); + await page.getByRole("button", { name: "save note" }).click(); + await expect(page.locator("[data-note-card]")).toHaveCount(1); + + await page.goto(`${PAGE}?rv=slides#later`); + await expect(page).toHaveURL(/\?rv=slides#s-4$/); + const slide = page.locator('[data-report-slides] [data-slide="section:later"]'); + await expect(slide.locator("[data-slide-notes]")).toHaveText("1 open note"); + await page.keyboard.press("ArrowLeft"); + await expect(page.locator('[data-report-slides] [data-slide="section:first"] [data-slide-notes]')).toHaveCount(0); +}); + +test("the overview pairs each part with its slide; a card opens its slide", async ({ page }) => { + await page.goto(`${PAGE}?rv=iso#first`); + const ov = page.locator("[data-report-overview]"); + await expect(ov.locator("[data-pair]")).toHaveCount(5); + await expect(ov.locator('[data-pair="section:first"]')).toHaveAttribute("data-active", "true"); + await ov.locator('[data-pair="section:later"] [data-ov-card]').focus(); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\?rv=slides#s-4$/); +}); + +test("the source tab lints the slides: a section whose first sentences run long", async ({ page }) => { + await page.goto("/sites/priv/polemic-beta?tab=source"); + const lint = page.locator("[data-slide-lint]"); + await expect(lint).toHaveAttribute("data-slide-lint", "1"); + await expect(lint.locator('[data-lint="section:only"]')).toContainText("no slide points"); + await expect(lint.locator('[data-lint="section:only"] a')).toHaveAttribute("href", "/sites/priv/polemic-beta?rv=slides#only"); + + await page.goto(`${PAGE}?tab=source`); + await expect(page.locator("[data-slide-lint]")).toHaveAttribute("data-slide-lint", "0"); + await expect(page.locator("[data-slide-lint]")).toContainText("nothing to fix"); +}); diff --git a/umtool/e2e/fixtures/sites-fixture.mjs b/umtool/e2e/fixtures/sites-fixture.mjs @@ -62,7 +62,10 @@ export const SITE_CUES = { const ALPHA_BODY_1 = "She said [the vote was rigged](cite:c1) in the first stream. Nobody checked the claim at the time, and the clip shows it.\n\nThe second paragraph repeats the claim for the record."; -const ALPHA_BODY_2 = "Two years later [she said the opposite](cite:c2), on a different show."; +const BETA_BODY = + "The draft's only paragraph opens with a sentence that keeps going, clause after clause, long past the point where any slide could hold it, because nobody has trimmed it yet and the draft is still a draft. " + + "Its second sentence is just as long, and it too wanders on through the reasons, the caveats and the asides that belong in the article rather than on a slide. A third follows."; +const ALPHA_BODY_2 ="Two years later [she said the opposite](cite:c2), on a different show."; const siteJson = (siteId, title, extra) => ({ siteId, @@ -130,7 +133,9 @@ export function makeSitesFixture({ dest, channels }) { kind: "sweep", title: "Beta: a draft", summary: "A draft nobody has published.", - sections: [{ id: "only", title: "Only section", body: "The draft's only paragraph." }], + // Its one section has no slide points, and its first two sentences run + // long for a slide: the source tab's slide lint says so. + sections: [{ id: "only", title: "Only section", body: BETA_BODY }], }); put(path.join(sites, "pub", "reports", "gamma", "report.json"), {