automatica11y 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Color math for the computed tier. Colors are `[r, g, b, a]` with r, g, and b from 0 to 255 and a from 0 to 1.
3
+ * The page turns every CSS color into this form (see measure-kit.js), so this file never parses CSS.
4
+ */
5
+
6
+ /** Relative luminance of an opaque color, as WCAG defines it. */
7
+ export function luminance([r, g, b]) {
8
+ const lin = (c) => {
9
+ const s = c / 255;
10
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
11
+ };
12
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
13
+ }
14
+
15
+ /** Lay `top` over an opaque `under` color. The result is opaque. */
16
+ export function composite(top, under) {
17
+ const a = top[3];
18
+ return [0, 1, 2].map((i) => top[i] * a + under[i] * (1 - a)).concat(1);
19
+ }
20
+
21
+ /** WCAG contrast ratio between two opaque colors, from 1 to 21. */
22
+ export function contrastRatio(a, b) {
23
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
24
+ return (hi + 0.05) / (lo + 0.05);
25
+ }
26
+
27
+ /** Contrast of a possibly translucent foreground over an opaque background. */
28
+ export function contrastOver(fg, bg) {
29
+ return contrastRatio(composite(fg, bg), bg);
30
+ }
31
+
32
+ /** The ratio text needs: 3 for large text (24px, or 18.66px and bold), and 4.5 for the rest. */
33
+ export function textThreshold(sizePx, weight) {
34
+ return sizePx >= 24 || (sizePx >= 18.66 && Number(weight) >= 700) ? 3 : 4.5;
35
+ }
36
+
37
+ /** "4.52:1". The ratio is cut down, never rounded up, so 2.999 never reads as 3. */
38
+ export function formatRatio(ratio) {
39
+ return `${(Math.floor(ratio * 100) / 100).toFixed(2).replace(/\.?0+$/, "")}:1`;
40
+ }
@@ -0,0 +1,25 @@
1
+ import { installHelpers } from "../interactions/helpers.js";
2
+ import { runCheck } from "../interactions/index.js";
3
+ import { COMPUTED_CHECKS } from "./checks.js";
4
+ import { installMeasure } from "./measure-kit.js";
5
+
6
+ /**
7
+ * Run the computed checks for one archetype against its fixture page. Each check gets a fresh page.
8
+ * A check that can't finish reports `error`, and one that can't reduce the page to colors reports `undetermined`.
9
+ * Neither counts as a pass.
10
+ * @param {import("playwright-core").Browser} browser
11
+ * @param {string} url
12
+ * @param {string} archetype
13
+ */
14
+ export async function runComputed(browser, url, archetype) {
15
+ if (archetype === "chart") return { status: "not-applicable", reason: "The chart archetype has no trigger to measure." };
16
+ const results = [];
17
+ for (const check of COMPUTED_CHECKS) results.push(await runCheck(browser, url, check, { kits: [installHelpers, installMeasure] }));
18
+ return { status: "ran", checks: results };
19
+ }
20
+
21
+ /** What a page or Storybook target says about this tier. */
22
+ export const COMPUTED_NOT_APPLICABLE_FOR_PAGES = {
23
+ status: "not-applicable",
24
+ reason: "Computed checks need an archetype fixture with a trigger hook. Page and Storybook targets don't have one.",
25
+ };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Runs inside the page before any page script. It sets `window.__a11yMeasure`, which reads resolved styles
3
+ * (getComputedStyle) and turns them into plain numbers: colors as [r, g, b, a], sizes in pixels.
4
+ * It never decides pass or fail. The checks in checks.js do that.
5
+ * This function is serialized and sent to the browser, so it can't use anything from outside itself.
6
+ */
7
+ export function installMeasure() {
8
+ const canvas = document.createElement("canvas");
9
+ canvas.width = 1;
10
+ canvas.height = 1;
11
+ const context = canvas.getContext("2d", { willReadFrequently: true });
12
+
13
+ /** Any CSS color to [r, g, b, a] in sRGB, or null. The canvas does the conversion, so modern color spaces work. */
14
+ const rgba = (css) => {
15
+ if (!css || !context) return null;
16
+ context.clearRect(0, 0, 1, 1);
17
+ context.fillStyle = "#000";
18
+ context.fillStyle = css;
19
+ context.fillRect(0, 0, 1, 1);
20
+ const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;
21
+ // The canvas stores premultiplied values, so a translucent color loses a little precision. That's fine for contrast.
22
+ return a === 0 ? [0, 0, 0, 0] : [r, g, b, a / 255];
23
+ };
24
+ const over = (top, under) => [0, 1, 2].map((i) => top[i] * top[3] + under[i] * (1 - top[3])).concat(1);
25
+ const parentOf = (el) => el.assignedSlot || el.parentElement || (el.getRootNode() instanceof ShadowRoot ? el.getRootNode().host : null);
26
+
27
+ /**
28
+ * The opaque color behind an element: its ancestors' backgrounds laid over each other, ending at white.
29
+ * `includeSelf` adds the element's own background. Gradients, images, and transparency can't be reduced to one color,
30
+ * so those return a reason instead of a guess.
31
+ */
32
+ const backdrop = (el, includeSelf) => {
33
+ const layers = [];
34
+ let node = includeSelf ? el : parentOf(el);
35
+ for (let hops = 0; node && hops < 60; hops += 1) {
36
+ const style = getComputedStyle(node);
37
+ if (style.backgroundImage !== "none") return { reason: "a background image or gradient sits behind it" };
38
+ if (Number(style.opacity) < 1) return { reason: "an element behind it is partly transparent" };
39
+ if (style.mixBlendMode !== "normal") return { reason: "a blend mode is applied behind it" };
40
+ const color = rgba(style.backgroundColor);
41
+ if (color && color[3] > 0) {
42
+ layers.push(color);
43
+ if (color[3] === 1) break;
44
+ }
45
+ node = parentOf(node);
46
+ }
47
+ let result = [255, 255, 255, 1];
48
+ for (const layer of layers.reverse()) result = over(layer, result);
49
+ return { color: result };
50
+ };
51
+
52
+ const visibleBox = (el) => {
53
+ const box = el.getBoundingClientRect();
54
+ return box.width > 0 && box.height > 0;
55
+ };
56
+ const deepActive = () => {
57
+ let active = document.activeElement;
58
+ while (active && active.shadowRoot && active.shadowRoot.activeElement) active = active.shadowRoot.activeElement;
59
+ return active;
60
+ };
61
+ const trigger = () => {
62
+ const find = (root) => {
63
+ const hit = root.querySelector("[data-a11y-trigger]");
64
+ if (hit) return hit;
65
+ for (const el of root.querySelectorAll("*")) if (el.shadowRoot) { const inner = find(el.shadowRoot); if (inner) return inner; }
66
+ return null;
67
+ };
68
+ return find(document);
69
+ };
70
+
71
+ /** Split a computed box-shadow list at its top-level commas. */
72
+ const splitShadows = (value) => {
73
+ const out = [];
74
+ let depth = 0;
75
+ let start = 0;
76
+ for (let i = 0; i < value.length; i += 1) {
77
+ if (value[i] === "(") depth += 1;
78
+ else if (value[i] === ")") depth -= 1;
79
+ else if (value[i] === "," && depth === 0) { out.push(value.slice(start, i).trim()); start = i + 1; }
80
+ }
81
+ out.push(value.slice(start).trim());
82
+ return out.filter(Boolean);
83
+ };
84
+ const parseShadow = (text) => {
85
+ const color = /(rgba?\([^)]*\)|color\([^)]*\)|oklab\([^)]*\)|oklch\([^)]*\)|lab\([^)]*\)|lch\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]{3,8}|\b[a-z]+\b(?=\s|$))/i.exec(text.replace(/\binset\b/, ""));
86
+ const rest = text.replace(/\binset\b/, "").replace(color ? color[0] : "", "");
87
+ const [x, y, blur, spread] = (rest.match(/-?[\d.]+px/g) ?? []).map(parseFloat);
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
+ };
90
+
91
+ window.__a11yMeasure = {
92
+ rgba,
93
+ /** The text inside the trigger: each element that directly holds text, with its color, size, and backdrop. */
94
+ text() {
95
+ const el = trigger();
96
+ if (!el) return null;
97
+ const parts = [];
98
+ const seen = new Set();
99
+ const consider = (node, label) => {
100
+ if (seen.has(node) || !visibleBox(node)) return;
101
+ seen.add(node);
102
+ const style = getComputedStyle(node);
103
+ if (style.visibility === "hidden") return;
104
+ const behind = backdrop(node, true);
105
+ const color = rgba(style.color);
106
+ parts.push({
107
+ text: label.replace(/\s+/g, " ").trim().slice(0, 40),
108
+ color,
109
+ size: parseFloat(style.fontSize),
110
+ weight: style.fontWeight,
111
+ backdrop: behind.color ?? null,
112
+ undetermined: behind.reason ?? (color ? null : "its color couldn't be read"),
113
+ });
114
+ };
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);
118
+ }
119
+ if (el.matches("input, textarea, select") && "value" in el && String(el.value).trim()) consider(el, String(el.value));
120
+ return parts;
121
+ },
122
+ /** What the control looks like from outside: label, input, or icon, and the colors that mark its edge. */
123
+ boundary() {
124
+ const el = trigger();
125
+ if (!el) return null;
126
+ const style = getComputedStyle(el);
127
+ const outside = backdrop(el, false);
128
+ const inside = backdrop(el, true);
129
+ const parts = [];
130
+ if (outside.color && inside.color) {
131
+ parts.push({ kind: "fill", color: inside.color });
132
+ for (const side of ["Top", "Right", "Bottom", "Left"]) {
133
+ const border = parseFloat(style[`border${side}Width`]) > 0 && style[`border${side}Style`] !== "none" ? rgba(style[`border${side}Color`]) : null;
134
+ if (border && border[3] > 0) parts.push({ kind: `${side.toLowerCase()} border`, color: over(border, inside.color), width: parseFloat(style[`border${side}Width`]) });
135
+ }
136
+ }
137
+ const graphics = [];
138
+ for (const shape of el.querySelectorAll("svg path, svg circle, svg ellipse, svg rect, svg line, svg polyline, svg polygon")) {
139
+ const s = getComputedStyle(shape);
140
+ for (const [kind, value] of [["fill", s.fill], ["stroke", s.stroke]]) {
141
+ const color = value && value !== "none" ? rgba(value) : null;
142
+ if (color && color[3] > 0 && visibleBox(shape) && inside.color) graphics.push({ kind, color: over(color, inside.color) });
143
+ }
144
+ }
145
+ return {
146
+ outside: outside.color ?? null,
147
+ undetermined: outside.reason ?? inside.reason ?? null,
148
+ parts,
149
+ graphics: graphics.slice(0, 12),
150
+ hasText: (el.innerText ?? el.textContent ?? "").trim().length > 0 || (el.matches("input, textarea, select") && "value" in el && String(el.value).trim().length > 0),
151
+ inputLike: el.matches("input:not([type=button]):not([type=submit]):not([type=reset]):not([type=checkbox]):not([type=radio]), textarea, select") || ["textbox", "combobox", "searchbox", "listbox"].includes(el.getAttribute("role") ?? ""),
152
+ box: (() => { const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, width: b.width, height: b.height }; })(),
153
+ };
154
+ },
155
+ /** The styles that draw a focus indicator on the focused element itself, with the colors they sit against. */
156
+ focusStyles(atRest) {
157
+ const el = atRest ? trigger() : deepActive();
158
+ if (!el) return null;
159
+ const style = getComputedStyle(el);
160
+ const outside = backdrop(el, false);
161
+ const inside = backdrop(el, true);
162
+ const side = ["Top", "Right", "Bottom", "Left"].find((name) => parseFloat(style[`border${name}Width`]) > 0 && style[`border${name}Style`] !== "none");
163
+ const border = side ? rgba(style[`border${side}Color`]) : null;
164
+ return {
165
+ outside: outside.color ?? null,
166
+ inside: inside.color ?? null,
167
+ undetermined: outside.reason ?? inside.reason ?? null,
168
+ outline: style.outlineStyle !== "none" && parseFloat(style.outlineWidth) > 0 ? { width: parseFloat(style.outlineWidth), offset: parseFloat(style.outlineOffset) || 0, color: rgba(style.outlineColor) } : null,
169
+ shadows: style.boxShadow === "none" ? [] : splitShadows(style.boxShadow).map(parseShadow),
170
+ border: border ? { width: parseFloat(style[`border${side}Width`]), color: border } : null,
171
+ box: (() => { const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, width: b.width, height: b.height }; })(),
172
+ };
173
+ },
174
+ /**
175
+ * Compare two screenshots (as data URLs) pixel by pixel. For each pixel that changed, find the contrast between
176
+ * its two colors. Used when a focus indicator isn't a plain outline or ring, such as a ripple or a background change.
177
+ */
178
+ async compareShots(before, after) {
179
+ const load = async (url) => {
180
+ const bitmap = await createImageBitmap(await (await fetch(url)).blob());
181
+ const c = document.createElement("canvas");
182
+ c.width = bitmap.width;
183
+ c.height = bitmap.height;
184
+ const ctx = c.getContext("2d", { willReadFrequently: true });
185
+ ctx.drawImage(bitmap, 0, 0);
186
+ return ctx.getImageData(0, 0, c.width, c.height);
187
+ };
188
+ const [a, b] = [await load(before), await load(after)];
189
+ const lin = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
190
+ const lum = (d, i) => 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]);
191
+ let changed = 0;
192
+ let strong = 0;
193
+ let max = 1;
194
+ for (let i = 0; i < a.data.length; i += 4) {
195
+ if (a.data[i] === b.data[i] && a.data[i + 1] === b.data[i + 1] && a.data[i + 2] === b.data[i + 2]) continue;
196
+ changed += 1;
197
+ const [hi, lo] = [lum(a.data, i), lum(b.data, i)].sort((x, y) => y - x);
198
+ const ratio = (hi + 0.05) / (lo + 0.05);
199
+ if (ratio > max) max = ratio;
200
+ if (ratio >= 3) strong += 1;
201
+ }
202
+ return { changed, strong, max, width: a.width, height: a.height };
203
+ },
204
+ };
205
+ }
@@ -5,6 +5,8 @@
5
5
  * Checks only use the two hooks (`data-a11y-trigger`, `data-a11y-root`) and ARIA roles.
