@a11y-skills/audit 0.2.0 → 0.3.1

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,370 @@
1
+ /**
2
+ * Pure mappers from check-specific `details` to the axe-style buckets
3
+ * (`violations` / `incomplete` / `passes` / `inapplicable`), plus the envelope
4
+ * assembler and the opt-in cross-check merge.
5
+ *
6
+ * Everything here is browser-independent: the runners gather evidence into a
7
+ * details object, and these functions derive the normalized view from it.
8
+ * Because the details objects are part of the saved JSON, the buckets can be
9
+ * re-derived from a result file at any time.
10
+ */
11
+ import { AUDIT_DISCLAIMER, HTML_SNIPPET_MAX_LENGTH } from '../constants.js';
12
+ import { getRule } from './rule-registry.js';
13
+ function emptyBuckets() {
14
+ return { violations: [], incomplete: [], passes: [], inapplicable: [] };
15
+ }
16
+ function ruleResult(key, nodes) {
17
+ const meta = getRule(key);
18
+ return {
19
+ id: meta.id,
20
+ impact: meta.impact,
21
+ description: meta.description,
22
+ help: meta.help,
23
+ helpUrl: meta.helpUrl,
24
+ tags: [...meta.tags],
25
+ nodes,
26
+ };
27
+ }
28
+ /**
29
+ * Route a rule's findings into the right bucket:
30
+ * not applicable → `inapplicable`, no findings → `passes`, findings →
31
+ * the registry's classification (or an explicit override).
32
+ */
33
+ function bucketize(buckets, key, nodes, applicable, override) {
34
+ if (!applicable) {
35
+ buckets.inapplicable.push(ruleResult(key, []));
36
+ return;
37
+ }
38
+ if (nodes.length === 0) {
39
+ buckets.passes.push(ruleResult(key, []));
40
+ return;
41
+ }
42
+ const classification = override ?? getRule(key).classification;
43
+ (classification === 'violation'
44
+ ? buckets.violations
45
+ : buckets.incomplete).push(ruleResult(key, nodes));
46
+ }
47
+ /** Build a node from element evidence, synthesizing `html` when missing. */
48
+ function toNode(el, failureSummary) {
49
+ const tag = (el.tagName ?? el.tag ?? 'element').toLowerCase();
50
+ const html = el.html && el.html.length > 0 ? el.html : `<${tag}>`;
51
+ return {
52
+ target: [el.selector],
53
+ html,
54
+ htmlTruncated: el.htmlTruncated ?? false,
55
+ failureSummary,
56
+ };
57
+ }
58
+ /** Build a page-level node (`target: ['html']`). */
59
+ function pageNode(failureSummary) {
60
+ return {
61
+ target: ['html'],
62
+ html: '<html>',
63
+ htmlTruncated: false,
64
+ failureSummary,
65
+ };
66
+ }
67
+ function describeElement(ref) {
68
+ return `<${ref.tag.toLowerCase()}> "${ref.name}"`;
69
+ }
70
+ // =============================================================================
71
+ // Envelope assembly
72
+ // =============================================================================
73
+ /** Assemble the common envelope from a check's details and buckets. */
74
+ export function buildAuditResult(args) {
75
+ const { source, url, details, buckets, timestamp } = args;
76
+ const summary = {
77
+ violationCount: buckets.violations.length,
78
+ incompleteCount: buckets.incomplete.length,
79
+ passCount: buckets.passes.length,
80
+ };
81
+ if (buckets.checkedNodes !== undefined) {
82
+ summary.checkedNodes = buckets.checkedNodes;
83
+ }
84
+ return {
85
+ source,
86
+ url,
87
+ timestamp: timestamp ?? new Date().toISOString(),
88
+ violations: buckets.violations,
89
+ incomplete: buckets.incomplete,
90
+ passes: buckets.passes,
91
+ inapplicable: buckets.inapplicable,
92
+ summary,
93
+ details,
94
+ disclaimer: AUDIT_DISCLAIMER,
95
+ };
96
+ }
97
+ // =============================================================================
98
+ // Per-check normalizers
99
+ // =============================================================================
100
+ export function normalizeFocusCheck(details) {
101
+ const buckets = emptyBuckets();
102
+ const applicable = details.totalFocusableElements > 0;
103
+ buckets.checkedNodes = details.totalFocusableElements;
104
+ bucketize(buckets, 'focus-visible', details.issues.map((el) => toNode(el, `${describeElement(el)} shows no computed-style change on focus ` +
105
+ '(outline, box-shadow, background-color). Verify manually whether a ' +
106
+ 'visual focus indicator exists (pseudo-elements, canvas, or parent ' +
107
+ 'changes are not detected).')), applicable);
108
+ bucketize(buckets, 'no-context-change-on-focus', details.onFocusViolations.map((v) => toNode(v.element, `Focusing ${describeElement(v.element)} triggered a ${v.changeType} ` +
109
+ `from ${v.fromUrl} to ${v.toUrl}.`)), applicable);
110
+ bucketize(buckets, 'focus-not-obscured', details.focusObscuredIssues.map((issue) => toNode(issue.element, `${describeElement(issue.element)} is ${(issue.obscuredRatio * 100).toFixed(0)}% ` +
111
+ `obscured when focused (by ${issue.overlaps
112
+ .map((o) => `<${o.obscuredBy.tag.toLowerCase()}>`)
113
+ .join(', ')}). Verify whether the focused element is hidden.`)), applicable);
114
+ return buckets;
115
+ }
116
+ export function normalizeReflowCheck(details) {
117
+ const buckets = emptyBuckets();
118
+ const overflowNodes = details.overflowingElements.map((el) => toNode(el, `<${el.tagName}> extends to ${el.rect.right}px in a ${el.viewportWidth}px ` +
119
+ `viewport (${el.reason}). Verify whether a two-dimensional layout ` +
120
+ 'exception (table, map, diagram, ...) applies.'));
121
+ if (details.hasHorizontalScroll && overflowNodes.length === 0) {
122
+ overflowNodes.push(pageNode(`Document scrolls horizontally at ${details.viewport.width}px ` +
123
+ `(scrollWidth ${details.documentScrollWidth}px > clientWidth ` +
124
+ `${details.documentClientWidth}px). Verify whether an exception applies.`));
125
+ }
126
+ bucketize(buckets, 'reflow-overflow', overflowNodes, true);
127
+ bucketize(buckets, 'reflow-clipped-text', details.clippedTextElements.map((el) => toNode(el, `<${el.tagName}> clips its text at ${details.viewport.width}px ` +
128
+ `(scrollWidth ${el.scrollWidth}px > clientWidth ${el.clientWidth}px, ` +
129
+ `overflow: ${el.overflow}). Verify whether content is lost.`)), true);
130
+ return buckets;
131
+ }
132
+ export function normalizeTargetSizeCheck(details) {
133
+ const buckets = emptyBuckets();
134
+ const applicable = details.totalTargetsChecked > 0;
135
+ buckets.checkedNodes = details.totalTargetsChecked;
136
+ const minimumFailureSummary = (issue) => {
137
+ const base = `Target is ${issue.width}x${issue.height}px ` +
138
+ `(min dimension ${issue.minDimension}px, requirement 24px).`;
139
+ if (issue.exception) {
140
+ return (`${base} Possible '${issue.exception}' exception: ` +
141
+ `${issue.exceptionDetails ?? 'see manual review notes'}. Confirm manually.`);
142
+ }
143
+ return `${base} No exception detected, but the essential exception cannot be ruled out automatically.`;
144
+ };
145
+ // Minimum (2.5.8 AA): findings are fail-aa targets, with and without
146
+ // detected exceptions. Only 'ruled-out' assessments are confirmed violations.
147
+ const minimumIssues = [...details.failAA, ...details.exceptedTargets].filter((issue) => issue.level === 'fail-aa');
148
+ const confirmed = minimumIssues.filter((i) => i.exceptionAssessment === 'ruled-out');
149
+ const needsReview = minimumIssues.filter((i) => i.exceptionAssessment !== 'ruled-out');
150
+ if (!applicable) {
151
+ buckets.inapplicable.push(ruleResult('target-size-minimum', []));
152
+ }
153
+ else if (minimumIssues.length === 0) {
154
+ buckets.passes.push(ruleResult('target-size-minimum', []));
155
+ }
156
+ else {
157
+ if (confirmed.length > 0) {
158
+ buckets.violations.push(ruleResult('target-size-minimum', confirmed.map((i) => toNode(i, minimumFailureSummary(i)))));
159
+ }
160
+ if (needsReview.length > 0) {
161
+ buckets.incomplete.push(ruleResult('target-size-minimum', needsReview.map((i) => toNode(i, minimumFailureSummary(i)))));
162
+ }
163
+ }
164
+ // Enhanced (2.5.5 AAA): targets that pass AA but miss the 44px requirement.
165
+ // Targets already failing AA are reported under target-size-minimum only.
166
+ bucketize(buckets, 'target-size-enhanced', details.failAAAOnly.map((issue) => toNode(issue, `Target is ${issue.width}x${issue.height}px ` +
167
+ `(min dimension ${issue.minDimension}px, AAA requirement 44px). ` +
168
+ 'Verify whether an SC 2.5.5 exception applies.')), applicable);
169
+ return buckets;
170
+ }
171
+ export function normalizeTextSpacingCheck(details) {
172
+ const buckets = emptyBuckets();
173
+ buckets.checkedNodes = details.totalElementsChecked;
174
+ bucketize(buckets, 'text-spacing', details.clippedElements.map((el) => toNode(el, `<${el.tagName}> clips its content (${el.issueType}) when WCAG 1.4.12 ` +
175
+ `text spacing is applied: ${el.afterMetrics.scrollWidth}x${el.afterMetrics.scrollHeight}px ` +
176
+ `content in a ${el.afterMetrics.clientWidth}x${el.afterMetrics.clientHeight}px box.`)), details.totalElementsChecked > 0);
177
+ return buckets;
178
+ }
179
+ export function normalizeZoomCheck(details) {
180
+ const buckets = emptyBuckets();
181
+ const nodes = details.clippedElements.map((el) => toNode(el, `<${el.tagName}> ${el.issueType === 'horizontal-scroll' ? 'overflows horizontally' : 'clips its content'} ` +
182
+ `at ${details.zoomFactor * 100}% zoom (scrollWidth ${el.scrollWidth}px > ` +
183
+ `clientWidth ${el.clientWidth}px). Verify whether content or ` +
184
+ 'functionality is lost.'));
185
+ if (details.hasHorizontalScroll && nodes.length === 0) {
186
+ nodes.push(pageNode(`Document scrolls horizontally at ${details.zoomFactor * 100}% zoom ` +
187
+ `(scrollWidth ${details.documentScrollWidth}px > clientWidth ` +
188
+ `${details.documentClientWidth}px). Horizontal scrolling alone does ` +
189
+ 'not fail SC 1.4.4 — verify whether text becomes unusable.'));
190
+ }
191
+ bucketize(buckets, 'resize-text', nodes, true);
192
+ return buckets;
193
+ }
194
+ export function normalizeOrientationCheck(details) {
195
+ const buckets = emptyBuckets();
196
+ const nodes = [];
197
+ if (details.hasOrientationLock) {
198
+ const state = details.lockDetectedIn === 'landscape'
199
+ ? details.landscape
200
+ : details.portrait;
201
+ const messagePart = state.lockMessageText
202
+ ? ` Lock message found: "${state.lockMessageText}".`
203
+ : '';
204
+ nodes.push(pageNode(`Content appears restricted to a single orientation ` +
205
+ `(detected in: ${details.lockDetectedIn}).${messagePart} Verify ` +
206
+ 'whether the essential exception (SC 1.3.4) applies.'));
207
+ }
208
+ bucketize(buckets, 'orientation-lock', nodes, true);
209
+ return buckets;
210
+ }
211
+ export function normalizeAutocompleteAudit(details) {
212
+ const buckets = emptyBuckets();
213
+ const applicable = details.totalFieldsChecked > 0;
214
+ buckets.checkedNodes = details.totalFieldsChecked;
215
+ bucketize(buckets, 'autocomplete-invalid', details.invalidAutocomplete.map((field) => toNode(field, `autocomplete="${field.currentAutocomplete}" is not a valid token. ` +
216
+ `Expected "${field.expectedToken}" (purpose matched by ${field.matchedBy}).`)), applicable);
217
+ bucketize(buckets, 'autocomplete-missing', details.missingAutocomplete.map((field) => toNode(field, `Field appears to collect "${field.expectedToken}" (matched by ` +
218
+ `${field.matchedBy}) but has ${field.currentAutocomplete === null
219
+ ? 'no autocomplete attribute'
220
+ : `autocomplete="${field.currentAutocomplete}"`}. Confirm the field purpose manually.`)), applicable);
221
+ return buckets;
222
+ }
223
+ export function normalizeTimeLimitDetector(details) {
224
+ const buckets = emptyBuckets();
225
+ bucketize(buckets, 'meta-refresh', details.metaRefresh.map((meta) => ({
226
+ target: ['meta[http-equiv="refresh"]'],
227
+ html: meta.html || `<meta http-equiv="refresh" content="${meta.content}">`,
228
+ htmlTruncated: meta.htmlTruncated ?? false,
229
+ failureSummary: `Page refreshes${meta.url ? ` to ${meta.url}` : ''} after ${meta.seconds}s. ` +
230
+ 'Verify whether the time limit can be turned off, adjusted, or extended, ' +
231
+ 'or whether an SC 2.2.1 exception (e.g. over 20 hours) applies.',
232
+ })), true);
233
+ bucketize(buckets, 'time-limit-timer', details.timers.map((timer) => pageNode(`${timer.type} with a ${timer.delayMs}ms delay detected` +
234
+ `${timer.callStack ? ` (${timer.callStack.split('\n')[0]?.trim()})` : ''}. ` +
235
+ 'Verify whether it implements a time limit and is adjustable.')), true);
236
+ bucketize(buckets, 'time-limit-countdown', details.countdownIndicators.map((indicator) => toNode(indicator, `Countdown/timeout wording found: "${indicator.text.slice(0, 80)}". ` +
237
+ 'Verify whether it indicates an adjustable time limit.')), true);
238
+ return buckets;
239
+ }
240
+ export function normalizeAutoPlayDetection(details) {
241
+ const buckets = emptyBuckets();
242
+ const nodes = [];
243
+ if (details.hasAutoPlayContent && !details.stopsWithin5Seconds) {
244
+ const controlsPart = details.pauseControls.found
245
+ ? `Pause controls found (${details.pauseControls.controls.length}); ` +
246
+ `pause verified working: ${details.pauseVerification.pauseWorked ?? 'unknown'}.`
247
+ : 'No pause controls found.';
248
+ nodes.push(pageNode(`Moving content continues past 5 seconds (pixel-diff detection). ` +
249
+ `${controlsPart} ${details.recommendation} Verify the content type ` +
250
+ 'and audio manually.'));
251
+ }
252
+ bucketize(buckets, 'auto-play', nodes, true);
253
+ return buckets;
254
+ }
255
+ function normalizeAxeRule(rule, includeNodes) {
256
+ return {
257
+ id: rule.id,
258
+ impact: (rule.impact ?? null),
259
+ description: rule.description,
260
+ help: rule.help,
261
+ helpUrl: rule.helpUrl,
262
+ tags: [...rule.tags],
263
+ nodes: includeNodes
264
+ ? rule.nodes.map((n) => {
265
+ const truncated = n.html.length > HTML_SNIPPET_MAX_LENGTH;
266
+ return {
267
+ target: n.target.map((t) => String(t)),
268
+ html: truncated ? n.html.slice(0, HTML_SNIPPET_MAX_LENGTH) : n.html,
269
+ htmlTruncated: truncated,
270
+ failureSummary: n.failureSummary ?? '',
271
+ };
272
+ })
273
+ : [],
274
+ };
275
+ }
276
+ /**
277
+ * Normalize raw axe-core results into the common buckets. Must be fed the RAW
278
+ * `AxeResults` (before any reduction) — pass/incomplete details are not
279
+ * recoverable afterwards. Node lists are kept for violations and incomplete;
280
+ * passes/inapplicable carry rule metadata only.
281
+ */
282
+ export function normalizeAxeResults(raw) {
283
+ return {
284
+ violations: raw.violations.map((r) => normalizeAxeRule(r, true)),
285
+ incomplete: raw.incomplete.map((r) => normalizeAxeRule(r, true)),
286
+ passes: raw.passes.map((r) => normalizeAxeRule(r, false)),
287
+ inapplicable: raw.inapplicable.map((r) => normalizeAxeRule(r, false)),
288
+ };
289
+ }
290
+ const BUCKETS = ['violations', 'incomplete', 'passes', 'inapplicable'];
291
+ /**
292
+ * Merge several check results for the same URL into one normalized view.
293
+ *
294
+ * - Results with differing URLs are rejected (throws).
295
+ * - The same rule id is merged into one entry; nodes are deduplicated by
296
+ * `target` + `failureSummary`. Identical selectors inside different frames
297
+ * or shadow roots are NOT distinguished.
298
+ * - A rule appearing in several buckets is placed in the highest-priority one:
299
+ * violations > incomplete > passes > inapplicable.
300
+ */
301
+ export function mergeNormalizedResults(results) {
302
+ if (results.length === 0) {
303
+ throw new Error('mergeNormalizedResults requires at least one result.');
304
+ }
305
+ const first = results[0];
306
+ const mismatch = results.find((r) => r.url !== first.url);
307
+ if (mismatch) {
308
+ throw new Error(`mergeNormalizedResults: URL mismatch — "${first.url}" vs "${mismatch.url}". ` +
309
+ 'Merge only results for the same page.');
310
+ }
311
+ const byId = new Map();
312
+ const nodeKey = (n) => `${JSON.stringify(n.target)}|${n.failureSummary}`;
313
+ for (const result of results) {
314
+ BUCKETS.forEach((bucket, bucketIndex) => {
315
+ for (const rule of result[bucket]) {
316
+ let entry = byId.get(rule.id);
317
+ if (!entry) {
318
+ entry = {
319
+ bucketIndex,
320
+ rule: { ...rule, nodes: [] },
321
+ nodeKeys: new Set(),
322
+ };
323
+ byId.set(rule.id, entry);
324
+ }
325
+ entry.bucketIndex = Math.min(entry.bucketIndex, bucketIndex);
326
+ for (const node of rule.nodes) {
327
+ const key = nodeKey(node);
328
+ if (!entry.nodeKeys.has(key)) {
329
+ entry.nodeKeys.add(key);
330
+ entry.rule.nodes.push(node);
331
+ }
332
+ }
333
+ }
334
+ });
335
+ }
336
+ const merged = {
337
+ violations: [],
338
+ incomplete: [],
339
+ passes: [],
340
+ inapplicable: [],
341
+ };
342
+ for (const entry of byId.values()) {
343
+ merged[BUCKETS[entry.bucketIndex]].push(entry.rule);
344
+ }
345
+ const latestTimestamp = results
346
+ .map((r) => r.timestamp)
347
+ .sort()
348
+ .at(-1);
349
+ const checkedNodes = results.reduce((sum, r) => {
350
+ if (r.summary.checkedNodes === undefined)
351
+ return sum;
352
+ return (sum ?? 0) + r.summary.checkedNodes;
353
+ }, undefined);
354
+ const summary = {
355
+ violationCount: merged.violations.length,
356
+ incompleteCount: merged.incomplete.length,
357
+ passCount: merged.passes.length,
358
+ };
359
+ if (checkedNodes !== undefined) {
360
+ summary.checkedNodes = checkedNodes;
361
+ }
362
+ return {
363
+ url: first.url,
364
+ timestamp: latestTimestamp,
365
+ sources: [...new Set(results.map((r) => r.source))],
366
+ ...merged,
367
+ summary,
368
+ disclaimer: AUDIT_DISCLAIMER,
369
+ };
370
+ }
@@ -8,6 +8,8 @@ export interface LayoutCheckOptions {
8
8
  overflowTolerance: number;
9
9
  checkSelector: string;
10
10
  allowedOverflowSelectors: readonly string[];
11
+ /** Maximum length of captured outerHTML snippets. */
12
+ htmlSnippetMaxLength: number;
11
13
  }
