pour-engine 1.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/config/project.config.js +11 -0
  4. package/engine/COVERAGE.md +115 -0
  5. package/engine/README.md +115 -0
  6. package/engine/index.js +258 -0
  7. package/engine/lib/accessible-name.js +126 -0
  8. package/engine/lib/contrast.js +628 -0
  9. package/engine/lib/dom.js +116 -0
  10. package/engine/lib/roles.js +125 -0
  11. package/engine/rules/best-practice/accesskeys.js +29 -0
  12. package/engine/rules/best-practice/aria-label-misuse.js +22 -0
  13. package/engine/rules/best-practice/button-type.js +15 -0
  14. package/engine/rules/best-practice/empty-heading.js +19 -0
  15. package/engine/rules/best-practice/fieldset-legend.js +35 -0
  16. package/engine/rules/best-practice/heading-order.js +30 -0
  17. package/engine/rules/best-practice/landmark-one-main.js +22 -0
  18. package/engine/rules/best-practice/landmark-top-level.js +46 -0
  19. package/engine/rules/best-practice/landmark-unique.js +84 -0
  20. package/engine/rules/best-practice/multiple-labels.js +23 -0
  21. package/engine/rules/best-practice/new-window-link.js +21 -0
  22. package/engine/rules/best-practice/no-autofocus.js +16 -0
  23. package/engine/rules/best-practice/page-heading-one.js +17 -0
  24. package/engine/rules/best-practice/positive-tabindex.js +17 -0
  25. package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
  26. package/engine/rules/best-practice/redundant-aria-label.js +18 -0
  27. package/engine/rules/best-practice/redundant-aria.js +32 -0
  28. package/engine/rules/best-practice/redundant-image-alt.js +20 -0
  29. package/engine/rules/best-practice/redundant-role.js +62 -0
  30. package/engine/rules/best-practice/redundant-tabindex.js +15 -0
  31. package/engine/rules/best-practice/region.js +73 -0
  32. package/engine/rules/index.js +179 -0
  33. package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
  34. package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
  35. package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
  36. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  37. package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
  38. package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
  39. package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
  40. package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
  41. package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
  42. package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
  43. package/engine/rules/wcag/1.3.1-form-label.js +81 -0
  44. package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
  45. package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
  46. package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
  47. package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
  48. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
  49. package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
  50. package/engine/rules/wcag/1.4.10-reflow.js +25 -0
  51. package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
  52. package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
  53. package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
  54. package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
  55. package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
  56. package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
  57. package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
  58. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
  59. package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
  60. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
  61. package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
  62. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
  63. package/engine/rules/wcag/2.4.2-document-title.js +18 -0
  64. package/engine/rules/wcag/2.4.4-link-name.js +19 -0
  65. package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
  66. package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
  67. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
  68. package/engine/rules/wcag/2.5.8-target-size.js +159 -0
  69. package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
  70. package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
  71. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
  72. package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
  73. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
  74. package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
  75. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
  76. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
  77. package/engine/rules/wcag/4.1.2-button-name.js +17 -0
  78. package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
  79. package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
  80. package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
  81. package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
  82. package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
  83. package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
  84. package/engine/wcag22.js +116 -0
  85. package/engine/wcag30-draft.js +149 -0
  86. package/package.json +34 -0
