automatica11y 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ {
2
+ "source": "https://www.w3.org/WAI/WCAG22/wcag.json",
3
+ "about": "https://github.com/w3c/wcag/blob/main/11ty/json/README.md",
4
+ "retrieved": "2026-10-08",
5
+ "sha256": "3a034865a879a7d874b60ff2aec7f430cf37bc1597a1e00985680fd80ef5cf75"
6
+ }
package/src/globals.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Properties our init scripts and helpers put on the page's window. They only exist inside the browser. */
2
2
  interface Window {
3
3
  __a11y: any;
4
+ __a11yMeasure: any;
4
5
  __vsr: any;
5
6
  __a11yClicks: number;
6
7
  __a11yLast: any;
@@ -5,6 +5,20 @@ const ASSET_LOADERS = /** @type {Record<string, import("esbuild").Loader>} */ (O
5
5
  [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".woff", ".woff2", ".ttf", ".eot"].map((ext) => [ext, "dataurl"]),
6
6
  ));
7
7
 
8
+ /**
9
+ * The package names esbuild couldn't resolve, such as `@emotion/react`. A subpath import counts as its package.
10
+ * @param {{ text?: string }[]} errors
11
+ * @returns {string[]}
12
+ */
13
+ export function unresolvedPackages(errors) {
14
+ const names = new Set();
15
+ for (const { text = "" } of errors) {
16
+ const match = /^Could not resolve "([^".\/][^"]*)"/.exec(text);
17
+ if (match) names.add(match[1].split("/").slice(0, match[1].startsWith("@") ? 2 : 1).join("/"));
18
+ }
19
+ return [...names];
20
+ }
21
+
8
22
  /**
9
23
  * Bundle fixture entries into one folder of browser-ready ES modules, plus one HTML shell per entry.
10
24
  * esbuild loads here and only here. It doesn't type-check, and fixtures don't need it to.
@@ -33,7 +47,7 @@ export async function bundleEntries({ entries, outdir, workDir, react = false })
33
47
  });
34
48
  } catch (error) {
35
49
  const messages = (error.errors ?? []).slice(0, 3).map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`);
36
- throw new Error(`Bundling failed: ${messages.join("; ") || error.message}`);
50
+ throw Object.assign(new Error(`Bundling failed: ${messages.join("; ") || error.message}`), { unresolved: unresolvedPackages(error.errors ?? []) });
37
51
  }
38
52
  /** @type {Record<string, string>} */
39
53
  const pages = {};
@@ -41,7 +55,7 @@ export async function bundleEntries({ entries, outdir, workDir, react = false })
41
55
  const css = existsSync(join(outdir, `${name}.css`)) ? `<link rel="stylesheet" href="./${name}.css">` : "";
42
56
  writeFileSync(
43
57
  join(outdir, `${name}.html`),
44
- `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>${name}</title><link rel="icon" href="data:,">${css}</head>\n<body><div id="root"></div><script type="module" src="./${name}.js"></script></body>\n</html>\n`,
58
+ `<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8"><title>${name}</title><link rel="icon" href="data:,">${css}</head>\n<body><main><div id="root"></div></main><script type="module" src="./${name}.js"></script></body>\n</html>\n`,
45
59
  );
46
60
  pages[name] = `/${name}.html`;
47
61
  }
@@ -1,5 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  const NPM_FLAGS = ["--ignore-scripts", "--no-audit", "--no-fund", "--prefer-offline", "--loglevel=error"];
@@ -19,6 +19,9 @@ export function runNpm(args, cwd, { timeoutMs = 300_000 } = {}) {
19
19
  });
20
20
  }
21
21
 
22
+ /** npm's local copy of a package list can lag behind the registry, so a version that exists looks missing. */
23
+ const STALE_CACHE = /ETARGET|notarget|No matching version/i;
24
+
22
25
  /** The version of an installed package, or null. */
