pour-engine 1.32.0 → 1.34.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 (67) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/COVERAGE.md +5 -5
  3. package/engine/index.js +11 -4
  4. package/engine/lib/accessible-name.js +15 -4
  5. package/engine/lib/contrast.js +316 -11
  6. package/engine/lib/roles.js +30 -6
  7. package/engine/rules/best-practice/button-type.js +23 -2
  8. package/engine/rules/best-practice/fieldset-legend.js +7 -2
  9. package/engine/rules/best-practice/heading-order.js +2 -1
  10. package/engine/rules/best-practice/landmark-top-level.js +10 -0
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/new-window-link.js +34 -1
  13. package/engine/rules/best-practice/no-autofocus.js +15 -3
  14. package/engine/rules/best-practice/page-heading-one.js +15 -1
  15. package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
  16. package/engine/rules/best-practice/redundant-aria-label.js +24 -0
  17. package/engine/rules/best-practice/redundant-role.js +71 -20
  18. package/engine/rules/best-practice/region.js +6 -2
  19. package/engine/rules/index.js +13 -0
  20. package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
  21. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  22. package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
  23. package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
  24. package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
  25. package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
  26. package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
  27. package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
  28. package/engine/rules/wcag/1.3.1-form-label.js +39 -1
  29. package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
  30. package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
  31. package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
  32. package/engine/rules/wcag/1.3.4-orientation.js +21 -7
  33. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
  34. package/engine/rules/wcag/1.4.1-link-in-text-block.js +254 -13
  35. package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
  36. package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
  37. package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
  38. package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
  39. package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
  40. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
  41. package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
  42. package/engine/rules/wcag/2.4.2-document-title.js +8 -2
  43. package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
  44. package/engine/rules/wcag/2.4.4-link-name.js +11 -0
  45. package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
  46. package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
  47. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
  48. package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
  49. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
  50. package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
  51. package/engine/rules/wcag/2.5.8-target-size.js +141 -7
  52. package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
  53. package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
  54. package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
  55. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
  56. package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +1 -1
  57. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
  58. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
  59. package/engine/rules/wcag/4.1.2-aria-label-misuse.js +1 -1
  60. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +44 -73
  61. package/engine/rules/wcag/4.1.2-button-name.js +11 -0
  62. package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
  63. package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
  64. package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
  65. package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
  66. package/engine/wcag22.js +4 -4
  67. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  // WAI forms tutorial: radio/checkbox groups need a group label — each
2
2
  // input's own label isn't enough to convey what the group asks.
