Archilyzer · Source

archilyzer

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

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:
MCITATIONS.md | 1+
Mcommon/lib/citations/schema.ts | 6++++++
Mcommon/lib/report/views.test.ts | 17+++++++++++++++++
Mcommon/lib/report/views.ts | 6++++++
Mexport/CHANGELOG.md | 2+-
Mexport/app/components/reports/ReportArticle.tsx | 48++++++++++++++++++++++++++++++++++++------------
Mexport/app/components/reports/parts.tsx | 4+++-
Mexport/app/lib/reports.test.ts | 7++++++-
Mexport/e2e-report/report-site.spec.ts | 6++++++
Mexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 3+++
Mexport/fixtures/report-site/source/demo-factcheck/report.json | 1+
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" } ]