Default view shows ${view.visibleCount} prioritized issue type(s); ${hiddenCount} more are available if you switch to "Show everything".
`
: `All findings fit within the prioritized view this week.
`;
const dupLine = dupCount > 0
? `${dupCount} finding(s) marked "possible duplicate" — Alfa and axe-core both flagged the same WCAG SC on overlapping pages. If they target the same element, the axe-core report is authoritative. Filter the CSV by possible_duplicate_of to see these. Two engines flagging the same barrier reduces the chance of a false positive.
`
: '';
return ` by its data-severity / data-category attributes.
const BUG_FILTER_SCRIPT = ``;
/**
* Per-engine coverage: how many of the week's pages each engine ran on,
* reflecting the configured weekly sampling rates.
*/
function coverageTable(summary) {
const cov = summary.coverage;
if (!cov || Object.keys(cov).length === 0) return '';
const total = summary.pagesScanned || 1;
const rows = Object.entries(cov)
.sort((a, b) => b[1] - a[1])
.map(([engine, n]) => `${esc(engine)} ${n} ${Math.round((100 * n) / total)}% `)
.join('\n');
const attemptLine = (summary.pagesAttempted != null)
? ` · ${summary.pagesSucceeded ?? '?'} succeeded of ${summary.pagesAttempted} attempted`
: '';
return `
Scan coverage this week (${summary.pagesScanned} pages${attemptLine})
Pages each engine ran on, per the configured weekly sampling rates.
Engine Pages Coverage
${rows}
`;
}
/**
* Embedded/linked non-HTML resources: PDFs, Office docs, iframes,
* embedded media. Leads with what's NEW this week (the question a site
* owner most wants answered), then the by-type inventory, with a CSV of
* everything (incl. first-seen).
*/
const RESOURCE_LABELS = {
pdf: 'PDF documents', document: 'Word/text documents', presentation: 'Presentations',
spreadsheet: 'Spreadsheets', archive: 'Archives (zip, etc.)', video: 'Video files',
audio: 'Audio files', image: 'Images', svg: 'SVG', iframe: 'Iframes',
'embedded-media': 'Embedded media players', embed: 'Embeds / objects',
};
function resourcesSection(summary) {
const r = summary.resources;
if (!r) return '';
const typeRows = Object.entries(r.byType)
.sort((a, b) => b[1] - a[1])
.map(([type, n]) => `${esc(RESOURCE_LABELS[type] ?? type)} ${n} `)
.join('\n');
const newList = (r.newThisWeek ?? []);
const newBlock = newList.length
? `New this week (${newList.length})
${newList.slice(0, 100).map((n) => `${esc(RESOURCE_LABELS[n.type] ?? n.type)}: ${esc(n.url)} `).join('')} `
: `No new resources first seen this week.
`;
return `
${heading('h-resources', `Embedded & linked resources`)}
Non-HTML resources this site links to or embeds — PDFs, Office documents, iframes, and media. The site owner is responsible for their accessibility too. ${r.csv ? `Full inventory with first-seen dates: CSV .` : ''}
${newBlock}
${r.total} distinct resources, by type.
Type Count
${typeRows}
`;
}
/**
* Cross-engine consensus: the true number of unique accessibility issues
* (deduplicated across axe and Alfa via W3C ACT rules), and how many both
* engines agree on. Prevents the "looks like 2x the errors" problem of
* summing two engines that overlap.
*/
/**
* "Fix these first" — the highest-leverage issues for this domain,
* ranked by pages affected × severity × people reached. Each row links
* its remediation tip and the CSV of affected pages, so a team can act.
*/
function fixFirstSection(bugs) {
const top = rankBugs(bugs, 8);
if (top.length === 0) return '';
const rows = top
.map((b) => `
${esc(b.summary)} ${b.rule_url ? ` (rule↗) ` : ''}
${esc(b.severity)}
${b.frequency.pages_affected}
${b.impact?.groups?.length ? esc(b.impact.groups.map((g) => g.group).slice(0, 2).join(', ')) : '—'}
${b.remediation_tip ? esc(b.remediation_tip) : (b.suggested_fix ? esc(b.suggested_fix) : '—')}
${b.affected_pages_csv ? `pages (CSV) ` : '—'}
`)
.join('\n');
return `
${heading('h-fixfirst', `Fix these first`)}
Highest-leverage issues, ranked by pages affected × severity × people reached. Fixing a shared component often clears many pages at once. Issue links go to the full bug detail on the Accessibility page .
Top ${top.length} issues to prioritize this week.
Issue Severity Pages Who it affects How to fix Evidence
${rows}
`;
}
/**
* Security + web-standards checklists, ScanGov-style (per ScanGov's
* Security/Botability/Usability topics), measured across our scan rather
* than just the homepage. Pass/fail with a check icon; credits ScanGov.
*/
function checklist(items) {
return `${items
.map((c) => `${c.pass ? '✓' : '✗'} ${esc(c.label)}${c.detail ? ` ${esc(String(c.detail))} ` : ''}: ${c.pass ? 'pass' : 'fail'} `)
.join('')} `;
}
function standardsSecuritySection(summary) {
const sec = summary.security;
const std = summary.standards;
if (!sec && !std) return '';
const secBlock = sec ? `
Security & domain hygiene ${sec.passed}/${sec.total} on the origin
${checklist(sec.checks)}` : '';
const stdBlock = std ? `
Web standards & metadata across ${std.pagesChecked} page(s)
Share of checked pages passing each standard (lowest first).
Standard Pass rate Pages
${std.checks.map((c) => `${esc(c.label)} ${c.rate}% ${c.pass}/${c.total} `).join('')}
${std.social?.length ? `Open social presence found: ${std.social.map((s) => `${esc(s.platform)} `).join(', ')}.
` : 'No Mastodon/Bluesky links detected on checked pages.
'}` : '';
return `
${heading('h-standards', `Standards & security`)}
Web-standards, metadata, and security checks in the spirit of ScanGov (methodology CC0), run across our scan rather than only the homepage.
${secBlock}
${stdBlock}
`;
}
function consensusSection(summary) {
const c = summary.consensus;
if (!c || c.uniqueIssues === 0) return '';
const naive = c.rawAxe + c.rawAlfa;
const saved = naive - c.uniqueIssues;
// Rules flagged by BOTH engines — the highest-confidence findings, since two
// independent implementations of the same ACT rule agree. List them with
// links to each engine's rule docs and the canonical ACT rule.
const axeRules = summary.axe?.rules ?? {};
const alfaRules = summary.alfa?.rules ?? {};
const both = Object.values(c.byKey ?? {})
.filter((g) => g.engines === 'both')
.sort((a, b) => b.pages - a.pages);
const bothRows = both
.map((g) => {
const axeId = g.axeRules[0];
const alfaId = g.alfaRules[0];
const help = axeId
? (rulePlainLabel('axe-core', axeId, { help: axeRules[axeId]?.help }) ?? axeId)
: (alfaId ? (rulePlainLabel('alfa', alfaId) ?? alfaId) : '');
const axeUrl = axeId ? axeRules[axeId]?.helpUrl : null;
const alfaUrl = alfaId ? alfaRules[alfaId]?.ruleUrl : null;
const actUrl = g.actRuleId ? `https://act-rules.github.io/rules/${esc(g.actRuleId)}` : null;
const links = [
axeUrl ? `axe ${esc(axeId)} ` : (axeId ? `axe ${esc(axeId)}` : ''),
alfaUrl ? `Alfa ${esc(alfaId)} ` : (alfaId ? `Alfa ${esc(alfaId)}` : ''),
actUrl ? `ACT ${esc(g.actRuleId)} ` : '',
].filter(Boolean).join(' · ');
return `${esc(help)} ${g.pages} ${links} `;
})
.join('\n');
const bothTable = both.length
? `
${both.length} rule type(s) caught by both engines — highest confidence
Two independent ACT-rule implementations (Deque axe-core and Siteimprove Alfa) flagged the same issue on the same pages. Agreement between separate engines is strong evidence the barrier is real, not a single-tool false positive — the best place to start.
Rules flagged by both axe-core and Alfa in ${esc(summary.week)}, by pages affected.
Issue Pages Rule references
${bothRows}
`
: 'No issues were flagged by both engines on the same pages this week.
';
return `
${heading('h-consensus', `Unique accessibility issues (axe + Alfa consolidated)`)}
axe and Alfa both implement W3C ACT rules, so the same issue is often caught by both. These are deduplicated by ACT rule and page, so a shared finding counts once${saved > 0 ? ` (${naive} raw engine findings → ${c.uniqueIssues} unique)` : ''}.
Unique issues (rule × page) ${c.uniqueIssues}
Caught by both engines ${c.consensus} highest confidence
axe only ${c.axeOnly}
Alfa only ${c.alfaOnly}
${bothTable}
`;
}
/**
* Standalone Lighthouse page for a domain/week: every sampled URL with
* its category scores (performance, accessibility, best-practices, SEO,
* and the experimental agentic-browsing score) plus Core Web Vitals
* metrics. Linked from the domain report. Returns null if no LH data.
*/
// Human labels for the recommendation categories (engine uses LH category ids).
const LH_CATEGORY_LABELS = {
performance: 'Performance',
seo: 'SEO',
'best-practices': 'Best Practices',
'agentic-browsing': 'Agentic (AI-readiness)',
};
const LH_CATEGORY_ORDER = ['performance', 'best-practices', 'seo', 'agentic-browsing'];
/** "340 KB", "1.2 MB", or '' for zero. */
function fmtSavingsBytes(b) {
if (!b) return '';
return b >= 1048576 ? `${(b / 1048576).toFixed(1)} MB` : `${Math.round(b / 1024)} KB`;
}
/**
* Recommendations rolled up from Lighthouse's non-accessibility audits,
* grouped by category and ranked by pages affected then estimated impact.
* Mirrors the axe rule table: each row is an issue with how many sampled
* pages it hit and an estimated saving where Lighthouse provides one.
*/
function lighthouseRecommendations(recommendations, pagesSampled) {
if (!recommendations?.length) return '';
const byCat = {};
for (const r of recommendations) (byCat[r.category] ??= []).push(r);
const sections = LH_CATEGORY_ORDER
.filter((cat) => byCat[cat]?.length)
.map((cat) => {
const rows = byCat[cat]
.map((r) => {
const savings = [fmtSavingsBytes(r.savingsBytes), r.savingsMs ? `${(r.savingsMs / 1000).toFixed(1)}s` : '']
.filter(Boolean).join(' · ') || '—';
return `
${esc(r.title)}
${r.pages}/${pagesSampled}
${savings}
`;
})
.join('\n');
return `${esc(LH_CATEGORY_LABELS[cat] ?? cat)}
${esc(LH_CATEGORY_LABELS[cat] ?? cat)} recommendations from Lighthouse, by sampled pages affected.
Recommendation Pages Est. saving
${rows}
`;
})
.join('\n');
return `
${heading('h-lh-reco', `Recommendations`)}
Issues Lighthouse flagged across the ${pagesSampled} sampled page(s), beyond the headline scores — grouped by category and ranked by how many pages they affect. Estimated savings (transfer bytes or load time) are Lighthouse's own estimates where available. Accessibility audits are intentionally omitted; they overlap with the axe-core findings on the Accessibility page .
${sections}
`;
}
/**
* Plain-language explainer for the Agentic (AI-readiness) score, which is new
* in Lighthouse 13.4+ and unfamiliar to most readers.
*/
function agenticExplainer(lh) {
if (lh.medianAgentic == null) return '';
const agenticRecos = (lh.recommendations ?? []).filter((r) => r.category === 'agentic-browsing');
const gaps = agenticRecos.length
? `Gaps found on the sampled pages: ${agenticRecos.map((r) => `${esc(r.title)} (${r.pages})`).join(', ')}.
`
: '';
return `
${heading('h-lh-agentic', `What the Agentic score means`)}
The Agentic (AI-readiness) score is new in Google Lighthouse (13.4+). It measures how well a page works for AI agents and assistants — the tools that increasingly mediate how people find and use government services. It checks for things like an llms.txt file (a machine-readable guide for language models), valid structured data , a well-formed accessibility tree that agents can parse, and WebMCP tool/form descriptions. A higher score means an AI assistant is more likely to understand the page and complete tasks on a citizen's behalf correctly.
It is experimental and evolving; treat it as a forward-looking signal, not a compliance requirement. Median across sampled pages: ${fmtScore(lh.medianAgentic)} .
${gaps}
`;
}
export function renderLighthousePage(target, summary, csvHref) {
const lh = summary.lighthouse;
if (!lh || !lh.pageDetail?.length) {
return emptyCriterionPage(target, summary, { active: 'lighthouse', label: 'Lighthouse', message: 'No Lighthouse audits ran on this week\'s sampled pages (Lighthouse is sampled at a low rate; some weeks have none).' });
}
const ms = (v) => (v == null ? 'n/a' : `${(v / 1000).toFixed(1)}s`);
const sc = (v) => (v == null ? 'n/a' : `${v}`);
const cell = (html, sort) => ({ html, sort: sort == null ? -1 : sort });
// Sortable per-page table: page name sorts alphabetically, metrics numerically.
const cols = [
{ label: 'Page' }, { label: 'Perf', num: 1 }, { label: 'A11y', num: 1 },
{ label: 'Best practices', num: 1 }, { label: 'SEO', num: 1 }, { label: 'Agentic', num: 1 },
{ label: 'FCP', num: 1 }, { label: 'LCP', num: 1 }, { label: 'Speed Index', num: 1 },
{ label: 'TBT', num: 1 }, { label: 'CLS', num: 1 },
];
const rows = lh.pageDetail.map((p) => {
const path = (() => { try { return new URL(p.url).pathname || '/'; } catch { return p.url; } })();
const m = p.metrics || {};
return [
cell(`${esc(path)} `, path),
cell(sc(p.scores.performance), p.scores.performance), cell(sc(p.scores.accessibility), p.scores.accessibility),
cell(sc(p.scores.bestPractices), p.scores.bestPractices), cell(sc(p.scores.seo), p.scores.seo),
cell(sc(p.scores.agentic), p.scores.agentic),
cell(ms(m.firstContentfulPaintMs), m.firstContentfulPaintMs), cell(ms(m.largestContentfulPaintMs), m.largestContentfulPaintMs),
cell(ms(m.speedIndexMs), m.speedIndexMs), cell(ms(m.totalBlockingTimeMs), m.totalBlockingTimeMs),
cell(p.metrics.cumulativeLayoutShift ?? 'n/a', p.metrics.cumulativeLayoutShift),
];
});
const m = lh.metrics ?? {};
const weights = summary.sustainability?.bytesList ?? [];
const impact = performanceImpact(lh.pageDetail, weights, target.page_loads_per_week ?? null);
const body = `
${esc(target.domain)}: Lighthouse — week ${esc(summary.week)}
${subnav('lighthouse')}
${lh.pageDetail.length} pages sampled by Google Lighthouse (its own headless Chrome). Scores are 0–100 (higher is better); metrics are Core Web Vitals. ${csvHref ? `Download CSV .` : ''}
${impact ? perfImpactSection(impact) : ''}
${heading('h-lh-medians', `Medians across sampled pages`)}
Performance ${fmtScore(lh.medianPerformance)}
Accessibility ${fmtScore(lh.medianAccessibility)}
Best practices ${fmtScore(lh.medianBestPractices)}
SEO ${fmtScore(lh.medianSeo)}
${lh.medianAgentic != null ? `
Agentic browsing ${fmtScore(lh.medianAgentic)} ` : ''}
Largest Contentful Paint ${ms(m.largestContentfulPaintMs)}
First Contentful Paint ${ms(m.firstContentfulPaintMs)}
Speed Index ${ms(m.speedIndexMs)}
Total Blocking Time ${ms(m.totalBlockingTimeMs)}
Cumulative Layout Shift ${m.cumulativeLayoutShift ?? 'n/a'}
${lighthouseRecommendations(lh.recommendations, lh.pageDetail.length)}
${agenticExplainer(lh)}
${heading('h-lh-pages', `Per-page results`)}
${sortableTable(`Lighthouse scores and Core Web Vitals per sampled page (${summary.week}); Agentic = experimental agentic-browsing score.`, cols, rows)}
`;
return layout({
title: `${target.domain} Lighthouse ${summary.week} | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} ${esc(summary.week)} Lighthouse `,
body,
depth: 3,
extraScript: SORT_SCRIPT,
});
}
/**
* Performance-impact section: extra wait time and data vs Google's
* benchmarks (LCP 2.5s, page weight 1.6 MB). Per-page averages always;
* site-wide totals + Wikipedia-copies when traffic is configured.
*/
function perfImpactSection(impact) {
const secs = (ms) => (ms == null ? 'n/a' : `${(ms / 1000).toFixed(1)}s`);
const mb = (b) => (b == null ? 'n/a' : `${(b / 1e6).toFixed(2)} MB`);
const totals = impact.totals;
return `
${heading('h-lh-impact', `Performance impact`)}
How far pages fall short of Google's "good" benchmarks: Largest Contentful Paint ≤ 2.5s and page weight ≤ 1.6 MB. Lower is better.
Avg extra LCP over 2.5s ${secs(impact.avgExtraLcpMs)} ${impact.pagesOverLcp}/${impact.lcpPages} pages over
Avg extra weight over 1.6 MB ${mb(impact.avgExtraWeightBytes)} ${impact.pagesOverWeight}/${impact.weightPages} pages over
${totals ? `With an estimated ${totals.pageLoadsPerWeek.toLocaleString()} page loads/week, that is roughly ${esc(totals.extraSecondsHuman)} of extra waiting and ${esc(totals.extraBytesHuman)} of extra data transferred per week${totals.wikipediaCopies > 0 ? ` (~${totals.wikipediaCopies.toLocaleString()} copies of Wikipedia)` : ''}. Rough estimate, traffic spread evenly across sampled pages.
`
: `Set page_loads_per_week for this target to also estimate total wasted time and data (the way daily-dap uses traffic counts).
`}
`;
}
/**
* Standalone readability page: a sortable table of every prose page with
* its word count, Flesch Reading Ease and Flesch-Kincaid grade, plus
* documentation of what the metrics mean. Returns null if no data.
*/
export function renderReadabilityPage(target, summary, csvHref) {
const pl = summary.plainLanguage;
if (!pl || !pl.pageRows?.length) {
return emptyCriterionPage(target, summary, { active: 'readability', label: 'Readability', message: 'No readable prose pages were sampled this week, so there are no readability metrics to report.' });
}
const cell = (html, sort) => ({ html, sort: sort === '' || sort == null ? -1 : sort });
const cols = [
{ label: 'Page' }, { label: 'Words', num: 1 }, { label: 'Reading ease', num: 1 },
{ label: 'Grade level', num: 1 }, { label: 'Scored', num: 0 },
];
const rows = pl.pageRows.map((r) => {
const path = (() => { try { return new URL(r.url).pathname || '/'; } catch { return r.url; } })();
return [
cell(`${esc(path)} `, path),
cell(String(r.wordCount), r.wordCount),
cell(r.fleschReadingEase === '' ? 'n/a' : String(r.fleschReadingEase), r.fleschReadingEase),
cell(r.fleschKincaidGrade === '' ? 'n/a' : String(r.fleschKincaidGrade), r.fleschKincaidGrade),
cell(r.scored ? 'yes' : 'too little prose', r.scored ? 1 : 0),
];
});
const acronyms = summary.plainLanguage?.topUnexplainedAcronyms ?? [];
const misspellings = summary.plainLanguage?.topMisspellings ?? [];
const body = `
${esc(target.domain)}: Readability — week ${esc(summary.week)}
${subnav('readability')}
Plain-language metrics for the main content of each scanned page (navigation, header, and footer excluded). ${csvHref ? `Download CSV .` : ''}
${heading('h-read-about', `What these mean`)}
Words per page Main-content word count. Median ${pl.medianWordsPerPage ?? 'n/a'}
Reading ease (Flesch) 0–100; higher is easier. ~60+ is plain language; below ~30 is very hard. Median ${pl.medianReadingEase ?? 'n/a'}
Grade level (Flesch-Kincaid) US school grade needed to read it; aim for ~8 or lower for the public. Median ${pl.medianGrade ?? 'n/a'}
Scored Pages with too little prose (mostly links/cards) are not scored — those numbers would be misleading. ${pl.pagesScored} of ${pl.pagesChecked} scored
Heuristics for triage and trends, not authoritative linguistics. They flag pages worth a human plain-language review.
${heading('h-read-pages', `Per-page readability`)}
${sortableTable(`Readability per scanned page (${summary.week}).`, cols, rows)}
${acronyms.length ? `
${heading('h-acronyms', `Unexplained acronyms`)}
Acronyms used without an on-page expansion (e.g. "Centers for Medicare & Medicaid Services (CMS)"), by pages affected.${downloadLinks(summary.plainLanguage?.acronymsCsv, summary.plainLanguage?.acronymsJson)}
${acronyms.map((a) => `${esc(a.acronym)} — ${a.pages} page(s) `).join('')}
` : ''}
${misspellings.length ? `
${heading('h-spelling', `Possible misspellings`)}
Main-content words not found in the dictionary or the project allowlist, by pages affected. Government and medical jargon may be false positives — add real terms to config/spelling-allowlist.txt.${downloadLinks(summary.plainLanguage?.spellingCsv, summary.plainLanguage?.spellingJson)}
${misspellings.map((m) => `${esc(m.word)} — ${m.pages} page(s) `).join('')}
` : ''}`;
return layout({
title: `${target.domain} Readability ${summary.week} | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} ${esc(summary.week)} Readability `,
body,
depth: 3,
extraScript: SORT_SCRIPT,
});
}
/**
* Standalone tech-detection page for a domain/week. Lists all technologies
* identified across the sampled pages, grouped by category (CMS, framework,
* analytics, CDN, etc.), with confidence level and the evidence signals that
* triggered each detection. Linked from the domain's subnav when data exists.
*/
export function renderTechPage(target, summary, csvHref) {
if (!summary.tech?.length) {
return emptyCriterionPage(target, summary, { active: 'tech', label: 'Technology stack', message: 'No technology was detected in this week\'s sampled pages.' });
}
// Denominator for coverage is the number of pages the tech engine actually
// ran on (its sample), not every page scanned — so "59% (63 of 106)" reads
// as "found on 63 of the 106 pages we checked for technology".
const techRan = summary.coverage?.tech ?? null;
const coverage = (d) => {
if (!techRan) return `${d.pagesConfirmed}`;
return `${Math.round((100 * d.pagesConfirmed) / techRan)}% (${d.pagesConfirmed} of ${techRan}) `;
};
const byCategory = {};
for (const d of summary.tech) {
(byCategory[d.category] ??= []).push(d);
}
const confColor = (c) => c === 100 ? 'var(--better)' : c >= 75 ? 'var(--accent)' : 'var(--muted)';
const sections = Object.entries(byCategory)
.sort(([a], [b]) => a.localeCompare(b))
.map(([cat, items]) => {
const rows = items
.map((d) => {
const nameCell = d.website
? `${esc(d.name)} `
: esc(d.name);
const examples = (d.examplePages ?? []).length
? `${d.examplePages.length} example page(s) ${d.examplePages.map((u) => `${urlCell(u)} `).join('')} `
: '';
return `
${nameCell}${d.version ? ` v${esc(d.version)} ` : ''}${examples}
${d.confidence}%
${coverage(d)}
${esc(d.categories.join(', '))}
`;
})
.join('\n');
return `${esc(cat)}
${esc(cat)} technologies detected on ${esc(target.domain)}, ${esc(summary.week)}.
Technology Confidence Coverage All categories
${rows}
`;
})
.join('\n');
const ranNote = techRan ? ` The technology engine ran on ${techRan} of ${summary.pagesScanned} pages scanned this week; coverage below is the share of those ${techRan} pages where each technology was found.` : '';
const body = `
${esc(target.domain)}: technology stack
${subnav('tech')}
${summary.tech.length} technologies detected in ${esc(summary.week)} , using response headers, HTML meta tags, JavaScript globals, and script/link src patterns. Confidence reflects how specifically the signal identifies the technology. This is automated heuristic detection — verify before relying on results for procurement or compliance decisions.${ranNote}${downloadLinks(csvHref, 'tech.json')}
Detection is additive across the week's sampled pages. Expand a technology to see example pages where it was found.
${sections}`;
return layout({
title: `${target.domain} Tech Stack ${summary.week} | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} ${esc(summary.week)} Tech stack `,
body,
depth: 3,
extraScript: SORT_SCRIPT,
});
}
/** "axe:color-contrast" -> "color-contrast (axe)" for display. */
function findingLabel(key) {
const [engine, ...rest] = String(key).split(':');
const id = rest.join(':');
return `${id} (${engine})`;
}
/**
* Tech ↔ issues page: surfaces accessibility findings that are statistically
* over-represented on pages running a given technology. The signal is lift
* (how much more likely a finding is on pages with the tech vs. its overall
* rate). High lift across many pages is a candidate systemic issue — a barrier
* that travels with a CMS/theme/widget rather than with any one page's content.
*
* Association is not causation: the page says "associated with", and notes that
* a stack of technologies detected on the same pages will share lift values
* (collinearity), so the implicated component still needs human confirmation.
*/
export function renderTechFindingsPage(target, summary) {
const tf = summary.techFindings;
if (!tf || !tf.associations?.length) {
return emptyCriterionPage(target, summary, { active: 'tech-findings', label: 'Technology ↔ issues', message: 'No technology-to-finding associations cleared the support threshold this week (need enough pages where both technology detection and an accessibility engine ran).' });
}
const model = tf.model;
// Group the ranked associations by technology, strongest first.
const byTech = {};
for (const a of tf.associations) (byTech[a.tech] ??= []).push(a);
const techOrder = Object.keys(byTech).sort(
(a, b) => (byTech[b][0]?.lift ?? 0) - (byTech[a][0]?.lift ?? 0)
);
const sections = techOrder
.map((tech) => {
const rows = byTech[tech]
.map((a) => `
${esc(findingLabel(a.finding))}
${a.lift.toFixed(2)}×
${a.pairPages} / ${a.techPages}
${a.findingPages}
`)
.join('\n');
return `${esc(tech)} ${model.tech[tech]} pages
Findings over-represented on pages running ${esc(tech)} (lift ≥ 1, ≥5 pages support).
Finding
Lift
On tech pages
On all pages
${rows}
`;
})
.join('\n');
const body = `
${esc(target.domain)}: technology ↔ issues
${subnav('tech-findings')}
Accessibility findings that appear disproportionately on pages running a given technology, across the ${model.pages} page(s) in ${esc(summary.week)} where both technology detection and an accessibility engine ran. Lift is how many times more likely a finding is on pages with the technology than on pages overall — a value of 2× means twice the baseline rate.
This is an association , not proof of cause. Technologies that are detected on the same set of pages (e.g. a CMS, its host, and its language) will share identical lift values; the listing groups by technology but cannot tell which one in a co-located stack is responsible. Treat high-lift pairs as leads for a human to confirm — a barrier that recurs with the same technology, especially across multiple sites, is likely a bug in that technology rather than in any one page's content.
${sections}`;
return layout({
title: `${target.domain} Tech ↔ Issues ${summary.week} | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} ${esc(summary.week)} Tech ↔ issues `,
body,
depth: 3,
extraScript: SORT_SCRIPT,
});
}
/**
* Third parties page: every third-party vendor (registrable domain) serving
* resources to the site, with its load cost (median bytes/requests/duration
* per page it appears on), whether it serves JavaScript, how widely it's
* deployed, and what share of the pages carrying it also had an accessibility
* finding. Third-party JS is easy to add and frequently degrades a page; this
* makes the cost visible. New-this-week vendors are flagged from the ledger.
*
* The finding share is an association, not proof of cause — a vendor on heavy
* pages will co-occur with findings without causing them. The rigorous causal
* test (blocked-load comparison) is a separate, heavier mode.
*/
export function renderThirdPartyPage(target, summary, csvHref) {
const tp = summary.thirdParty;
if (!tp || !tp.vendors?.length) {
return emptyCriterionPage(target, summary, { active: 'third-party', label: 'Third parties', message: 'No third-party origins were recorded on this week\'s sampled pages (the third-party engine is sampled; some weeks have none).' });
}
const dlLink = csvHref ? ` · Download CSV ` : '';
const rows = tp.vendors
.map((v) => {
const isNew = v.firstSeen && v.firstSeen === summary.week;
const findingShare = v.pages ? Math.round((100 * v.pagesWithFindings) / v.pages) : 0;
return `
${esc(v.origin)}${isNew ? ' new ' : ''}${v.isScriptVendor ? ' JS ' : ''}
${v.pages}
${kb(v.medianBytes)}
${v.medianRequests}
${v.medianDurationMs} ms
${findingShare}%
`;
})
.join('\n');
const scriptVendors = tp.vendors.filter((v) => v.isScriptVendor).length;
const body = `
${esc(target.domain)}: third parties
${subnav('third-party')}
Third-party origins serving resources to ${esc(target.domain)} across the ${tp.pagesScanned} page(s) measured in ${esc(summary.week)} . ${tp.vendors.length} distinct third-party domains, ${scriptVendors} of them serving JavaScript (JS ). Costs are medians per page the vendor appears on.${dlLink}
Third-party JavaScript is easy to add and often reduces accessibility and performance — it injects DOM the site owner never reviewed and adds load time. "Pages w/ finding" is the share of pages carrying this vendor that also had an accessibility finding: an association to investigate, not proof the vendor caused it. Third parties vary per page, so a vendor on few pages may simply not have been sampled elsewhere.
Third-party vendors on ${esc(target.domain)}, ${esc(summary.week)} — sortable.
Vendor (registrable domain)
Pages
Median bytes
Median requests
Median load
Pages w/ finding
${rows}
`;
return layout({
title: `${target.domain} Third Parties ${summary.week} | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} ${esc(summary.week)} Third parties `,
body,
depth: 3,
extraScript: SORT_SCRIPT,
});
}
/**
* Images page: per-page alt-text coverage summary and a table of all
* images found during the week's scan, with their alt text, dimensions,
* and lazy-loading attributes. Links to the images.csv download.
*/
// Human label + explanation for each alt-text verdict.
const ALT_VERDICT_INFO = {
MISSING: ['Missing alt', 'No alt attribute — a screen reader may announce the filename.'],
FILENAME: ['Filename as alt', 'The alt text looks like a filename (e.g. hero_1234.jpg), not a description.'],
SUSPICIOUS: ['Redundant / meaningless', 'Phrases like "image of…" or bare values like "photo" add nothing.'],
TOO_SHORT: ['Too short', 'A single character or word unlikely to convey the image\'s meaning.'],
TOO_LONG: ['Too long', 'So long it probably belongs in a caption or a separate description.'],
DECORATIVE: ['Decorative', 'alt="" or aria-hidden — intentionally not announced. Not a problem.'],
GOOD: ['Looks good', 'Present, plausible, no red flags (still worth a human spot-check).'],
};
const ALT_VERDICT_ORDER = ['MISSING', 'FILENAME', 'SUSPICIOUS', 'TOO_SHORT', 'TOO_LONG', 'GOOD', 'DECORATIVE'];
export function renderImagesPage(target, summary, csvHref) {
const img = summary.images;
if (!img) {
return emptyCriterionPage(target, summary, { active: 'images', label: 'Image inventory', message: 'No images were inventoried on this week\'s sampled pages.' });
}
const links = [
csvHref ? `CSV ` : '',
`JSON `,
].filter(Boolean).join(' · ');
const dlLink = links ? ` Download: ${links}.` : '';
const pct = (n) => img.totalImages ? `${Math.round((n / img.totalImages) * 100)}%` : '0%';
const statsTable = `
Image alt-text coverage across ${img.pagesScanned} page(s) scanned in ${esc(summary.week)}.
Category Count Share
Total image occurrences ${img.totalImages} —
Unique images ${img.uniqueImages ?? '—'} —
Has alt text ${img.withAlt} ${pct(img.withAlt)}
Decorative (alt="") ${img.decorative} ${pct(img.decorative)}
Missing alt attribute ${img.missingAlt} ${pct(img.missingAlt)}
`;
// Alt-text quality summary (counted over unique images).
const verdicts = img.altVerdicts ?? {};
const totalUnique = img.uniqueImages || 1;
const qualityRows = ALT_VERDICT_ORDER
.filter((v) => verdicts[v])
.map((v) => {
const [label, expl] = ALT_VERDICT_INFO[v];
const cls = (v === 'GOOD' || v === 'DECORATIVE') ? '' : ' class="error"';
return `${esc(label)} ${verdicts[v]} ${Math.round((100 * verdicts[v]) / totalUnique)}% ${esc(expl)} `;
})
.join('\n');
const qualitySection = qualityRows ? `
${heading('h-images-quality', 'Alt-text quality')}
Beyond present-vs-missing, each unique image's alt text is classified for quality. Filenames, redundant phrasing ("image of…"), and too-short or too-long values are technically present but unhelpful — the cases a human should rewrite. Decorative and "looks good" are not problems.
Alt-text quality across ${img.uniqueImages} unique image(s).
Verdict Images Share What it means
${qualityRows}
` : '';
// Deduplicated images table using the reusable sortableTable helper.
const tableCols = [
{ label: 'Image URL' },
{ label: 'Alt text' },
{ label: 'Alt verdict' },
{ label: 'Loading' },
{ label: 'Size', num: true },
{ label: 'Pages', num: true },
{ label: 'Occurrences', num: true },
];
const tableRows = (img.uniqueImageList ?? []).slice(0, 500).map((u) => {
const altCell = u.altVerdict === 'MISSING'
? 'missing '
: u.altVerdict === 'DECORATIVE'
? 'decorative '
: esc(u.alt ?? '');
const [vlabel] = ALT_VERDICT_INFO[u.altVerdict] ?? [u.altVerdict];
const vCls = (u.altVerdict === 'GOOD' || u.altVerdict === 'DECORATIVE') ? 'bug-meta' : 'error';
const inconsistent = (u.altCount ?? 1) > 1 ? ` ${u.altCount} alt variants ` : '';
const loadingVal = u.loading ?? '—';
return [
{ html: urlCell(u.src), sort: u.src },
{ html: altCell + inconsistent, sort: u.alt ?? '' },
{ html: `${esc(vlabel)} `, sort: u.altVerdict },
{ html: esc(loadingVal), sort: loadingVal },
{ html: u.bytes != null ? kb(u.bytes) : '—', sort: u.bytes ?? 0 },
{ html: String(u.pages ?? 1), sort: u.pages ?? 1 },
{ html: String(u.occurrences), sort: u.occurrences },
];
});
const detailTable = sortableTable(
`Up to 500 unique image occurrences from ${img.pagesScanned} page(s) scanned, most-reused first.`,
tableCols,
tableRows
);
const body = `
${esc(target.domain)}: image inventory
${subnav('images')}
Unique images encountered on scanned pages in ${esc(summary.week)} , deduplicated by URL and Alt — the same image reused across pages with the same explanation is one row. Images with alternate captions are split into separate rows. ${dlLink}
${statsTable}
${qualitySection}
${heading('h-images-detail', 'Image detail')}
${detailTable}
`;
return layout({
title: `${target.domain} Images ${summary.week} | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} ${esc(summary.week)} Images `,
body,
depth: 3,
extraScript: SORT_SCRIPT,
});
}
/**
* Archive page: every retained ISO-week report for a domain, newest
* first, with key metrics and a week-over-week comparison. Lets reviewers
* jump back to W24 etc. Lives at the domain's latest-week directory and
* links into each week's own report folder.
*/
export function renderArchivePage(target, series, latestWeek) {
if (!series || series.length === 0) return null;
const ordered = [...series].reverse(); // newest first
const rows = ordered
.map((s, i) => {
const newer = ordered[i - 1]; // the week after this one (for delta)
const sc = scoreFor(s);
const med = s.axe.medianViolations ?? 0;
const d = newer ? med - (newer.axe.medianViolations ?? 0) : null;
return `
${esc(s.week)}
${sc ? `${esc(sc.grade)} ${sc.score}` : 'n/a'}
${s.pagesAudited ?? s.pagesScanned}
${fmtMedian(s.axe.medianViolations)}${d != null && d !== 0 ? ` ${delta(d)}` : ''}
${fmtMedian(s.alfa.medianFailures)}
`;
})
.join('\n');
const body = `
${esc(target.domain)}: report archive
${subnav('archive')}
Every recorded ISO week for this site, newest first. The dashboard headline uses a rolling last-7-days window; these are the full per-week reports for week-over-week comparison.
Weekly reports for ${esc(target.domain)} (${series.length} weeks).
Week Score Pages audited Median axe / page Median Alfa / page
${rows}
`;
return layout({
title: `${target.domain} archive | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} Archive `,
body,
depth: 3,
});
}
export function renderDomainReport(target, summary, prev, diff, series, bugs = [], csvLinks = { byRule: {}, bugsAll: null }, invSummary = null) {
const score = scoreFor(summary);
const traj = trajectory(series, 4);
const trendViol = series.map((s) => s.axe.medianViolations ?? 0);
const csvLink = (href, text) => (href ? ` ${text} ` : '');
const resolvedCount = diff ? (diff.axe.resolved.length + diff.alfa.resolved.length) : 0;
const body = `
${esc(target.domain)}: week ${esc(summary.week)}
${subnav('overview')}
This is the ${esc(summary.week)} ISO-week report (${summary.pagesScanned} pages fetched, ${summary.pagesAudited ?? summary.pagesScanned} unique pages audited by axe/Alfa). Generated ${esc(summary.generatedAt.slice(0, 10))}.
${prev ? `Compared against ${esc(prev.week)} (${prev.pagesScanned} fetched).` : 'First recorded week; no comparison yet.'} The dashboard headline uses a rolling last-7-days window; this page is the full ISO week.
${score ? `
${esc(score.grade)}
${score.score}/100 ${esc(score.band)}
${esc(scoreMeaning(summary, score))}
${traj ? `${esc(traj.direction)} (${traj.delta >= 0 ? '+' : ''}${traj.delta} pts since ${esc(traj.fromWeek)}).` : ''}
${resolvedCount > 0 ? `${resolvedCount} issue type(s) resolved since last week.` : ''}
Score reflects the typical page's issue count vs other government sites (lower is better). Automated testing finds ~⅓ of barriers — a good score is a floor, not a finish line.
` : ''}
${invSummary ? `Over the whole history of this site, ${invSummary.totalKnownPages} unique pages have been scanned at least once; ${invSummary.pagesWithKnownIssues} have known accessibility issues. ${invSummary.scannedThisWeek} of them were re-checked this ISO week. Download full data (JSON) .
` : ''}
${heading('h-summary', `This week at a glance`)}
Median axe violations / page ${fmtMedian(summary.axe.medianViolations)} ${sparkline(trendViol)}
Pages with axe violations ${summary.axe.pagesWithViolations} of ${summary.axe.pagesScanned ?? summary.pagesScanned}${csvLink(csvLinks.axeAll, 'CSV')}
Median Alfa failures / page ${fmtMedian(summary.alfa.medianFailures)}
Pages with Alfa failures ${summary.alfa.pagesWithFailures} of ${summary.alfa.pagesScanned ?? summary.pagesScanned}${csvLink(csvLinks.alfaAll, 'CSV')}
Unique pages audited ${summary.pagesAudited ?? summary.pagesScanned}
${summary.lighthouse ? `
Lighthouse performance (median) ${fmtScore(summary.lighthouse.medianPerformance)} ${summary.lighthouse.pagesSampled} sampled ${csvLink('lighthouse.html', 'details')}
Lighthouse SEO (median) ${fmtScore(summary.lighthouse.medianSeo)}
Lighthouse best practices (median) ${fmtScore(summary.lighthouse.medianBestPractices)}
${summary.lighthouse.medianAgentic != null ? `
Lighthouse agentic (median) ${fmtScore(summary.lighthouse.medianAgentic)} ` : ''}` : ''}
${summary.plainLanguage ? `
Words per page (median) ${summary.plainLanguage.medianWordsPerPage ?? 'n/a'} main content, nav excluded
${summary.plainLanguage.medianReadingEase != null ? `
Reading ease (median) ${summary.plainLanguage.medianReadingEase} ${summary.plainLanguage.pagesScored} prose pages ${csvLink(summary.plainLanguage.readabilityCsv, 'details')} ` : ''}
${summary.plainLanguage.medianGrade != null ? `
Reading grade (median) ${summary.plainLanguage.medianGrade} ` : ''}
${summary.plainLanguage.topMisspellings?.length ? `
Misspellings ${summary.plainLanguage.topMisspellings.length}+ distinct${csvLink('readability.html#h-spelling', 'details')} ` : ''}` : ''}
${summary.linkCheck ? `
Broken links ${summary.linkCheck.brokenCount}${summary.linkCheck.brokenCount > 0 ? ` details ` : ''} ` : ''}
${summary.sustainability ? `
Median page weight ${kb(summary.sustainability.medianBytes)}
${diff?.sustainability ? delta(Math.round(diff.sustainability.medianBytesDelta / 1024), { unit: ' KB' }) : ''}
Median requests per page ${summary.sustainability.medianRequests}
${sustainabilityHeadline(summary.sustainability).label} ${sustainabilityHeadline(summary.sustainability).value} ` : ''}
${prev && summary.pagesScanned !== prev.pagesScanned ? `Note: page counts differ between weeks (${prev.pagesScanned} → ${summary.pagesScanned}). Prefer the "pages affected" columns over raw instance counts when comparing.
` : ''}
${coverageTable(summary)}
${series.length > 1 ? `
${heading('h-trends', `Trends over time`)}
${lineChart('Median axe violations per page', series.map((s) => ({ week: s.week, value: s.axe.medianViolations })), { lowerIsBetter: true })}
${lineChart('Median Alfa failures per page', series.map((s) => ({ week: s.week, value: s.alfa.medianFailures })), { lowerIsBetter: true })}
${series.some((s) => s.plainLanguage?.medianReadingEase != null) ? lineChart('Reading ease (median)', series.map((s) => ({ week: s.week, value: s.plainLanguage?.medianReadingEase ?? null })), { lowerIsBetter: false }) : ''}
${series.some((s) => s.sustainability) ? lineChart('Median page weight (KB)', series.map((s) => ({ week: s.week, value: s.sustainability ? Math.round(s.sustainability.medianBytes / 1024) : null })), { unit: ' KB', lowerIsBetter: true }) : ''}
` : ''}
${diff ? `
${heading('h-wow', `Changes since ${diff.prevWeek}`)}
${changeList('axe-core', diff.axe)}
${changeList('Alfa', diff.alfa)}
` : ''}
${fixFirstSection(bugs)}
${resourcesSection(summary)}
`;
return layout({
title: `${target.domain} ${summary.week} | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} ${esc(summary.week)} `,
body,
depth: 3,
});
}
/**
* Standalone accessibility page: bug reports (with anchored per bug),
* axe-core and Alfa rule tables, and the consensus deduplication summary.
* Linked from the overview and from "Fix these first" deep links.
*/
export function renderAccessibilityPage(target, summary, bugs, csvLinks, reporting = {}) {
const body = `
${esc(target.domain)}: Accessibility — week ${esc(summary.week)}
${subnav('accessibility')}
${bugReportsSection(target, summary, bugs, csvLinks.bugsAll ?? null, reporting)}
${heading('h-axe', `Deque axe-core findings`)}
Rule-level axe-core summary (${Object.keys(summary.axe.rules).length} rule type(s))
Each failing rule links out to the axe-core documentation. For full element-level detail including HTML snippets and XPaths, see the bug reports above.
${ruleTable(`axe-core rules failing in ${summary.week}, by pages affected`, summary.axe.rules, 'axe-core', 'axe-core', csvLinks)}
${heading('h-alfa', `Siteimprove Alfa findings`)}
Rule-level Alfa summary (${Object.keys(summary.alfa.rules).length} rule type(s))
Rule-level summary from Siteimprove Alfa (W3C ACT-based). Findings that overlap with axe-core on the same WCAG success criterion are noted as possible duplicates in the bug reports above.
${ruleTable(`Alfa rules failing in ${summary.week}, by pages affected`, summary.alfa.rules, 'Alfa', 'alfa', csvLinks)}
${consensusSection(summary)}
`;
return layout({
title: `${target.domain} Accessibility ${summary.week} | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} ${esc(summary.week)} Accessibility `,
body,
depth: 3,
});
}
/**
* Standalone standards & security page.
*/
export function renderStandardsPage(target, summary) {
const content = standardsSecuritySection(summary);
if (!content) {
return emptyCriterionPage(target, summary, { active: 'standards', label: 'Standards & Security', message: 'No web-standards or security checks ran on this week\'s sampled pages.' });
}
const body = `
${esc(target.domain)}: Standards & Security — week ${esc(summary.week)}
${subnav('standards')}
${content}
`;
return layout({
title: `${target.domain} Standards ${summary.week} | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} ${esc(summary.week)} Standards `,
body,
depth: 3,
});
}
/**
* Standalone errors page: broken links and non-404 error pages.
*/
export function renderErrorsPage(target, summary, csvHref = null) {
const content = linksAndErrorsSection(summary, csvHref);
if (!content) {
return emptyCriterionPage(target, summary, { active: 'errors', label: 'Broken Links & Errors', message: 'No broken links or error pages were found on this week\'s sampled pages — clean week.' });
}
const body = `
${esc(target.domain)}: Broken Links & Errors — week ${esc(summary.week)}
${subnav('errors')}
${content}
`;
return layout({
title: `${target.domain} Errors ${summary.week} | vital-scans`,
breadcrumb: `All domains ${esc(target.domain)} ${esc(summary.week)} Errors `,
body,
depth: 3,
});
}
function changeList(engineName, d) {
const items = [];
for (const id of d.appeared) items.push(`New: ${esc(id)} appeared this week. `);
for (const id of d.resolved) items.push(`Resolved: ${esc(id)} no longer fails on any scanned page. `);
for (const c of d.changed) {
const dir = c.pagesAfter > c.pagesBefore ? 'spread' : 'shrank';
items.push(`${esc(c.id)} ${dir}: ${c.pagesBefore} → ${c.pagesAfter} pages affected. `);
}
if (items.length === 0) return `${esc(engineName)} No rule-level changes.
`;
return `${esc(engineName)} `;
}
/**
* Fleet-wide sustainability trend: fleet mean CO₂g (or Wh) per page across
* all active domains by week, expressed as a simple week-over-week line chart.
* Only weeks where ≥1 domain has sustainability data contribute.
*/
function fleetSustainabilityChart(ranked) {
const withData = ranked.filter((d) => d.series.some((s) => s.sustainability));
if (withData.length === 0) return '';
const allWeeks = [...new Set(withData.flatMap((d) => d.series.map((s) => s.week)))].sort();
if (allWeeks.length < 2) return '';
// Per week: fleet mean of each domain's mean CO₂g/page (equal-weight per domain).
const useEnergy = SUSTAINABILITY_METRIC === 'energy';
const pts = allWeeks.map((week) => {
const vals = withData
.map((d) => {
const s = d.series.find((x) => x.week === week);
return useEnergy ? (s?.sustainability?.meanEnergyWh ?? null) : (s?.sustainability?.meanCo2g ?? null);
})
.filter((v) => v != null);
return { week, value: vals.length ? Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 1000) / 1000 : null };
}).filter((p) => p.value != null);
if (pts.length < 2) return '';
const label = useEnergy ? 'Fleet mean energy per page (Wh)' : 'Fleet mean CO₂ per page (g)';
const unit = useEnergy ? ' Wh' : ' g';
return lineChart(label, pts, { unit, lowerIsBetter: true });
}
/**
* Overlay line chart: every domain's median axe violations/page over the
* weeks they share. Accessible (role=img + aria-label + a data-table
* fallback). Each domain gets a distinct dash pattern (not color alone).
*/
function crossDomainChart(ranked) {
const withSeries = ranked.filter((d) => d.series.length > 1);
if (withSeries.length < 1) return '';
const allWeeks = [...new Set(withSeries.flatMap((d) => d.series.map((s) => s.week)))].sort();
if (allWeeks.length < 2) return '';
const W = 720, H = 240, padL = 40, padR = 140, padT = 16, padB = 28;
const valAt = (d, week) => {
const s = d.series.find((x) => x.week === week);
return s ? (s.axe.medianViolations ?? null) : null;
};
const allVals = withSeries.flatMap((d) => allWeeks.map((w) => valAt(d, w))).filter((v) => v != null);
if (allVals.length === 0) return '';
const max = Math.max(...allVals, 1);
const x = (i) => padL + (i / (allWeeks.length - 1)) * (W - padL - padR);
const y = (v) => H - padB - (v / max) * (H - padT - padB);
const dashes = ['', '6 3', '2 3', '8 3 2 3', '4 2'];
const lines = withSeries.map((d, di) => {
const pts = allWeeks
.map((w, i) => ({ i, v: valAt(d, w) }))
.filter((p) => p.v != null)
.map((p) => `${x(p.i).toFixed(1)},${y(p.v).toFixed(1)}`)
.join(' ');
const ly = padT + 14 + di * 16;
return {
line: ` `,
legend: `${esc(d.target.domain)} `,
};
});
const xlabels = [0, Math.floor((allWeeks.length - 1) / 2), allWeeks.length - 1]
.filter((v, idx, a) => a.indexOf(v) === idx)
.map((i) => `${esc(allWeeks[i].slice(5))} `)
.join('');
const table = `Median axe violations per page by domain and week
Domain ${allWeeks.map((w) => `${esc(w)} `).join('')}
${withSeries.map((d) => `${esc(d.target.domain)} ${allWeeks.map((w) => `${valAt(d, w) ?? '—'} `).join('')} `).join('')}
`;
return `
Median axe violations per page, all domains — lower is better
${lines.map((l) => l.line).join('')}${lines.map((l) => l.legend).join('')}${xlabels}
${max} 0
${table}
`;
}
export function renderIndex(dashboard, { branding = {} } = {}) {
// Profile branding overrides the default headline/intro; absent (the
// GitHub Pages default) it falls back to the original copy unchanged.
const h1 = branding.title || 'Weekly quality ledger';
const intro = branding.intro
|| 'Accessibility and sustainability, measured continuously with open source engines. '
+ 'Thousands of pages per domain, scanned slowly and politely across each week.';
const pageTitle = branding.title ? `${branding.title} | vital-scans` : 'vital-scans | weekly quality ledger';
// Separate targets whose latest week is blocked (e.g. a WAF returning
// 403 to the scanner) so they don't read as zero-violation successes.
const blocked = dashboard.filter(({ series }) => series[series.length - 1].blocked);
const active = dashboard.filter(({ series }) => !series[series.length - 1].blocked);
// Blocked targets are useful context but not the headline — collapsed
// into an accordion at the bottom of the dashboard, not up top.
const blockedCallout = blocked.length === 0 ? '' : `
Blocked targets (${blocked.length})
These sites returned only access-denied responses to the scanner, so no
accessibility or sustainability data could be collected. This is typically a
WAF or bot manager blocking automated traffic, not a scan failure. See
WAF-ALLOWLIST.md
for how the scanner can be allowlisted.
${blocked
.map(({ target, series }) => {
const latest = series[series.length - 1];
return `${esc(target.domain)} — HTTP ${latest.blocked.status} (${esc(latest.week)}) `;
})
.join('\n')}
`;
// Leaderboard: rank domains best->worst by score (computed over the
// trailing-7-day window so it's a fair, like-for-like benchmark), with
// trajectory. Links still point at the latest ISO-week report.
const medAxe = (s) => s.axe.medianViolations ?? 0;
const ranked = active
.map((d) => {
const win = d.windowSummary ?? d.series[d.series.length - 1];
return { ...d, latest: d.series[d.series.length - 1], win, score: scoreFor(win), traj: trajectory(d.series, 4) };
})
.sort((a, b) => (b.score?.score ?? -1) - (a.score?.score ?? -1));
const arrow = (t) => {
if (!t) return '— new ';
const sym = t.direction === 'improving' ? '▲' : t.direction === 'worsening' ? '▼' : '▬';
return `${sym} ${esc(t.direction)} ${t.delta >= 0 ? '+' : ''}${t.delta} `;
};
const rows = ranked
.map((d) => {
const { target, series, latest, win, score, traj } = d;
const trend = series.map(medAxe);
return `
${esc(target.domain)}
${score ? `${esc(score.grade)} ${score.score}` : 'n/a'}
${arrow(traj)}
${win.pagesAudited ?? win.pagesScanned}
${fmtMedian(win.axe.medianViolations)}
${fmtMedian(win.alfa.medianFailures)}
${sparkline(trend)}Median axe violations per page over ${series.length} weeks: ${trend.join(', ')}.
`;
})
.join('\n');
// Overlay chart: every domain's median axe violations/page over time.
const overlay = crossDomainChart(ranked);
// Fleet sustainability trend: mean CO₂g/page across all domains by week.
const sustainTrend = fleetSustainabilityChart(ranked);
// Fleet-wide worst offenders: highest-impact issues across all domains.
const worst = fleetWorstOffenders(active.map((d) => ({ target: d.target, bugs: d.bugs ?? [] })), 20);
const worstSection = worst.length === 0 ? '' : `
${heading('h-worst', `Worst offenders across all domains`)}
Highest-impact issues fleet-wide, ranked by pages affected × severity × people reached — where to focus effort first.
Top ${worst.length} issues across all active domains.
Domain Issue Severity Pages
${worst
.map((b) => `
${esc(b.domain)}
${esc(b.summary)}
${esc(b.severity)}
${b.frequency.pages_affected}
`)
.join('\n')}
`;
// Fleet-wide tech ↔ issue associations: merge every active domain's latest
// tech↔finding model, then rank pairs by lift × sites-affected. A finding
// that recurs with the same technology across multiple independent sites is
// the strongest systemic signal — likely a bug in that technology itself.
const tfEntries = active
.map((d) => {
const latest = d.series[d.series.length - 1];
return latest.techFindings?.model ? { domain: d.target.domain, model: latest.techFindings.model } : null;
})
.filter(Boolean);
let techFindingsSection = '';
if (tfEntries.length >= 2) {
const fleet = mergeFleet(tfEntries);
const fleetPairs = rankFleetAssociations(fleet, { minPages: 5, minSites: 2, limit: 25 });
if (fleetPairs.length) {
techFindingsSection = `
${heading('h-techfindings', `Cross-technology issues`)}
Accessibility findings that recur with the same technology across multiple sites — the strongest signal that a barrier lives in a shared CMS, theme, or widget rather than in one site's content. Ranked by lift × number of sites affected. Association, not proof of cause: confirm before attributing.
Top ${fleetPairs.length} technology ↔ finding associations spanning ≥2 sites.
Technology
Finding
Lift
Sites
Pages
${fleetPairs
.map((p) => `
${esc(p.tech)}
${esc(findingLabel(p.finding))}
${p.lift.toFixed(2)}×
${p.sites}
${p.pairPages}
`)
.join('\n')}
`;
}
}
// Fleet-wide Lighthouse recommendations: merge each domain's latest
// non-accessibility recommendations by audit id, tracking how many sites and
// how many pages each affects, plus total estimated savings. A recommendation
// common across many independent government sites is a shared platform/CDN
// problem worth a coordinated fix.
const lhMerged = {}; // auditId -> { id, category, title, sites, pages, savingsBytes, savingsMs }
for (const d of active) {
const recos = d.series[d.series.length - 1]?.lighthouse?.recommendations ?? [];
for (const r of recos) {
const e = (lhMerged[r.id] ??= { id: r.id, category: r.category, title: r.title, sites: 0, pages: 0, savingsBytes: 0, savingsMs: 0 });
e.sites++;
e.pages += r.pages ?? 0;
e.savingsBytes += r.savingsBytes ?? 0;
e.savingsMs += r.savingsMs ?? 0;
}
}
const lhFleet = Object.values(lhMerged)
.filter((e) => e.sites >= 2) // only issues common to multiple sites
.sort((a, b) => b.sites - a.sites || b.pages - a.pages)
.slice(0, 25);
let lighthouseFleetSection = '';
if (lhFleet.length) {
const catLabel = (c) => LH_CATEGORY_LABELS[c] ?? c;
lighthouseFleetSection = `
${heading('h-lhfleet', `Common Lighthouse recommendations`)}
Non-accessibility issues Google Lighthouse flagged on multiple sites' sampled pages — performance, best-practices, SEO, and AI-readiness. Recurring across independent government sites usually points at a shared platform, theme, or CDN, where one coordinated fix helps everyone. Ranked by number of sites affected. Accessibility audits are omitted (they overlap with axe-core).
Top ${lhFleet.length} Lighthouse recommendations spanning ≥2 sites.
Recommendation
Category
Sites
Pages
Est. saving
${lhFleet
.map((e) => {
const saving = [fmtSavingsBytes(e.savingsBytes), e.savingsMs ? `${(e.savingsMs / 1000).toFixed(1)}s` : '']
.filter(Boolean).join(' · ') || '—';
return `
${esc(e.title)}
${esc(catLabel(e.category))}
${e.sites}
${e.pages}
${saving}
`;
})
.join('\n')}
`;
}
const body = `
${esc(h1)}
${esc(intro)}
${active.length === 0
? (dashboard.length === 0
? 'No scan data yet. The first weekly report appears after the first scheduled scans complete.
'
: 'No accessibility or sustainability data could be collected yet — every target is currently blocked (see the bottom of this page).
')
: `
Domains ranked by accessibility score (best first). Trajectory compares the score against ~4 weeks ago. Counts are medians per page over the last 7 days, comparable across sites of any size.
Domain Score Trajectory Pages audited (7d) Median axe / page Median Alfa / page Trend
${rows}
Scores are a relative, automated signal based on axe violations per page (axe runs on every page; Alfa is sampled and reported separately). Automated testing finds only ~⅓ of barriers — use scores to compare and track direction, not as a pass/fail.
${overlay}
${sustainTrend}
${worstSection}
${techFindingsSection}
${lighthouseFleetSection}
${blockedCallout}`}
${heading('h-why', `Why this exists`)}
Continuous measurement beats one-off audits. This ledger tracks whether each site is getting more
accessible and lighter over time, using axe-core and
Alfa (the open source engine behind Siteimprove) for
accessibility, and page weight with Sustainable Web Design
CO₂ estimates for sustainability. Everything here is open: the scanner, the data, and the reports.
`;
return layout({ title: pageTitle, breadcrumb: '', body, depth: 0 });
}
export function writeAsset(docsDir) {
fs.writeFileSync(path.join(docsDir, 'style.css'), CSS);
// Serve the vendored ParaCharts runtime first-party (AGPL-3.0). Charts are
// progressively enhanced: the static SVG + table render without it; this
// bundle is lazy-imported only on report pages to upgrade them. Copied as a
// build artifact (never committed to docs/), like style.css.
const vendorBundle = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../vendor/paracharts/paracharts.js');
if (fs.existsSync(vendorBundle)) {
fs.copyFileSync(vendorBundle, path.join(docsDir, 'paracharts.js'));
}
}
const CSS = `/* vital-scans ledger. System fonts only; ~2 KB; honors user color scheme. */
:root {
--ink: #1c2326; --paper: #fbfaf7; --accent: #00585c; --rule: #c9c4b8;
--better: #1d5c2f; --worse: #8c2f1b; --muted: #5a6166;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --ink: #e8e6e1; --paper: #14181a; --accent: #6fd2d6; --rule: #3a4145;
--better: #8fd6a0; --worse: #f0a48d; --muted: #9aa3a8; }
}
/* Manual overrides (mirror the values above) win over the OS preference. */
:root[data-theme="light"] { --ink: #1c2326; --paper: #fbfaf7; --accent: #00585c; --rule: #c9c4b8;
--better: #1d5c2f; --worse: #8c2f1b; --muted: #5a6166; }
:root[data-theme="dark"] { --ink: #e8e6e1; --paper: #14181a; --accent: #6fd2d6; --rule: #3a4145;
--better: #8fd6a0; --worse: #f0a48d; --muted: #9aa3a8; }
* { box-sizing: border-box; }
.theme-toggle { display: inline-flex; align-items: center; gap: .35rem; margin-top: .5rem;
background: transparent; color: var(--ink); border: 1px solid var(--rule); border-radius: 4px;
padding: .3rem .6rem; font: inherit; font-size: .85rem; cursor: pointer; }
.theme-toggle:hover { border-color: var(--accent); }
.theme-toggle:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.theme-toggle .icon-sun { display: none; }
.theme-toggle .icon-moon { display: inline; }
:root[data-theme="dark"] .theme-toggle .icon-sun,
.theme-toggle[aria-pressed="true"] .icon-sun { display: inline; }
:root[data-theme="dark"] .theme-toggle .icon-moon,
.theme-toggle[aria-pressed="true"] .icon-moon { display: none; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .theme-toggle .icon-sun { display: inline; }
:root:not([data-theme="light"]) .theme-toggle .icon-moon { display: none; }
}
body { margin: 0 auto; max-width: 72rem; padding: 1rem 1.25rem 3rem;
font-family: ui-sans-serif, system-ui, sans-serif; line-height: 1.55;
color: var(--ink); background: var(--paper); }
a { color: var(--accent); }
a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.skip { position: absolute; left: -999px; }
.skip:focus { left: 1rem; top: 1rem; background: var(--paper); padding: .5rem 1rem;
border: 2px solid var(--accent); z-index: 1; }
header { border-bottom: 3px double var(--rule); padding-bottom: .5rem; margin-bottom: 1.5rem; }
.brand { font-variant: small-caps; letter-spacing: .06em; font-size: 1.1rem; margin: 0; }
.brand a { text-decoration: none; color: var(--ink); font-weight: 700; }
.tag { color: var(--muted); font-size: .85rem; letter-spacing: .1em; }
.crumbs { list-style: none; padding: 0; margin: .25rem 0 0; font-size: .9rem; }
.crumbs li { display: inline; }
.crumbs li + li::before { content: " / "; color: var(--muted); }
h1 { font-size: 1.6rem; line-height: 1.2; }
h2 { font-size: 1.2rem; border-bottom: 1px solid var(--rule); padding-bottom: .2rem; margin-top: 2.5rem; scroll-margin-top: 1rem; }
/* Shareable heading anchor: a "#" that appears on hover/focus. The glyph
is a CSS ::before so it is never part of the heading's copyable text. */
.anchor { float: left; margin-left: -1.1em; padding-right: .3em; color: var(--rule);
text-decoration: none; opacity: 0; transition: opacity .1s, color .1s; }
.anchor::before { content: "#"; }
h2:hover .anchor, h2:focus-within .anchor, .anchor:focus { opacity: 1; color: var(--accent); }
@media (max-width: 40rem) { .anchor { float: none; margin-left: 0; } }
/* Long URLs: truncate with ellipsis, full URL on hover/focus (title);
the text stays fully selectable so it copies in full. */
.url { display: inline-block; max-width: 28rem; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; vertical-align: bottom; }
td .url, th .url { max-width: 22rem; }
.subnav ul { list-style: none; display: flex; flex-wrap: wrap; gap: .25rem 1rem; padding: 0; margin: .25rem 0 1rem; font-size: .95rem; }
.subnav li[aria-current="page"] { font-weight: 700; }
.sort-btn { background: none; border: 0; padding: 0; margin: 0; font: inherit; color: inherit;
text-transform: inherit; letter-spacing: inherit; cursor: pointer; }
.sort-btn:hover, .sort-btn:focus-visible { color: var(--accent); }
.sort-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.meta, .note { color: var(--muted); }
.note { border-left: 4px solid var(--rule); padding-left: .75rem; }
.callout-blocked { border-left: 4px solid var(--worse); padding: .25rem 1rem;
background: color-mix(in srgb, var(--worse) 8%, transparent); border-radius: 2px; }
.callout-blocked h2 { color: var(--worse); border-bottom: none; margin-top: .75rem; }
.callout-blocked ul { margin: .5rem 0; }
.blocked-accordion { margin-top: 2rem; border-top: 1px solid var(--rule); padding-top: .5rem; }
.blocked-accordion > summary { cursor: pointer; color: var(--muted); font-weight: 600; }
.ledger { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: .75rem 2rem; margin: 1rem 0; }
.ledger div { border-top: 1px solid var(--rule); padding-top: .4rem; }
.ledger dt { font-size: .85rem; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
.ledger dd { margin: 0; font-size: 1.4rem; font-variant-numeric: tabular-nums; }
.delta { font-size: .85rem; padding: 0 .35rem; border: 1px solid currentColor; border-radius: 2px;
white-space: nowrap; vertical-align: middle; }
.delta.worse { color: var(--worse); }
.delta.better { color: var(--better); }
.delta.same { color: var(--muted); }
table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: .95rem; }
caption { text-align: left; color: var(--muted); font-size: .85rem; padding-bottom: .4rem; }
th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--rule);
vertical-align: top; }
thead th { border-bottom: 2px solid var(--ink); font-size: .8rem; text-transform: uppercase;
letter-spacing: .05em; }
td.num { text-align: right; font-variant-numeric: tabular-nums; }
tbody th[scope="row"] { font-weight: 600; }
.spark { color: var(--accent); vertical-align: middle; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap; }
footer { margin-top: 3rem; border-top: 3px double var(--rule); padding-top: 1rem;
font-size: .85rem; color: var(--muted); }
.scorecard { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1rem;
border: 1px solid var(--rule); border-left: 5px solid var(--accent); border-radius: 4px;
padding: .8rem 1rem; margin: 1rem 0; }
.scorecard .grade { font-size: 2rem; font-weight: 700; line-height: 1;
padding: .1rem .5rem; border-radius: 4px; border: 2px solid currentColor; }
.grade-A { color: var(--better); } .grade-B { color: var(--better); }
.grade-C { color: var(--muted); } .grade-D { color: var(--worse); } .grade-F { color: var(--worse); }
.scorecard .score { font-size: 1.6rem; font-variant-numeric: tabular-nums; }
.scorecard .score-max { font-size: .9rem; color: var(--muted); }
.scorecard .band { font-size: 1rem; color: var(--muted); margin-left: .25rem; }
.scorecard .score-detail { flex: 1 1 16rem; }
.scorecard .score-caveat, p.score-caveat { color: var(--muted); font-size: .85rem; flex-basis: 100%; }
.traj { white-space: nowrap; font-size: .9rem; }
.traj-improving { color: var(--better); } .traj-worsening { color: var(--worse); } .traj-stable { color: var(--muted); }
.chart { margin: 1.25rem 0; }
.chart figcaption { color: var(--muted); font-size: .9rem; margin-bottom: .25rem; }
.linechart { width: 100%; height: auto; color: var(--accent); }
.linechart .axis { fill: var(--muted); font-size: 11px; }
.checklist { list-style: none; padding: 0; margin: .5rem 0; }
.checklist li { padding: .2rem 0; }
.checklist .check { display: inline-block; width: 1.2em; font-weight: 700; }
.checklist li.pass .check { color: var(--better); }
.checklist li.fail .check { color: var(--worse); }
@media (prefers-reduced-motion: no-preference) { a { transition: color .15s; } }
.bug-filter { margin: .75rem 0 1rem; padding: .75rem .9rem; border: 1px solid var(--rule);
border-radius: 2px; background: color-mix(in srgb, var(--accent) 5%, transparent); }
.bug-filter-row { display: flex; flex-wrap: wrap; gap: .75rem 1.25rem; align-items: end; }
.bug-filter label { display: flex; flex-direction: column; gap: .2rem; font-size: .85rem; font-weight: 600; }
.bug-filter-check { flex-direction: row; align-items: center; gap: .4rem; }
.bug-filter select { font: inherit; padding: .25rem .4rem; }
.bug-filter button { font: inherit; padding: .3rem .7rem; cursor: pointer; }
.bug-filter-count { margin: .6rem 0 0; font-size: .85rem; color: var(--muted); }
.bug-filter-empty { padding: .9rem; border: 1px dashed var(--rule); border-radius: 2px; color: var(--muted); }
.bug { border: 1px solid var(--rule); border-left-width: 4px; border-radius: 2px;
margin: .6rem 0; padding: 0 .9rem; }
.bug > summary { cursor: pointer; padding: .6rem 0; font-weight: 600; }
.bug[open] > summary { border-bottom: 1px solid var(--rule); margin-bottom: .6rem; }
.engine-findings > summary { cursor: pointer; font-weight: 600; padding: .4rem 0; }
.bug.sev-critical { border-left-color: var(--worse); }
.bug.sev-high { border-left-color: var(--worse); }
.bug.sev-medium { border-left-color: var(--accent); }
.bug.sev-low { border-left-color: var(--muted); }
.sev-badge { display: inline-block; font-size: .75rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; padding: 0 .4rem; border: 1px solid currentColor; border-radius: 2px;
vertical-align: middle; margin-right: .4rem; }
.sev-critical .sev-badge, .sev-high .sev-badge { color: var(--worse); }
.sev-medium .sev-badge { color: var(--accent); }
.sev-low .sev-badge { color: var(--muted); }
.wcag-badge { display: inline-block; font-size: .72rem; font-weight: 600; padding: 0 .4rem;
border-radius: 2px; vertical-align: middle; margin-right: .35rem;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
.wcag-badge[data-cat="best-practice"] { background: color-mix(in srgb, var(--muted) 12%, transparent);
color: var(--muted); border-color: color-mix(in srgb, var(--muted) 35%, transparent); }
.bug-meta { font-weight: 400; color: var(--muted); font-size: .85rem; }
.bug-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: .3rem 1.5rem; margin: .3rem 0; }
.bug-fields div { border-top: 1px solid var(--rule); padding-top: .25rem; }
.bug-fields dt { font-size: .8rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
.bug-fields dd { margin: 0; }
.bug-label { font-size: .8rem; color: var(--muted); text-transform: uppercase; letter-spacing: .04em;
margin: .8rem 0 .2rem; }
.bug-placeholder { color: var(--muted); font-style: italic; }
.bug pre { background: color-mix(in srgb, var(--ink) 6%, transparent); padding: .6rem .8rem;
border-radius: 2px; overflow-x: auto; font-size: .85rem; }
.error { color: var(--worse); font-weight: 600; }
`;