Archilyzer · Source

archilyzer

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

commit d9c3827cbaf5a635a3f8815ab297e3c99103b1eb
parent 98ea87ea99cd0d202fe9f52e8243ddb5f195eca5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 15:10:09 -0400

Merge main (report/polish) into report/exports

Conflicts: the report page's downloads line keeps the exports' list, and the
"Downloads" jump link now targets it (id="downloads") instead of the span at
the end of the references; both changelog bullets stay under [Unreleased].
The report-site fixture's page.json is regenerated.

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

Diffstat:
MCITATIONS.md | 2++
MREPORT.md | 5++++-
Mcommon/components/citations/CitationCard.tsx | 29+++++++++++++++++++++++++++++
Mcommon/components/citations/ReferenceList.tsx | 5++++-
Mcommon/lib/citations/schema.ts | 15+++++++++++++++
Mcommon/lib/report/report.test.ts | 17+++++++++++++++++
Mcommon/lib/report/schema.ts | 14+++++++++++++-
Mcommon/lib/report/validate.ts | 19+++++++++++++++++++
Mcommon/lib/report/views.test.ts | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/report/views.ts | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mexport/CHANGELOG.md | 3++-
Mexport/app/(workspace)/page.tsx | 17+++++++++++++----
Mexport/app/components/reports/ReportArticle.tsx | 352++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mexport/app/components/reports/parts.tsx | 35+++++++++++++++++++++++++++--------
Mexport/app/lib/reports.test.ts | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mexport/app/lib/reports.ts | 26++++++++++++++++++++++++++
Mexport/app/reports/[reportId]/page.tsx | 8++++----
Mexport/e2e-report/report-site.spec.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mexport/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json | 17+++++++++++++++++
Mexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 13++++++++++++-
Mexport/fixtures/report-site/source/demo-factcheck/report.json | 12++++++++++--
Mplans/report-sites.md | 4++--
22 files changed, 922 insertions(+), 103 deletions(-)