6
6
  */
7
7
 
8
+ import { focusIndicatorChanges } from "./focus-indicator.js";
9
+
8
10
  const pass = (detail, extra = {}) => ({ result: "pass", detail, ...extra });
9
11
  const fail = (detail, extra = {}) => ({ result: "fail", detail, ...extra });
10
12
  const na = (detail) => ({ result: "not-applicable", detail });
@@ -28,15 +30,15 @@ const COMMON = [
28
30
  criteria: ["2.4.7"],
29
31
  async run(ctx) {
30
32
  if ((await ctx.tabToTrigger()) === null) return na("The trigger can't be reached with Tab, so its focus indicator wasn't checked.");
31
- const focused = await ctx.page.evaluate(() => window.__a11y.focusStyle());
33
+ const focused = await ctx.page.evaluate(() => window.__a11y.focusSnapshot());
32
34
  await ctx.page.evaluate(() => window.__a11y.remember());
33
35
  const clip = ctx.clipAround(focused.box);
34
36
  const shotFocused = await ctx.page.screenshot({ clip });
35
37
  await ctx.page.evaluate(() => /** @type {any} */ (window).__a11yLast?.blur());
36
38
  await ctx.settle();
37
- const unfocused = await ctx.page.evaluate(() => window.__a11y.lastStyle());
38
- const changed = Object.keys(focused.style).filter((key) => focused.style[key] !== unfocused?.[key]);
39
- if (changed.length) return pass(`Computed style changed on focus: ${changed.join(", ")}.`, { method: "computed-style" });
39
+ const unfocused = await ctx.page.evaluate(() => window.__a11y.lastSnapshot());
40
+ const changed = focusIndicatorChanges(focused.parts, unfocused?.parts);
41
+ if (changed.length) return pass(`Computed style changed on focus: ${changed.join("; ")}.`, { method: "computed-style" });
40
42
  const shotBlurred = await ctx.page.screenshot({ clip });
41
43
  if (!shotFocused.equals(shotBlurred)) return pass("The pixels around the trigger changed on focus, though no style property did.", { method: "screenshot" });
42
44
  return fail("Nothing visible changed when the trigger got keyboard focus. Checked computed styles first, then a screenshot comparison.", { method: "screenshot" });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Compare the resolved styles of a control with and without keyboard focus, and say which changes can show a focus indicator.
3
+ * A change counts only if a person could see it: an outline with a width and a color, a box shadow, a border, a color change,
4
+ * a text decoration, or an element that appeared inside. An outline offset alone doesn't count, because it moves nothing visible.
5
+ * @param {Record<string, Record<string, any>>} focused Parts of the focused control, keyed by where they are.
6
+ * @param {Record<string, Record<string, any>> | undefined} unfocused The same parts without focus.
7
+ * @returns {string[]} Each change, such as `the element: outline`.
8
+ */
9
+ export function focusIndicatorChanges(focused, unfocused) {
10
+ const changes = [];
11
+ for (const [where, now] of Object.entries(focused)) {
12
+ const before = unfocused?.[where];
13
+ if (!before) {
14
+ if (now.rendered) changes.push(`${where}: appeared on focus`);
15
+ continue;
16
+ }
17
+ // A ring that stays in the page but only shows on focus, such as a ripple, goes from not rendered to rendered.
18
+ if (now.rendered && !before.rendered) {
19
+ changes.push(`${where}: appeared on focus`);
20
+ continue;
21
+ }
22
+ const found = [];
23
+ const drawnOutline = (s) => s.outlineStyle !== "none" && parseFloat(s.outlineWidth) > 0 && !transparent(s.outlineColor);
24
+ if (drawnOutline(now) && (!drawnOutline(before) || now.outlineWidth !== before.outlineWidth || now.outlineColor !== before.outlineColor || now.outlineStyle !== before.outlineStyle)) found.push("outline");
25
+ if (now.boxShadow !== before.boxShadow) found.push("box-shadow");
26
+ const drawnBorder = (s) => s.borderTopStyle !== "none" && parseFloat(s.borderTopWidth) > 0;
27
+ if (drawnBorder(now) && (now.borderTopColor !== before.borderTopColor || now.borderTopWidth !== before.borderTopWidth || !drawnBorder(before))) found.push("border");
28
+ if (now.backgroundColor !== before.backgroundColor) found.push("background color");
29
+ if (now.color !== before.color) found.push("text color");
30
+ if (now.textDecorationLine !== before.textDecorationLine) found.push("text decoration");
31
+ if (!now.rendered && !before.rendered) continue;
32
+ for (const what of found) changes.push(`${where}: ${what}`);
33
+ }
34
+ return changes;
35
+ }
36
+
37
+ function transparent(color) {
38
+ return color === "transparent" || /^rgba\(.*,\s*0\)$/.test(color) || /\/\s*0\)$/.test(color);
39
+ }
@@ -12,6 +12,28 @@ export function installHelpers() {
12
12
  return uids.get(el);
13
13
  };
14
14
 
15
+ const FOCUS_PROPS = ["outlineStyle", "outlineWidth", "outlineColor", "boxShadow", "borderTopStyle", "borderTopColor", "borderTopWidth", "backgroundColor", "color", "textDecorationLine"];
16
+ const snapshotOf = (el) => {
17
+ if (!el) return null;
18
+ const read = (node, pseudo) => {
19
+ const s = getComputedStyle(node, pseudo);
20
+ if (pseudo && (s.content === "none" || s.content === "normal")) return null;
21
+ const box = node.getBoundingClientRect();
22
+ return { ...Object.fromEntries(FOCUS_PROPS.map((key) => [key, s[key]])), rendered: s.display !== "none" && s.visibility !== "hidden" && Number(s.opacity) > 0 && box.width > 0 && box.height > 0 };
23
+ };
24
+ const parts = {};
25
+ const add = (key, node, pseudo) => {
26
+ const part = read(node, pseudo);
27
+ if (part) parts[key] = part;
28
+ };
29
+ add("the element", el);
30
+ add("its ::before", el, "::before");
31
+ add("its ::after", el, "::after");
32
+ [...el.querySelectorAll("*")].slice(0, 30).forEach((child, index) => add(`inner element ${index + 1} (${child.tagName.toLowerCase()})`, child));
33
+ const box = el.getBoundingClientRect();
34
+ return { parts, box: { x: box.x, y: box.y, width: box.width, height: box.height } };
35
+ };
36
+
15
37
  /** All matches in the document and in every open shadow root. */
16
38
  const queryAllDeep = (selector, root = /** @type {Document | ShadowRoot} */ (document)) => {
17
39
  const found = [...root.querySelectorAll(selector)];
@@ -116,26 +138,19 @@ export function installHelpers() {
116
138
  const live = [...root.querySelectorAll('[role="alert"], [aria-live="assertive"], [aria-live="polite"], [role="status"]')].map(text).filter(Boolean);
117
139
  return { invalid: control.getAttribute("aria-invalid") === "true" || control.matches(":invalid"), ariaInvalid: control.getAttribute("aria-invalid") === "true", linked, live };
118
140
  },
119
- /** Computed style properties that can show a focus indicator. */
120
- focusStyle() {
121
- const el = deepActive();
122
- if (!el) return null;
123
- const s = getComputedStyle(el);
124
- const pick = ["outlineStyle", "outlineWidth", "outlineColor", "outlineOffset", "boxShadow", "borderTopColor", "borderTopWidth", "backgroundColor", "color", "textDecorationLine"];
125
- const style = Object.fromEntries(pick.map((key) => [key, s[key]]));
126
- const box = el.getBoundingClientRect();
127
- return { style, box: { x: box.x, y: box.y, width: box.width, height: box.height } };
141
+ /**
142
+ * The resolved styles that can show a focus indicator, for the focused element, its ::before and ::after,
143
+ * and the elements inside it (a library may draw its ring on a child, such as a ripple).
144
+ */
145
+ focusSnapshot() {
146
+ return snapshotOf(deepActive());
128
147
  },
129
- /** Remember the focused element, so its style can be read after it loses focus. */
148
+ /** Remember the focused element, so its snapshot can be taken after it loses focus. */
130
149
  remember() {
131
150
  window.__a11yLast = deepActive();
132
151
  },
133
- lastStyle() {
134
- const el = window.__a11yLast;
135
- if (!el) return null;
136
- const s = getComputedStyle(el);
137
- const pick = ["outlineStyle", "outlineWidth", "outlineColor", "outlineOffset", "boxShadow", "borderTopColor", "borderTopWidth", "backgroundColor", "color", "textDecorationLine"];
138
- return Object.fromEntries(pick.map((key) => [key, s[key]]));
152
+ lastSnapshot() {
153
+ return snapshotOf(window.__a11yLast);
139
154
  },
140
155
  inputValue() {
141
156
  const el = queryDeep("[data-a11y-trigger]");
@@ -7,7 +7,7 @@ const CHECK_TIMEOUT_MS = 25_000;
7
7
  const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
8
8
 
9
9
  /** Everything a check can do, bound to one fresh page. */
10
- function makeContext(page) {
10
+ export function makeContext(page) {
11
11
  const ctx = {
12
12
  page,
13
13
  trigger: page.locator("[data-a11y-trigger]").first(),
@@ -66,11 +66,11 @@ function makeContext(page) {
66
66
  }
67
67
 
68
68
  /** Run one check on a fresh page, so no check inherits another's state. */
69
- export async function runCheck(browser, url, check, { timeoutMs = CHECK_TIMEOUT_MS } = {}) {
69
+ export async function runCheck(browser, url, check, { timeoutMs = CHECK_TIMEOUT_MS, kits = [installHelpers] } = {}) {
70
70
  /** @type {Awaited<ReturnType<typeof openPage>> | null} */
71
71
  let opened = null;
72
72
  try {
73
- opened = await openPage(browser, url, { beforeGoto: (page) => page.addInitScript(installHelpers), waitUntil: "load" });
73
+ opened = await openPage(browser, url, { beforeGoto: async (page) => { for (const kit of kits) await page.addInitScript(kit); }, waitUntil: "load" });
74
74
  const ctx = makeContext(opened.page);
75
75
  await ctx.trigger.waitFor({ state: "attached", timeout: 3000 });
76
76
  const outcome = await Promise.race([
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
+ import { criterionFromDigits } from "../../wcag/index.js";
3
4
 
4
5
  const require = createRequire(import.meta.url);
5
6
  let axeSource = null;
@@ -27,10 +28,10 @@ export function axeTags(wcag, level) {
27
28
  return tags;
28
29
  }
29
30
 
30
- /** `wcag143` becomes `1.4.3`. Returns null for tags that aren't success criteria. */
31
+ /** `wcag143` becomes `1.4.3`, using the W3C's list of criteria. Returns null for tags that aren't success criteria. */
31
32
  function criterionFromTag(tag) {
32
- const match = /^wcag(\d)(\d)(\d{1,2})$/.exec(tag);
33
- return match ? `${match[1]}.${match[2]}.${match[3]}` : null;
33
+ const match = /^wcag(\d{3,4})$/.exec(tag);
34
+ return match ? (criterionFromDigits(match[1])?.num ?? null) : null;
34
35
  }
35
36
 
36
37
  /** An axe target can be nested for iframes and shadow roots. Flatten it to one readable selector. */
@@ -0,0 +1,83 @@
1
+ /**
2
+ * WCAG success criteria, read from the W3C's own published JSON (src/data/wcag-2.2.json, unmodified).
3
+ * Criterion numbers, names, levels, and versions all come from that file. Nothing here is typed in by hand.
4
+ * The links are built from each criterion's `id` and are ours, not the W3C's data.
5
+ *
6
+ * Source: Web Content Accessibility Guidelines (WCAG) 2.2, https://www.w3.org/TR/WCAG22/
7
+ * Terms of use: https://github.com/w3c/wcag/blob/main/11ty/json/README.md
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+
11
+ const DATA = new URL("../data/wcag-2.2.json", import.meta.url);
12
+ const SOURCE = new URL("../data/wcag-2.2.source.json", import.meta.url);
13
+
14
+ /** @typedef {{ num: string, id: string, handle: string, level: "A" | "AA" | "AAA", versions: string[], url: string, understandingUrl: string }} Criterion */
15
+
16
+ /** @type {Map<string, Criterion> | null} */
17
+ let index = null;
18
+
19
+ function load() {
20
+ if (index) return index;
21
+ const data = JSON.parse(readFileSync(DATA, "utf8"));
22
+ index = new Map();
23
+ for (const principle of data.principles) {
24
+ for (const guideline of principle.guidelines) {
25
+ for (const sc of guideline.successcriteria) {
26
+ index.set(sc.num, {
27
+ num: sc.num,
28
+ id: sc.id,
29
+ handle: sc.handle,
30
+ level: sc.level,
31
+ versions: sc.versions,
32
+ url: `https://www.w3.org/TR/WCAG22/#${sc.id}`,
33
+ understandingUrl: `https://www.w3.org/WAI/WCAG22/Understanding/${sc.id}`,
34
+ });
35
+ }
36
+ }
37
+ }
38
+ return index;
39
+ }
40
+
41
+ /** The criterion with this number, such as `1.4.3`, or null when WCAG 2.2 has no such criterion. */
42
+ export function criterion(num) {
43
+ return load().get(num) ?? null;
44
+ }
45
+
46
+ /** The criterion for a number written without dots, the way axe-core tags it: `1410` is 1.4.10. Null when none matches. */
47
+ export function criterionFromDigits(digits) {
48
+ for (const c of load().values()) if (c.num.replaceAll(".", "") === digits) return c;
49
+ return null;
50
+ }
51
+
52
+ /** Every criterion, in the W3C's order. */
53
+ export function allCriteria() {
54
+ return [...load().values()];
55
+ }
56
+
57
+ /** True when the criterion is part of this WCAG version (`2.0`, `2.1`, or `2.2`). */
58
+ export function inVersion(num, version) {
59
+ return criterion(num)?.versions.includes(version) ?? false;
60
+ }
61
+
62
+ /** "1.4.3 Contrast (Minimum)", or just the number when the W3C data doesn't know it. */
63
+ export function criterionName(num) {
64
+ const c = criterion(num);
65
+ return c ? `${c.num} ${c.handle}` : num;
66
+ }
67
+
68
+ /** "WCAG 2.4.13 Focus Appearance (level AAA)" for use in a sentence. */
69
+ export function criterionRef(num) {
70
+ const c = criterion(num);
71
+ return c ? `WCAG ${c.num} ${c.handle} (level ${c.level})` : `WCAG ${num}`;
72
+ }
73
+
74
+ /** Where the data came from and when it was downloaded, for the report's attribution line. */
75
+ export function wcagSource() {
76
+ return JSON.parse(readFileSync(SOURCE, "utf8"));
77
+ }
78
+
79
+ /** The sentence a report prints wherever it shows criterion names. */
80
+ export function wcagAttribution() {
81
+ const { retrieved } = wcagSource();
82
+ return `Criterion names and levels come from the W3C's [WCAG 2.2 JSON](https://www.w3.org/WAI/WCAG22/wcag.json), retrieved ${retrieved}. Source: [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/), W3C. Links to each criterion are added by automatica11y.`;
83
+ }