commit 24f433897458fe32a80b221ccc48d4f16d3499e5
parent 0d905bb76fbf805a83c68479711c61aa2858a8ef
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 13:59:28 -0400
reports: the reviewed document's sentences sit on a rail in its colour instead of linking up to its box
- citations schema: a source's optional `accent` ("#rrggbb" only: it is written
into a page's style); CITATIONS.md regenerated
- SourceView.accent / SourceCitationView.sourceAccent
- SourceSentence: border-l-4 rail in the accent (else the border colour); a
sentence of the subject drops its "from <title>" link, another document's
keeps it; the figcaption is only the in-sentence archive links
- SourceBlock's left edge wears the accent
- fixture subject accented; unit + report-site e2e assertions; changelog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
11 files changed, 86 insertions(+), 15 deletions(-)
diff --git a/CITATIONS.md b/CITATIONS.md
@@ -111,6 +111,7 @@ The documents a `source` citation quotes, in a container's `sources` map by id.
| `date` | no | When it was published: `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or an ISO 8601 date-time. |
| `archives` | no | The document's archive links, in context — as the document had them. Shown with every citation of it. |
| `note` | no | A note shown with the document's byline (plain text), e.g. which copy of it was read. |
+| `accent` | no | The document's colour, `"#rrggbb"`: the rail beside each of its quoted sentences and the edge of its box, so they read as one source. Absent = the theme's border colour. |
| `saved` | no | A saved copy of the document, relative to the container's directory (e.g. `sources/s0/page.html`): the input the stills are shot from. NEVER published. |
#### `sources.<id>.archives[]`
diff --git a/common/lib/citations/schema.ts b/common/lib/citations/schema.ts
@@ -205,6 +205,9 @@ export const sourceArchiveSchema = z.strictObject({
context: text.optional(),
});
+// A source's colour: `#rrggbb` only — it is written into a page's style.
+export const SOURCE_ACCENT_RE = /^#[0-9a-fA-F]{6}$/;
+
export const sourceSchema = z.strictObject({
kind: z.enum(SOURCE_KINDS),
title: text,
@@ -214,6 +217,7 @@ export const sourceSchema = z.strictObject({
date: text.optional(),
archives: z.array(sourceArchiveSchema).optional(),
note: text.optional(),
+ accent: text.regex(SOURCE_ACCENT_RE).optional(),
saved: text.optional(),
});
@@ -229,6 +233,8 @@ export const SOURCE_FIELD_DOCS: FieldDocs<Source> = {
date: "When it was published: `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or an ISO 8601 date-time.",
archives: "The document's archive links, in context — as the document had them. Shown with every citation of it.",
note: "A note shown with the document's byline (plain text), e.g. which copy of it was read.",
+ accent:
+ "The document's colour, `\"#rrggbb\"`: the rail beside each of its quoted sentences and the edge of its box, so they read as one source. Absent = the theme's border colour.",
saved:
"A saved copy of the document, relative to the container's directory (e.g. `sources/s0/page.html`): the input the stills are shot from. NEVER published.",
};
diff --git a/common/lib/report/views.test.ts b/common/lib/report/views.test.ts
@@ -335,3 +335,20 @@ test("the page's own attribution names the kind and the site; with no site title
assert.equal(reportAttribution("factcheck"), "Fact-check");
assert.equal(reportAttribution("sweep", " "), "Report");
});
+
+test("a source's accent rides its view and its sentences' views; only #rrggbb is a source accent", () => {
+ const accented = structuredClone(report);
+ accented.sources!.s0.accent = "#8a6fb0";
+ assert.deepEqual(validateReport(accented), []);
+ const v = buildReportPageView(accented, { record: (c) => record(c.channel, c.id) });
+ assert.equal(v.sources.s0.accent, "#8a6fb0");
+ const sentence = Object.values(v.citations).find((c) => c.kind === "source");
+ assert.ok(sentence && sentence.kind === "source");
+ assert.equal(sentence.sourceAccent, "#8a6fb0");
+ assert.equal("accent" in view.sources.s0, false, "absent when the source has none");
+ for (const bad of ["purple", "#abc", "#8a6fb0; color: red"]) {
+ const b = structuredClone(report);
+ (b.sources!.s0 as Record<string, unknown>).accent = bad;
+ assert.ok(validateReport(b).some((p) => p.path === "sources.s0.accent"), bad);
+ }
+});
diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts
@@ -134,6 +134,8 @@ export type SourceView = {
date?: string;
// A line shown under the byline, e.g. which copy of the document was read.
note?: string;
+ // The document's colour (`#rrggbb`): its sentences' rail and its box's edge.
+ accent?: string;
archives: SourceArchive[];
};
@@ -183,6 +185,8 @@ export type SourceCitationView = CitationViewCommon & {
// "from <title>" (sourceAnchor links to the document's block on the page).
sourceId: string;
sourceTitle: string;
+ // The document's colour, for the sentence's rail (SourceView.accent).
+ sourceAccent?: string;
// The still of the sentence, published (reportAssetPath).
image?: string;
// The document itself, when it has a URL.
@@ -364,6 +368,7 @@ function sourceView(id: string, s: Source): SourceView {
...(s.author ? { author: s.author } : {}),
...(s.date ? { date: s.date } : {}),
...(s.note ? { note: s.note } : {}),
+ ...(s.accent ? { accent: s.accent } : {}),
archives: s.archives ?? [],
};
}
@@ -433,6 +438,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver)
...common,
sourceId: c.source,
sourceTitle: s.title,
+ sourceAccent: s.accent,
image: c.image ? reportAssetPath(report.id, c.image) : undefined,
href: s.url,
});
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,7 +1,7 @@
# Changelog
## [Unreleased]
-- **A report's claim can carry a flag, its title a byline, and a site with one report names it in the browser tab.** `report.json` claim `flag` (one line, at most 60 characters) shows as a small pill in the accent colour beside the claim's verdict, e.g. "No source given". On a report-only site with one report, the home page's tab title is the report's, as on the report's own page, where it was the site's title alone. A report's page follows its title with a byline from the document under review, "by <author> · <publisher>", in the same line when it fits; the publisher (or, with none, the author) links to the document. Under it, a step apart, the page names its own: "Fact-check by <site title>" ("Report by …"), then the dates, then the subtitle; the kind's label no longer sits above the title on the report's page. Needs a rebuild and deploy of the site.
+- **A report's claim can carry a flag, its title a byline, and a site with one report names it in the browser tab.** `report.json` claim `flag` (one line, at most 60 characters) shows as a small pill in the accent colour beside the claim's verdict, e.g. "No source given". On a report-only site with one report, the home page's tab title is the report's, as on the report's own page, where it was the site's title alone. A report's page follows its title with a byline from the document under review, "by <author> · <publisher>", in the same line when it fits; the publisher (or, with none, the author) links to the document. Under it, a step apart, the page names its own: "Fact-check by <site title>" ("Report by …"), then the dates, then the subtitle; the kind's label no longer sits above the title on the report's page. A claim's sentence from the document under review no longer links up to the document's box: it sits on a rail in the document's colour, and the box's left edge wears the same colour (a source's `accent`, `"#rrggbb"`; without one, the border colour). A sentence of another document keeps its "from <title>" link. Needs a rebuild and deploy of the site.
- **A report-only site with one report opens on that report.** Its home page is the report itself, its header links nothing, and `/reports/` forwards home: there is no index of one. With more reports the home page is the list, without repeating the site's title under the header; a list entry is the report's name, subtitle and dates (its counts and tally are on its page). Pages a report-only site does not have link home.
- **A report can belong to a series.** `report.json` `series` is shown on its own line above the report's title, in the accent colour, in place of the kind's label ("Fact-check"); a page title, a cited-in link, `llms.txt` and the MCP name it `<series>: <title>`.
- **`pnpm start:export` serves a built site's moment pages.** It used `serve`, which listed a video or audio moment's directory (`3126.00-3151.00`) instead of serving its page; it now runs `export/scripts/serve-out.mjs`, which serves directories as Cloudflare Pages does, on `EXPORT_DEV_PORT` (3000).
diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx
@@ -30,9 +30,27 @@ import { ArchiveList, ReportName, SourceBlock, dateLabel, textLink } from "./par
const proseClass = "text-[0.95rem] leading-relaxed text-foreground";
-function SourceSentence({ c, archives }: { c: SourceCitationView; archives: readonly SourceArchive[] | undefined }) {
+// A claim's sentence in a quoted document: its still (or its words), on a rail
+// in the document's colour (its `accent`, else the border colour) — the same
+// colour as the document's box, so the sentences and the box read as one
+// source. A sentence of the document under review needs no link to it: the
+// rail says whose it is. Another document's sentence links to its box.
+function SourceSentence({
+ c,
+ archives,
+ isSubject,
+}: {
+ c: SourceCitationView;
+ archives: readonly SourceArchive[] | undefined;
+ isSubject: boolean;
+}) {
+ const hasArchives = !!archives && archives.length > 0;
return (
- <figure data-source-sentence={c.id} className="flex flex-col gap-1.5">
+ <figure
+ data-source-sentence={c.id}
+ className={`flex flex-col gap-1.5 border-l-4 pl-3${c.sourceAccent ? "" : " border-border"}`}
+ style={c.sourceAccent ? { borderLeftColor: c.sourceAccent } : undefined}
+ >
{c.image ? (
// eslint-disable-next-line @next/next/no-img-element -- a published file of unknown size; the export serves images unoptimized
<img
@@ -42,18 +60,22 @@ function SourceSentence({ c, archives }: { c: SourceCitationView; archives: read
className="w-full max-w-xl rounded-md border border-border bg-white object-contain object-left-top"
/>
) : (
- <blockquote className="border-l-2 border-border pl-3 italic text-muted-foreground">
+ <blockquote className="italic text-muted-foreground">
<q>{c.quote}</q>
</blockquote>
)}
- <figcaption className="flex flex-col gap-1 text-xs text-muted-foreground">
- <a href={`#${sourceAnchor(c.sourceId)}`} className={textLink}>
- from {c.sourceTitle}
- </a>
- {/* Only the document's archive links that sit in this sentence; the
- full list is with the document, once. */}
- {archives && archives.length > 0 && <ArchiveList archives={archives} />}
- </figcaption>
+ {(!isSubject || hasArchives) && (
+ <figcaption className="flex flex-col gap-1 text-xs text-muted-foreground">
+ {!isSubject && (
+ <a href={`#${sourceAnchor(c.sourceId)}`} className={textLink}>
+ from {c.sourceTitle}
+ </a>
+ )}
+ {/* Only the document's archive links that sit in this sentence; the
+ full list is with the document, once. */}
+ {hasArchives && <ArchiveList archives={archives} />}
+ </figcaption>
+ )}
</figure>
);
}
@@ -128,7 +150,9 @@ function Claim({
{subjectLabel}: <q className="text-foreground">{claim.text}</q>
</p>
)}
- {sentence?.kind === "source" && <SourceSentence c={sentence} archives={claim.archives} />}
+ {sentence?.kind === "source" && (
+ <SourceSentence c={sentence} archives={claim.archives} isSubject={sentence.sourceId === view.subject} />
+ )}
{claim.findings && (
<CitedMarkdown className={proseClass}>
{claim.findings}
diff --git a/export/app/components/reports/parts.tsx b/export/app/components/reports/parts.tsx
@@ -64,6 +64,7 @@ export function ExternalLinkText({ href, children, title }: { href: string; chil
// archive links in the context the document gave them — collapsed, since a
// long document may carry hundreds. The block is the document's one place on
// the page: its anchor is what a source citation's "from <title>" links to.
+// With an `accent`, its left edge wears it, as its sentences' rails do.
export function SourceBlock({ source, label }: { source: SourceView; label: string }) {
const byline = [source.publisher, source.author, dateLabel(source.date)].filter(Boolean).join(" · ");
const n = source.archives.length;
@@ -71,7 +72,8 @@ export function SourceBlock({ source, label }: { source: SourceView; label: stri
<div
id={sourceAnchor(source.id)}
data-subject-source={source.id}
- className="flex scroll-mt-20 flex-col gap-1.5 rounded-lg border border-border bg-card p-4 text-sm"
+ className={`flex scroll-mt-20 flex-col gap-1.5 rounded-lg border border-border bg-card p-4 text-sm${source.accent ? " border-l-4" : ""}`}
+ style={source.accent ? { borderLeftColor: source.accent } : undefined}
>
<p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">{label}</p>
<p className="font-medium leading-snug text-foreground">
diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts
@@ -98,7 +98,12 @@ test("the report page: header, tally, sections and claims, inline cites, referen
// the document's archive links are listed once, collapsed, under its anchor
assert.match(html, /<div id="source-s0"[^>]*>[^]*?<details data-source-archives="1"/);
// a claim's source sentence links to the document instead of repeating them
- assert.match(html, /data-source-sentence="a01"[^]*?<a href="#source-s0"[^>]*>from An example article about a demo channel<\/a>/);
+ // a sentence of the document under review sits on a rail in its colour, with
+ // no link to the document: the rail says whose it is; its box wears the colour too
+ const sentence = html.slice(html.indexOf('data-source-sentence="a01"'), html.indexOf("</figure>", html.indexOf('data-source-sentence="a01"')));
+ assert.match(sentence, /class="[^"]*border-l-4[^"]*" style="border-left-color:#8a6fb0"/);
+ assert.doesNotMatch(sentence, /href="#source-/);
+ assert.match(html, /<div id="source-s0"[^>]*style="border-left-color:#8a6fb0"/);
assert.match(html, /data-verdict-tally=""/);
for (const v of ["CORROBORATED", "PARTLY", "CONTRADICTED", "UNTESTABLE"]) assert.match(html, new RegExp(`data-verdict="${v}"`));
assert.ok(html.includes("Read in the edition published on the day"), "the source's note, under its byline");
diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts
@@ -75,6 +75,12 @@ test("the report: verdict chips, the tally, the source's sentence as a still, nu
// The claim's sentence in the article, as the reviewed page showed it.
await expectImageLoaded(page.locator('[data-source-sentence="a01"] img[src$="/stills/a01.png"]'));
+ // It sits on a rail in the document's colour, as the document's box does, and
+ // does not link back to the box.
+ const sentence = page.locator('[data-source-sentence="a01"]');
+ await expect(sentence).toHaveCSS("border-left-color", "rgb(138, 111, 176)");
+ await expect(sentence.locator('a[href^="#source-"]')).toHaveCount(0);
+ await expect(page.locator("#source-s0")).toHaveCSS("border-left-color", "rgb(138, 111, 176)");
// Inline citations carry their number in the report, by first appearance.
for (const [id, n] of [
diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json
@@ -19,6 +19,7 @@
"author": "A. Writer",
"date": "2026-09-20",
"note": "Read in the edition published on the day; it has since been revised.",
+ "accent": "#8a6fb0",
"archives": [
{
"label": "archive.org",
@@ -107,6 +108,7 @@
"quote": "He opened the bridge himself in 2018.",
"sourceId": "s0",
"sourceTitle": "An example article about a demo channel",
+ "sourceAccent": "#8a6fb0",
"image": "/reports/demo-factcheck/stills/a01.png",
"href": "https://example.org/articles/demo"
},
@@ -127,6 +129,7 @@
"quote": "He has said many times that he would move away.",
"sourceId": "s0",
"sourceTitle": "An example article about a demo channel",
+ "sourceAccent": "#8a6fb0",
"href": "https://example.org/articles/demo"
},
"p01": {
diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json
@@ -18,6 +18,7 @@
"author": "A. Writer",
"date": "2026-09-20",
"note": "Read in the edition published on the day; it has since been revised.",
+ "accent": "#8a6fb0",
"archives": [
{ "label": "archive.org", "url": "https://web.archive.org/web/2026/https://example.org/articles/demo", "context": "as published on the day" }
]