Archilyzer · Source

archilyzer

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

commit d24ec9328d22d2eb9e9e08ff81f55c63d0875289
parent 96cc52eec74759ff0935e5a0ee92a4cf744e48de
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon,  5 Oct 2026 17:41:26 -0400

reports: two-line name, depth-only tier markers, the claim's sentence alone, the added mark under its number

The report page's heading is the series on one line (accent, semibold) and
the title on the next (regular weight), or the title alone; the HTML/PDF
export does the same and the Markdown export puts the series as a bold line
above `# title`. Each tier opens with the hairline and its depth dots only,
decorative (aria-hidden); readingMinutes, reportTierMinutes and wordCount
are removed, and the Markdown export opens each tier with `---`.

A claim that cites the document's own sentence shows that sentence (its
still, its words as the alt; else the words) with no "The article says"
line; a titled claim without one shows its text plain, unquoted. The same
in both exports. An added citation's card wears the Archilyzer mark under
its number box, as wide as the box (a one-digit box's width with no
number), with "Not in the article" as its tooltip and screen-reader text;
the HTML export puts the mark beside the [n] with the words hidden, and the
Markdown export carries no marker.

The report-site fixture's report has a series, so the two-line heading is
exercised end to end; unit tests cover the no-series path, a titled claim
with no sentence, and the mark's place.

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

Diffstat:
MREPORT.md | 2+-
Mcommon/components/citations/CitationCard.tsx | 42++++++++++++++++++++++++++----------------
Mcommon/lib/report/export.test.ts | 35++++++++++++++++++++++++-----------
Mcommon/lib/report/exportHtml.ts | 70++++++++++++++++++++++++++++++++++++----------------------------------
Mcommon/lib/report/exportMarkdown.ts | 48+++++++++++++++++++++---------------------------
Mcommon/lib/report/schema.ts | 2+-
Mcommon/lib/report/views.test.ts | 22----------------------
Mcommon/lib/report/views.ts | 58++++------------------------------------------------------
Mexport/app/components/reports/ReportArticle.tsx | 77+++++++++++++++++++++++++++++++++++++----------------------------------------
Mexport/app/components/reports/parts.tsx | 10+++++-----
Mexport/app/lib/reports.test.ts | 67++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mexport/e2e-report/report-site.spec.ts | 29+++++++++++++++++++----------
Mexport/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json | 6+++---
Mexport/fixtures/report-site/public/m/demo-podcast/ep-042/610.50-628.00/moment.json | 6+++---
Mexport/fixtures/report-site/public/m/demo-social/1234567890/moment.json | 2+-
Mexport/fixtures/report-site/public/reports/demo-factcheck/page.json | 1+
Mexport/fixtures/report-site/public/reports/index.json | 1+
Mexport/fixtures/report-site/source/demo-factcheck/report.json | 1+
Mexport/fixtures/report-site/source/demo-factcheck/report.v1.json | 1+
19 files changed, 231 insertions(+), 249 deletions(-)

