Spaces:
Sleeping
Sleeping
vital-scans[bot]
Switch HF Space to Docker appliance (VA profile): Dockerfile + serve-hf.js, sync source, sdk: docker
421ed2a | 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, '>').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 `<li><a href="${esc(u)}">${esc(label)}</a></li>`; | |
| }; | |
| if (total <= 25 && urls.length >= total) { | |
| return `<ul class="affected">${urls.map(li).join('')}</ul>`; | |
| } | |
| const more = b.affected_pages_csv | |
| ? `<a href="${esc(b.affected_pages_csv)}">all ${total} pages (CSV)</a>` | |
| : `${total} pages total`; | |
| return `<ul class="affected">${urls.slice(0, 25).map(li).join('')}</ul><p>…and more — ${more}.</p>`; | |
| } | |
| /** 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 `<a href="${esc(u)}">${esc(label)}</a>`; | |
| }); | |
| const more = list.length > 3 ? ` +${list.length - 3} more` : ''; | |
| return shown.join('<br>') + 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 `<h${level} id="${esc(id)}"><a class="anchor" href="#${esc(id)}" aria-label="Link to this section"></a>${esc(text)}</h${level}>`; | |
| } | |
| /** " Download: CSV · JSON." from optional relative hrefs; '' if none. */ | |
| function downloadLinks(csvHref, jsonHref) { | |
| const parts = [ | |
| csvHref ? `<a href="${esc(csvHref)}">CSV</a>` : '', | |
| jsonHref ? `<a href="${esc(jsonHref)}">JSON</a>` : '', | |
| ].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 `<a class="url" href="${esc(url)}" title="${esc(url)}">${esc(url)}</a>`; | |
| } | |
| /** | |
| * 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 `<nav class="subnav" aria-label="Domain report pages"><ul>${SUBNAV_ITEMS | |
| .map(([href, label, key]) => key === active | |
| ? `<li aria-current="page">${esc(label)}</li>` | |
| : `<li><a href="${esc(href)}">${esc(label)}</a></li>`) | |
| .join('')}</ul></nav>`; | |
| } | |
| /** | |
| * 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 = ` | |
| <h1>${esc(target.domain)}: ${esc(label)} — week ${esc(summary.week)}</h1> | |
| ${subnav(active)} | |
| <p class="meta">${esc(message)}</p> | |
| <p class="note">This criterion is evaluated for every domain, but a given week's sample may not include pages with data for it. Check the <a href="archive.html">archive</a> for other weeks, or the <a href="index.html">overview</a>.</p>`; | |
| return layout({ | |
| title: `${target.domain} ${label} ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="index.html">${esc(target.domain)} ${esc(summary.week)}</a></li><li aria-current="page">${esc(label)}</li>`, | |
| 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) => `<th scope="col"${c.num ? ' class="num"' : ''}><button type="button" class="sort-btn" data-col="${i}">${esc(c.label)}<span class="sort-ind" aria-hidden="true"></span></button></th>`) | |
| .join(''); | |
| const body = rows | |
| .map((r) => `<tr>${r.map((cell, i) => (i === 0 | |
| ? `<th scope="row" data-sort="${esc(String(cell.sort))}">${cell.html}</th>` | |
| : (cols[i]?.num | |
| ? `<td class="num" data-sort="${esc(String(cell.sort))}">${cell.html}</td>` | |
| : `<td data-sort="${esc(String(cell.sort))}">${cell.html}</td>`))).join('')}</tr>`) | |
| .join('\n'); | |
| return `<table class="sortable"> | |
| <caption>${esc(caption)} <span class="bug-meta">— click a column heading to sort</span></caption> | |
| <thead><tr>${thead}</tr></thead> | |
| <tbody>${body}</tbody> | |
| </table>`; | |
| } | |
| // 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 = `<script> | |
| (function () { | |
| for (const table of document.querySelectorAll('table.sortable')) { | |
| const tbody = table.tBodies[0]; | |
| table.querySelectorAll('th .sort-btn').forEach((btn) => { | |
| btn.addEventListener('click', () => { | |
| const col = +btn.dataset.col; | |
| const asc = btn.dataset.dir !== 'asc'; | |
| table.querySelectorAll('.sort-btn').forEach((b) => { b.removeAttribute('data-dir'); b.querySelector('.sort-ind').textContent = ''; }); | |
| btn.dataset.dir = asc ? 'asc' : 'desc'; | |
| btn.querySelector('.sort-ind').textContent = asc ? ' ▲' : ' ▼'; | |
| const rows = [...tbody.rows]; | |
| rows.sort((ra, rb) => { | |
| const a = ra.cells[col].dataset.sort, b = rb.cells[col].dataset.sort; | |
| const na = parseFloat(a), nb = parseFloat(b); | |
| const cmp = (!isNaN(na) && !isNaN(nb)) ? na - nb : String(a).localeCompare(String(b)); | |
| return asc ? cmp : -cmp; | |
| }); | |
| rows.forEach((r) => tbody.appendChild(r)); | |
| }); | |
| }); | |
| } | |
| })(); | |
| </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 | |
| ? ` <span class="bug-meta">(broken for ${b.weeksBroken} weeks, first seen ${esc(b.firstSeen)})</span>` | |
| : (b.firstSeen ? ` <span class="bug-meta">(first seen ${esc(b.firstSeen)})</span>` : ''); | |
| return `<tr><th scope="row">${urlCell(b.url)}${history}</th><td>${esc(b.status || b.reason)}</td><td>broken link</td><td>${linkedFrom(b.foundOn)}</td></tr>`; | |
| }) | |
| .join('\n'); | |
| const errorRows = errors | |
| .map((e) => `<tr><th scope="row">${urlCell(e.url)}</th><td>${esc(e.status)}</td><td>page error</td><td>n/a</td></tr>`) | |
| .join('\n'); | |
| const dlLink = csvHref ? ` · <a href="${esc(csvHref)}">Download CSV</a>` : ''; | |
| return `<section aria-labelledby="h-links"> | |
| ${heading('h-links', 'Broken links & errors')} | |
| <p class="meta">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}</p> | |
| <table> | |
| <caption>${broken.length} broken link(s) and ${errors.length} page error(s) in ${esc(summary.week)}.</caption> | |
| <thead><tr><th scope="col">URL</th><th scope="col">Status</th><th scope="col">Type</th><th scope="col">Linked from</th></tr></thead> | |
| <tbody>${brokenRows}${errorRows}</tbody> | |
| </table> | |
| </section>`; | |
| } | |
| /** Delta rendered as text first; symbol is reinforcement, not the meaning. */ | |
| function delta(n, { goodWhenDown = true, unit = '' } = {}) { | |
| if (n === 0) return '<span class="delta same">no change</span>'; | |
| const worse = goodWhenDown ? n > 0 : n < 0; | |
| const cls = worse ? 'worse' : 'better'; | |
| const word = worse ? 'worse' : 'better'; | |
| const sign = n > 0 ? '+' : ''; | |
| return `<span class="delta ${cls}">${sign}${esc(n)}${unit} ${word}</span>`; | |
| } | |
| 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 `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="2"/></svg>`; | |
| } | |
| /** | |
| * 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 <button>, placed after nav, not sticky | |
| * The toggle and its tiny script are the only JS in the reports; with JS | |
| * off, prefers-color-scheme still applies (progressive enhancement). | |
| */ | |
| function themeToggle() { | |
| return `<button id="theme-toggle" type="button" class="theme-toggle" aria-label="Switch to dark mode" aria-pressed="false" hidden> | |
| <svg class="icon-sun" aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20"> | |
| <circle cx="12" cy="12" r="5" fill="currentColor"/> | |
| <path stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none" | |
| d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/> | |
| </svg> | |
| <svg class="icon-moon" aria-hidden="true" focusable="false" viewBox="0 0 24 24" width="20" height="20"> | |
| <path fill="currentColor" d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/> | |
| </svg> | |
| <span class="theme-label">Theme</span> | |
| </button> | |
| <script> | |
| (function () { | |
| var btn = document.getElementById('theme-toggle'); | |
| if (!btn) return; | |
| btn.hidden = false; // only show the control when JS can drive it | |
| var mq = window.matchMedia('(prefers-color-scheme: dark)'); | |
| function current() { | |
| var attr = document.documentElement.getAttribute('data-theme'); | |
| return attr || (mq.matches ? 'dark' : 'light'); | |
| } | |
| function sync() { | |
| var dark = current() === 'dark'; | |
| // Label/pressed describe the action and state for screen readers. | |
| btn.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode'); | |
| btn.setAttribute('aria-pressed', String(dark)); | |
| } | |
| sync(); | |
| btn.addEventListener('click', function () { | |
| var next = current() === 'dark' ? 'light' : 'dark'; | |
| document.documentElement.setAttribute('data-theme', next); | |
| try { localStorage.setItem('vital-theme', next); } catch (e) {} | |
| sync(); | |
| }); | |
| mq.addEventListener && mq.addEventListener('change', sync); | |
| })(); | |
| </script>`; | |
| } | |
| /** | |
| * 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 `<p class="meta">${esc(title)}: not enough weeks yet for a trend.</p>`; | |
| } | |
| 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) => `<circle cx="${x(i).toFixed(1)}" cy="${y(p.value).toFixed(1)}" r="2.5" fill="currentColor"/>`).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) => `<text x="${x(i).toFixed(1)}" y="${H - 8}" text-anchor="middle" class="axis">${esc(pts[i].week.slice(5))}</text>`) | |
| .join(''); | |
| const ylabels = `<text x="4" y="${(y(max) + 4).toFixed(1)}" class="axis">${max}</text><text x="4" y="${(y(min) + 4).toFixed(1)}" class="axis">${min}</text>`; | |
| 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 = `<table class="visually-hidden"><caption>${esc(title)} by week</caption> | |
| <thead><tr><th scope="col">Week</th><th scope="col">${esc(title)}</th></tr></thead> | |
| <tbody>${pts.map((p) => `<tr><th scope="row">${esc(p.week)}</th><td>${p.value}${esc(unit)}</td></tr>`).join('')}</tbody></table>`; | |
| // 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 | |
| // <para-chart>, 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 `<figure class="chart" data-parachart="${dataAttr}"> | |
| <figcaption>${esc(title)} over ${pts.length} weeks — ${esc(trend)}</figcaption> | |
| <svg viewBox="0 0 ${W} ${H}" class="linechart chart-fallback" role="img" aria-label="${esc(title)} trend: ${esc(trend)}" preserveAspectRatio="xMidYMid meet"> | |
| <polyline points="${poly}" fill="none" stroke="currentColor" stroke-width="2"/> | |
| ${dots}${xlabels}${ylabels} | |
| </svg> | |
| ${table} | |
| </figure>`; | |
| } | |
| function layout({ title, breadcrumb, body, depth, extraScript = '' }) { | |
| const base = '../'.repeat(depth); | |
| return `<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <meta name="color-scheme" content="light dark"> | |
| <title>${esc(title)}</title> | |
| <link rel="stylesheet" href="${base}style.css"> | |
| <script> | |
| // Apply the saved theme before first paint to avoid a flash of the | |
| // wrong colour scheme. No saved choice = follow the OS (prefers-color-scheme). | |
| try { | |
| var t = localStorage.getItem('vital-theme'); | |
| if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t); | |
| } catch (e) {} | |
| </script> | |
| </head> | |
| <body> | |
| <a class="skip" href="#main">Skip to content</a> | |
| <header> | |
| <p class="brand"><a href="${base}index.html">vital-scans</a> <span class="tag">open quality ledger</span></p> | |
| ${breadcrumb ? `<nav aria-label="Breadcrumb"><ol class="crumbs">${breadcrumb}</ol></nav>` : ''} | |
| ${themeToggle()} | |
| </header> | |
| <main id="main"> | |
| ${body} | |
| </main> | |
| <footer> | |
| <p>Built in the open with <a href="https://github.com/dequelabs/axe-core">axe-core</a> and | |
| <a href="https://github.com/Siteimprove/alfa">Siteimprove Alfa</a>; emissions estimated with | |
| <a href="https://github.com/thegreenwebfoundation/co2.js">co2.js</a> (SWD v4 model).</p> | |
| <p>This project follows public commitments to | |
| <a href="https://mgifford.github.io/ACCESSIBILITY.md/">accessibility</a> and | |
| <a href="https://mgifford.github.io/SUSTAINABILITY.md/">sustainability</a>, and the | |
| <a href="https://w3c.github.io/sustainableweb-wsg/">W3C Web Sustainability Guidelines</a>.</p> | |
| <p>Automated checks find roughly a third of accessibility barriers. A clean report is a floor, not a finish line.</p> | |
| </footer> | |
| ${paraChartLoader(base)} | |
| ${extraScript} | |
| </body> | |
| </html>`; | |
| } | |
| /** | |
| * 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 <para-chart> 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 `<script type="module"> | |
| const figs = document.querySelectorAll('.chart[data-parachart]'); | |
| if (figs.length) { | |
| import('${base}paracharts.js').then(() => { | |
| figs.forEach((fig) => { | |
| let manifest; | |
| try { manifest = fig.getAttribute('data-parachart'); } catch (e) { return; } | |
| if (!manifest) return; | |
| const chart = document.createElement('para-chart'); | |
| chart.setAttribute('manifestType', 'content'); | |
| chart.setAttribute('type', 'line'); | |
| chart.setAttribute('manifest', manifest); | |
| chart.style.display = 'block'; | |
| chart.style.width = '100%'; | |
| chart.style.maxWidth = '640px'; | |
| chart.style.aspectRatio = '16 / 7'; | |
| const fallback = fig.querySelector('.chart-fallback'); | |
| if (fallback) fallback.hidden = true; | |
| fig.insertBefore(chart, fig.firstChild ? fig.firstChild.nextSibling : null); | |
| }); | |
| }).catch(() => { /* keep the SVG + table fallback */ }); | |
| } | |
| </script>`; | |
| } | |
| 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 `<p>No ${esc(kind)} findings this week.</p>`; | |
| 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)} <span class="bug-meta">(${esc(id)})</span>` : esc(id); | |
| const csv = csvLinks.byRule?.[`${engineKey}:${id}`]; | |
| return `<tr> | |
| <th scope="row">${link ? `<a href="${esc(link)}">${label}</a>` : label}</th> | |
| <td>${r.impact ? esc(r.impact) : 'n/a'}</td> | |
| <td class="num">${r.pages}</td> | |
| <td class="num">${r.count}</td> | |
| <td>${(r.examplePages ?? []).map((u) => `<a href="${esc(u)}">${esc(new URL(u).pathname)}</a>`).join('<br>')}</td> | |
| <td>${csv ? `<a href="${esc(csv)}">all ${r.pages} pages (CSV)</a>` : '—'}</td> | |
| </tr>`; | |
| }) | |
| .join('\n'); | |
| return `<table> | |
| <caption>${esc(caption)}</caption> | |
| <thead><tr><th scope="col">Rule</th><th scope="col">Impact</th><th scope="col">Pages affected</th><th scope="col">Instances</th><th scope="col">Example pages</th><th scope="col">All affected</th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table>`; | |
| } | |
| /** | |
| * Structured per-rule bug reports following the best-practices format. | |
| * Each report is a collapsible block — semantic, keyboard-operable, and | |
| * JavaScript-free (native <details>). 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 `<section aria-labelledby="h-bugs"> | |
| ${heading('h-bugs', `Bug reports`)} | |
| <p>No accessibility findings to report this week.</p> | |
| </section>`; | |
| } | |
| 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)} <span class="bug-meta">(${esc(b.rule_id)})</span>` | |
| : esc(b.rule_id); | |
| const ruleLink = b.rule_url | |
| ? `<a href="${esc(b.rule_url)}">${esc(b.tool)} — ${ruleLabel}</a>` | |
| : `${esc(b.tool)} — ${ruleLabel}`; | |
| const dupNote = b.possible_duplicate_of | |
| ? `<div><dt>Possible duplicate</dt><dd>Same WCAG SC covered by axe report <code>${esc(b.possible_duplicate_of)}</code> (pattern <code>${esc(b.possible_duplicate_pattern)}</code>). If axe and this engine flag the same element, the axe report takes precedence — mark this as duplicate in JIRA.</dd></div>` | |
| : ''; | |
| return `<details id="${esc(b.instance_id)}" class="bug sev-${esc(b.severity.toLowerCase())}${b.possible_duplicate_of ? ' possible-dup' : ''}" data-severity="${esc(b.severity)}" data-category="${esc(b.wcag_category ?? 'Undetermined')}" data-default-visible="${b.default_visible ? '1' : '0'}" data-priority-tier="${esc(String(b.priority_tier ?? 5))}"${b.possible_duplicate_of ? ' data-duplicate="1"' : ''}> | |
| <summary><span class="sev-badge">${esc(b.severity)}</span> ${b.wcag_category ? `<span class="wcag-badge"${b.wcag_category === 'Best Practice' ? ' data-cat="best-practice"' : ''}>${esc(b.wcag_category)}</span> ` : ''}${esc(b.summary)} | |
| <span class="bug-meta">${b.frequency.pages_affected}/${b.frequency.total_pages_scanned} pages · ${b.frequency.instances} instances${b.possible_duplicate_of ? ' · possible duplicate' : ''}</span></summary> | |
| <dl class="bug-fields"> | |
| <div><dt>Bug ID</dt><dd><code>${esc(b.instance_id)}</code></dd></div> | |
| <div><dt>Pattern ID</dt><dd><code>${esc(b.pattern_id)}</code></dd></div> | |
| <div><dt>Combined ID</dt><dd><code>${esc(b.instance_id)}</code> (pattern <code>${esc(b.pattern_id)}</code>) — use this format in JIRA/spreadsheets to filter by instance or pattern</dd></div> | |
| <div><dt>WCAG category</dt><dd>${esc(b.wcag_category ?? 'Undetermined')}</dd></div> | |
| <div><dt>WCAG SC</dt><dd>${wcagDetail}</dd></div> | |
| <div><dt>Rule</dt><dd>${ruleLink}</dd></div> | |
| <div><dt>Example URL</dt><dd><a href="${esc(b.url)}">${esc(b.url)}</a></dd></div> | |
| ${b.xpath ? `<div><dt>XPath / selector</dt><dd><code>${esc(b.xpath)}</code></dd></div>` : ''} | |
| ${b.first_seen ? `<div><dt>History</dt><dd>first seen ${esc(b.first_seen)}, last seen ${esc(b.last_seen)} (${b.weeks_seen} wk)</dd></div>` : ''} | |
| ${dupNote} | |
| </dl> | |
| ${b.html_snippet ? `<p class="bug-label">HTML snippet — use this to validate the finding without re-running the tool</p><pre><code>${esc(b.html_snippet)}</code></pre>` : ''} | |
| <p class="bug-label">Description</p><p>${esc(b.description)}</p> | |
| <p class="bug-label">Steps to reproduce</p><ol>${b.steps_to_reproduce.map((s) => `<li>${esc(s)}</li>`).join('')}</ol> | |
| <p class="bug-label">Impact</p> | |
| ${b.impact?.groups?.length | |
| ? `<p>${esc(b.impact.summary)}</p><ul class="impact-groups">${b.impact.groups | |
| .map((g) => `<li><strong>${esc(g.group)}</strong> — ${esc(g.percent)} of population${g.estimatedExcluded != null ? ` (~${g.estimatedExcluded.toLocaleString()} people/week potentially excluded)` : ''}</li>`) | |
| .join('')}</ul>` | |
| : `<p class="bug-placeholder">${esc(b.impact?.summary ?? 'Requires manual testing.')}</p>`} | |
| <p class="bug-label">Affected pages</p> | |
| ${affectedPagesBlock(b)} | |
| <p class="bug-label">Testing environment</p><p>${esc(b.testing_environment)}</p> | |
| <p class="bug-label">Suggested fix</p>${b.remediation_tip ? `<p><strong>How to fix:</strong> ${esc(b.remediation_tip)}</p>` : ''}<p>${esc(b.suggested_fix)}</p> | |
| </details>`; | |
| }) | |
| .join('\n'); | |
| // Progressive-enhancement filter. Without JS every bug is visible; the | |
| // script below reveals the controls and filters the <details> 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) => `<option value="${esc(s)}">${esc(s)} (${sevCount[s]})</option>`).join(''); | |
| const catOpts = catPresent.map((c) => `<option value="${esc(c)}">${esc(c)} (${catCounts[c]})</option>`).join(''); | |
| const filterBar = `<form class="bug-filter" hidden aria-label="Filter bug reports" data-total="${ordered.length}" data-prioritized="${view.visibleCount}"> | |
| <div class="bug-filter-row"> | |
| <label class="bug-filter-check"><input type="checkbox" id="filter-all"> Show everything</label> | |
| <label>Severity <select id="filter-sev"><option value="">All severities</option>${sevOpts}</select></label> | |
| <label>WCAG category <select id="filter-cat"><option value="">All categories</option>${catOpts}</select></label> | |
| <label class="bug-filter-check"><input type="checkbox" id="filter-dup"> Hide possible duplicates</label> | |
| <button type="button" id="filter-reset">Reset</button> | |
| </div> | |
| <p class="bug-filter-count" aria-live="polite" id="filter-count">Showing ${view.visibleCount} prioritized issue type(s) out of ${ordered.length}.</p> | |
| </form>`; | |
| const csvLink = csvBugsHref ? ` · <a href="${esc(csvBugsHref)}">CSV (all findings)</a>` : ''; | |
| const hiddenCount = ordered.length - view.visibleCount; | |
| const priorityLine = hiddenCount > 0 | |
| ? `<p class="note">Default view shows ${view.visibleCount} prioritized issue type(s); ${hiddenCount} more are available if you switch to "Show everything".</p>` | |
| : `<p class="note">All findings fit within the prioritized view this week.</p>`; | |
| const dupLine = dupCount > 0 | |
| ? `<p class="note">${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 <code>possible_duplicate_of</code> to see these. Two engines flagging the same barrier reduces the chance of a false positive.</p>` | |
| : ''; | |
| return `<section aria-labelledby="h-bugs"> | |
| ${heading('h-bugs', `Bug reports`)} | |
| <p class="meta">${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.</p> | |
| <p class="meta">Overall severity mix: ${esc(sevSummary)}. By WCAG category: ${esc(catSummary)}. Ordered by severity, key-page impact, WCAG level, and prevalence. Following | |
| <a href="https://mgifford.github.io/ACCESSIBILITY.md/examples/ACCESSIBILITY_BUG_REPORTING_BEST_PRACTICES.html">accessibility bug-reporting best practices</a>. | |
| Download: <a href="bugs.md">Markdown</a> · <a href="bugs.json">JSON</a>${csvLink}.</p> | |
| <p class="note">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.</p> | |
| ${priorityLine} | |
| ${dupLine} | |
| ${filterBar} | |
| <div class="bug-list">${blocks}</div> | |
| <p class="bug-filter-empty" hidden>No issues match the current filters. <button type="button" id="filter-reset-2">Clear filters</button></p> | |
| ${BUG_FILTER_SCRIPT} | |
| </section>`; | |
| } | |
| // 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 <details> by its data-severity / data-category attributes. | |
| const BUG_FILTER_SCRIPT = `<script> | |
| (function () { | |
| var form = document.querySelector('.bug-filter'); | |
| if (!form) return; | |
| form.hidden = false; | |
| var showAll = document.getElementById('filter-all'); | |
| var sev = document.getElementById('filter-sev'); | |
| var cat = document.getElementById('filter-cat'); | |
| var dup = document.getElementById('filter-dup'); | |
| var count = document.getElementById('filter-count'); | |
| var empty = document.querySelector('.bug-filter-empty'); | |
| var bugs = Array.prototype.slice.call(document.querySelectorAll('.bug-list .bug')); | |
| var total = bugs.length; | |
| var prioritized = Number(form.getAttribute('data-prioritized') || '0'); | |
| function apply() { | |
| var s = sev.value, c = cat.value, hideDup = dup.checked, showEverything = showAll.checked, shown = 0; | |
| bugs.forEach(function (b) { | |
| var ok = (showEverything || b.getAttribute('data-default-visible') === '1') | |
| && (!s || b.getAttribute('data-severity') === s) | |
| && (!c || b.getAttribute('data-category') === c) | |
| && (!hideDup || b.getAttribute('data-duplicate') !== '1'); | |
| b.hidden = !ok; | |
| if (ok) shown++; | |
| }); | |
| var filtered = showEverything || s || c || hideDup; | |
| count.textContent = filtered | |
| ? (showEverything ? 'Showing all ' + shown + ' issue type(s).' : 'Showing ' + shown + ' of ' + total + ' issue type(s).') | |
| : 'Showing ' + prioritized + ' prioritized issue type(s) out of ' + total + '.'; | |
| if (empty) empty.hidden = shown !== 0; | |
| } | |
| function reset() { showAll.checked = false; sev.value = ''; cat.value = ''; dup.checked = false; apply(); } | |
| showAll.addEventListener('change', apply); | |
| sev.addEventListener('change', apply); | |
| cat.addEventListener('change', apply); | |
| dup.addEventListener('change', apply); | |
| document.getElementById('filter-reset').addEventListener('click', reset); | |
| var r2 = document.getElementById('filter-reset-2'); | |
| if (r2) r2.addEventListener('click', reset); | |
| apply(); | |
| })(); | |
| </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]) => `<tr><th scope="row">${esc(engine)}</th><td class="num">${n}</td><td class="num">${Math.round((100 * n) / total)}%</td></tr>`) | |
| .join('\n'); | |
| const attemptLine = (summary.pagesAttempted != null) | |
| ? ` · ${summary.pagesSucceeded ?? '?'} succeeded of ${summary.pagesAttempted} attempted` | |
| : ''; | |
| return `<details class="coverage"> | |
| <summary>Scan coverage this week (${summary.pagesScanned} pages${attemptLine})</summary> | |
| <table> | |
| <caption>Pages each engine ran on, per the configured weekly sampling rates.</caption> | |
| <thead><tr><th scope="col">Engine</th><th scope="col">Pages</th><th scope="col">Coverage</th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table> | |
| </details>`; | |
| } | |
| /** | |
| * 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]) => `<tr><th scope="row">${esc(RESOURCE_LABELS[type] ?? type)}</th><td class="num">${n}</td></tr>`) | |
| .join('\n'); | |
| const newList = (r.newThisWeek ?? []); | |
| const newBlock = newList.length | |
| ? `<h3>New this week (${newList.length})</h3> | |
| <ul>${newList.slice(0, 100).map((n) => `<li><span class="bug-meta">${esc(RESOURCE_LABELS[n.type] ?? n.type)}:</span> <a href="${esc(n.url)}">${esc(n.url)}</a></li>`).join('')}</ul>` | |
| : `<p>No new resources first seen this week.</p>`; | |
| return `<section aria-labelledby="h-resources"> | |
| ${heading('h-resources', `Embedded & linked resources`)} | |
| <p class="meta">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: <a href="${esc(r.csv)}">CSV</a>.` : ''}</p> | |
| ${newBlock} | |
| <table> | |
| <caption>${r.total} distinct resources, by type.</caption> | |
| <thead><tr><th scope="col">Type</th><th scope="col">Count</th></tr></thead> | |
| <tbody>${typeRows}</tbody> | |
| </table> | |
| </section>`; | |
| } | |
| /** | |
| * 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) => `<tr> | |
| <th scope="row"><a href="accessibility.html#${esc(b.instance_id)}">${esc(b.summary)}</a>${b.rule_url ? ` <a href="${esc(b.rule_url)}" class="bug-meta">(rule↗)</a>` : ''}</th> | |
| <td><span class="sev-badge">${esc(b.severity)}</span></td> | |
| <td class="num">${b.frequency.pages_affected}</td> | |
| <td>${b.impact?.groups?.length ? esc(b.impact.groups.map((g) => g.group).slice(0, 2).join(', ')) : '—'}</td> | |
| <td>${b.remediation_tip ? esc(b.remediation_tip) : (b.suggested_fix ? esc(b.suggested_fix) : '—')}</td> | |
| <td>${b.affected_pages_csv ? `<a href="${esc(b.affected_pages_csv)}">pages (CSV)</a>` : '—'}</td> | |
| </tr>`) | |
| .join('\n'); | |
| return `<section aria-labelledby="h-fixfirst"> | |
| ${heading('h-fixfirst', `Fix these first`)} | |
| <p class="meta">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 <a href="accessibility.html#h-bugs">Accessibility page</a>.</p> | |
| <table> | |
| <caption>Top ${top.length} issues to prioritize this week.</caption> | |
| <thead><tr><th scope="col">Issue</th><th scope="col">Severity</th><th scope="col">Pages</th><th scope="col">Who it affects</th><th scope="col">How to fix</th><th scope="col">Evidence</th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table> | |
| </section>`; | |
| } | |
| /** | |
| * 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 `<ul class="checklist">${items | |
| .map((c) => `<li class="${c.pass ? 'pass' : 'fail'}"><span class="check" aria-hidden="true">${c.pass ? '✓' : '✗'}</span> ${esc(c.label)}${c.detail ? ` <span class="bug-meta">${esc(String(c.detail))}</span>` : ''}<span class="visually-hidden">: ${c.pass ? 'pass' : 'fail'}</span></li>`) | |
| .join('')}</ul>`; | |
| } | |
| function standardsSecuritySection(summary) { | |
| const sec = summary.security; | |
| const std = summary.standards; | |
| if (!sec && !std) return ''; | |
| const secBlock = sec ? ` | |
| <h3>Security & domain hygiene <span class="bug-meta">${sec.passed}/${sec.total} on the origin</span></h3> | |
| ${checklist(sec.checks)}` : ''; | |
| const stdBlock = std ? ` | |
| <h3>Web standards & metadata <span class="bug-meta">across ${std.pagesChecked} page(s)</span></h3> | |
| <table> | |
| <caption>Share of checked pages passing each standard (lowest first).</caption> | |
| <thead><tr><th scope="col">Standard</th><th scope="col">Pass rate</th><th scope="col">Pages</th></tr></thead> | |
| <tbody>${std.checks.map((c) => `<tr><th scope="row">${esc(c.label)}</th><td class="num">${c.rate}%</td><td class="num">${c.pass}/${c.total}</td></tr>`).join('')}</tbody> | |
| </table> | |
| ${std.social?.length ? `<p class="meta">Open social presence found: ${std.social.map((s) => `<a href="${esc(s.href)}">${esc(s.platform)}</a>`).join(', ')}.</p>` : '<p class="meta">No Mastodon/Bluesky links detected on checked pages.</p>'}` : ''; | |
| return `<section aria-labelledby="h-standards"> | |
| ${heading('h-standards', `Standards & security`)} | |
| <p class="meta">Web-standards, metadata, and security checks in the spirit of <a href="https://standards.scangov.org/">ScanGov</a> (methodology CC0), run across our scan rather than only the homepage.</p> | |
| ${secBlock} | |
| ${stdBlock} | |
| </section>`; | |
| } | |
| 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 ? `<a href="${esc(axeUrl)}">axe ${esc(axeId)}</a>` : (axeId ? `axe ${esc(axeId)}` : ''), | |
| alfaUrl ? `<a href="${esc(alfaUrl)}">Alfa ${esc(alfaId)}</a>` : (alfaId ? `Alfa ${esc(alfaId)}` : ''), | |
| actUrl ? `<a href="${esc(actUrl)}">ACT ${esc(g.actRuleId)}</a>` : '', | |
| ].filter(Boolean).join(' · '); | |
| return `<tr><th scope="row">${esc(help)}</th><td class="num">${g.pages}</td><td class="bug-meta">${links}</td></tr>`; | |
| }) | |
| .join('\n'); | |
| const bothTable = both.length | |
| ? `<details class="engine-findings" open> | |
| <summary>${both.length} rule type(s) caught by both engines — highest confidence</summary> | |
| <p class="meta">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.</p> | |
| <table> | |
| <caption>Rules flagged by both axe-core and Alfa in ${esc(summary.week)}, by pages affected.</caption> | |
| <thead><tr><th scope="col">Issue</th><th scope="col">Pages</th><th scope="col">Rule references</th></tr></thead> | |
| <tbody>${bothRows}</tbody> | |
| </table> | |
| </details>` | |
| : '<p class="meta">No issues were flagged by both engines on the same pages this week.</p>'; | |
| return `<section aria-labelledby="h-consensus"> | |
| ${heading('h-consensus', `Unique accessibility issues (axe + Alfa consolidated)`)} | |
| <p class="meta">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)` : ''}.</p> | |
| <dl class="ledger"> | |
| <div><dt>Unique issues (rule × page)</dt><dd>${c.uniqueIssues}</dd></div> | |
| <div><dt>Caught by both engines</dt><dd>${c.consensus}<span class="bug-meta"> highest confidence</span></dd></div> | |
| <div><dt>axe only</dt><dd>${c.axeOnly}</dd></div> | |
| <div><dt>Alfa only</dt><dd>${c.alfaOnly}</dd></div> | |
| </dl> | |
| ${bothTable} | |
| </section>`; | |
| } | |
| /** | |
| * 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 `<tr> | |
| <th scope="row">${esc(r.title)}</th> | |
| <td class="num">${r.pages}/${pagesSampled}</td> | |
| <td class="num">${savings}</td> | |
| </tr>`; | |
| }) | |
| .join('\n'); | |
| return `<h3>${esc(LH_CATEGORY_LABELS[cat] ?? cat)}</h3> | |
| <table> | |
| <caption>${esc(LH_CATEGORY_LABELS[cat] ?? cat)} recommendations from Lighthouse, by sampled pages affected.</caption> | |
| <thead><tr><th scope="col">Recommendation</th><th scope="col">Pages</th><th scope="col">Est. saving</th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table>`; | |
| }) | |
| .join('\n'); | |
| return `<section aria-labelledby="h-lh-reco"> | |
| ${heading('h-lh-reco', `Recommendations`)} | |
| <p class="meta">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 <a href="accessibility.html">Accessibility page</a>.</p> | |
| ${sections} | |
| </section>`; | |
| } | |
| /** | |
| * 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 | |
| ? `<p class="meta">Gaps found on the sampled pages: ${agenticRecos.map((r) => `${esc(r.title)} (${r.pages})`).join(', ')}.</p>` | |
| : ''; | |
| return `<section aria-labelledby="h-lh-agentic"> | |
| ${heading('h-lh-agentic', `What the Agentic score means`)} | |
| <p>The <strong>Agentic (AI-readiness)</strong> score is new in Google Lighthouse (13.4+). It measures how well a page works for <strong>AI agents and assistants</strong> — the tools that increasingly mediate how people find and use government services. It checks for things like an <code>llms.txt</code> file (a machine-readable guide for language models), valid <a href="https://schema.org/">structured data</a>, a well-formed accessibility tree that agents can parse, and <a href="https://github.com/webmachinelearning/webmcp">WebMCP</a> 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.</p> | |
| <p class="meta">It is experimental and evolving; treat it as a forward-looking signal, not a compliance requirement. Median across sampled pages: <strong>${fmtScore(lh.medianAgentic)}</strong>.</p> | |
| ${gaps} | |
| </section>`; | |
| } | |
| 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(`<a class="url" href="${esc(p.url)}" title="${esc(p.url)}">${esc(path)}</a>`, 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 = ` | |
| <h1>${esc(target.domain)}: Lighthouse — week ${esc(summary.week)}</h1> | |
| ${subnav('lighthouse')} | |
| <p class="meta">${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 ? `<a href="${esc(csvHref)}">Download CSV</a>.` : ''}</p> | |
| ${impact ? perfImpactSection(impact) : ''} | |
| <section aria-labelledby="h-lh-medians"> | |
| ${heading('h-lh-medians', `Medians across sampled pages`)} | |
| <dl class="ledger"> | |
| <div><dt>Performance</dt><dd>${fmtScore(lh.medianPerformance)}</dd></div> | |
| <div><dt>Accessibility</dt><dd>${fmtScore(lh.medianAccessibility)}</dd></div> | |
| <div><dt>Best practices</dt><dd>${fmtScore(lh.medianBestPractices)}</dd></div> | |
| <div><dt>SEO</dt><dd>${fmtScore(lh.medianSeo)}</dd></div> | |
| ${lh.medianAgentic != null ? `<div><dt>Agentic browsing</dt><dd>${fmtScore(lh.medianAgentic)}</dd></div>` : ''} | |
| <div><dt>Largest Contentful Paint</dt><dd>${ms(m.largestContentfulPaintMs)}</dd></div> | |
| <div><dt>First Contentful Paint</dt><dd>${ms(m.firstContentfulPaintMs)}</dd></div> | |
| <div><dt>Speed Index</dt><dd>${ms(m.speedIndexMs)}</dd></div> | |
| <div><dt>Total Blocking Time</dt><dd>${ms(m.totalBlockingTimeMs)}</dd></div> | |
| <div><dt>Cumulative Layout Shift</dt><dd>${m.cumulativeLayoutShift ?? 'n/a'}</dd></div> | |
| </dl> | |
| </section> | |
| ${lighthouseRecommendations(lh.recommendations, lh.pageDetail.length)} | |
| ${agenticExplainer(lh)} | |
| <section aria-labelledby="h-lh-pages"> | |
| ${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)} | |
| </section>`; | |
| return layout({ | |
| title: `${target.domain} Lighthouse ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="index.html">${esc(target.domain)} ${esc(summary.week)}</a></li><li aria-current="page">Lighthouse</li>`, | |
| 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 `<section aria-labelledby="h-lh-impact"> | |
| ${heading('h-lh-impact', `Performance impact`)} | |
| <p class="meta">How far pages fall short of Google's "good" benchmarks: Largest Contentful Paint ≤ 2.5s and page weight ≤ 1.6 MB. Lower is better.</p> | |
| <dl class="ledger"> | |
| <div><dt>Avg extra LCP over 2.5s</dt><dd>${secs(impact.avgExtraLcpMs)}<span class="bug-meta"> ${impact.pagesOverLcp}/${impact.lcpPages} pages over</span></dd></div> | |
| <div><dt>Avg extra weight over 1.6 MB</dt><dd>${mb(impact.avgExtraWeightBytes)}<span class="bug-meta"> ${impact.pagesOverWeight}/${impact.weightPages} pages over</span></dd></div> | |
| </dl> | |
| ${totals ? `<p>With an estimated <strong>${totals.pageLoadsPerWeek.toLocaleString()}</strong> page loads/week, that is roughly <strong>${esc(totals.extraSecondsHuman)}</strong> of extra waiting and <strong>${esc(totals.extraBytesHuman)}</strong> of extra data transferred per week${totals.wikipediaCopies > 0 ? ` (~${totals.wikipediaCopies.toLocaleString()} copies of Wikipedia)` : ''}. Rough estimate, traffic spread evenly across sampled pages.</p>` | |
| : `<p class="meta">Set <code>page_loads_per_week</code> for this target to also estimate total wasted time and data (the way <a href="https://github.com/mgifford/daily-dap">daily-dap</a> uses traffic counts).</p>`} | |
| </section>`; | |
| } | |
| /** | |
| * 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(`<a class="url" href="${esc(r.url)}" title="${esc(r.url)}">${esc(path)}</a>`, 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 = ` | |
| <h1>${esc(target.domain)}: Readability — week ${esc(summary.week)}</h1> | |
| ${subnav('readability')} | |
| <p class="meta">Plain-language metrics for the main content of each scanned page (navigation, header, and footer excluded). ${csvHref ? `<a href="${esc(csvHref)}">Download CSV</a>.` : ''}</p> | |
| <section aria-labelledby="h-read-about"> | |
| ${heading('h-read-about', `What these mean`)} | |
| <dl class="ledger"> | |
| <div><dt>Words per page</dt><dd>Main-content word count.<span class="bug-meta"> Median ${pl.medianWordsPerPage ?? 'n/a'}</span></dd></div> | |
| <div><dt>Reading ease (Flesch)</dt><dd>0–100; higher is easier. ~60+ is plain language; below ~30 is very hard.<span class="bug-meta"> Median ${pl.medianReadingEase ?? 'n/a'}</span></dd></div> | |
| <div><dt>Grade level (Flesch-Kincaid)</dt><dd>US school grade needed to read it; aim for ~8 or lower for the public.<span class="bug-meta"> Median ${pl.medianGrade ?? 'n/a'}</span></dd></div> | |
| <div><dt>Scored</dt><dd>Pages with too little prose (mostly links/cards) are not scored — those numbers would be misleading.<span class="bug-meta"> ${pl.pagesScored} of ${pl.pagesChecked} scored</span></dd></div> | |
| </dl> | |
| <p class="meta">Heuristics for triage and trends, not authoritative linguistics. They flag pages worth a human plain-language review.</p> | |
| </section> | |
| <section aria-labelledby="h-read-pages"> | |
| ${heading('h-read-pages', `Per-page readability`)} | |
| ${sortableTable(`Readability per scanned page (${summary.week}).`, cols, rows)} | |
| </section> | |
| ${acronyms.length ? `<section aria-labelledby="h-acronyms"> | |
| ${heading('h-acronyms', `Unexplained acronyms`)} | |
| <p class="meta">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)}</p> | |
| <ul>${acronyms.map((a) => `<li><code>${esc(a.acronym)}</code> — ${a.pages} page(s)</li>`).join('')}</ul> | |
| </section>` : ''} | |
| ${misspellings.length ? `<section aria-labelledby="h-spelling"> | |
| ${heading('h-spelling', `Possible misspellings`)} | |
| <p class="meta">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 <code>config/spelling-allowlist.txt</code>.${downloadLinks(summary.plainLanguage?.spellingCsv, summary.plainLanguage?.spellingJson)}</p> | |
| <ul>${misspellings.map((m) => `<li><code>${esc(m.word)}</code> — ${m.pages} page(s)</li>`).join('')}</ul> | |
| </section>` : ''}`; | |
| return layout({ | |
| title: `${target.domain} Readability ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="index.html">${esc(target.domain)} ${esc(summary.week)}</a></li><li aria-current="page">Readability</li>`, | |
| 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)}% <span class="bug-meta">(${d.pagesConfirmed} of ${techRan})</span>`; | |
| }; | |
| 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 | |
| ? `<a href="${esc(d.website)}">${esc(d.name)}</a>` | |
| : esc(d.name); | |
| const examples = (d.examplePages ?? []).length | |
| ? `<details><summary class="bug-meta">${d.examplePages.length} example page(s)</summary><ul>${d.examplePages.map((u) => `<li>${urlCell(u)}</li>`).join('')}</ul></details>` | |
| : ''; | |
| return `<tr> | |
| <th scope="row">${nameCell}${d.version ? ` <span class="bug-meta">v${esc(d.version)}</span>` : ''}${examples}</th> | |
| <td class="num" style="color:${confColor(d.confidence)}">${d.confidence}%</td> | |
| <td class="num" data-sort="${d.pagesConfirmed}">${coverage(d)}</td> | |
| <td class="bug-meta">${esc(d.categories.join(', '))}</td> | |
| </tr>`; | |
| }) | |
| .join('\n'); | |
| return `<h3>${esc(cat)}</h3> | |
| <table class="sortable"> | |
| <caption>${esc(cat)} technologies detected on ${esc(target.domain)}, ${esc(summary.week)}.</caption> | |
| <thead><tr><th scope="col">Technology</th><th scope="col">Confidence</th><th scope="col">Coverage</th><th scope="col">All categories</th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table>`; | |
| }) | |
| .join('\n'); | |
| const ranNote = techRan ? ` The technology engine ran on <strong>${techRan}</strong> of ${summary.pagesScanned} pages scanned this week; coverage below is the share of those ${techRan} pages where each technology was found.` : ''; | |
| const body = ` | |
| <h1>${esc(target.domain)}: technology stack</h1> | |
| ${subnav('tech')} | |
| <p class="meta"><strong>${summary.tech.length}</strong> technologies detected in <strong>${esc(summary.week)}</strong>, 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')}</p> | |
| <p class="note">Detection is additive across the week's sampled pages. Expand a technology to see example pages where it was found.</p> | |
| ${sections}`; | |
| return layout({ | |
| title: `${target.domain} Tech Stack ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="index.html">${esc(target.domain)} ${esc(summary.week)}</a></li><li aria-current="page">Tech stack</li>`, | |
| 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) => `<tr> | |
| <th scope="row">${esc(findingLabel(a.finding))}</th> | |
| <td class="num">${a.lift.toFixed(2)}×</td> | |
| <td class="num">${a.pairPages} / ${a.techPages}</td> | |
| <td class="num">${a.findingPages}</td> | |
| </tr>`) | |
| .join('\n'); | |
| return `<h3>${esc(tech)} <span class="bug-meta">${model.tech[tech]} pages</span></h3> | |
| <table class="sortable"> | |
| <caption>Findings over-represented on pages running ${esc(tech)} (lift ≥ 1, ≥5 pages support).</caption> | |
| <thead><tr> | |
| <th scope="col">Finding</th> | |
| <th scope="col" class="num">Lift</th> | |
| <th scope="col" class="num">On tech pages</th> | |
| <th scope="col" class="num">On all pages</th> | |
| </tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table>`; | |
| }) | |
| .join('\n'); | |
| const body = ` | |
| <h1>${esc(target.domain)}: technology ↔ issues</h1> | |
| ${subnav('tech-findings')} | |
| <p class="meta">Accessibility findings that appear disproportionately on pages running a given technology, across the ${model.pages} page(s) in <strong>${esc(summary.week)}</strong> where both technology detection and an accessibility engine ran. <strong>Lift</strong> 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.</p> | |
| <p class="note">This is an <em>association</em>, 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.</p> | |
| ${sections}`; | |
| return layout({ | |
| title: `${target.domain} Tech ↔ Issues ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="index.html">${esc(target.domain)} ${esc(summary.week)}</a></li><li aria-current="page">Tech ↔ issues</li>`, | |
| 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 ? ` · <a href="${esc(csvHref)}">Download CSV</a>` : ''; | |
| 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 `<tr> | |
| <th scope="row">${esc(v.origin)}${isNew ? ' <span class="bug-meta">new</span>' : ''}${v.isScriptVendor ? ' <span class="wcag-badge">JS</span>' : ''}</th> | |
| <td class="num" data-sort="${v.pages}">${v.pages}</td> | |
| <td class="num" data-sort="${v.medianBytes}">${kb(v.medianBytes)}</td> | |
| <td class="num" data-sort="${v.medianRequests}">${v.medianRequests}</td> | |
| <td class="num" data-sort="${v.medianDurationMs}">${v.medianDurationMs} ms</td> | |
| <td class="num" data-sort="${findingShare}">${findingShare}%</td> | |
| </tr>`; | |
| }) | |
| .join('\n'); | |
| const scriptVendors = tp.vendors.filter((v) => v.isScriptVendor).length; | |
| const body = ` | |
| <h1>${esc(target.domain)}: third parties</h1> | |
| ${subnav('third-party')} | |
| <p class="meta">Third-party origins serving resources to <strong>${esc(target.domain)}</strong> across the ${tp.pagesScanned} page(s) measured in <strong>${esc(summary.week)}</strong>. <strong>${tp.vendors.length}</strong> distinct third-party domains, <strong>${scriptVendors}</strong> of them serving JavaScript (<span class="wcag-badge">JS</span>). Costs are medians per page the vendor appears on.${dlLink}</p> | |
| <p class="note">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 <em>association</em> 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.</p> | |
| <table class="sortable"> | |
| <caption>Third-party vendors on ${esc(target.domain)}, ${esc(summary.week)} — sortable.</caption> | |
| <thead><tr> | |
| <th scope="col">Vendor (registrable domain)</th> | |
| <th scope="col" class="num">Pages</th> | |
| <th scope="col" class="num">Median bytes</th> | |
| <th scope="col" class="num">Median requests</th> | |
| <th scope="col" class="num">Median load</th> | |
| <th scope="col" class="num">Pages w/ finding</th> | |
| </tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table>`; | |
| return layout({ | |
| title: `${target.domain} Third Parties ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="index.html">${esc(target.domain)} ${esc(summary.week)}</a></li><li aria-current="page">Third parties</li>`, | |
| 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 ? `<a href="${esc(csvHref)}">CSV</a>` : '', | |
| `<a href="images.json">JSON</a>`, | |
| ].filter(Boolean).join(' · '); | |
| const dlLink = links ? ` Download: ${links}.` : ''; | |
| const pct = (n) => img.totalImages ? `${Math.round((n / img.totalImages) * 100)}%` : '0%'; | |
| const statsTable = `<table class="stats-table"> | |
| <caption>Image alt-text coverage across ${img.pagesScanned} page(s) scanned in ${esc(summary.week)}.</caption> | |
| <thead><tr><th scope="col">Category</th><th scope="col">Count</th><th scope="col">Share</th></tr></thead> | |
| <tbody> | |
| <tr><th scope="row">Total image occurrences</th><td class="num">${img.totalImages}</td><td class="num">—</td></tr> | |
| <tr><th scope="row">Unique images</th><td class="num">${img.uniqueImages ?? '—'}</td><td class="num">—</td></tr> | |
| <tr><th scope="row">Has alt text</th><td class="num">${img.withAlt}</td><td class="num">${pct(img.withAlt)}</td></tr> | |
| <tr><th scope="row">Decorative (alt="")</th><td class="num">${img.decorative}</td><td class="num">${pct(img.decorative)}</td></tr> | |
| <tr><th scope="row">Missing alt attribute</th><td class="num">${img.missingAlt}</td><td class="num">${pct(img.missingAlt)}</td></tr> | |
| </tbody> | |
| </table>`; | |
| // 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 `<tr><th scope="row"${cls}>${esc(label)}</th><td class="num">${verdicts[v]}</td><td class="num">${Math.round((100 * verdicts[v]) / totalUnique)}%</td><td>${esc(expl)}</td></tr>`; | |
| }) | |
| .join('\n'); | |
| const qualitySection = qualityRows ? `<section aria-labelledby="h-images-quality"> | |
| ${heading('h-images-quality', 'Alt-text quality')} | |
| <p class="meta">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.</p> | |
| <table> | |
| <caption>Alt-text quality across ${img.uniqueImages} unique image(s).</caption> | |
| <thead><tr><th scope="col">Verdict</th><th scope="col">Images</th><th scope="col">Share</th><th scope="col">What it means</th></tr></thead> | |
| <tbody>${qualityRows}</tbody> | |
| </table> | |
| </section>` : ''; | |
| // 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' | |
| ? '<span class="error">missing</span>' | |
| : u.altVerdict === 'DECORATIVE' | |
| ? '<em>decorative</em>' | |
| : 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 ? ` <span class="bug-meta">${u.altCount} alt variants</span>` : ''; | |
| const loadingVal = u.loading ?? '—'; | |
| return [ | |
| { html: urlCell(u.src), sort: u.src }, | |
| { html: altCell + inconsistent, sort: u.alt ?? '' }, | |
| { html: `<span class="${vCls}">${esc(vlabel)}</span>`, 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 = ` | |
| <h1>${esc(target.domain)}: image inventory</h1> | |
| ${subnav('images')} | |
| <p class="meta">Unique images encountered on scanned pages in <strong>${esc(summary.week)}</strong>, 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}</p> | |
| ${statsTable} | |
| ${qualitySection} | |
| <section aria-labelledby="h-images-detail"> | |
| ${heading('h-images-detail', 'Image detail')} | |
| ${detailTable} | |
| </section>`; | |
| return layout({ | |
| title: `${target.domain} Images ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="index.html">${esc(target.domain)} ${esc(summary.week)}</a></li><li aria-current="page">Images</li>`, | |
| 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 `<tr> | |
| <th scope="row"><a href="../${esc(s.week)}/index.html">${esc(s.week)}</a></th> | |
| <td class="num">${sc ? `<span class="grade grade-${esc(sc.grade)}">${esc(sc.grade)}</span> ${sc.score}` : 'n/a'}</td> | |
| <td class="num">${s.pagesAudited ?? s.pagesScanned}</td> | |
| <td class="num">${fmtMedian(s.axe.medianViolations)}${d != null && d !== 0 ? ` ${delta(d)}` : ''}</td> | |
| <td class="num">${fmtMedian(s.alfa.medianFailures)}</td> | |
| </tr>`; | |
| }) | |
| .join('\n'); | |
| const body = ` | |
| <h1>${esc(target.domain)}: report archive</h1> | |
| ${subnav('archive')} | |
| <p class="meta">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.</p> | |
| <table> | |
| <caption>Weekly reports for ${esc(target.domain)} (${series.length} weeks).</caption> | |
| <thead><tr><th scope="col">Week</th><th scope="col">Score</th><th scope="col">Pages audited</th><th scope="col">Median axe / page</th><th scope="col">Median Alfa / page</th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table>`; | |
| return layout({ | |
| title: `${target.domain} archive | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="../${esc(latestWeek)}/index.html">${esc(target.domain)}</a></li><li aria-current="page">Archive</li>`, | |
| 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 ? ` <a href="${esc(href)}" class="csv-link">${text}</a>` : ''); | |
| const resolvedCount = diff ? (diff.axe.resolved.length + diff.alfa.resolved.length) : 0; | |
| const body = ` | |
| <h1>${esc(target.domain)}: week ${esc(summary.week)}</h1> | |
| ${subnav('overview')} | |
| <p class="meta">This is the <strong>${esc(summary.week)}</strong> ISO-week report (<strong>${summary.pagesScanned}</strong> pages fetched, <strong>${summary.pagesAudited ?? summary.pagesScanned}</strong> 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.</p> | |
| ${score ? `<aside class="scorecard" aria-label="Accessibility scorecard"> | |
| <span class="grade grade-${esc(score.grade)}">${esc(score.grade)}</span> | |
| <span class="score">${score.score}<span class="score-max">/100</span> <span class="band">${esc(score.band)}</span></span> | |
| <span class="score-detail">${esc(scoreMeaning(summary, score))} | |
| ${traj ? `<strong class="traj traj-${esc(traj.direction)}">${esc(traj.direction)}</strong> (${traj.delta >= 0 ? '+' : ''}${traj.delta} pts since ${esc(traj.fromWeek)}).` : ''} | |
| ${resolvedCount > 0 ? `<strong>${resolvedCount} issue type(s) resolved</strong> since last week.` : ''}</span> | |
| <span class="score-caveat">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.</span> | |
| </aside>` : ''} | |
| ${invSummary ? `<p class="meta">Over the whole history of this site, <strong>${invSummary.totalKnownPages}</strong> unique pages have been scanned at least once; <strong>${invSummary.pagesWithKnownIssues}</strong> have known accessibility issues. <strong>${invSummary.scannedThisWeek}</strong> of them were re-checked this ISO week. <a href="../../../data/${esc(target.key)}/domain.json">Download full data (JSON)</a>.</p>` : ''} | |
| <section aria-labelledby="h-summary"> | |
| ${heading('h-summary', `This week at a glance`)} | |
| <dl class="ledger"> | |
| <div><dt>Median axe violations / page</dt><dd>${fmtMedian(summary.axe.medianViolations)} ${sparkline(trendViol)}</dd></div> | |
| <div><dt>Pages with axe violations</dt><dd>${summary.axe.pagesWithViolations} of ${summary.axe.pagesScanned ?? summary.pagesScanned}${csvLink(csvLinks.axeAll, 'CSV')}</dd></div> | |
| <div><dt>Median Alfa failures / page</dt><dd>${fmtMedian(summary.alfa.medianFailures)}</dd></div> | |
| <div><dt>Pages with Alfa failures</dt><dd>${summary.alfa.pagesWithFailures} of ${summary.alfa.pagesScanned ?? summary.pagesScanned}${csvLink(csvLinks.alfaAll, 'CSV')}</dd></div> | |
| <div><dt>Unique pages audited</dt><dd>${summary.pagesAudited ?? summary.pagesScanned}</dd></div> | |
| ${summary.lighthouse ? ` | |
| <div><dt>Lighthouse performance (median)</dt><dd>${fmtScore(summary.lighthouse.medianPerformance)}<span class="bug-meta"> ${summary.lighthouse.pagesSampled} sampled</span> ${csvLink('lighthouse.html', 'details')}</dd></div> | |
| <div><dt>Lighthouse SEO (median)</dt><dd>${fmtScore(summary.lighthouse.medianSeo)}</dd></div> | |
| <div><dt>Lighthouse best practices (median)</dt><dd>${fmtScore(summary.lighthouse.medianBestPractices)}</dd></div> | |
| ${summary.lighthouse.medianAgentic != null ? `<div><dt>Lighthouse agentic (median)</dt><dd>${fmtScore(summary.lighthouse.medianAgentic)}</dd></div>` : ''}` : ''} | |
| ${summary.plainLanguage ? ` | |
| <div><dt>Words per page (median)</dt><dd>${summary.plainLanguage.medianWordsPerPage ?? 'n/a'}<span class="bug-meta"> main content, nav excluded</span></dd></div> | |
| ${summary.plainLanguage.medianReadingEase != null ? `<div><dt>Reading ease (median)</dt><dd>${summary.plainLanguage.medianReadingEase}<span class="bug-meta"> ${summary.plainLanguage.pagesScored} prose pages</span>${csvLink(summary.plainLanguage.readabilityCsv, 'details')}</dd></div>` : ''} | |
| ${summary.plainLanguage.medianGrade != null ? `<div><dt>Reading grade (median)</dt><dd>${summary.plainLanguage.medianGrade}</dd></div>` : ''} | |
| ${summary.plainLanguage.topMisspellings?.length ? `<div><dt>Misspellings</dt><dd>${summary.plainLanguage.topMisspellings.length}+ distinct${csvLink('readability.html#h-spelling', 'details')}</dd></div>` : ''}` : ''} | |
| ${summary.linkCheck ? ` | |
| <div><dt>Broken links</dt><dd>${summary.linkCheck.brokenCount}${summary.linkCheck.brokenCount > 0 ? ` <a href="errors.html" class="csv-link">details</a>` : ''}</dd></div>` : ''} | |
| ${summary.sustainability ? ` | |
| <div><dt>Median page weight</dt><dd>${kb(summary.sustainability.medianBytes)} | |
| ${diff?.sustainability ? delta(Math.round(diff.sustainability.medianBytesDelta / 1024), { unit: ' KB' }) : ''}</dd></div> | |
| <div><dt>Median requests per page</dt><dd>${summary.sustainability.medianRequests}</dd></div> | |
| <div><dt>${sustainabilityHeadline(summary.sustainability).label}</dt><dd>${sustainabilityHeadline(summary.sustainability).value}</dd></div>` : ''} | |
| </dl> | |
| ${prev && summary.pagesScanned !== prev.pagesScanned ? `<p class="note">Note: page counts differ between weeks (${prev.pagesScanned} → ${summary.pagesScanned}). Prefer the "pages affected" columns over raw instance counts when comparing.</p>` : ''} | |
| ${coverageTable(summary)} | |
| </section> | |
| ${series.length > 1 ? ` | |
| <section aria-labelledby="h-trends"> | |
| ${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 }) : ''} | |
| </section>` : ''} | |
| ${diff ? ` | |
| <section aria-labelledby="h-wow"> | |
| ${heading('h-wow', `Changes since ${diff.prevWeek}`)} | |
| ${changeList('axe-core', diff.axe)} | |
| ${changeList('Alfa', diff.alfa)} | |
| </section>` : ''} | |
| ${fixFirstSection(bugs)} | |
| ${resourcesSection(summary)} | |
| `; | |
| return layout({ | |
| title: `${target.domain} ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li aria-current="page">${esc(target.domain)} ${esc(summary.week)}</li>`, | |
| body, | |
| depth: 3, | |
| }); | |
| } | |
| /** | |
| * Standalone accessibility page: bug reports (with anchored <details> 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 = ` | |
| <h1>${esc(target.domain)}: Accessibility — week ${esc(summary.week)}</h1> | |
| ${subnav('accessibility')} | |
| ${bugReportsSection(target, summary, bugs, csvLinks.bugsAll ?? null, reporting)} | |
| <section aria-labelledby="h-axe"> | |
| ${heading('h-axe', `Deque axe-core findings`)} | |
| <details class="engine-findings"> | |
| <summary>Rule-level axe-core summary (${Object.keys(summary.axe.rules).length} rule type(s))</summary> | |
| <p class="meta">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.</p> | |
| ${ruleTable(`axe-core rules failing in ${summary.week}, by pages affected`, summary.axe.rules, 'axe-core', 'axe-core', csvLinks)} | |
| </details> | |
| </section> | |
| <section aria-labelledby="h-alfa"> | |
| ${heading('h-alfa', `Siteimprove Alfa findings`)} | |
| <details class="engine-findings"> | |
| <summary>Rule-level Alfa summary (${Object.keys(summary.alfa.rules).length} rule type(s))</summary> | |
| <p class="meta">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.</p> | |
| ${ruleTable(`Alfa rules failing in ${summary.week}, by pages affected`, summary.alfa.rules, 'Alfa', 'alfa', csvLinks)} | |
| </details> | |
| </section> | |
| ${consensusSection(summary)} | |
| `; | |
| return layout({ | |
| title: `${target.domain} Accessibility ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="index.html">${esc(target.domain)} ${esc(summary.week)}</a></li><li aria-current="page">Accessibility</li>`, | |
| 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 = ` | |
| <h1>${esc(target.domain)}: Standards & Security — week ${esc(summary.week)}</h1> | |
| ${subnav('standards')} | |
| ${content} | |
| `; | |
| return layout({ | |
| title: `${target.domain} Standards ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="index.html">${esc(target.domain)} ${esc(summary.week)}</a></li><li aria-current="page">Standards</li>`, | |
| 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 = ` | |
| <h1>${esc(target.domain)}: Broken Links & Errors — week ${esc(summary.week)}</h1> | |
| ${subnav('errors')} | |
| ${content} | |
| `; | |
| return layout({ | |
| title: `${target.domain} Errors ${summary.week} | vital-scans`, | |
| breadcrumb: `<li><a href="../../../index.html">All domains</a></li><li><a href="index.html">${esc(target.domain)} ${esc(summary.week)}</a></li><li aria-current="page">Errors</li>`, | |
| body, | |
| depth: 3, | |
| }); | |
| } | |
| function changeList(engineName, d) { | |
| const items = []; | |
| for (const id of d.appeared) items.push(`<li><strong>New:</strong> ${esc(id)} appeared this week.</li>`); | |
| for (const id of d.resolved) items.push(`<li><strong>Resolved:</strong> ${esc(id)} no longer fails on any scanned page.</li>`); | |
| for (const c of d.changed) { | |
| const dir = c.pagesAfter > c.pagesBefore ? 'spread' : 'shrank'; | |
| items.push(`<li>${esc(c.id)} ${dir}: ${c.pagesBefore} → ${c.pagesAfter} pages affected.</li>`); | |
| } | |
| if (items.length === 0) return `<h3>${esc(engineName)}</h3><p>No rule-level changes.</p>`; | |
| return `<h3>${esc(engineName)}</h3><ul>${items.join('\n')}</ul>`; | |
| } | |
| /** | |
| * 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: `<polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="${dashes[di % dashes.length]}" opacity="0.85"/>`, | |
| legend: `<g transform="translate(${W - padR + 6},${ly})"><line x1="0" y1="-4" x2="18" y2="-4" stroke="currentColor" stroke-width="2" stroke-dasharray="${dashes[di % dashes.length]}"/><text x="22" y="0" class="axis">${esc(d.target.domain)}</text></g>`, | |
| }; | |
| }); | |
| const xlabels = [0, Math.floor((allWeeks.length - 1) / 2), allWeeks.length - 1] | |
| .filter((v, idx, a) => a.indexOf(v) === idx) | |
| .map((i) => `<text x="${x(i).toFixed(1)}" y="${H - 8}" text-anchor="middle" class="axis">${esc(allWeeks[i].slice(5))}</text>`) | |
| .join(''); | |
| const table = `<table class="visually-hidden"><caption>Median axe violations per page by domain and week</caption> | |
| <thead><tr><th scope="col">Domain</th>${allWeeks.map((w) => `<th scope="col">${esc(w)}</th>`).join('')}</tr></thead> | |
| <tbody>${withSeries.map((d) => `<tr><th scope="row">${esc(d.target.domain)}</th>${allWeeks.map((w) => `<td>${valAt(d, w) ?? '—'}</td>`).join('')}</tr>`).join('')}</tbody></table>`; | |
| return `<figure class="chart"> | |
| <figcaption>Median axe violations per page, all domains — lower is better</figcaption> | |
| <svg viewBox="0 0 ${W} ${H}" class="linechart" role="img" aria-label="Median axe violations per page over time, compared across ${withSeries.length} domains" preserveAspectRatio="xMidYMid meet"> | |
| ${lines.map((l) => l.line).join('')}${lines.map((l) => l.legend).join('')}${xlabels} | |
| <text x="4" y="${(y(max) + 4).toFixed(1)}" class="axis">${max}</text><text x="4" y="${(y(0) + 4).toFixed(1)}" class="axis">0</text> | |
| </svg> | |
| ${table} | |
| </figure>`; | |
| } | |
| 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 ? '' : ` | |
| <section aria-labelledby="h-blocked"> | |
| <details class="blocked-accordion"> | |
| <summary><span id="h-blocked">Blocked targets (${blocked.length})</span></summary> | |
| <p>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 | |
| <a href="https://github.com/mgifford/vital-core/blob/main/WAF-ALLOWLIST.md">WAF-ALLOWLIST.md</a> | |
| for how the scanner can be allowlisted.</p> | |
| <ul>${blocked | |
| .map(({ target, series }) => { | |
| const latest = series[series.length - 1]; | |
| return `<li><strong>${esc(target.domain)}</strong> — HTTP ${latest.blocked.status} (${esc(latest.week)})</li>`; | |
| }) | |
| .join('\n')}</ul> | |
| </details> | |
| </section>`; | |
| // 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 '<span class="traj traj-stable">— new</span>'; | |
| const sym = t.direction === 'improving' ? '▲' : t.direction === 'worsening' ? '▼' : '▬'; | |
| return `<span class="traj traj-${esc(t.direction)}">${sym} ${esc(t.direction)} ${t.delta >= 0 ? '+' : ''}${t.delta}</span>`; | |
| }; | |
| const rows = ranked | |
| .map((d) => { | |
| const { target, series, latest, win, score, traj } = d; | |
| const trend = series.map(medAxe); | |
| return `<tr> | |
| <th scope="row"><a href="reports/${esc(target.key)}/${esc(latest.week)}/index.html">${esc(target.domain)}</a></th> | |
| <td class="num">${score ? `<span class="grade grade-${esc(score.grade)}">${esc(score.grade)}</span> ${score.score}` : 'n/a'}</td> | |
| <td>${arrow(traj)}</td> | |
| <td class="num">${win.pagesAudited ?? win.pagesScanned}</td> | |
| <td class="num">${fmtMedian(win.axe.medianViolations)}</td> | |
| <td class="num">${fmtMedian(win.alfa.medianFailures)}</td> | |
| <td>${sparkline(trend)}<span class="visually-hidden">Median axe violations per page over ${series.length} weeks: ${trend.join(', ')}.</span></td> | |
| </tr>`; | |
| }) | |
| .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 ? '' : ` | |
| <section aria-labelledby="h-worst"> | |
| ${heading('h-worst', `Worst offenders across all domains`)} | |
| <p class="meta">Highest-impact issues fleet-wide, ranked by pages affected × severity × people reached — where to focus effort first.</p> | |
| <table> | |
| <caption>Top ${worst.length} issues across all active domains.</caption> | |
| <thead><tr><th scope="col">Domain</th><th scope="col">Issue</th><th scope="col">Severity</th><th scope="col">Pages</th></tr></thead> | |
| <tbody>${worst | |
| .map((b) => `<tr> | |
| <th scope="row"><a href="reports/${esc(b.key)}/${esc(b._week)}/index.html">${esc(b.domain)}</a></th> | |
| <td>${esc(b.summary)}</td> | |
| <td><span class="sev-badge">${esc(b.severity)}</span></td> | |
| <td class="num">${b.frequency.pages_affected}</td> | |
| </tr>`) | |
| .join('\n')}</tbody> | |
| </table> | |
| </section>`; | |
| // 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 = ` | |
| <section aria-labelledby="h-techfindings"> | |
| ${heading('h-techfindings', `Cross-technology issues`)} | |
| <p class="meta">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.</p> | |
| <table class="sortable"> | |
| <caption>Top ${fleetPairs.length} technology ↔ finding associations spanning ≥2 sites.</caption> | |
| <thead><tr> | |
| <th scope="col">Technology</th> | |
| <th scope="col">Finding</th> | |
| <th scope="col" class="num">Lift</th> | |
| <th scope="col" class="num">Sites</th> | |
| <th scope="col" class="num">Pages</th> | |
| </tr></thead> | |
| <tbody>${fleetPairs | |
| .map((p) => `<tr> | |
| <th scope="row">${esc(p.tech)}</th> | |
| <td>${esc(findingLabel(p.finding))}</td> | |
| <td class="num">${p.lift.toFixed(2)}×</td> | |
| <td class="num">${p.sites}</td> | |
| <td class="num">${p.pairPages}</td> | |
| </tr>`) | |
| .join('\n')}</tbody> | |
| </table> | |
| </section>`; | |
| } | |
| } | |
| // 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 = ` | |
| <section aria-labelledby="h-lhfleet"> | |
| ${heading('h-lhfleet', `Common Lighthouse recommendations`)} | |
| <p class="meta">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).</p> | |
| <table class="sortable"> | |
| <caption>Top ${lhFleet.length} Lighthouse recommendations spanning ≥2 sites.</caption> | |
| <thead><tr> | |
| <th scope="col">Recommendation</th> | |
| <th scope="col">Category</th> | |
| <th scope="col" class="num">Sites</th> | |
| <th scope="col" class="num">Pages</th> | |
| <th scope="col" class="num">Est. saving</th> | |
| </tr></thead> | |
| <tbody>${lhFleet | |
| .map((e) => { | |
| const saving = [fmtSavingsBytes(e.savingsBytes), e.savingsMs ? `${(e.savingsMs / 1000).toFixed(1)}s` : ''] | |
| .filter(Boolean).join(' · ') || '—'; | |
| return `<tr> | |
| <th scope="row">${esc(e.title)}</th> | |
| <td>${esc(catLabel(e.category))}</td> | |
| <td class="num">${e.sites}</td> | |
| <td class="num">${e.pages}</td> | |
| <td class="num">${saving}</td> | |
| </tr>`; | |
| }) | |
| .join('\n')}</tbody> | |
| </table> | |
| </section>`; | |
| } | |
| const body = ` | |
| <h1>${esc(h1)}</h1> | |
| <p class="meta">${esc(intro)}</p> | |
| ${active.length === 0 | |
| ? (dashboard.length === 0 | |
| ? '<p>No scan data yet. The first weekly report appears after the first scheduled scans complete.</p>' | |
| : '<p>No accessibility or sustainability data could be collected yet — every target is currently blocked (see the bottom of this page).</p>') | |
| : ` | |
| <table> | |
| <caption>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.</caption> | |
| <thead><tr><th scope="col">Domain</th><th scope="col">Score</th><th scope="col">Trajectory</th><th scope="col">Pages audited (7d)</th><th scope="col">Median axe / page</th><th scope="col">Median Alfa / page</th><th scope="col">Trend</th></tr></thead> | |
| <tbody>${rows}</tbody> | |
| </table> | |
| <p class="score-caveat">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.</p> | |
| ${overlay} | |
| ${sustainTrend} | |
| ${worstSection} | |
| ${techFindingsSection} | |
| ${lighthouseFleetSection} | |
| ${blockedCallout}`} | |
| <section aria-labelledby="h-why"> | |
| ${heading('h-why', `Why this exists`)} | |
| <p>Continuous measurement beats one-off audits. This ledger tracks whether each site is getting more | |
| accessible and lighter over time, using <a href="https://github.com/dequelabs/axe-core">axe-core</a> and | |
| <a href="https://github.com/Siteimprove/alfa">Alfa</a> (the open source engine behind Siteimprove) for | |
| accessibility, and page weight with <a href="https://sustainablewebdesign.org/">Sustainable Web Design</a> | |
| CO₂ estimates for sustainability. Everything here is open: the scanner, the data, and the reports.</p> | |
| </section>`; | |
| 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; } | |
| `; | |