// AUDIT → `Doc`. The pure half of the HTML tier. // // It DECIDES NOTHING. Every status comes from the shared projections — `reportGroups` / // `packReportGroups` for the conformance grid, `derivePages` + `pageGridModel` for the // per-page view, `auditorUnitModel` for a non-conformity block. A second derivation here // would drift from the Markdown report, and the first symptom of drift in an accessibility // deliverable is a criterion quietly declared conforming. // // It also touches no disk. Crops arrive as a lookup (`CropLookup`); whether their bytes end // up beside the page or inlined as a data: URI is src/html-emit.ts's problem. import { auditorUnitModel } from "./auditor.js"; import { agentMarkNote, basisLabel, commonOrigin, derivePages, formatRate, pageBasisWarning, pageColumnLabel, pageOriginNote, pagesOf, unattributedFindings, unattributedNote, pageGridModel, pageView, } from "./pages.js"; import { pageCoverage, pageCriterionRows, pageRatePct, pageTally } from "./pages-report.js"; import { partitionUnits, prdUnits, type PrdUnit } from "./prd.js"; import { automationOverview, conformanceRate, packReportGroups, reportCoverage, reportGroups, reportTotals, tallyRows } from "./report.js"; import { CORE, type StandardId, isCore, loadPack, standardLabel, titlePlain } from "./standards/index.js"; import { findingsForStandard } from "./standards/derive.js"; import type { Block, Cell, Doc, Run } from "./html.js"; import type { AuditResult, Finding, Lang, PageResult, Status } from "./types.js"; /** Resolves the annotated crop for one occurrence. Same contract as the auditor block's * `cropFor`, and for the same reason: this module never learns how a finding is keyed. */ export type CropLookup = (f: Finding) => { href: string; alt: string } | undefined; /** What the evidence tier REFUSED to draw, for one page or — with `null` — for the whole * run. Same shape as the crop lookup and for the same reason: this module never learns how * the evidence tier works, only what it decided (src/evidence.ts `evidenceRefusals`). */ export type RefusalLookup = (pageId: string | null) => { headline: string; reasons: string[] } | undefined; export interface HtmlReportOpts { standard?: StandardId; lang?: Lang; crops?: CropLookup; /** An occurrence with no picture must never read as an occurrence with no defect, so the * document states what it did not draw. Without this, the HTML — the deliverable that * actually reaches the client — is the one surface that keeps the omission to itself. */ refusals?: RefusalLookup; /** Sibling documents, for the nav. */ nav?: Doc["nav"]; } /** The refusal list as blocks: the sentence, then one item per reason. */ function refusalBlocks(r: { headline: string; reasons: string[] } | undefined): Block[] { if (!r) return []; return [ { kind: "note", tone: "warn", runs: [{ text: r.headline }] }, { kind: "list", items: r.reasons.map((text) => [{ text }]) }, ]; } const T = { fr: { reportTitle: "Rapport de conformité", indexTitle: "Rapport d'accessibilité", pagesTitle: "Rapport page par page", date: "Date", files: "fichiers", rate: "réussite automatique", conformityRate: "de conformité (critères validés ÷ applicables)", synthesis: "Synthèse", synthCaption: (h: string) => `Nombre de critères par statut, regroupés par ${h}.`, byGuideline: "règle WCAG", byTheme: "thématique", total: "Total", conforming: "C", nonConforming: "NC", notApplicable: "NA", toAssess: "À évaluer", group: "Groupe", coverage: (d: number, t: number) => `Couverture : ${d}/${t} critère(s) décidé(s). Le taux ne porte que sur eux et ne dit rien des ${t - d} autres.`, ncTitle: "Non-conformités", ncCaption: "Une entrée par critère non conforme.", // NOT « par le moteur statique ». This document is also rendered for a run that captured // real pages, measured them in a browser and had an adjudicator rule on the result; // naming one tier there tells the reader the other two did not happen. noNc: "Aucune non-conformité relevée sur ce périmètre. Les critères « à évaluer » restent à trancher.", recTitle: "Recommandations (non normatives)", recNote: "Bonnes pratiques sans test normatif en échec : elles ne rendent aucun critère non conforme et n'entrent pas dans le taux.", occurrences: "Occurrences", occCaption: (id: string) => `Occurrences du critère ${id}.`, where: "Emplacement", selector: "Sélecteur", what: "Constat", evidence: "Preuve", perPage: "Bilan page par page", perPageCaption: "Taux et constats par page, avec la base sur laquelle chaque page a été jugée.", page: "Page", url: "URL", basis: "Base", pageRate: "Taux", sheet: "Fiche", grid: "Grille des critères", gridCaption: (n: string) => `Statut de chaque critère pour la page ${n}.`, crossGridCaption: "Statut de chaque critère, page par page.", criterion: "Critère", status: "Statut", legend: "`C` conforme · `NC` non conforme · `—` non applicable · `?` à évaluer.", tally: (c: number, nc: number, na: number, m: number) => `${c} conforme(s) · ${nc} non conforme(s) · ${na} non applicable(s) · ${m} à évaluer`, manualWarn: "Un critère « à évaluer » n'est ni conforme ni non conforme : personne ne l'a encore tranché. Les critères de jugement s'adjugent avec `verify --manual`, ceux « à restituer » avec `scan`.", documents: "Documents", openComposite: "Rapport complet, en un seul fichier (imprimable en PDF)", openPages: "Rapport page par page", noScreenshot: "Aucune capture d'écran pour cette page.", screenshotAlt: (n: string) => `Capture d'écran de la page ${n}`, scopeTitle: "Périmètre réellement testé", renderedPages: (n: number) => `${n} page(s) rendue(s) réellement testée(s)`, noRenderedPages: "Aucune page rendue : les tests rendered n'ont pas été exécutés dans ce run.", automation: (s: number, sc: number, r: number, rc: number, j: number, jc: number) => `Contrat RGAA : ${s} test(s) static sur ${sc} critère(s), ${r} rendered sur ${rc}, ${j} judgment sur ${jc}.`, exhaustive: "Grille exhaustive des critères", exhaustiveCaption: "Une ligne par critère ; S/R/J indique le nombre de tests static, rendered et judgment.", automationCol: "Tests S / R / J", decidedBy: "Décidé par", owner: { engine: "moteur", scan: "scan", agent: "IA", pending: "à adjuger" }, }, en: { reportTitle: "Conformance report", indexTitle: "Accessibility report", pagesTitle: "Page-by-page report", date: "Date", files: "files", rate: "automatic pass rate", conformityRate: "conformity (validated ÷ applicable criteria)", synthesis: "Synthesis", synthCaption: (h: string) => `Criteria count per status, grouped by ${h}.`, byGuideline: "WCAG guideline", byTheme: "theme", total: "Total", conforming: "C", nonConforming: "NC", notApplicable: "NA", toAssess: "To assess", group: "Group", coverage: (d: number, t: number) => `Coverage: ${d}/${t} criteria decided. The rate covers only those and says nothing about the other ${t - d}.`, ncTitle: "Non-conformities", ncCaption: "One entry per non-conforming criterion.", noNc: "No non-conformity found in this scope. The criteria left to assess are still open.", recTitle: "Recommendations (non-normative)", recNote: "Good practices with no failing normative test: they never make a criterion non-conforming and do not enter the rate.", occurrences: "Occurrences", occCaption: (id: string) => `Occurrences of criterion ${id}.`, where: "Location", selector: "Selector", what: "Finding", evidence: "Evidence", perPage: "Page-by-page scoreboard", perPageCaption: "Rate and findings per page, with the basis each page was judged on.", page: "Page", url: "URL", basis: "Basis", pageRate: "Rate", sheet: "Sheet", grid: "Criteria grid", gridCaption: (n: string) => `Each criterion's status for the ${n} page.`, crossGridCaption: "Each criterion's status, page by page.", criterion: "Criterion", status: "Status", legend: "`C` conforming · `NC` non-conforming · `—` not applicable · `?` to assess.", tally: (c: number, nc: number, na: number, m: number) => `${c} conforming · ${nc} non-conforming · ${na} not applicable · ${m} to assess`, manualWarn: 'A criterion "to assess" is neither conforming nor non-conforming: nobody has ruled on it yet. Judgment criteria are adjudicated with `verify --manual`, rendering ones with `scan`.', documents: "Documents", openComposite: "Full report, in a single file (printable to PDF)", openPages: "Page-by-page report", noScreenshot: "No screenshot for this page.", screenshotAlt: (n: string) => `Screenshot of the ${n} page`, scopeTitle: "Actual tested scope", renderedPages: (n: number) => `${n} rendered page(s) actually tested`, noRenderedPages: "No rendered page: the rendered tests were not executed in this run.", automation: (s: number, sc: number, r: number, rc: number, j: number, jc: number) => `RGAA contract: ${s} static test(s) across ${sc} criterion(ia), ${r} rendered across ${rc}, ${j} judgment across ${jc}.`, exhaustive: "Exhaustive criteria grid", exhaustiveCaption: "One row per criterion; S/R/J is the number of static, rendered and judgment tests.", automationCol: "S / R / J tests", decidedBy: "Decided by", owner: { engine: "engine", scan: "scan", agent: "AI", pending: "to adjudicate" }, }, } as const; /** Backtick-delimited spans in a shared sentence, turned into real `` runs. The honesty * sentences (src/pages.ts) are written once for both Markdown and HTML; this is the only * place their inline code markers are interpreted, and it never interprets anything else. */ function ticks(text: string): Run[] { return text.split(/`([^`]+)`/).flatMap((part, i) => (part === "" ? [] : i % 2 === 1 ? [{ text: part, mono: true }] : [{ text: part }])); } const stdName = (standard: StandardId): string => (isCore(standard) ? "WCAG 2.2 AA" : loadPack(standard).name); /** The run's identity line, and the rate WITH its denominator and its agent mark. */ function headline(result: AuditResult, standard: StandardId, lang: Lang): { runs: Run[]; agentRuled: boolean; decided: number; total: number } { const t = T[lang]; const core = isCore(standard); const groups = core ? reportGroups(result, lang) : packReportGroups(result, loadPack(standard), lang); const { decided, total } = reportCoverage(groups); const agentRuled = groups.some((g) => g.rows.some((r) => r.decidedBy === "agent" && r.status === "C")); // ONE SET, NOT TWO. The denominator was read off the PACK's derived criteria while the // percentage stayed `result.conformancePct`, which is the CORE audit's ratio over the WCAG // success criteria — so « 29 % (101/106) » described a rate over 55 criteria and a coverage // over 106, side by side, as though they were one measurement. // // A pack dashboard now shows the standard's own conformity rate, the same number and the // same formula the Markdown report leads with (src/report.ts `conformanceRate`), so the two // documents cannot state different figures for the same run. // AND ITS OWN OPERANDS. Half-fixing this was worse than not: the percentage became the // standard's conformity rate while the parenthesis kept `decided/total`, so the header read // « 80 % (101/106) » — a rate over 74 applicable criteria beside a coverage over 106, which // is the very mismatch the fix was for. A rate is published with the two numbers it was // computed from or it is not checkable. const rate = core ? null : conformanceRate(reportTotals(groups)); const pct = rate ? rate.pct : result.conformancePct; const num = rate ? rate.validated : decided; const den = rate ? rate.applicable : total; return { runs: [ { text: result.date, mono: true }, { text: ` · ${result.scope.files} ${t.files} · ` }, { text: `${formatRate(den === 0 ? null : pct, num, den)}${agentRuled ? "*" : ""}`, strong: true }, { text: ` ${core ? t.rate : t.conformityRate}` }, ], agentRuled, decided, total, }; } /** §1 — the synthesis grid, then the coverage sentence under it. */ function synthesisBlocks(result: AuditResult, standard: StandardId, lang: Lang): Block[] { const t = T[lang]; const core = isCore(standard); const groups = core ? reportGroups(result, lang) : packReportGroups(result, loadPack(standard), lang); const tot = reportTotals(groups); const { decided, total } = reportCoverage(groups); const rows: Cell[][] = groups.map((g) => { const x = tallyRows(g.rows); return [ { text: `${g.key} ${g.title}`.trim() }, { text: String(x.c), align: "end" as const }, { text: String(x.nc), align: "end" as const }, { text: String(x.na), align: "end" as const }, { text: String(x.manual), align: "end" as const }, ]; }); rows.push([ { text: t.total, strong: true }, { text: String(tot.c), align: "end" as const, strong: true }, { text: String(tot.nc), align: "end" as const, strong: true }, { text: String(tot.na), align: "end" as const, strong: true }, { text: String(tot.manual), align: "end" as const, strong: true }, ]); return [ { kind: "heading", level: 2, text: t.synthesis, id: "synthesis" }, { kind: "table", caption: t.synthCaption(core ? t.byGuideline : t.byTheme), columns: [ { text: t.group }, { text: t.conforming, align: "end" }, { text: t.nonConforming, align: "end" }, { text: t.notApplicable, align: "end" }, { text: t.toAssess, align: "end" }, ], rows, }, { kind: "note", tone: "warn", runs: ticks(t.coverage(decided, total)) }, { kind: "note", tone: "warn", runs: ticks(t.manualWarn) }, ]; } /** One non-conformity, from `auditorUnitModel` — the same decisions the Markdown block makes, * presented as a table of occurrences with their crops rather than as a checklist. */ function criterionBlocks(unit: PrdUnit, standard: StandardId, lang: Lang, level: 2 | 3 | 4, crops?: CropLookup): Block[] { const t = T[lang]; const m = auditorUnitModel(unit, standard, lang, { collapse: true }); const out: Block[] = [{ kind: "heading", level, text: `${m.icon} ${m.label}`, id: `c-${unit.criteriaId}` }]; out.push({ kind: "note", tone: "info", runs: ticks(m.normativeNote) }); out.push({ kind: "list", items: m.fields.map((f) => [{ text: `${f.label} : `, strong: true }, ...ticks(f.value)]) }); out.push({ kind: "para", runs: [{ text: `${t.what} (${m.conformanceTerms.nonConformant}) : `, strong: true }, { text: `${m.occurrences} — ${m.messages.join(" ; ")}` }], }); if (m.fixes.length) out.push({ kind: "para", runs: [{ text: `${m.conformanceTerms.conformant} : `, strong: true }, { text: m.fixes.join(" ; ") }] }); const hasEvidence = crops ? m.normative.some((f) => crops(f)) : false; const columns = [{ text: t.where }, { text: t.selector }, { text: t.what }, ...(hasEvidence ? [{ text: t.evidence }] : [])]; const rows = m.normative.map((f) => { const cells: Cell[] = [{ text: `${f.file}:${f.line}`, mono: true }, { text: f.selectorHint, mono: true }, { text: resolveOccurrence(f, lang) }]; if (hasEvidence) cells.push({ text: crops?.(f) ? "▣" : "" }); return cells; }); if (rows.length) out.push({ kind: "table", caption: t.occCaption(unit.criteriaId), columns, rows }); // The crops themselves, as figures — one per occurrence that has one. A table cell is the // wrong container for a 960px image, and `
` gives the caption a home. if (crops) { for (const f of m.normative) { const c = crops(f); if (c) out.push({ kind: "figure", src: c.href, alt: c.alt, caption: `${f.file}:${f.line} — ${f.selectorHint}` }); } } return out; } /** The occurrence message, resolved through the same catalogue the Markdown uses. */ function resolveOccurrence(f: Finding, lang: Lang): string { // auditorUnitModel already resolved the unit's distinct messages; a per-occurrence one can // interpolate its own values (a measured contrast ratio), so it is resolved here. return f.msg ? (auditorUnitModel({ criteriaId: f.criteriaId, title: "", label: "", refs: [], severity: f.severity, findings: [f] }, CORE, lang).messages[0] ?? f.message) : f.message; } /** §2 + §Recommendations — every non-conformity, then the advisory units. */ function findingsBlocks(result: AuditResult, standard: StandardId, lang: Lang, level: 2 | 3, crops?: CropLookup, refusals?: RefusalLookup): Block[] { const t = T[lang]; const { nc, advisory } = partitionUnits(prdUnits(result, standard, lang)); const out: Block[] = [{ kind: "heading", level: 2, text: t.ncTitle, id: "nc" }]; // Before the first figure, not after the last: a reader who stops scrolling must already // know that the pictures below are a subset of the occurrences listed beside them. out.push(...refusalBlocks(refusals?.(null))); if (!nc.length) out.push({ kind: "para", runs: [{ text: t.noNc }] }); for (const u of nc) out.push(...criterionBlocks(u, standard, lang, level === 2 ? 3 : 4, crops)); if (advisory.length) { out.push({ kind: "heading", level: 2, text: t.recTitle, id: "rec" }); out.push({ kind: "note", tone: "info", runs: ticks(t.recNote) }); for (const u of advisory) out.push(...criterionBlocks(u, standard, lang, level === 2 ? 3 : 4, crops)); } return out; } /** What this run truly executed, kept separate from the pack's capability contract. */ function scopeBlocks(result: AuditResult, standard: StandardId, lang: Lang): Block[] { const t = T[lang]; const pages = result.scope.pagesAudited?.length ?? 0; const items: Run[][] = [[{ text: `${result.scope.files} ${t.files}` }], [{ text: t.renderedPages(pages) }]]; const automation = automationOverview(standard); if (automation) { items.push([ { text: t.automation( automation.tests.static, automation.criteria.static.length, automation.tests.rendered, automation.criteria.rendered.length, automation.tests.judgment, automation.criteria.judgment.length, ), }, ]); } const out: Block[] = [ { kind: "heading", level: 2, text: t.scopeTitle, id: "scope" }, { kind: "list", items }, ]; if (pages === 0) out.push({ kind: "note", tone: "warn", runs: ticks(t.noRenderedPages) }); return out; } /** The run-wide grid that the source-only artifact previously lacked. */ function criteriaGridBlocks(result: AuditResult, standard: StandardId, lang: Lang): Block[] { const t = T[lang]; const groups = isCore(standard) ? reportGroups(result, lang) : packReportGroups(result, loadPack(standard), lang); const pack = isCore(standard) ? undefined : loadPack(standard); const rows: Cell[][] = []; for (const row of groups.flatMap((group) => group.rows)) { const criterion = pack?.criteria.find((entry) => entry.id === row.id); const tiers = Object.values(criterion?.automation?.tests ?? {}); const n = (tier: "static" | "rendered" | "judgment") => tiers.filter((value) => value === tier).length; const status = row.inapplicable ? "NA" : row.status; const owner = row.status === "manual" ? t.owner.pending : t.owner[row.decidedBy ?? "engine"]; rows.push([ { text: pack && criterion ? `${pack.name} ${row.id} — ${titlePlain(pack, criterion, lang)}` : row.label }, { status, text: "" }, { text: pack ? `${n("static")} / ${n("rendered")} / ${n("judgment")}` : "—", align: "end" }, { text: owner }, ]); } return [ { kind: "heading", level: 2, text: t.exhaustive, id: "criteria" }, { kind: "table", caption: t.exhaustiveCaption, columns: [{ text: t.criterion }, { text: t.status }, { text: t.automationCol, align: "end" }, { text: t.decidedBy }], rows, }, ]; } /** The per-page scoreboard: one row per page, its basis, its rate WITH the denominator. */ export function scoreboardBlocks(result: AuditResult, standard: StandardId, lang: Lang, sheetHref?: (id: string) => string): Block[] { const t = T[lang]; const scope = pagesOf(result); if (!scope.length) return []; const derived = derivePages(result, scope); // Through the standard-aware helpers, exactly like `renderPageRates` in src/report.ts: // `PageResult.conformancePct` is the WCAG core projection, so under a pack this cell used to // quote a rate out of 55 beside a document counting 106. const rows = derived.map((p) => { const criteria = pageCriterionRows(result, p, standard, lang); const cov = pageCoverage(criteria); return [ { text: `${p.name}${p.auth ? " 🔒" : ""}`, ...(sheetHref ? { href: sheetHref(p.id) } : {}) }, { text: p.url, mono: true }, { text: basisLabel(p.basis, lang) }, { text: formatRate(pageRatePct(criteria), cov.decided, cov.total), align: "end" as const }, ]; }); const out: Block[] = [ { kind: "heading", level: 2, text: t.perPage, id: "pages" }, { kind: "table", caption: t.perPageCaption, columns: [{ text: t.page }, { text: t.url }, { text: t.basis }, { text: t.pageRate, align: "end" }], rows, }, ]; const orphans = unattributedFindings(result).filter((f) => !f.advisory).length; if (orphans) out.push({ kind: "note", tone: "warn", runs: ticks(unattributedNote(orphans, lang)) }); // One caveat per basis actually present — a "not audited" page must not be explained by the // sentence that asserts it has no snapshot. for (const basis of ["attributed", "not-audited"] as const) { const note = derived.some((p) => p.basis === basis) ? pageBasisWarning(basis, lang) : undefined; if (note) out.push({ kind: "note", tone: "warn", runs: ticks(note) }); } return out; } /** The cross-page grid: one row per criterion, one column per page. */ export function crossGridBlocks(result: AuditResult, derived: PageResult[], standard: StandardId, lang: Lang): Block[] { const t = T[lang]; if (!derived.length) return []; const { rows, status } = pageGridModel(result, derived, standard, lang); if (!rows.length) return []; const origin = commonOrigin(derived); const originNote = pageOriginNote(origin, lang); const table: Block = { kind: "table", caption: t.crossGridCaption, columns: [{ text: t.criterion }, ...derived.map((p) => ({ text: pageColumnLabel(p, origin) }))], rows: [], }; let group = ""; for (const row of rows) { if (row.group !== group) { group = row.group; table.rows.push([{ text: group, colspan: derived.length + 1 }]); } table.rows.push([{ text: row.label }, ...derived.map((p) => ({ status: (status.get(row.id)?.get(p.id) ?? "manual") as Status, text: "" }))]); } return [ { kind: "heading", level: 2, text: t.grid, id: "grid" }, { kind: "note", tone: "info", runs: ticks(t.legend) }, ...(originNote ? [{ kind: "note", tone: "info", runs: ticks(originNote) } as Block] : []), table, ]; } /** One page's criteria grid — the sheet's own table, not the cross-page one. */ function pageGridBlocks(result: AuditResult, page: PageResult, standard: StandardId, lang: Lang): Block[] { const t = T[lang]; const rows = pageCriterionRows(result, page, standard, lang); if (!rows.length) return []; const cov = pageCoverage(rows); const tally = pageTally(rows); const table: Block = { kind: "table", caption: t.gridCaption(page.name), columns: [{ text: t.criterion }, { text: t.status }], rows: [] }; let group = ""; for (const row of rows) { if (row.group !== group) { group = row.group; table.rows.push([{ text: group, colspan: 2 }]); } // A conformity an agent RULED carries the mark here too, exactly as on the Markdown sheet. table.rows.push([{ text: row.label }, { status: row.status, text: row.decidedBy === "agent" && row.status === "C" ? "*" : "" }]); } const out: Block[] = [ { kind: "heading", level: 2, text: t.grid }, { kind: "para", runs: [ { text: `${t.pageRate} : `, strong: true }, { text: formatRate(pageRatePct(rows), cov.decided, cov.total), strong: true }, ], }, { kind: "para", runs: [{ text: t.tally(tally.c, tally.nc, tally.na, tally.manual) }] }, { kind: "note", tone: "info", runs: ticks(t.legend) }, table, ]; if (rows.some((r) => r.decidedBy === "agent" && r.status === "C")) out.push({ kind: "note", tone: "warn", runs: ticks(agentMarkNote(lang)) }); return out; } /** THE ENTRY POINT. Dashboard only: identity, rate, synthesis, scoreboard, and the links out. * It carries no image, so an artifact viewer opens it instantly however heavy the evidence. */ export function indexDoc(result: AuditResult, opts: HtmlReportOpts & { links?: { href: string; text: string }[] } = {}): Doc { const standard = opts.standard ?? CORE; const lang = opts.lang ?? "en"; const t = T[lang]; const h = headline(result, standard, lang); const blocks: Block[] = []; if (opts.links?.length) { blocks.push({ kind: "heading", level: 2, text: t.documents, id: "documents" }); blocks.push({ kind: "list", items: opts.links.map((l) => [{ text: l.text, href: l.href }]) }); } blocks.push(...scopeBlocks(result, standard, lang)); blocks.push(...synthesisBlocks(result, standard, lang)); blocks.push(...criteriaGridBlocks(result, standard, lang)); if (h.agentRuled) blocks.push({ kind: "note", tone: "warn", runs: ticks(agentMarkNote(lang)) }); blocks.push(...scoreboardBlocks(result, standard, lang)); return { lang, title: `${t.indexTitle} — ${stdName(standard)}`, subtitle: h.runs, ...(opts.nav ? { nav: opts.nav } : {}), blocks }; } /** THE COMPOSITE — one detachable, printable file carrying the whole audit. */ export function compositeDoc(result: AuditResult, opts: HtmlReportOpts = {}): Doc { const standard = opts.standard ?? CORE; const lang = opts.lang ?? "en"; const t = T[lang]; const h = headline(result, standard, lang); const blocks: Block[] = [...scopeBlocks(result, standard, lang), ...synthesisBlocks(result, standard, lang), ...criteriaGridBlocks(result, standard, lang)]; if (h.agentRuled) blocks.push({ kind: "note", tone: "warn", runs: ticks(agentMarkNote(lang)) }); blocks.push(...findingsBlocks(result, standard, lang, 2, opts.crops, opts.refusals)); blocks.push(...scoreboardBlocks(result, standard, lang)); const scope = pagesOf(result); if (scope.length) blocks.push(...crossGridBlocks(result, derivePages(result, scope), standard, lang)); return { lang, title: `${t.reportTitle} ${standardLabel(standard)} — ${result.date}`, subtitle: h.runs, ...(opts.nav ? { nav: opts.nav } : {}), blocks }; } /** The page site's index. */ export function pagesIndexDoc( result: AuditResult, opts: HtmlReportOpts & { sheetHref: (id: string) => string } = { sheetHref: (id) => `./page-${id}.html` }, ): Doc { const standard = opts.standard ?? CORE; const lang = opts.lang ?? "en"; const t = T[lang]; const h = headline(result, standard, lang); const scope = pagesOf(result); const blocks: Block[] = [...scoreboardBlocks(result, standard, lang, opts.sheetHref)]; if (scope.length) blocks.push(...crossGridBlocks(result, derivePages(result, scope), standard, lang)); return { lang, title: `${t.pagesTitle} — ${stdName(standard)}`, subtitle: h.runs, ...(opts.nav ? { nav: opts.nav } : {}), blocks }; } /** One page's dossier: identity, screenshot, grid, then its own non-conformities. */ export function pageDoc(result: AuditResult, page: PageResult, opts: HtmlReportOpts & { screenshot?: string } = {}): Doc { const standard = opts.standard ?? CORE; const lang = opts.lang ?? "en"; const t = T[lang]; const blocks: Block[] = [ { kind: "list", items: [ [ { text: `${t.url} : `, strong: true }, { text: page.url, mono: true }, ], [{ text: `${t.basis} : `, strong: true }, { text: basisLabel(page.basis, lang) }], ], }, ]; const warn = pageBasisWarning(page.basis, lang); if (warn) blocks.push({ kind: "note", tone: "warn", runs: ticks(warn) }); blocks.push( opts.screenshot ? { kind: "figure", src: opts.screenshot, alt: t.screenshotAlt(page.name) } : { kind: "para", runs: [{ text: t.noScreenshot, em: true }] }, ); // Right after the capture and before the grid — the same place the Markdown sheet puts it // (src/pages-report.ts), so a reader moving between the two documents finds it twice in // the same spot rather than once in one of them. blocks.push(...refusalBlocks(opts.refusals?.(page.id))); blocks.push(...pageGridBlocks(result, page, standard, lang)); // The page's OWN findings, through the same view the Markdown sheet uses — so a criterion // cannot be non-conforming here and conforming there. // // `pageView`, not a second copy of it. The copy that used to live here replaced `criteria` // and `findings` and kept the run-wide `packFindings`, so every declarative pack-rule finding // — one per capture, on a 37-page audit — was printed on all thirty-seven cards. The // Markdown sheet has filtered them by page since it was written (src/pages.ts): two renderers // of one fact, and only one of them was right. const view = pageView(result, page); const { nc, advisory } = partitionUnits(prdUnits(view, standard, lang)); blocks.push({ kind: "heading", level: 2, text: t.ncTitle }); if (!nc.length) blocks.push({ kind: "para", runs: [{ text: t.noNc }] }); for (const u of nc) blocks.push(...criterionBlocks(u, standard, lang, 3, opts.crops)); if (advisory.length) { blocks.push({ kind: "heading", level: 2, text: t.recTitle }); blocks.push({ kind: "note", tone: "info", runs: ticks(t.recNote) }); for (const u of advisory) blocks.push(...criterionBlocks(u, standard, lang, 3, opts.crops)); } return { lang, title: `${page.name} — ${stdName(standard)}`, subtitle: [{ text: result.date, mono: true }, { text: ` · ${findingsForStandard(view, standard).length} ${t.what.toLowerCase()}` }], ...(opts.nav ? { nav: opts.nav } : {}), blocks, }; }