automatica11y 0.3.3 → 0.4.1
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 +2 -0
- package/README.md +39 -11
- package/package.json +5 -3
- package/skills/automatica11y/SKILL.md +3 -1
- package/skills/automatica11y-runner/SKILL.md +47 -14
- package/skills/automatica11y-runner/references/fixtures.md +38 -2
- package/src/commands/common.js +5 -2
- package/src/frameworks/index.js +42 -0
- package/src/frameworks/react.js +77 -0
- package/src/frameworks/vue.js +90 -0
- package/src/frameworks/wc.js +55 -0
- package/src/globals.d.ts +1 -0
- package/src/harness/bundle.js +7 -6
- package/src/harness/generate/dialects.js +64 -0
- package/src/harness/generate/index.js +12 -0
- package/src/harness/generate/jsx-recipes.js +224 -0
- package/src/harness/generate/jsx.js +76 -0
- package/src/harness/generate/kit.js +50 -0
- package/src/harness/generate/marking.js +64 -0
- package/src/harness/generate/probe.js +97 -0
- package/src/harness/generate/shared.js +12 -0
- package/src/harness/generate/wc-recipes.js +132 -0
- package/src/harness/npm-install.js +38 -7
- package/src/harness/settle.js +17 -0
- package/src/harness/storybook.js +1 -0
- package/src/harness/url.js +13 -3
- package/src/plan/classify.js +11 -4
- package/src/plan/mapping.js +10 -7
- package/src/plan/resolve-npm.js +15 -9
- package/src/plan/subpath.js +133 -0
- package/src/report/comparison.js +14 -9
- package/src/report/parts.js +36 -12
- package/src/run/audit-npm.js +118 -28
- package/src/run/generate-fixture.js +74 -0
- package/src/run/run-plan.js +19 -4
- package/src/run/summary.js +5 -0
- package/src/schema.js +30 -6
- package/src/tiers/computed/checks.js +44 -5
- package/src/tiers/computed/color.js +8 -0
- package/src/tiers/computed/index.js +2 -2
- package/src/tiers/computed/measure-kit.js +30 -10
- package/src/tiers/conditions/checks.js +283 -0
- package/src/tiers/conditions/index.js +30 -0
- package/src/tiers/conditions/kit.js +133 -0
- package/src/tiers/interactions/archetypes.js +111 -0
- package/src/tiers/interactions/helpers.js +56 -0
- package/src/tiers/interactions/index.js +16 -3
- package/src/harness/npm-react.js +0 -39
- package/src/harness/npm-wc.js +0 -30
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { probeFixture } from "../harness/generate/probe.js";
|
|
4
|
+
|
|
5
|
+
const firstLine = (error) => (error instanceof Error ? error.message : String(error)).split("\n").find((l) => l.trim()) ?? "unknown error";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build a fixture for an archetype from what discovery found, and keep it only if it works.
|
|
9
|
+
* Candidates are bundled (all at once, or one by one if that fails), then probed in the browser in order. The first that
|
|
10
|
+
* passes wins. Every attempt is recorded, whether or not one wins, so a report can say what was tried.
|
|
11
|
+
*
|
|
12
|
+
* @param {{
|
|
13
|
+
* browser: import("playwright-core").Browser,
|
|
14
|
+
* adapter: import("../frameworks/index.js").Adapter,
|
|
15
|
+
* archetype: string,
|
|
16
|
+
* entry: { export?: string, tag?: string },
|
|
17
|
+
* found: { exports: any[], tags: string[], facts: Record<string, any> },
|
|
18
|
+
* pkg: string,
|
|
19
|
+
* explicit?: boolean,
|
|
20
|
+
* tmp: string,
|
|
21
|
+
* workDir: string,
|
|
22
|
+
* buildDir: string,
|
|
23
|
+
* getServer: () => Promise<{ origin: string }>,
|
|
24
|
+
* bundle: (options: any) => Promise<unknown>,
|
|
25
|
+
* }} input
|
|
26
|
+
* @returns {Promise<{ ok: boolean, reason: string | null, attempts: Array<{ recipe: string, summary: string, ok: boolean, reason: string | null }>, winner?: { recipe: string, summary: string, used: string[], source: string, file: string, extension: string } }>}
|
|
27
|
+
*/
|
|
28
|
+
export async function generateFixture({ browser, adapter, archetype, entry, found, explicit = false, pkg, tmp, workDir, buildDir, getServer, bundle }) {
|
|
29
|
+
const { candidates, reason } = adapter.generate({ archetype, pkg, entry, exports: found.exports, facts: found.facts, explicit });
|
|
30
|
+
if (candidates.length === 0) return { ok: false, reason, attempts: [] };
|
|
31
|
+
|
|
32
|
+
const extension = adapter.extension;
|
|
33
|
+
mkdirSync(join(tmp, "generated"), { recursive: true });
|
|
34
|
+
mkdirSync(join(tmp, "entries"), { recursive: true });
|
|
35
|
+
const prepared = candidates.map((candidate, index) => {
|
|
36
|
+
const name = `gen-${archetype}-${index}`;
|
|
37
|
+
const file = join(tmp, "generated", `${archetype}-${index}.${extension}`);
|
|
38
|
+
writeFileSync(file, candidate.source);
|
|
39
|
+
const entryFile = join(tmp, "entries", `${name}.js`);
|
|
40
|
+
writeFileSync(entryFile, adapter.entry(file, pkg));
|
|
41
|
+
return { candidate, name, file, entryFile, bundled: null };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// One build for every candidate. If a single bad one breaks it, build them one by one so the rest still get a chance.
|
|
45
|
+
try {
|
|
46
|
+
await bundle({ entries: Object.fromEntries(prepared.map((p) => [p.name, p.entryFile])), outdir: buildDir, workDir, framework: adapter });
|
|
47
|
+
for (const p of prepared) p.bundled = true;
|
|
48
|
+
} catch {
|
|
49
|
+
for (const p of prepared) {
|
|
50
|
+
try {
|
|
51
|
+
await bundle({ entries: { [p.name]: p.entryFile }, outdir: buildDir, workDir, framework: adapter });
|
|
52
|
+
p.bundled = true;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
p.bundled = firstLine(error);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const server = await getServer();
|
|
60
|
+
const attempts = [];
|
|
61
|
+
for (const p of prepared) {
|
|
62
|
+
const base = { recipe: p.candidate.id, summary: p.candidate.summary };
|
|
63
|
+
if (p.bundled !== true) {
|
|
64
|
+
attempts.push({ ...base, ok: false, reason: `it didn't bundle: ${p.bundled}` });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const probe = await probeFixture(browser, `${server.origin}/${p.name}.html`, archetype);
|
|
68
|
+
attempts.push({ ...base, ok: probe.ok, reason: probe.reason });
|
|
69
|
+
if (probe.ok) {
|
|
70
|
+
return { ok: true, reason: null, attempts, winner: { ...base, used: p.candidate.used, source: p.candidate.source, file: p.file, extension } };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { ok: false, reason: `${attempts.length === 1 ? "The one generated fixture didn't work" : `None of the ${attempts.length} generated fixtures worked`}. ${attempts.slice(0, 2).map((a) => `${a.summary}: ${a.reason}`).join("; ")}${attempts.length > 2 ? `; and ${attempts.length - 2} more` : ""}.`, attempts };
|
|
74
|
+
}
|
package/src/run/run-plan.js
CHANGED
|
@@ -7,6 +7,7 @@ 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
9
|
import { COMPUTED_NOT_APPLICABLE_FOR_PAGES } from "../tiers/computed/index.js";
|
|
10
|
+
import { CONDITIONS_NOT_APPLICABLE_FOR_STORIES, runConditions } from "../tiers/conditions/index.js";
|
|
10
11
|
import { NOT_APPLICABLE_FOR_PAGES } from "../tiers/interactions/index.js";
|
|
11
12
|
import { failedVsr, runVsr } from "../tiers/vsr.js";
|
|
12
13
|
import { openPage } from "../harness/url.js";
|
|
@@ -17,6 +18,7 @@ import { runRules, selfTest } from "../tiers/rules/index.js";
|
|
|
17
18
|
import { evaluateFailCheck } from "./fail-check.js";
|
|
18
19
|
import { mapPool } from "./pool.js";
|
|
19
20
|
import { auditNpm } from "./audit-npm.js";
|
|
21
|
+
import { adapterFor } from "../frameworks/index.js";
|
|
20
22
|
import { failedTarget, summarize } from "./summary.js";
|
|
21
23
|
|
|
22
24
|
/** How many stories to audit at once. */
|
|
@@ -24,7 +26,7 @@ const STORY_CONCURRENCY = 4;
|
|
|
24
26
|
|
|
25
27
|
const EXIT = { OK: 0, FAIL_THRESHOLD: 1, ENVIRONMENT: 3, ALL_TARGETS_FAILED: 4 };
|
|
26
28
|
|
|
27
|
-
const NPM_KINDS = new Set(["npm", "npm-react", "npm-wc", "npm-unsupported"]);
|
|
29
|
+
const NPM_KINDS = new Set(["npm", "npm-react", "npm-vue", "npm-wc", "npm-unsupported"]);
|
|
28
30
|
const UNSUPPORTED_KIND = (kind) => `${kind} targets aren't supported.`;
|
|
29
31
|
|
|
30
32
|
/** Audit one page and return its target result. */
|
|
@@ -40,6 +42,9 @@ async function auditPage(browser, url, planTarget, plan, extraWarnings) {
|
|
|
40
42
|
tiers.interactions = NOT_APPLICABLE_FOR_PAGES;
|
|
41
43
|
} else if (tier === "computed") {
|
|
42
44
|
tiers.computed = COMPUTED_NOT_APPLICABLE_FOR_PAGES;
|
|
45
|
+
} else if (tier === "conditions") {
|
|
46
|
+
// Each check opens its own copies of the page, so this runs while the first page is still open.
|
|
47
|
+
tiers.conditions = await runConditions(browser, url, "page");
|
|
43
48
|
} else {
|
|
44
49
|
tiers.vsr = await runVsr(opened.page, { scope: "body" }).catch(failedVsr);
|
|
45
50
|
}
|
|
@@ -76,7 +81,9 @@ async function auditStory(browser, base, story, plan) {
|
|
|
76
81
|
? NOT_APPLICABLE_FOR_PAGES
|
|
77
82
|
: tier === "computed"
|
|
78
83
|
? COMPUTED_NOT_APPLICABLE_FOR_PAGES
|
|
79
|
-
:
|
|
84
|
+
: tier === "conditions"
|
|
85
|
+
? CONDITIONS_NOT_APPLICABLE_FOR_STORIES
|
|
86
|
+
: await runVsr(opened.page, { scope: "#storybook-root" }).catch(failedVsr);
|
|
80
87
|
}
|
|
81
88
|
const hidden = notTestableEntries(await closedShadowHosts(opened.page));
|
|
82
89
|
return { id: story.id, ok: true, archetype: { status: "ran", configs: [{ libA11y: "n/a", tiers }] }, hidden };
|
|
@@ -222,14 +229,17 @@ export async function runPlan(plan, io) {
|
|
|
222
229
|
}
|
|
223
230
|
const targets = [];
|
|
224
231
|
const mappings = {};
|
|
232
|
+
/** Fixtures the tool generated, by path under the output folder. */
|
|
233
|
+
const generatedFiles = {};
|
|
225
234
|
for (const planTarget of plan.targets) {
|
|
226
|
-
const { result, mapping } = await runTarget(browser, planTarget, plan, io);
|
|
235
|
+
const { result, mapping, files } = await runTarget(browser, planTarget, plan, io);
|
|
227
236
|
targets.push(result);
|
|
237
|
+
if (files) Object.assign(generatedFiles, files);
|
|
228
238
|
if (mapping) {
|
|
229
239
|
mappings[planTarget.id] = mapping;
|
|
230
240
|
planTarget.mapping = mapping;
|
|
231
241
|
}
|
|
232
|
-
if (result.npm) planTarget.kind = result.npm.flavor
|
|
242
|
+
if (result.npm) planTarget.kind = adapterFor(result.npm.flavor).kind;
|
|
233
243
|
}
|
|
234
244
|
|
|
235
245
|
const results = parseResults({
|
|
@@ -245,6 +255,11 @@ export async function runPlan(plan, io) {
|
|
|
245
255
|
mkdirSync(outDir, { recursive: true });
|
|
246
256
|
writeFileSync(resolve(outDir, "results.json"), `${JSON.stringify(results, null, 2)}\n`);
|
|
247
257
|
writeFileSync(resolve(outDir, "report.md"), renderReport({ plan, results }));
|
|
258
|
+
for (const [relative, text] of Object.entries(generatedFiles)) {
|
|
259
|
+
const file = resolve(outDir, relative);
|
|
260
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
261
|
+
writeFileSync(file, text);
|
|
262
|
+
}
|
|
248
263
|
if (Object.keys(mappings).length > 0) {
|
|
249
264
|
// The candidate mapping, in the shape --mapping reads, so it can be edited and passed back in.
|
|
250
265
|
writeFileSync(resolve(outDir, "mapping.json"), `${JSON.stringify(mappings, null, 2)}\n`);
|
package/src/run/summary.js
CHANGED
|
@@ -37,6 +37,11 @@ export function summarize(archetypes, engines, gaps = []) {
|
|
|
37
37
|
summary.computed = { pass: 0, fail: 0, undetermined: 0, notApplicable: 0, error: 0 };
|
|
38
38
|
for (const check of measured) summary.computed[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
|
|
39
39
|
}
|
|
40
|
+
const adapted = Object.values(archetypes).flatMap((a) => a.configs.flatMap((c) => c.tiers.conditions?.checks ?? []));
|
|
41
|
+
if (adapted.length) {
|
|
42
|
+
summary.conditions = { pass: 0, fail: 0, undetermined: 0, notApplicable: 0, error: 0 };
|
|
43
|
+
for (const check of adapted) summary.conditions[check.result === "not-applicable" ? "notApplicable" : check.result] += 1;
|
|
44
|
+
}
|
|
40
45
|
const walks = Object.entries(archetypes).flatMap(([name, a]) => a.configs.map((c) => ({ name, vsr: c.tiers.vsr })).filter((x) => x.vsr?.status === "ran"));
|
|
41
46
|
if (walks.length) {
|
|
42
47
|
summary.vsr = { walks: walks.length, flagged: walks.reduce((n, w) => n + w.vsr.flags.length, 0) };
|
package/src/schema.js
CHANGED
|
@@ -2,20 +2,20 @@ 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", "computed", "vsr"];
|
|
5
|
+
export const TIERS = ["rules", "interactions", "computed", "conditions", "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"];
|
|
9
9
|
export const TOOLKIT_LEVELS = [1, 2, 3];
|
|
10
10
|
export const FAIL_MODES = ["any", "all"];
|
|
11
|
-
export const ARCHETYPES = ["button", "link", "dialog", "menu", "tabs", "combobox", "form-field", "accordion", "tooltip", "chart"];
|
|
12
|
-
export const FLAVORS = ["react", "wc"];
|
|
13
|
-
export const MAPPING_STATUSES = ["template", "authored", "needs-fixture", "no-match"];
|
|
11
|
+
export const ARCHETYPES = ["button", "link", "dialog", "menu", "tabs", "combobox", "form-field", "accordion", "tooltip", "live-region", "chart"];
|
|
12
|
+
export const FLAVORS = ["react", "vue", "wc"];
|
|
13
|
+
export const MAPPING_STATUSES = ["template", "authored", "generated", "needs-fixture", "no-match"];
|
|
14
14
|
|
|
15
15
|
/** What a candidate mapping says about one archetype of one npm target. */
|
|
16
16
|
export const MappingEntrySchema = v.object({
|
|
17
17
|
flavor: v.optional(v.picklist(FLAVORS)),
|
|
18
|
-
/** Export name (React) the fixture or template uses. */
|
|
18
|
+
/** Export name (React or Vue) the fixture or template uses. */
|
|
19
19
|
export: v.optional(v.string()),
|
|
20
20
|
/** Custom element tag (web components) the fixture or template uses. */
|
|
21
21
|
tag: v.optional(v.string()),
|
|
@@ -23,7 +23,14 @@ export const MappingEntrySchema = v.object({
|
|
|
23
23
|
fixture: v.optional(v.nullable(v.string())),
|
|
24
24
|
/** The library ships opt-in accessibility features. The fixture gets `libA11y` (true or false) and `--lib-a11y` runs it both ways. */
|
|
25
25
|
libA11y: v.optional(v.boolean()),
|
|
26
|
+
/** Other packages to install beside the target, such as the token stylesheet or theme the library asks for. A fixture can then import them. */
|
|
27
|
+
install: v.optional(v.array(v.string())),
|
|
26
28
|
status: v.optional(v.picklist(MAPPING_STATUSES)),
|
|
29
|
+
/** For a generated fixture: which recipe worked, what it was, which parts it used, and where its source was written. */
|
|
30
|
+
recipe: v.optional(v.string()),
|
|
31
|
+
summary: v.optional(v.string()),
|
|
32
|
+
used: v.optional(v.array(v.string())),
|
|
33
|
+
generatedFile: v.optional(v.string()),
|
|
27
34
|
candidates: v.optional(v.array(v.string())),
|
|
28
35
|
parts: v.optional(v.array(v.string())),
|
|
29
36
|
reason: v.optional(v.string()),
|
|
@@ -42,7 +49,7 @@ export function parseMappingFile(input) {
|
|
|
42
49
|
return result.output;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
export const TARGET_KINDS = ["npm", "npm-react", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
|
|
52
|
+
export const TARGET_KINDS = ["npm", "npm-react", "npm-vue", "npm-wc", "npm-unsupported", "npm-non-ui", "storybook", "url", "html-file", "static-dir"];
|
|
46
53
|
|
|
47
54
|
const nullableString = v.nullable(v.string());
|
|
48
55
|
|
|
@@ -77,6 +84,8 @@ export const PlanSchema = v.object({
|
|
|
77
84
|
archetypes: v.nullable(v.array(v.picklist(ARCHETYPES))),
|
|
78
85
|
mapping: nullableString,
|
|
79
86
|
maxStories: v.pipe(v.number(), v.integer(), v.minValue(1)),
|
|
87
|
+
/** Build fixtures from a package's parts when none is authored. A plan saved before this existed generates. */
|
|
88
|
+
generate: v.optional(v.boolean(), true),
|
|
80
89
|
out: v.string(),
|
|
81
90
|
fail: v.nullable(FailConfigSchema),
|
|
82
91
|
}),
|
|
@@ -200,6 +209,17 @@ export const TargetResultSchema = v.object({
|
|
|
200
209
|
v.object({
|
|
201
210
|
status: v.picklist(["ran", "gap"]),
|
|
202
211
|
reason: v.optional(v.nullable(v.string())),
|
|
212
|
+
/** Where the fixture came from, and for a generated one what was tried. */
|
|
213
|
+
fixture: v.optional(
|
|
214
|
+
v.object({
|
|
215
|
+
source: v.picklist(["template", "authored", "generated", "none"]),
|
|
216
|
+
recipe: v.optional(v.string()),
|
|
217
|
+
summary: v.optional(v.string()),
|
|
218
|
+
used: v.optional(v.array(v.string())),
|
|
219
|
+
file: v.optional(v.nullable(v.string())),
|
|
220
|
+
attempts: v.optional(v.array(v.object({ recipe: v.string(), summary: v.string(), ok: v.boolean(), reason: v.nullable(v.string()) }))),
|
|
221
|
+
}),
|
|
222
|
+
),
|
|
203
223
|
configs: v.array(v.object({ libA11y: v.picklist(["on", "off", "n/a"]), state: v.optional(v.string()), tiers: v.record(v.string(), TierResultSchema) })),
|
|
204
224
|
}),
|
|
205
225
|
),
|
|
@@ -207,11 +227,14 @@ export const TargetResultSchema = v.object({
|
|
|
207
227
|
npm: v.optional(
|
|
208
228
|
v.object({
|
|
209
229
|
name: v.string(),
|
|
230
|
+
/** The sub-path of the package that was tested, such as `button/v2`, or null for the package itself. */
|
|
231
|
+
subpath: v.optional(nullableString),
|
|
210
232
|
version: nullableString,
|
|
211
233
|
flavor: v.picklist(FLAVORS),
|
|
212
234
|
framework: nullableString,
|
|
213
235
|
react: nullableString,
|
|
214
236
|
reactDom: nullableString,
|
|
237
|
+
vue: v.optional(nullableString),
|
|
215
238
|
tags: v.array(v.string()),
|
|
216
239
|
}),
|
|
217
240
|
),
|
|
@@ -221,6 +244,7 @@ export const TargetResultSchema = v.object({
|
|
|
221
244
|
notTestable: v.array(v.string()),
|
|
222
245
|
interactions: v.optional(v.object({ pass: v.number(), fail: v.number(), notApplicable: v.number(), error: v.number() })),
|
|
223
246
|
computed: v.optional(v.object({ pass: v.number(), fail: v.number(), undetermined: v.number(), notApplicable: v.number(), error: v.number() })),
|
|
247
|
+
conditions: v.optional(v.object({ pass: v.number(), fail: v.number(), undetermined: v.number(), notApplicable: v.number(), error: v.number() })),
|
|
224
248
|
vsr: v.optional(v.object({ walks: v.number(), flagged: v.number() })),
|
|
225
249
|
}),
|
|
226
250
|
warnings: v.array(v.string()),
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* It's a gap and never a pass.
|
|
6
6
|
* These are automatica11y's own measurements. They're reported on their own and never added to axe-core or IBM counts.
|
|
7
7
|
*/
|
|
8
|
+
import { num } from "../../text.js";
|
|
8
9
|
import { criterionRef } from "../../wcag/index.js";
|
|
9
|
-
import { contrastOver, contrastRatio, formatRatio, textThreshold } from "./color.js";
|
|
10
|
+
import { contrastOver, contrastRatio, formatRatio, ringIsEnough, textThreshold } from "./color.js";
|
|
10
11
|
|
|
11
12
|
const pass = (detail, extra = {}) => ({ result: "pass", detail, ...extra });
|
|
12
13
|
const fail = (detail, extra = {}) => ({ result: "fail", detail, ...extra });
|
|
@@ -67,7 +68,7 @@ const TEXT_CONTRAST = {
|
|
|
67
68
|
const skipped = reached === null ? " The trigger couldn't be reached with Tab, so keyboard focus wasn't measured." : "";
|
|
68
69
|
if (failed.length) {
|
|
69
70
|
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
|
+
const more = failed.length > 1 ? ` ${num(failed.length - 1)} more measurement${failed.length > 2 ? "s are" : " is"} below its threshold.` : "";
|
|
71
72
|
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
|
}
|
|
73
74
|
if (unknown.length || reached === null) {
|
|
@@ -150,9 +151,47 @@ const FOCUS_CONTRAST = {
|
|
|
150
151
|
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
152
|
const perimeter = 2 * (rest.box.width + rest.box.height);
|
|
152
153
|
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)}.
|
|
154
|
-
return px.strong
|
|
154
|
+
const detail = `${num(px.strong)} of ${num(px.changed)} changed pixels reach 3:1 against their unfocused color, and the strongest change is ${formatRatio(px.max)}. Enough to count is about half the control's perimeter, ${Math.round(perimeter / 2)}.`;
|
|
155
|
+
return ringIsEnough(px.strong, perimeter) ? pass(detail, { method: "pixels", measurements }) : fail(detail, { method: "pixels", measurements });
|
|
155
156
|
},
|
|
156
157
|
};
|
|
157
158
|
|
|
158
|
-
|
|
159
|
+
/**
|
|
160
|
+
* For a live region, the trigger is only a button that makes the message appear, so the message is what gets measured:
|
|
161
|
+
* the text that appears after the trigger is activated, in its resting state.
|
|
162
|
+
*/
|
|
163
|
+
const MESSAGE_TEXT_CONTRAST = {
|
|
164
|
+
name: "message-text-contrast",
|
|
165
|
+
criteria: ["1.4.3"],
|
|
166
|
+
async run(ctx) {
|
|
167
|
+
await parkPointer(ctx);
|
|
168
|
+
const before = (await ctx.page.evaluate(() => window.__a11yMeasure.text("page"))) ?? [];
|
|
169
|
+
await ctx.focus();
|
|
170
|
+
await ctx.press("Enter");
|
|
171
|
+
await ctx.settle(SETTLE_MS);
|
|
172
|
+
const after = (await ctx.page.evaluate(() => window.__a11yMeasure.text("page"))) ?? [];
|
|
173
|
+
const known = new Set(before.map((p) => p.key));
|
|
174
|
+
const fresh = after.filter((p) => !known.has(p.key));
|
|
175
|
+
if (fresh.length === 0) return na("No new visible text appeared when the trigger was activated, so there's no message text to measure.");
|
|
176
|
+
const rows = [];
|
|
177
|
+
const unknown = [];
|
|
178
|
+
for (const part of fresh) {
|
|
179
|
+
if (part.undetermined || !part.backdrop) unknown.push(`"${part.text}" can't be measured because ${part.undetermined ?? "its background couldn't be read"}.`);
|
|
180
|
+
else rows.push({ text: part.text, ratio: contrastOver(part.color, part.backdrop), required: textThreshold(part.size, part.weight) });
|
|
181
|
+
}
|
|
182
|
+
const measurements = rows.map((r) => ({ text: r.text, ratio: floor2(r.ratio), required: r.required }));
|
|
183
|
+
const failed = rows.filter((r) => r.ratio < r.required).sort((a, b) => a.ratio / a.required - b.ratio / b.required);
|
|
184
|
+
if (failed.length) return fail(`The message text "${failed[0].text}" has ${formatRatio(failed[0].ratio)} against its background and needs ${failed[0].required}:1.`, { measurements });
|
|
185
|
+
if (unknown.length) return undetermined(`${rows.length ? `The lowest measured is ${formatRatio(Math.min(...rows.map((r) => r.ratio)))}. ` : ""}${unknown[0]}`, { measurements });
|
|
186
|
+
const lowest = rows.reduce((a, b) => (b.ratio / b.required < a.ratio / a.required ? b : a));
|
|
187
|
+
return pass(`The message text "${lowest.text}" has ${formatRatio(lowest.ratio)} against its background (needs ${lowest.required}:1).`, { measurements });
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/** The checks that apply to an archetype. */
|
|
192
|
+
export function computedChecksFor(archetype) {
|
|
193
|
+
return archetype === "live-region" ? [MESSAGE_TEXT_CONTRAST] : [TEXT_CONTRAST, BOUNDARY_CONTRAST, FOCUS_CONTRAST];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Every computed check, for tests and docs. */
|
|
197
|
+
export const COMPUTED_CHECKS = [TEXT_CONTRAST, BOUNDARY_CONTRAST, FOCUS_CONTRAST, MESSAGE_TEXT_CONTRAST];
|
|
@@ -34,6 +34,14 @@ export function textThreshold(sizePx, weight) {
|
|
|
34
34
|
return sizePx >= 24 || (sizePx >= 18.66 && Number(weight) >= 700) ? 3 : 4.5;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Is a focus change big enough to count as an indicator? Pixels that change by 3:1 or more have to cover at least half the
|
|
39
|
+
* control's perimeter: a ring or underline a person can't miss, even with anti-aliased edges and rounded corners.
|
|
40
|
+
*/
|
|
41
|
+
export function ringIsEnough(strongPixels, perimeter) {
|
|
42
|
+
return strongPixels >= perimeter / 2;
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
/** "4.52:1". The ratio is cut down, never rounded up, so 2.999 never reads as 3. */
|
|
38
46
|
export function formatRatio(ratio) {
|
|
39
47
|
return `${(Math.floor(ratio * 100) / 100).toFixed(2).replace(/\.?0+$/, "")}:1`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { installHelpers } from "../interactions/helpers.js";
|
|
2
2
|
import { runCheck } from "../interactions/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { computedChecksFor } from "./checks.js";
|
|
4
4
|
import { installMeasure } from "./measure-kit.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -14,7 +14,7 @@ import { installMeasure } from "./measure-kit.js";
|
|
|
14
14
|
export async function runComputed(browser, url, archetype) {
|
|
15
15
|
if (archetype === "chart") return { status: "not-applicable", reason: "The chart archetype has no trigger to measure." };
|
|
16
16
|
const results = [];
|
|
17
|
-
for (const check of
|
|
17
|
+
for (const check of computedChecksFor(archetype)) results.push(await runCheck(browser, url, check, { kits: [installHelpers, installMeasure] }));
|
|
18
18
|
return { status: "ran", checks: results };
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -88,22 +88,36 @@ export function installMeasure() {
|
|
|
88
88
|
return { inset: /\binset\b/.test(text), x: x ?? 0, y: y ?? 0, blur: blur ?? 0, spread: spread ?? 0, color: color ? rgba(color[0]) : null };
|
|
89
89
|
};
|
|
90
90
|
|
|
91
|
+
const keys = new WeakMap();
|
|
92
|
+
let keyCounter = 0;
|
|
93
|
+
|
|
91
94
|
window.__a11yMeasure = {
|
|
92
95
|
rgba,
|
|
93
|
-
/**
|
|
94
|
-
|
|
96
|
+
/**
|
|
97
|
+
* The text inside the trigger (or, with "page", anywhere on the page except the trigger): each piece of text with its
|
|
98
|
+
* color, size, and backdrop. Slotted text takes its style from the slot's parent in the flattened tree, and text that is
|
|
99
|
+
* visually hidden (a one-pixel screen reader copy) is left out. `key` lets a later call tell which text is new.
|
|
100
|
+
*/
|
|
101
|
+
text(scope) {
|
|
102
|
+
const wholePage = scope === "page" || scope === "all";
|
|
95
103
|
const el = trigger();
|
|
96
|
-
if (!el) return null;
|
|
104
|
+
if (!el && !wholePage) return null;
|
|
97
105
|
const parts = [];
|
|
98
106
|
const seen = new Set();
|
|
99
|
-
const consider = (
|
|
100
|
-
|
|
101
|
-
|
|
107
|
+
const consider = (textNode, label, own) => {
|
|
108
|
+
const host = own ?? textNode.parentElement;
|
|
109
|
+
const node = own ?? textNode.assignedSlot ?? host;
|
|
110
|
+
if (!host || !node || seen.has(textNode) || !visibleBox(host)) return;
|
|
111
|
+
const box = host.getBoundingClientRect();
|
|
112
|
+
if (box.width <= 1 && box.height <= 1) return;
|
|
113
|
+
seen.add(textNode);
|
|
102
114
|
const style = getComputedStyle(node);
|
|
103
115
|
if (style.visibility === "hidden") return;
|
|
116
|
+
if (!keys.has(textNode)) keys.set(textNode, (keyCounter += 1));
|
|
104
117
|
const behind = backdrop(node, true);
|
|
105
118
|
const color = rgba(style.color);
|
|
106
119
|
parts.push({
|
|
120
|
+
key: keys.get(textNode),
|
|
107
121
|
text: label.replace(/\s+/g, " ").trim().slice(0, 40),
|
|
108
122
|
color,
|
|
109
123
|
size: parseFloat(style.fontSize),
|
|
@@ -112,11 +126,17 @@ export function installMeasure() {
|
|
|
112
126
|
undetermined: behind.reason ?? (color ? null : "its color couldn't be read"),
|
|
113
127
|
});
|
|
114
128
|
};
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
129
|
+
const walk = (root) => {
|
|
130
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, wholePage ? { acceptNode: (n) => (n.parentElement?.closest(scope === "all" ? "script, style, noscript" : "script, style, noscript, [data-a11y-trigger]") ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT) } : undefined);
|
|
131
|
+
for (let node = walker.nextNode(); node && parts.length < (scope === "all" ? 300 : 30); node = walker.nextNode()) {
|
|
132
|
+
if (node.nodeValue && node.nodeValue.trim() && node.parentElement) consider(node, node.nodeValue);
|
|
133
|
+
}
|
|
134
|
+
if (wholePage) for (const host of root.querySelectorAll("*")) if (host.shadowRoot) walk(host.shadowRoot);
|
|
135
|
+
};
|
|
136
|
+
walk(wholePage ? document.body : el);
|
|
137
|
+
if (!wholePage && el.matches("input, textarea, select") && "value" in el && String(el.value).trim()) {
|
|
138
|
+
consider(el, String(el.value), el);
|
|
118
139
|
}
|
|
119
|
-
if (el.matches("input, textarea, select") && "value" in el && String(el.value).trim()) consider(el, String(el.value));
|
|
120
140
|
return parts;
|
|
121
141
|
},
|
|
122
142
|
/** What the control looks like from outside: label, input, or icon, and the colors that mark its edge. */
|