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.
- package/AGENTS.md +1 -1
- package/README.md +13 -4
- package/package.json +2 -1
- package/skills/automatica11y/SKILL.md +5 -3
- package/skills/automatica11y-runner/SKILL.md +15 -10
- package/src/commands/common.js +1 -1
- package/src/data/README.md +19 -0
- package/src/data/wcag-2.2.json +7557 -0
- package/src/data/wcag-2.2.source.json +6 -0
- package/src/globals.d.ts +1 -0
- package/src/harness/bundle.js +16 -2
- package/src/harness/npm-install.js +69 -5
- package/src/plan/mapping.js +2 -1
- package/src/plan/resolve-npm.js +2 -1
- package/src/report/comparison.js +14 -4
- package/src/report/parts.js +31 -3
- package/src/run/audit-npm.js +24 -6
- package/src/run/run-plan.js +6 -1
- package/src/run/summary.js +5 -0
- package/src/schema.js +5 -2
- package/src/tiers/computed/checks.js +158 -0
- package/src/tiers/computed/color.js +40 -0
- package/src/tiers/computed/index.js +25 -0
- package/src/tiers/computed/measure-kit.js +205 -0
- package/src/tiers/interactions/archetypes.js +6 -4
- package/src/tiers/interactions/focus-indicator.js +39 -0
- package/src/tiers/interactions/helpers.js +31 -16
- package/src/tiers/interactions/index.js +3 -3
- package/src/tiers/rules/axe.js +4 -3
- package/src/wcag/index.js +83 -0
package/src/globals.d.ts
CHANGED
package/src/harness/bundle.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
}
|
package/src/plan/mapping.js
CHANGED
|
@@ -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
|
-
|
|
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] = {
|
package/src/plan/resolve-npm.js
CHANGED
|
@@ -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
|
-
|
|
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)) {
|
package/src/report/comparison.js
CHANGED
|
@@ -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;
|
package/src/report/parts.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/run/audit-npm.js
CHANGED
|
@@ -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
|
|
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
|
|
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: [] };
|
package/src/run/run-plan.js
CHANGED
|
@@ -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
|
-
:
|
|
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 };
|
package/src/run/summary.js
CHANGED
|
@@ -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];
|