diff --git a/CITATIONS.md b/CITATIONS.md @@ -27,6 +27,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | `date` | no | When the quote was said or written: `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or an ISO 8601 date-time. Absent = the record's own date. | | `label` | no | A short display name for the citation (one line), used where its number alone would be too little. | | `note` | no | An editorial note shown with the citation (plain text): context the quote needs. | +| `origin` | no | In a report that reviews a document (`subject`): where this evidence came from. `"subject"` — the document itself gave it (its link, its embedded clip, its picture); `"added"` — the report's author found it, the document did not give it. Absent = not known. A report with no `subject` carries none. | | `verification` | no | COMPUTED, not authored: what checking this citation found, written by compose. A hand-typed block that could not have come from a check (a score outside 0–1, a score without its time) is a validation problem. | #### `verification` @@ -111,6 +112,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/REPORT.md b/REPORT.md @@ -18,10 +18,11 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | `version` | yes | `1`. | | `id` | yes | The report's id: a lowercase slug (`[a-z0-9][a-z0-9-]*`, at most 64), its directory name under `reports/` and the last segment of its page, `/reports/<id>/`. Must match the directory. | | `kind` | yes | `"factcheck"` — sections of claims, each with a verdict — or `"sweep"` — sections with no verdicts, or bodies with inline citations. | -| `series` | no | The series the report belongs to: a recurring name for its kind of report. It leads the title in the accent colour, in place of the kind's label, and is joined to it as `<series>: <title>` where one line names the report (a page title, a link, a cited-in entry). | +| `series` | no | The series the report belongs to: a recurring name for its kind of report. It is shown on its own line above the title, in the accent colour, in place of the kind's label, and is joined to it as `<series>: <title>` where one line names the report (a page title, a link, a cited-in entry). | | `title` | yes | The report's title. | | `subtitle` | no | A line under the title. | | `summary` | no | The report's summary, in markdown, shown before the sections. May cite inline: `[label](cite:<id>)`. | +| `method` | no | How the report was checked, in markdown (short; it cites nothing): shown under "How it was checked" at the start of the claims in full. | | `published` | no | When the report was published: `YYYY-MM-DD` or an ISO 8601 date-time with a zone. | | `updated` | no | When it was last changed, in the same form; not before `published`. | | `subject` | no | The document under review, when the report reviews one: `{ "source": "<id>" }`, an id in `sources`. | @@ -47,6 +48,8 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | `title` | no | A short headline for the claim (plain text, e.g. a phrase it turns on), shown above its text. Absent = the text alone. | | `text` | yes | The claim, as stated by the document under review (plain text). | | `verdict` | no | The ruling on the claim: `CORROBORATED`, `PARTLY`, `CONTRADICTED`, `NOT_FOUND`, `UNTESTABLE`. A fact-check's claim may leave it out (not yet ruled); a sweep's carries none. | +| `gist` | no | The claim's finding in one line (plain text, at most 240 characters), shown beside its title in the overview of what the check found. Absent = the title alone. | +| `flag` | no | A short mark on the claim (plain text, one line, at most 60 characters), shown as a pill on its card: e.g. that the document gives no source for it. Absent = none. | | `sourceQuote` | no | The document's own sentence making the claim: `{ "citation": "<id>" }`, naming a `source` citation (its still is shown with the claim). | | `findings` | no | What the evidence shows, in markdown, citing inline: `[label](cite:<id>)`. | | `citations` | no | The citations the claim rests on, in the order they are listed under it. Each must exist; none twice. | diff --git a/common/components/citations/CitationCard.tsx b/common/components/citations/CitationCard.tsx @@ -1,5 +1,7 @@ import { Archive, ExternalLink, FileText, Globe, MessageSquareQuote, Mic, Play, Video } from "lucide-react"; import type { CitationKind } from "../../lib/citations/schema"; +import { ICON_PALETTES } from "../../lib/brand"; +import { BrandMark } from "../BrandMark"; import { CITATION_KIND_LABELS, sourceAnchor, @@ -180,15 +182,27 @@ function PageLinks({ c }: { c: PageCitationView }) { ); } +// Evidence the report's author found — the document under review did not give +// it (`origin: "added"`): the project's mark and the words, e.g. "Not in the +// article". A card shows both; a reference entry or a preview, the mark alone +// (the words for a screen reader). The claim's flag pill wears the same mark. +export function AddedMark({ className }: { className?: string }) { + return <BrandMark palette={ICON_PALETTES.archilyzer} className={cn("size-3.5 shrink-0", className)} />; +} + export function CitationCard({ citation: c, variant = "card", className, + addedLabel = "Not in the source", }: { citation: CitationView; variant?: CitationCardVariant; className?: string; + // The words for added evidence: "Not in the article", "Not in the source". + addedLabel?: string; }) { + const added = c.origin === "added"; const Icon = KIND_ICON[c.kind]; const { title, meta } = heading(c); const showNumber = variant !== "reference" && c.number !== undefined; @@ -204,6 +218,15 @@ export function CitationCard({ className, )} > + {added && variant === "card" && ( + <p + data-citation-added="" + className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground" + > + <AddedMark /> + {addedLabel} + </p> + )} <div className="flex min-w-0 items-start gap-2"> {showNumber && ( <span className="mt-px shrink-0 rounded bg-brand-soft px-1.5 font-mono text-xs font-semibold leading-5 text-brand-strong"> @@ -214,6 +237,12 @@ export function CitationCard({ <div className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground"> <Icon className="size-3 shrink-0" aria-hidden /> {CITATION_KIND_LABELS[c.kind]} + {added && variant !== "card" && ( + <span data-citation-added="" title={addedLabel} className="inline-flex items-center"> + <AddedMark className="size-3" /> + <span className="sr-only">{addedLabel}</span> + </span> + )} {c.label && <span className="truncate normal-case tracking-normal">· {c.label}</span>} </div> <div className="font-medium leading-snug text-foreground break-words">{title}</div> diff --git a/common/components/citations/ReferenceList.tsx b/common/components/citations/ReferenceList.tsx @@ -16,10 +16,13 @@ export function ReferenceList({ citations, anchors = true, className, + addedLabel, }: { citations: readonly CitationView[]; anchors?: boolean; className?: string; + // The words behind an added citation's mark (CitationCard). + addedLabel?: string; }) { if (citations.length === 0) return null; return ( @@ -35,7 +38,7 @@ export function ReferenceList({ <span className="w-7 shrink-0 pt-0.5 text-right font-mono text-sm font-semibold text-brand-strong"> {c.number !== undefined ? `${c.number}.` : "·"} </span> - <CitationCard citation={c} variant="reference" className="flex-1" /> + <CitationCard citation={c} variant="reference" className="flex-1" addedLabel={addedLabel} /> </li> ))} </ol> diff --git a/common/lib/citations/schema.ts b/common/lib/citations/schema.ts @@ -67,6 +67,12 @@ export const CITATION_VERIFICATION_FIELD_DOCS: FieldDocs<CitationVerification> = method: "What did the checking, e.g. the cue-window comparison and its version. Free text, one line.", }; +// Where a report's evidence came from, relative to the document it reviews: +// the document itself gave it ("subject"), or the report's author found it +// ("added"). Absent = not known. +export const CITATION_ORIGINS = ["subject", "added"] as const; +export type CitationOrigin = (typeof CITATION_ORIGINS)[number]; + const common = { quote: text, speaker: text.optional(), @@ -74,6 +80,7 @@ const common = { label: text.optional(), note: text.optional(), verification: verificationSchema.optional(), + origin: z.enum(CITATION_ORIGINS).optional(), }; const pad = z.strictObject({ @@ -149,6 +156,8 @@ export const CITATION_COMMON_FIELD_DOCS: FieldDocs<CitationCommon> = { date: "When the quote was said or written: `YYYY`, `YYYY-MM`, `YYYY-MM-DD` or an ISO 8601 date-time. Absent = the record's own date.", label: "A short display name for the citation (one line), used where its number alone would be too little.", note: "An editorial note shown with the citation (plain text): context the quote needs.", + origin: + 'In a report that reviews a document (`subject`): where this evidence came from. `"subject"` — the document itself gave it (its link, its embedded clip, its picture); `"added"` — the report\'s author found it, the document did not give it. Absent = not known. A report with no `subject` carries none.', verification: "COMPUTED, not authored: what checking this citation found, written by compose. A hand-typed block that could not have come from a check (a score outside 0–1, a score without its time) is a validation problem.", }; @@ -205,6 +214,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 +226,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 +242,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/report.test.ts b/common/lib/report/report.test.ts @@ -239,3 +239,20 @@ test("the verdict vocabulary: five verdicts, a default for each, overrides laid assert.deepEqual(v.PARTLY, { label: "Half true", color: VERDICT_DEFAULTS.PARTLY.color }); assert.deepEqual(Object.keys(v), [...VERDICTS]); }); + +test("a claim's flag: one line of at most 60 characters", () => { + const withFlag = (flag: unknown) => { + const f = structuredClone(fixture()); + (f.sections[0].claims![0] as Record<string, unknown>).flag = flag; + return f; + }; + const ok = parseReport(withFlag("x".repeat(60))); + assert.equal(ok.ok, true); + assert.deepEqual(ok.problems, []); + assert.deepEqual(validateReport(withFlag("No source given")), []); + const long = parseReport(withFlag("x".repeat(61))); + assert.equal(long.ok, false); + assert.deepEqual(paths(long.problems), ["sections[0].claims[0].flag"]); + assert.deepEqual(paths(validateReport(withFlag(" "))), ["sections[0].claims[0].flag"]); + assert.deepEqual(paths(validateReport(withFlag("two\nlines"))), ["sections[0].claims[0].flag"]); +}); diff --git a/common/lib/report/schema.ts b/common/lib/report/schema.ts @@ -37,11 +37,18 @@ export type ReportKind = (typeof REPORT_KINDS)[number]; const text = z.string(); const verdict = z.enum(VERDICTS as unknown as [Verdict, ...Verdict[]]); +// A claim's flag is a short mark, shown as a pill on the claim's card. +export const CLAIM_FLAG_MAX = 60; +// A claim's gist is its one line in the overview of what the check found. +export const CLAIM_GIST_MAX = 240; + export const claimSchema = z.strictObject({ id: text, title: text.optional(), text, verdict: verdict.optional(), + gist: text.max(CLAIM_GIST_MAX).optional(), + flag: text.max(CLAIM_FLAG_MAX).optional(), sourceQuote: z.strictObject({ citation: text }).optional(), findings: text.optional(), citations: z.array(text).optional(), @@ -65,6 +72,7 @@ export const reportSchema = z.strictObject({ title: text, subtitle: text.optional(), summary: text.optional(), + method: text.optional(), published: text.optional(), updated: text.optional(), subject: z.strictObject({ source: text }).optional(), @@ -93,10 +101,12 @@ export const REPORT_FIELD_DOCS: FieldDocs<Report> = { version: `\`${REPORT_VERSION}\`.`, id: "The report's id: a lowercase slug (`[a-z0-9][a-z0-9-]*`, at most 64), its directory name under `reports/` and the last segment of its page, `/reports/<id>/`. Must match the directory.", kind: '`"factcheck"` — sections of claims, each with a verdict — or `"sweep"` — sections with no verdicts, or bodies with inline citations.', - series: "The series the report belongs to: a recurring name for its kind of report. It leads the title in the accent colour, in place of the kind's label, and is joined to it as `<series>: <title>` where one line names the report (a page title, a link, a cited-in entry).", + series: "The series the report belongs to: a recurring name for its kind of report. It is shown on its own line above the title, in the accent colour, in place of the kind's label, and is joined to it as `<series>: <title>` where one line names the report (a page title, a link, a cited-in entry).", title: "The report's title.", subtitle: "A line under the title.", summary: "The report's summary, in markdown, shown before the sections. May cite inline: `[label](cite:<id>)`.", + method: + "How the report was checked, in markdown (short; it cites nothing): shown under \"How it was checked\" at the start of the claims in full.", published: "When the report was published: `YYYY-MM-DD` or an ISO 8601 date-time with a zone.", updated: "When it was last changed, in the same form; not before `published`.", subject: @@ -120,6 +130,8 @@ export const CLAIM_FIELD_DOCS: FieldDocs<Claim> = { title: "A short headline for the claim (plain text, e.g. a phrase it turns on), shown above its text. Absent = the text alone.", text: "The claim, as stated by the document under review (plain text).", verdict: `The ruling on the claim: ${VERDICTS.map((v) => `\`${v}\``).join(", ")}. A fact-check's claim may leave it out (not yet ruled); a sweep's carries none.`, + gist: `The claim's finding in one line (plain text, at most ${CLAIM_GIST_MAX} characters), shown beside its title in the overview of what the check found. Absent = the title alone.`, + flag: `A short mark on the claim (plain text, one line, at most ${CLAIM_FLAG_MAX} characters), shown as a pill on its card: e.g. that the document gives no source for it. Absent = none.`, sourceQuote: "The document's own sentence making the claim: `{ \"citation\": \"<id>\" }`, naming a `source` citation (its still is shown with the claim).", findings: "What the evidence shows, in markdown, citing inline: `[label](cite:<id>)`.", diff --git a/common/lib/report/validate.ts b/common/lib/report/validate.ts @@ -31,6 +31,7 @@ import { type PathSegment, type Problem, } from "../citations/validate"; +import { extractCiteRefs } from "../citations/inline"; import { isRefId } from "../citations/schema"; import { reportSchema, isReportId, type Report } from "./schema"; import { reportCitationUses } from "./uses"; @@ -95,6 +96,16 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[] } out.push(...citationMapProblems(citations, report.sources)); + if (report.method !== undefined && extractCiteRefs(report.method).length > 0) { + out.push(problem(["method"], "cites nothing: a citation belongs in the summary, a section or a claim")); + } + if (!report.subject) { + for (const [id, c] of Object.entries(citations)) { + if (c.origin !== undefined) { + out.push(problem(["citations", id, "origin"], "says where evidence came from relative to the document under review; the report has no `subject`")); + } + } + } const anchors = new Map<string, string>(); const anchor = (id: string, at: PathSegment[]) => { @@ -114,6 +125,14 @@ function reportProblems(report: Report, opts: ReportValidateOptions): Problem[] const cp: PathSegment[] = [...sp, "claims", ci]; anchor(claim.id, cp); if (blank(claim.text)) out.push(problem([...cp, "text"], "must not be blank")); + if (claim.gist !== undefined) { + if (blank(claim.gist)) out.push(problem([...cp, "gist"], "must not be blank")); + else if (/[\r\n]/.test(claim.gist)) out.push(problem([...cp, "gist"], "must be one line")); + } + if (claim.flag !== undefined) { + if (blank(claim.flag)) out.push(problem([...cp, "flag"], "must not be blank")); + else if (/[\r\n]/.test(claim.flag)) out.push(problem([...cp, "flag"], "must be one line")); + } if (report.kind === "sweep" && claim.verdict !== undefined) { out.push(problem([...cp, "verdict"], "a sweep's claims carry no verdict (make the report a factcheck)")); } diff --git a/common/lib/report/views.test.ts b/common/lib/report/views.test.ts @@ -15,9 +15,15 @@ import { claimArchives, citedInViews, evidenceClipPath, + foundGroups, + readingMinutes, + reportTierMinutes, + wordCount, momentViewPath, orderedCitations, reportAssetPath, + reportAttribution, + reportByline, reportIndexEntry, reportFullTitle, sourceAnchor, @@ -25,6 +31,8 @@ import { spanLabel, verdictTally, type RecordView, + type ReportPageView, + type SourceView, } from "./views"; const report: Report = { @@ -298,3 +306,131 @@ test("a report's series rides its views and leads its one-line name", () => { const key = Object.keys(index)[0]; assert.ok(citedInViews(index[key], [v]).every((e) => e.reportTitle === `On the Record: ${report.title}`)); }); + +test("a claim's flag rides its view; a claim without one carries none", () => { + const flagged = structuredClone(report); + flagged.sections[0].claims![0].flag = "No source given"; + assert.deepEqual(validateReport(flagged), []); + const v = buildReportPageView(flagged, { record: (c) => record(c.channel, c.id) }); + assert.equal(v.sections[0].claims[0].flag, "No source given"); + assert.equal("flag" in view.sections[0].claims[0], false); +}); + +test("a report's byline comes from its subject: the URL links the publisher, else the author", () => { + const src = (s: Partial<SourceView>): Pick<ReportPageView, "subject" | "sources"> => ({ + subject: "s0", + sources: { s0: { id: "s0", kind: "article", title: "T", archives: [], ...s } }, + }); + const url = "https://example.test/a"; + assert.deepEqual(reportByline(src({ author: "A. Writer", publisher: "Gazette", url })), { + author: { text: "A. Writer" }, + publisher: { text: "Gazette", href: url }, + }); + assert.deepEqual(reportByline(src({ author: "A. Writer", url })), { author: { text: "A. Writer", href: url } }); + assert.deepEqual(reportByline(src({ author: "A. Writer" })), { author: { text: "A. Writer" } }); + assert.deepEqual(reportByline(src({ publisher: "Gazette", url })), { publisher: { text: "Gazette", href: url } }); + assert.equal(reportByline(src({ url })), undefined, "neither: no byline"); + assert.equal(reportByline({ sources: {} }), undefined, "no subject: no byline"); +}); + +test("the page's own attribution names the kind and the site; with no site title, the kind alone", () => { + assert.equal(reportAttribution("factcheck", "Demo Reports"), "Fact-check by Demo Reports"); + assert.equal(reportAttribution("sweep", "Demo Reports"), "Report by Demo Reports"); + 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); + } +}); + +test("a citation's origin rides its view; a report with no subject carries none", () => { + const withOrigin = structuredClone(report); + const ids = Object.keys(withOrigin.citations!); + withOrigin.citations![ids[0]].origin = "added"; + withOrigin.citations![ids[1]].origin = "subject"; + assert.deepEqual(validateReport(withOrigin), []); + const v = buildReportPageView(withOrigin, { record: (c) => record(c.channel, c.id) }); + assert.equal(v.citations[ids[0]]?.origin, "added"); + assert.equal(v.citations[ids[1]]?.origin, "subject"); + assert.ok(Object.values(view.citations).every((c) => !("origin" in c)), "absent when not given"); + const noSubject = structuredClone(withOrigin); + delete noSubject.subject; + const paths = validateReport(noSubject).map((p) => p.path); + assert.ok(paths.includes(`citations.${ids[0]}.origin`), paths.join()); + const bad = structuredClone(report); + (bad.citations![ids[0]] as Record<string, unknown>).origin = "found"; + assert.ok(validateReport(bad).length > 0); +}); + +test("reading time: words (a cite link is its label) at 230 a minute, rounded up", () => { + assert.equal(wordCount("He says [so on stream](cite:c01), twice."), 6); + assert.equal(wordCount("**Bold** and `code` — it's 2019."), 5); + assert.equal(wordCount(undefined), 0); + assert.equal(readingMinutes(0), 0); + assert.equal(readingMinutes(1), 1); + assert.equal(readingMinutes(230), 1); + assert.equal(readingMinutes(231), 2); + const long = structuredClone(report); + long.method = "word ".repeat(460); + const m = reportTierMinutes(buildReportPageView(long, { record: (c) => record(c.channel, c.id) })); + assert.ok(m.claims >= 3, `the method is read with the claims: ${m.claims}`); + assert.equal(m.quick, reportTierMinutes(view).quick, "the method is not in the quick take"); + const sweep = structuredClone(report); + sweep.kind = "sweep"; + for (const s of sweep.sections) for (const c of s.claims ?? []) delete c.verdict; + assert.equal(reportTierMinutes(buildReportPageView(sweep, { record: (c) => record(c.channel, c.id) })).found, undefined); +}); + +test("what the check found: ruled claims by verdict, changed first, confirmed last; gist and method ride the view", () => { + const r = structuredClone(report); + r.method = "Searched the transcripts."; + r.sections[0].claims![0].gist = "One line."; + const v = buildReportPageView(r, { record: (c) => record(c.channel, c.id) }); + assert.equal(v.method, "Searched the transcripts."); + assert.equal(v.sections[0].claims[0].gist, "One line."); + assert.equal("method" in view, false); + const order = ["CONTRADICTED", "NOT_FOUND", "PARTLY", "UNTESTABLE", "CORROBORATED"]; + const groups = foundGroups({ + sections: [ + { + id: "s", + title: "S", + claims: [ + { id: "a", text: "a", verdict: "CORROBORATED", citations: [] }, + { id: "b", text: "b", verdict: "CONTRADICTED", citations: [] }, + { id: "c", text: "c", citations: [] }, + { id: "d", text: "d", verdict: "PARTLY", citations: [] }, + { id: "e", text: "e", verdict: "CONTRADICTED", citations: [] }, + ], + }, + ], + }); + assert.deepEqual(groups.map((g) => [g.verdict, g.claims.map((c) => c.id)]), [ + ["CONTRADICTED", ["b", "e"]], + ["PARTLY", ["d"]], + ["CORROBORATED", ["a"]], + ]); + assert.ok(groups.every((g, i) => i === 0 || order.indexOf(g.verdict) > order.indexOf(groups[i - 1].verdict))); + // gist: one line of at most 240; method cites nothing + const bad = structuredClone(r); + bad.sections[0].claims![0].gist = "two\nlines"; + bad.method = "See [this](cite:v1)."; + assert.deepEqual(validateReport(bad).map((p) => p.path).sort(), ["method", "sections[0].claims[0].gist"]); + const long = structuredClone(r); + long.sections[0].claims![0].gist = "x".repeat(241); + assert.ok(validateReport(long).some((p) => p.path === "sections[0].claims[0].gist")); +}); diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts @@ -42,6 +42,7 @@ import { } from "../citations/moments"; import type { CitationKind, + CitationOrigin, CitationVerification, PostCitation, Source, @@ -150,6 +151,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[]; }; @@ -163,6 +166,9 @@ type CitationViewCommon = { label?: string; note?: string; verification?: CitationVerification; + // Where the evidence came from relative to the document under review: it + // gave it ("subject") or the report's author found it ("added"). + origin?: CitationOrigin; }; export type SpanCitationView = CitationViewCommon & { @@ -199,6 +205,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. @@ -221,6 +229,11 @@ export type ClaimView = { // The claim, as the document under review states it. text: string; verdict?: Verdict; + // The claim's finding in one line, for the overview of what the check found. + gist?: string; + // A short mark on the claim (e.g. that the document gives no source), + // shown as a pill on its card. + flag?: string; // The id of the `source` citation holding the document's own sentence. sourceQuote?: string; // The document's archive links that sit in this claim's sentence (their @@ -243,11 +256,14 @@ export type ReportPageView = { version: typeof REPORT_VIEWS_VERSION; id: string; kind: ReportKind; - // Shown above the title in place of the kind's label; `<series>: <title>` on one line. + // Its own line above the title, in place of the kind's label; `<series>: <title>` + // where one line names the report (reportFullTitle). series?: string; title: string; subtitle?: string; summary?: string; + // How it was checked (markdown, cites nothing). + method?: string; published?: string; updated?: string; // The document under review: its id in `sources`. @@ -276,7 +292,8 @@ export type VerdictCount = { verdict: Verdict; count: number }; export type ReportIndexEntry = { id: string; kind: ReportKind; - // Shown above the title in place of the kind's label; `<series>: <title>` on one line. + // Its own line above the title, in place of the kind's label; `<series>: <title>` + // where one line names the report (reportFullTitle). series?: string; title: string; subtitle?: string; @@ -381,6 +398,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 ?? [], }; } @@ -407,6 +425,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver) label: c.label, note: c.note, verification: c.verification, + origin: c.origin, }); switch (c.kind) { case "video": @@ -450,6 +469,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, }); @@ -492,6 +512,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver) title: report.title, subtitle: report.subtitle, summary: report.summary, + method: report.method, published: report.published, updated: report.updated, subject: subjectId, @@ -509,6 +530,8 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver) title: c.title, text: c.text, verdict: c.verdict, + gist: c.gist, + flag: c.flag, sourceQuote: c.sourceQuote?.citation, archives: archivesFor(c), findings: c.findings, @@ -603,6 +626,104 @@ export function reportFullTitle(r: { series?: string; title: string }): string { return r.series ? `${r.series}: ${r.title}` : r.title; } +// A report's byline, from the document under review (its `subject` source): +// "by <author> · <publisher>". The source's URL links the publisher, else the +// author. Neither an author nor a publisher → no byline. +export type BylinePart = { text: string; href?: string }; +export type ReportByline = { author?: BylinePart; publisher?: BylinePart }; + +export function reportByline(view: Pick<ReportPageView, "subject" | "sources">): ReportByline | undefined { + const source = view.subject ? view.sources[view.subject] : undefined; + const author = source?.author?.trim(); + const publisher = source?.publisher?.trim(); + if (!source || (!author && !publisher)) return undefined; + const href = source.url || undefined; + return defined({ + author: author ? defined({ text: author, href: publisher ? undefined : href }) : undefined, + publisher: publisher ? defined({ text: publisher, href }) : undefined, + }); +} + +// ─── The report page's tiers: the quick take, what the check found, every claim ─── + +// The order "what the check found" groups claims in: what the check changed +// about the document first, what it confirmed last. +export const FOUND_VERDICT_ORDER: readonly Verdict[] = ["CONTRADICTED", "NOT_FOUND", "PARTLY", "UNTESTABLE", "CORROBORATED"]; + +export type FoundGroup = { verdict: Verdict; claims: ClaimView[] }; + +// A fact-check's claims by verdict, in FOUND_VERDICT_ORDER; a verdict no +// claim carries is left out, and so is a claim with no verdict. +export function foundGroups(view: Pick<ReportPageView, "sections">): FoundGroup[] { + const claims = view.sections.flatMap((s) => s.claims); + return FOUND_VERDICT_ORDER.map((verdict) => ({ verdict, claims: claims.filter((c) => c.verdict === verdict) })).filter( + (g) => g.claims.length > 0, + ); +} + +export const READING_WPM = 230; + +// The words a reader reads in a text: a cite link counts as its label, and +// markdown's punctuation as nothing. +export function wordCount(text: string | undefined): number { + if (!text) return 0; + return (text.replace(/\]\([^)]*\)/g, "]").match(/[\p{L}\p{N}][\p{L}\p{N}'’.-]*/gu) ?? []).length; +} + +// Minutes to read so many words, rounded up; nothing to read is no minutes. +export function readingMinutes(words: number): number { + return words > 0 ? Math.ceil(words / READING_WPM) : 0; +} + +export type ReportTierMinutes = { quick: number; found?: number; claims: number }; + +// Each tier's reading time: the quick take (the tally and the summary), what +// the check found (each row's title and gist; a fact-check with verdicts +// only), and every claim in full (the method, section bodies, each claim's +// text and findings, and the quote of every citation they show, once). +export function reportTierMinutes(view: ReportPageView): ReportTierMinutes { + const sum = (xs: (string | undefined)[]) => xs.reduce((n, x) => n + wordCount(x), 0); + const tally = + view.kind === "factcheck" + ? verdictTally(view).map((t) => `${view.verdicts[t.verdict]?.label ?? t.verdict} ${t.count}`) + : []; + const groups = view.kind === "factcheck" ? foundGroups(view) : []; + const claims = view.sections.flatMap((s) => s.claims); + const quoted = new Set<string>(); + for (const c of claims) { + if (c.sourceQuote) quoted.add(c.sourceQuote); + for (const id of c.citations) quoted.add(id); + } + return defined({ + quick: readingMinutes(sum([view.summary, ...tally])), + found: + groups.length > 0 + ? readingMinutes(sum(groups.flatMap((g) => g.claims.flatMap((c) => [c.title ?? c.text, c.gist])))) + : undefined, + claims: readingMinutes( + sum([ + view.method, + ...view.sections.map((s) => s.body), + ...claims.flatMap((c) => [c.title, c.text, c.findings]), + ...[...quoted].map((id) => view.citations[id]?.quote), + ]), + ), + }); +} + +// A report kind's label: "Fact-check" or "Report". +export function reportKindLabel(kind: ReportKind): string { + return kind === "factcheck" ? "Fact-check" : "Report"; +} + +// The page's own attribution, set apart from the reviewed document's byline: +// "Fact-check by <site title>" ("Report by …"); with no site title (the hub), +// the kind's label alone. +export function reportAttribution(kind: ReportKind, siteTitle?: string): string { + const by = siteTitle?.trim(); + return by ? `${reportKindLabel(kind)} by ${by}` : reportKindLabel(kind); +} + // The report's entry in the index. export function reportIndexEntry(view: ReportPageView): ReportIndexEntry { const tally = view.kind === "factcheck" ? verdictTally(view) : undefined; diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -2,8 +2,9 @@ ## [Unreleased] - **A report can be saved whole: as one HTML page, a PDF, Markdown, or an evidence pack.** A report page's download line now reads HTML · PDF · Markdown · Evidence pack · Citations JSON · CSV, each listed only when the site publishes it. The HTML is one file that opens with no network: the report with its verdicts, the document's sentences and the post screenshots inside it, numbered citations, and a reference list giving each quote's speaker, date, record, the original at its time and the moment page on the site. The PDF is that page printed. The Markdown is the same report as plain text with numbered references. The evidence pack is a zip of the page with its clips, stills and screenshots beside it, so the clips play offline. Each ends with a line naming the report's date and the start of its checksum. Needs `reports export` (or prepare) and a rebuild and deploy of each site with reports. +- **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. A report's citation can say where its evidence came from (`origin`: `"subject"`, the document under review gave it; `"added"`, the report's author found it). A claim lists what the report added first, each card marked with the Archilyzer mark and "Not in the article" ("Not in the source"), then evidence of unknown origin, then what the document gave itself folded under "In the article (n)"; the reference list marks an added citation with the mark alone, and a claim's flag pill wears the same mark. A fact-check's page reads in three tiers, each opened by a hairline with one, two or three dots and its reading time (at 230 words a minute): the quick take (the tally, the summary, and links to what the check found, every claim and the downloads); **What the check found**, every ruled claim grouped by verdict (contradicted, not found, partly, untestable, corroborated), one line each linking to the claim, with its `gist` (a new optional claim field, one line, at most 240 characters) and its flag; and **Every claim, with its evidence**, which opens with **How it was checked** (`method`, a new optional report field in markdown). A report of kind `sweep` has the first and last tiers only. 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` leads 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>`. +- **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). - **MCP: a site's reports can be read, and a site that publishes only reports says so instead of looking empty.** Two new tools: `list_reports` lists the reports a site publishes (id, title, kind, claim and citation counts, a fact-check's verdict tally, its page), and `get_report` reads one — its tally, then each section's claims with their verdicts and findings and, for every citation, the verbatim quote, the original (the platform at the cited second, the post, the document) and the site's moment page; `section` reads one section. On a site that publishes only its reports, `list_channels`, `list_sources` and `resolve_source` say "cited-only site: N report(s)" where they said "No channels found"; on a site with reports as well, `list_sources` and `resolve_source` say how many. The archive readers (local, remote) read `corpus.json` spec 5: a cited site is an empty corpus without an error, and a local copy of one is never read from a stale `transcripts/` folder. A hub has no reports of its own; `list_reports` says to name a member site. - **The hub leaves out a site that publishes only its reports.** A site with search off (`search: false`) is not a searchable archive, so it is no hub member (not in federated search, the hub's `corpus.json` or `llms.txt`) and no other site's footer links to it, whatever its **List on the Archilyzer homepage and hub** setting says. A site with reports that publishes its full corpus is a member as before. Needs a hub rebuild and deploy once such a site exists. diff --git a/export/app/(workspace)/page.tsx b/export/app/(workspace)/page.tsx @@ -1,12 +1,22 @@ +import type { Metadata } from "next"; import { isCitedSite } from "yt-dlp-transcript-common/lib/siteSchema"; import { currentSite } from "../lib/site"; import { instanceMode } from "../lib/mode"; -import { readReportIndex, readReportView, reportIds } from "../lib/reports"; +import { onlyReportView, readReportIndex, reportMetadata, reportSiteTitle } from "../lib/reports"; import NewSinceLastVisit from "../components/NewSinceLastVisit"; import HubHome from "../components/hub/HubHome"; import ReportIndex from "../components/reports/ReportIndex"; import ReportArticle from "../components/reports/ReportArticle"; +// A cited site with one report: the home page IS the report, so its tab names +// the report as the report's own page does. Every other home keeps the +// layout's default title (the site's). +export function generateMetadata(): Metadata { + if (instanceMode() === "hub" || !isCitedSite(currentSite())) return {}; + const only = onlyReportView(); + return only ? reportMetadata(only) : {}; +} + export default async function Home() { // Hub mode renders the federated shelf + cross-site search and reads no local // summaries manifest (there is none), so branch before countTranscripts(). @@ -26,9 +36,8 @@ export default async function Home() { // links nothing: there is nowhere else to go). With more, the index -- no // heading of its own: the header already names the site. if (isCitedSite(settings)) { - const ids = reportIds(); - const only = ids.length === 1 ? readReportView(ids[0]) : null; - if (only) return <ReportArticle view={only} />; + const only = onlyReportView(); + if (only) return <ReportArticle view={only} siteTitle={reportSiteTitle()} />; return <ReportIndex index={readReportIndex()} heading={null} tagline={settings.homeTagline} />; } const { countTranscripts } = await import("yt-dlp-transcript-common/lib/transcripts"); diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx @@ -1,26 +1,34 @@ import { ArrowDownToLine } from "lucide-react"; -import { CitationCard } from "yt-dlp-transcript-common/components/citations/CitationCard"; +import { AddedMark, CitationCard } from "yt-dlp-transcript-common/components/citations/CitationCard"; import { CitationsProvider } from "yt-dlp-transcript-common/components/citations/CitationsContext"; import { CitedMarkdown } from "yt-dlp-transcript-common/components/citations/CitedMarkdown"; import { ReferenceList } from "yt-dlp-transcript-common/components/citations/ReferenceList"; import { VerdictChip, VerdictTally } from "yt-dlp-transcript-common/components/report/VerdictChip"; import type { SourceArchive } from "yt-dlp-transcript-common/lib/citations/schema"; import { + foundGroups, orderedCitations, + reportAttribution, + reportTierMinutes, + reportByline, sourceAnchor, verdictTally, + type BylinePart, type CitationView, type ClaimView, + type ReportByline, type ReportPageView, type SourceCitationView, } from "yt-dlp-transcript-common/lib/report/views"; -import { ArchiveList, Eyebrow, ReportName, SourceBlock, dateLabel, textLink } from "./parts"; +import { ArchiveList, ReportName, SourceBlock, dateLabel, textLink } from "./parts"; -// ONE REPORT, from its view (common/lib/report/views.ts): the header (title, -// subtitle, dates, the document under review with its archive links), a +// ONE REPORT, from its view (common/lib/report/views.ts): the header (title and +// the reviewed document's byline, then ours — "Fact-check by <site>" — and the +// dates, the subtitle, the document under review with its archive links), a // fact-check's tally, the summary, the sections and their claims — each claim -// its verdict, the document's own sentence (its still), the findings with -// their inline citations, and the evidence cards — then the numbered +// its verdict and flag, the document's own sentence (its still), the findings with +// their inline citations, and the evidence cards (what the report added first, +// marked; what the document gave itself folded away last) — then the numbered // reference list the inline markers jump to, and the downloads: the report as // files (HTML, PDF, Markdown, the evidence pack) and its citations as data. @@ -35,9 +43,27 @@ const DOWNLOADS: readonly (readonly [keyof NonNullable<ReportPageView["downloads ["csv", "CSV"], ]; -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 @@ -47,36 +73,123 @@ 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> ); } +// A claim's flag: a quiet pill in the accent, behind the project's mark (the +// mark added evidence wears: the two read as the report's own marks). +function FlagPill({ flag, className }: { flag: string; className?: string }) { + return ( + <span + data-claim-flag="" + className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border border-brand/40 bg-brand-soft px-2.5 py-0.5 text-xs font-medium text-brand${className ? ` ${className}` : ""}`} + > + <AddedMark className="size-3" /> + {flag} + </span> + ); +} + +// Where a tier of the page begins: a hairline with, at its left, how deep it +// goes (one, two or three dots filled in the accent) and how long it takes to +// read. Read aloud as one plain line ("Next: more detail, about 6 minutes"). +function TierMarker({ depth, minutes, spoken }: { depth: 1 | 2 | 3; minutes: number; spoken: string }) { + const time = `${Math.max(1, minutes)} min`; + const said = `${spoken}, about ${Math.max(1, minutes)} minute${Math.max(1, minutes) === 1 ? "" : "s"}`; + return ( + <div data-tier-marker={depth} className="flex items-center gap-2.5"> + <span aria-hidden="true" className="flex items-center gap-1"> + {[1, 2, 3].map((i) => ( + <span + key={i} + data-depth-dot={i <= depth ? "filled" : "empty"} + className={ + i <= depth + ? "size-1.5 rounded-full bg-brand" + : "size-1.5 rounded-full border border-muted-foreground/60" + } + /> + ))} + </span> + <span aria-hidden="true" className="font-mono text-xs text-muted-foreground"> + {time} + </span> + <span aria-hidden="true" className="h-px flex-1 bg-border" /> + <span className="sr-only">{said}</span> + </div> + ); +} + +// The byline after the report's title, from the document under review: +// "by <author> · <publisher>", the document's URL on the publisher (else the +// author). Each part keeps its words together; the line breaks between them. +function Byline({ byline }: { byline: ReportByline }) { + const part = (p: BylinePart) => + p.href ? ( + <a href={p.href} target="_blank" rel="noopener noreferrer" className={textLink}> + {p.text} + </a> + ) : ( + p.text + ); + return ( + <span + data-report-byline="" + className="font-sans text-base font-normal tracking-normal text-muted-foreground sm:text-lg" + > + {byline.author && ( + <span className="whitespace-nowrap"> + by {part(byline.author)} + {byline.publisher ? " ·" : ""} + </span> + )} + {byline.author && byline.publisher ? " " : null} + {byline.publisher && <span className="whitespace-nowrap">{part(byline.publisher)}</span>} + </span> + ); +} + function Claim({ claim, view, subjectLabel, + subjectNoun, }: { claim: ClaimView; view: ReportPageView; subjectLabel: string; + // What the document under review is called: "article" or "source". + subjectNoun: string; }) { const sentence = claim.sourceQuote ? view.citations[claim.sourceQuote] : undefined; const evidence = claim.citations .filter((id) => id !== claim.sourceQuote) .map((id) => view.citations[id]) .filter((c): c is CitationView => !!c); + // What the report adds comes first, marked; then evidence of unknown origin; + // what the document under review gave itself is folded away at the end. + const added = evidence.filter((c) => c.origin === "added"); + const unknown = evidence.filter((c) => c.origin === undefined); + const given = evidence.filter((c) => c.origin === "subject"); + const shown = [...added, ...unknown]; + const addedLabel = `Not in the ${subjectNoun}`; return ( <article id={claim.id} @@ -85,6 +198,7 @@ function Claim({ > <div className="flex flex-wrap items-start gap-x-3 gap-y-2"> {claim.verdict && <VerdictChip verdict={claim.verdict} style={view.verdicts[claim.verdict]} className="mt-0.5" />} + {claim.flag && <FlagPill flag={claim.flag} className="mt-0.5" />} <h3 className="min-w-0 flex-1 basis-60 font-display text-lg font-semibold leading-snug text-foreground"> <a href={`#${claim.id}`} className="hover:text-brand transition-colors"> {claim.title ?? claim.text} @@ -96,7 +210,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} @@ -105,23 +221,41 @@ function Claim({ {evidence.length > 0 && ( <div className="flex flex-col gap-2"> <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">Evidence</p> - <div className="grid gap-3 sm:grid-cols-2"> - {evidence.map((c) => ( - <CitationCard key={c.id} citation={c} /> - ))} - </div> + {shown.length > 0 && ( + <div className="grid gap-3 sm:grid-cols-2"> + {shown.map((c) => ( + <CitationCard key={c.id} citation={c} addedLabel={addedLabel} /> + ))} + </div> + )} + {given.length > 0 && ( + <details data-subject-evidence="" className="group"> + <summary className="cursor-pointer select-none font-mono text-xs text-muted-foreground hover:text-foreground"> + {`In the ${subjectNoun} (${given.length})`} + </summary> + <div className="mt-2 grid gap-3 sm:grid-cols-2"> + {given.map((c) => ( + <CitationCard key={c.id} citation={c} addedLabel={addedLabel} /> + ))} + </div> + </details> + )} </div> )} </article> ); } -export default function ReportArticle({ view }: { view: ReportPageView }) { +// `siteTitle` names whose report it is ("Fact-check by <site>"): the site's +// title, absent on the hub. +export default function ReportArticle({ view, siteTitle }: { view: ReportPageView; siteTitle?: string }) { const isFactcheck = view.kind === "factcheck"; const tally = isFactcheck ? verdictTally(view) : []; const references = orderedCitations(view); + const byline = reportByline(view); const subject = view.subject ? view.sources[view.subject] : undefined; - const subjectLabel = subject?.kind === "article" ? "The article says" : "The source says"; + const subjectNoun = subject?.kind === "article" ? "article" : "source"; + const subjectLabel = `The ${subjectNoun} says`; const dates = [ view.published ? `Published ${dateLabel(view.published)}` : null, view.updated && view.updated !== view.published ? `Updated ${dateLabel(view.updated)}` : null, @@ -133,6 +267,13 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { const href = view.downloads?.[key]; return href ? [[key, label, href] as const] : []; }); + const groups = isFactcheck ? foundGroups(view) : []; + const minutes = reportTierMinutes(view); + const jumps = [ + ...(groups.length > 0 ? [{ href: "#found", label: "What the check found" }] : []), + { href: "#claims", label: "Every claim" }, + ...(downloads.length > 0 ? [{ href: "#downloads", label: "Downloads" }] : []), + ]; // Documents quoted that are not the subject, each shown once before the // references. const otherSources = Object.values(view.sources).filter((s) => s.id !== view.subject); @@ -142,14 +283,27 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { // 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"> - <header className="flex flex-col gap-3 border-b border-border pb-6"> - {!view.series && <Eyebrow>{isFactcheck ? "Fact-check" : "Report"}</Eyebrow>} + <header className="flex flex-col gap-3"> <h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground sm:text-4xl"> - <ReportName series={view.series} title={view.title} /> + <ReportName series={view.series} title={view.title}> + {byline && <Byline byline={byline} />} + </ReportName> </h1> + {/* Ours, set a step apart from the reviewed document's byline above; + the kind is named here, so the header carries no kind eyebrow. */} + <div className="mt-1 flex flex-col gap-1"> + <p data-report-attribution="" className="text-sm text-foreground sm:text-base"> + {reportAttribution(view.kind, siteTitle)} + </p> + {dates.length > 0 && <p className="font-mono text-xs text-muted-foreground">{dates.join(" · ")}</p>} + </div> {view.subtitle && <p className="text-lg text-muted-foreground">{view.subtitle}</p>} - {dates.length > 0 && <p className="font-mono text-xs text-muted-foreground">{dates.join(" · ")}</p>} {subject && <SourceBlock source={subject} label="Under review" />} + </header> + + {/* The quick take: the tally and the summary, then where to go next. */} + <section data-report-tier="1" aria-label="In brief" className="flex flex-col gap-4"> + <TierMarker depth={1} minutes={minutes.quick} spoken="In brief" /> {tally.length > 0 && ( <div className="flex flex-col gap-2"> <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"> @@ -158,46 +312,106 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { <VerdictTally tally={tally} styles={view.verdicts} /> </div> )} - </header> - - {view.summary && ( - <CitedMarkdown className={proseClass}> - {view.summary} - </CitedMarkdown> - )} + {view.summary && ( + <CitedMarkdown className={proseClass}> + {view.summary} + </CitedMarkdown> + )} + <p data-report-jumps="" className="flex flex-wrap gap-x-2 gap-y-1 text-sm"> + {jumps.map((j, i) => ( + <span key={j.href} className="whitespace-nowrap"> + {i > 0 && <span aria-hidden="true" className="mr-2 text-muted-foreground">·</span>} + <a href={j.href} className={textLink}> + {j.label} + </a> + </span> + ))} + </p> + </section> - {view.sections.length > 1 && ( - <nav aria-label="Sections" className="rounded-lg border border-border p-4 text-sm"> - <ol className="flex list-decimal flex-col gap-1 pl-5 marker:text-muted-foreground"> - {view.sections.map((s) => ( - <li key={s.id}> - <a href={`#${s.id}`} className={textLink}> - {s.title} - </a> - {s.claims.length > 0 && ( - <span className="ml-2 font-mono text-xs text-muted-foreground"> - {s.claims.length} claim{s.claims.length === 1 ? "" : "s"} - </span> - )} - </li> - ))} - </ol> - </nav> + {/* What the check found: every ruled claim by verdict, one line each. */} + {groups.length > 0 && ( + <section id="found" data-report-tier="2" aria-labelledby="found-heading" className="flex scroll-mt-20 flex-col gap-4"> + <TierMarker depth={2} minutes={minutes.found ?? 0} spoken="Next: more detail" /> + <h2 id="found-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground"> + What the check found + </h2> + {groups.map((g) => ( + <div key={g.verdict} data-found-group={g.verdict} className="flex flex-col gap-2"> + <h3> + <VerdictChip verdict={g.verdict} style={view.verdicts[g.verdict]} count={g.claims.length} /> + </h3> + <ul className="flex flex-col gap-1.5 pl-1 text-sm"> + {g.claims.map((c) => ( + <li key={c.id} data-found-row={c.id} className="leading-snug"> + <a href={`#${c.id}`} className="font-medium text-foreground hover:text-brand transition-colors"> + {c.title ?? c.text} + </a> + {c.gist && <span className="text-muted-foreground"> — {c.gist}</span>} + {c.flag && ( + <> + {" "} + <FlagPill flag={c.flag} className="ml-1 align-middle" /> + </> + )} + </li> + ))} + </ul> + </div> + ))} + </section> )} - {view.sections.map((s) => ( - <section key={s.id} id={s.id} data-section={s.id} className="flex scroll-mt-20 flex-col gap-4"> - <h2 className="font-display text-2xl font-semibold tracking-tight text-foreground">{s.title}</h2> - {s.body && ( - <CitedMarkdown className={proseClass}> - {s.body} - </CitedMarkdown> + {/* Every claim, with its evidence. */} + <div id="claims" data-report-tier="3" className="flex scroll-mt-20 flex-col gap-8"> + <div className="flex flex-col gap-4"> + <TierMarker depth={3} minutes={minutes.claims} spoken="Next: every claim in full" /> + <h2 className="font-display text-2xl font-semibold tracking-tight text-foreground"> + Every claim, with its evidence + </h2> + {view.method && ( + <div data-report-method="" className="flex flex-col gap-1"> + <h3 className="font-mono text-xs uppercase tracking-[0.16em] text-muted-foreground">How it was checked</h3> + <CitedMarkdown className={proseClass}> + {view.method} + </CitedMarkdown> + </div> )} - {s.claims.map((c) => ( - <Claim key={c.id} claim={c} view={view} subjectLabel={subjectLabel} /> - ))} - </section> - ))} + </div> + + {view.sections.length > 1 && ( + <nav aria-label="Sections" className="rounded-lg border border-border p-4 text-sm"> + <ol className="flex list-decimal flex-col gap-1 pl-5 marker:text-muted-foreground"> + {view.sections.map((s) => ( + <li key={s.id}> + <a href={`#${s.id}`} className={textLink}> + {s.title} + </a> + {s.claims.length > 0 && ( + <span className="ml-2 font-mono text-xs text-muted-foreground"> + {s.claims.length} claim{s.claims.length === 1 ? "" : "s"} + </span> + )} + </li> + ))} + </ol> + </nav> + )} + + {view.sections.map((s) => ( + <section key={s.id} id={s.id} data-section={s.id} className="flex scroll-mt-20 flex-col gap-4"> + <h2 className="font-display text-2xl font-semibold tracking-tight text-foreground">{s.title}</h2> + {s.body && ( + <CitedMarkdown className={proseClass}> + {s.body} + </CitedMarkdown> + )} + {s.claims.map((c) => ( + <Claim key={c.id} claim={c} view={view} subjectLabel={subjectLabel} subjectNoun={subjectNoun} /> + ))} + </section> + ))} + </div> {otherSources.length > 0 && ( <section aria-labelledby="sources-heading" className="flex flex-col gap-3 border-t border-border pt-6"> @@ -215,12 +429,16 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { <h2 id="references-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground"> References </h2> - <ReferenceList citations={references} /> + <ReferenceList citations={references} addedLabel={`Not in the ${subjectNoun}`} /> </section> )} {downloads.length > 0 && ( - <p data-citation-downloads="" className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground"> + <p + id="downloads" + data-citation-downloads="" + className="flex scroll-mt-20 flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground" + > <ArrowDownToLine className="size-4" aria-hidden /> Download: {downloads.map(([key, label, href]) => ( diff --git a/export/app/components/reports/parts.tsx b/export/app/components/reports/parts.tsx @@ -16,17 +16,34 @@ export function Eyebrow({ children }: { children: React.ReactNode }) { return <p className="font-mono text-xs uppercase tracking-[0.18em] text-brand">{children}</p>; } -// A report's name as a heading. With a series, the series leads in the accent -// (the tag, like a wordmark's lead) and the title follows in regular weight; -// without one, the title alone (the kind's label is then the eyebrow above). -export function ReportName({ series, title }: { series?: string; title: string }) { - if (!series) return <>{title}</>; +// A report's name as a heading. With a series, the series is its own line in +// the accent (the tag, like a wordmark's lead) and the title the next, in +// regular weight — both inside the heading, so its name reads "<series> +// <title>"; without one, the title alone (the kind's label is then the +// eyebrow above). `children` (the report page's byline) follow the title in +// its own text flow: on its line when they fit, wrapping under it when not. +export function ReportName({ + series, + title, + children, +}: { + series?: string; + title: string; + children?: React.ReactNode; +}) { + const name = ( + <> + {title} + {children ? <> {children}</> : null} + </> + ); + if (!series) return name; return ( <> - <span data-report-series="" className="font-semibold text-brand"> + <span data-report-series="" className="block font-semibold text-brand"> {series} </span>{" "} - <span className="font-normal">{title}</span> + <span className="block font-normal">{name}</span> </> ); } @@ -47,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; @@ -54,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 @@ -81,14 +81,29 @@ test("params: every report and every moment, as the index files list them", () = test("the report page: header, tally, sections and claims, inline cites, references, downloads", async () => { const params = Promise.resolve({ reportId: "demo-factcheck" }); const html = render(await reportPage.default({ params })); - assert.match(html, /<h1[^>]*>Checking an example article<\/h1>/); - assert.ok(html.includes("Published 2026-10-01 · Updated 2026-10-04")); + // the title, then the byline from the document under review in the same flow, + // the publisher linked to the document + assert.match( + html, + /<h1[^>]*>Checking an example article <span data-report-byline=""[^>]*><span class="whitespace-nowrap">by A. Writer ·<\/span> <span class="whitespace-nowrap"><a href="https:\/\/example.org\/articles\/demo"[^>]*>Example Gazette<\/a><\/span><\/span><\/h1>/, + ); + // then ours, a step apart: the kind by the site, the dates under it, then the subtitle + assert.match( + html, + /<\/h1><div[^>]*><p data-report-attribution=""[^>]*>Fact-check by Demo Reports<\/p><p[^>]*>Published 2026-10-01 · Updated 2026-10-04<\/p><\/div><p[^>]*>Four claims about a demo channel/, + ); + assert.doesNotMatch(html.slice(0, html.indexOf("<h1")), /Fact-check/, "no kind eyebrow above the title"); assert.match(html, /data-subject-source="s0"/); assert.ok(html.includes("as published on the day")); // 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"); @@ -110,6 +125,49 @@ test("the report page: header, tally, sections and claims, inline cites, referen assert.match(html, /href="\/reports\/demo-factcheck\/citations.csv" download=""/); const meta = await reportPage.generateMetadata({ params }); assert.equal(meta.title, "Checking an example article"); + // a claim's flag: a pill on its card; an unflagged claim carries none + assert.match(html, /<article id="claim-4"[^]*?<span data-claim-flag=""[^>]*><svg[^>]*data-brand-mark=""[^]*?<\/svg>No source given<\/span>/); + // a claim's evidence: what the report added first, marked; then evidence of + // unknown origin, unmarked; what the article gave itself folded away, unmarked + const claim1 = html.slice(html.indexOf('<article id="claim-1"'), html.indexOf("</article>", html.indexOf('<article id="claim-1"'))); + const order = [...claim1.matchAll(/data-citation="([^"]+)"/g)].map((m) => m[1]); + assert.deepEqual(order, ["c01", "au1", "w01"]); + assert.match(claim1, /<div data-citation="c01"[^>]*><p data-citation-added=""[^>]*><svg[^>]*data-brand-mark=""[^]*?<\/svg>Not in the article<\/p>/); + assert.equal([...claim1.matchAll(/data-citation-added=/g)].length, 1, "only the added card is marked"); + assert.match(claim1, /<details data-subject-evidence=""[^>]*><summary[^>]*>In the article \(1\)<\/summary>[^]*?data-citation="w01"/); + assert.doesNotMatch(claim1, /<details[^>]* open/, "folded by default"); + // the reference list marks the added citation with the mark alone + const refList = html.slice(html.indexOf("data-reference-list")); + assert.match(refList, /<li id="c-c01"[^]*?<span data-citation-added="" title="Not in the article"[^>]*><svg/); + assert.equal([...refList.matchAll(/data-citation-added=/g)].length, 1); + assert.equal([...html.matchAll(/data-claim-flag=/g)].length, 2, "the claim's card and its row in what the check found"); + // three tiers, in order, each opened by its marker: the quick take, what the + // check found, every claim; nothing in the first two is folded away + const tiers = [...html.matchAll(/data-report-tier="(\d)"/g)].map((m) => m[1]); + assert.deepEqual(tiers, ["1", "2", "3"]); + const tier1 = html.slice(html.indexOf('data-report-tier="1"'), html.indexOf('data-report-tier="2"')); + const tier2 = html.slice(html.indexOf('data-report-tier="2"'), html.indexOf('data-report-tier="3"')); + const tier3 = html.slice(html.indexOf('data-report-tier="3"')); + assert.doesNotMatch(tier1 + tier2, /<details/); + assert.match(tier1, /data-tier-marker="1"[^]*?<span class="sr-only">In brief, about 1 minute<\/span>/); + assert.match(tier2, /data-tier-marker="2"[^]*?<span class="sr-only">Next: more detail, about 1 minute<\/span>/); + assert.match(tier3, /data-tier-marker="3"[^]*?<span class="sr-only">Next: every claim in full, about 1 minute<\/span>/); + assert.equal([...tier2.matchAll(/data-depth-dot="filled"/g)].length, 2); + assert.match(tier1, /data-verdict-tally=""[^]*?The article makes four claims/); + const jumps = [...tier1.slice(tier1.indexOf("data-report-jumps")).matchAll(/<a href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => `${m[1]} ${m[2]}`); + assert.deepEqual(jumps, ["#found What the check found", "#claims Every claim", "#downloads Downloads"]); + assert.match(html, /id="downloads"/); + // what the check found: groups in a fixed order, every row a link to its claim + assert.match(tier2, /<h2 id="found-heading"[^>]*>What the check found<\/h2>/); + const groups = [...tier2.matchAll(/data-found-group="([A-Z_]+)"/g)].map((m) => m[1]); + assert.deepEqual(groups, ["CONTRADICTED", "PARTLY", "UNTESTABLE", "CORROBORATED"]); + const rows = [...tier2.matchAll(/data-found-row="([^"]+)"/g)].map((m) => m[1]); + assert.deepEqual(rows, ["claim-1", "claim-3", "claim-2", "claim-4", "claim-5"]); + assert.match(tier2, /<li data-found-row="claim-1"[^>]*><a href="#claim-1"[^>]*>“He opened it himself”<\/a><span[^>]*> — It opened in 2019/); + assert.match(tier2, /<li data-found-row="claim-5"[^>]*><a href="#claim-5"[^>]*>He was at the opening.<\/a><\/li>/, "no gist: the title alone"); + assert.match(tier2, /<li data-found-row="claim-4"[^]*?data-claim-flag=""/); + // every claim: the tier's heading, then how it was checked — plain, not folded + assert.match(tier3, /<h2[^>]*>Every claim, with its evidence<\/h2><div data-report-method=""[^>]*><h3[^>]*>How it was checked<\/h3>[^]*?searched for in the channel/); }); test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => { @@ -158,10 +216,16 @@ test("one report: /reports/ is no index, it forwards to the home page (the repor test("a cited site with one report: its home IS the report, and never counts transcripts", async () => { // There is no summaries manifest here: countTranscripts() would throw. const html = render(await home.default()); - assert.match(html, /<h1[^>]*>Checking an example article<\/h1>/); + assert.match(html, /<h1[^>]*>Checking an example article <span data-report-byline=""/); assert.match(html, /data-claim=/); assert.doesNotMatch(html, /data-report-index/); - assert.doesNotMatch(html, /Demo Reports/, "the site title is the header's, not the page's"); + // the site title is the header's; the page names it only as whose report it is + assert.deepEqual([...html.matchAll(/Demo Reports/g)].length, 1); + assert.match(html, /<p data-report-attribution=""[^>]*>Fact-check by Demo Reports<\/p>/); + // its tab names the report as the report's own page does + const reportMeta = await reportPage.generateMetadata({ params: Promise.resolve({ reportId: "demo-factcheck" }) }); + assert.deepEqual(home.generateMetadata(), reportMeta); + assert.equal(home.generateMetadata().title, "Checking an example article"); }); test("a cited site with more than one report: its home is the index, with no heading of its own", async () => { @@ -176,14 +240,16 @@ test("a cited site with more than one report: its home is the index, with no hea assert.match(html, /data-report-entry="demo-factcheck"/); assert.match(html, /data-report-entry="demo-second"/); assert.doesNotMatch(html, /<h1/, "no heading: the header already names the site"); + assert.deepEqual(home.generateMetadata(), {}, "the index keeps the site's own title"); + assert.doesNotMatch(html, /data-report-byline/, "an index card is a link: no byline"); // With more than one, /reports/ is the index, tally and all. const idx = render(reportsIndexPage.default()); assert.match(idx, /data-report-entry="demo-factcheck"/); assert.doesNotMatch(idx, /claims ·|data-verdict-tally/, "counts and tally are the report page's"); assert.doesNotMatch(idx, /http-equiv="refresh"/); - // A series leads its title in the accent, in place of the kind's eyebrow. + // A series is its own line above the title, in the accent, in place of the kind's eyebrow. const second = html.slice(html.indexOf('data-report-entry="demo-second"')); - assert.match(second, /<span data-report-series="" class="font-semibold text-brand">On the Record<\/span> <span class="font-normal">A second report<\/span>/); + assert.match(second, /<span data-report-series="" class="block font-semibold text-brand">On the Record<\/span> <span class="block font-normal">A second report<\/span>/); assert.doesNotMatch(second.slice(0, second.indexOf("</li>")), /Fact-check/); const first = html.slice(html.indexOf('data-report-entry="demo-factcheck"')); assert.match(first.slice(0, first.indexOf("</li>")), /Fact-check/, "no series: the kind's eyebrow as before"); diff --git a/export/app/lib/reports.ts b/export/app/lib/reports.ts @@ -1,6 +1,9 @@ import fs from "node:fs"; import path from "node:path"; +import type { Metadata } from "next"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { instanceMode } from "./mode"; +import { currentSite } from "./site"; import { parseMomentKey } from "yt-dlp-transcript-common/lib/citations/moments"; import { isReportId } from "yt-dlp-transcript-common/lib/report/schema"; import { @@ -11,6 +14,7 @@ import { REPORT_PAGE_FORMAT, REPORTS_INDEX_PATH, momentViewPath, + reportFullTitle, reportViewPath, type MomentIndexView, type MomentPageView, @@ -58,6 +62,28 @@ export function readReportView(reportId: string): ReportPageView | null { return v && v.id === reportId && Array.isArray(v.sections) ? v : null; } +// The build's one report, when it publishes exactly one: a cited site's home +// page IS that report. +export function onlyReportView(): ReportPageView | null { + const ids = reportIds(); + return ids.length === 1 ? readReportView(ids[0]) : null; +} + +// Whose report it is: the site's title, as its tab names it. The hub has no +// site of its own to name. +export function reportSiteTitle(): string | undefined { + if (instanceMode() === "hub") return undefined; + return currentSite().siteTitle.trim() || undefined; +} + +// A report's page metadata — its page's, and a one-report cited site's home's, +// so the tab reads the same on both: the title as one line names it +// (`<series>: <title>`, the layout's template adds the site), the subtitle as +// the description. +export function reportMetadata(view: ReportPageView): Metadata { + return { title: reportFullTitle(view), ...(view.subtitle ? { description: view.subtitle } : {}) }; +} + // The moment keys to build pages for (only keys exactly as momentKey spells // them: each segment is a path segment of a page written to disk). export function momentKeys(): string[] { diff --git a/export/app/reports/[reportId]/page.tsx b/export/app/reports/[reportId]/page.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; -import { REPORT_PLACEHOLDER_ID, reportFullTitle } from "yt-dlp-transcript-common/lib/report/views"; +import { REPORT_PLACEHOLDER_ID } from "yt-dlp-transcript-common/lib/report/views"; import ReportArticle from "../../components/reports/ReportArticle"; import { EmptyState } from "../../components/reports/parts"; -import { readReportView, reportIds } from "../../lib/reports"; +import { readReportView, reportIds, reportMetadata, reportSiteTitle } from "../../lib/reports"; // /reports/<reportId>/: one report, from /reports/<reportId>/page.json. // Static: every published report is listed, nothing else resolves. A site with @@ -20,7 +20,7 @@ type Params = { params: Promise<{ reportId: string }> }; export async function generateMetadata({ params }: Params): Promise<Metadata> { const view = readReportView((await params).reportId); if (!view) return { title: "No reports" }; - return { title: reportFullTitle(view), ...(view.subtitle ? { description: view.subtitle } : {}) }; + return reportMetadata(view); } export default async function ReportPage({ params }: Params) { @@ -32,5 +32,5 @@ export default async function ReportPage({ params }: Params) { </EmptyState> ); } - return <ReportArticle view={view} />; + return <ReportArticle view={view} siteTitle={reportSiteTitle()} />; } diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts @@ -27,8 +27,19 @@ async function expectImageLoaded(img: Locator): Promise<void> { test("one report: the home page IS the report, and the header links nothing", async ({ page }) => { await page.goto("/"); - await expect(page.locator("h1")).toHaveText("Checking an example article"); + await expect(page.locator("h1")).toHaveText("Checking an example article by A. Writer · Example Gazette"); + // The byline, in the title's flow: the publisher links the document under review. + const byline = page.locator("h1 [data-report-byline]"); + await expect(byline).toHaveText("by A. Writer · Example Gazette"); + await expect(byline.getByRole("link", { name: "Example Gazette" })).toHaveAttribute( + "href", + "https://example.org/articles/demo", + ); + // Ours, under it: the kind by this site. + await expect(page.locator("[data-report-attribution]")).toHaveText("Fact-check by Demo Reports"); await expect(page.locator("article[data-claim]")).toHaveCount(5); + // The tab names the report, as the report's own page does. + await expect(page).toHaveTitle("Checking an example article — Demo Reports"); await expect(page.locator("[data-report-index]")).toHaveCount(0); await expect(page.locator("header nav a")).toHaveCount(0); }); @@ -36,7 +47,7 @@ test("one report: the home page IS the report, and the header links nothing", as test("one report: /reports/ is no index, it forwards to the home page", async ({ page }) => { await page.goto("/reports/"); await expect(page).toHaveURL("/"); - await expect(page.locator("h1")).toHaveText("Checking an example article"); + await expect(page.locator("h1")).toContainText("Checking an example article"); }); test("the report: verdict chips, the tally, the source's sentence as a still, numbered inline citations", async ({ page }) => { @@ -57,9 +68,19 @@ test("the report: verdict chips, the tally, the source's sentence as a still, nu ]) { await expect(page.locator(`article#${claim} [data-verdict="${verdict}"]`)).toBeVisible(); } + // A claim's flag is a pill on its card; an unflagged claim has none. + await expect(page.locator("article#claim-4 [data-claim-flag]")).toHaveText("No source given"); + await expect(page.locator("article[data-claim] [data-claim-flag]")).toHaveCount(1); + await expect(page).toHaveTitle("Checking an example article — Demo Reports"); // 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 [ @@ -73,6 +94,68 @@ test("the report: verdict chips, the tally, the source's sentence as a still, nu } }); +test("three tiers, each marked by its depth and reading time; nothing above the claims is folded away", async ({ page }) => { + await page.goto(REPORT); + const tiers = page.locator("[data-report-tier]"); + expect(await tiers.evaluateAll((els) => els.map((e) => e.getAttribute("data-report-tier")))).toEqual(["1", "2", "3"]); + for (const depth of [1, 2, 3]) { + const marker = page.locator(`[data-report-tier="${depth}"] [data-tier-marker="${depth}"]`); + await expect(marker).toBeVisible(); + await expect(marker.locator('[data-depth-dot="filled"]')).toHaveCount(depth); + await expect(marker).toContainText(/\d+ min/); + } + await expect(page.locator('[data-report-tier="1"] details, [data-report-tier="2"] details')).toHaveCount(0); + + // The quick take ends on where to go next. + const jumps = page.locator("[data-report-jumps] a"); + await expect(jumps).toHaveText(["What the check found", "Every claim", "Downloads"]); + + // What the check found: groups in a fixed order, every row shown, each a link to its claim. + const found = page.locator('[data-report-tier="2"]'); + expect(await found.locator("[data-found-group]").evaluateAll((els) => els.map((e) => e.getAttribute("data-found-group")))).toEqual([ + "CONTRADICTED", + "PARTLY", + "UNTESTABLE", + "CORROBORATED", + ]); + const rows = found.locator("[data-found-row]"); + await expect(rows).toHaveCount(5); + for (const row of await rows.all()) await expect(row).toBeVisible(); + await expect(found.locator('[data-found-row="claim-3"]')).toContainText("He denies ever saying it."); + await found.locator('[data-found-row="claim-2"] a').click(); + await expect(page).toHaveURL(`${REPORT}#claim-2`); + await expect(page.locator("article#claim-2")).toBeInViewport(); + + // Every claim: how it was checked, plain, at its start. + const method = page.locator('[data-report-tier="3"] [data-report-method]'); + await expect(method).toBeVisible(); + await expect(method).toContainText("How it was checked"); + await expect(method.locator("details")).toHaveCount(0); +}); + +test("a claim's evidence: what the report added first and marked, the article's own folded away", async ({ page }) => { + await page.goto(REPORT); + const claim = page.locator("article#claim-1"); + const cards = claim.locator("[data-citation]"); + await expect(cards).toHaveCount(3); + expect(await cards.evaluateAll((els) => els.map((e) => e.getAttribute("data-citation")))).toEqual(["c01", "au1", "w01"]); + // Added: the project's mark and the words; nothing else in the claim is marked. + const added = claim.locator('[data-citation="c01"] [data-citation-added]'); + await expect(added).toHaveText("Not in the article"); + await expect(added.locator("svg[data-brand-mark]")).toBeVisible(); + await expect(claim.locator("[data-citation-added]")).toHaveCount(1); + // The flag pill wears the same mark. + await expect(page.locator("article#claim-4 [data-claim-flag] svg[data-brand-mark]")).toBeVisible(); + // What the article gave itself: folded until asked for. + const given = claim.locator("details[data-subject-evidence]"); + await expect(given.locator("summary")).toHaveText("In the article (1)"); + await expect(given).not.toHaveAttribute("open", /.*/); + await expect(given.locator('[data-citation="w01"]')).toBeHidden(); + await given.locator("summary").click(); + await expect(given.locator('[data-citation="w01"]')).toBeVisible(); + await expect(given.locator("[data-citation-added]")).toHaveCount(0); +}); + test("hovering an inline citation previews it; leaving closes it", async ({ page }) => { await page.goto(REPORT); for (const [id, kind, text] of [ @@ -180,6 +263,10 @@ test("report.html opens with no network: one file, no script, every image inline await expect.poll(() => img.evaluate((el) => (el as HTMLImageElement).naturalWidth)).toBeGreaterThan(0); } await expect(page.locator("[data-reference-list] > li")).toHaveCount(6); + // The page's tiers, its flag pill and the added mark, as the site has them. + await expect(page.locator("[data-report-tier]")).toHaveCount(3); + await expect(page.locator("[data-claim-flag]").first()).toContainText("No source given"); + await expect(page.locator("[data-citation-added] svg").first()).toBeVisible(); await expect(page.locator("[data-export-footer]")).toContainText("report sha256"); expect(asked).toEqual([]); }); diff --git a/export/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json b/export/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json @@ -57,6 +57,23 @@ { "reportId": "demo-factcheck", "sectionId": "bridge", + "claimId": "claim-1", + "citationId": "au1", + "href": "/reports/demo-factcheck/#claim-1", + "reportTitle": "Checking an example article", + "sectionTitle": "The bridge", + "claimTitle": "“He opened it himself”", + "claimText": "He opened the bridge himself in 2018.", + "verdict": "CONTRADICTED", + "verdictStyle": { + "label": "Contradicted", + "color": "#e5534b" + }, + "number": 2 + }, + { + "reportId": "demo-factcheck", + "sectionId": "bridge", "claimId": "claim-2", "citationId": "au1", "href": "/reports/demo-factcheck/#claim-2", diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json @@ -6,6 +6,7 @@ "title": "Checking an example article", "subtitle": "Four claims about a demo channel, tested against what was said on air", "summary": "The article makes four claims. The recordings support one, partly support another, and contradict a third; the fourth cannot be tested. The host said the opening date out loud [on stream](cite:c01), and later [repeated it](cite:au1).", + "method": "Each claim was searched for in the channel's transcripts and the guest's podcast, and checked against the city's public records.", "published": "2026-10-01", "updated": "2026-10-04", "subject": "s0", @@ -19,6 +20,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", @@ -62,6 +64,7 @@ "voiceChecked": true, "method": "cue-window v1" }, + "origin": "added", "moment": "demo-channel/abc123/3126.00-3151.00", "href": "/m/demo-channel/abc123/3126.00-3151.00/", "start": 3126, @@ -107,6 +110,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" }, @@ -116,6 +120,7 @@ "number": 4, "quote": "Opened to traffic: April 2019.", "note": "The city's own record of the opening.", + "origin": "subject", "href": "https://example.org/city/bridge-history", "title": "Bridge history — city records", "archiveUrl": "https://web.archive.org/web/2026/https://example.org/city/bridge-history" @@ -127,6 +132,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": { @@ -161,17 +167,20 @@ "title": "“He opened it himself”", "text": "He opened the bridge himself in 2018.", "verdict": "CONTRADICTED", + "gist": "It opened in 2019, as he says on stream and the city's record shows.", "sourceQuote": "a01", "findings": "He was there, but the bridge opened in 2019: he says so [on stream](cite:c01), and the [city's record](cite:w01) agrees.", "citations": [ "c01", - "w01" + "w01", + "au1" ] }, { "id": "claim-2", "text": "He went back to the bridge every year.", "verdict": "PARTLY", + "gist": "Once is on record; every year is not.", "findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.", "citations": [ "au1" @@ -187,6 +196,7 @@ "id": "claim-3", "text": "He has said many times that he would move away.", "verdict": "CONTRADICTED", + "gist": "He denies ever saying it.", "sourceQuote": "a02", "findings": "The article [puts it as a habit](cite:a02); he [denies it outright](cite:p01).", "citations": [ @@ -197,6 +207,7 @@ "id": "claim-4", "text": "He privately regrets it.", "verdict": "UNTESTABLE", + "flag": "No source given", "findings": "Nothing public speaks to a private feeling.", "citations": [] }, diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json @@ -6,6 +6,7 @@ "title": "Checking an example article", "subtitle": "Four claims about a demo channel, tested against what was said on air", "summary": "The article makes four claims. The recordings support one, partly support another, and contradict a third; the fourth cannot be tested. The host said the opening date out loud [on stream](cite:c01), and later [repeated it](cite:au1).", + "method": "Each claim was searched for in the channel's transcripts and the guest's podcast, and checked against the city's public records.", "published": "2026-10-01", "updated": "2026-10-04", "subject": { "source": "s0" }, @@ -18,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", "url": "https://web.archive.org/web/2026/https://example.org/articles/demo", "context": "as published on the day" } ] @@ -32,6 +34,7 @@ "end": 3151, "pad": { "before": 5, "after": 5 }, "quote": "The bridge opened in the spring of twenty nineteen, I was there for it.", + "origin": "added", "verification": { "quoteScore": 0.97, "quoteCheckedAt": "2026-10-04T12:00:00Z", "voiceChecked": true, "method": "cue-window v1" } }, "au1": { @@ -68,7 +71,8 @@ "title": "Bridge history — city records", "archiveUrl": "https://web.archive.org/web/2026/https://example.org/city/bridge-history", "quote": "Opened to traffic: April 2019.", - "note": "The city's own record of the opening." + "note": "The city's own record of the opening.", + "origin": "subject" }, "unused": { "kind": "page", @@ -87,14 +91,16 @@ "title": "“He opened it himself”", "text": "He opened the bridge himself in 2018.", "verdict": "CONTRADICTED", + "gist": "It opened in 2019, as he says on stream and the city's record shows.", "sourceQuote": { "citation": "a01" }, "findings": "He was there, but the bridge opened in 2019: he says so [on stream](cite:c01), and the [city's record](cite:w01) agrees.", - "citations": ["c01", "w01"] + "citations": ["c01", "w01", "au1"] }, { "id": "claim-2", "text": "He went back to the bridge every year.", "verdict": "PARTLY", + "gist": "Once is on record; every year is not.", "findings": "He mentions going back [once](cite:au1); nothing in the recordings says every year.", "citations": ["au1"] } @@ -108,6 +114,7 @@ "id": "claim-3", "text": "He has said many times that he would move away.", "verdict": "CONTRADICTED", + "gist": "He denies ever saying it.", "sourceQuote": { "citation": "a02" }, "findings": "The article [puts it as a habit](cite:a02); he [denies it outright](cite:p01).", "citations": ["p01"] @@ -116,6 +123,7 @@ "id": "claim-4", "text": "He privately regrets it.", "verdict": "UNTESTABLE", + "flag": "No source given", "findings": "Nothing public speaks to a private feeling." }, { diff --git a/plans/report-sites.md b/plans/report-sites.md @@ -28,7 +28,7 @@ on existing full sites) need configuration, not code. ```jsonc { "format": "archilyzer-report", "version": 1, - "id", "kind": "factcheck" | "sweep", "title", "subtitle", "summary" /* md */, "published", "updated", + "id", "kind": "factcheck" | "sweep", "title", "subtitle", "summary" /* md */, "method" /* md, optional: "How it was checked" */, "published", "updated", "subject": { "source": "s0" }, // the document under review (optional) "verdicts": { "PARTLY": { "label": "…" } }, // optional label/colour overrides of the shared vocab "sources": { "s0": { "kind": "article", "title", "url", "publisher", "author", "date", @@ -39,7 +39,7 @@ on existing full sites) need configuration, not code. "p01": { "kind": "post", "channel", "id", "quote" }, "a01": { "kind": "source", "source": "s0", "quote", "image": "stills/a01.png" } }, "sections": [ { "id", "title", "body" /* md */, - "claims": [ { "id", "text", "verdict" /* optional */, + "claims": [ { "id", "text", "verdict" /* optional */, "gist" /* optional, ≤240: its line in "What the check found" */, "flag" /* optional, ≤60 chars: a pill on the card */, "sourceQuote": { "citation": "a01" }, "findings": "md with [label](cite:c01) refs", "citations": ["c01", "p01"] } ] } ] }