import fs from 'node:fs'; import path from 'node:path'; import { scoreFor, trajectory, scoreMeaning } from './lib/score.js'; import { rankBugs, fleetWorstOffenders } from './lib/priority.js'; import { prioritizeAccessibilityBugs } from './lib/accessibility-priority.js'; import { performanceImpact } from './lib/perf-impact.js'; import { mergeFleet, rankFleetAssociations } from './lib/tech-findings.js'; import { buildLineManifest } from './lib/paracharts.js'; import { rulePlainLabel } from './lib/rule-label.js'; /** * Report design constraints, in priority order: * 1. ACCESSIBILITY.md: semantic structure, visible focus, no * color-only meaning, real tables with captions and scope, * keyboard-complete, dark mode honored. * 2. SUSTAINABILITY.md: no JavaScript, no web fonts, one small shared * stylesheet, sparklines as inline SVG marked decorative with the * same data available in tables. A report about page weight should * not be heavy. * 3. Then aesthetics: a quiet civic ledger. */ const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); const kb = (b) => (b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : Math.round(b / 1024) + ' KB'); // Report-wide display preference for the sustainability figure, set once // by aggregate via setSustainabilityMetric(). 'co2' or 'energy'. let SUSTAINABILITY_METRIC = 'co2'; export function setSustainabilityMetric(metric) { SUSTAINABILITY_METRIC = metric === 'energy' ? 'energy' : 'co2'; } /** The headline sustainability stat for a page, per the configured metric. */ function sustainabilityHeadline(s) { if (!s) return null; return SUSTAINABILITY_METRIC === 'energy' ? { label: 'Estimated energy per page (mean)', value: `${s.meanEnergyWh ?? 'n/a'} Wh` } : { label: 'Estimated CO₂ per page (mean)', value: `${s.meanCo2g} g` }; } const fmtScore = (s) => (s == null ? 'n/a' : `${s}/100`); const fmtMedian = (n) => (n == null ? 'n/a' : String(n)); /** * Affected-page display for a bug: list URLs inline when there are 25 or * fewer (more useful than a CSV link for a handful); above that, list the * first 25 then link the full CSV. Used in the detailed bug blocks (not * the compact "fix first" table, which stays a single count + CSV link). */ function affectedPagesBlock(b) { const total = b.frequency.pages_affected; const urls = b.affected_pages ?? []; const li = (u) => { let label = u; try { label = new URL(u).pathname || u; } catch { /* keep raw */ } return `
  • ${esc(label)}
  • `; }; if (total <= 25 && urls.length >= total) { return ``; } const more = b.affected_pages_csv ? `all ${total} pages (CSV)` : `${total} pages total`; return `

    …and more — ${more}.

    `; } /** Render the pages that link to a broken URL, capped with a "+N more". */ function linkedFrom(sources) { const list = Array.isArray(sources) ? sources : sources ? [sources] : []; if (list.length === 0) return 'n/a'; const shown = list.slice(0, 3).map((u) => { let label = u; try { label = new URL(u).pathname || u; } catch { /* keep raw */ } return `${esc(label)}`; }); const more = list.length > 3 ? ` +${list.length - 3} more` : ''; return shown.join('
    ') + more; } /** * Section heading with a shareable anchor link. The "#" reveals on * hover/focus, links to the section, and is marked aria-hidden + given a * data-attribute so it isn't read by screen readers or copied with the * heading text (the CSS uses ::before so the glyph isn't in the DOM text). */ function heading(id, text, level = 2) { return `${esc(text)}`; } /** " Download: CSV · JSON." from optional relative hrefs; '' if none. */ function downloadLinks(csvHref, jsonHref) { const parts = [ csvHref ? `CSV` : '', jsonHref ? `JSON` : '', ].filter(Boolean); return parts.length ? ` Download: ${parts.join(' · ')}.` : ''; } /** A long URL: shown truncated (CSS ellipsis) with the full URL on * hover/focus (title) and still fully selectable/copyable. */ function urlCell(url) { return `${esc(url)}`; } /** * Sub-page navigation shared by every page in a domain's weekly report. * `active` is the current page key. The nav is FIXED — it always lists every * criterion we evaluate, in the same order, on every domain and every week — * so navigation is consistent and predictable. Every sub-page is always * written (with a "no data this week" empty state where a criterion had none), * so no link 404s. Links are relative within the same week directory. */ const SUBNAV_ITEMS = [ ['index.html', 'Overview', 'overview'], ['accessibility.html', 'Accessibility', 'accessibility'], ['standards.html', 'Standards', 'standards'], ['errors.html', 'Errors', 'errors'], ['lighthouse.html', 'Lighthouse', 'lighthouse'], ['readability.html', 'Readability', 'readability'], ['tech.html', 'Tech stack', 'tech'], ['tech-findings.html', 'Tech ↔ issues', 'tech-findings'], ['third-party.html', 'Third parties', 'third-party'], ['images.html', 'Images', 'images'], ['archive.html', 'Archive', 'archive'], ]; function subnav(active) { return ``; } /** * A consistent empty-state page for a criterion that had no data this week. * Keeps the full shared nav so navigation never breaks and the page reads as * "we evaluate this, there just wasn't data this week" rather than 404. */ function emptyCriterionPage(target, summary, { active, label, message }) { const body = `

    ${esc(target.domain)}: ${esc(label)} — week ${esc(summary.week)}

    ${subnav(active)}

    ${esc(message)}

    This criterion is evaluated for every domain, but a given week's sample may not include pages with data for it. Check the archive for other weeks, or the overview.

    `; return layout({ title: `${target.domain} ${label} ${summary.week} | vital-scans`, breadcrumb: `
  • All domains
  • ${esc(target.domain)} ${esc(summary.week)}
  • ${esc(label)}
  • `, body, depth: 3, }); } /** * A sortable data table (progressive enhancement: fully usable without * JS; columns become click-to-sort when JS is on). Numeric columns sort * numerically via a data-sort value. `cols` is [{label, num}], `rows` is * arrays of { html, sort } cells. */ function sortableTable(caption, cols, rows) { const thead = cols .map((c, i) => ``) .join(''); const body = rows .map((r) => `${r.map((cell, i) => (i === 0 ? `${cell.html}` : (cols[i]?.num ? `${cell.html}` : `${cell.html}`))).join('')}`) .join('\n'); return `${thead}${body}
    ${esc(caption)} — click a column heading to sort
    `; } // One small script, included on pages with sortable tables. Tables work // fully without it; this just adds click-to-sort. No external deps. const SORT_SCRIPT = ``; /** * One combined "Broken links & errors" section. Broken links (from the * link-check engine) and pages that returned a non-404 error status * (e.g. 403/5xx — 404s are already covered by broken-link checking, so * listing them again is redundant). Returns '' when there's nothing. */ function linksAndErrorsSection(summary, csvHref = null) { const broken = summary.linkCheck?.broken ?? []; // Non-404 error pages only (404s show up via broken-link checking). const errors = (summary.errorPages ?? []).filter((e) => Number(e.status) !== 404); if (broken.length === 0 && errors.length === 0) return ''; const brokenRows = broken .map((b) => { const history = b.weeksBroken > 1 ? ` (broken for ${b.weeksBroken} weeks, first seen ${esc(b.firstSeen)})` : (b.firstSeen ? ` (first seen ${esc(b.firstSeen)})` : ''); return `${urlCell(b.url)}${history}${esc(b.status || b.reason)}broken link${linkedFrom(b.foundOn)}`; }) .join('\n'); const errorRows = errors .map((e) => `${urlCell(e.url)}${esc(e.status)}page errorn/a`) .join('\n'); const dlLink = csvHref ? ` · Download CSV` : ''; return `
    ${heading('h-links', 'Broken links & errors')}

    Broken links found on scanned pages, plus scanned pages that themselves returned a non-404 error (e.g. 403 or 5xx — 404s are already captured as broken links).${dlLink}

    ${brokenRows}${errorRows}
    ${broken.length} broken link(s) and ${errors.length} page error(s) in ${esc(summary.week)}.
    URLStatusTypeLinked from
    `; } /** Delta rendered as text first; symbol is reinforcement, not the meaning. */ function delta(n, { goodWhenDown = true, unit = '' } = {}) { if (n === 0) return 'no change'; const worse = goodWhenDown ? n > 0 : n < 0; const cls = worse ? 'worse' : 'better'; const word = worse ? 'worse' : 'better'; const sign = n > 0 ? '+' : ''; return `${sign}${esc(n)}${unit} ${word}`; } function sparkline(values, width = 220, height = 36) { if (values.length < 2) return ''; const max = Math.max(...values, 1); const min = Math.min(...values, 0); const span = max - min || 1; const pts = values .map((v, i) => `${((i / (values.length - 1)) * (width - 4) + 2).toFixed(1)},${(height - 4 - ((v - min) / span) * (height - 8) + 2).toFixed(1)}`) .join(' '); return ``; } /** * Accessible light/dark theme toggle, following the light-dark-mode skill * from mgifford/accessibility-skills: * - defaults to prefers-color-scheme; manual choice persists in localStorage * - aria-label describes the ACTION ("Switch to dark mode"), updated on click * - shows the moon in light mode (action: go dark) and sun in dark mode * - keyboard-operable native `; } /** * Accessible multi-week line chart: labeled inline SVG with a visually- * hidden data table fallback (the SVG is aria-hidden; screen readers get * the table). No JavaScript. `points` is [{ week, value }]; lowerIsBetter * controls the caption wording only. */ function lineChart(title, points, { unit = '', lowerIsBetter = true } = {}) { const pts = points.filter((p) => p.value != null); if (pts.length < 2) { return `

    ${esc(title)}: not enough weeks yet for a trend.

    `; } const W = 640, H = 180, padL = 40, padR = 12, padT = 16, padB = 28; const vals = pts.map((p) => p.value); const max = Math.max(...vals); const min = Math.min(...vals, 0); const span = max - min || 1; const x = (i) => padL + (i / (pts.length - 1)) * (W - padL - padR); const y = (v) => H - padB - ((v - min) / span) * (H - padT - padB); const poly = pts.map((p, i) => `${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(' '); const dots = pts.map((p, i) => ``).join(''); // A few x labels (first, middle, last) and y gridline labels (min/max). const xlabels = [0, Math.floor((pts.length - 1) / 2), pts.length - 1] .filter((v, idx, a) => a.indexOf(v) === idx) .map((i) => `${esc(pts[i].week.slice(5))}`) .join(''); const ylabels = `${max}${min}`; const first = pts[0].value, last = pts[pts.length - 1].value; const change = last - first; const better = lowerIsBetter ? change < 0 : change > 0; const trend = change === 0 ? 'unchanged' : `${better ? 'better' : 'worse'} (${first}${unit} → ${last}${unit})`; const table = `${pts.map((p) => ``).join('')}
    ${esc(title)} by week
    Week${esc(title)}
    ${esc(p.week)}${p.value}${esc(unit)}
    `; // Progressive enhancement: the SVG + table below are the no-JS baseline. The // data-parachart attribute carries a ParaCharts manifest; the loader script // (PARACHART_LOADER) lazy-imports the runtime, mounts an accessible // , and hides the SVG fallback. If JS is off or the runtime // fails to load, the SVG + table remain — nothing regresses. const manifest = buildLineManifest(title, title, pts, { unit }); const dataAttr = esc(JSON.stringify(manifest)); return `
    ${esc(title)} over ${pts.length} weeks — ${esc(trend)}
    ${dots}${xlabels}${ylabels} ${table}
    `; } function layout({ title, breadcrumb, body, depth, extraScript = '' }) { const base = '../'.repeat(depth); return ` ${esc(title)}

    vital-scans open quality ledger

    ${breadcrumb ? `` : ''} ${themeToggle()}
    ${body}
    ${paraChartLoader(base)} ${extraScript} `; } /** * Progressive-enhancement loader for ParaCharts. Emitted on every report page; * a no-op unless the page has `.chart[data-parachart]` figures. Lazy-imports * the vendored runtime (so the 2.8 MB AGPL bundle never blocks first paint or * the no-JS baseline), then for each chart builds a from the * figure's manifest, mounts it, and hides the static SVG fallback. If the * import fails, the SVG + data table remain untouched. */ function paraChartLoader(base) { return ``; } function ruleTable(caption, rules, kind, engineKey, csvLinks = { byRule: {} }) { const ids = Object.keys(rules).sort((a, b) => rules[b].pages - rules[a].pages || rules[b].count - rules[a].count); if (ids.length === 0) return `

    No ${esc(kind)} findings this week.

    `; const rows = ids .map((id) => { const r = rules[id]; const link = r.helpUrl ?? r.ruleUrl; const plain = rulePlainLabel(engineKey, id, { help: r.help }); const label = plain ? `${esc(plain)} (${esc(id)})` : esc(id); const csv = csvLinks.byRule?.[`${engineKey}:${id}`]; return ` ${link ? `${label}` : label} ${r.impact ? esc(r.impact) : 'n/a'} ${r.pages} ${r.count} ${(r.examplePages ?? []).map((u) => `${esc(new URL(u).pathname)}`).join('
    ')} ${csv ? `all ${r.pages} pages (CSV)` : '—'} `; }) .join('\n'); return `${rows}
    ${esc(caption)}
    RuleImpactPages affectedInstancesExample pagesAll affected
    `; } /** * Structured per-rule bug reports following the best-practices format. * Each report is a collapsible block — semantic, keyboard-operable, and * JavaScript-free (native
    ). Prioritized so the most actionable * issues appear first. Downloadable as CSV, Markdown, * and JSON. csvBugsHref is the relative path to bugs.csv (may be null). */ function bugReportsSection(target, summary, bugs, csvBugsHref = null, reporting = {}) { const view = prioritizeAccessibilityBugs(summary, bugs, { keyPages: reporting.keyPages ?? [], reporting }); const ordered = view.bugs; if (!ordered || ordered.length === 0) { return `
    ${heading('h-bugs', `Bug reports`)}

    No accessibility findings to report this week.

    `; } const sevCount = ordered.reduce((m, b) => ((m[b.severity] = (m[b.severity] ?? 0) + 1), m), {}); const sevSummary = ['Critical', 'High', 'Medium', 'Low'] .filter((s) => sevCount[s]) .map((s) => `${sevCount[s]} ${s.toLowerCase()}`) .join(', '); const dupCount = ordered.filter((b) => b.possible_duplicate_of).length; const catCounts = ordered.reduce((m, b) => ((m[b.wcag_category ?? 'Undetermined'] = (m[b.wcag_category ?? 'Undetermined'] ?? 0) + 1), m), {}); const catOrder = ['WCAG 2.0 A', 'WCAG 2.0 AA', 'WCAG 2.1 A', 'WCAG 2.1 AA', 'WCAG 2.2 A', 'WCAG 2.2 AA', 'WCAG 2.x AAA', 'Best Practice', 'Undetermined']; const catSummary = catOrder.filter((c) => catCounts[c]).map((c) => `${catCounts[c]} ${c}`).join(', '); const blocks = ordered .map((b) => { const wcagDetail = b.wcag_sc ? `${esc(b.wcag_sc)} ${esc(b.wcag_name)} (Level ${esc(b.wcag_level)}, WCAG ${esc(b.wcag_version ?? '2.x')})` : b.wcag_category === 'Best Practice' ? 'Best Practice — not a WCAG requirement' : 'undetermined'; const ruleLabel = b.rule_label && b.rule_label !== b.rule_id ? `${esc(b.rule_label)} (${esc(b.rule_id)})` : esc(b.rule_id); const ruleLink = b.rule_url ? `${esc(b.tool)} — ${ruleLabel}` : `${esc(b.tool)} — ${ruleLabel}`; const dupNote = b.possible_duplicate_of ? `
    Possible duplicate
    Same WCAG SC covered by axe report ${esc(b.possible_duplicate_of)} (pattern ${esc(b.possible_duplicate_pattern)}). If axe and this engine flag the same element, the axe report takes precedence — mark this as duplicate in JIRA.
    ` : ''; return `
    ${esc(b.severity)} ${b.wcag_category ? `${esc(b.wcag_category)} ` : ''}${esc(b.summary)} ${b.frequency.pages_affected}/${b.frequency.total_pages_scanned} pages · ${b.frequency.instances} instances${b.possible_duplicate_of ? ' · possible duplicate' : ''}
    Bug ID
    ${esc(b.instance_id)}
    Pattern ID
    ${esc(b.pattern_id)}
    Combined ID
    ${esc(b.instance_id)} (pattern ${esc(b.pattern_id)}) — use this format in JIRA/spreadsheets to filter by instance or pattern
    WCAG category
    ${esc(b.wcag_category ?? 'Undetermined')}
    WCAG SC
    ${wcagDetail}
    Rule
    ${ruleLink}
    Example URL
    ${esc(b.url)}
    ${b.xpath ? `
    XPath / selector
    ${esc(b.xpath)}
    ` : ''} ${b.first_seen ? `
    History
    first seen ${esc(b.first_seen)}, last seen ${esc(b.last_seen)} (${b.weeks_seen} wk)
    ` : ''} ${dupNote}
    ${b.html_snippet ? `

    HTML snippet — use this to validate the finding without re-running the tool

    ${esc(b.html_snippet)}
    ` : ''}

    Description

    ${esc(b.description)}

    Steps to reproduce

      ${b.steps_to_reproduce.map((s) => `
    1. ${esc(s)}
    2. `).join('')}

    Impact

    ${b.impact?.groups?.length ? `

    ${esc(b.impact.summary)}

      ${b.impact.groups .map((g) => `
    • ${esc(g.group)} — ${esc(g.percent)} of population${g.estimatedExcluded != null ? ` (~${g.estimatedExcluded.toLocaleString()} people/week potentially excluded)` : ''}
    • `) .join('')}
    ` : `

    ${esc(b.impact?.summary ?? 'Requires manual testing.')}

    `}

    Affected pages

    ${affectedPagesBlock(b)}

    Testing environment

    ${esc(b.testing_environment)}

    Suggested fix

    ${b.remediation_tip ? `

    How to fix: ${esc(b.remediation_tip)}

    ` : ''}

    ${esc(b.suggested_fix)}

    `; }) .join('\n'); // Progressive-enhancement filter. Without JS every bug is visible; the // script below reveals the controls and filters the
    blocks by // their data-severity / data-category attributes. const sevPresent = ['Critical', 'High', 'Medium', 'Low'].filter((s) => sevCount[s]); const catPresent = catOrder.filter((c) => catCounts[c]); const sevOpts = sevPresent.map((s) => ``).join(''); const catOpts = catPresent.map((c) => ``).join(''); const filterBar = ``; const csvLink = csvBugsHref ? ` · CSV (all findings)` : ''; const hiddenCount = ordered.length - view.visibleCount; const priorityLine = hiddenCount > 0 ? `

    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 `
    ${heading('h-bugs', `Bug reports`)}

    ${view.visibleCount} issue type(s) are shown by default out of ${ordered.length} total. Prioritized by severity, key pages, WCAG level, and prevalence; use the toggle to show everything.

    Overall severity mix: ${esc(sevSummary)}. By WCAG category: ${esc(catSummary)}. Ordered by severity, key-page impact, WCAG level, and prevalence. Following accessibility bug-reporting best practices. Download: Markdown · JSON${csvLink}.

    Fields marked "requires manual testing" cannot be observed by an automated scan. Manual AT verification is required before filing in JIRA. Best Practice findings are axe rules not tied to a WCAG criterion — address WCAG requirements first.

    ${priorityLine} ${dupLine} ${filterBar}
    ${blocks}
    ${BUG_FILTER_SCRIPT}
    `; } // Progressive-enhancement filtering for the bug-report list. Reveals the // filter form (hidden by default so no-JS users see every bug) and toggles // each .bug
    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}) ${rows}
    Pages each engine ran on, per the configured weekly sampling rates.
    EnginePagesCoverage
    `; } /** * 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} ${typeRows}
    ${r.total} distinct resources, by type.
    TypeCount
    `; } /** * 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.

    ${rows}
    Top ${top.length} issues to prioritize this week.
    IssueSeverityPagesWho it affectsHow to fixEvidence
    `; } /** * 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) => `
    • ${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)

    ${std.checks.map((c) => ``).join('')}
    Share of checked pages passing each standard (lowest first).
    StandardPass ratePages
    ${esc(c.label)}${c.rate}%${c.pass}/${c.total}
    ${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.

    ${bothRows}
    Rules flagged by both axe-core and Alfa in ${esc(summary.week)}, by pages affected.
    IssuePagesRule references
    ` : '

    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)}

    ${rows}
    ${esc(LH_CATEGORY_LABELS[cat] ?? cat)} recommendations from Lighthouse, by sampled pages affected.
    RecommendationPagesEst. saving
    `; }) .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)}

    ${rows}
    ${esc(cat)} technologies detected on ${esc(target.domain)}, ${esc(summary.week)}.
    TechnologyConfidenceCoverageAll categories
    `; }) .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

    ${rows}
    Findings over-represented on pages running ${esc(tech)} (lift ≥ 1, ≥5 pages support).
    Finding Lift On tech pages On all pages
    `; }) .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.

    ${rows}
    Third-party vendors on ${esc(target.domain)}, ${esc(summary.week)} — sortable.
    Vendor (registrable domain) Pages Median bytes Median requests Median load Pages w/ finding
    `; 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)}.
    CategoryCountShare
    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.

    ${qualityRows}
    Alt-text quality across ${img.uniqueImages} unique image(s).
    VerdictImagesShareWhat it means
    ` : ''; // 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.

    ${rows}
    Weekly reports for ${esc(target.domain)} (${series.length} weeks).
    WeekScorePages auditedMedian axe / pageMedian Alfa / page
    `; 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 ? `` : ''} ${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)}

      ${items.join('\n')}
    `; } /** * 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 = `${allWeeks.map((w) => ``).join('')}${withSeries.map((d) => `${allWeeks.map((w) => ``).join('')}`).join('')}
    Median axe violations per page by domain and week
    Domain${esc(w)}
    ${esc(d.target.domain)}${valAt(d, w) ?? '—'}
    `; 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.

    ${worst .map((b) => ``) .join('\n')}
    Top ${worst.length} issues across all active domains.
    DomainIssueSeverityPages
    ${esc(b.domain)} ${esc(b.summary)} ${esc(b.severity)} ${b.frequency.pages_affected}
    `; // 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.

    ${fleetPairs .map((p) => ``) .join('\n')}
    Top ${fleetPairs.length} technology ↔ finding associations spanning ≥2 sites.
    Technology Finding Lift Sites Pages
    ${esc(p.tech)} ${esc(findingLabel(p.finding))} ${p.lift.toFixed(2)}× ${p.sites} ${p.pairPages}
    `; } } // 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).

    ${lhFleet .map((e) => { const saving = [fmtSavingsBytes(e.savingsBytes), e.savingsMs ? `${(e.savingsMs / 1000).toFixed(1)}s` : ''] .filter(Boolean).join(' · ') || '—'; return ``; }) .join('\n')}
    Top ${lhFleet.length} Lighthouse recommendations spanning ≥2 sites.
    Recommendation Category Sites Pages Est. saving
    ${esc(e.title)} ${esc(catLabel(e.category))} ${e.sites} ${e.pages} ${saving}
    `; } 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).

    ') : ` ${rows}
    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.
    DomainScoreTrajectoryPages audited (7d)Median axe / pageMedian Alfa / pageTrend

    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; } `;