@@ -0,0 +1,17 @@
1
+ export default {
2
+ id: 'positive-tabindex',
3
+ impact: 'serious',
4
+ tags: ['best-practice'],
5
+ help: 'Positive tabindex values disrupt the natural focus order',
6
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html',
7
+ selector: '[tabindex]',
8
+ evaluate(element) {
9
+ const tabindex = parseInt(element.getAttribute('tabindex'), 10);
10
+ if (!(tabindex > 0)) return { status: 'pass' };
11
+ return {
12
+ status: 'fail',
13
+ message: `tabindex="${tabindex}" overrides the natural focus order, which almost always creates a confusing keyboard path.`,
14
+ fix: 'Use tabindex="0" and place the element where it belongs in the document order instead.',
15
+ };
16
+ },
17
+ };
@@ -0,0 +1,22 @@
1
+ // "photo of"/"photograph of" is deliberately NOT flagged: for portraits it
2
+ // conveys the medium (a photo of a person vs. a logo or illustration),
3
+ // which is widely accepted alt style — flagging it is noise.
4
+ const REDUNDANT_PHRASE = /^(image|picture|graphic|icon)\b( of\b)?/i;
5
+
6
+ export default {
7
+ id: 'redundant-alt-phrase',
8
+ impact: 'minor',
9
+ tags: ['best-practice'],
10
+ help: 'alt text should not start with "image of" or similar',
11
+ helpUrl: 'https://www.w3.org/WAI/tutorials/images/tips/',
12
+ selector: 'img[alt]',
13
+ evaluate(element) {
14
+ const alt = element.getAttribute('alt').trim();
15
+ if (!alt || !REDUNDANT_PHRASE.test(alt)) return { status: 'pass' };
16
+ return {
17
+ status: 'fail',
18
+ message: `alt="${alt}" — screen readers already announce this element as an image, so users hear "image, ${alt}".`,
19
+ fix: `Describe the content directly, e.g. alt="${alt.replace(REDUNDANT_PHRASE, '').trim() || '…'}".`,
20
+ };
21
+ },
22
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ id: 'redundant-aria-label',
3
+ impact: 'minor',
4
+ tags: ['best-practice'],
5
+ help: 'aria-label should not repeat the visible text',
6
+ helpUrl: 'https://www.w3.org/TR/using-aria/#rule2',
7
+ selector: '[aria-label]',
8
+ evaluate(element) {
9
+ const label = element.getAttribute('aria-label').replace(/\s+/g, ' ').trim().toLowerCase();
10
+ const visible = element.textContent.replace(/\s+/g, ' ').trim().toLowerCase();
11
+ if (!visible || label !== visible) return { status: 'pass' };
12
+ return {
13
+ status: 'fail',
14
+ message: `aria-label="${element.getAttribute('aria-label')}" is identical to the element's visible text — it adds nothing and will drift out of sync when the text changes.`,
15
+ fix: 'Remove the aria-label and let the visible text be the accessible name.',
16
+ };
17
+ },
18
+ };
@@ -0,0 +1,32 @@
1
+ // "Overdoing it" family: aria-* attributes that duplicate a native HTML
2
+ // attribute the browser already exposes to assistive technology.
3
+ const NATIVE_PAIRS = [
4
+ { aria: 'aria-required', native: 'required' },
5
+ { aria: 'aria-disabled', native: 'disabled' },
6
+ { aria: 'aria-readonly', native: 'readonly' },
7
+ { aria: 'aria-placeholder', native: 'placeholder' },
8
+ { aria: 'aria-checked', native: 'checked' },
9
+ ];
10
+
11
+ export default {
12
+ id: 'redundant-aria',
13
+ impact: 'minor',
14
+ tags: ['best-practice'],
15
+ help: 'aria attributes should not duplicate native HTML attributes',
16
+ helpUrl: 'https://www.w3.org/TR/using-aria/#rule2',
17
+ selector: '[aria-required], [aria-disabled], [aria-readonly], [aria-placeholder], [aria-checked], [aria-hidden="false"]',
18
+ evaluate(element) {
19
+ const redundant = NATIVE_PAIRS
20
+ .filter(({ aria, native }) => element.hasAttribute(aria) && element.hasAttribute(native))
21
+ .map(({ aria, native }) => `${aria} duplicates ${native}`);
22
+ if (element.getAttribute('aria-hidden') === 'false') {
23
+ redundant.push('aria-hidden="false" does nothing reliable — elements are visible to AT by default');
24
+ }
25
+ if (!redundant.length) return { status: 'pass' };
26
+ return {
27
+ status: 'fail',
28
+ message: `Redundant ARIA: ${redundant.join('; ')}. The native attribute already tells assistive technology everything.`,
29
+ fix: 'Remove the aria-* attribute(s) and keep the native HTML attribute.',
30
+ };
31
+ },
32
+ };
@@ -0,0 +1,20 @@
1
+ export default {
2
+ id: 'redundant-image-alt',
3
+ impact: 'minor',
4
+ tags: ['best-practice'],
5
+ help: 'Image alt should not repeat adjacent link or button text',
6
+ helpUrl: 'https://www.w3.org/WAI/tutorials/images/functional/',
7
+ selector: 'a[href] img[alt], button img[alt]',
8
+ evaluate(element) {
9
+ const alt = element.getAttribute('alt').replace(/\s+/g, ' ').trim().toLowerCase();
10
+ if (!alt) return { status: 'pass' };
11
+ const container = element.closest('a[href], button');
12
+ const text = container.textContent.replace(/\s+/g, ' ').trim().toLowerCase();
13
+ if (alt !== text) return { status: 'pass' };
14
+ return {
15
+ status: 'fail',
16
+ message: `The image alt repeats the ${container.tagName === 'A' ? 'link' : 'button'} text — screen readers announce "${element.getAttribute('alt')}" twice.`,
17
+ fix: 'Use alt="" on the image; the visible text already names the control.',
18
+ };
19
+ },
20
+ };
@@ -0,0 +1,62 @@
1
+ // "Overdoing it" family: explicit role attributes that duplicate the
2
+ // element's built-in (implicit) ARIA role are noise — they add maintenance
3
+ // risk without helping assistive technology.
4
+ const IMPLICIT_ROLES = {
5
+ ul: 'list', ol: 'list', li: 'listitem',
6
+ button: 'button', nav: 'navigation', main: 'main', aside: 'complementary',
7
+ form: 'form', table: 'table', article: 'article', img: 'img', hr: 'separator',
8
+ progress: 'progressbar', textarea: 'textbox', dialog: 'dialog', option: 'option',
9
+ h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
10
+ // Table structure (th is skipped: its implicit role is contextual —
11
+ // columnheader or rowheader). header/footer/section are contextual too.
12
+ tr: 'row', thead: 'rowgroup', tbody: 'rowgroup', tfoot: 'rowgroup', td: 'cell',
13
+ fieldset: 'group',
14
+ };
15
+
16
+ const INPUT_ROLES = {
17
+ checkbox: 'checkbox', radio: 'radio', button: 'button', submit: 'button', reset: 'button',
18
+ text: 'textbox', email: 'textbox', tel: 'textbox', url: 'textbox',
19
+ range: 'slider', number: 'spinbutton', search: 'searchbox',
20
+ };
21
+
22
+ function implicitRole(element) {
23
+ const tag = element.tagName.toLowerCase();
24
+ if (tag === 'a') return element.hasAttribute('href') ? 'link' : null;
25
+ if (tag === 'input') return INPUT_ROLES[element.type] ?? null;
26
+ return IMPLICIT_ROLES[tag] ?? null;
27
+ }
28
+
29
+ export default {
30
+ id: 'redundant-role',
31
+ impact: 'minor',
32
+ tags: ['best-practice'],
33
+ help: 'role attributes should not repeat the element’s built-in role',
34
+ helpUrl: 'https://www.w3.org/TR/using-aria/#firstrule',
35
+ selector: '[role]',
36
+ evaluate(element) {
37
+ const explicit = element.getAttribute('role').trim().split(/\s+/)[0]?.toLowerCase();
38
+ const implicit = implicitRole(element);
39
+ if (!implicit || explicit !== implicit) return { status: 'pass' };
40
+ // The exceptions this rule's own advice describes are honoured, not
41
+ // just documented:
42
+ // - role="list"/"listitem" on list-style:none lists is the standard
43
+ // fix for Safari/VoiceOver dropping list semantics — keep it.
44
+ // - role="img" on an SVG-source <img> is the standard fix for
45
+ // VoiceOver skipping the alt text of SVG images — keep it.
46
+ if (explicit === 'list' && getComputedStyle(element).listStyleType === 'none') return { status: 'pass' };
47
+ if (explicit === 'listitem') {
48
+ const list = element.closest('ul, ol');
49
+ if (list && getComputedStyle(list).listStyleType === 'none') return { status: 'pass' };
50
+ }
51
+ if (explicit === 'img' && /\.svg([?#]|$)/i.test(element.currentSrc || element.src || '')) return { status: 'pass' };
52
+ const tag = element.tagName.toLowerCase();
53
+ const caveat = implicit === 'list'
54
+ ? ' (Exception: keep it if you set list-style: none — Safari/VoiceOver drops list semantics without it.)'
55
+ : '';
56
+ return {
57
+ status: 'fail',
58
+ message: `role="${explicit}" is redundant: <${tag}> already has that role built in. Redundant ARIA adds noise and drift risk, not accessibility.${caveat}`,
59
+ fix: `Remove role="${explicit}" from the <${tag}>.`,
60
+ };
61
+ },
62
+ };
@@ -0,0 +1,15 @@
1
+ export default {
2
+ id: 'redundant-tabindex',
3
+ impact: 'minor',
4
+ tags: ['best-practice'],
5
+ help: 'tabindex="0" is unnecessary on natively focusable elements',
6
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html',
7
+ selector: 'button[tabindex="0"], a[href][tabindex="0"], input[tabindex="0"], select[tabindex="0"], textarea[tabindex="0"], summary[tabindex="0"]',
8
+ evaluate(element) {
9
+ return {
10
+ status: 'fail',
11
+ message: `<${element.tagName.toLowerCase()}> is focusable by default — tabindex="0" adds nothing.`,
12
+ fix: 'Remove the tabindex attribute.',
13
+ };
14
+ },
15
+ };
@@ -0,0 +1,73 @@
1
+ const LANDMARK =
2
+ 'main, nav, header, footer, aside, form[aria-label], form[aria-labelledby], ' +
3
+ 'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
4
+ '[role="banner"], [role="contentinfo"], [role="complementary"], [role="region"], ' +
5
+ '[role="search"], [role="form"], [role="dialog"]';
6
+
7
+ export default {
8
+ id: 'region',
9
+ impact: 'moderate',
10
+ tags: ['best-practice'],
11
+ help: 'All readable content belongs inside a landmark region',
12
+ helpUrl: 'https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/',
13
+ // Any element with its own text — wrapper elements without direct text are
14
+ // skipped, so each offending text block is reported exactly once.
15
+ selector: 'body *:not(script):not(style):not(template):not(noscript)',
16
+ // evaluateAll so document-level facts are computed ONCE per audit: a
17
+ // page with NO landmarks at all has one structural problem, not one per
18
+ // text block — landmark-one-main reports it once; flagging every
19
+ // paragraph (30k times on a single-page spec) is noise at ruinous
20
+ // serialization cost. region's per-block value is for pages that HAVE
21
+ // landmarks but leave content outside them.
22
+ evaluateAll(elements, helpers) {
23
+ const doc = elements[0]?.ownerDocument ?? document;
24
+ if (!doc.querySelector(LANDMARK)) return elements.map(() => ({ status: 'pass' }));
25
+ return elements.map((element) => this.judge(element, helpers));
26
+ },
27
+ judge(element, { ownText }) {
28
+ // Any perceivable content counts, not just text — but only content in
29
+ // the accessibility tree: decorative images (alt="", presentation) and
30
+ // unnamed inline svg are invisible to AT, so they can't be "unreachable".
31
+ const isMedia = element.matches(
32
+ 'img:not([alt=""]):not([role="presentation"]):not([role="none"]), svg[role="img"], video, audio, canvas, iframe',
33
+ );
34
+ if (!ownText(element) && !isMedia) return { status: 'pass' };
35
+ if (element.closest(LANDMARK)) return { status: 'pass' };
36
+ const doc = element.ownerDocument;
37
+ // Skip links are the exception the spec itself creates: technique G1/G124
38
+ // (SC 2.4.1) requires them to be the first focusable controls on the
39
+ // page, which puts them before any landmark by construction. A same-page
40
+ // fragment link with a real target, sitting before the first landmark,
41
+ // is that pattern — not stray content. Anything after the first landmark
42
+ // (a floating table of contents, say) still counts.
43
+ const link = element.closest('a[href*="#"]');
44
+ if (link) {
45
+ // Anchors in the wild contain raw '%' that isn't valid
46
+ // percent-encoding (spec section links, e.g. "#sec-%-operator") —
47
+ // decodeURIComponent THROWS on those; fall back to the raw fragment.
48
+ let id = link.getAttribute('href').split('#')[1] ?? '';
49
+ try { id = decodeURIComponent(id); } catch { /* raw % in fragment: use as-is */ }
50
+ const target = id && (doc.getElementById(id) || doc.getElementsByName(id)[0]);
51
+ // "Skip to …" wording is the G1 pattern even when the target lives
52
+ // in a shadow root or the href routes through the page URL — the
53
+ // wording plus position-before-landmarks is evidence enough.
54
+ const looksLikeSkip = /^skip\b/i.test(link.textContent.trim());
55
+ const firstLandmark = doc.querySelector(LANDMARK);
56
+ const beforeLandmarks =
57
+ !firstLandmark ||
58
+ link.compareDocumentPosition(firstLandmark) & Node.DOCUMENT_POSITION_FOLLOWING;
59
+ if ((target || looksLikeSkip) && beforeLandmarks) return { status: 'pass' };
60
+ }
61
+ // One finding per branch: if an ancestor also has stray content it will
62
+ // be flagged itself — reporting the child too is duplicate noise
63
+ // (e.g. a link AND the span inside it).
64
+ for (let parent = element.parentElement; parent && parent !== doc.body; parent = parent.parentElement) {
65
+ if (ownText(parent)) return { status: 'pass' };
66
+ }
67
+ return {
68
+ status: 'fail',
69
+ message: 'This content sits outside any landmark, so screen-reader users navigating by regions never reach it.',
70
+ fix: 'Move it into <main>, <nav>, <header>, <footer>, <aside>, or a labelled <section>.',
71
+ };
72
+ },
73
+ };
@@ -0,0 +1,179 @@
1
+ // Rule registry. Add a rule: create the file in the right folder, import
2
+ // it, add it here.
3
+ //
4
+ // wcag/ — rules mapped to WCAG success criteria (spec-required;
5
+ // tagged with wcag* criterion tags)
6
+ // best-practice/ — opt-in extras tagged 'best-practice': ARIA Authoring
7
+ // Practices, WAI tutorial guidance, and our "overdoing
8
+ // it" redundancy family. Never tagged as WCAG.
9
+
10
+ // --- WCAG ---
11
+ import documentTitle from './wcag/2.4.2-document-title.js';
12
+ import htmlLang from './wcag/3.1.1-html-lang.js';
13
+ import validLangParts from './wcag/3.1.2-valid-lang-parts.js';
14
+ import imageAlt from './wcag/1.1.1-image-alt.js';
15
+ import svgImgAlt from './wcag/1.1.1-svg-img-alt.js';
16
+ import buttonName from './wcag/4.1.2-button-name.js';
17
+ import linkName from './wcag/2.4.4-link-name.js';
18
+ // Parked by product decision (2026-07-29): label-in-name excluded from runs
19
+ // for now — restore the import and registry entry below to bring it back.
20
+ // import labelInName from './wcag/2.5.3-label-in-name.js';
21
+ import formLabel from './wcag/1.3.1-form-label.js';
22
+ import autocompleteValid from './wcag/1.3.5-autocomplete-valid.js';
23
+ import metaViewport from './wcag/1.4.4-meta-viewport.js';
24
+ import colorContrast from './wcag/1.4.3-color-contrast.js';
25
+ import frameTitle from './wcag/4.1.2-frame-title.js';
26
+ import ariaValidRefs from './wcag/4.1.2-aria-valid-refs.js';
27
+ import validRole from './wcag/4.1.2-valid-role.js';
28
+ import ariaHiddenFocus from './wcag/4.1.2-aria-hidden-focus.js';
29
+ import listStructure from './wcag/1.3.1-list-structure.js';
30
+ import nestedInteractive from './wcag/4.1.2-nested-interactive.js';
31
+ import bypassBlocks from './wcag/2.4.1-bypass-blocks.js';
32
+ import targetSize from './wcag/2.5.8-target-size.js';
33
+ import audioControl from './wcag/1.4.2-audio-control.js';
34
+ import pauseStopHide from './wcag/2.2.2-pause-stop-hide.js';
35
+ import mediaCaptions from './wcag/1.2.2-media-captions.js';
36
+ import ariaAttrValid from './wcag/4.1.2-aria-attr-valid.js';
37
+ import ariaAllowedAttr from './wcag/4.1.2-aria-allowed-attr.js';
38
+ import ariaFieldName from './wcag/4.1.2-aria-field-name.js';
39
+ // Demoted to best-practice: prohibited-but-inert ARIA on a generic element
40
+ // fails no success criterion (see the note in the rule file).
41
+ import ariaLabelMisuse from './best-practice/aria-label-misuse.js';
42
+ import roleRequiredAria from './wcag/4.1.2-role-required-aria.js';
43
+ import labelForValid from './wcag/4.1.2-label-for-valid.js';
44
+ import listitemParent from './wcag/1.3.1-listitem-parent.js';
45
+ import definitionList from './wcag/1.3.1-definition-list.js';
46
+ import dlitemParent from './wcag/1.3.1-dlitem-parent.js';
47
+ import areaAlt from './wcag/1.1.1-area-alt.js';
48
+ import objectAlt from './wcag/1.1.1-object-alt.js';
49
+ import inputImageAlt from './wcag/1.1.1-input-image-alt.js';
50
+ // Demoted to best-practice: multiple labels are valid HTML that 3.3.2
51
+ // does not prohibit (see the note in the rule file).
52
+ import multipleLabels from './best-practice/multiple-labels.js';
53
+ import metaRefresh from './wcag/2.2.1-meta-refresh.js';
54
+ import linkInTextBlock from './wcag/1.4.1-link-in-text-block.js';
55
+ import pAsHeading from './wcag/1.3.1-p-as-heading.js';
56
+ import ariaRequiredChildren from './wcag/1.3.1-aria-required-children.js';
57
+ import ariaRequiredParent from './wcag/1.3.1-aria-required-parent.js';
58
+ import scrollableRegionFocusable from './wcag/2.1.1-scrollable-region-focusable.js';
59
+ import tableHeaders from './wcag/1.3.1-table-headers.js';
60
+ import contrastEnhanced from './wcag/1.4.6-contrast-enhanced.js';
61
+ import targetSizeEnhanced from './wcag/2.5.5-target-size-enhanced.js';
62
+ import dialogName from './wcag/4.1.2-dialog-name.js';
63
+ import controlContrast from './wcag/1.4.3-control-contrast.js';
64
+ import nonTextContrast from './wcag/1.4.11-non-text-contrast.js';
65
+ import textSpacing from './wcag/1.4.12-text-spacing.js';
66
+ import reflow from './wcag/1.4.10-reflow.js';
67
+ import focusVisible from './wcag/2.4.7-focus-visible.js';
68
+ import focusNotObscured from './wcag/2.4.11-focus-not-obscured.js';
69
+ import authFieldObstruction from './wcag/3.3.8-auth-field-obstruction.js';
70
+
71
+ // --- Best practice ---
72
+ import headingOrder from './best-practice/heading-order.js';
73
+ import emptyHeading from './best-practice/empty-heading.js';
74
+ import positiveTabindex from './best-practice/positive-tabindex.js';
75
+ import region from './best-practice/region.js';
76
+ import landmarkOneMain from './best-practice/landmark-one-main.js';
77
+ import pageHeadingOne from './best-practice/page-heading-one.js';
78
+ import landmarkUnique from './best-practice/landmark-unique.js';
79
+ import redundantRole from './best-practice/redundant-role.js';
80
+ import redundantAria from './best-practice/redundant-aria.js';
81
+ import redundantAriaLabel from './best-practice/redundant-aria-label.js';
82
+ import redundantAltPhrase from './best-practice/redundant-alt-phrase.js';
83
+ import redundantTabindex from './best-practice/redundant-tabindex.js';
84
+ import redundantImageAlt from './best-practice/redundant-image-alt.js';
85
+ import landmarkTopLevel from './best-practice/landmark-top-level.js';
86
+ import accesskeys from './best-practice/accesskeys.js';
87
+ import buttonType from './best-practice/button-type.js';
88
+ import noAutofocus from './best-practice/no-autofocus.js';
89
+ import newWindowLink from './best-practice/new-window-link.js';
90
+ import fieldsetLegend from './best-practice/fieldset-legend.js';
91
+
92
+ /** Sort key: primary WCAG SC (numeric), best-practice rules after all WCAG
93
+ * rules, ties by rule id. Execution order doesn't change any verdict —
94
+ * spec order just makes progress display and reports predictable. */
95
+ function scKey(rule) {
96
+ const tag = rule.tags.find((t) => /^wcag\d{3,4}$/.test(t));
97
+ if (!tag) return [9, 9, 99];
98
+ const digits = tag.slice(4);
99
+ return [+digits[0], +digits[1], +digits.slice(2)];
100
+ }
101
+
102
+ export default [
103
+ documentTitle,
104
+ htmlLang,
105
+ validLangParts,
106
+ imageAlt,
107
+ svgImgAlt,
108
+ buttonName,
109
+ linkName,
110
+ // labelInName, — parked 2026-07-29, see note at the import
111
+ formLabel,
112
+ autocompleteValid,
113
+ metaViewport,
114
+ colorContrast,
115
+ frameTitle,
116
+ ariaValidRefs,
117
+ validRole,
118
+ ariaHiddenFocus,
119
+ listStructure,
120
+ nestedInteractive,
121
+ bypassBlocks,
122
+ targetSize,
123
+ audioControl,
124
+ pauseStopHide,
125
+ mediaCaptions,
126
+ ariaAttrValid,
127
+ ariaAllowedAttr,
128
+ ariaFieldName,
129
+ ariaLabelMisuse,
130
+ roleRequiredAria,
131
+ labelForValid,
132
+ listitemParent,
133
+ definitionList,
134
+ dlitemParent,
135
+ areaAlt,
136
+ objectAlt,
137
+ inputImageAlt,
138
+ multipleLabels,
139
+ metaRefresh,
140
+ linkInTextBlock,
141
+ pAsHeading,
142
+ ariaRequiredChildren,
143
+ ariaRequiredParent,
144
+ scrollableRegionFocusable,
145
+ tableHeaders,
146
+ dialogName,
147
+ controlContrast,
148
+ nonTextContrast,
149
+ textSpacing,
150
+ reflow,
151
+ focusVisible,
152
+ focusNotObscured,
153
+ authFieldObstruction,
154
+ contrastEnhanced,
155
+ targetSizeEnhanced,
156
+ headingOrder,
157
+ emptyHeading,
158
+ positiveTabindex,
159
+ region,
160
+ landmarkOneMain,
161
+ pageHeadingOne,
162
+ landmarkUnique,
163
+ redundantRole,
164
+ redundantAria,
165
+ redundantAriaLabel,
166
+ redundantAltPhrase,
167
+ redundantTabindex,
168
+ redundantImageAlt,
169
+ landmarkTopLevel,
170
+ accesskeys,
171
+ buttonType,
172
+ noAutofocus,
173
+ newWindowLink,
174
+ fieldsetLegend,
175
+ ].sort((a, b) => {
176
+ const ka = scKey(a);
177
+ const kb = scKey(b);
178
+ return (ka[0] - kb[0]) || (ka[1] - kb[1]) || (ka[2] - kb[2]) || a.id.localeCompare(b.id);
179
+ });
@@ -0,0 +1,18 @@
1
+ // WCAG SC 1.1.1 Non-text Content (Level A) · SC 2.4.4 Link Purpose (In Context) (Level A)
2
+ export default {
3
+ id: 'area-alt',
4
+ impact: 'critical',
5
+ tags: ['wcag2a', 'wcag111', 'wcag244'],
6
+ help: 'Image map areas must have alternative text',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
8
+ selector: 'map area[href]',
9
+ visibleOnly: false,
10
+ evaluate(element, { accessibleName }) {
11
+ if (accessibleName(element)) return { status: 'pass' };
12
+ return {
13
+ status: 'fail',
14
+ message: 'This clickable map area has no alt text — screen readers announce nothing for it.',
15
+ fix: 'Add alt="Where this area links to".',
16
+ };
17
+ },
18
+ };
@@ -0,0 +1,48 @@
1
+ // WCAG SC 1.1.1 Non-text Content (Level A)
2
+ // alt="" (exactly empty) is the CONFORMING decorative marker (H67) — it
3
+ // hides the image from screen readers on purpose and passes. A
4
+ // whitespace-only alt is neither a text alternative nor that marker:
5
+ // assistive technology handles it inconsistently (some announce the file
6
+ // name, some skip it) — that fails. Filename/generic alts technically
7
+ // satisfy presence but tell the user nothing (F30/F39) — flagged for eyes.
8
+ const FILENAME_ALT = /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)([?#].*)?$/i;
9
+ const GENERIC_ALT = /^(image|img|photo|photograph|picture|graphic|icon|untitled|placeholder|spacer|\d+)$/i;
10
+
11
+ export default {
12
+ id: 'image-alt',
13
+ impact: 'critical',
14
+ tags: ['wcag2a', 'wcag111'],
15
+ help: 'Every <img> needs a text alternative',
16
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
17
+ selector: 'img',
18
+ evaluate(element, { accessibleName }) {
19
+ const role = element.getAttribute('role');
20
+ if (role === 'presentation' || role === 'none') return { status: 'pass' };
21
+ if (element.hasAttribute('alt')) {
22
+ const alt = element.getAttribute('alt');
23
+ // alt="" marks the image decorative — valid and deliberate.
24
+ if (alt === '') return { status: 'pass' };
25
+ if (!alt.trim()) {
26
+ return {
27
+ status: 'fail',
28
+ message: `alt="${alt}" is only whitespace — neither a text alternative nor the decorative marker (alt="" exactly); screen readers handle it unpredictably, some falling back to the file name.`,
29
+ fix: 'Use alt="" (nothing between the quotes) for decorative images, or write a real description.',
30
+ };
31
+ }
32
+ const trimmed = alt.trim();
33
+ if (FILENAME_ALT.test(trimmed) || GENERIC_ALT.test(trimmed)) {
34
+ return {
35
+ status: 'incomplete',
36
+ message: `alt="${trimmed}" looks like a file name or placeholder, not a description — screen-reader users learn nothing from it. If the image carries meaning, describe it; if not, use alt="".`,
37
+ };
38
+ }
39
+ return { status: 'pass' };
40
+ }
41
+ if (accessibleName(element)) return { status: 'pass' };
42
+ return {
43
+ status: 'fail',
44
+ message: 'This image has no alt attribute, so screen readers announce its file name or nothing at all.',
45
+ fix: `Describe the image: <img alt="…" src="${element.getAttribute('src') ?? ''}">, or mark it decorative with alt="".`,
46
+ };
47
+ },
48
+ };
@@ -0,0 +1,17 @@
1
+ // WCAG SC 1.1.1 Non-text Content (Level A) · SC 4.1.2 Name, Role, Value (Level A)
2
+ export default {
3
+ id: 'input-image-alt',
4
+ impact: 'critical',
5
+ tags: ['wcag2a', 'wcag111', 'wcag412'],
6
+ help: 'Image inputs need alt text describing their action',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
8
+ selector: 'input[type="image"]',
9
+ evaluate(element, { accessibleName }) {
10
+ if (accessibleName(element)) return { status: 'pass' };
11
+ return {
12
+ status: 'fail',
13
+ message: 'This image button has no alt text — screen readers announce just "button".',
14
+ fix: 'Add alt="What the button does", e.g. alt="Search".',
15
+ };
16
+ },
17
+ };
@@ -0,0 +1,17 @@
1
+ // WCAG SC 1.1.1 Non-text Content (Level A)
2
+ export default {
3
+ id: 'object-alt',
4
+ impact: 'serious',
5
+ tags: ['wcag2a', 'wcag111'],
6
+ help: '<object> embeds must have a text alternative',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
8
+ selector: 'object',
9
+ evaluate(element, { accessibleName }) {
10
+ if (accessibleName(element) || element.textContent.trim()) return { status: 'pass' };
11
+ return {
12
+ status: 'fail',
13
+ message: 'This embedded object has no text alternative — users who can’t see it get nothing.',
14
+ fix: 'Add aria-label="…", or fallback content inside the <object> element.',
15
+ };
16
+ },
17
+ };
@@ -0,0 +1,34 @@
1
+ // WCAG SC 1.1.1 Non-text Content (Level A)
2
+ export default {
3
+ id: 'svg-img-alt',
4
+ impact: 'serious',
5
+ tags: ['wcag2a', 'wcag111'],
6
+ help: 'Inline SVG and role="img" graphics need an accessible name',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
8
+ // <img> itself is handled by image-alt; this covers SVG (with or without a
9
+ // role) and role="img" divs/spans. Top-level plain <svg> is included
10
+ // because browsers expose it to AT as a graphic even without a role.
11
+ selector: 'svg[role="img"], svg[role="graphics-document"], svg[role="graphics-symbol"], [role="img"]:not(img):not(svg), svg:not([role])',
12
+ evaluate(element, { accessibleName }) {
13
+ if (accessibleName(element)) return { status: 'pass' };
14
+ if (!element.getAttribute('role') && element.tagName.toLowerCase() === 'svg') {
15
+ if (element.parentNode instanceof SVGElement) return { status: 'pass' }; // nested svg: part of the outer graphic
16
+ // Inside a control that already has a name, the svg is effectively
17
+ // decorative icon-work — the name covers it.
18
+ const control = element.closest('a[href], button, [role="button"], [role="link"]');
19
+ if (control && accessibleName(control)) return { status: 'pass' };
20
+ // Unlabeled and roleless: AT behaviour varies, and we can't tell
21
+ // meaningful from decorative — a human call, not an automatic fail.
22
+ return {
23
+ status: 'incomplete',
24
+ message: 'This SVG has no accessible name and is not marked decorative — some screen readers announce it as an unlabeled graphic.',
25
+ fix: 'Add aria-hidden="true" if decorative; if meaningful, add role="img" and aria-label="…" (or a <title> as the first child).',
26
+ };
27
+ }
28
+ return {
29
+ status: 'fail',
30
+ message: 'This element is marked as an image but has no accessible name.',
31
+ fix: 'Add aria-label="…" (for SVG, a <title> as the first child also works).',
32
+ };
33
+ },
34
+ };
@@ -0,0 +1,19 @@
1
+ // WCAG SC 1.2.2 Captions (Prerecorded) (Level A)
2
+ export default {
3
+ id: 'media-captions',
4
+ impact: 'critical',
5
+ tags: ['wcag2a', 'wcag122'],
6
+ help: 'Video content must have captions',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/captions-prerecorded.html',
8
+ selector: 'video',
9
+ visibleOnly: false,
10
+ evaluate(element) {
11
+ if (element.querySelector('track[kind="captions"], track[kind="subtitles"]')) return { status: 'pass' };
12
+ // Captions may be burned in or provided by the player — a human must check.
13
+ return {
14
+ status: 'incomplete',
15
+ message: 'No caption track found on this video. If captions aren’t burned in or provided by the player, deaf and hard-of-hearing users are excluded — please verify.',
16
+ fix: 'Add <track kind="captions" src="…" srclang="…" label="…"> with a WebVTT file.',
17
+ };
18
+ },
19
+ };