automatica11y 0.3.3 → 0.4.0

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 (47) hide show
  1. package/README.md +25 -7
  2. package/package.json +5 -3
  3. package/skills/automatica11y-runner/SKILL.md +44 -13
  4. package/skills/automatica11y-runner/references/fixtures.md +38 -2
  5. package/src/commands/common.js +5 -2
  6. package/src/frameworks/index.js +42 -0
  7. package/src/frameworks/react.js +77 -0
  8. package/src/frameworks/vue.js +90 -0
  9. package/src/frameworks/wc.js +55 -0
  10. package/src/globals.d.ts +1 -0
  11. package/src/harness/bundle.js +7 -6
  12. package/src/harness/generate/dialects.js +64 -0
  13. package/src/harness/generate/index.js +12 -0
  14. package/src/harness/generate/jsx-recipes.js +224 -0
  15. package/src/harness/generate/jsx.js +76 -0
  16. package/src/harness/generate/kit.js +50 -0
  17. package/src/harness/generate/marking.js +64 -0
  18. package/src/harness/generate/probe.js +97 -0
  19. package/src/harness/generate/shared.js +12 -0
  20. package/src/harness/generate/wc-recipes.js +132 -0
  21. package/src/harness/npm-install.js +38 -7
  22. package/src/harness/settle.js +17 -0
  23. package/src/harness/storybook.js +1 -0
  24. package/src/harness/url.js +13 -3
  25. package/src/plan/classify.js +11 -4
  26. package/src/plan/mapping.js +10 -7
  27. package/src/plan/resolve-npm.js +15 -9
  28. package/src/plan/subpath.js +133 -0
  29. package/src/report/comparison.js +14 -9
  30. package/src/report/parts.js +36 -12
  31. package/src/run/audit-npm.js +118 -28
  32. package/src/run/generate-fixture.js +74 -0
  33. package/src/run/run-plan.js +19 -4
  34. package/src/run/summary.js +5 -0
  35. package/src/schema.js +30 -6
  36. package/src/tiers/computed/checks.js +44 -5
  37. package/src/tiers/computed/color.js +8 -0
  38. package/src/tiers/computed/index.js +2 -2
  39. package/src/tiers/computed/measure-kit.js +30 -10
  40. package/src/tiers/conditions/checks.js +283 -0
  41. package/src/tiers/conditions/index.js +30 -0
  42. package/src/tiers/conditions/kit.js +133 -0
  43. package/src/tiers/interactions/archetypes.js +111 -0
  44. package/src/tiers/interactions/helpers.js +56 -0
  45. package/src/tiers/interactions/index.js +16 -3
  46. package/src/harness/npm-react.js +0 -39
  47. package/src/harness/npm-wc.js +0 -30
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. */
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The conditions checks. Each opens the page under a setting a person might use (reduced motion, dark mode, forced colors),
3
+ * or in an environment (a 320 pixel window, wider text spacing), and says whether the page holds up.
4
+ * A check returns pass, fail, undetermined, or not-applicable, with a detail and the numbers behind it.
5
+ * These are automatica11y's own measurements. They're reported on their own and never added to axe-core or IBM counts.
6
+ */
7
+ import { contrastOver, formatRatio, ringIsEnough, textThreshold } from "../computed/color.js";
8
+ import { num, plural } from "../../text.js";
9
+ import { criterionRef } from "../../wcag/index.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
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
17
+ const floor2 = (n) => Math.floor(n * 100) / 100;
18
+ const list = (items, max = 3) => `${items.slice(0, max).join("; ")}${items.length > max ? `; and ${items.length - max} more` : ""}`;
19
+
20
+ /** Archetypes whose trigger shouldn't be pressed to reach their resting state: a link would navigate, and a chart has nothing to press. */
21
+ const NO_ACTIVATION = new Set(["link", "chart"]);
22
+
23
+ /** Does this page have a trigger hook (a fixture), or is it a whole page? */
24
+ const hasTrigger = async (ctx) => (await ctx.trigger.count()) > 0;
25
+
26
+ /** Press Enter on the trigger of a fixture, so a dialog, menu, or message is showing. Whole pages are left as they are. */
27
+ async function reachState(ctx, archetype) {
28
+ if (!(await hasTrigger(ctx)) || NO_ACTIVATION.has(archetype)) return false;
29
+ await ctx.focus();
30
+ await ctx.press("Enter");
31
+ await ctx.settle(300);
32
+ return true;
33
+ }
34
+
35
+ // ---- reduced motion ----
36
+
37
+ /** Every animation seen at load and just after the trigger is pressed. Each is recorded once. */
38
+ async function collectAnimations(ctx, archetype) {
39
+ const seen = new Map();
40
+ const take = async () => {
41
+ for (const a of await ctx.page.evaluate(() => window.__a11yConditions.animations())) seen.set(`${a.kind}|${a.name}|${a.target}|${a.props.join()}|${a.duration}|${a.iterations}`, a);
42
+ };
43
+ await ctx.settle(100);
44
+ await take();
45
+ await sleep(150);
46
+ await take();
47
+ if ((await hasTrigger(ctx)) && !NO_ACTIVATION.has(archetype)) {
48
+ await ctx.focus();
49
+ await ctx.page.keyboard.press("Enter");
50
+ for (const wait of [0, 40, 80, 160, 240]) {
51
+ await take();
52
+ await sleep(wait);
53
+ }
54
+ }
55
+ return [...seen.values()];
56
+ }
57
+
58
+ /** An animation worth reducing: anything that repeats forever, or moves for longer than a blink. */
59
+ const concerning = (a) => a.iterations === "infinite" || (a.moving && (a.duration ?? 0) > 100);
60
+ const describeAnimation = (a) => `${a.name || a.kind} on ${a.target} (${a.moving ? `moves ${a.props.filter((p) => /^(transform|translate|rotate|scale|top|left|right|bottom|inset|margin|height|width|offset|background|clip|max|min)/.test(p)).join(", ")}` : a.props.join(", ") || "no keyframes"}; ${a.duration ?? "?"}ms${a.iterations === "infinite" ? ", repeats forever" : ""})`;
61
+
62
+ const reducedMotion = (archetype) => ({
63
+ name: "reduced-motion-respected",
64
+ criteria: ["2.3.3", "2.2.2"],
65
+ async run(ctx) {
66
+ const normal = await collectAnimations(ctx, archetype);
67
+ const reduced = await collectAnimations(await ctx.variant({ reducedMotion: true }), archetype);
68
+ const measurements = [{ setting: "no preference", animations: normal.length, moveOrRepeat: normal.filter(concerning).length }, { setting: "reduce", animations: reduced.length, moveOrRepeat: reduced.filter(concerning).length }];
69
+ if (normal.length === 0 && reduced.length === 0) return na("No animations or transitions were running at load or after the trigger was pressed, so there's nothing to reduce. Motion driven by JavaScript timers isn't visible to this check.");
70
+ const still = reduced.filter(concerning);
71
+ if (still.length) {
72
+ const before = normal.filter(concerning).length;
73
+ return fail(`With prefers-reduced-motion: reduce, ${plural(still.length, "animation")} still ${still.length === 1 ? "moves or repeats" : "move or repeat"}: ${list(still.map(describeAnimation))}. Without the preference, ${num(before)} did.`, { measurements });
74
+ }
75
+ const had = normal.filter(concerning);
76
+ return had.length
77
+ ? pass(`${plural(had.length, "animation")} that move or repeat (${list(had.map(describeAnimation), 2)}) don't run when motion is reduced.`, { measurements })
78
+ : pass(`The page animates (${plural(normal.length, "animation")}), but nothing moves or repeats, so there's no motion to reduce. Motion driven by JavaScript timers isn't visible to this check.`, { measurements });
79
+ },
80
+ });
81
+
82
+ // ---- color scheme ----
83
+
84
+ const colorScheme = () => ({
85
+ name: "dark-mode-contrast",
86
+ criteria: ["1.4.3"],
87
+ async run(ctx) {
88
+ await ctx.settle(300);
89
+ // Animations are frozen in both shots, so a spinner or a fade can't make a page look as if it changed.
90
+ const light = await ctx.page.screenshot({ animations: "disabled" });
91
+ const dark = await ctx.variant({ colorScheme: "dark" });
92
+ await dark.settle(300);
93
+ const shot = await dark.page.screenshot({ animations: "disabled" });
94
+ if (light.equals(shot)) return na("The page looks the same under prefers-color-scheme: dark, so it doesn't adapt to it. A page isn't required to. Nothing was checked.");
95
+ const parts = (await dark.page.evaluate(() => window.__a11yMeasure.text("all"))) ?? [];
96
+ if (parts.length === 0) return undetermined("The page changes under prefers-color-scheme: dark, but no visible text was found to measure.");
97
+ const rows = [];
98
+ const unknown = [];
99
+ for (const part of parts) {
100
+ if (part.undetermined || !part.backdrop) unknown.push(`"${part.text}" can't be measured because ${part.undetermined ?? "its background couldn't be read"}.`);
101
+ else rows.push({ text: part.text, ratio: contrastOver(part.color, part.backdrop), required: textThreshold(part.size, part.weight) });
102
+ }
103
+ const failed = rows.filter((r) => r.ratio < r.required).sort((a, b) => a.ratio / a.required - b.ratio / b.required);
104
+ const measurements = [{ measured: rows.length, undetermined: unknown.length, below: failed.length, lowestRatio: rows.length ? floor2(Math.min(...rows.map((r) => r.ratio))) : null }];
105
+ if (failed.length) return fail(`In dark mode, ${plural(failed.length, "piece")} of text fall${failed.length === 1 ? "s" : ""} below the contrast it needs. The worst is "${failed[0].text}" at ${formatRatio(failed[0].ratio)} (needs ${failed[0].required}:1).`, { measurements });
106
+ if (unknown.length) return undetermined(`${rows.length ? `All ${plural(rows.length, "measured piece")} of text pass in dark mode. ` : ""}${plural(unknown.length, "other piece")} can't be reduced to one color. ${unknown[0]}`, { measurements });
107
+ return pass(`The page adapts to dark mode, and all ${plural(rows.length, "piece")} of text measured pass. The lowest is ${formatRatio(Math.min(...rows.map((r) => r.ratio)))}.`, { measurements });
108
+ },
109
+ });
110
+
111
+ // ---- prefers-contrast ----
112
+
113
+ /** Measure the contrast of every piece of text on a page. */
114
+ async function measureAllText(ctx) {
115
+ const parts = (await ctx.page.evaluate(() => window.__a11yMeasure.text("all"))) ?? [];
116
+ const rows = [];
117
+ const unknown = [];
118
+ for (const part of parts) {
119
+ if (part.undetermined || !part.backdrop) unknown.push(`"${part.text}" can't be measured because ${part.undetermined ?? "its background couldn't be read"}.`);
120
+ else rows.push({ text: part.text, ratio: contrastOver(part.color, part.backdrop), required: textThreshold(part.size, part.weight) });
121
+ }
122
+ return { rows, unknown };
123
+ }
124
+
125
+ /** The piece of text with the least room above its threshold, and how many fall below it. */
126
+ function tightest(rows) {
127
+ const sorted = [...rows].sort((a, b) => a.ratio / a.required - b.ratio / b.required);
128
+ return { worst: sorted[0], below: sorted.filter((r) => r.ratio < r.required) };
129
+ }
130
+
131
+ /** Screenshots with animations frozen, so a running spinner can't make a page look different. */
132
+ async function lookOf(ctx) {
133
+ await ctx.settle(300);
134
+ return ctx.page.screenshot({ animations: "disabled" });
135
+ }
136
+
137
+ const moreContrast = () => ({
138
+ name: "more-contrast-respected",
139
+ criteria: ["1.4.3", "1.4.6"],
140
+ async run(ctx) {
141
+ const normal = await lookOf(ctx);
142
+ const more = await ctx.variant({ contrast: "more" });
143
+ if (normal.equals(await lookOf(more))) return na("The page looks the same under prefers-contrast: more, so it doesn't respond to it. A page isn't required to. Nothing was checked.");
144
+ const base = await measureAllText(ctx);
145
+ const now = await measureAllText(more);
146
+ if (now.rows.length === 0) return undetermined("The page changes under prefers-contrast: more, but no visible text was measurable.");
147
+ const { worst, below } = tightest(now.rows);
148
+ const lowestNow = Math.min(...now.rows.map((r) => r.ratio));
149
+ const lowestBase = base.rows.length ? Math.min(...base.rows.map((r) => r.ratio)) : null;
150
+ const enhanced = now.rows.every((r) => r.ratio >= (r.required === 3 ? 4.5 : 7));
151
+ const measurements = [{ lowestWithoutPreference: lowestBase === null ? null : floor2(lowestBase), lowestWithMore: floor2(lowestNow), belowMinimum: below.length, reachesEnhanced: enhanced }];
152
+ if (below.length) return fail(`With prefers-contrast: more, ${plural(below.length, "piece")} of text fall${below.length === 1 ? "s" : ""} below the minimum contrast. The worst is "${worst.text}" at ${formatRatio(worst.ratio)} (needs ${worst.required}:1).`, { measurements });
153
+ if (lowestBase !== null && lowestNow < lowestBase - 0.01) return fail(`With prefers-contrast: more, the lowest text contrast dropped from ${formatRatio(lowestBase)} to ${formatRatio(lowestNow)}, so the page asked for more contrast and got less.`, { measurements });
154
+ if (now.unknown.length) return undetermined(`All ${plural(now.rows.length, "measured piece")} of text pass with more contrast. ${plural(now.unknown.length, "other piece")} can't be reduced to one color. ${now.unknown[0]}`, { measurements });
155
+ return pass(`The page responds to prefers-contrast: more. The lowest text contrast is ${formatRatio(lowestNow)}${lowestBase === null ? "" : `, from ${formatRatio(lowestBase)} without the preference`}. Enhanced contrast (${criterionRef("1.4.6")}, 7:1 for normal text) is ${enhanced ? "reached" : "not reached for every piece of text"}.`, { measurements });
156
+ },
157
+ });
158
+
159
+ const lessContrast = () => ({
160
+ name: "less-contrast-stays-readable",
161
+ criteria: ["1.4.3"],
162
+ async run(ctx) {
163
+ const normal = await lookOf(ctx);
164
+ const less = await ctx.variant({ contrast: "less" });
165
+ if (normal.equals(await lookOf(less))) return na("The page looks the same under prefers-contrast: less, so it doesn't respond to it. A page isn't required to. Nothing was checked.");
166
+ const now = await measureAllText(less);
167
+ if (now.rows.length === 0) return undetermined("The page changes under prefers-contrast: less, but no visible text was measurable.");
168
+ const { worst, below } = tightest(now.rows);
169
+ const measurements = [{ measured: now.rows.length, belowMinimum: below.length, lowest: floor2(Math.min(...now.rows.map((r) => r.ratio))) }];
170
+ if (below.length) return fail(`With prefers-contrast: less, ${plural(below.length, "piece")} of text fall${below.length === 1 ? "s" : ""} below the minimum contrast. The worst is "${worst.text}" at ${formatRatio(worst.ratio)} (needs ${worst.required}:1). Softer contrast is fine only while text stays readable.`, { measurements });
171
+ if (now.unknown.length) return undetermined(`All ${plural(now.rows.length, "measured piece")} of text stay above the minimum. ${plural(now.unknown.length, "other piece")} can't be reduced to one color. ${now.unknown[0]}`, { measurements });
172
+ return pass(`The page softens its contrast under prefers-contrast: less, and all ${plural(now.rows.length, "piece")} of text measured stay above the minimum. The lowest is ${formatRatio(Math.min(...now.rows.map((r) => r.ratio)))}.`, { measurements });
173
+ },
174
+ });
175
+
176
+ // ---- prefers-reduced-transparency ----
177
+
178
+ const reducedTransparency = () => ({
179
+ name: "reduced-transparency-respected",
180
+ // The preference isn't a success criterion. See-through backgrounds make text contrast unpredictable, which 1.4.3 and 1.4.11 care about.
181
+ criteria: ["1.4.3", "1.4.11"],
182
+ async run(ctx) {
183
+ await ctx.settle(300);
184
+ const normal = await ctx.page.evaluate(() => window.__a11yConditions.translucentSurfaces());
185
+ const reduced = await ctx.variant({ reducedTransparency: true });
186
+ await reduced.settle(300);
187
+ const still = await reduced.page.evaluate(() => window.__a11yConditions.translucentSurfaces());
188
+ const measurements = [{ translucentWithoutPreference: normal.length, translucentWithReduce: still.length }];
189
+ if (normal.length === 0 && still.length === 0) return na("No surface that holds text is see-through (no translucent background and no backdrop filter), so there's nothing to reduce. Empty overlays aren't counted.");
190
+ const name = (s) => `${s.element} (${[s.background, s.backdropFilter && `backdrop-filter ${s.backdropFilter}`].filter(Boolean).join(", ")})`;
191
+ if (still.length) return fail(`With prefers-reduced-transparency: reduce, ${plural(still.length, "surface")} holding text stay${still.length === 1 ? "s" : ""} see-through: ${list(still.map(name))}. Without the preference there were ${num(normal.length)}. This preference isn't a WCAG requirement. It matters because see-through backgrounds make text contrast unpredictable.`, { measurements });
192
+ return pass(`${plural(normal.length, "see-through surface")} holding text (${list(normal.map(name), 2)}) become opaque when transparency is reduced.`, { measurements });
193
+ },
194
+ });
195
+
196
+ // ---- forced colors ----
197
+
198
+ const forcedColors = (archetype) => ({
199
+ name: "forced-colors-focus-visible",
200
+ criteria: ["1.4.11", "2.4.7"],
201
+ async run(ctx) {
202
+ const forced = await ctx.variant({ forcedColors: true });
203
+ await forced.settle(300);
204
+ let focused;
205
+ if (await hasTrigger(forced)) {
206
+ if ((await forced.tabToTrigger()) === null) return na("The trigger can't be reached with Tab, so its focus indicator wasn't checked.");
207
+ } else {
208
+ await forced.page.keyboard.press("Tab");
209
+ await forced.settle(100);
210
+ }
211
+ focused = await forced.page.evaluate(() => window.__a11yMeasure.focusStyles(false));
212
+ if (!focused || (focused.box.width === 0 && focused.box.height === 0) || (await forced.page.evaluate(() => document.activeElement === document.body))) return na("Nothing on the page takes keyboard focus, so there's no focus indicator to check.");
213
+ await forced.settle(400);
214
+ const clip = forced.clipAround(focused.box, 12);
215
+ const shotFocused = await forced.page.screenshot({ clip, animations: "disabled" });
216
+ await forced.page.evaluate(() => /** @type {HTMLElement} */ (document.activeElement)?.blur?.());
217
+ await forced.page.mouse.move(0, 0);
218
+ await forced.settle(300);
219
+ const shotBlurred = await forced.page.screenshot({ clip, animations: "disabled" });
220
+ const url = (buffer) => `data:image/png;base64,${buffer.toString("base64")}`;
221
+ const px = await forced.page.evaluate(([a, b]) => window.__a11yMeasure.compareShots(a, b), [url(shotBlurred), url(shotFocused)]);
222
+ const optOuts = await forced.page.evaluate(() => window.__a11yConditions.forcedColorOptOuts());
223
+ const note = optOuts.length ? ` ${plural(optOuts.length, "element")} opt${optOuts.length === 1 ? "s" : ""} out of forced colors with forced-color-adjust: none (${list(optOuts, 3)}), so a person should check them.` : "";
224
+ const perimeter = 2 * (focused.box.width + focused.box.height);
225
+ const measurements = [{ changedPixels: px.changed, pixelsAtLeast3to1: px.strong, perimeterPixels: Math.round(perimeter), forcedColorOptOuts: optOuts.length }];
226
+ if (px.changed === 0) return fail(`With forced colors on, nothing visible changed when the control took focus. A focus ring drawn with box-shadow or a background color disappears in forced colors. Use an outline.${note}`, { measurements, method: "pixels" });
227
+ return ringIsEnough(px.strong, perimeter)
228
+ ? pass(`With forced colors on, the focus indicator is visible: ${num(px.strong)} of ${num(px.changed)} changed pixels reach 3:1 against their unfocused color.${note}`, { measurements, method: "pixels" })
229
+ : fail(`With forced colors on, the focus indicator is weak: only ${num(px.strong)} of ${num(px.changed)} changed pixels reach 3:1 against their unfocused color, and enough to count is about half the control's perimeter, ${Math.round(perimeter / 2)}.${note}`, { measurements, method: "pixels" });
230
+ },
231
+ });
232
+
233
+ // ---- reflow ----
234
+
235
+ const reflow = (archetype) => ({
236
+ name: "reflow-at-320px",
237
+ criteria: ["1.4.10"],
238
+ async run(ctx) {
239
+ const narrow = await ctx.variant({ viewport: { width: 320, height: 256 } });
240
+ await narrow.settle(300);
241
+ await reachState(narrow, archetype);
242
+ const o = await narrow.page.evaluate(() => window.__a11yConditions.overflow("[data-a11y-root]"));
243
+ const measurements = [{ viewportWidth: o.viewportWidth, scrollWidth: o.scrollWidth, overflowingElements: o.offenderCount }];
244
+ const problems = [];
245
+ if (o.scrollWidth > o.viewportWidth + 1) problems.push(`the page scrolls sideways (it is ${o.scrollWidth}px wide in a ${o.viewportWidth}px window)`);
246
+ if (o.offenders.length) problems.push(`${plural(o.offenderCount, "element")} reach${o.offenderCount === 1 ? "es" : ""} past the right edge: ${list(o.offenders.map((e) => `${e.element} ends at ${e.right}px`))}`);
247
+ if (o.root && (o.root.right > o.viewportWidth + 1 || o.root.left < -1)) problems.push(`${o.root.element} spans ${o.root.left}px to ${o.root.right}px`);
248
+ if (problems.length) return fail(`In a 320px window, ${problems.join(", and ")}. ${criterionRef("1.4.10")} exempts two-dimensional content such as data tables and maps, so a person should judge whether that applies.`, { measurements });
249
+ return pass(`In a 320px window, nothing reaches past the right edge and the page doesn't scroll sideways${o.root ? `. The ${o.root.element} fits (${o.root.left}px to ${o.root.right}px)` : ""}.`, { measurements });
250
+ },
251
+ });
252
+
253
+ // ---- text spacing ----
254
+
255
+ const textSpacing = (archetype) => ({
256
+ name: "text-spacing-no-clipping",
257
+ criteria: ["1.4.12"],
258
+ async run(ctx) {
259
+ await ctx.settle(200);
260
+ await reachState(ctx, archetype);
261
+ const before = await ctx.page.evaluate(() => window.__a11yConditions.clipping());
262
+ await ctx.page.evaluate(() => window.__a11yConditions.applySpacing());
263
+ await ctx.settle(200);
264
+ const after = await ctx.page.evaluate(() => window.__a11yConditions.clipping());
265
+ if (before.length !== after.length) return undetermined("The page's elements changed while the spacing was applied, so the before and after can't be compared.");
266
+ const clipped = (e) => (e.hidesX && e.overX > 1) || (e.hidesY && e.overY > 1);
267
+ const cut = [];
268
+ after.forEach((now, index) => {
269
+ const was = before[index];
270
+ if (clipped(now) && now.text && !now.visuallyHidden && (!clipped(was) || now.overX - was.overX > 1 || now.overY - was.overY > 1)) cut.push(`${now.element} ("${now.text}")`);
271
+ });
272
+ const measurements = [{ elementsChecked: after.length, clipped: cut.length }];
273
+ const settings = "line height 1.5, letter spacing 0.12em, word spacing 0.16em, and paragraph spacing 2em";
274
+ return cut.length
275
+ ? fail(`With ${settings}, text in ${plural(cut.length, "element")} gets cut off: ${list(cut)}. Overlapping text isn't checked.`, { measurements })
276
+ : pass(`With ${settings}, no element cut off its text (${num(after.length)} elements checked). Overlapping text isn't checked.`, { measurements });
277
+ },
278
+ });
279
+
280
+ /** The conditions checks for an archetype. Whole pages use the name "page". */
281
+ export function conditionChecksFor(archetype) {
282
+ return [reducedMotion(archetype), colorScheme(), moreContrast(), lessContrast(), reducedTransparency(), forcedColors(archetype), reflow(archetype), textSpacing(archetype)];
283
+ }
@@ -0,0 +1,30 @@
1
+ import { installMeasure } from "../computed/measure-kit.js";
2
+ import { installHelpers } from "../interactions/helpers.js";
3
+ import { runCheck } from "../interactions/index.js";
4
+ import { conditionChecksFor } from "./checks.js";
5
+ import { installConditions } from "./kit.js";
6
+
7
+ /** These checks open each page twice, and sometimes press keys, so they get longer than the others. */
8
+ const TIMEOUT_MS = 60_000;
9
+
10
+ /**
11
+ * Run the conditions checks against a fixture page or a whole page. Each check opens its own fresh pages.
12
+ * A check that can't finish reports `error`, and one that can't reduce the page to colors reports `undetermined`.
13
+ * Neither counts as a pass.
14
+ * @param {import("playwright-core").Browser} browser
15
+ * @param {string} url
16
+ * @param {string} archetype An archetype name for a fixture, or "page" for a whole page.
17
+ */
18
+ export async function runConditions(browser, url, archetype) {
19
+ const results = [];
20
+ for (const check of conditionChecksFor(archetype)) {
21
+ results.push(await runCheck(browser, url, check, { kits: [installHelpers, installMeasure, installConditions], needsTrigger: archetype !== "page", timeoutMs: TIMEOUT_MS, waitUntil: archetype === "page" ? "networkidle" : "load" }));
22
+ }
23
+ return { status: "ran", checks: results };
24
+ }
25
+
26
+ /** What a Storybook target says about this tier. */
27
+ export const CONDITIONS_NOT_APPLICABLE_FOR_STORIES = {
28
+ status: "not-applicable",
29
+ reason: "Conditions checks open the whole page again with other settings. Storybook stories are audited inside their own frame, so this version doesn't run them.",
30
+ };