12
14
  export interface LayoutCheckResult {
13
15
  hasHorizontalScroll: boolean;
@@ -7,7 +7,26 @@
7
7
  * This is serialized and executed in the browser context.
8
8
  */
9
9
  export function createLayoutChecker(options) {
10
- const { viewportWidth, overflowTolerance, checkSelector, allowedOverflowSelectors, } = options;
10
+ const { viewportWidth, overflowTolerance, checkSelector, allowedOverflowSelectors, htmlSnippetMaxLength, } = options;
11
+ function getHtmlSnippet(element) {
12
+ let html = '';
13
+ try {
14
+ html = element.outerHTML || '';
15
+ }
16
+ catch {
17
+ html = '';
18
+ }
19
+ if (!html) {
20
+ return {
21
+ html: `<${element.tagName.toLowerCase()}>`,
22
+ htmlTruncated: false,
23
+ };
24
+ }
25
+ if (html.length > htmlSnippetMaxLength) {
26
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
27
+ }
28
+ return { html, htmlTruncated: false };
29
+ }
11
30
  // Helper functions (must be defined inside for browser context)
12
31
  /**
13
32
  * Generate a unique CSS selector for an element using index-based approach
@@ -31,7 +50,9 @@ export function createLayoutChecker(options) {
31
50
  current = parent;
32
51
  }
33
52
  // Append element index as fallback for guaranteed uniqueness
34
- return path.length > 0 ? path.join(' > ') : `[data-index="${elementIndex}"]`;
53
+ return path.length > 0
54
+ ? path.join(' > ')
55
+ : `[data-index="${elementIndex}"]`;
35
56
  }
36
57
  function isAllowedOverflow(element) {
37
58
  return allowedOverflowSelectors.some((selector) => {
@@ -76,6 +97,7 @@ export function createLayoutChecker(options) {
76
97
  overflowingElements.push({
77
98
  selector,
78
99
  tagName: element.tagName.toLowerCase(),
100
+ ...getHtmlSnippet(element),
79
101
  rect: {
80
102
  left: Math.round(rect.left),
81
103
  right: Math.round(rect.right),
@@ -91,6 +113,7 @@ export function createLayoutChecker(options) {
91
113
  overflowingElements.push({
92
114
  selector,
93
115
  tagName: element.tagName.toLowerCase(),
116
+ ...getHtmlSnippet(element),
94
117
  rect: {
95
118
  left: Math.round(rect.left),
96
119
  right: Math.round(rect.right),
@@ -123,6 +146,7 @@ export function createLayoutChecker(options) {
123
146
  clippedTextElements.push({
124
147
  selector,
125
148
  tagName: element.tagName.toLowerCase(),
149
+ ...getHtmlSnippet(element),
126
150
  scrollWidth,
127
151
  clientWidth,
128
152
  scrollHeight,
@@ -5,7 +5,7 @@
5
5
  * Generate a recommendation based on detection results.
6
6
  */
7
7
  export function generateRecommendation(ctx) {
8
- const { hasAutoPlayContent, stopsWithin5Seconds, pauseControls, pauseVerification } = ctx;
8
+ const { hasAutoPlayContent, stopsWithin5Seconds, pauseControls, pauseVerification, } = ctx;
9
9
  if (!hasAutoPlayContent) {
10
10
  return 'No auto-playing content detected in viewport.';
11
11
  }
@@ -32,7 +32,7 @@ export function generateRecommendation(ctx) {
32
32
  * Print summary to console.
33
33
  */
34
34
  export function printSummary(ctx, outputDir) {
35
- const { hasAutoPlayContent, stopsWithin5Seconds, pauseControls, pauseVerification } = ctx;
35
+ const { hasAutoPlayContent, stopsWithin5Seconds, pauseControls, pauseVerification, } = ctx;
36
36
  console.log('\n--- Summary ---');
37
37
  if (!hasAutoPlayContent) {
38
38
  console.log('✓ No auto-playing content detected in viewport');
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Rule metadata registry for the custom (non-axe) checks.
3
+ *
4
+ * Single source of truth for rule ids, WCAG mapping, severity, and the
5
+ * violation/incomplete classification. The mappers in `axe-format.ts` read
6
+ * from here so that classification policy is reviewable in one place.
7
+ *
8
+ * Classification policy: `'violation'` is reserved for rules whose detection
9
+ * has no blind spot AND where no WCAG exception can apply to a finding.
10
+ * Heuristic detections and findings with possible exceptions are
11
+ * `'incomplete'` (the manual-review queue). impact is NOT derived from the
12
+ * WCAG conformance level; it is assigned per rule, conservatively defaulting
13
+ * to `moderate`.
14
+ */
15
+ import type { NormalizedImpact } from '../types.js';
16
+ export interface RuleMeta {
17
+ /** Namespaced rule id, e.g. `a11y-skills/focus-visible`. */
18
+ id: string;
19
+ /** WCAG success criteria, e.g. `['2.4.7']`. */
20
+ sc: string[];
21
+ /**
22
+ * axe-style tags. The version+level tag reflects the WCAG version that
23
+ * introduced the SC (2.4.7 → `wcag2aa`, 1.4.10 → `wcag21aa`, ...).
24
+ */
25
+ tags: string[];
26
+ impact: NormalizedImpact;
27
+ /** Whether findings refer to specific elements or the page as a whole. */
28
+ scope: 'node' | 'page';
29
+ description: string;
30
+ help: string;
31
+ /** W3C Understanding document URL. */
32
+ helpUrl: string;
33
+ /** Default bucket for findings of this rule. */
34
+ classification: 'violation' | 'incomplete';
35
+ }
36
+ export declare const RULES: {
37
+ readonly 'focus-visible': {
38
+ readonly id: "a11y-skills/focus-visible";
39
+ readonly sc: ["2.4.7"];
40
+ readonly tags: ["a11y-skills", "wcag2aa", "wcag247"];
41
+ readonly impact: "serious";
42
+ readonly scope: "node";
43
+ readonly description: "Ensure keyboard focus produces a visible indicator on focusable elements";
44
+ readonly help: "Focusable elements should have a visible focus indicator";
45
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html";
46
+ readonly classification: "incomplete";
47
+ };
48
+ readonly 'no-context-change-on-focus': {
49
+ readonly id: "a11y-skills/no-context-change-on-focus";
50
+ readonly sc: ["3.2.1"];
51
+ readonly tags: ["a11y-skills", "wcag2a", "wcag321"];
52
+ readonly impact: "serious";
53
+ readonly scope: "node";
54
+ readonly description: "Ensure receiving focus does not trigger a change of context (navigation, new window)";
55
+ readonly help: "Focusing an element must not trigger a context change";
56
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/on-focus.html";
57
+ readonly classification: "violation";
58
+ };
59
+ readonly 'focus-not-obscured': {
60
+ readonly id: "a11y-skills/focus-not-obscured";
61
+ readonly sc: ["2.4.11", "2.4.12"];
62
+ readonly tags: ["a11y-skills", "wcag22aa", "wcag2411"];
63
+ readonly impact: "moderate";
64
+ readonly scope: "node";
65
+ readonly description: "Ensure the focused element is not hidden by fixed or sticky content";
66
+ readonly help: "Focused elements should not be obscured by author-created content";
67
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html";
68
+ readonly classification: "incomplete";
69
+ };
70
+ readonly 'reflow-overflow': {
71
+ readonly id: "a11y-skills/reflow-overflow";
72
+ readonly sc: ["1.4.10"];
73
+ readonly tags: ["a11y-skills", "wcag21aa", "wcag1410"];
74
+ readonly impact: "serious";
75
+ readonly scope: "node";
76
+ readonly description: "Ensure content reflows at 320 CSS px width without horizontal scrolling";
77
+ readonly help: "Content should not require horizontal scrolling at 320px width";
78
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/reflow.html";
79
+ readonly classification: "incomplete";
80
+ };
81
+ readonly 'reflow-clipped-text': {
82
+ readonly id: "a11y-skills/reflow-clipped-text";
83
+ readonly sc: ["1.4.10"];
84
+ readonly tags: ["a11y-skills", "wcag21aa", "wcag1410"];
85
+ readonly impact: "moderate";
86
+ readonly scope: "node";
87
+ readonly description: "Ensure text is not clipped when content reflows at 320 CSS px";
88
+ readonly help: "Text should remain readable at 320px width";
89
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/reflow.html";
90
+ readonly classification: "incomplete";
91
+ };
92
+ readonly 'target-size-minimum': {
93
+ readonly id: "a11y-skills/target-size-minimum";
94
+ readonly sc: ["2.5.8"];
95
+ readonly tags: ["a11y-skills", "wcag22aa", "wcag258"];
96
+ readonly impact: "serious";
97
+ readonly scope: "node";
98
+ readonly description: "Ensure pointer targets are at least 24x24 CSS px (WCAG 2.5.8 AA)";
99
+ readonly help: "Pointer targets should be at least 24x24 CSS px";
100
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html";
101
+ readonly classification: "incomplete";
102
+ };
103
+ readonly 'target-size-enhanced': {
104
+ readonly id: "a11y-skills/target-size-enhanced";
105
+ readonly sc: ["2.5.5"];
106
+ readonly tags: ["a11y-skills", "wcag21aaa", "wcag255"];
107
+ readonly impact: "moderate";
108
+ readonly scope: "node";
109
+ readonly description: "Ensure pointer targets are at least 44x44 CSS px (WCAG 2.5.5 AAA)";
110
+ readonly help: "Pointer targets should be at least 44x44 CSS px";
111
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html";
112
+ readonly classification: "incomplete";
113
+ };
114
+ readonly 'text-spacing': {
115
+ readonly id: "a11y-skills/text-spacing";
116
+ readonly sc: ["1.4.12"];
117
+ readonly tags: ["a11y-skills", "wcag21aa", "wcag1412"];
118
+ readonly impact: "moderate";
119
+ readonly scope: "node";
120
+ readonly description: "Ensure no loss of content when WCAG 1.4.12 text spacing overrides are applied";
121
+ readonly help: "Text must not be clipped under increased text spacing";
122
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html";
123
+ readonly classification: "violation";
124
+ };
125
+ readonly 'resize-text': {
126
+ readonly id: "a11y-skills/resize-text";
127
+ readonly sc: ["1.4.4"];
128
+ readonly tags: ["a11y-skills", "wcag2aa", "wcag144"];
129
+ readonly impact: "moderate";
130
+ readonly scope: "node";
131
+ readonly description: "Ensure content remains usable when text is resized to 200%";
132
+ readonly help: "Content should not be lost or clipped at 200% zoom";
133
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html";
134
+ readonly classification: "incomplete";
135
+ };
136
+ readonly 'orientation-lock': {
137
+ readonly id: "a11y-skills/orientation-lock";
138
+ readonly sc: ["1.3.4"];
139
+ readonly tags: ["a11y-skills", "wcag21aa", "wcag134"];
140
+ readonly impact: "serious";
141
+ readonly scope: "page";
142
+ readonly description: "Ensure content does not restrict its view to a single display orientation";
143
+ readonly help: "Content should work in both portrait and landscape orientation";
144
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/orientation.html";
145
+ readonly classification: "incomplete";
146
+ };
147
+ readonly 'autocomplete-invalid': {
148
+ readonly id: "a11y-skills/autocomplete-invalid";
149
+ readonly sc: ["1.3.5"];
150
+ readonly tags: ["a11y-skills", "wcag21aa", "wcag135"];
151
+ readonly impact: "moderate";
152
+ readonly scope: "node";
153
+ readonly description: "Ensure autocomplete attribute values are valid tokens";
154
+ readonly help: "autocomplete attributes must use valid token values";
155
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html";
156
+ readonly classification: "violation";
157
+ };
158
+ readonly 'autocomplete-missing': {
159
+ readonly id: "a11y-skills/autocomplete-missing";
160
+ readonly sc: ["1.3.5"];
161
+ readonly tags: ["a11y-skills", "wcag21aa", "wcag135"];
162
+ readonly impact: "moderate";
163
+ readonly scope: "node";
164
+ readonly description: "Ensure fields collecting user information declare their purpose via autocomplete";
165
+ readonly help: "Add an autocomplete attribute matching the field purpose";
166
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html";
167
+ readonly classification: "incomplete";
168
+ };
169
+ readonly 'meta-refresh': {
170
+ readonly id: "a11y-skills/meta-refresh";
171
+ readonly sc: ["2.2.1"];
172
+ readonly tags: ["a11y-skills", "wcag2a", "wcag221"];
173
+ readonly impact: "serious";
174
+ readonly scope: "node";
175
+ readonly description: "Ensure meta refresh time limits can be turned off, adjusted, or extended";
176
+ readonly help: "Verify the meta refresh satisfies an SC 2.2.1 exception or is adjustable";
177
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html";
178
+ readonly classification: "incomplete";
179
+ };
180
+ readonly 'time-limit-timer': {
181
+ readonly id: "a11y-skills/time-limit-timer";
182
+ readonly sc: ["2.2.1"];
183
+ readonly tags: ["a11y-skills", "wcag2a", "wcag221"];
184
+ readonly impact: "moderate";
185
+ readonly scope: "page";
186
+ readonly description: "Detect JavaScript timers that may implement a time limit";
187
+ readonly help: "Verify whether detected timers implement an adjustable time limit";
188
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html";
189
+ readonly classification: "incomplete";
190
+ };
191
+ readonly 'time-limit-countdown': {
192
+ readonly id: "a11y-skills/time-limit-countdown";
193
+ readonly sc: ["2.2.1"];
194
+ readonly tags: ["a11y-skills", "wcag2a", "wcag221"];
195
+ readonly impact: "moderate";
196
+ readonly scope: "node";
197
+ readonly description: "Detect countdown/timeout wording in visible text";
198
+ readonly help: "Verify whether the countdown text indicates an adjustable time limit";
199
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html";
200
+ readonly classification: "incomplete";
201
+ };
202
+ readonly 'auto-play': {
203
+ readonly id: "a11y-skills/auto-play";
204
+ readonly sc: ["2.2.2", "1.4.2"];
205
+ readonly tags: ["a11y-skills", "wcag2a", "wcag222", "wcag142"];
206
+ readonly impact: "moderate";
207
+ readonly scope: "page";
208
+ readonly description: "Detect auto-playing/moving content lasting longer than 5 seconds without a working pause control";
209
+ readonly help: "Moving content over 5 seconds needs a pause, stop, or hide mechanism";
210
+ readonly helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html";
211
+ readonly classification: "incomplete";
212
+ };
213
+ };
214
+ export type RuleKey = keyof typeof RULES;
215
+ /** Look up a rule's metadata. */
216
+ export declare function getRule(key: RuleKey): RuleMeta;