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.
- package/LICENSE +22 -0
- package/README.md +409 -0
- package/THIRD-PARTY-NOTICES.md +32 -0
- package/package.json +51 -0
- package/skill/a11y-loop/SKILL.md +332 -0
- package/skill/a11y-loop/evals/evals.json +168 -0
- package/skill/a11y-loop/evals/trigger-evals.json +20 -0
- package/skill/a11y-loop/references/ai-failure-modes.md +272 -0
- package/skill/a11y-loop/references/apg-patterns.md +264 -0
- package/skill/a11y-loop/references/manual-testing.md +224 -0
- package/skill/a11y-loop/references/wcag22-quick-ref.md +224 -0
- package/src/cli.js +207 -0
- package/src/commands/audit.js +125 -0
- package/src/commands/contrast.js +141 -0
- package/src/commands/diff.js +65 -0
- package/src/lib/axe-runner.js +400 -0
- package/src/lib/browser-utils.js +221 -0
- package/src/lib/checks/dialog.js +341 -0
- package/src/lib/checks/div-button.js +87 -0
- package/src/lib/checks/focus-visible.js +296 -0
- package/src/lib/checks/keyboard.js +235 -0
- package/src/lib/checks/link-text.js +83 -0
- package/src/lib/checks/reduced-motion.js +139 -0
- package/src/lib/checks/reflow.js +101 -0
- package/src/lib/checks/target-size.js +128 -0
- package/src/lib/contrast-math.js +189 -0
- package/src/lib/diff.js +118 -0
- package/src/lib/finding.js +164 -0
- package/src/lib/fingerprint.js +0 -0
- package/src/lib/format/checklist.js +281 -0
- package/src/lib/format/human.js +175 -0
- package/src/lib/format/json.js +139 -0
- package/src/lib/format/sarif.js +111 -0
- package/src/lib/serve.js +189 -0
- package/src/lib/suggest-color.js +169 -0
- 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
|
+
`;
|