3
- import { labelledByName } from '../../lib/accessible-name.js';
3
+ import { accessibleName, labelledByName } from '../../lib/accessible-name.js';
4
4
  export default {
5
5
  id: 'fieldset-legend',
6
6
  name: 'Grouped field labels',
@@ -29,9 +29,14 @@ export default {
29
29
  // a reference, or a label. Not a general accessible-name call, which
30
30
  // would fall through to name-from-contents and read the options inside
31
31
  // the fieldset as if they were its name.
32
+ // The legend itself IS named by the accessible-name computation: a
33
+ // legend holding an <img alt="Choose a colour"> names the group (the
34
+ // browser exposes group "Choose a colour"), and raw textContent called
35
+ // it empty (2026-08-25 overnight audit).
32
36
  const group = first.closest('fieldset, [role="group"], [role="radiogroup"]');
37
+ const legend = group?.querySelector(':scope > legend');
33
38
  const grouped = group && (
34
- group.querySelector(':scope > legend')?.textContent.trim()
39
+ (legend && accessibleName(legend))
35
40
  || labelledByName(group)
36
41
  || group.getAttribute('aria-label')?.trim()
37
42
  );
@@ -1,6 +1,7 @@
1
1
  import { effectiveRole } from '../../lib/roles.js';
2
2
 
3
- const level = (el) =>
3
+ // Shared with page-heading-one so both rules agree on what level 1 is.
4
+ export const level = (el) =>
4
5
  el.hasAttribute('aria-level')
5
6
  ? parseInt(el.getAttribute('aria-level'), 10)
6
7
  : parseInt(el.tagName[1], 10) || 2; // role="heading" without aria-level defaults to 2
@@ -9,6 +9,15 @@ const LANDMARK_ROLES = new Set([
9
9
  'main', 'navigation', 'banner', 'contentinfo', 'complementary', 'region', 'search', 'form',
10
10
  ]);
11
11
 
12
+ /** HTML-AAM (form, section): "If a form has no accessible name, do not
13
+ * expose the element as a landmark", and a section is a region only with
14
+ * an accessible name. An unnamed page-wrapping <form> (the WebForms
15
+ * form runat="server" shape) is therefore no landmark container at all,
16
+ * and the banner, main and contentinfo inside it are top level. Mirrors
17
+ * region.js, decided locally here (2026-08-25 overnight audit). */
18
+ const named = (element) =>
19
+ element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby') || element.hasAttribute('title');
20
+
12
21
  /** The nearest ancestor that is a landmark in the accessibility tree —
13
22
  * explicit role OR implicit (nav, labelled section/form, top-level
14
23
  * header/footer). A selector can't express the implicit cases; a
@@ -18,6 +27,7 @@ function landmarkAncestor(element) {
18
27
  for (let parent = element.parentElement; parent; parent = parent.parentElement) {
19
28
  const explicit = parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase();
20
29
  const role = explicit ?? implicitRole(parent);
30
+ if (!explicit && (role === 'form' || role === 'region') && !named(parent)) continue;
21
31
  if (role && LANDMARK_ROLES.has(role)) return { element: parent, role };
22
32
  }
23
33
  return null;
@@ -6,6 +6,14 @@ import { labelledByName } from '../../lib/accessible-name.js';
6
6
 
7
7
  const SINGLETON = new Set(['banner', 'contentinfo', 'main']);
8
8
 
9
+ // ARIA 1.2, Landmark Roles: the eight roles that are landmarks. An explicit
10
+ // non-landmark role on a landmark element (<nav role="menubar">) makes it a
11
+ // widget, not a landmark, so it has nothing to be distinguished from
12
+ // (2026-08-25 overnight audit).
13
+ const LANDMARK_ROLES = new Set([
14
+ 'banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search',
15
+ ]);
16
+
9
17
  /** Landmark role of the element in the accessibility tree — explicit role
10
18
  * first, else the implicit role (which handles header/footer demotion
11
19
  * inside sectioning content and unlabelled sections being generic). */
@@ -39,7 +47,7 @@ export default {
39
47
  const outcomes = elements.map(() => ({ status: 'pass' }));
40
48
  const landmarkIndexes = elements
41
49
  .map((element, index) => ({ element, index, role: roles[index] }))
42
- .filter(({ role }) => role && role !== 'generic' && role !== 'presentation' && role !== 'none');
50
+ .filter(({ role }) => LANDMARK_ROLES.has(role));
43
51
 
44
52
  // Singleton roles: more than one banner/contentinfo/main is a failure
45
53
  // regardless of labels.
@@ -2,6 +2,27 @@
2
2
  // users who can't see the window change. Heuristic is English-only for now.
3
3
  const MENTIONS_NEW_WINDOW = /new (window|tab)|opens? in/i;
4
4
 
5
+ /** The nearest declared language. An undeclared language is judged as
6
+ * English (html-lang reports the missing declaration itself). */
7
+ function effectiveLang(element) {
8
+ return element.closest('[lang]')?.getAttribute('lang')?.trim()
9
+ || element.ownerDocument.documentElement.getAttribute('lang')?.trim()
10
+ || '';
11
+ }
12
+
13
+ /** G201 asks only that a warning is "spoken in assistive technology"; its
14
+ * own second example delivers it through aria-describedby, so the
15
+ * description joins the text tested. Targets are read hidden or not, the
16
+ * way the description computation reads them (2026-08-25 overnight audit). */
17
+ function describedByText(element) {
18
+ const refs = element.getAttribute('aria-describedby');
19
+ if (!refs) return '';
20
+ const root = element.getRootNode();
21
+ return refs.split(/\s+/).filter(Boolean)
22
+ .map((id) => root.getElementById?.(id)?.textContent ?? '')
23
+ .join(' ');
24
+ }
25
+
5
26
  export default {
6
27
  id: 'new-window-link',
7
28
  name: 'New-window warnings',
@@ -11,8 +32,20 @@ export default {
11
32
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Techniques/general/G201',
12
33
  selector: 'a[target="_blank"], a[target="blank"]',
13
34
  evaluate(element, { accessibleName }) {
14
- const name = `${accessibleName(element)} ${element.getAttribute('title') ?? ''}`;
35
+ const name = `${accessibleName(element)} ${element.getAttribute('title') ?? ''} ${describedByText(element)}`;
15
36
  if (MENTIONS_NEW_WINDOW.test(name)) return { status: 'pass' };
37
+ // The wording test reads English only. In another language the DOM
38
+ // cannot prove the warning is missing ("nouvelle fenêtre" satisfies
39
+ // G201 just as well), so the verdict is a question, not a failure
40
+ // (2026-08-25 overnight audit).
41
+ const lang = effectiveLang(element);
42
+ if (lang && !/^en(?:[-_]|$)/i.test(lang)) {
43
+ return {
44
+ status: 'incomplete',
45
+ message: `This link opens a new window or tab. Does its name, title or description say so in the page's language ("${lang}")? The automated wording check reads English only.`,
46
+ fix: 'Say so in the link text or a visually hidden span (for example the local-language equivalent of "opens in new window"), or drop target="_blank".',
47
+ };
48
+ }
16
49
  return {
17
50
  status: 'fail',
18
51
  message: 'This link opens a new window/tab without saying so — the back button stops working and screen-reader users get no warning about the context change.',
@@ -1,3 +1,13 @@
1
+ // Inside a dialog or popover the attribute never acts on page load: the HTML
2
+ // dialog focusing steps pick the autofocus descendant as the control to
3
+ // focus when the dialog is SHOWN, and the spec's own note tells authors to
4
+ // put autofocus on exactly that element (or on the dialog itself when there
5
+ // is none). Outside those containers the spec's caution ("use careful
6
+ // consideration") is real, but its own example of a page whose main purpose
7
+ // IS the control is legitimate use, so the DOM cannot prove a failure: the
8
+ // verdict is a review question (2026-08-25 overnight audit).
9
+ const FOCUS_CONTAINER = 'dialog, [role="dialog"], [role="alertdialog"], [popover]';
10
+
1
11
  export default {
2
12
  id: 'no-autofocus',
3
13
  name: 'Autofocus on load',
@@ -8,10 +18,12 @@ export default {
8
18
  selector: '[autofocus]',
9
19
  visibleOnly: false,
10
20
  evaluate(element) {
21
+ if (element.closest(FOCUS_CONTAINER)) return { status: 'pass' };
22
+ const tag = element.tagName.toLowerCase();
11
23
  return {
12
- status: 'fail',
13
- message: 'autofocus jumps focus past the page start — screen-reader and magnifier users lose all the context before this element.',
14
- fix: `Remove autofocus from the <${element.tagName.toLowerCase()}>; let users reach it in document order.`,
24
+ status: 'incomplete',
25
+ message: `autofocus moves focus to this <${tag}> as soon as the page loads, so screen-reader and magnifier users skip everything before it. Review: the HTML spec allows this only when the control is the page's main purpose (a search box on a search page, say); otherwise it disorients.`,
26
+ fix: `Remove autofocus from the <${tag}> unless this control is the page's whole purpose; let users reach it in document order.`,
15
27
  };
16
28
  },
17
29
  };
@@ -1,3 +1,15 @@
1
+ import { collectRoots } from '../../lib/dom.js';
2
+ import { level } from './heading-order.js';
3
+
4
+ // The outline is what the accessibility tree builds: it spans every open
5
+ // shadow root (a web-component shell whose only h1 renders in its shadow
6
+ // tree HAS an h1, exactly as landmark-one-main counts mains), and a
7
+ // heading's level is aria-level when set, else the tag's own digit, on any
8
+ // h1-h6 or role="heading". heading-order's level helper decides both rules,
9
+ // so <h2 aria-level="1"> starts the outline and <h1 aria-level="2"> does
10
+ // not (2026-08-25 overnight audit).
11
+ const HEADING = 'h1, h2, h3, h4, h5, h6, [role="heading"]';
12
+
1
13
  export default {
2
14
  id: 'page-heading-one',
3
15
  name: 'Leading page heading',
@@ -8,7 +20,9 @@ export default {
8
20
  selector: 'html',
9
21
  visibleOnly: false,
10
22
  evaluate(element) {
11
- if (element.ownerDocument.querySelector('h1, [role="heading"][aria-level="1"]')) return { status: 'pass' };
23
+ const hasLevelOne = collectRoots(element.ownerDocument)
24
+ .some((root) => [...root.querySelectorAll(HEADING)].some((heading) => level(heading) === 1));
25
+ if (hasLevelOne) return { status: 'pass' };
12
26
  return {
13
27
  status: 'fail',
14
28
  message: 'No <h1> — the page outline has no starting point for screen-reader navigation.',
@@ -1,7 +1,15 @@
1
1
  // "photo of"/"photograph of" is deliberately NOT flagged: for portraits it
2
2
  // conveys the medium (a photo of a person vs. a logo or illustration),
3
3
  // which is widely accepted alt style — flagging it is noise.
4
- const REDUNDANT_PHRASE = /^(image|picture|graphic|icon)\b( of\b)?/i;
4
+ //
5
+ // The tip this rule cites (WAI images tutorial) is about announcing the
6
+ // MEDIUM, so only the prefix shape counts: the word followed by "of", a
7
+ // colon, a hyphen, "showing"/"depicting", or the word alone as the whole
8
+ // alt. "Graphic design services" and "Picture frame, oak" are content, and
9
+ // stripping their first word destroys the meaning; "graphic" and "picture"
10
+ // are common nouns, so they count only with "of" (2026-08-25 overnight audit).
11
+ const REDUNDANT_PHRASE =
12
+ /^(?:(?:image|icon)s?(?:\s*[:-]\s*|\s+(?:of|showing|depicting)\b\s*|\s*$)|(?:picture|graphic)s?\s+of\b\s*)/i;
5
13
 
6
14
  export default {
7
15
  id: 'redundant-alt-phrase',
@@ -9,6 +9,29 @@ const NAME_FROM_CONTENT = new Set(['button', 'link', 'menuitem', 'menuitemcheckb
9
9
  'menuitemradio', 'option', 'tab', 'treeitem', 'checkbox', 'radio', 'switch', 'heading',
10
10
  'cell', 'gridcell', 'columnheader', 'rowheader', 'tooltip']);
11
11
 
12
+ const EMPTY_CONTENT = new Set(['none', 'normal', '""', "''"]);
13
+
14
+ /** Name-from-content is not textContent. accname 2F adds ::before/::after
15
+ * text, image alternatives and svg titles, and drops hidden subtrees; when
16
+ * any of those sit inside the element, the aria-label is what keeps the
17
+ * announced name clean (an icon-font glyph before "Menu" would otherwise
18
+ * be spoken as part of the name), so it is not redundant. Only computed
19
+ * for elements whose label already matched their text, so the per-element
20
+ * style reads stay bounded to real candidates (2026-08-25 overnight audit). */
21
+ function contentBeyondText(element) {
22
+ const nodes = [element, ...element.querySelectorAll('*')];
23
+ for (const node of nodes) {
24
+ const tag = node.tagName.toLowerCase();
25
+ if (tag === 'img' || tag === 'area' || tag === 'svg') return true;
26
+ if (node !== element && (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden'))) return true;
27
+ const style = getComputedStyle(node);
28
+ if (node !== element && (style.display === 'none' || style.visibility === 'hidden')) return true;
29
+ if (!EMPTY_CONTENT.has(getComputedStyle(node, '::before').content)) return true;
30
+ if (!EMPTY_CONTENT.has(getComputedStyle(node, '::after').content)) return true;
31
+ }
32
+ return false;
33
+ }
34
+
12
35
  export default {
13
36
  id: 'redundant-aria-label',
14
37
  name: 'Redundant aria-label',
@@ -22,6 +45,7 @@ export default {
22
45
  const label = element.getAttribute('aria-label').replace(/\s+/g, ' ').trim().toLowerCase();
23
46
  const visible = element.textContent.replace(/\s+/g, ' ').trim().toLowerCase();
24
47
  if (!visible || label !== visible) return { status: 'pass' };
48
+ if (contentBeyondText(element)) return { status: 'pass' };
25
49
  return {
26
50
  status: 'fail',
27
51
  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.`,
@@ -1,29 +1,76 @@
1
1
  // "Overdoing it" family: explicit role attributes that duplicate the
2
2
  // element's built-in (implicit) ARIA role are noise — they add maintenance
3
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',
4
+ //
5
+ // The implicit role comes from lib/roles.js, not a private table: the
6
+ // contextual cases (an unnamed aside inside sectioning content is generic,
7
+ // so role="complementary" on it is the ONLY thing making it a landmark) are
8
+ // modelled there and verified against Chromium. The set of elements judged
9
+ // stays as it was, so nothing new is asserted (2026-08-25 overnight audit).
10
+ import { implicitRole as modelledRole } from '../../lib/roles.js';
11
+
12
+ const JUDGED_TAGS = new Set([
13
+ 'ul', 'ol', 'li', 'button', 'nav', 'main', 'aside', 'form', 'table', 'article', 'img', 'hr',
14
+ 'progress', 'textarea', 'dialog', 'option', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
10
15
  // Table structure (th is skipped: its implicit role is contextual —
11
16
  // columnheader or rowheader). header/footer/section are contextual too.
12
- tr: 'row', thead: 'rowgroup', tbody: 'rowgroup', tfoot: 'rowgroup', td: 'cell',
13
- fieldset: 'group',
14
- };
17
+ 'tr', 'thead', 'tbody', 'tfoot', 'td', 'fieldset', 'a', 'input',
18
+ ]);
15
19
 
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
- };
20
+ const JUDGED_INPUT_TYPES = new Set([
21
+ 'checkbox', 'radio', 'button', 'submit', 'reset', 'text', 'email', 'tel', 'url',
22
+ 'range', 'number', 'search',
23
+ ]);
21
24
 
22
25
  function implicitRole(element) {
23
26
  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
+ if (!JUDGED_TAGS.has(tag)) return null;
28
+ if (tag === 'input' && !JUDGED_INPUT_TYPES.has(element.type)) return null;
29
+ const role = modelledRole(element);
30
+ // generic / presentation are "no role to duplicate" here, never a match.
31
+ return role && role !== 'generic' && role !== 'presentation' ? role : null;
32
+ }
33
+
34
+ // Native display value of each table-structure element. When CSS has
35
+ // replaced it (display:flex / grid / block responsive tables), browsers
36
+ // stop exposing a data table and the explicit role is what restores the
37
+ // semantics (MDN, table role: "when CSS's display property overrides the
38
+ // native semantics of a table ... you can use the ARIA table roles to
39
+ // re-add the semantics"). Same shape as the list-style exception
40
+ // (2026-08-25 overnight audit).
41
+ const TABLE_DISPLAY = {
42
+ table: ['table', 'inline-table'],
43
+ thead: ['table-header-group'], tbody: ['table-row-group'], tfoot: ['table-footer-group'],
44
+ tr: ['table-row'], td: ['table-cell'],
45
+ };
46
+ const TABLE_ROLES = new Set(['table', 'rowgroup', 'row', 'cell']);
47
+
48
+ function tableSemanticsStripped(element, tag) {
49
+ const table = tag === 'table' ? element : element.closest('table');
50
+ if (table && !TABLE_DISPLAY.table.includes(getComputedStyle(table).display)) return true;
51
+ const native = TABLE_DISPLAY[tag];
52
+ return !!native && !native.includes(getComputedStyle(element).display);
53
+ }
54
+
55
+ /** An item with no marker: list-style-type none on the item itself, or a
56
+ * ::marker emptied by CSS. Safari/VoiceOver drops list semantics whenever
57
+ * the bullets go, however the CSS spells it (the write-up the exception
58
+ * is based on: "any CSS that would remove the bullet or number indicators
59
+ * of a list's items will also remove the semantics"). The ul's own
60
+ * list-style-type stays "disc" when the property sits on the li, which is
61
+ * the more common spelling (2026-08-25 overnight audit). */
62
+ function markerRemoved(item) {
63
+ if (getComputedStyle(item).listStyleType === 'none') return true;
64
+ const marker = getComputedStyle(item, '::marker').content;
65
+ return marker === 'none' || marker === '""' || marker === "''";
66
+ }
67
+
68
+ function listMarkersRemoved(list) {
69
+ if (getComputedStyle(list).listStyleType === 'none') return true;
70
+ for (const child of list.children) {
71
+ if (child.tagName === 'LI' && markerRemoved(child)) return true;
72
+ }
73
+ return false;
27
74
  }
28
75
 
29
76
  export default {
@@ -38,19 +85,23 @@ export default {
38
85
  const explicit = element.getAttribute('role').trim().split(/\s+/)[0]?.toLowerCase();
39
86
  const implicit = implicitRole(element);
40
87
  if (!implicit || explicit !== implicit) return { status: 'pass' };
88
+ const tag = element.tagName.toLowerCase();
41
89
  // The exceptions this rule's own advice describes are honoured, not
42
90
  // just documented:
43
- // - role="list"/"listitem" on list-style:none lists is the standard
91
+ // - role="list"/"listitem" on marker-less lists is the standard
44
92
  // fix for Safari/VoiceOver dropping list semantics — keep it.
45
93
  // - role="img" on an SVG-source <img> is the standard fix for
46
94
  // VoiceOver skipping the alt text of SVG images — keep it.
47
- if (explicit === 'list' && getComputedStyle(element).listStyleType === 'none') return { status: 'pass' };
95
+ // - table roles on a table whose CSS display is no longer table-* are
96
+ // what keeps it a data table: keep them.
97
+ if (explicit === 'list' && listMarkersRemoved(element)) return { status: 'pass' };
48
98
  if (explicit === 'listitem') {
99
+ if (markerRemoved(element)) return { status: 'pass' };
49
100
  const list = element.closest('ul, ol');
50
101
  if (list && getComputedStyle(list).listStyleType === 'none') return { status: 'pass' };
51
102
  }
103
+ if (TABLE_ROLES.has(explicit) && tableSemanticsStripped(element, tag)) return { status: 'pass' };
52
104
  if (explicit === 'img' && /\.svg([?#]|$)/i.test(element.currentSrc || element.src || '')) return { status: 'pass' };
53
- const tag = element.tagName.toLowerCase();
54
105
  const caveat = implicit === 'list'
55
106
  ? ' (Exception: keep it if you set list-style: none — Safari/VoiceOver drops list semantics without it.)'
56
107
  : '';
@@ -4,14 +4,18 @@
4
4
  // Divs do NOT demote — a header wrapped in plain divs is still a banner.
5
5
  // role="alertdialog" joins dialog: both are modal containers whose content
6
6
  // region navigation reaches through the dialog itself, and consent banners
7
- // (measured on a live news home) are exactly this shape.
7
+ // (measured on a live news home) are exactly this shape. The native
8
+ // <dialog> element has the same implicit role and the same policy; only a
9
+ // showing one (the open attribute is set by show() and showModal()) counts,
10
+ // so a closed dialog cannot make a landmark-less page look landmarked
11
+ // (2026-08-25 overnight audit).
8
12
  const LANDMARK =
9
13
  'main, nav, aside, form[aria-label], form[aria-labelledby], ' +
10
14
  'header:not(:is(article, aside, main, nav, section) header), ' +
11
15
  'footer:not(:is(article, aside, main, nav, section) footer), ' +
12
16
  'section[aria-label], section[aria-labelledby], [role="main"], [role="navigation"], ' +
13
17
  '[role="banner"], [role="contentinfo"], [role="complementary"], [role="region"], ' +
14
- '[role="search"], [role="form"], [role="dialog"], [role="alertdialog"]';
18
+ '[role="search"], [role="form"], [role="dialog"], [role="alertdialog"], dialog[open]';
15
19
 
16
20
  export default {
17
21
  id: 'region',
@@ -90,6 +90,15 @@ import embedAlt from './wcag/1.1.1-embed-alt.js';
90
90
  import canvasAlt from './wcag/1.1.1-canvas-alt.js';
91
91
  import videoLoopMotion from './wcag/2.2.2-video-loop-motion.js';
92
92
  import visualOrderDivergence from './wcag/2.4.3-visual-order-divergence.js';
93
+ // 2026-08-25 gap batch (reports/coverage-gaps/2026-08-25-1915.html): the four
94
+ // A/AA criteria neither engine covered where an honest review question
95
+ // exists. All four ask, none asserts — each SC's failure hinges on a fact
96
+ // (a transcript nearby, an essential confirmation, a no-drag path, meaning
97
+ // in a sequence) the DOM cannot prove.
98
+ import audioTranscript from './wcag/1.2.1-audio-transcript.js';
99
+ import readingOrderDivergence from './wcag/1.3.2-reading-order-divergence.js';
100
+ import dragAlternative from './wcag/2.5.7-drag-alternative.js';
101
+ import redundantEntry from './wcag/3.3.7-redundant-entry.js';
93
102
  import onInputNavigation from './wcag/3.2.2-on-input-navigation.js';
94
103
  import errorMessageLinkage from './wcag/3.3.1-error-message-linkage.js';
95
104
  import compositeWidgetName from './wcag/4.1.2-composite-widget-name.js';
@@ -185,6 +194,10 @@ export default [
185
194
  canvasAlt,
186
195
  videoLoopMotion,
187
196
  visualOrderDivergence,
197
+ audioTranscript,
198
+ readingOrderDivergence,
199
+ dragAlternative,
200
+ redundantEntry,
188
201
  onInputNavigation,
189
202
  errorMessageLinkage,
190
203
  compositeWidgetName,
@@ -11,7 +11,10 @@ export default {
11
11
  if (accessibleName(element)) return { status: 'pass' };
12
12
  return {
13
13
  status: 'fail',
14
- message: 'This image button has no alt text — screen readers announce just "button".',
14
+ // HTML-AAM: a nameless image input takes an implementation-defined
15
+ // name such as "Submit", which says nothing about what THIS button
16
+ // does (2026-08-25 overnight audit).
17
+ message: 'This image button has no alt text, so browsers fall back to a generic "Submit" name that does not describe what the button does.',
15
18
  fix: 'Add alt="What the button does", e.g. alt="Search".',
16
19
  };
17
20
  },
@@ -1,4 +1,9 @@
1
1
  // WCAG SC 1.1.1 Non-text Content (Level A)
2
+ // An <object> that declares a TEXT document (type="text/…", PDF, XHTML, or
3
+ // a typeless data URL whose extension says document) embeds text, not
4
+ // non-text content, so 1.1.1's applicability is itself in question there.
5
+ // That case asks instead of asserting, the same gate embed-alt applies to
6
+ // its sibling element (2026-08-25 overnight audit closed the asymmetry).
2
7
  export default {
3
8
  id: 'object-alt',
4
9
  name: 'Object alt text',
@@ -9,6 +14,18 @@ export default {
9
14
  selector: 'object',
10
15
  evaluate(element, { accessibleName }) {
11
16
  if (accessibleName(element) || element.textContent.trim()) return { status: 'pass' };
17
+ const type = (element.getAttribute('type') ?? '').trim().toLowerCase();
18
+ const data = (element.getAttribute('data') ?? '').split(/[?#]/)[0].toLowerCase();
19
+ const isDocument = type.startsWith('text/')
20
+ || type === 'application/pdf' || type === 'application/xhtml+xml'
21
+ || (!type && /\.(html?|xhtml|pdf|txt)$/.test(data));
22
+ if (isDocument) {
23
+ return {
24
+ status: 'incomplete',
25
+ message: 'This embedded object renders a text document but has no accessible name, so assistive technology users get no indication of what the embedded document is. Check it is identified in the surrounding context.',
26
+ fix: 'Add aria-label="…" naming the embedded document, or fallback content inside the <object> element.',
27
+ };
28
+ }
12
29
  return {
13
30
  status: 'fail',
14
31
  message: 'This embedded object has no text alternative — users who can’t see it get nothing.',
@@ -0,0 +1,47 @@
1
+ // WCAG SC 1.2.1 Audio-only and Video-only (Prerecorded) (Level A) — the
2
+ // audio-only slice
3
+ //
4
+ // Prerecorded audio-only content needs a text alternative that presents
5
+ // equivalent information: a transcript. Whether a transcript exists — a
6
+ // link beside the player, a paragraph below it, a page it points to — is
7
+ // not decidable from the DOM, so this rule never asserts. It asks, once
8
+ // per audio element that can actually present sound to someone.
9
+ //
10
+ // The video-only half of the criterion (silent video needing a transcript
11
+ // or audio track) is a DISCLOSED gap: whether a video file carries an
12
+ // audio stream is not visible from the DOM, so flagging every <video>
13
+ // would drown real findings in noise.
14
+ //
15
+ // The "cannot present sound" logic mirrors media-captions: muteness and
16
+ // controls decide whether audio can reach anyone (properties first —
17
+ // frameworks set them without reflecting attributes), and an element with
18
+ // no media resource presents nothing. The live exemption (1.2.1 covers
19
+ // prerecorded only) is undecidable from the DOM and stays with the human:
20
+ // the message names it rather than the rule guessing.
21
+ export default {
22
+ id: 'audio-transcript',
23
+ name: 'Audio transcript',
24
+ impact: 'serious',
25
+ tags: ['wcag2a', 'wcag121'],
26
+ help: 'Prerecorded audio-only content needs a transcript',
27
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/audio-only-and-video-only-prerecorded.html',
28
+ selector: 'audio',
29
+ visibleOnly: false,
30
+ evaluate(element) {
31
+ // No media resource: nothing is presented, nothing to transcribe
32
+ // (lazy players before a script sets their src).
33
+ if (!element.currentSrc && !element.getAttribute('src') && !element.querySelector('source')) {
34
+ return { status: 'pass' };
35
+ }
36
+ // Muted with no controls: no user path to any sound, so whatever the
37
+ // file carries is never presented to anyone.
38
+ if ((element.muted || element.volume === 0) && !element.hasAttribute('controls')) {
39
+ return { status: 'pass' };
40
+ }
41
+ return {
42
+ status: 'incomplete',
43
+ message: 'This audio content needs a transcript nearby — deaf and hard-of-hearing users get nothing from the recording itself. Check that an equivalent text version exists and is easy to find from the player (1.2.1 covers prerecorded audio; a live stream is 1.2.9 instead).',
44
+ fix: 'Provide a transcript of the recording as text on the page or behind a clearly labelled link next to the player.',
45
+ };
46
+ },
47
+ };
@@ -14,9 +14,24 @@ export default {
14
14
  // state — so a bare <track> is a subtitle track and counts. (An invalid
15
15
  // value defaults to metadata, which does not, so `kind=""` and
16
16
  // `kind="banana"` are deliberately left out.)
17
- if (element.querySelector('track[kind="captions" i], track[kind="subtitles" i], track:not([kind])')) {
17
+ if (element.querySelector('track[kind="captions" i]')) {
18
18
  return { status: 'pass' };
19
19
  }
20
+ // A subtitles track (explicit, or the kind-less default) is not proof
21
+ // of captions: HTML defines subtitles as dialogue for hearers who do
22
+ // not understand the language, captions as dialogue PLUS sound effects
23
+ // and other audio information for people who cannot hear it. H95 says
24
+ // a subtitles track "will not be sufficient" where other audio
25
+ // information matters, while a mislabelled one may be. The DOM cannot
26
+ // tell which, so this is a review, not a pass (David, 2026-08-26; it
27
+ // had passed since the rule was written).
28
+ if (element.querySelector('track[kind="subtitles" i], track:not([kind])')) {
29
+ return {
30
+ status: 'incomplete',
31
+ message: 'This video has a subtitles track but no captions track. Subtitles carry dialogue for people who can hear the audio; captions also carry sound effects and other audio information deaf and hard-of-hearing users need. Check the track covers those, or mark it kind="captions" if it does.',
32
+ fix: 'Use <track kind="captions" src="…" srclang="…" label="…"> for a track that includes non-speech audio information.',
33
+ };
34
+ }
20
35
  // A video with no media resource at all presents nothing — there is no
21
36
  // audio content to caption (lazy players before their source is set).
22
37
  if (!element.currentSrc && !element.getAttribute('src') && !element.querySelector('source')) {
@@ -20,6 +20,32 @@ const roleOf = (element) =>
20
20
  element.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
21
21
  implicitRole(element);
22
22
 
23
+ /**
24
+ * Every element in the COMPOSED subtree of `element`: its light-DOM
25
+ * descendants plus, for each element carrying an open shadow root
26
+ * (`element` itself included), everything inside that shadow tree, again
27
+ * recursively. Assistive technology reads the flat tree, so a role="list"
28
+ * whose items are custom elements rendering role="listitem" inside their
29
+ * own shadow roots is a list of listitems (Chromium exposes list > listitem,
30
+ * listitem); querySelectorAll stops at the shadow boundary and reported it
31
+ * as empty (2026-08-25 overnight audit; same reasoning as
32
+ * aria-required-parent's flat-tree walk). Closed roots stay invisible, as
33
+ * everywhere else in the engine.
34
+ */
35
+ function composedDescendants(element) {
36
+ const found = [];
37
+ const pending = [element];
38
+ for (let i = 0; i < pending.length; i++) {
39
+ const scope = pending[i];
40
+ if (scope.shadowRoot) pending.push(scope.shadowRoot);
41
+ for (const el of scope.querySelectorAll('*')) {
42
+ found.push(el);
43
+ if (el.shadowRoot) pending.push(el.shadowRoot);
44
+ }
45
+ }
46
+ return found;
47
+ }
48
+
23
49
  /**
24
50
  * Everything the container could plausibly own: its whole subtree, plus any
25
51
  * element it claims by aria-owns.
@@ -33,13 +59,13 @@ const roleOf = (element) =>
33
59
  * container is not empty, and that is the only claim this rule should make.
34
60
  */
35
61
  function candidateDescendants(element) {
36
- const found = [...element.querySelectorAll('*')].filter((el) => !el.matches('script, style, template'));
62
+ const found = composedDescendants(element).filter((el) => !el.matches('script, style, template'));
37
63
  const owns = element.getAttribute('aria-owns');
38
64
  if (owns) {
39
65
  const root = element.getRootNode();
40
66
  for (const id of owns.split(/\s+/).filter(Boolean)) {
41
67
  const target = root.getElementById?.(id);
42
- if (target) found.push(target, ...target.querySelectorAll('*'));
68
+ if (target) found.push(target, ...composedDescendants(target));
43
69
  }
44
70
  }
45
71
  return found;
@@ -57,7 +83,8 @@ export default {
57
83
  if (element.getAttribute('aria-busy') === 'true') return { status: 'pass' }; // still loading
58
84
  const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
59
85
  const required = REQUIRED_CHILDREN[role];
60
- const children = [...element.children].filter((c) => !c.matches('script, style, template'));
86
+ const children = [...element.children, ...(element.shadowRoot?.children ?? [])]
87
+ .filter((c) => !c.matches('script, style, template'));
61
88
  // A completely empty container is a lazy-load placeholder more often
62
89
  // than a defect — it announces as an empty list, which is accurate.
63
90
  if (!children.length && !element.hasAttribute('aria-owns')) return { status: 'pass' };