vital-core / src /lib /score.js
vital-core sync
Deploy snapshot for Hugging Face Spaces
50841a0
Raw
History Blame Contribute Delete
4.9 kB
/**
* A single comparable accessibility score per domain, designed to drive
* action — not to award a participation F to everyone.
*
* Why not "share of perfectly clean pages": with two strict engines
* (axe + Alfa), almost every real government page has at least one minor
* finding, so "perfectly clean" is ~0 everywhere and every site collapses
* to F. That score differentiates nothing.
*
* Instead we score the TYPICAL page's burden — the median issues per page
* — against realistic government-web benchmarks. This spreads sites
* across a curve, rewards reducing the everyday burden (the thing teams
* can actually act on), and makes an F genuinely rare and bad.
*
* Benchmark anchors (median axe+Alfa findings per page), from observed
* government sites:
* ~0 -> 100 exceptional
* 2 -> 90 (A) very good
* 4 -> 80 (B) good
* 6 -> 70 (C) typical / acceptable
* 10 -> 55 (D) needs work
* 16 -> 40 (F) poor
* 30+ -> ~20 severe
* A typical site lands around C, better and worse sites spread around it.
*/
// Piecewise-linear map from median issues/page to a 0-100 score, through
// the benchmark anchors above. Monotonic: fewer issues always scores higher.
const ANCHORS = [
[0, 100], [2, 90], [4, 80], [6, 70], [10, 55], [16, 40], [30, 20], [60, 5],
];
function scoreFromDensity(median) {
if (median <= 0) return 100;
for (let i = 1; i < ANCHORS.length; i++) {
const [x0, y0] = ANCHORS[i - 1];
const [x1, y1] = ANCHORS[i];
if (median <= x1) {
const t = (median - x0) / (x1 - x0);
return Math.round(y0 + t * (y1 - y0));
}
}
return ANCHORS[ANCHORS.length - 1][1]; // floor for very high densities
}
export function scoreFor(summary) {
const axePages = summary.axe?.pagesScanned ?? 0;
const alfaPages = summary.alfa?.pagesScanned ?? 0;
const auditedPages = summary.pagesAudited ?? Math.max(axePages, alfaPages);
if (!auditedPages) return null;
// The score is based on axe ALONE, deliberately:
// - axe runs on 100% of pages; Alfa is sampled (~30%), so Alfa's median
// is over a different, smaller page set — mixing two coverage levels
// into one number is unsound.
// - axe counts unique rule violations (stable, comparable across
// sites); Alfa counts individual failing elements, which inflates
// by an order of magnitude for one repeated pattern.
// Alfa still runs and is reported separately as independent cross-engine
// confirmation — it just doesn't skew the comparable score.
const medianIssues = summary.axe?.medianViolations ?? 0;
const score = scoreFromDensity(medianIssues);
// Kept for context in the report copy (not part of the score).
const dirtyPages = summary.axe?.pagesWithViolations ?? 0;
const cleanShare = Math.round(Math.max(0, 1 - dirtyPages / auditedPages) * 100);
return { score, grade: grade(score), band: band(score), medianIssues, cleanShare };
}
/**
* Letter grade calibrated to the density scale above, so a typical
* government site (~6 issues/page) lands at C — not F. An F now means
* the typical page carries a heavy, unusual burden.
*/
export function grade(score) {
if (score >= 85) return 'A';
if (score >= 75) return 'B';
if (score >= 65) return 'C';
if (score >= 50) return 'D';
return 'F';
}
/** Plain-language band label (avoids school-grade baggage where useful). */
export function band(score) {
if (score >= 85) return 'Leading';
if (score >= 75) return 'On track';
if (score >= 65) return 'Typical';
if (score >= 50) return 'Needs work';
return 'At risk';
}
/**
* One-line, plain explanation of what the score means and the single
* highest-leverage next step, given the week's summary.
*/
export function scoreMeaning(summary, sc) {
if (!sc) return '';
const topAxe = topRule(summary.axe?.rules);
const action = topAxe
? `Biggest lever: fix “${topAxe.id}” — it affects ${topAxe.pages} page(s).`
: 'Keep the typical page’s issue count low.';
return `The typical page has ${sc.medianIssues} axe violation(s). ${action}`;
}
function topRule(rules) {
if (!rules) return null;
let best = null;
for (const [id, r] of Object.entries(rules)) {
if (!best || r.pages > best.pages) best = { id, pages: r.pages };
}
return best;
}
/**
* Trajectory of the score across a series of summaries: latest vs
* `lookback` weeks earlier.
*/
export function trajectory(series, lookback = 4) {
if (!series || series.length < 2) return null;
const latest = scoreFor(series[series.length - 1]);
const idx = Math.max(0, series.length - 1 - lookback);
const past = scoreFor(series[idx]);
if (!latest || !past) return null;
const delta = latest.score - past.score;
const direction = delta >= 3 ? 'improving' : delta <= -3 ? 'worsening' : 'stable';
return { direction, delta, fromWeek: series[idx].week };
}