a11y-loop 0.1.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 (36) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +409 -0
  3. package/THIRD-PARTY-NOTICES.md +32 -0
  4. package/package.json +51 -0
  5. package/skill/a11y-loop/SKILL.md +332 -0
  6. package/skill/a11y-loop/evals/evals.json +168 -0
  7. package/skill/a11y-loop/evals/trigger-evals.json +20 -0
  8. package/skill/a11y-loop/references/ai-failure-modes.md +272 -0
  9. package/skill/a11y-loop/references/apg-patterns.md +264 -0
  10. package/skill/a11y-loop/references/manual-testing.md +224 -0
  11. package/skill/a11y-loop/references/wcag22-quick-ref.md +224 -0
  12. package/src/cli.js +207 -0
  13. package/src/commands/audit.js +125 -0
  14. package/src/commands/contrast.js +141 -0
  15. package/src/commands/diff.js +65 -0
  16. package/src/lib/axe-runner.js +400 -0
  17. package/src/lib/browser-utils.js +221 -0
  18. package/src/lib/checks/dialog.js +341 -0
  19. package/src/lib/checks/div-button.js +87 -0
  20. package/src/lib/checks/focus-visible.js +296 -0
  21. package/src/lib/checks/keyboard.js +235 -0
  22. package/src/lib/checks/link-text.js +83 -0
  23. package/src/lib/checks/reduced-motion.js +139 -0
  24. package/src/lib/checks/reflow.js +101 -0
  25. package/src/lib/checks/target-size.js +128 -0
  26. package/src/lib/contrast-math.js +189 -0
  27. package/src/lib/diff.js +118 -0
  28. package/src/lib/finding.js +164 -0
  29. package/src/lib/fingerprint.js +0 -0
  30. package/src/lib/format/checklist.js +281 -0
  31. package/src/lib/format/human.js +175 -0
  32. package/src/lib/format/json.js +139 -0
  33. package/src/lib/format/sarif.js +111 -0
  34. package/src/lib/serve.js +189 -0
  35. package/src/lib/suggest-color.js +169 -0
  36. package/src/lib/wcag-map.js +271 -0
