commit 21f0421be4dce83ac18500225306ebe0a161bd18
parent 5036cb4985d34c0381074fb66f000393b7f2d466
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 18:11:23 -0400
export: the timeline's page test, and a changelog line
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 39 insertions(+), 0 deletions(-)
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [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 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.
diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts
@@ -228,6 +228,44 @@ test("a titled claim with no sentence of the document: its text follows plain, u
assert.doesNotMatch(claim, /data-source-sentence|says:|<q[^>]*>He opened/);
});
+test("a report's timeline: after In brief, before the claims, newest first, dated, each its anchor; Subscribe and the alternates with feeds", async () => {
+ const view = fixture.buildFixtureReportView();
+ const feeds = {
+ rss: "https://reports.example.org/reports/demo-factcheck/feed.xml",
+ json: "https://reports.example.org/reports/demo-factcheck/feed.json",
+ };
+ const entries = [
+ { id: "update-2", date: "2026-10-06T09:30:00Z", title: "A second update", body: "More [on stream](cite:c01)." },
+ { id: "update-1", date: "2026-10-05", updated: "2026-10-06", title: "A first update", body: "Something new." },
+ ];
+ const html = render(React.createElement(ReportArticle, { view: { ...view, entries, feeds } }));
+ const at = (s: string) => html.indexOf(s);
+ assert.ok(at('id="in-brief"') < at("data-report-timeline"));
+ assert.ok(at("data-report-timeline") < at('id="found"'));
+ assert.ok(at('data-entry="update-2"') < at('data-entry="update-1"'));
+ const newest = html.slice(at('<li id="update-2"'), html.indexOf("</li>", at('<li id="update-2"')));
+ assert.match(newest, /<a href="#update-2" data-entry-date=""[^>]*><time dateTime="2026-10-06T09:30:00Z">2026-10-06<\/time><\/a>/);
+ assert.match(newest, /<h3[^>]*>A second update<\/h3>/);
+ // Its body cites as a section's does: the citation's number.
+ assert.match(newest, /on stream<\/a><sup[^>]*><a href="#c-c01" data-cite-number="1"/);
+ assert.match(html, /updated <time dateTime="2026-10-06">2026-10-06<\/time>/);
+ // The header: Subscribe, to both feeds.
+ const header = html.slice(at("<header"), at("</header>"));
+ assert.match(header, /data-report-feeds=""[^]*?Subscribe:[^]*?<a href="https:\/\/reports\.example\.org\/reports\/demo-factcheck\/feed\.xml" type="application\/rss\+xml" data-feed="rss"/);
+ assert.match(header, /<a href="https:\/\/reports\.example\.org\/reports\/demo-factcheck\/feed\.json" type="application\/feed\+json" data-feed="json"/);
+ const { reportMetadata } = await import("./reports");
+ assert.deepEqual(reportMetadata({ ...view, entries, feeds }).alternates, {
+ types: {
+ "application/rss+xml": [{ url: feeds.rss, title: "Demo Checks: Checking an example article" }],
+ "application/feed+json": [{ url: feeds.json, title: "Demo Checks: Checking an example article" }],
+ },
+ });
+ // No timeline, no feeds: none of it.
+ const plain = render(React.createElement(ReportArticle, { view }));
+ assert.doesNotMatch(plain, /data-report-timeline|data-report-feeds/);
+ assert.equal(reportMetadata(view).alternates, undefined);
+});
+
test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => {
const html = render(
await momentPage.default({ params: Promise.resolve({ moment: ["demo-channel", "abc123", "3126.00-3151.00"] }) }),