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.
Files changed (49) hide show
  1. package/AGENTS.md +2 -0
  2. package/README.md +39 -11
  3. package/package.json +5 -3
  4. package/skills/automatica11y/SKILL.md +3 -1
  5. package/skills/automatica11y-runner/SKILL.md +47 -14
  6. package/skills/automatica11y-runner/references/fixtures.md +38 -2
  7. package/src/commands/common.js +5 -2
  8. package/src/frameworks/index.js +42 -0
  9. package/src/frameworks/react.js +77 -0
  10. package/src/frameworks/vue.js +90 -0
  11. package/src/frameworks/wc.js +55 -0
  12. package/src/globals.d.ts +1 -0
  13. package/src/harness/bundle.js +7 -6
  14. package/src/harness/generate/dialects.js +64 -0
  15. package/src/harness/generate/index.js +12 -0
  16. package/src/harness/generate/jsx-recipes.js +224 -0
  17. package/src/harness/generate/jsx.js +76 -0
  18. package/src/harness/generate/kit.js +50 -0
  19. package/src/harness/generate/marking.js +64 -0
  20. package/src/harness/generate/probe.js +97 -0
  21. package/src/harness/generate/shared.js +12 -0
  22. package/src/harness/generate/wc-recipes.js +132 -0
  23. package/src/harness/npm-install.js +38 -7
  24. package/src/harness/settle.js +17 -0
  25. package/src/harness/storybook.js +1 -0
  26. package/src/harness/url.js +13 -3
  27. package/src/plan/classify.js +11 -4
  28. package/src/plan/mapping.js +10 -7
  29. package/src/plan/resolve-npm.js +15 -9
  30. package/src/plan/subpath.js +133 -0
  31. package/src/report/comparison.js +14 -9
  32. package/src/report/parts.js +36 -12
  33. package/src/run/audit-npm.js +118 -28
  34. package/src/run/generate-fixture.js +74 -0
  35. package/src/run/run-plan.js +19 -4
  36. package/src/run/summary.js +5 -0
  37. package/src/schema.js +30 -6
  38. package/src/tiers/computed/checks.js +44 -5
  39. package/src/tiers/computed/color.js +8 -0
  40. package/src/tiers/computed/index.js +2 -2
  41. package/src/tiers/computed/measure-kit.js +30 -10
  42. package/src/tiers/conditions/checks.js +283 -0
  43. package/src/tiers/conditions/index.js +30 -0
  44. package/src/tiers/conditions/kit.js +133 -0
  45. package/src/tiers/interactions/archetypes.js +111 -0
  46. package/src/tiers/interactions/helpers.js +56 -0
  47. package/src/tiers/interactions/index.js +16 -3
  48. package/src/harness/npm-react.js +0 -39
  49. 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
+ }
@@ -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
- : await runVsr(opened.page, { scope: "#storybook-root" }).catch(failedVsr);
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 === "react" ? "npm-react" : "npm-wc";
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`);
@@ -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)}. 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 });
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
- export const COMPUTED_CHECKS = [TEXT_CONTRAST, BOUNDARY_CONTRAST, FOCUS_CONTRAST];
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 { COMPUTED_CHECKS } from "./checks.js";
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 COMPUTED_CHECKS) results.push(await runCheck(browser, url, check, { kits: [installHelpers, installMeasure] }));
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
- /** The text inside the trigger: each element that directly holds text, with its color, size, and backdrop. */
94
- text() {
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 = (node, label) => {
100
- if (seen.has(node) || !visibleBox(node)) return;
101
- seen.add(node);
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 walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
116
- for (let node = walker.nextNode(); node && parts.length < 12; node = walker.nextNode()) {
117
- if (node.nodeValue && node.nodeValue.trim() && node.parentElement) consider(node.parentElement, node.nodeValue);
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. */