commit f111b66baa23a7d06cfaa8f9c613d0dfb0c3bbcd
parent a38ef94914b2d5ecfc92a5799231fcc96dd28a52
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 13:46:34 -0400
reports: a claim may carry a flag (a quiet accent pill on its card); a one-report site's home tab names the report
- report.json claim `flag`: one line, at most 60 characters (schema + validate); REPORT.md regenerated
- ClaimView carries it; ReportArticle draws it beside the verdict chip in the accent
- (workspace)/page generateMetadata: a cited site with one report titles `/` as the report page does
(shared reportMetadata + onlyReportView in export/app/lib/reports.ts)
- fixture claim-4 flagged; unit + report-site e2e assertions
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
16 files changed, 99 insertions(+), 8 deletions(-)
diff --git a/REPORT.md b/REPORT.md
@@ -47,6 +47,7 @@ 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. |
+| `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/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,15 @@ 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;
+
export const claimSchema = z.strictObject({
id: text,
title: text.optional(),
text,
verdict: verdict.optional(),
+ flag: text.max(CLAIM_FLAG_MAX).optional(),
sourceQuote: z.strictObject({ citation: text }).optional(),
findings: text.optional(),
citations: z.array(text).optional(),
@@ -120,6 +124,7 @@ 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.`,
+ 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
@@ -114,6 +114,10 @@ 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.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
@@ -298,3 +298,12 @@ 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);
+});
diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts
@@ -205,6 +205,9 @@ export type ClaimView = {
// The claim, as the document under review states it.
text: string;
verdict?: Verdict;
+ // 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
@@ -487,6 +490,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver)
title: c.title,
text: c.text,
verdict: c.verdict,
+ flag: c.flag,
sourceQuote: c.sourceQuote?.citation,
archives: archivesFor(c),
findings: c.findings,
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **A report's claim can carry a flag, 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. 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>`.
- **`pnpm start:export` serves a built site's moment pages.** It used `serve`, which listed a video or audio moment's directory (`3126.00-3151.00`) instead of serving its page; it now runs `export/scripts/serve-out.mjs`, which serves directories as Cloudflare Pages does, on `EXPORT_DEV_PORT` (3000).
diff --git a/export/app/(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 } 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,8 +36,7 @@ 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;
+ const only = onlyReportView();
if (only) return <ReportArticle view={only} />;
return <ReportIndex index={readReportIndex()} heading={null} tagline={settings.homeTagline} />;
}
diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx
@@ -19,7 +19,7 @@ import { ArchiveList, Eyebrow, ReportName, SourceBlock, dateLabel, textLink } fr
// ONE REPORT, from its view (common/lib/report/views.ts): the header (title,
// subtitle, dates, 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
+// its verdict and flag, the document's own sentence (its still), the findings with
// their inline citations, and the evidence cards — then the numbered
// reference list the inline markers jump to, and the citations as files.
@@ -75,6 +75,14 @@ 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 && (
+ <span
+ data-claim-flag=""
+ className="mt-0.5 inline-flex shrink-0 items-center rounded-full border border-brand/40 bg-brand-soft px-2.5 py-0.5 text-xs font-medium text-brand"
+ >
+ {claim.flag}
+ </span>
+ )}
<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}
diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts
@@ -110,6 +110,9 @@ 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=""[^>]*>No source given<\/span>/);
+ assert.equal([...html.matchAll(/data-claim-flag=/g)].length, 1);
});
test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => {
@@ -162,6 +165,10 @@ test("a cited site with one report: its home IS the report, and never counts tra
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");
+ // 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,6 +183,7 @@ 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");
// With more than one, /reports/ is the index, tally and all.
const idx = render(reportsIndexPage.default());
assert.match(idx, /data-report-entry="demo-factcheck"/);
diff --git a/export/app/lib/reports.ts b/export/app/lib/reports.ts
@@ -1,5 +1,6 @@
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 { parseMomentKey } from "yt-dlp-transcript-common/lib/citations/moments";
import { isReportId } from "yt-dlp-transcript-common/lib/report/schema";
@@ -11,6 +12,7 @@ import {
REPORT_PAGE_FORMAT,
REPORTS_INDEX_PATH,
momentViewPath,
+ reportFullTitle,
reportViewPath,
type MomentIndexView,
type MomentPageView,
@@ -58,6 +60,21 @@ 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;
+}
+
+// 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 } 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) {
diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts
@@ -29,6 +29,8 @@ test("one report: the home page IS the report, and the header links nothing", as
await page.goto("/");
await expect(page.locator("h1")).toHaveText("Checking an example article");
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);
});
@@ -57,6 +59,10 @@ 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("[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"]'));
diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json
@@ -197,6 +197,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
@@ -116,6 +116,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
@@ -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 */, "flag" /* optional, ≤60 chars: a pill on the card */,
"sourceQuote": { "citation": "a01" },
"findings": "md with [label](cite:c01) refs",
"citations": ["c01", "p01"] } ] } ] }