23
26
  export function installedVersion(dir, name) {
24
27
  try {
@@ -39,11 +42,23 @@ export async function installPackage({ dir, name, version, flavor, run = runNpm
39
42
  if (!existsSync(join(dir, "package.json"))) writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "automatica11y-target", private: true }));
40
43
  /** @type {string[]} */
41
44
  const warnings = [];
45
+ let refreshed = false;
46
+ /** Run npm. If it says a version doesn't exist, ask once more with fresh package data, since the version came from the registry. */
47
+ const npm = async (args) => {
48
+ try {
49
+ return await run(args, dir);
50
+ } catch (error) {
51
+ if (!STALE_CACHE.test(String(error.message)) || !args.includes("--prefer-offline")) throw error;
52
+ if (!refreshed) warnings.push(`npm's local list of ${name} versions was out of date, so the install refreshed it and tried again.`);
53
+ refreshed = true;
54
+ return run(args.map((arg) => (arg === "--prefer-offline" ? "--prefer-online" : arg)), dir);
55
+ }
56
+ };
42
57
  try {
43
- await run(["install", `${name}@${version}`, ...NPM_FLAGS], dir);
58
+ await npm(["install", `${name}@${version}`, ...NPM_FLAGS]);
44
59
  } catch (error) {
45
60
  if (!/ERESOLVE|peer dep/i.test(String(error.message))) throw new Error(`npm couldn't install ${name}@${version}: ${firstLine(error.message)}`);
46
- await run(["install", `${name}@${version}`, "--legacy-peer-deps", ...NPM_FLAGS], dir).catch((retry) => {
61
+ await npm(["install", `${name}@${version}`, "--legacy-peer-deps", ...NPM_FLAGS]).catch((retry) => {
47
62
  throw new Error(`npm couldn't install ${name}@${version}: ${firstLine(retry.message)}`);
48
63
  });
49
64
  warnings.push(`npm couldn't satisfy ${name}'s peer dependencies, so it installed them loosely (--legacy-peer-deps). Results may not match a supported setup.`);
@@ -51,15 +66,64 @@ export async function installPackage({ dir, name, version, flavor, run = runNpm
51
66
  if (flavor === "react") {
52
67
  const react = installedVersion(dir, "react");
53
68
  if (!react) {
54
- await run(["install", "react", "react-dom", ...NPM_FLAGS, "--legacy-peer-deps"], dir);
69
+ await npm(["install", "react", "react-dom", ...NPM_FLAGS, "--legacy-peer-deps"]);
55
70
  warnings.push(`${name} didn't bring in react, so the latest react and react-dom were added.`);
56
71
  } else if (!installedVersion(dir, "react-dom")) {
57
- await run(["install", `react-dom@${react}`, ...NPM_FLAGS, "--legacy-peer-deps"], dir);
72
+ await npm(["install", `react-dom@${react}`, ...NPM_FLAGS, "--legacy-peer-deps"]);
58
73
  }
59
74
  }
60
75
  return { dir, warnings, react: installedVersion(dir, "react"), reactDom: installedVersion(dir, "react-dom"), version: installedVersion(dir, name) };
61
76
  }
62
77
 
78
+ /**
79
+ * Find the optional peer dependencies that installed packages declare, with the range each asks for.
80
+ * npm leaves these out, but a library's default setup can still need one (MUI needs an Emotion package).
81
+ * @param {string} dir The install folder.
82
+ * @returns {Map<string, string>} Package name to range.
83
+ */
84
+ export function declaredOptionalPeers(dir) {
85
+ const root = join(dir, "node_modules");
86
+ const found = new Map();
87
+ const names = [];
88
+ for (const entry of existsSync(root) ? readdirSync(root) : []) {
89
+ if (entry.startsWith(".")) continue;
90
+ if (entry.startsWith("@")) for (const inner of readdirSync(join(root, entry))) names.push(`${entry}/${inner}`);
91
+ else names.push(entry);
92
+ }
93
+ for (const name of names) {
94
+ try {
95
+ const pkg = JSON.parse(readFileSync(join(root, name, "package.json"), "utf8"));
96
+ for (const [peer, meta] of Object.entries(pkg.peerDependenciesMeta ?? {})) {
97
+ if (meta?.optional && pkg.peerDependencies?.[peer] && !found.has(peer)) found.set(peer, pkg.peerDependencies[peer]);
98
+ }
99
+ } catch {
100
+ // A folder without a readable package.json isn't a package.
101
+ }
102
+ }
103
+ return found;
104
+ }
105
+
106
+ /**
107
+ * Install the optional peer dependencies a failed bundle was missing. Only packages that an installed library
108
+ * declares as optional peers are added, so a typo in a fixture can't pull in an arbitrary package.
109
+ * @param {{ dir: string, unresolved: string[], run?: typeof runNpm }} options
110
+ * @returns {Promise<{ installed: string[], warnings: string[] }>}
111
+ */
112
+ export async function installOptionalPeers({ dir, unresolved, run = runNpm }) {
113
+ const declared = declaredOptionalPeers(dir);
114
+ const wanted = unresolved.filter((name) => declared.has(name) && !installedVersion(dir, name));
115
+ if (wanted.length === 0) return { installed: [], warnings: [] };
116
+ // A loose install prunes packages that only arrived as peers, so name React again to keep it.
117
+ const keep = ["react", "react-dom"].flatMap((name) => (installedVersion(dir, name) ? [`${name}@${installedVersion(dir, name)}`] : []));
118
+ const specs = [...wanted.map((name) => `${name}@${declared.get(name)}`), ...keep];
119
+ try {
120
+ await run(["install", ...specs, "--legacy-peer-deps", ...NPM_FLAGS], dir);
121
+ } catch (error) {
122
+ throw new Error(`npm couldn't install the optional peer dependencies ${wanted.join(", ")}: ${firstLine(error.message)}`);
123
+ }
124
+ return { installed: wanted, warnings: [`The library lists ${wanted.join(", ")} as optional peer dependencies and its code needs them to load, so they were installed.`] };
125
+ }
126
+
63
127
  function firstLine(text) {
64
128
  return String(text).split("\n").find((line) => line.trim())?.replace(/^npm (error|ERR!)\s*/i, "").trim() ?? "unknown error";
65
129
  }
@@ -58,7 +58,8 @@ export function candidateMapping({ flavor, exports = [], tags = [] }) {
58
58
  const info = exports.find((e) => e.name === best);
59
59
  const parts = info?.parts ?? [];
60
60
  // Flat compound libraries (DialogRoot, DialogTrigger, DialogContent) have sibling exports that share a prefix.
61
- const siblings = flavor === "react" ? names.filter((n) => n !== best && n.startsWith(best) && n.length > best.length) : [];
61
+ // A button or link usually sits beside ButtonBase, ButtonGroup, and the like, which aren't its parts, so only real parts (Button.Root) count there.
62
+ const siblings = flavor === "react" && !TEMPLATED.has(archetype) ? names.filter((n) => n !== best && n.startsWith(best) && n.length > best.length) : [];
62
63
  const compound = parts.length > 0 || siblings.length >= 2;
63
64
  const templated = TEMPLATED.has(archetype) && !compound;
64
65
  mapping[archetype] = {
@@ -53,7 +53,8 @@ export function detectFlavor(meta) {
53
53
  const peers = meta.peerDependencies ?? {};
54
54
  const deps = meta.dependencies ?? {};
55
55
  if ("react" in peers || "react-dom" in peers || "react" in deps) {
56
- return { kind: "npm-react", framework: "React", reason: "The package lists react as a dependency." };
56
+ const how = "react" in peers || "react-dom" in peers ? "peer dependency" : "dependency";
57
+ return { kind: "npm-react", framework: "React", reason: `The package lists react as a ${how}.` };
57
58
  }
58
59
  if (meta.customElements) return { kind: "npm-wc", framework: "Web components", reason: "The package has a customElements manifest." };
59
60
  for (const [name, label] of Object.entries(OTHER_FRAMEWORKS)) {
@@ -15,7 +15,7 @@ import {
15
15
  targetSection,
16
16
  } from "./parts.js";
17
17
 
18
- const TIER_ORDER = ["rules", "interactions", "vsr"];
18
+ const TIER_ORDER = ["rules", "interactions", "computed", "vsr"];
19
19
 
20
20
  /** The archetype rows: component archetypes in a fixed order, then whole pages, then Storybook stories. */
21
21
  function archetypeKeys(results) {
@@ -104,6 +104,16 @@ function interactionsCell(configs) {
104
104
  return parts.filter(Boolean).join("; ");
105
105
  }
106
106
 
107
+ function computedCell(configs) {
108
+ const checks = configs.flatMap((c) => c.tiers.computed?.checks ?? []);
109
+ if (checks.length === 0) return configs.map((c) => c.tiers.computed?.status).find(Boolean) ?? "-";
110
+ const failed = checks.filter((c) => c.result === "fail").map((c) => code(c.name));
111
+ const unknown = checks.filter((c) => c.result === "undetermined").length;
112
+ const errors = checks.filter((c) => c.result === "error").length;
113
+ const parts = [failed.length ? `failed: ${failed.slice(0, 3).join(", ")}${failed.length > 3 ? `, and ${num(failed.length - 3)} more` : ""}` : "no failures", unknown ? `${num(unknown)} undetermined` : null, errors ? plural(errors, "error") : null];
114
+ return parts.filter(Boolean).join("; ");
115
+ }
116
+
107
117
  function vsrCell(configs) {
108
118
  const walks = configs.map((c) => c.tiers.vsr).filter((v) => v?.status === "ran");
109
119
  if (walks.length === 0) return configs.map((c) => c.tiers.vsr?.status).find(Boolean) ?? "-";
@@ -113,17 +123,17 @@ function vsrCell(configs) {
113
123
  }
114
124
 
115
125
  function findingsTable(plan, results, key) {
116
- const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Virtual screen reader (simulated)"];
126
+ const head = ["Target", "Configuration", ENGINE_NAMES.axe, ENGINE_NAMES.ibm, "Interactions", "Computed checks", "Virtual screen reader (simulated)"];
117
127
  const lines = [`| ${head.join(" | ")} |`, `| ${head.map(() => "---").join(" | ")} |`];
118
128
  for (const target of results.targets) {
119
129
  const planTarget = plan.targets.find((t) => t.id === target.id);
120
130
  for (const row of rowsFor(planTarget, target, key)) {
121
131
  if (row.note) {
122
- lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | |`);
132
+ lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(row.note)} | | | | |`);
123
133
  continue;
124
134
  }
125
135
  const c = row.configs;
126
- lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(vsrCell(c))} |`);
136
+ lines.push(`| ${cell(target.id)} | ${cell(row.label)} | ${cell(ruleCell(c, "axe"))} | ${cell(ruleCell(c, "ibm"))} | ${cell(interactionsCell(c))} | ${cell(computedCell(c))} | ${cell(vsrCell(c))} |`);
127
137
  }
128
138
  }
129
139
  return lines;
@@ -1,3 +1,4 @@
1
+ import { criterion, criterionName, wcagAttribution } from "../wcag/index.js";
1
2
  import { cap, num, plural } from "../text.js";
2
3
 
3
4
  /** Markdown helpers. */
@@ -8,7 +9,7 @@ export const sentence = (text) => String(text).replace(/\.+$/, "");
8
9
  /** Escape angle brackets so rule text like <input> doesn't turn into HTML. */
9
10
  export const esc = (text) => String(text ?? "").replace(/</g, "\\<");
10
11
  export const ENGINE_NAMES = { axe: "axe-core", ibm: "IBM Equal Access" };
11
- export const TIER_NAMES = { rules: "Rules", interactions: "Interactions", vsr: "Virtual screen reader" };
12
+ export const TIER_NAMES = { rules: "Rules", interactions: "Interactions", computed: "Computed checks", vsr: "Virtual screen reader" };
12
13
 
13
14
  export function toolLines(tools) {
14
15
  return Object.entries(tools)
@@ -27,6 +28,11 @@ export function engineCell(summary, engine) {
27
28
  export function tierCell(target, tier) {
28
29
  const counts = target.summary?.interactions;
29
30
  const vsr = target.summary?.vsr;
31
+ const measured = target.summary?.computed;
32
+ if (tier === "computed" && measured) {
33
+ const parts = [measured.fail && `${num(measured.fail)} failed`, measured.undetermined && `${num(measured.undetermined)} undetermined`, measured.error && plural(measured.error, "error"), measured.pass && `${num(measured.pass)} passed`, measured.notApplicable && `${num(measured.notApplicable)} not applicable`].filter(Boolean);
34
+ return `ran, ${parts.join(", ")}`;
35
+ }
30
36
  if (tier === "vsr" && vsr) return `ran (simulated), ${vsr.flagged ? `${num(vsr.flagged)} flagged` : "none flagged"}`;
31
37
  if (tier === "interactions" && counts) {
32
38
  const parts = [counts.fail && plural(counts.fail, "failed", "failed"), counts.error && plural(counts.error, "error"), counts.pass && `${num(counts.pass)} passed`, counts.notApplicable && `${num(counts.notApplicable)} not applicable`].filter(Boolean);
@@ -58,7 +64,7 @@ export function findingBlock(finding, { showImpact, showToolkit }) {
58
64
  showImpact && finding.impact ? `impact: ${finding.impact}` : null,
59
65
  showToolkit && finding.toolkitLevel != null ? `IBM Toolkit level ${finding.toolkitLevel}` : null,
60
66
  finding.kind ? `kind: ${finding.kind}` : null,
61
- finding.wcag.length ? `WCAG ${finding.wcag.join(", ")}` : null,
67
+ finding.wcag.length ? `WCAG ${finding.wcag.map(criterionName).join(", ")}` : null,
62
68
  plural(finding.nodeCount, "element"),
63
69
  ].filter(Boolean);
64
70
  const lines = [`- ${code(finding.ruleId)} (${meta.join("; ")}). ${esc(finding.help)} [Rule help](${finding.helpUrl})`];
@@ -200,7 +206,25 @@ export function interactionsSection(result, nested) {
200
206
  const lines = [`${nested ? "#####" : "####"} Interactions.`, "", "Each check ran on a fresh page, using only the trigger and root hooks and ARIA roles. A check that couldn't finish is an error, which counts as a gap and never as a pass.", ""];
201
207
  lines.push("| Check | Result | WCAG | Detail |", "| --- | --- | --- | --- |");
202
208
  for (const check of result.checks) {
203
- lines.push(`| ${code(check.name)} | ${check.result} | ${cell((check.criteria ?? []).join(", ") || "-")} | ${cell(check.detail)}${check.method ? cell(` (method: ${check.method})`) : ""} |`);
209
+ lines.push(`| ${code(check.name)} | ${check.result} | ${cell(criteriaCell(check.criteria))} | ${cell(check.detail)}${check.method ? cell(` (method: ${check.method})`) : ""} |`);
210
+ }
211
+ return lines.join("\n");
212
+ }
213
+
214
+ /** Criterion numbers as links to the W3C text, with the W3C's name for each. Unknown numbers stay plain. */
215
+ export function criteriaCell(list) {
216
+ if (!list || list.length === 0) return "-";
217
+ return list.map((num) => {
218
+ const c = criterion(num);
219
+ return c ? `[${c.num} ${c.handle}](${c.url})` : num;
220
+ }).join(", ");
221
+ }
222
+
223
+ export function computedSection(result, nested) {
224
+ const lines = [`${nested ? "#####" : "####"} Computed checks.`, "", "automatica11y's own measurements from resolved styles in the browser, with the numbers WCAG gives. They're reported on their own and never added to the axe-core or IBM Equal Access counts. A check that can't reduce the page to colors (a gradient, an image, transparency) is undetermined, which counts as a gap and never as a pass.", ""];
225
+ lines.push("| Check | Result | WCAG | Detail |", "| --- | --- | --- | --- |");
226
+ for (const check of result.checks) {
227
+ lines.push(`| ${code(check.name)} | ${check.result} | ${cell(criteriaCell(check.criteria))} | ${cell(check.detail)}${check.method ? cell(` (method: ${check.method})`) : ""} |`);
204
228
  }
205
229
  return lines.join("\n");
206
230
  }
@@ -250,6 +274,8 @@ export function targetSection(planTarget, target) {
250
274
  lines.push(vsrSection(result, name !== "page"), "");
251
275
  } else if (tier === "interactions" && result.status === "ran") {
252
276
  lines.push(interactionsSection(result, name !== "page"), "");
277
+ } else if (tier === "computed" && result.status === "ran") {
278
+ lines.push(computedSection(result, name !== "page"), "");
253
279
  } else if (result.status !== "ran") {
254
280
  skipped.add(`${TIER_NAMES[tier] ?? tier}: ${sentence(result.reason ?? result.status)}.`);
255
281
  }
@@ -326,6 +352,8 @@ export function reportFooter(results) {
326
352
  "",
327
353
  "Contrast results depend on how the browser rendered the page, so the browser version is recorded above.",
328
354
  "",
355
+ `**WCAG data.** ${wcagAttribution()}`,
356
+ "",
329
357
  );
330
358
  return lines;
331
359
  }
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { bundleEntries } from "../harness/bundle.js";
5
- import { installPackage } from "../harness/npm-install.js";
5
+ import { installOptionalPeers, installPackage } from "../harness/npm-install.js";
6
6
  import * as react from "../harness/npm-react.js";
7
7
  import * as wc from "../harness/npm-wc.js";
8
8
  import { closedShadowHosts, notTestableEntries } from "../harness/shadow.js";
@@ -10,6 +10,7 @@ import { serveStatic } from "../harness/static-serve.js";
10
10
  import { openPage } from "../harness/url.js";
11
11
  import { candidateMapping, findAuthoredFixture } from "../plan/mapping.js";
12
12
  import { ARCHETYPES } from "../schema.js";
13
+ import { runComputed } from "../tiers/computed/index.js";
13
14
  import { runInteractions } from "../tiers/interactions/index.js";
14
15
  import { runRules } from "../tiers/rules/index.js";
15
16
  import { failedVsr, runVsr } from "../tiers/vsr.js";
@@ -40,12 +41,28 @@ function watchErrors(errors) {
40
41
  };
41
42
  }
42
43
 
44
+ /**
45
+ * Bundle, and if the library needs an optional peer dependency that npm left out, install it and bundle once more.
46
+ * @param {Parameters<typeof bundleEntries>[0]} options
47
+ * @param {string[]} warnings Gets a note when peers were installed.
48
+ */
49
+ async function bundleWithPeers(options, warnings) {
50
+ try {
51
+ return await bundleEntries(options);
52
+ } catch (error) {
53
+ const added = await installOptionalPeers({ dir: options.workDir, unresolved: error.unresolved ?? [] });
54
+ if (added.installed.length === 0) throw error;
55
+ warnings.push(...added.warnings);
56
+ return bundleEntries(options);
57
+ }
58
+ }
59
+
43
60
  /** Load the whole package in a page to list its exports and the custom elements it defines. */
44
- async function discover({ browser, workDir, flavor, pkg, buildDir }) {
61
+ async function discover({ browser, workDir, flavor, pkg, buildDir, warnings }) {
45
62
  const helper = flavor === "react" ? react : wc;
46
63
  const entryFile = join(workDir, "discover.js");
47
64
  writeFileSync(entryFile, helper.discoverEntry(pkg));
48
- await bundleEntries({ entries: { discover: entryFile }, outdir: buildDir, workDir, react: flavor === "react" });
65
+ await bundleWithPeers({ entries: { discover: entryFile }, outdir: buildDir, workDir, react: flavor === "react" }, warnings);
49
66
  const server = await serveStatic(buildDir);
50
67
  const errors = [];
51
68
  try {
@@ -104,7 +121,7 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
104
121
  /** @type {Record<string, any>} */
105
122
  const tiers = {};
106
123
  for (const tier of plan.options.tiers) {
107
- if (tier === "interactions") continue;
124
+ if (tier === "interactions" || tier === "computed") continue;
108
125
  if (tier === "vsr") tiers.vsr = failure ? { status: "skipped", simulated: true, reason: failure } : await runVsr(opened.page, { scope: "body", state }).catch(failedVsr);
109
126
  else if (failure) tiers.rules = { status: "failed", reason: failure, engines: Object.fromEntries(plan.options.engines.map((e) => [e, { status: "failed", reason: failure }])) };
110
127
  else {
@@ -117,6 +134,7 @@ async function auditFixturePage({ browser, url, archetype, plan, libA11y }) {
117
134
  const hidden = notTestableEntries(await closedShadowHosts(opened.page));
118
135
  // The checks open their own fresh pages, so run them after this page's rules results are in.
119
136
  if (plan.options.tiers.includes("interactions")) configs[0].tiers.interactions = await runInteractions(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, archetype);
137
+ if (plan.options.tiers.includes("computed")) configs[0].tiers.computed = await runComputed(browser, libA11y === "n/a" ? url : `${url}?libA11y=${libA11y}`, archetype);
120
138
  return { configs, hidden };
121
139
  } finally {
122
140
  await opened.close();
@@ -164,7 +182,7 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
164
182
  const installed = await install({ dir: workDir, name: resolved.name, version: resolved.version, flavor });
165
183
  const warnings = [...installed.warnings];
166
184
 
167
- const found = await discover({ browser, workDir, flavor: flavor === "react" ? "react" : "wc", pkg: resolved.name, buildDir });
185
+ const found = await discover({ browser, workDir, flavor: flavor === "react" ? "react" : "wc", pkg: resolved.name, buildDir, warnings });
168
186
  if (flavor === "unknown") {
169
187
  if (found.tags.length > 0) flavor = "wc";
170
188
  else if (installed.react && found.exports.some((e) => /^[A-Z]/.test(e.name))) flavor = "react";
@@ -219,7 +237,7 @@ export async function auditNpm({ browser, planTarget, plan, cwd, install = insta
219
237
  mkdirSync(join(tmp, "entries"), { recursive: true });
220
238
  writeFileSync(entryFile, helper.entry(fixture, resolved.name));
221
239
  try {
222
- await bundleEntries({ entries: { [archetype]: entryFile }, outdir: buildDir, workDir, react: flavor === "react" });
240
+ await bundleWithPeers({ entries: { [archetype]: entryFile }, outdir: buildDir, workDir, react: flavor === "react" }, warnings);
223
241
  runnable[archetype] = `/${archetype}.html`;
224
242
  } catch (error) {
225
243
  archetypes[archetype] = { status: "gap", reason: `The fixture didn't bundle. ${firstLine(error)}`, configs: [] };
@@ -6,6 +6,7 @@ import { launchBrowser } from "../harness/browser.js";
6
6
  import { serveStatic } from "../harness/static-serve.js";
7
7
  import { listStories, readIndex, selectStories, storyUrl, waitForStory } from "../harness/storybook.js";
8
8
  import { closedShadowHosts, notTestableEntries } from "../harness/shadow.js";
9
+ import { COMPUTED_NOT_APPLICABLE_FOR_PAGES } from "../tiers/computed/index.js";
9
10
  import { NOT_APPLICABLE_FOR_PAGES } from "../tiers/interactions/index.js";
10
11
  import { failedVsr, runVsr } from "../tiers/vsr.js";
11
12
  import { openPage } from "../harness/url.js";
@@ -37,6 +38,8 @@ async function auditPage(browser, url, planTarget, plan, extraWarnings) {
37
38
  tiers.rules = await runRules(opened.page, { engines: plan.options.engines, wcag: plan.options.wcag, level: plan.options.level });
38
39
  } else if (tier === "interactions") {
39
40
  tiers.interactions = NOT_APPLICABLE_FOR_PAGES;
41
+ } else if (tier === "computed") {
42
+ tiers.computed = COMPUTED_NOT_APPLICABLE_FOR_PAGES;
40
43
  } else {
41
44
  tiers.vsr = await runVsr(opened.page, { scope: "body" }).catch(failedVsr);
42
45
  }
@@ -71,7 +74,9 @@ async function auditStory(browser, base, story, plan) {
71
74
  ? await runRules(opened.page, { engines: plan.options.engines, wcag: plan.options.wcag, level: plan.options.level, scope: "#storybook-root" })
72
75
  : tier === "interactions"
73
76
  ? NOT_APPLICABLE_FOR_PAGES
74
- : await runVsr(opened.page, { scope: "#storybook-root" }).catch(failedVsr);
77
+ : tier === "computed"
78
+ ? COMPUTED_NOT_APPLICABLE_FOR_PAGES
79
+ : await runVsr(opened.page, { scope: "#storybook-root" }).catch(failedVsr);
75
80
  }
76
81
  const hidden = notTestableEntries(await closedShadowHosts(opened.page));
77
82
  return { id: story.id, ok: true, archetype: { status: "ran", configs: [{ libA11y: "n/a", tiers }] }, hidden };
@@ -32,6 +32,11 @@ export function summarize(archetypes, engines, gaps = []) {
32
32
  summary.interactions = { pass: 0, fail: 0, notApplicable: 0, error: 0 };
33
33
  for (const check of checks) summary.interactions[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
34
34
  }
35
+ const measured = Object.values(archetypes).flatMap((a) => a.configs.flatMap((c) => c.tiers.computed?.checks ?? []));
36
+ if (measured.length) {
37
+ summary.computed = { pass: 0, fail: 0, undetermined: 0, notApplicable: 0, error: 0 };
38
+ for (const check of measured) summary.computed[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
39
+ }
35
40
  const walks = Object.entries(archetypes).flatMap(([name, a]) => a.configs.map((c) => ({ name, vsr: c.tiers.vsr })).filter((x) => x.vsr?.status === "ran"));
36
41
  if (walks.length) {
37
42
  summary.vsr = { walks: walks.length, flagged: walks.reduce((n, w) => n + w.vsr.flags.length, 0) };
package/src/schema.js CHANGED
@@ -2,7 +2,7 @@ import * as v from "valibot";
2
2
 
3
3
  export const WCAG_VERSIONS = ["2.0", "2.1", "2.2"];
4
4
  export const LEVELS = ["A", "AA", "AAA"];
5
- export const TIERS = ["rules", "interactions", "vsr"];
5
+ export const TIERS = ["rules", "interactions", "computed", "vsr"];
6
6
  export const ENGINES = ["axe", "ibm"];
7
7
  export const LIB_A11Y = ["on", "off"];
8
8
  export const IMPACTS = ["minor", "moderate", "serious", "critical"];
@@ -136,10 +136,12 @@ const TierResultSchema = v.object({
136
136
  v.object({
137
137
  name: v.string(),
138
138
  criteria: v.optional(v.array(v.string())),
139
- result: v.picklist(["pass", "fail", "not-applicable", "error"]),
139
+ result: v.picklist(["pass", "fail", "undetermined", "not-applicable", "error"]),
140
140
  detail: v.string(),
141
141
  /** How the focus indicator was detected: computed-style or screenshot. */
142
142
  method: v.optional(v.string()),
143
+ /** The numbers behind a computed check, for example contrast ratios by state. */
144
+ measurements: v.optional(v.array(v.record(v.string(), v.unknown()))),
143
145
  }),
144
146
  ),
145
147
  ),
@@ -218,6 +220,7 @@ export const TargetResultSchema = v.object({
218
220
  gaps: v.array(v.string()),
219
221
  notTestable: v.array(v.string()),
220
222
  interactions: v.optional(v.object({ pass: v.number(), fail: v.number(), notApplicable: v.number(), error: v.number() })),
223
+ computed: v.optional(v.object({ pass: v.number(), fail: v.number(), undetermined: v.number(), notApplicable: v.number(), error: v.number() })),
221
224
  vsr: v.optional(v.object({ walks: v.number(), flagged: v.number() })),
222
225
  }),
223
226
  warnings: v.array(v.string()),
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The computed checks. Each reads resolved styles (and, for one case, pixels) from a real browser and compares
3
+ * them with the numbers WCAG gives. A check returns pass, fail, undetermined, or not-applicable with a detail.
4
+ * `undetermined` means the page uses something a color can't be reduced to (a gradient, an image, transparency).
5
+ * It's a gap and never a pass.
6
+ * These are automatica11y's own measurements. They're reported on their own and never added to axe-core or IBM counts.
7
+ */
8
+ import { criterionRef } from "../../wcag/index.js";
9
+ import { contrastOver, contrastRatio, formatRatio, textThreshold } from "./color.js";
10
+
11
+ const pass = (detail, extra = {}) => ({ result: "pass", detail, ...extra });
12
+ const fail = (detail, extra = {}) => ({ result: "fail", detail, ...extra });
13
+ const na = (detail) => ({ result: "not-applicable", detail });
14
+ const undetermined = (detail, extra = {}) => ({ result: "undetermined", detail, ...extra });
15
+
16
+ /** Time for CSS transitions to finish before a state is measured. */
17
+ const SETTLE_MS = 400;
18
+
19
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
20
+ const floor2 = (n) => Math.floor(n * 100) / 100;
21
+
22
+ /** Move the pointer away and let any hover styles end. */
23
+ async function parkPointer(ctx) {
24
+ await ctx.page.mouse.move(0, 0);
25
+ await ctx.settle(SETTLE_MS);
26
+ }
27
+
28
+ /** Text contrast in each state a person can put the control in. */
29
+ const TEXT_CONTRAST = {
30
+ name: "text-contrast-by-state",
31
+ criteria: ["1.4.3"],
32
+ async run(ctx) {
33
+ /** @type {Array<{ state: string, parts: any[] }>} */
34
+ const states = [];
35
+ const measure = async (state) => states.push({ state, parts: (await ctx.page.evaluate(() => window.__a11yMeasure.text())) ?? [] });
36
+ await parkPointer(ctx);
37
+ await measure("rest");
38
+ if (states[0].parts.length === 0) return na("The trigger has no visible text, so its text contrast wasn't measured.");
39
+ await ctx.trigger.hover();
40
+ await ctx.settle(SETTLE_MS);
41
+ await measure("hover");
42
+ await parkPointer(ctx);
43
+ const reached = await ctx.tabToTrigger();
44
+ await ctx.settle(SETTLE_MS);
45
+ if (reached !== null) await measure("keyboard focus");
46
+ await ctx.page.evaluate(() => /** @type {HTMLElement} */ (document.activeElement)?.blur?.());
47
+ await ctx.trigger.hover();
48
+ await ctx.page.mouse.down();
49
+ await ctx.settle(SETTLE_MS);
50
+ await measure("pressed");
51
+ await ctx.page.mouse.up().catch(() => {});
52
+
53
+ const rows = [];
54
+ const unknown = [];
55
+ for (const { state, parts } of states) {
56
+ for (const part of parts) {
57
+ if (part.undetermined || !part.backdrop) {
58
+ unknown.push(`In ${state}, "${part.text}" can't be measured because ${part.undetermined ?? "its background couldn't be read"}.`);
59
+ continue;
60
+ }
61
+ rows.push({ state, text: part.text, ratio: contrastOver(part.color, part.backdrop), required: textThreshold(part.size, part.weight) });
62
+ }
63
+ }
64
+ const measurements = rows.map((r) => ({ state: r.state, text: r.text, ratio: floor2(r.ratio), required: r.required }));
65
+ const failed = rows.filter((r) => r.ratio < r.required).sort((a, b) => a.ratio / a.required - b.ratio / b.required);
66
+ const list = `Measured in ${states.map((s) => s.state).join(", ")}.`;
67
+ const skipped = reached === null ? " The trigger couldn't be reached with Tab, so keyboard focus wasn't measured." : "";
68
+ if (failed.length) {
69
+ const f = failed[0];
70
+ const more = failed.length > 1 ? ` ${failed.length - 1} more measurement${failed.length > 2 ? "s are" : " is"} below its threshold.` : "";
71
+ return fail(`In ${f.state}, "${f.text}" has ${formatRatio(f.ratio)} against its background and needs ${f.required}:1.${more} ${list}${skipped}`, { measurements });
72
+ }
73
+ if (unknown.length || reached === null) {
74
+ const low = rows.length ? `The lowest measured is ${formatRatio(Math.min(...rows.map((r) => r.ratio)))}. ` : "";
75
+ return undetermined(`${low}${unknown[0] ?? ""} ${list}${skipped}`.replace(/\s+/g, " ").trim(), { measurements });
76
+ }
77
+ const lowest = rows.reduce((a, b) => (b.ratio / b.required < a.ratio / a.required ? b : a));
78
+ return pass(`The lowest is ${formatRatio(lowest.ratio)} in ${lowest.state} (needs ${lowest.required}:1). ${list}`, { measurements });
79
+ },
80
+ };
81
+
82
+ /** The edge, fill, or icon that tells a person where a control is. */
83
+ const BOUNDARY_CONTRAST = {
84
+ name: "boundary-contrast",
85
+ criteria: ["1.4.11"],
86
+ async run(ctx) {
87
+ await parkPointer(ctx);
88
+ const b = await ctx.page.evaluate(() => window.__a11yMeasure.boundary());
89
+ if (!b) return na("There's no trigger to measure.");
90
+ if (b.undetermined || !b.outside) return undetermined(`The control's edge can't be measured because ${b.undetermined ?? "its surroundings couldn't be read"}.`);
91
+ const fill = b.parts.find((p) => p.kind === "fill")?.color ?? b.outside;
92
+ const marks = [
93
+ ...b.parts.map((p) => ({ label: p.kind, ratio: contrastRatio(p.color, b.outside) })),
94
+ ...b.graphics.map((g) => ({ label: `icon ${g.kind}`, ratio: contrastRatio(g.color, fill) })),
95
+ ];
96
+ if (marks.length === 0) return undetermined("No border, fill, or icon was found to measure.");
97
+ const best = marks.reduce((a, c) => (c.ratio > a.ratio ? c : a));
98
+ const measurements = marks.map((m) => ({ part: m.label, ratio: floor2(m.ratio) }));
99
+ const text = `The strongest mark is its ${best.label}, at ${formatRatio(best.ratio)} (needs 3:1).`;
100
+ if (b.inputLike) return best.ratio >= 3 ? pass(`${text} A field's edge identifies it.`, { measurements }) : fail(`${text} A field has no label inside it, so its edge is what identifies it.`, { measurements });
101
+ if (b.hasText) {
102
+ return best.ratio >= 3
103
+ ? pass(text, { measurements })
104
+ : { ...na(`${text} The control has visible text that identifies it, so ${criterionRef("1.4.11")} doesn't require its edge to reach 3:1. A person should confirm the text is enough.`), measurements };
105
+ }
106
+ return best.ratio >= 3 ? pass(`${text} The control has no text, so its icon or edge identifies it.`, { measurements }) : fail(`${text} The control has no text, so its icon or edge has to identify it.`, { measurements });
107
+ },
108
+ };
109
+
110
+ /** Does the focus indicator stand out from what's around it? */
111
+ const FOCUS_CONTRAST = {
112
+ name: "focus-indicator-contrast",
113
+ criteria: ["1.4.11", "2.4.13"],
114
+ async run(ctx) {
115
+ await parkPointer(ctx);
116
+ const rest = await ctx.page.evaluate(() => window.__a11yMeasure.focusStyles(true));
117
+ if (!rest) return na("There's no trigger to measure.");
118
+ const clip = ctx.clipAround(rest.box, 12);
119
+ const shotRest = await ctx.page.screenshot({ clip });
120
+ if ((await ctx.tabToTrigger()) === null) return na("The trigger can't be reached with Tab, so its focus indicator wasn't measured.");
121
+ await ctx.settle(SETTLE_MS);
122
+ const now = await ctx.page.evaluate(() => window.__a11yMeasure.focusStyles(false));
123
+
124
+ // First choice: an outline, ring, or border on the control itself. Its colors and thickness are exact.
125
+ if (now && !now.undetermined && now.outside && now.inside) {
126
+ const found = [];
127
+ if (now.outline && !same(now.outline, rest.outline)) {
128
+ const against = now.outline.offset >= 0 ? now.outside : now.inside;
129
+ found.push({ label: "outline", ratio: contrastOver(now.outline.color, against), width: now.outline.width });
130
+ }
131
+ for (const shadow of now.shadows) {
132
+ if (shadow.inset || shadow.blur > 0 || !shadow.color || shadow.color[3] === 0 || (shadow.spread <= 0 && shadow.x === 0 && shadow.y === 0)) continue;
133
+ if (rest.shadows.some((r) => same(r, shadow))) continue;
134
+ found.push({ label: "box-shadow ring", ratio: contrastOver(shadow.color, now.outside), width: Math.max(shadow.spread, Math.abs(shadow.x), Math.abs(shadow.y)) });
135
+ }
136
+ if (now.border && !same(now.border, rest.border)) found.push({ label: "border", ratio: contrastOver(now.border.color, now.outside), width: now.border.width });
137
+ if (found.length) {
138
+ const best = found.reduce((a, c) => (c.ratio > a.ratio ? c : a));
139
+ const thin = best.width < 2 ? ` It's ${best.width}px thick, and ${criterionRef("2.4.13")} asks for at least 2px.` : "";
140
+ const detail = `The ${best.label} has ${formatRatio(best.ratio)} against what's next to it (needs 3:1).${thin}`;
141
+ const measurements = found.map((f) => ({ indicator: f.label, ratio: floor2(f.ratio), widthPx: f.width }));
142
+ return best.ratio >= 3 ? pass(detail, { method: "computed-style", measurements }) : fail(detail, { method: "computed-style", measurements });
143
+ }
144
+ }
145
+
146
+ // Otherwise the indicator is something styles can't summarize (a ripple, a background change, a blurred glow), so compare pixels.
147
+ const shotNow = await ctx.page.screenshot({ clip });
148
+ const url = (buffer) => `data:image/png;base64,${buffer.toString("base64")}`;
149
+ const px = await ctx.page.evaluate(([a, b]) => window.__a11yMeasure.compareShots(a, b), [url(shotRest), url(shotNow)]);
150
+ if (px.changed === 0) return na("Nothing visible changed on focus, so there's no indicator to measure. The interactions tier reports that as a failure.");
151
+ const perimeter = 2 * (rest.box.width + rest.box.height);
152
+ const measurements = [{ changedPixels: px.changed, pixelsAtLeast3to1: px.strong, strongestChange: floor2(px.max), perimeterPixels: Math.round(perimeter) }];
153
+ const detail = `${px.strong} of ${px.changed} changed pixels reach 3:1 against their unfocused color, and the strongest change is ${formatRatio(px.max)}. A ring around this control needs about ${Math.round(perimeter)}.`;
154
+ return px.strong >= perimeter ? pass(detail, { method: "pixels", measurements }) : fail(detail, { method: "pixels", measurements });
155
+ },
156
+ };
157
+
158
+ export const COMPUTED_CHECKS = [TEXT_CONTRAST, BOUNDARY_CONTRAST, FOCUS_CONTRAST];