diff --git a/REPORT.md b/REPORT.md @@ -18,7 +18,7 @@ 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 heads the report's page in place of its title, is shown on its own line above the title, in the accent colour, in place of the kind's label, in the report list, 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, at the head of the report's page, its exports and the report list (where a report with no series shows its kind instead), and is joined to the title 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>)`. | diff --git a/common/components/citations/CitationCard.tsx b/common/components/citations/CitationCard.tsx @@ -198,9 +198,11 @@ 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. +// it (`origin: "added"`): the project's mark, never words on screen (the +// words, e.g. "Not in the article", are its tooltip and what a screen reader +// says). A card wears it under its number box, as wide as the box (with no +// number, as wide as a one-digit box); a reference entry or a preview, inline +// after the kind. 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)} />; } @@ -221,6 +223,7 @@ export function CitationCard({ const Icon = KIND_ICON[c.kind]; const { title, meta } = heading(c); const showNumber = variant !== "reference" && c.number !== undefined; + const markUnder = added && variant === "card"; const postText = c.kind === "post" && c.text && c.text.trim() !== c.quote.trim() ? c.text : null; return ( <div @@ -233,26 +236,33 @@ 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"> - {c.number} + {(showNumber || markUnder) && ( + <span className="mt-px inline-flex shrink-0 flex-col items-stretch gap-1"> + {showNumber && ( + <span className="rounded bg-brand-soft px-1.5 font-mono text-xs font-semibold leading-5 text-brand-strong"> + {c.number} + </span> + )} + {markUnder && ( + <span + data-citation-added="" + title={addedLabel} + // Sized by the number box, never sizing it: no width of its own + // (the svg would bring 300px) and at least the column's. + className={cn("block opacity-80", showNumber ? "w-0 min-w-full" : "w-[calc(1ch+0.75rem)] font-mono text-xs")} + > + <BrandMark palette={ICON_PALETTES.archilyzer} className="block h-auto w-full" /> + <span className="sr-only">{addedLabel}</span> + </span> + )} </span> )} <div className="min-w-0 flex-1"> <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" && ( + {added && !markUnder && ( <span data-citation-added="" title={addedLabel} className="inline-flex items-center"> <AddedMark className="size-3" /> <span className="sr-only">{addedLabel}</span> diff --git a/common/lib/report/export.test.ts b/common/lib/report/export.test.ts @@ -102,15 +102,15 @@ test("the HTML export is deterministic, holds no script, and every image is a da assert.doesNotMatch(html, /<link |@import|url\(/); }); -test("the heading: the series (else the title), the dates and revision, the document as a cited line", () => { +test("the heading: the series and the title (or the title alone), the dates and revision, the document as a cited line", () => { const html = reportExportHtml(view, opts); assert.match( html, - /<header class="report-head"><h1>Demo Checks<\/h1>\n<p class="dates" data-report-dates="">2026-10-01 · updated 2026-10-04<\/p>\n<p class="subject" data-subject-source="s0" style="border-left-color:#aa3300"><a href="https:\/\/example\.org\/a">An article<\/a> <span class="meta">— <span style="white-space:nowrap">A\. Writer<\/span> · <span style="white-space:nowrap">Example Gazette<\/span><\/span><\/p>\n<p class="subtitle">Two claims, tested<\/p><\/header>/, + /<header class="report-head"><h1><span class="series" data-report-series="">Demo Checks<\/span> <span class="title">A demo fact-check<\/span><\/h1>\n<p class="dates" data-report-dates="">2026-10-01 · updated 2026-10-04<\/p>\n<p class="subject" data-subject-source="s0" style="border-left-color:#aa3300"><a href="https:\/\/example\.org\/a">An article<\/a> <span class="meta">— <span style="white-space:nowrap">A\. Writer<\/span> · <span style="white-space:nowrap">Example Gazette<\/span><\/span><\/p>\n<p class="subtitle">Two claims, tested<\/p><\/header>/, ); assert.match(html, /<title>Demo Checks: A demo fact-check<\/title>/, "the title stays the document's name"); assert.doesNotMatch(html, /data-report-attribution|data-report-byline|Under review|Fact-check by|class="eyebrow"/); - // No series: the title is the heading. + // No series: the title alone. assert.match(reportExportHtml({ ...view, series: undefined }, opts), /<h1>A demo fact-check<\/h1>/); // No subject: no cited line. assert.doesNotMatch(reportExportHtml({ ...view, subject: undefined }, opts), /class="subject"/); @@ -133,7 +133,8 @@ test("the three tiers: the quick take, what the check found, every claim from ho const at = order.map((m) => html.indexOf(m)); assert.ok(at.every((i) => i >= 0), JSON.stringify(at)); assert.deepEqual([...at].sort((a, b) => a - b), at, "in the page's order"); - assert.match(html, /<div class="tier" data-tier-marker="1"><span class="dots" aria-hidden="true"><i class="on"><\/i><i class=""><\/i><i class=""><\/i><\/span><span class="min">1 min<\/span>/); + assert.match(html, /<div class="tier" data-tier-marker="1" aria-hidden="true"><span class="dots"><i class="on"><\/i><i class=""><\/i><i class=""><\/i><\/span><span class="rule"><\/span><\/div>/); + assert.doesNotMatch(html, /\d+ min\b/); assert.match(html, /<a href="#found">What the check found<\/a> · <a href="#claims">Every claim<\/a> · <a href="#references">References<\/a>/); // Grouped in FOUND_VERDICT_ORDER, each row its title, gist and flag, to the claim. const groups = [...html.matchAll(/data-found-group="([A-Z_]+)"/g)].map((m) => m[1]); @@ -152,13 +153,22 @@ test("a claim: its flag, the subject's sentence on the accent rail with no link, assert.match(claim, /data-verdict="CONTRADICTED"[^]*<span class="flag" data-claim-flag=""><svg class="mark"[^>]*>[^]*<\/svg>No source given<\/span><h3>One<\/h3>/); assert.match(claim, /<figure class="sentence" data-source-sentence="s1" style="border-left-color:#aa3300">/); assert.doesNotMatch(claim, /from <a href="#source-s0">/, "the rail says whose sentence it is"); + // The document's sentence stands for the claim: no paraphrase beside it. + assert.doesNotMatch(claim, /Claim one\.|data-claim-text|says:/); + // Without a sentence, a titled claim's text follows plain, unquoted. + const noSentence = structuredClone(view); + delete noSentence.sections[0].claims[0].sourceQuote; + const plainHtml = reportExportHtml(noSentence, opts); + assert.match(plainHtml, /<h3>One<\/h3><\/div>\n<p class="claim-text" data-claim-text="">Claim one\.<\/p>/); + assert.match(reportExportMarkdown(noSentence, opts), /^### One\n\n(?:.*\n\n)?Claim one\.\n/m); + assert.doesNotMatch(reportExportMarkdown(view, opts), /Claim one\./); assert.match(html, /<p class="subject" data-subject-source="s0" style="border-left-color:#aa3300">/, "the heading's cited line wears the same rail"); // Added first, marked; the subject's own folded under "In the article". - assert.match(claim, /<li data-evidence="v1"><a href="#c-v1">\[1\]<\/a> <span class="added" data-citation-added=""><svg class="mark"[^]*?<\/svg>Not in the article<\/span> Video/); + assert.match(claim, /<li data-evidence="v1"><a href="#c-v1">\[1\]<\/a> <span class="added" data-citation-added="" title="Not in the article"><svg class="mark"[^]*?<\/svg><span class="sr">Not in the article<\/span><\/span> Video/); assert.match(claim, /<details class="given" data-subject-evidence=""><summary>In the article \(1\)<\/summary><ul class="evidence"><li data-evidence="a1">/); assert.match(reportExportHtml(view, { ...opts, print: true }), /<details class="given" data-subject-evidence="" open>/, "printed open"); // The reference list marks added evidence too. - assert.match(html, /<li id="c-v1"[^>]*><p class="quote"><q>one \* two<\/q><\/p><p class="meta"><span class="added"/); + assert.match(html, /<li id="c-v1"[^>]*><p class="quote"><span class="added" data-citation-added=""[^]*?<\/span><\/span> <q>one \* two<\/q><\/p>/); }); test("verdicts, inline markers [n] to the reference list, and references with every link", () => { @@ -212,23 +222,26 @@ test("the Markdown export: the heading lines, `label [n]` citations, numbered re assert.equal(reportExportMarkdown(view, opts), md); assert.ok( md.startsWith( - "# Demo Checks\n\n2026-10-01 · updated 2026-10-04\n\n" + + "**Demo Checks**\n\n# A demo fact-check\n\n2026-10-01 · updated 2026-10-04\n\n" + "[An article](https://example.org/a) — A. Writer · Example Gazette\n\n*Two claims, tested*\n", ), md.slice(0, 300), ); + assert.ok(reportExportMarkdown({ ...view, series: undefined }, opts).startsWith("# A demo fact-check\n\n2026-10-01"), "no series: the title alone"); assert.match(md, /It starts here \[1\]\./); assert.match(md, /See this \[3\], \*\*bold\*\*/); assert.match(md, /^### One$/m); assert.match(md, /^\*\*Verdict: Contradicted\*\* \*\[No source given\]\*$/m); - // The tiers as plain lines; what the check found by verdict; how it was checked. - assert.deepEqual([...md.matchAll(/^— (\d+) min —$/gm)].map((m) => m[1]), ["1", "1", "1"]); + // Each tier after a rule (and the footer after one); what the check found by verdict; how it was checked. + assert.equal([...md.matchAll(/^---$/gm)].length, 4); + assert.doesNotMatch(md, /\d+ min\b/); assert.match(md, /## What the check found\n\n### Contradicted \(1\)\n\n- One — The recording says otherwise\. \*\[No source given\]\*\n/); assert.match(md, /## Every claim, with its evidence\n\n\*\*How it was checked\*\*\n\nEach quote was checked against the recording\.\n/); // Evidence by origin: the added marked in words, the subject's under its own line. - assert.match(md, /Evidence:\n\n- \[1\] Not in the article: Video, /); + assert.match(md, /Evidence:\n\n- \[1\] Video, /); + assert.doesNotMatch(md, /Not in the/, "the Markdown carries no added marker"); assert.match(md, /In the article:\n\n- \[4\] Audio, /); - assert.match(md, /^1\. “one \\\* two” \n Not in the article \n/m); + assert.match(md, /^1\. “one \\\* two” \n Video · /m); assert.doesNotMatch(reportExportMarkdown({ ...view, kind: "sweep" }, opts), /What the check found/); const refs = [...md.matchAll(/^(\d+)\. “(.*)”/gm)].map((m) => `${m[1]}:${m[2]}`); assert.deepEqual(refs, ["1:one \\* two", "2:four", "3:five", "4:two", "5:three"]); diff --git a/common/lib/report/exportHtml.ts b/common/lib/report/exportHtml.ts @@ -10,16 +10,17 @@ // answers — a data: URI for the one-file export, a relative path in the pack. // Clips are LINKED, never inlined (the pack plays them from its media/). // -// What it holds, as the site's report page has it: the heading (the series, -// else the title; one line of dates and the revision; the document under -// review as a cited line on its colour's rail; the subtitle), then the three tiers, each -// opened by a marker (depth dots, minutes to read): the quick take (the +// What it holds, as the site's report page has it: the heading (the series +// on one line and the title on the next, or the title alone; one line of +// dates and the revision; the document under review as a cited line on its +// colour's rail; the subtitle), then the three tiers, each opened by a +// marker (a hairline and depth dots): the quick take (the // tally, the summary, jump links); what the check found (a fact-check's // claims by verdict, a line each with its gist and flag); and every claim // with its evidence, opening with how it was checked — each claim its // verdict and flag pill, the document's sentence on its rail, the findings // with numbered markers, and the evidence: what the report added first -// (the project's mark, "Not in the article"), then the rest, then what the +// (the project's mark beside its number), then the rest, then what the // document gave itself under "In the article (n)" (a <details>, printed // open). Then the documents quoted with their archive links, the numbered // references (quote, speaker, date, record, the original at its time, the @@ -39,7 +40,6 @@ import { reportFullTitle, reportPagePath, reportRevisionLabel, - reportTierMinutes, sourceAnchor, spanLabel, subjectMetaParts, @@ -278,6 +278,8 @@ main{max-width:46rem;margin:0 auto;padding:2rem 1rem 3rem} a{color:var(--accent);text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px;overflow-wrap:anywhere} h1,h2,h3,h4{line-height:1.25;margin:0} h1{font-size:2rem;font-weight:600;letter-spacing:-.01em;overflow-wrap:anywhere} +h1 .series{display:block;color:var(--accent)} +h1 .title{display:block;font-weight:400} h2{font-size:1.5rem;font-weight:600;margin-top:2.5rem;padding-top:1rem;border-top:1px solid var(--border)} h3{font-size:1.15rem;font-weight:600} h4,h5,h6{font-size:1rem;font-weight:600;margin:1rem 0 .25rem} @@ -300,7 +302,6 @@ header.report-head{padding-bottom:1.25rem;border-bottom:1px solid var(--border)} .tier .dots{display:inline-flex;gap:.25rem} .tier .dots i{display:inline-block;width:.4rem;height:.4rem;border-radius:50%;border:1px solid var(--muted)} .tier .dots i.on{background:var(--accent);border-color:var(--accent)} -.tier .min{font:.75rem/1 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)} .tier .rule{flex:1;height:1px;background:var(--border)} .tier+h2{margin-top:.5rem;padding-top:0;border-top:0} h2.section{font-size:1.3rem} @@ -313,7 +314,8 @@ h2.section{font-size:1.3rem} .method{margin:.75rem 0} .flag,.added{display:inline-flex;align-items:center;gap:.35rem;white-space:nowrap} .flag{border:1px solid color-mix(in srgb,var(--accent) 40%,#fff);background:color-mix(in srgb,var(--accent) 8%,#fff);color:var(--accent);border-radius:999px;padding:.05rem .6rem;font-size:.78rem;font-weight:500} -.added{font:.7rem/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;text-transform:uppercase;letter-spacing:.12em;color:var(--muted)} +.added{opacity:.8;vertical-align:-.1em} +.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap} svg.mark{flex:none;border-radius:22%} details.given{margin:.4rem 0 0} details.given summary{cursor:pointer;font:.8rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)} @@ -326,8 +328,7 @@ nav.toc ol{margin:.5rem 0;padding-left:1.4rem} .claim{margin:1.25rem 0;padding:1rem 1.1rem;border:1px solid var(--border);border-radius:8px} .claim-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:.4rem .7rem} .claim-head h3{flex:1 1 15rem} -.says{color:var(--muted);font-size:.95rem} -.says q{color:var(--fg)} +.claim-text{color:var(--muted);font-size:.95rem} figure.sentence{margin:.75rem 0;padding-left:.75rem;border-left:4px solid var(--border)} figure.sentence img{display:block;border:1px solid var(--border);border-radius:4px;background:#fff;break-inside:avoid} figure.sentence figcaption{font-size:.85rem;color:var(--muted);margin-top:.3rem} @@ -415,14 +416,15 @@ function link(href: string, text?: string): string { } // Evidence the report's author found (`origin: "added"`): the project's mark -// (the site's AddedMark, as a static SVG) and the words. +// alone (the site's AddedMark, as a static SVG), beside the citation's number; +// the words are its tooltip and what a screen reader says. const ADDED_MARK = markSvg(ICON_PALETTES.archilyzer).replace( "<svg ", '<svg class="mark" width="12" height="12" aria-hidden="true" focusable="false" ', ); function addedTag(label: string): string { - return `<span class="added" data-citation-added="">${ADDED_MARK}${escapeHtml(label)}</span>`; + return `<span class="added" data-citation-added="" title="${escapeHtml(label)}">${ADDED_MARK}<span class="sr">${escapeHtml(label)}</span></span>`; } function flagPill(flag: string): string { @@ -476,8 +478,7 @@ function reference(c: CitationView, opts: ReportExportHtmlOptions, addedLabel: s const extra = [c.label, c.note].filter(Boolean).map((x) => `<p class="meta">${escapeHtml(x!)}</p>`).join(""); return ( `<li id="${escapeHtml(citationAnchor(c.id))}" value="${c.number ?? ""}" data-reference="${escapeHtml(c.id)}">` + - `<p class="quote"><q>${escapeHtml(c.quote)}</q></p>` + - (c.origin === "added" ? `<p class="meta">${addedTag(addedLabel)}</p>` : "") + + `<p class="quote">${c.origin === "added" ? `${addedTag(addedLabel)} ` : ""}<q>${escapeHtml(c.quote)}</q></p>` + `<p class="meta">${meta.join(" · ")}</p>` + extra + (links.length > 0 ? `<p class="links">${links.join("<br>")}</p>` : "") + @@ -497,7 +498,6 @@ function evidenceItem(c: CitationView, addedLabel: string): string { type ClaimContext = { view: ReportPageView; - subjectLabel: string; subjectNoun: string; cite: (id: string) => CiteRef | undefined; opts: ReportExportHtmlOptions; @@ -511,9 +511,12 @@ function claimHtml(claim: ClaimView, x: ClaimContext): string { `<div class="claim-head">${claim.verdict ? verdictChip(view, claim.verdict) : ""}` + `${claim.flag ? flagPill(claim.flag) : ""}<h3>${escapeHtml(claim.title ?? claim.text)}</h3></div>`, ); - if (claim.title) parts.push(`<p class="says">${escapeHtml(x.subjectLabel)}: <q>${escapeHtml(claim.text)}</q></p>`); + // The document's own sentence (its still, else its verbatim words) when the + // claim cites one; else the claim as the report states it, plain. const sentence = claim.sourceQuote ? view.citations[claim.sourceQuote] : undefined; - if (sentence?.kind === "source") { + if (sentence?.kind !== "source") { + if (claim.title) parts.push(`<p class="claim-text" data-claim-text="">${escapeHtml(claim.text)}</p>`); + } else { // On a rail in the document's colour; a sentence of the document under // review needs no link to it (the rail says whose it is). const isSubject = sentence.sourceId === view.subject; @@ -563,13 +566,10 @@ function railStyle(accent: string | undefined): string { } // Where a tier of the page begins: a hairline with, at its left, how deep it -// goes (one, two or three dots) and how long it takes to read. -function tierMarker(depth: 1 | 2 | 3, minutes: number): string { +// goes (one, two or three dots). Decoration only. +function tierMarker(depth: 1 | 2 | 3): string { const dots = [1, 2, 3].map((i) => `<i class="${i <= depth ? "on" : ""}"></i>`).join(""); - return ( - `<div class="tier" data-tier-marker="${depth}"><span class="dots" aria-hidden="true">${dots}</span>` + - `<span class="min">${Math.max(1, minutes)} min</span><span class="rule" aria-hidden="true"></span></div>` - ); + return `<div class="tier" data-tier-marker="${depth}" aria-hidden="true"><span class="dots">${dots}</span><span class="rule"></span></div>`; } // ─── The document ─── @@ -583,18 +583,20 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt const md = (text: string, headingBase = 3) => markdownToHtml(text, cite, { siteUrl: opts.siteUrl, headingBase }); const subject = view.subject ? view.sources[view.subject] : undefined; const subjectNoun = subject?.kind === "article" ? "article" : "source"; - const subjectLabel = `The ${subjectNoun} says`; const tally = isFactcheck ? verdictTally(view) : []; const groups = isFactcheck ? foundGroups(view) : []; - const minutes = reportTierMinutes(view); const claimCount = view.sections.reduce((n, s) => n + s.claims.length, 0); const pageUrl = siteLink(opts.siteUrl, reportPagePath(view.id)); - const ctx: ClaimContext = { view, subjectLabel, subjectNoun, cite, opts }; - - // The heading, in the site's order: the series (else the title), one line - // of dates and this export's revision, the document under review as a - // cited line on its colour's rail, the subtitle. - const head: string[] = [`<h1>${escapeHtml(view.series ?? view.title)}</h1>`]; + const ctx: ClaimContext = { view, subjectNoun, cite, opts }; + + // The heading, in the site's order: the series on one line and the title + // on the next (or the title alone), one line of dates and this export's + // revision, the document under review as a cited line on its colour's + // rail, the subtitle. + const name = view.series + ? `<span class="series" data-report-series="">${escapeHtml(view.series)}</span> <span class="title">${escapeHtml(view.title)}</span>` + : escapeHtml(view.title); + const head: string[] = [`<h1>${name}</h1>`]; const historyUrl = view.history ? siteLink(opts.siteUrl, view.history.href) : undefined; const revision = opts.footer.revision !== undefined @@ -615,7 +617,7 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt const body: string[] = [`<header class="report-head">${head.join("\n")}</header>`]; // Tier 1, the quick take: the tally, the summary, where to go next. - const quick: string[] = [tierMarker(1, minutes.quick)]; + const quick: string[] = [tierMarker(1)]; if (tally.length > 0) { quick.push( `<p class="label">${plural(claimCount, "claim")} checked</p>` + @@ -634,7 +636,7 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt // Tier 2, what the check found: every ruled claim by verdict, one line each. if (groups.length > 0) { body.push( - `<section id="found" data-report-tier="2">${tierMarker(2, minutes.found ?? 0)}<h2>What the check found</h2>` + + `<section id="found" data-report-tier="2">${tierMarker(2)}<h2>What the check found</h2>` + groups .map( (g) => @@ -654,7 +656,7 @@ export function reportExportHtml(view: ReportPageView, opts: ReportExportHtmlOpt } // Tier 3, every claim with its evidence, opening with how it was checked. - const full: string[] = [tierMarker(3, minutes.claims), `<h2>Every claim, with its evidence</h2>`]; + const full: string[] = [tierMarker(3), `<h2>Every claim, with its evidence</h2>`]; if (view.method) full.push(`<div class="method" data-report-method=""><h3 class="label">How it was checked</h3>${md(view.method)}</div>`); if (view.sections.length > 1) { full.push( diff --git a/common/lib/report/exportMarkdown.ts b/common/lib/report/exportMarkdown.ts @@ -22,7 +22,6 @@ import { reportDateParts, reportPagePath, reportRevisionLabel, - reportTierMinutes, spanLabel, subjectMetaParts, verdictTally, @@ -87,7 +86,7 @@ function citationDate(c: CitationView): string | undefined { return dateLabel(c.date ?? (c.kind === "video" || c.kind === "audio" || c.kind === "post" ? c.record.date : undefined)); } -function referenceLines(c: CitationView, siteUrl: string | undefined, addedLabel: string): string[] { +function referenceLines(c: CitationView, siteUrl: string | undefined): string[] { const meta = [ CITATION_KIND_LABELS[c.kind], c.speaker ? mdText(c.speaker) : null, @@ -96,7 +95,6 @@ function referenceLines(c: CitationView, siteUrl: string | undefined, addedLabel c.verification?.quoteScore !== undefined ? `quote match ${Math.round(c.verification.quoteScore * 100)}%` : null, ].filter(Boolean); const out = [`${c.number ?? "-"}. “${mdText(c.quote)}”`]; - if (c.origin === "added") out.push(` ${addedLabel}`); out.push(` ${meta.join(" · ")}`); for (const x of [c.label, c.note]) if (x) out.push(` ${mdText(x)}`); const link = (label: string, url: string) => out.push(` ${label}: <${url}>`); @@ -127,32 +125,28 @@ function referenceLines(c: CitationView, siteUrl: string | undefined, addedLabel return out.map((l, i, a) => (i < a.length - 1 ? `${l} ` : l)); } -// A tier's start, as a plain line: `— 6 min —`. -function tierLine(minutes: number): string { - return `— ${Math.max(1, minutes)} min —`; -} - -function evidenceLine(c: CitationView, addedLabel: string): string { - return `- [${c.number ?? "?"}] ${c.origin === "added" ? `${addedLabel}: ` : ""}${CITATION_KIND_LABELS[c.kind]}, ${citationWhere(c)}: “${mdText(c.quote)}”`; +function evidenceLine(c: CitationView): string { + return `- [${c.number ?? "?"}] ${CITATION_KIND_LABELS[c.kind]}, ${citationWhere(c)}: “${mdText(c.quote)}”`; } function claimLines( claim: ClaimView, view: ReportPageView, - subjectLabel: string, subjectNoun: string, siteUrl: string | undefined, ): string[] { - const addedLabel = `Not in the ${subjectNoun}`; const out: string[] = [`### ${mdText(claim.title ?? claim.text)}`, ""]; const marks = [ claim.verdict ? `**Verdict: ${mdText(view.verdicts[claim.verdict].label)}**` : null, claim.flag ? `*[${mdText(claim.flag)}]*` : null, ].filter(Boolean); if (marks.length > 0) out.push(marks.join(" "), ""); - if (claim.title) out.push(`${subjectLabel}: “${mdText(claim.text)}”`, ""); + // The document's own sentence when the claim cites one; else the claim as + // the report states it, plain. const sentence = claim.sourceQuote ? view.citations[claim.sourceQuote] : undefined; - if (sentence?.kind === "source") { + if (sentence?.kind !== "source") { + if (claim.title) out.push(mdText(claim.text), ""); + } else { out.push(`> “${mdText(sentence.quote)}” [${sentence.number ?? "?"}]`); if (sentence.sourceId !== view.subject) out.push(">", `> — from *${mdText(sentence.sourceTitle)}*`); out.push(""); @@ -167,8 +161,8 @@ function claimLines( // the document under review gave itself. const shown = [...evidence.filter((c) => c.origin === "added"), ...evidence.filter((c) => c.origin === undefined)]; const given = evidence.filter((c) => c.origin === "subject"); - if (shown.length > 0) out.push("Evidence:", "", ...shown.map((c) => evidenceLine(c, addedLabel)), ""); - if (given.length > 0) out.push(`In the ${subjectNoun}:`, "", ...given.map((c) => evidenceLine(c, addedLabel)), ""); + if (shown.length > 0) out.push("Evidence:", "", ...shown.map((c) => evidenceLine(c)), ""); + if (given.length > 0) out.push(`In the ${subjectNoun}:`, "", ...given.map((c) => evidenceLine(c)), ""); return out; } @@ -176,14 +170,14 @@ export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOpt const out: string[] = []; const subject = view.subject ? view.sources[view.subject] : undefined; const subjectNoun = subject?.kind === "article" ? "article" : "source"; - const subjectLabel = `The ${subjectNoun} says`; const pageUrl = siteLink(opts.siteUrl, reportPagePath(view.id)); const isFactcheck = view.kind === "factcheck"; - const minutes = reportTierMinutes(view); - // The series (else the title), one line of dates and this export's - // revision, the document under review as a cited line, the subtitle. - out.push(`# ${mdText(view.series ?? view.title)}`, ""); + // The series on its own line and the title as the heading (or the title + // alone), one line of dates and this export's revision, the document under + // review as a cited line, the subtitle. + if (view.series) out.push(`**${mdText(view.series)}**`, ""); + out.push(`# ${mdText(view.title)}`, ""); const historyUrl = view.history ? siteLink(opts.siteUrl, view.history.href) : undefined; const revision = opts.footer.revision !== undefined @@ -199,8 +193,8 @@ export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOpt } if (view.subtitle) out.push(`*${mdText(view.subtitle)}*`, ""); - // Tier 1: the quick take. - out.push(tierLine(minutes.quick), ""); + // Tier 1: the quick take, after a rule (each tier opens with one). + out.push("---", ""); const tally = isFactcheck ? verdictTally(view) : []; if (tally.length > 0) { const claims = view.sections.reduce((n, s) => n + s.claims.length, 0); @@ -214,7 +208,7 @@ export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOpt // Tier 2: what the check found (a fact-check with verdicts). const groups = isFactcheck ? foundGroups(view) : []; if (groups.length > 0) { - out.push(tierLine(minutes.found ?? 0), "", "## What the check found", ""); + out.push("---", "", "## What the check found", ""); for (const g of groups) { out.push(`### ${mdText(view.verdicts[g.verdict].label)} (${g.claims.length})`, ""); for (const c of g.claims) { @@ -225,12 +219,12 @@ export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOpt } // Tier 3: every claim, with its evidence. - out.push(tierLine(minutes.claims), "", "## Every claim, with its evidence", ""); + out.push("---", "", "## Every claim, with its evidence", ""); if (view.method) out.push("**How it was checked**", "", citedMarkdownToPlain(view.method, view, opts.siteUrl).trim(), ""); for (const s of view.sections) { out.push(`## ${mdText(s.title)}`, ""); if (s.body) out.push(citedMarkdownToPlain(s.body, view, opts.siteUrl).trim(), ""); - for (const c of s.claims) out.push(...claimLines(c, view, subjectLabel, subjectNoun, opts.siteUrl)); + for (const c of s.claims) out.push(...claimLines(c, view, subjectNoun, opts.siteUrl)); } const sources = Object.values(view.sources); @@ -249,7 +243,7 @@ export function reportExportMarkdown(view: ReportPageView, opts: ReportExportOpt const refs = orderedCitations(view); if (refs.length > 0) { out.push("## References", ""); - for (const c of refs) out.push(...referenceLines(c, opts.siteUrl, `Not in the ${subjectNoun}`), ""); + for (const c of refs) out.push(...referenceLines(c, opts.siteUrl), ""); } out.push("---", "", reportExportFooterLine(opts.footer)); diff --git a/common/lib/report/schema.ts b/common/lib/report/schema.ts @@ -101,7 +101,7 @@ 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 heads the report's page in place of its title, is shown on its own line above the title, in the accent colour, in place of the kind's label, in the report list, 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, at the head of the report's page, its exports and the report list (where a report with no series shows its kind instead), and is joined to the title 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>)`.", diff --git a/common/lib/report/views.test.ts b/common/lib/report/views.test.ts @@ -16,9 +16,6 @@ import { citedInViews, evidenceClipPath, foundGroups, - readingMinutes, - reportTierMinutes, - wordCount, momentViewPath, orderedCitations, reportAssetPath, @@ -371,25 +368,6 @@ test("a citation's origin rides its view; a report with no subject carries none" 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."; diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts @@ -267,8 +267,8 @@ export type ReportPageView = { version: typeof REPORT_VIEWS_VERSION; id: string; kind: ReportKind; - // Its own line above the title, in place of the kind's label; `<series>: <title>` - // where one line names the report (reportFullTitle). + // Its own line above the title; `<series>: <title>` where one line names + // the report (reportFullTitle). series?: string; title: string; subtitle?: string; @@ -306,8 +306,8 @@ export type VerdictCount = { verdict: Verdict; count: number }; export type ReportIndexEntry = { id: string; kind: ReportKind; - // Its own line above the title, in place of the kind's label; `<series>: <title>` - // where one line names the report (reportFullTitle). + // Its own line above the title; `<series>: <title>` where one line names + // the report (reportFullTitle). series?: string; title: string; subtitle?: string; @@ -686,56 +686,6 @@ export function foundGroups(view: Pick<ReportPageView, "sections">): FoundGroup[ ); } -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), - ]), - ), - }); -} - // 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/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx @@ -10,7 +10,6 @@ import { orderedCitations, reportDateParts, reportRevisionLabel, - reportTierMinutes, sourceAnchor, verdictTally, type CitationView, @@ -18,17 +17,20 @@ import { type ReportPageView, type SourceCitationView, } from "yt-dlp-transcript-common/lib/report/views"; -import { ArchiveList, SourceBlock, SubjectCard, textLink } from "./parts"; +import { ArchiveList, ReportName, SourceBlock, SubjectCard, textLink } from "./parts"; // ONE REPORT, from its view (common/lib/report/views.ts): the header (its -// series, else its title; one line of dates and the revision, linked to its -// history; the document under review as a card on its colour's rail, with its -// archive links; the subtitle), a fact-check's tally, the summary, the sections and their claims — each claim -// 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. +// series and its title, or the title alone; one line of dates and the +// revision, linked to its history; the document under review as a card on its +// colour's rail, with its archive links; the subtitle), the three tiers each +// opened by a depth marker — the quick take (a fact-check's tally, the +// summary), what the check found, and every claim with its evidence: each +// claim 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. const proseClass = "text-[0.95rem] leading-relaxed text-foreground"; @@ -106,14 +108,12 @@ function FlagPill({ flag, className }: { flag: string; className?: string }) { } // 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"}`; +// goes (one, two or three dots filled in the accent). Decoration only: the +// tier's own label or heading names it. +function TierMarker({ depth }: { depth: 1 | 2 | 3 }) { return ( - <div data-tier-marker={depth} className="flex items-center gap-2.5"> - <span aria-hidden="true" className="flex items-center gap-1"> + <div data-tier-marker={depth} aria-hidden="true" className="flex items-center gap-2.5"> + <span className="flex items-center gap-1"> {[1, 2, 3].map((i) => ( <span key={i} @@ -126,28 +126,26 @@ function TierMarker({ depth, minutes, spoken }: { depth: 1 | 2 | 3; minutes: num /> ))} </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> + <span className="h-px flex-1 bg-border" /> </div> ); } +// A claim: its verdict, flag and title; the document's own sentence (its +// still, else its verbatim words) when the claim cites one, else the claim as +// the report states it, plain; the findings; the evidence. 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 cited = claim.sourceQuote ? view.citations[claim.sourceQuote] : undefined; + const sentence = cited?.kind === "source" ? cited : undefined; const evidence = claim.citations .filter((id) => id !== claim.sourceQuote) .map((id) => view.citations[id]) @@ -174,13 +172,14 @@ function Claim({ </a> </h3> </div> - {claim.title && ( - <p className="text-sm text-muted-foreground"> - {subjectLabel}: <q className="text-foreground">{claim.text}</q> - </p> - )} - {sentence?.kind === "source" && ( + {sentence ? ( <SourceSentence c={sentence} archives={claim.archives} isSubject={sentence.sourceId === view.subject} /> + ) : ( + claim.title && ( + <p data-claim-text="" className="text-sm text-muted-foreground"> + {claim.text} + </p> + ) )} {claim.findings && ( <CitedMarkdown className={proseClass}> @@ -221,7 +220,6 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { const references = orderedCitations(view); const subject = view.subject ? view.sources[view.subject] : undefined; const subjectNoun = subject?.kind === "article" ? "article" : "source"; - const subjectLabel = `The ${subjectNoun} says`; const dates = reportDateParts(view); const claimCount = view.sections.reduce((n, s) => n + s.claims.length, 0); // The report as files (its exports), then its citations as data: only what @@ -231,7 +229,6 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { 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" }, @@ -246,12 +243,12 @@ 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"> - {/* The report's name (its series, else its title: with a series, the - card below names the document), one line of dates and the + {/* The report's name (its series on one line, its title on the next; + without a series, the title alone), one line of dates and the revision, the document under review, the subtitle. */} <header className="flex flex-col gap-3"> <h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground [overflow-wrap:anywhere] sm:text-4xl"> - {view.series ?? view.title} + <ReportName series={view.series} title={view.title} /> </h1> {(dates.length > 0 || view.history) && ( <p data-report-dates="" className="font-mono text-xs text-muted-foreground"> @@ -274,7 +271,7 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { {/* 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" /> + <TierMarker depth={1} /> {tally.length > 0 && ( <div className="flex flex-col gap-2"> <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"> @@ -303,7 +300,7 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { {/* 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" /> + <TierMarker depth={2} /> <h2 id="found-heading" className="font-display text-2xl font-semibold tracking-tight text-foreground"> What the check found </h2> @@ -336,7 +333,7 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { {/* 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" /> + <TierMarker depth={3} /> <h2 className="font-display text-2xl font-semibold tracking-tight text-foreground"> Every claim, with its evidence </h2> @@ -378,7 +375,7 @@ export default function ReportArticle({ view }: { view: ReportPageView }) { </CitedMarkdown> )} {s.claims.map((c) => ( - <Claim key={c.id} claim={c} view={view} subjectLabel={subjectLabel} subjectNoun={subjectNoun} /> + <Claim key={c.id} claim={c} view={view} subjectNoun={subjectNoun} /> ))} </section> ))} diff --git a/export/app/components/reports/parts.tsx b/export/app/components/reports/parts.tsx @@ -17,11 +17,11 @@ 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, in the index and on the history page. 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). +// A report's name as a heading, on its page, in the index and on the history +// page. 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. export function ReportName({ series, title }: { series?: string; title: string }) { if (!series) return <>{title}</>; return ( diff --git a/export/app/lib/reports.test.ts b/export/app/lib/reports.test.ts @@ -82,10 +82,14 @@ 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 })); - // the header: the title (no series), one muted line of dates and the - // revision linked to its history, the document under review as a card, the subtitle + // the header: the series on one line and the title on the next, one muted + // line of dates and the revision linked to its history, the document under + // review as a card, the subtitle const header = html.slice(html.indexOf("<header"), html.indexOf("</header>")); - assert.match(header, /<h1[^>]*>Checking an example article<\/h1>/); + assert.match( + header, + /<h1[^>]*><span data-report-series="" class="block font-semibold text-brand">Demo Checks<\/span> <span class="block font-normal">Checking an example article<\/span><\/h1>/, + ); assert.match( header, /<\/h1><p data-report-dates=""[^>]*>2026-10-01 · updated 2026-10-04 · <a href="\/reports\/demo-factcheck\/history\/" data-report-revision="2"[^>]*>revision 2<\/a><\/p><div id="source-s0" data-subject-source="s0"/, @@ -112,8 +116,11 @@ test("the report page: header, tally, sections and claims, inline cites, referen assert.match(html, /data-verdict-tally=""/); for (const v of ["CORROBORATED", "PARTLY", "CONTRADICTED", "UNTESTABLE"]) assert.match(html, new RegExp(`data-verdict="${v}"`)); assert.match(html, /<section id="bridge"/); - // a claim with a title: the title heads it, the claim's text follows as what the article says - assert.match(html, /<article id="claim-1"[^]*?<h3[^>]*><a href="#claim-1"[^>]*>“He opened it himself”<\/a><\/h3>[^]*?The article says: <q[^>]*>He opened the bridge himself in 2018.<\/q>/); + // a claim with a title and the article's sentence: the title heads it, the + // sentence's still follows (its words its alt), with no paraphrase beside it + const titled = html.slice(html.indexOf('<article id="claim-1"'), html.indexOf("</article>", html.indexOf('<article id="claim-1"'))); + assert.match(titled, /<h3[^>]*><a href="#claim-1"[^>]*>“He opened it himself”<\/a><\/h3><\/div><figure data-source-sentence="a01"[^>]*><img src="[^"]*a01.png" alt="He opened the bridge himself in 2018."/); + assert.doesNotMatch(titled, /says:|<q[^>]*>He opened the bridge|data-claim-text/); // a claim without one is headed by its text assert.match(html, /<h3[^>]*><a href="#claim-2"[^>]*>He went back to the bridge every year.<\/a>/); assert.match(html, /<article id="claim-1"/); @@ -128,7 +135,7 @@ test("the report page: header, tally, sections and claims, inline cites, referen assert.match(html, /href="\/reports\/demo-factcheck\/citations.json" download=""/); assert.match(html, /href="\/reports\/demo-factcheck\/citations.csv" download=""/); const meta = await reportPage.generateMetadata({ params }); - assert.equal(meta.title, "Checking an example article"); + assert.equal(meta.title, "Demo Checks: 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 @@ -136,7 +143,11 @@ test("the report page: header, tally, sections and claims, inline cites, referen 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>/); + // the added card: the mark alone, under its number box, the words its tooltip + assert.match( + claim1, + /<div data-citation="c01"[^>]*><div[^>]*><span class="[^"]*flex-col[^"]*"><span[^>]*>1<\/span><span data-citation-added="" title="Not in the article"[^>]*><svg[^>]*data-brand-mark=""[^]*?<\/svg><span class="sr-only">Not in the article<\/span><\/span><\/span>/, + ); 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"); @@ -153,9 +164,10 @@ test("the report page: header, tally, sections and claims, inline cites, referen 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.match(tier1, /<div data-tier-marker="1" aria-hidden="true"/); + assert.match(tier2, /<div data-tier-marker="2" aria-hidden="true"/); + assert.match(tier3, /<div data-tier-marker="3" aria-hidden="true"/); + assert.doesNotMatch(html, />\s*\d+ min\s*<|\d+ minutes?\b/, "no reading time"); 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]}`); @@ -174,14 +186,14 @@ test("the report page: header, tally, sections and claims, inline cites, referen 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("the report header: a series heads it in place of the title; an edited report says so; no accent, the border's rail", () => { +test("the report header: no series, the title alone; an edited report says so; no accent, the border's rail", () => { const view = fixture.buildFixtureReportView(); const s0 = view.sources.s0; const html = render( React.createElement(ReportArticle, { view: { ...view, - series: "On the Record", + series: undefined, updated: view.published, history: { ...view.history!, current: false }, sources: { ...view.sources, s0: { ...s0, accent: undefined, author: undefined, date: undefined } }, @@ -189,9 +201,9 @@ test("the report header: a series heads it in place of the title; an edited repo }), ); const header = html.slice(html.indexOf("<header"), html.indexOf("</header>")); - // the series is the heading; the title is the card's document, not repeated - assert.match(header, /<h1[^>]*>On the Record<\/h1>/); - assert.ok(!header.includes(view.title), "with a series the report's title is not in the header"); + // no series: the title alone is the heading + assert.match(header, /<h1[^>]*>Checking an example article<\/h1>/); + assert.doesNotMatch(header, /data-report-series/); // no 'updated' when it is the published date; the revision, edited since assert.match(header, /<p data-report-dates=""[^>]*>2026-10-01 · <a [^>]*data-report-revision="2"[^>]*>edited since revision 2<\/a><\/p>/); // without an accent the card's rail is the border colour; only the parts it has @@ -199,6 +211,18 @@ test("the report header: a series heads it in place of the title; an edited repo assert.match(header, /<p[^>]*><span class="whitespace-nowrap">Example Gazette<\/span><\/p>/); }); +test("a titled claim with no sentence of the document: its text follows plain, unquoted and unlabelled", () => { + const view = fixture.buildFixtureReportView(); + const sections = view.sections.map((s) => ({ + ...s, + claims: s.claims.map((c) => (c.id === "claim-1" ? { ...c, sourceQuote: undefined } : c)), + })); + const html = render(React.createElement(ReportArticle, { view: { ...view, sections } })); + const claim = html.slice(html.indexOf('<article id="claim-1"'), html.indexOf("</article>", html.indexOf('<article id="claim-1"'))); + assert.match(claim, /<\/h3><\/div><p data-claim-text=""[^>]*>He opened the bridge himself in 2018.<\/p>/); + assert.doesNotMatch(claim, /data-source-sentence|says:|<q[^>]*>He opened/); +}); + test("a video moment: the clip, quote, verification, cue lines, original link, cited in", async () => { const html = render( await momentPage.default({ params: Promise.resolve({ moment: ["demo-channel", "abc123", "3126.00-3151.00"] }) }), @@ -210,7 +234,7 @@ test("a video moment: the clip, quote, verification, cue lines, original link, c assert.equal([...html.matchAll(/data-in-span=""/g)].length, 2); assert.match(html, /href="https:\/\/media.example.org\/demo-channel\/abc123\?t=3126"[^>]*>Original at 52:06/); assert.match(html, /data-cited-in=""/); - assert.match(html, /href="\/reports\/demo-factcheck\/"[^>]*>Checking an example article/); + assert.match(html, /href="\/reports\/demo-factcheck\/"[^>]*>Demo Checks: Checking an example article/); assert.match(html, /href="\/reports\/demo-factcheck\/#claim-1"/); assert.ok(html.includes("— “He opened it himself”"), "a cited-in entry names the claim by its title"); assert.match(html, /<span>Summary<\/span>/, "a summary citation is named as the summary"); @@ -245,7 +269,7 @@ 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[^>]*><span data-report-series=""[^>]*>Demo Checks<\/span> <span[^>]*>Checking an example article<\/span><\/h1>/); assert.match(html, /data-claim=/); assert.doesNotMatch(html, /data-report-index/); // the site's title is the site header's alone: the report names no attribution @@ -254,7 +278,7 @@ test("a cited site with one report: its home IS the report, and never counts tra // 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"); + assert.equal(home.generateMetadata().title, "Demo Checks: 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 () => { @@ -262,7 +286,8 @@ test("a cited site with more than one report: its home is the index, with no hea const original = fs.readFileSync(file, "utf8"); try { const index = JSON.parse(original); - index.reports = [...index.reports, { ...index.reports[0], id: "demo-second", series: "On the Record", title: "A second report", href: "/reports/demo-second/" }]; + const { series: _series, ...unseried } = index.reports[0]; + index.reports = [unseried, { ...index.reports[0], id: "demo-second", series: "On the Record", title: "A second report", href: "/reports/demo-second/" }]; fs.writeFileSync(file, JSON.stringify(index)); const html = render(await home.default()); assert.match(html, /data-report-index/); @@ -275,12 +300,12 @@ test("a cited site with more than one report: its home is the index, with no hea 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 is its own line above the title, in the accent, in place of the kind's eyebrow. + // A series is its own line above the title, in the accent, with no kind eyebrow. const second = html.slice(html.indexOf('data-report-entry="demo-second"')); 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"); + assert.match(first.slice(0, first.indexOf("</li>")), /Fact-check/, "no series: the kind's eyebrow"); } finally { fs.writeFileSync(file, original); } diff --git a/export/e2e-report/report-site.spec.ts b/export/e2e-report/report-site.spec.ts @@ -37,7 +37,9 @@ 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"); + // The series on one line, the title on the next, both in the heading. + await expect(page.locator("h1")).toHaveText("Demo Checks Checking an example article"); + await expect(page.locator("h1 [data-report-series]")).toHaveText("Demo Checks"); // One line of dates and the revision; then the document under review as a // card on its rail: its title linked to it, author · publisher · date, no label. await expect(page.locator("[data-report-dates]")).toHaveText("2026-10-01 · updated 2026-10-04 · revision 2"); @@ -52,7 +54,7 @@ test("one report: the home page IS the report, and the header links nothing", as await expect(page.locator("[data-report-attribution], [data-report-byline]")).toHaveCount(0); 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).toHaveTitle("Demo Checks: Checking an example article — Demo Reports"); await expect(page.locator("[data-report-index]")).toHaveCount(0); await expect(page.locator("header nav a")).toHaveCount(0); }); @@ -94,7 +96,7 @@ test("the report: verdict chips, the tally, the source's sentence as a still, nu // 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"); + await expect(page).toHaveTitle("Demo Checks: 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"]')); @@ -117,7 +119,7 @@ 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 }) => { +test("three tiers, each marked by its depth; 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"]); @@ -125,7 +127,7 @@ test("three tiers, each marked by its depth and reading time; nothing above the 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(marker).toHaveText(""); } await expect(page.locator('[data-report-tier="1"] details, [data-report-tier="2"] details')).toHaveCount(0); @@ -162,10 +164,17 @@ test("a claim's evidence: what the report added first and marked, the article's 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. + // Added: the project's mark alone, under the card's number box and as wide + // (the words are its tooltip); 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(added).toHaveAttribute("title", "Not in the article"); + const mark = added.locator("svg[data-brand-mark]"); + await expect(mark).toBeVisible(); + const numberBox = (await added.locator("xpath=preceding-sibling::span[1]").boundingBox())!; + const markBox = (await mark.boundingBox())!; + expect(Math.abs(markBox.width - numberBox.width)).toBeLessThan(1); + expect(Math.abs(markBox.x - numberBox.x)).toBeLessThan(1); + expect(markBox.y).toBeGreaterThanOrEqual(numberBox.y + numberBox.height); 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(); @@ -258,9 +267,9 @@ test("the report downloads as HTML, PDF, Markdown and an evidence pack, its cita const body = await res.body(); if (href.endsWith("citations.json")) expect(JSON.parse(body.toString()).format).toBe("archilyzer-citations"); else if (href.endsWith(".csv")) expect(body.toString()).toContain("c01"); - else if (href.endsWith(".html")) expect(body.toString()).toContain("<title>Checking an example article</title>"); + else if (href.endsWith(".html")) expect(body.toString()).toContain("<title>Demo Checks: Checking an example article</title>"); else if (href.endsWith(".pdf")) expect(body.subarray(0, 5).toString()).toBe("%PDF-"); - else if (href.endsWith(".md")) expect(body.toString()).toContain("# Checking an example article"); + else if (href.endsWith(".md")) expect(body.toString()).toContain("**Demo Checks**\n\n# Checking an example article\n"); else expect(body.subarray(0, 4).toString("hex")).toBe("504b0304"); } }); diff --git a/export/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json b/export/fixtures/report-site/public/m/demo-channel/abc123/3126.00-3151.00/moment.json @@ -72,7 +72,7 @@ "claimId": null, "citationId": "c01", "href": "/reports/demo-factcheck/", - "reportTitle": "Checking an example article", + "reportTitle": "Demo Checks: Checking an example article", "number": 1 }, { @@ -81,7 +81,7 @@ "claimId": "claim-1", "citationId": "c01", "href": "/reports/demo-factcheck/#claim-1", - "reportTitle": "Checking an example article", + "reportTitle": "Demo Checks: Checking an example article", "sectionTitle": "The bridge", "claimTitle": "“He opened it himself”", "claimText": "He opened the bridge himself in 2018.", @@ -98,7 +98,7 @@ "claimId": "claim-5", "citationId": "c01", "href": "/reports/demo-factcheck/#claim-5", - "reportTitle": "Checking an example article", + "reportTitle": "Demo Checks: Checking an example article", "sectionTitle": "Moving away", "claimText": "He was at the opening.", "verdict": "CORROBORATED", 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 @@ -51,7 +51,7 @@ "claimId": null, "citationId": "au1", "href": "/reports/demo-factcheck/", - "reportTitle": "Checking an example article", + "reportTitle": "Demo Checks: Checking an example article", "number": 2 }, { @@ -60,7 +60,7 @@ "claimId": "claim-1", "citationId": "au1", "href": "/reports/demo-factcheck/#claim-1", - "reportTitle": "Checking an example article", + "reportTitle": "Demo Checks: Checking an example article", "sectionTitle": "The bridge", "claimTitle": "“He opened it himself”", "claimText": "He opened the bridge himself in 2018.", @@ -77,7 +77,7 @@ "claimId": "claim-2", "citationId": "au1", "href": "/reports/demo-factcheck/#claim-2", - "reportTitle": "Checking an example article", + "reportTitle": "Demo Checks: Checking an example article", "sectionTitle": "The bridge", "claimText": "He went back to the bridge every year.", "verdict": "PARTLY", diff --git a/export/fixtures/report-site/public/m/demo-social/1234567890/moment.json b/export/fixtures/report-site/public/m/demo-social/1234567890/moment.json @@ -25,7 +25,7 @@ "claimId": "claim-3", "citationId": "p01", "href": "/reports/demo-factcheck/#claim-3", - "reportTitle": "Checking an example article", + "reportTitle": "Demo Checks: Checking an example article", "sectionTitle": "Moving away", "claimText": "He has said many times that he would move away.", "verdict": "CONTRADICTED", diff --git a/export/fixtures/report-site/public/reports/demo-factcheck/page.json b/export/fixtures/report-site/public/reports/demo-factcheck/page.json @@ -3,6 +3,7 @@ "version": 1, "id": "demo-factcheck", "kind": "factcheck", + "series": "Demo Checks", "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).", diff --git a/export/fixtures/report-site/public/reports/index.json b/export/fixtures/report-site/public/reports/index.json @@ -5,6 +5,7 @@ { "id": "demo-factcheck", "kind": "factcheck", + "series": "Demo Checks", "title": "Checking an example article", "subtitle": "Four claims about a demo channel, tested against what was said on air", "published": "2026-10-01", diff --git a/export/fixtures/report-site/source/demo-factcheck/report.json b/export/fixtures/report-site/source/demo-factcheck/report.json @@ -3,6 +3,7 @@ "version": 1, "id": "demo-factcheck", "kind": "factcheck", + "series": "Demo Checks", "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).", diff --git a/export/fixtures/report-site/source/demo-factcheck/report.v1.json b/export/fixtures/report-site/source/demo-factcheck/report.v1.json @@ -3,6 +3,7 @@ "version": 1, "id": "demo-factcheck", "kind": "factcheck", + "series": "Demo Checks", "title": "Checking an example article", "subtitle": "Three claims about a demo channel, tested against what was said on air", "summary": "The article makes four claims. The recordings partly support two 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).",