@@ -0,0 +1,400 @@
1
+ /**
2
+ * The audit engine: five rendering passes, own checks, interaction states.
3
+ *
4
+ * Engine choice is deliberate and documented in docs/research/tooling.md:
5
+ * axe-core via @axe-core/playwright, Chromium only, one engine done well.
6
+ * Pa11y is LGPL and pins a stale axe; Lighthouse's accessibility category is an
7
+ * axe subset behind a gameable 0–100 score.
8
+ *
9
+ * The five passes cost almost nothing and roughly double real-world contrast
10
+ * findings, because a single default-mode scan never sees dark-mode or
11
+ * forced-colors failures.
12
+ */
13
+
14
+ import { chromium } from 'playwright';
15
+ import { AxeBuilder } from '@axe-core/playwright';
16
+
17
+ import { HELPERS_SOURCE, tabbableScript } from './browser-utils.js';
18
+ import { makeFinding, dedupeFindings, SEVERITY } from './finding.js';
19
+ import { isBestPractice } from './wcag-map.js';
20
+ import { suggestColors } from './suggest-color.js';
21
+ import { resolveTarget } from './serve.js';
22
+
23
+ import { surveyKeyboard, keyboardFindings } from './checks/keyboard.js';
24
+ import { surveyFocusVisibility, focusVisibilityFindings } from './checks/focus-visible.js';
25
+ import { surveyTargets, targetSizeFindings } from './checks/target-size.js';
26
+ import { surveyAnimations, reducedMotionFindings } from './checks/reduced-motion.js';
27
+ import { surveyLinks, linkTextFindings } from './checks/link-text.js';
28
+ import { surveyReflow, reflowFindings, REFLOW_VIEWPORT } from './checks/reflow.js';
29
+ import { surveyClickableNonInteractive, divButtonFindings } from './checks/div-button.js';
30
+ import {
31
+ hasVisibleDialog,
32
+ captureDialogInitialState,
33
+ surveyDialog,
34
+ dialogFindings,
35
+ surveyRoleButtons,
36
+ roleButtonFindings,
37
+ } from './checks/dialog.js';
38
+
39
+ export const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
40
+
41
+ /**
42
+ * axe tags are NOT cumulative — every tag must be listed explicitly or coverage
43
+ * silently drops. `best-practice` is included so those rules can be reported in
44
+ * their own non-blocking bucket, and removed by --no-best-practice.
45
+ */
46
+ export const WCAG_TAGS = [
47
+ 'wcag2a',
48
+ 'wcag2aa',
49
+ 'wcag21a',
50
+ 'wcag21aa',
51
+ 'wcag22a',
52
+ 'wcag22aa',
53
+ ];
54
+
55
+ export const BEST_PRACTICE_TAG = 'best-practice';
56
+
57
+ /** The five rendering passes. */
58
+ export const PASSES = [
59
+ { name: 'default', viewport: DEFAULT_VIEWPORT, context: {} },
60
+ { name: 'dark', viewport: DEFAULT_VIEWPORT, context: { colorScheme: 'dark' } },
61
+ { name: 'forced-colors', viewport: DEFAULT_VIEWPORT, context: { forcedColors: 'active' } },
62
+ { name: 'reduced-motion', viewport: DEFAULT_VIEWPORT, context: { reducedMotion: 'reduce' } },
63
+ { name: 'reflow', viewport: REFLOW_VIEWPORT, context: {} },
64
+ ];
65
+
66
+ /** An error the CLI should report as a tool failure (exit 2), not a finding. */
67
+ export class ToolError extends Error {
68
+ constructor(message, { hint } = {}) {
69
+ super(message);
70
+ this.name = 'ToolError';
71
+ this.hint = hint ?? null;
72
+ }
73
+ }
74
+
75
+ const MISSING_BROWSER_HINT = [
76
+ 'Install the browser a11y-loop needs:',
77
+ '',
78
+ ' npx playwright install chromium',
79
+ '',
80
+ 'If you keep Playwright browsers outside the default location, set',
81
+ 'PLAYWRIGHT_BROWSERS_PATH to that directory both when installing and when running',
82
+ 'a11y-loop — a11y-loop honours it automatically when it is set. For example:',
83
+ '',
84
+ ' PLAYWRIGHT_BROWSERS_PATH=/path/to/playwright-browsers npx playwright install chromium',
85
+ ].join('\n');
86
+
87
+ /** Launch Chromium, translating a missing install into an actionable message. */
88
+ export async function launchBrowser({ headed = false } = {}) {
89
+ try {
90
+ return await chromium.launch({ headless: !headed });
91
+ } catch (error) {
92
+ const message = String(error?.message ?? '');
93
+ if (/Executable doesn't exist|Failed to launch|browserType\.launch/i.test(message)) {
94
+ throw new ToolError('Chromium is not available to Playwright.', {
95
+ hint: MISSING_BROWSER_HINT,
96
+ });
97
+ }
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ /** `"4.5:1"` → 4.5 */
103
+ function parseExpectedRatio(value, fallback = 4.5) {
104
+ const n = Number.parseFloat(String(value ?? ''));
105
+ return Number.isFinite(n) ? n : fallback;
106
+ }
107
+
108
+ /**
109
+ * Concrete colour suggestions for a contrast finding — the thing that closes the
110
+ * loop for an agent. "3.8:1, fails" is not actionable; "#6B7280 → #4B5563
111
+ * (3.8:1 → 7.1:1), or lighten the background to #F9FAFB" is.
112
+ */
113
+ export function contrastSuggestionsForNode(node) {
114
+ const data = node.any?.find((check) => check.data?.fgColor && check.data?.bgColor)?.data;
115
+ if (!data) return undefined;
116
+ const target = parseExpectedRatio(data.expectedContrastRatio);
117
+ try {
118
+ const result = suggestColors(data.fgColor, data.bgColor, { target });
119
+ return result.suggestions.length ? result.suggestions : undefined;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ const CONTRAST_RULES = new Set(['color-contrast', 'color-contrast-enhanced']);
126
+
127
+ /** Convert one axe result entry into a11y-loop findings. */
128
+ export function findingsFromAxeResult(result, { severity, passName, state }) {
129
+ return result.nodes.map((node) => {
130
+ // target-size findings are ALWAYS downgraded: axe ships the rule disabled
131
+ // because of a documented false-positive trail with overlapping and
132
+ // translucent targets.
133
+ const effectiveSeverity =
134
+ result.id === 'target-size' ? SEVERITY.NEEDS_REVIEW : severity;
135
+
136
+ return makeFinding({
137
+ ruleId: result.id,
138
+ source: 'axe',
139
+ severity: effectiveSeverity,
140
+ impact: node.impact ?? result.impact ?? null,
141
+ tags: result.tags ?? [],
142
+ selector: node.ancestry ?? node.target,
143
+ html: node.html,
144
+ message: node.failureSummary
145
+ ? `${result.help}. ${node.failureSummary.replace(/\s+/g, ' ')}`
146
+ : result.help,
147
+ helpUrl: result.helpUrl,
148
+ suggestions: CONTRAST_RULES.has(result.id) ? contrastSuggestionsForNode(node) : undefined,
149
+ passes: [passName],
150
+ state,
151
+ });
152
+ });
153
+ }
154
+
155
+ /** Split axe output into our three buckets and flatten to findings. */
156
+ export function findingsFromAxeResults(axeResults, { passName, state = null }) {
157
+ const findings = [];
158
+ for (const violation of axeResults.violations ?? []) {
159
+ const severity = isBestPractice(violation.tags ?? [])
160
+ ? SEVERITY.BEST_PRACTICE
161
+ : SEVERITY.VIOLATION;
162
+ findings.push(...findingsFromAxeResult(violation, { severity, passName, state }));
163
+ }
164
+ for (const incomplete of axeResults.incomplete ?? []) {
165
+ findings.push(
166
+ ...findingsFromAxeResult(incomplete, {
167
+ severity: SEVERITY.NEEDS_REVIEW,
168
+ passName,
169
+ state,
170
+ }),
171
+ );
172
+ }
173
+ return findings;
174
+ }
175
+
176
+ /** Count what is on the page, so the manual checklist can be specific. */
177
+ export async function surveyPageFacts(page) {
178
+ return page.evaluate(() => {
179
+ const count = (selector) => document.querySelectorAll(selector).length;
180
+ let ariaAttributes = 0;
181
+ for (const el of document.querySelectorAll('*')) {
182
+ for (const attr of el.attributes) {
183
+ if (attr.name === 'role' || attr.name.startsWith('aria-')) ariaAttributes += 1;
184
+ }
185
+ }
186
+ let animations = 0;
187
+ let stickyElements = 0;
188
+ for (const el of document.querySelectorAll('*')) {
189
+ const style = getComputedStyle(el);
190
+ if (style.animationName && style.animationName !== 'none') animations += 1;
191
+ if (style.position === 'sticky' || style.position === 'fixed') stickyElements += 1;
192
+ }
193
+ return {
194
+ images: count('img, [role="img"], svg[aria-label], svg[role="img"]'),
195
+ formFields: count('input:not([type="hidden"]), select, textarea'),
196
+ links: count('a[href], [role="link"]'),
197
+ headings: count('h1, h2, h3, h4, h5, h6, [role="heading"]'),
198
+ videos: count('video'),
199
+ audios: count('audio'),
200
+ tables: count('table'),
201
+ iframes: count('iframe'),
202
+ dialogs: count('[role="dialog"], [role="alertdialog"], dialog'),
203
+ buttons: count('button, [role="button"]'),
204
+ ariaAttributes,
205
+ animations,
206
+ stickyElements,
207
+ lang: document.documentElement.getAttribute('lang'),
208
+ title: document.title,
209
+ };
210
+ });
211
+ }
212
+
213
+ /** Prepare a page: inject helpers and tabbable, then navigate. */
214
+ async function openPage(context, url) {
215
+ const page = await context.newPage();
216
+ await page.addInitScript({ content: tabbableScript() });
217
+ await page.addInitScript({ content: HELPERS_SOURCE });
218
+ await page.goto(url, { waitUntil: 'load' });
219
+ // Let webfonts settle and any entrance animation finish, so contrast and
220
+ // geometry are measured against what a user would actually see.
221
+ await page.waitForTimeout(120);
222
+ return page;
223
+ }
224
+
225
+ function buildAxe(page, { includeBestPractice }) {
226
+ const tags = includeBestPractice ? [...WCAG_TAGS, BEST_PRACTICE_TAG] : [...WCAG_TAGS];
227
+ return new AxeBuilder({ page }).options({
228
+ runOnly: { type: 'tag', values: tags },
229
+ resultTypes: ['violations', 'incomplete'],
230
+ ancestry: true,
231
+ // Tag filtering does not resurrect a disabled rule; target-size has to be
232
+ // switched on by name. Its findings are always downgraded to needs-review.
233
+ rules: { 'target-size': { enabled: true } },
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Own checks that only make sense once, on a normally-rendered page.
239
+ *
240
+ * The dialog's initial-focus state is captured FIRST, before anything else
241
+ * runs: the keyboard and focus-visibility surveys each drive real Tab presses
242
+ * to walk the page, which moves focus around, and "was focus moved into the
243
+ * dialog on open" must answer for the moment the dialog opened — not for
244
+ * wherever those other surveys happened to leave focus afterwards. The
245
+ * interactive probes (trap, Escape, focus-return) still run last, since
246
+ * pressing Escape may close the dialog and change page state for anything
247
+ * that ran after it.
248
+ */
249
+ async function runDefaultPassChecks(page, ctx) {
250
+ const findings = [];
251
+
252
+ const dialogInitial = (await hasVisibleDialog(page))
253
+ ? await captureDialogInitialState(page, { presumedTrigger: ctx.presumedTrigger ?? null })
254
+ : null;
255
+
256
+ const keyboard = await surveyKeyboard(page);
257
+ findings.push(...keyboardFindings(keyboard, ctx));
258
+
259
+ const focus = await surveyFocusVisibility(page);
260
+ findings.push(...focusVisibilityFindings(focus, ctx));
261
+
262
+ const targets = await surveyTargets(page);
263
+ findings.push(...targetSizeFindings(targets, ctx));
264
+
265
+ const links = await surveyLinks(page);
266
+ findings.push(...linkTextFindings(links, ctx));
267
+
268
+ const clickableDivs = await surveyClickableNonInteractive(page);
269
+ findings.push(...divButtonFindings(clickableDivs, ctx));
270
+
271
+ const roleButtons = await surveyRoleButtons(page);
272
+ findings.push(...roleButtonFindings(roleButtons, ctx));
273
+
274
+ if (dialogInitial) {
275
+ const dialog = await surveyDialog(page, dialogInitial);
276
+ findings.push(...dialogFindings(dialog, ctx));
277
+ }
278
+
279
+ return findings;
280
+ }
281
+
282
+ /**
283
+ * Run the audit.
284
+ *
285
+ * @param {object} input
286
+ * @param {{type:'url'|'file'|'html', value:string}} input.target
287
+ * @param {object} [input.options]
288
+ * @param {boolean} [input.options.headed]
289
+ * @param {boolean} [input.options.bestPractice]
290
+ * @param {Record<string, (page:any) => Promise<void>>} [input.options.states]
291
+ * @returns {Promise<{findings:Array, facts:object, tool:object, incompleteRuleIds:string[]}>}
292
+ */
293
+ export async function runAudit({ target, options = {} }) {
294
+ const { headed = false, bestPractice = true, states = {} } = options;
295
+
296
+ const served = await resolveTarget(target);
297
+ const browser = await launchBrowser({ headed });
298
+ const findings = [];
299
+ const passesRun = [];
300
+ const statesRun = [];
301
+ let facts = {};
302
+ let browserVersion = 'unknown';
303
+ let userAgent = null;
304
+
305
+ try {
306
+ browserVersion = browser.version();
307
+
308
+ for (const pass of PASSES) {
309
+ const context = await browser.newContext({ viewport: pass.viewport, ...pass.context });
310
+ const page = await openPage(context, served.url);
311
+ const ctx = { passes: [pass.name], state: null };
312
+
313
+ try {
314
+ const axeResults = await buildAxe(page, { includeBestPractice: bestPractice }).analyze();
315
+ findings.push(...findingsFromAxeResults(axeResults, { passName: pass.name }));
316
+
317
+ if (pass.name === 'default') {
318
+ userAgent = await page.evaluate(() => navigator.userAgent);
319
+ facts = await surveyPageFacts(page);
320
+ findings.push(...(await runDefaultPassChecks(page, ctx)));
321
+ }
322
+
323
+ if (pass.name === 'reduced-motion') {
324
+ const animations = await surveyAnimations(page);
325
+ findings.push(...reducedMotionFindings(animations, ctx));
326
+ }
327
+
328
+ if (pass.name === 'reflow') {
329
+ const overflow = await surveyReflow(page);
330
+ findings.push(...reflowFindings(overflow, ctx));
331
+ }
332
+
333
+ passesRun.push(pass.name);
334
+ } finally {
335
+ await context.close();
336
+ }
337
+ }
338
+
339
+ // --interact states: the "state coverage" pillar. Each state gets a fresh
340
+ // page, so states never contaminate each other.
341
+ //
342
+ // Only findings the base page did NOT already have are reported for a state.
343
+ // Otherwise every pre-existing issue would be repeated once per state, and a
344
+ // page with five states would report six times its actual problems.
345
+ const baseFingerprints = new Set(findings.map((f) => f.fingerprint));
346
+
347
+ for (const [name, setup] of Object.entries(states)) {
348
+ const context = await browser.newContext({ viewport: DEFAULT_VIEWPORT });
349
+ const page = await openPage(context, served.url);
350
+ const ctx = { passes: ['default'], state: name };
351
+ try {
352
+ await setup(page);
353
+ await page.waitForTimeout(150);
354
+
355
+ // The last element clicked, not whatever currently has focus: a
356
+ // well-behaved dialog moves focus into itself as soon as it opens,
357
+ // which would overwrite the one clue that identifies the trigger by
358
+ // the time anything looked at document.activeElement.
359
+ const presumedTrigger = await page.evaluate(() => window.__a11yLoop.lastClickSelector());
360
+
361
+ const axeResults = await buildAxe(page, { includeBestPractice: bestPractice }).analyze();
362
+ const stateFindings = [
363
+ ...findingsFromAxeResults(axeResults, { passName: 'default', state: name }),
364
+ ...(await runDefaultPassChecks(page, { ...ctx, presumedTrigger })),
365
+ ];
366
+ findings.push(...stateFindings.filter((f) => !baseFingerprints.has(f.fingerprint)));
367
+ statesRun.push(name);
368
+ } catch (error) {
369
+ throw new ToolError(`The --interact state "${name}" threw: ${error.message}`, {
370
+ hint: 'Each state is `async (page) => { … }` and receives a Playwright Page.',
371
+ });
372
+ } finally {
373
+ await context.close();
374
+ }
375
+ }
376
+ } finally {
377
+ await browser.close();
378
+ await served.close();
379
+ }
380
+
381
+ const deduped = dedupeFindings(findings);
382
+ const incompleteRuleIds = [
383
+ ...new Set(
384
+ deduped
385
+ .filter((f) => f.severity === SEVERITY.NEEDS_REVIEW && f.source === 'axe')
386
+ .map((f) => f.ruleId),
387
+ ),
388
+ ];
389
+
390
+ return {
391
+ findings: deduped,
392
+ facts,
393
+ incompleteRuleIds,
394
+ passesRun,
395
+ statesRun,
396
+ browserVersion,
397
+ userAgent,
398
+ url: served.url,
399
+ };
400
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Code injected into the audited page.
3
+ *
4
+ * `tabbable` is loaded from its UMD build (it is a browser library, not a Node
5
+ * one) and the small helper set below gives every own check a consistent way to
6
+ * name an element in a report.
7
+ */
8
+
9
+ import { createRequire } from 'node:module';
10
+ import { readFileSync } from 'node:fs';
11
+
12
+ const require = createRequire(import.meta.url);
13
+
14
+ let tabbableSource = null;
15
+
16
+ /** The tabbable UMD bundle, read once. */
17
+ export function tabbableScript() {
18
+ if (tabbableSource === null) {
19
+ tabbableSource = readFileSync(require.resolve('tabbable/dist/index.umd.min.js'), 'utf8');
20
+ }
21
+ return tabbableSource;
22
+ }
23
+
24
+ /**
25
+ * Helpers available in-page as `window.__a11yLoop`.
26
+ * Kept as a source string so it can go through `addInitScript`, which runs
27
+ * before page scripts on every navigation.
28
+ */
29
+ export const HELPERS_SOURCE = `
30
+ (() => {
31
+ if (window.__a11yLoop) return;
32
+
33
+ /** A short, stable-ish CSS path for an element. */
34
+ function cssPath(el) {
35
+ if (!el || el.nodeType !== 1) return '';
36
+ if (el === document.documentElement) return 'html';
37
+ if (el === document.body) return 'html > body';
38
+ const parts = [];
39
+ let node = el;
40
+ while (node && node.nodeType === 1 && parts.length < 7) {
41
+ let part = node.tagName.toLowerCase();
42
+ if (node.id && /^[A-Za-z][-\\w]*$/.test(node.id)) {
43
+ parts.unshift('#' + node.id);
44
+ break;
45
+ }
46
+ const cls = (node.getAttribute('class') || '')
47
+ .trim()
48
+ .split(/\\s+/)
49
+ .filter((c) => c && /^[A-Za-z][-\\w]*$/.test(c))
50
+ .slice(0, 2);
51
+ if (cls.length) part += '.' + cls.join('.');
52
+ const parent = node.parentElement;
53
+ if (parent) {
54
+ const sameTag = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
55
+ if (sameTag.length > 1) part += ':nth-of-type(' + (sameTag.indexOf(node) + 1) + ')';
56
+ }
57
+ parts.unshift(part);
58
+ node = parent;
59
+ // Stop at the root so paths read like axe's ancestry selectors.
60
+ if (node === document.documentElement) {
61
+ parts.unshift('html');
62
+ break;
63
+ }
64
+ }
65
+ return parts.join(' > ');
66
+ }
67
+
68
+ /** Opening tag plus a little text — enough for an agent to locate the element. */
69
+ function shortHtml(el, limit) {
70
+ if (!el || el.nodeType !== 1) return '';
71
+ const html = el.outerHTML || '';
72
+ return html.length > (limit || 200) ? html.slice(0, limit || 200) : html;
73
+ }
74
+
75
+ /** Accessible name, best effort: aria-label, aria-labelledby, then text. */
76
+ function accessibleName(el) {
77
+ if (!el) return '';
78
+ const label = el.getAttribute && el.getAttribute('aria-label');
79
+ if (label && label.trim()) return label.trim();
80
+ const ids = el.getAttribute && el.getAttribute('aria-labelledby');
81
+ if (ids) {
82
+ const text = ids
83
+ .split(/\\s+/)
84
+ .map((id) => {
85
+ const target = document.getElementById(id);
86
+ return target ? (target.textContent || '').trim() : '';
87
+ })
88
+ .filter(Boolean)
89
+ .join(' ');
90
+ if (text) return text;
91
+ }
92
+ if (el.tagName === 'IMG') {
93
+ const alt = el.getAttribute('alt');
94
+ if (alt !== null) return alt.trim();
95
+ }
96
+ if (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA') {
97
+ if (el.labels && el.labels.length) {
98
+ return Array.from(el.labels).map((l) => (l.textContent || '').trim()).join(' ');
99
+ }
100
+ const title = el.getAttribute('title');
101
+ if (title) return title.trim();
102
+ const value = el.getAttribute('value');
103
+ if (el.type === 'submit' || el.type === 'button') return (value || '').trim();
104
+ }
105
+ return (el.textContent || '').replace(/\\s+/g, ' ').trim();
106
+ }
107
+
108
+ /** Is the element actually rendered — regardless of who it is hidden from? */
109
+ function isRendered(el) {
110
+ if (!el || el.nodeType !== 1) return false;
111
+ const style = getComputedStyle(el);
112
+ if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') return false;
113
+ if (Number(style.opacity) === 0) return false;
114
+ const rect = el.getBoundingClientRect();
115
+ if (rect.width === 0 && rect.height === 0) return false;
116
+ return true;
117
+ }
118
+
119
+ /**
120
+ * Is the element rendered AND exposed to assistive technology / keyboard
121
+ * navigation? Most checks want this — an aria-hidden element is not
122
+ * reachable, not announced, not a real target. Motion is the exception:
123
+ * SC 2.2.2 is about what a SIGHTED user sees moving on screen, which has
124
+ * nothing to do with the accessibility tree, so the reduced-motion check
125
+ * uses isRendered directly rather than this.
126
+ */
127
+ function isVisible(el) {
128
+ if (!isRendered(el)) return false;
129
+ if (el.closest('[aria-hidden="true"]')) return false;
130
+ // A native <dialog> opened with showModal() (or a fullscreen element)
131
+ // makes everything outside itself genuinely inert: unclickable,
132
+ // unfocusable, unreachable by Tab, whatever the DOM and CSS say. Without
133
+ // this, every check that surveys "visible" elements would flood a report
134
+ // with the rest of the page once a modal is open — not a real defect,
135
+ // just this helper not knowing the platform already handled it.
136
+ var modal = document.querySelector(':modal');
137
+ if (modal && !modal.contains(el)) return false;
138
+ return true;
139
+ }
140
+
141
+ // Record the CSS path of whatever was last clicked, for the whole life of
142
+ // the page. This is how a dialog check identifies "the trigger" when
143
+ // nothing declares it explicitly (no aria-controls / data-dialog-target):
144
+ // a click is a far more reliable signal than "whatever has focus right
145
+ // now", because a well-behaved dialog moves focus into itself as soon as
146
+ // it opens, which overwrites the one clue that would otherwise identify
147
+ // the trigger by the time anything gets a chance to look.
148
+ document.addEventListener(
149
+ 'click',
150
+ function (event) {
151
+ window.__a11yLoopLastClick = cssPath(event.target);
152
+ },
153
+ true,
154
+ );
155
+
156
+ /** Elements a user can interact with — the population for target-size etc. */
157
+ function interactiveElements() {
158
+ const selector = [
159
+ 'a[href]', 'button', 'input:not([type="hidden"])', 'select', 'textarea',
160
+ 'summary', '[role="button"]', '[role="link"]', '[role="checkbox"]',
161
+ '[role="radio"]', '[role="switch"]', '[role="tab"]', '[role="menuitem"]',
162
+ '[role="option"]', '[onclick]', '[tabindex]',
163
+ ].join(',');
164
+ return Array.from(document.querySelectorAll(selector)).filter((el) => {
165
+ if (!isVisible(el)) return false;
166
+ if (el.disabled) return false;
167
+ return true;
168
+ });
169
+ }
170
+
171
+ /**
172
+ * The first opaque background colour behind an element, walking ancestors.
173
+ * Returns null when nothing determinable is found (a background image or
174
+ * gradient), which callers must surface as needs-review rather than guess.
175
+ */
176
+ function backdropColor(el) {
177
+ let node = el;
178
+ while (node && node.nodeType === 1) {
179
+ const style = getComputedStyle(node);
180
+ if (style.backgroundImage && style.backgroundImage !== 'none') return null;
181
+ const bg = style.backgroundColor;
182
+ const match = /rgba?\\(([^)]+)\\)/.exec(bg || '');
183
+ if (match) {
184
+ const parts = match[1].split(/[,\\s/]+/).filter(Boolean).map(Number);
185
+ const alpha = parts.length > 3 ? parts[3] : 1;
186
+ if (alpha >= 1) return 'rgb(' + parts[0] + ', ' + parts[1] + ', ' + parts[2] + ')';
187
+ }
188
+ node = node.parentElement;
189
+ }
190
+ return 'rgb(255, 255, 255)';
191
+ }
192
+
193
+ /**
194
+ * Where a forward Tab walk should be scoped: inside the open modal dialog
195
+ * if there is one (everything outside it is inert and cannot be reached),
196
+ * otherwise the whole document. tabbable() itself has no idea a native
197
+ * dialog element made the rest of the page inert, so callers must pass
198
+ * this as the container rather than always walking document.body.
199
+ */
200
+ function tabbableRoot() {
201
+ return document.querySelector(':modal') || document.body;
202
+ }
203
+
204
+ /** The CSS path of whatever was last clicked, or null if nothing was. */
205
+ function lastClickSelector() {
206
+ return window.__a11yLoopLastClick || null;
207
+ }
208
+
209
+ window.__a11yLoop = {
210
+ cssPath: cssPath,
211
+ shortHtml: shortHtml,
212
+ accessibleName: accessibleName,
213
+ isRendered: isRendered,
214
+ isVisible: isVisible,
215
+ tabbableRoot: tabbableRoot,
216
+ lastClickSelector: lastClickSelector,
217
+ interactiveElements: interactiveElements,
218
+ backdropColor: backdropColor,
219
+ };
220
+ })();
221
+ `;