automatica11y 0.3.2 → 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 (53) hide show
  1. package/README.md +35 -8
  2. package/package.json +6 -3
  3. package/skills/automatica11y-runner/SKILL.md +47 -13
  4. package/skills/automatica11y-runner/references/fixtures.md +38 -2
  5. package/src/commands/common.js +5 -2
  6. package/src/data/README.md +19 -0
  7. package/src/data/wcag-2.2.json +7557 -0
  8. package/src/data/wcag-2.2.source.json +6 -0
  9. package/src/frameworks/index.js +42 -0
  10. package/src/frameworks/react.js +77 -0
  11. package/src/frameworks/vue.js +90 -0
  12. package/src/frameworks/wc.js +55 -0
  13. package/src/globals.d.ts +2 -0
  14. package/src/harness/bundle.js +23 -8
  15. package/src/harness/generate/dialects.js +64 -0
  16. package/src/harness/generate/index.js +12 -0
  17. package/src/harness/generate/jsx-recipes.js +224 -0
  18. package/src/harness/generate/jsx.js +76 -0
  19. package/src/harness/generate/kit.js +50 -0
  20. package/src/harness/generate/marking.js +64 -0
  21. package/src/harness/generate/probe.js +97 -0
  22. package/src/harness/generate/shared.js +12 -0
  23. package/src/harness/generate/wc-recipes.js +132 -0
  24. package/src/harness/npm-install.js +85 -5
  25. package/src/harness/settle.js +17 -0
  26. package/src/harness/storybook.js +1 -0
  27. package/src/harness/url.js +13 -3
  28. package/src/plan/classify.js +11 -4
  29. package/src/plan/mapping.js +11 -7
  30. package/src/plan/resolve-npm.js +15 -9
  31. package/src/plan/subpath.js +133 -0
  32. package/src/report/comparison.js +21 -6
  33. package/src/report/parts.js +60 -8
  34. package/src/run/audit-npm.js +137 -29
  35. package/src/run/generate-fixture.js +74 -0
  36. package/src/run/run-plan.js +24 -4
  37. package/src/run/summary.js +10 -0
  38. package/src/schema.js +34 -7
  39. package/src/tiers/computed/checks.js +197 -0
  40. package/src/tiers/computed/color.js +48 -0
  41. package/src/tiers/computed/index.js +25 -0
  42. package/src/tiers/computed/measure-kit.js +225 -0
  43. package/src/tiers/conditions/checks.js +283 -0
  44. package/src/tiers/conditions/index.js +30 -0
  45. package/src/tiers/conditions/kit.js +133 -0
  46. package/src/tiers/interactions/archetypes.js +117 -4
  47. package/src/tiers/interactions/focus-indicator.js +39 -0
  48. package/src/tiers/interactions/helpers.js +87 -16
  49. package/src/tiers/interactions/index.js +17 -4
  50. package/src/tiers/rules/axe.js +4 -3
  51. package/src/wcag/index.js +83 -0
  52. package/src/harness/npm-react.js +0 -39
  53. package/src/harness/npm-wc.js +0 -30
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The computed checks. Each reads resolved styles (and, for one case, pixels) from a real browser and compares
3
+ * them with the numbers WCAG gives. A check returns pass, fail, undetermined, or not-applicable with a detail.
4
+ * `undetermined` means the page uses something a color can't be reduced to (a gradient, an image, transparency).
5
+ * It's a gap and never a pass.
6
+ * These are automatica11y's own measurements. They're reported on their own and never added to axe-core or IBM counts.
7
+ */
8
+ import { num } from "../../text.js";
9
+ import { criterionRef } from "../../wcag/index.js";
10
+ import { contrastOver, contrastRatio, formatRatio, ringIsEnough, textThreshold } from "./color.js";
11
+
12
+ const pass = (detail, extra = {}) => ({ result: "pass", detail, ...extra });
13
+ const fail = (detail, extra = {}) => ({ result: "fail", detail, ...extra });
14
+ const na = (detail) => ({ result: "not-applicable", detail });
15
+ const undetermined = (detail, extra = {}) => ({ result: "undetermined", detail, ...extra });
16
+
17
+ /** Time for CSS transitions to finish before a state is measured. */
18
+ const SETTLE_MS = 400;
19
+
20
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
21
+ const floor2 = (n) => Math.floor(n * 100) / 100;
22
+
23
+ /** Move the pointer away and let any hover styles end. */
24
+ async function parkPointer(ctx) {
25
+ await ctx.page.mouse.move(0, 0);
26
+ await ctx.settle(SETTLE_MS);
27
+ }
28
+
29
+ /** Text contrast in each state a person can put the control in. */
30
+ const TEXT_CONTRAST = {
31
+ name: "text-contrast-by-state",
32
+ criteria: ["1.4.3"],
33
+ async run(ctx) {
34
+ /** @type {Array<{ state: string, parts: any[] }>} */
35
+ const states = [];
36
+ const measure = async (state) => states.push({ state, parts: (await ctx.page.evaluate(() => window.__a11yMeasure.text())) ?? [] });
37
+ await parkPointer(ctx);
38
+ await measure("rest");
39
+ if (states[0].parts.length === 0) return na("The trigger has no visible text, so its text contrast wasn't measured.");
40
+ await ctx.trigger.hover();
41
+ await ctx.settle(SETTLE_MS);
42
+ await measure("hover");
43
+ await parkPointer(ctx);
44
+ const reached = await ctx.tabToTrigger();
45
+ await ctx.settle(SETTLE_MS);
46
+ if (reached !== null) await measure("keyboard focus");
47
+ await ctx.page.evaluate(() => /** @type {HTMLElement} */ (document.activeElement)?.blur?.());
48
+ await ctx.trigger.hover();
49
+ await ctx.page.mouse.down();
50
+ await ctx.settle(SETTLE_MS);
51
+ await measure("pressed");
52
+ await ctx.page.mouse.up().catch(() => {});
53
+
54
+ const rows = [];
55
+ const unknown = [];
56
+ for (const { state, parts } of states) {
57
+ for (const part of parts) {
58
+ if (part.undetermined || !part.backdrop) {
59
+ unknown.push(`In ${state}, "${part.text}" can't be measured because ${part.undetermined ?? "its background couldn't be read"}.`);
60
+ continue;
61
+ }
62
+ rows.push({ state, text: part.text, ratio: contrastOver(part.color, part.backdrop), required: textThreshold(part.size, part.weight) });
63
+ }
64
+ }
65
+ const measurements = rows.map((r) => ({ state: r.state, text: r.text, ratio: floor2(r.ratio), required: r.required }));
66
+ const failed = rows.filter((r) => r.ratio < r.required).sort((a, b) => a.ratio / a.required - b.ratio / b.required);
67
+ const list = `Measured in ${states.map((s) => s.state).join(", ")}.`;
68
+ const skipped = reached === null ? " The trigger couldn't be reached with Tab, so keyboard focus wasn't measured." : "";
69
+ if (failed.length) {
70
+ const f = failed[0];
71
+ const more = failed.length > 1 ? ` ${num(failed.length - 1)} more measurement${failed.length > 2 ? "s are" : " is"} below its threshold.` : "";
72
+ return fail(`In ${f.state}, "${f.text}" has ${formatRatio(f.ratio)} against its background and needs ${f.required}:1.${more} ${list}${skipped}`, { measurements });
73
+ }
74
+ if (unknown.length || reached === null) {
75
+ const low = rows.length ? `The lowest measured is ${formatRatio(Math.min(...rows.map((r) => r.ratio)))}. ` : "";
76
+ return undetermined(`${low}${unknown[0] ?? ""} ${list}${skipped}`.replace(/\s+/g, " ").trim(), { measurements });
77
+ }
78
+ const lowest = rows.reduce((a, b) => (b.ratio / b.required < a.ratio / a.required ? b : a));
79
+ return pass(`The lowest is ${formatRatio(lowest.ratio)} in ${lowest.state} (needs ${lowest.required}:1). ${list}`, { measurements });
80
+ },
81
+ };
82
+
83
+ /** The edge, fill, or icon that tells a person where a control is. */
84
+ const BOUNDARY_CONTRAST = {
85
+ name: "boundary-contrast",
86
+ criteria: ["1.4.11"],
87
+ async run(ctx) {
88
+ await parkPointer(ctx);
89
+ const b = await ctx.page.evaluate(() => window.__a11yMeasure.boundary());
90
+ if (!b) return na("There's no trigger to measure.");
91
+ if (b.undetermined || !b.outside) return undetermined(`The control's edge can't be measured because ${b.undetermined ?? "its surroundings couldn't be read"}.`);
92
+ const fill = b.parts.find((p) => p.kind === "fill")?.color ?? b.outside;
93
+ const marks = [
94
+ ...b.parts.map((p) => ({ label: p.kind, ratio: contrastRatio(p.color, b.outside) })),
95
+ ...b.graphics.map((g) => ({ label: `icon ${g.kind}`, ratio: contrastRatio(g.color, fill) })),
96
+ ];
97
+ if (marks.length === 0) return undetermined("No border, fill, or icon was found to measure.");
98
+ const best = marks.reduce((a, c) => (c.ratio > a.ratio ? c : a));
99
+ const measurements = marks.map((m) => ({ part: m.label, ratio: floor2(m.ratio) }));
100
+ const text = `The strongest mark is its ${best.label}, at ${formatRatio(best.ratio)} (needs 3:1).`;
101
+ if (b.inputLike) return best.ratio >= 3 ? pass(`${text} A field's edge identifies it.`, { measurements }) : fail(`${text} A field has no label inside it, so its edge is what identifies it.`, { measurements });
102
+ if (b.hasText) {
103
+ return best.ratio >= 3
104
+ ? pass(text, { measurements })
105
+ : { ...na(`${text} The control has visible text that identifies it, so ${criterionRef("1.4.11")} doesn't require its edge to reach 3:1. A person should confirm the text is enough.`), measurements };
106
+ }
107
+ return best.ratio >= 3 ? pass(`${text} The control has no text, so its icon or edge identifies it.`, { measurements }) : fail(`${text} The control has no text, so its icon or edge has to identify it.`, { measurements });
108
+ },
109
+ };
110
+
111
+ /** Does the focus indicator stand out from what's around it? */
112
+ const FOCUS_CONTRAST = {
113
+ name: "focus-indicator-contrast",
114
+ criteria: ["1.4.11", "2.4.13"],
115
+ async run(ctx) {
116
+ await parkPointer(ctx);
117
+ const rest = await ctx.page.evaluate(() => window.__a11yMeasure.focusStyles(true));
118
+ if (!rest) return na("There's no trigger to measure.");
119
+ const clip = ctx.clipAround(rest.box, 12);
120
+ const shotRest = await ctx.page.screenshot({ clip });
121
+ if ((await ctx.tabToTrigger()) === null) return na("The trigger can't be reached with Tab, so its focus indicator wasn't measured.");
122
+ await ctx.settle(SETTLE_MS);
123
+ const now = await ctx.page.evaluate(() => window.__a11yMeasure.focusStyles(false));
124
+
125
+ // First choice: an outline, ring, or border on the control itself. Its colors and thickness are exact.
126
+ if (now && !now.undetermined && now.outside && now.inside) {
127
+ const found = [];
128
+ if (now.outline && !same(now.outline, rest.outline)) {
129
+ const against = now.outline.offset >= 0 ? now.outside : now.inside;
130
+ found.push({ label: "outline", ratio: contrastOver(now.outline.color, against), width: now.outline.width });
131
+ }
132
+ for (const shadow of now.shadows) {
133
+ if (shadow.inset || shadow.blur > 0 || !shadow.color || shadow.color[3] === 0 || (shadow.spread <= 0 && shadow.x === 0 && shadow.y === 0)) continue;
134
+ if (rest.shadows.some((r) => same(r, shadow))) continue;
135
+ found.push({ label: "box-shadow ring", ratio: contrastOver(shadow.color, now.outside), width: Math.max(shadow.spread, Math.abs(shadow.x), Math.abs(shadow.y)) });
136
+ }
137
+ if (now.border && !same(now.border, rest.border)) found.push({ label: "border", ratio: contrastOver(now.border.color, now.outside), width: now.border.width });
138
+ if (found.length) {
139
+ const best = found.reduce((a, c) => (c.ratio > a.ratio ? c : a));
140
+ const thin = best.width < 2 ? ` It's ${best.width}px thick, and ${criterionRef("2.4.13")} asks for at least 2px.` : "";
141
+ const detail = `The ${best.label} has ${formatRatio(best.ratio)} against what's next to it (needs 3:1).${thin}`;
142
+ const measurements = found.map((f) => ({ indicator: f.label, ratio: floor2(f.ratio), widthPx: f.width }));
143
+ return best.ratio >= 3 ? pass(detail, { method: "computed-style", measurements }) : fail(detail, { method: "computed-style", measurements });
144
+ }
145
+ }
146
+
147
+ // Otherwise the indicator is something styles can't summarize (a ripple, a background change, a blurred glow), so compare pixels.
148
+ const shotNow = await ctx.page.screenshot({ clip });
149
+ const url = (buffer) => `data:image/png;base64,${buffer.toString("base64")}`;
150
+ const px = await ctx.page.evaluate(([a, b]) => window.__a11yMeasure.compareShots(a, b), [url(shotRest), url(shotNow)]);
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.");
152
+ const perimeter = 2 * (rest.box.width + rest.box.height);
153
+ const measurements = [{ changedPixels: px.changed, pixelsAtLeast3to1: px.strong, strongestChange: floor2(px.max), perimeterPixels: Math.round(perimeter) }];
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 });
156
+ },
157
+ };
158
+
159
+ /**
160
+ * For a live region, the trigger is only a button that makes the message appear, so the message is what gets measured:
161
+ * the text that appears after the trigger is activated, in its resting state.
162
+ */
163
+ const MESSAGE_TEXT_CONTRAST = {
164
+ name: "message-text-contrast",
165
+ criteria: ["1.4.3"],
166
+ async run(ctx) {
167
+ await parkPointer(ctx);
168
+ const before = (await ctx.page.evaluate(() => window.__a11yMeasure.text("page"))) ?? [];
169
+ await ctx.focus();
170
+ await ctx.press("Enter");
171
+ await ctx.settle(SETTLE_MS);
172
+ const after = (await ctx.page.evaluate(() => window.__a11yMeasure.text("page"))) ?? [];
173
+ const known = new Set(before.map((p) => p.key));
174
+ const fresh = after.filter((p) => !known.has(p.key));
175
+ if (fresh.length === 0) return na("No new visible text appeared when the trigger was activated, so there's no message text to measure.");
176
+ const rows = [];
177
+ const unknown = [];
178
+ for (const part of fresh) {
179
+ if (part.undetermined || !part.backdrop) unknown.push(`"${part.text}" can't be measured because ${part.undetermined ?? "its background couldn't be read"}.`);
180
+ else rows.push({ text: part.text, ratio: contrastOver(part.color, part.backdrop), required: textThreshold(part.size, part.weight) });
181
+ }
182
+ const measurements = rows.map((r) => ({ text: r.text, ratio: floor2(r.ratio), required: r.required }));
183
+ const failed = rows.filter((r) => r.ratio < r.required).sort((a, b) => a.ratio / a.required - b.ratio / b.required);
184
+ if (failed.length) return fail(`The message text "${failed[0].text}" has ${formatRatio(failed[0].ratio)} against its background and needs ${failed[0].required}:1.`, { measurements });
185
+ if (unknown.length) return undetermined(`${rows.length ? `The lowest measured is ${formatRatio(Math.min(...rows.map((r) => r.ratio)))}. ` : ""}${unknown[0]}`, { measurements });
186
+ const lowest = rows.reduce((a, b) => (b.ratio / b.required < a.ratio / a.required ? b : a));
187
+ return pass(`The message text "${lowest.text}" has ${formatRatio(lowest.ratio)} against its background (needs ${lowest.required}:1).`, { measurements });
188
+ },
189
+ };
190
+
191
+ /** The checks that apply to an archetype. */
192
+ export function computedChecksFor(archetype) {
193
+ return archetype === "live-region" ? [MESSAGE_TEXT_CONTRAST] : [TEXT_CONTRAST, BOUNDARY_CONTRAST, FOCUS_CONTRAST];
194
+ }
195
+
196
+ /** Every computed check, for tests and docs. */
197
+ export const COMPUTED_CHECKS = [TEXT_CONTRAST, BOUNDARY_CONTRAST, FOCUS_CONTRAST, MESSAGE_TEXT_CONTRAST];
@@ -0,0 +1,48 @@
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
+ /**
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
+
45
+ /** "4.52:1". The ratio is cut down, never rounded up, so 2.999 never reads as 3. */
46
+ export function formatRatio(ratio) {
47
+ return `${(Math.floor(ratio * 100) / 100).toFixed(2).replace(/\.?0+$/, "")}:1`;
48
+ }
@@ -0,0 +1,25 @@
1
+ import { installHelpers } from "../interactions/helpers.js";
2
+ import { runCheck } from "../interactions/index.js";
3
+ import { computedChecksFor } 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 computedChecksFor(archetype)) 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,225 @@
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
+ const keys = new WeakMap();
92
+ let keyCounter = 0;
93
+
94
+ window.__a11yMeasure = {
95
+ rgba,
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";
103
+ const el = trigger();
104
+ if (!el && !wholePage) return null;
105
+ const parts = [];
106
+ const seen = new Set();
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);
114
+ const style = getComputedStyle(node);
115
+ if (style.visibility === "hidden") return;
116
+ if (!keys.has(textNode)) keys.set(textNode, (keyCounter += 1));
117
+ const behind = backdrop(node, true);
118
+ const color = rgba(style.color);
119
+ parts.push({
120
+ key: keys.get(textNode),
121
+ text: label.replace(/\s+/g, " ").trim().slice(0, 40),
122
+ color,
123
+ size: parseFloat(style.fontSize),
124
+ weight: style.fontWeight,
125
+ backdrop: behind.color ?? null,
126
+ undetermined: behind.reason ?? (color ? null : "its color couldn't be read"),
127
+ });
128
+ };
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);
139
+ }
140
+ return parts;
141
+ },
142
+ /** What the control looks like from outside: label, input, or icon, and the colors that mark its edge. */
143
+ boundary() {
144
+ const el = trigger();
145
+ if (!el) return null;
146
+ const style = getComputedStyle(el);
147
+ const outside = backdrop(el, false);
148
+ const inside = backdrop(el, true);
149
+ const parts = [];
150
+ if (outside.color && inside.color) {
151
+ parts.push({ kind: "fill", color: inside.color });
152
+ for (const side of ["Top", "Right", "Bottom", "Left"]) {
153
+ const border = parseFloat(style[`border${side}Width`]) > 0 && style[`border${side}Style`] !== "none" ? rgba(style[`border${side}Color`]) : null;
154
+ if (border && border[3] > 0) parts.push({ kind: `${side.toLowerCase()} border`, color: over(border, inside.color), width: parseFloat(style[`border${side}Width`]) });
155
+ }
156
+ }
157
+ const graphics = [];
158
+ for (const shape of el.querySelectorAll("svg path, svg circle, svg ellipse, svg rect, svg line, svg polyline, svg polygon")) {
159
+ const s = getComputedStyle(shape);
160
+ for (const [kind, value] of [["fill", s.fill], ["stroke", s.stroke]]) {
161
+ const color = value && value !== "none" ? rgba(value) : null;
162
+ if (color && color[3] > 0 && visibleBox(shape) && inside.color) graphics.push({ kind, color: over(color, inside.color) });
163
+ }
164
+ }
165
+ return {
166
+ outside: outside.color ?? null,
167
+ undetermined: outside.reason ?? inside.reason ?? null,
168
+ parts,
169
+ graphics: graphics.slice(0, 12),
170
+ hasText: (el.innerText ?? el.textContent ?? "").trim().length > 0 || (el.matches("input, textarea, select") && "value" in el && String(el.value).trim().length > 0),
171
+ 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") ?? ""),
172
+ box: (() => { const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, width: b.width, height: b.height }; })(),
173
+ };
174
+ },
175
+ /** The styles that draw a focus indicator on the focused element itself, with the colors they sit against. */
176
+ focusStyles(atRest) {
177
+ const el = atRest ? trigger() : deepActive();
178
+ if (!el) return null;
179
+ const style = getComputedStyle(el);
180
+ const outside = backdrop(el, false);
181
+ const inside = backdrop(el, true);
182
+ const side = ["Top", "Right", "Bottom", "Left"].find((name) => parseFloat(style[`border${name}Width`]) > 0 && style[`border${name}Style`] !== "none");
183
+ const border = side ? rgba(style[`border${side}Color`]) : null;
184
+ return {
185
+ outside: outside.color ?? null,
186
+ inside: inside.color ?? null,
187
+ undetermined: outside.reason ?? inside.reason ?? null,
188
+ outline: style.outlineStyle !== "none" && parseFloat(style.outlineWidth) > 0 ? { width: parseFloat(style.outlineWidth), offset: parseFloat(style.outlineOffset) || 0, color: rgba(style.outlineColor) } : null,
189
+ shadows: style.boxShadow === "none" ? [] : splitShadows(style.boxShadow).map(parseShadow),
190
+ border: border ? { width: parseFloat(style[`border${side}Width`]), color: border } : null,
191
+ box: (() => { const b = el.getBoundingClientRect(); return { x: b.x, y: b.y, width: b.width, height: b.height }; })(),
192
+ };
193
+ },
194
+ /**
195
+ * Compare two screenshots (as data URLs) pixel by pixel. For each pixel that changed, find the contrast between
196
+ * its two colors. Used when a focus indicator isn't a plain outline or ring, such as a ripple or a background change.
197
+ */
198
+ async compareShots(before, after) {
199
+ const load = async (url) => {
200
+ const bitmap = await createImageBitmap(await (await fetch(url)).blob());
201
+ const c = document.createElement("canvas");
202
+ c.width = bitmap.width;
203
+ c.height = bitmap.height;
204
+ const ctx = c.getContext("2d", { willReadFrequently: true });
205
+ ctx.drawImage(bitmap, 0, 0);
206
+ return ctx.getImageData(0, 0, c.width, c.height);
207
+ };
208
+ const [a, b] = [await load(before), await load(after)];
209
+ const lin = (v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
210
+ const lum = (d, i) => 0.2126 * lin(d[i]) + 0.7152 * lin(d[i + 1]) + 0.0722 * lin(d[i + 2]);
211
+ let changed = 0;
212
+ let strong = 0;
213
+ let max = 1;
214
+ for (let i = 0; i < a.data.length; i += 4) {
215
+ if (a.data[i] === b.data[i] && a.data[i + 1] === b.data[i + 1] && a.data[i + 2] === b.data[i + 2]) continue;
216
+ changed += 1;
217
+ const [hi, lo] = [lum(a.data, i), lum(b.data, i)].sort((x, y) => y - x);
218
+ const ratio = (hi + 0.05) / (lo + 0.05);
219
+ if (ratio > max) max = ratio;
220
+ if (ratio >= 3) strong += 1;
221
+ }
222
+ return { changed, strong, max, width: a.width, height: a.height };
223
+ },
224
+ };
225
+ }