@surea11y/core 1.5.0 → 1.6.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 (145) hide show
  1. package/CHANGELOG.md +193 -149
  2. package/README.md +27 -6
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +2 -2
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +16 -4
  8. package/docs/I18N.md +4 -4
  9. package/docs/INTEGRATION.md +1 -1
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/REPORT.md +1 -1
  12. package/docs/RULE_AUTHORING.md +53 -25
  13. package/docs/RULE_CATALOG.md +1878 -169
  14. package/docs/RULE_TAXONOMY.md +2 -2
  15. package/docs/TROUBLESHOOTING.md +2 -2
  16. package/docs/WCAG_CONFORMANCE.md +25 -9
  17. package/package.json +3 -7
  18. package/src/baseline.js +3 -3
  19. package/src/checks/automatic/area-alt-present.js +2 -2
  20. package/src/checks/automatic/aria-allowed-attr.js +68 -10
  21. package/src/checks/automatic/aria-allowed-role.js +2 -2
  22. package/src/checks/automatic/aria-braille-equivalent.js +3 -3
  23. package/src/checks/automatic/aria-conditional-attr.js +5 -5
  24. package/src/checks/automatic/aria-deprecated-role.js +1 -1
  25. package/src/checks/automatic/aria-hidden-body.js +2 -2
  26. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  27. package/src/checks/automatic/aria-prohibited-attr.js +18 -18
  28. package/src/checks/automatic/aria-prohibited-children.js +130 -37
  29. package/src/checks/automatic/aria-required-attr.js +60 -12
  30. package/src/checks/automatic/aria-required-children.js +21 -14
  31. package/src/checks/automatic/aria-required-parent.js +61 -9
  32. package/src/checks/automatic/aria-role-name-present.js +36 -22
  33. package/src/checks/automatic/aria-valid-attr-value.js +15 -12
  34. package/src/checks/automatic/aria-valid-attr.js +1 -1
  35. package/src/checks/automatic/autocomplete-valid.js +2 -2
  36. package/src/checks/automatic/binary-control-name-present.js +27 -5
  37. package/src/checks/automatic/button-name-present.js +92 -6
  38. package/src/checks/automatic/combobox-name-present.js +26 -6
  39. package/src/checks/automatic/contrast-computable.js +32 -0
  40. package/src/checks/automatic/contrast-enhanced.js +21 -1
  41. package/src/checks/automatic/contrast-minimum.js +21 -1
  42. package/src/checks/automatic/css-orientation-lock.js +96 -19
  43. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  44. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  45. package/src/checks/automatic/dialog-name-present.js +20 -2
  46. package/src/checks/automatic/duplicate-id-aria.js +5 -3
  47. package/src/checks/automatic/duplicate-id.js +198 -0
  48. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  49. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  50. package/src/checks/automatic/form-control-single-label.js +1 -1
  51. package/src/checks/automatic/iframe-focusable-content.js +63 -7
  52. package/src/checks/automatic/iframe-name-present.js +37 -3
  53. package/src/checks/automatic/iframe-title-unique.js +1 -1
  54. package/src/checks/automatic/img-alt-present.js +12 -4
  55. package/src/checks/automatic/label-in-name.js +172 -18
  56. package/src/checks/automatic/link-in-text-block.js +10 -10
  57. package/src/checks/automatic/link-name-present.js +22 -1
  58. package/src/checks/automatic/list-children-valid.js +6 -6
  59. package/src/checks/automatic/listbox-name-present.js +28 -8
  60. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  61. package/src/checks/automatic/menuitem-name-present.js +20 -2
  62. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  63. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  64. package/src/checks/automatic/meter-name-present.js +23 -4
  65. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  66. package/src/checks/automatic/option-name-present.js +23 -4
  67. package/src/checks/automatic/page-title-present.js +21 -3
  68. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  69. package/src/checks/automatic/progressbar-name-present.js +23 -4
  70. package/src/checks/automatic/role-img-alt-present.js +64 -16
  71. package/src/checks/automatic/searchbox-name-present.js +28 -8
  72. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  73. package/src/checks/automatic/slider-name-present.js +27 -6
  74. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  75. package/src/checks/automatic/summary-name-present.js +18 -2
  76. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  77. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  78. package/src/checks/automatic/tab-name-present.js +21 -2
  79. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  80. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  81. package/src/checks/automatic/target-size-minimum.js +71 -53
  82. package/src/checks/automatic/td-has-header.js +5 -5
  83. package/src/checks/automatic/textbox-name-present.js +28 -8
  84. package/src/checks/automatic/tooltip-name-present.js +21 -2
  85. package/src/checks/automatic/treeitem-name-present.js +23 -4
  86. package/src/checks/automatic/valid-lang.js +92 -7
  87. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  88. package/src/checks/manual/accesskeys-manual.js +3 -3
  89. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  90. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  91. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  92. package/src/checks/manual/aria-text-manual.js +4 -4
  93. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  94. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  95. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  96. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  97. package/src/checks/manual/empty-heading-manual.js +58 -11
  98. package/src/checks/manual/empty-table-header-manual.js +8 -8
  99. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  100. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  101. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  102. package/src/checks/manual/heading-order-manual.js +3 -3
  103. package/src/checks/manual/heading-quality-manual.js +338 -0
  104. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  105. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  106. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  107. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  108. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  109. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  110. package/src/checks/manual/label-title-only-manual.js +4 -4
  111. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  112. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  113. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  114. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  115. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  116. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  117. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  118. package/src/checks/manual/landmark-unique-manual.js +9 -9
  119. package/src/checks/manual/link-name-quality-manual.js +161 -32
  120. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  121. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  122. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  123. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  124. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  125. package/src/checks/manual/p-as-heading-manual.js +4 -4
  126. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  127. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  128. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  129. package/src/checks/manual/region-manual.js +19 -19
  130. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  131. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  132. package/src/checks/manual/skip-link-manual.js +5 -5
  133. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  134. package/src/checks/manual/tabindex-manual.js +2 -2
  135. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  136. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  137. package/src/checks/manual/video-caption-manual.js +3 -3
  138. package/src/checks/manual-review.js +17 -1
  139. package/src/core.js +8965 -1647
  140. package/src/report.js +2 -2
  141. package/surea11y.browser.js +3768 -611
  142. package/surea11y.i18n.de.js +1 -1
  143. package/surea11y.i18n.es.js +1 -1
  144. package/surea11y.i18n.fr.js +1 -1
  145. package/bin/surea11y-core.js +0 -20
@@ -6,7 +6,7 @@
6
6
  * @check landmark-no-duplicate-main
7
7
  * @atomic true
8
8
  * @summary A page must not have more than one main landmark
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever the page contains at least one main landmark
12
12
  * (explicit role="main", or an implicit <main>).
@@ -15,7 +15,7 @@
15
15
  * decision from landmark-one-main (that rule flags zero
16
16
  * mains too; this one only flags more than one).
17
17
  * @implementation-notes
18
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
18
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
19
19
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
20
20
  * header comment for the shared rationale/precedent.
21
21
  * - Only landmarks actually exposed to assistive technology can collide.
@@ -6,24 +6,24 @@
6
6
  * @check landmark-one-main
7
7
  * @atomic true
8
8
  * @summary The page should have a main landmark
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
- * Always applicable to any HTML document with a <body> element —
11
+ * Always applicable to any HTML document with a <body> element:
12
12
  * "does the page have a main landmark" is a whole-page concern,
13
13
  * matching bypass-blocks-present's pattern of evaluating the
14
14
  * document directly.
15
15
  * @expectation
16
16
  * At least one main landmark (role="main" or <main>), exposed to
17
- * assistive technology, exists on the page — a page with none gives
17
+ * assistive technology, exists on the page. A page with none gives
18
18
  * AT users no landmark to jump straight to for the primary content.
19
19
  * @implementation-notes
20
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
21
21
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
22
22
  * header comment for the shared rationale/precedent.
23
23
  * - Presence-only: a plain descendant-exists test. It does NOT flag more
24
24
  * than one main. "More than one main" is a separate rule,
25
- * `landmark-no-duplicate-main`, already implemented — deliberately not
26
- * duplicated here, since a page can genuinely have two visible `<main>`
25
+ * `landmark-no-duplicate-main`, already implemented and not
26
+ * duplicated here, since a page can legitimately have two visible `<main>`
27
27
  * elements, which is out of scope for "does a main landmark exist," not
28
28
  * a violation this rule should report.
29
29
  * - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
@@ -6,20 +6,20 @@
6
6
  * @check landmark-unique
7
7
  * @atomic true
8
8
  * @summary Landmarks sharing the same role must have unique accessible names
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies whenever two or more landmark regions on the page share the
12
12
  * same landmark role (banner, contentinfo, main, navigation,
13
- * complementary, region, form, or search — see implementation notes
13
+ * complementary, region, form, or search; see implementation notes
14
14
  * for the detection model).
15
15
  * @expectation
16
16
  * Among landmarks sharing a role, each has a distinct accessible name
17
- * (via aria-label/aria-labelledby — landmarks are not named from
17
+ * (via aria-label/aria-labelledby; landmarks are not named from
18
18
  * content). Two same-role landmarks with the same name (including two
19
19
  * both left unnamed) are indistinguishable to assistive technology
20
20
  * users navigating by landmark.
21
21
  * @implementation-notes
22
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
22
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
23
23
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
24
24
  * header comment for the shared rationale/precedent and the landmark-
25
25
  * detection model (HTML-AAM implicit-role mapping + explicit role
@@ -80,18 +80,18 @@ function runInPage(ctx) {
80
80
 
81
81
  // Delegates to the shared helpers.hasLandmarkScopingAncestor (role-aware:
82
82
  // an ancestor's bare TAG only counts when it carries no role attribute at
83
- // all; an explicit role="dialog"-style override no longer suppresses —
83
+ // all; an explicit role="dialog"-style override no longer suppresses;
84
84
  // see that function's header comment in src/core/aria-helpers.js), using
85
85
  // two distinct ancestor scopes rather than one shared list: <header>/
86
86
  // <footer> use "sectioning content PLUS <main>" (includeMain: true) to
87
87
  // decide banner/contentinfo suppression, but <aside> uses PLAIN
88
- // sectioning content only — NOT main (includeMain: false) — to decide
88
+ // sectioning content only, not main (includeMain: false), to decide
89
89
  // complementary suppression. A single shared sectioning-ancestors set
90
90
  // that includes 'main' is correct for header/footer but wrong for aside:
91
91
  // e.g. two unnamed <aside> elements that are direct children of <main>
92
92
  // would have their implicit "complementary" role incorrectly suppressed,
93
93
  // hiding a real duplicate-landmark violation. The role-aware half matters
94
- // too: e.g. an <aside role="dialog"> containing its own <header> —
94
+ // too: take an <aside role="dialog"> containing its own <header>.
95
95
  // role="dialog" isn't one of the four scoping roles, so the nested
96
96
  // <header> keeps "banner" per spec, but a tag-only (non-role-aware)
97
97
  // check would unconditionally suppress it just because the ancestor TAG
@@ -110,7 +110,7 @@ function runInPage(ctx) {
110
110
  if (tag === 'nav') return 'navigation';
111
111
  if (tag === 'aside') {
112
112
  // An <aside> is suppressed by a sectioning-content ancestor ONLY
113
- // when it also has no accessible name — a named <aside> is never
113
+ // when it also has no accessible name. A named <aside> is never
114
114
  // suppressed, even when nested.
115
115
  if (!hasSectioningAncestor(el, false)) return 'complementary';
116
116
  return getAccessibleLandmarkName(el) ? 'complementary' : '';
@@ -137,7 +137,7 @@ function runInPage(ctx) {
137
137
  if (explicit) {
138
138
  if (!LANDMARK_ROLES.has(explicit)) return '';
139
139
  // <form>/<section> only count as landmarks when they have an
140
- // accessible name — a property of the ELEMENT, not of how the role
140
+ // accessible name, a property of the ELEMENT, not of how the role
141
141
  // got there. This applies whether the role is implicit (already
142
142
  // handled in getImplicitLandmarkRole below) or explicit. Per the W3C
143
143
  // ARIA-in-HTML spec ("a form is not exposed as a landmark region
@@ -9,29 +9,38 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.4.4
11
11
  * @applicability
12
- * Elements matching `a[href]` with a non-empty computed accessible
13
- * name (programmatic first, then "name from content" — same
14
- * two-step resolution as `link-name-present`). Links with no name at
15
- * all are `link-name-present`'s concern, not this rule's.
12
+ * Elements matching `a[href], area[href], [role="link"]` with a
13
+ * non-empty computed accessible name (programmatic first, then "name
14
+ * from content", same two-step resolution as `link-name-present`,
15
+ * same selector too). Links with no name at all are
16
+ * `link-name-present`'s concern, not this rule's.
16
17
  * @expectation
17
18
  * The link's full accessible name, normalized (trimmed, case-folded,
18
19
  * trailing punctuation stripped), is not an exact match for a known
19
20
  * non-descriptive phrase ("click here", "read more", "more", "here",
20
- * "details", "link", etc.) — WCAG technique F84's known failure
21
- * pattern for SC 2.4.4.
21
+ * "details", "link", etc., WCAG technique F84's known failure pattern
22
+ * for SC 2.4.4) or a bare file-format/type name ("HTML", "PDF", "EPUB",
23
+ * ...) with no adjacent context (an aria-describedby target, the
24
+ * enclosing list item/table cell/paragraph's own text, or (format
25
+ * names only) a table's first-row header) naming what it belongs to.
22
26
  * @implementation-notes
23
- * - Deliberately EXACT match only, against a small, well-established
24
- * phrase list — not a substring/contains check. "Read more about our
27
+ * - EXACT match only, on purpose, against small, well-established
28
+ * phrase lists, not a substring/contains check. "Read more about our
25
29
  * privacy policy" does not match "read more"; only the bare phrase
26
30
  * alone does. This keeps false positives near zero at the cost of
27
31
  * not catching every possible non-descriptive phrasing (e.g. "click
28
- * this", a legitimate but uncommon variant, is not in the list).
29
- * - Authored as `type: 'manual'` (cantTell-capped, never fail): this
30
- * check does not verify whether *surrounding context* (adjacent text,
31
- * aria-describedby) makes the purpose clear, which is exactly what
32
- * distinguishes a genuine 2.4.4 failure (context doesn't help) from
33
- * a link that's fine in context despite generic-sounding text alone.
34
- * Flagging is a signal for review, not a definitive violation.
32
+ * this", a legitimate but uncommon variant, is not in either list).
33
+ * - Adjacent-context detection climbs through a bare wrapping list into
34
+ * an outer list item, so "Ulysses" heading a list of per-format
35
+ * download links still counts as that list's context. The table-header
36
+ * signal is format-name-only: a header cell can be present and still
37
+ * say nothing about what a generic "Download" link in the same row
38
+ * leads to, so the boilerplate-phrase list doesn't get that credit.
39
+ * - Still `type: 'manual'` (cantTell-capped, never fail): finding no
40
+ * adjacent context is a strong signal, not proof that context doesn't
41
+ * exist elsewhere (a preceding heading several rows up, page-level
42
+ * framing) or that the surrounding text actually disambiguates.
43
+ * Flagging is for review, not a definitive violation.
35
44
  * - Reuses the same accessible-name computation as `link-name-present`
36
45
  * (`getAccessibleNameInfo` then `getContentNameInfo` as fallback), so
37
46
  * this benefits from the same "name from content" recursion fix
@@ -43,7 +52,7 @@ const id = 'link-name-quality';
43
52
  const meta = {
44
53
  title: 'Link text should be descriptive, not generic',
45
54
  description:
46
- 'Flags links whose full accessible name is a known non-descriptive phrase (e.g. "click here", "read more", "more"), for manual review of whether the purpose is clear without additional context.',
55
+ 'Flags links whose full accessible name is a known non-descriptive phrase (e.g. "click here", "read more", "more") or a bare file-format name (e.g. "HTML", "PDF") with no adjacent context naming what it leads to, for manual review of whether the purpose is clear.',
47
56
  i18n: {
48
57
  titleKey: 'linkNameQuality_title',
49
58
  descriptionKey: 'linkNameQuality_description'
@@ -92,6 +101,29 @@ function runInPage(ctx) {
92
101
  'info'
93
102
  ]);
94
103
 
104
+ const FORMAT_NAME_LINK_TEXT = new Set([
105
+ 'html',
106
+ 'pdf',
107
+ 'epub',
108
+ 'txt',
109
+ 'plain text',
110
+ 'doc',
111
+ 'docx',
112
+ 'xml',
113
+ 'zip',
114
+ 'mp3',
115
+ 'mp4',
116
+ 'csv',
117
+ 'xls',
118
+ 'xlsx',
119
+ 'ppt',
120
+ 'pptx',
121
+ 'json',
122
+ 'rtf'
123
+ ]);
124
+
125
+ const CONTEXT_BLOCK_TAGS = new Set(['td', 'th', 'p', 'dd', 'blockquote', 'figcaption', 'dt']);
126
+
95
127
  function normalize(s) {
96
128
  return (s == null ? '' : String(s))
97
129
  .replace(/\s+/g, ' ')
@@ -101,7 +133,84 @@ function runInPage(ctx) {
101
133
  .trim();
102
134
  }
103
135
 
104
- const selector = 'a[href]';
136
+ function ownDirectText(el) {
137
+ let out = '';
138
+ const kids = el.childNodes || [];
139
+ for (let i = 0; i < kids.length; i++) {
140
+ const n = kids[i];
141
+ if (n.nodeType === 3) out += n.nodeValue || '';
142
+ }
143
+ return out.replace(/\s+/g, ' ').trim();
144
+ }
145
+
146
+ function isSubstantiveContext(text) {
147
+ return !!text && text.length >= 3 && /\p{L}/u.test(text);
148
+ }
149
+
150
+ // Direct text of the nearest enclosing list item/table cell/paragraph,
151
+ // climbing through a wrapping <ul>/<ol> into an outer <li> when the
152
+ // immediate one carries none of its own (a heading li wrapping a
153
+ // nested list of links, e.g. "Ulysses" above per-format download
154
+ // links).
155
+ function nearestBlockContextText(el) {
156
+ let node = el.parentElement;
157
+ let liHops = 0;
158
+ while (node) {
159
+ const tag = (node.tagName || '').toLowerCase();
160
+ if (tag === 'li') {
161
+ const text = ownDirectText(node);
162
+ if (text) return text;
163
+ liHops += 1;
164
+ if (liHops >= 4) return '';
165
+ const list = node.parentElement;
166
+ node = list ? list.parentElement : null;
167
+ continue;
168
+ }
169
+ if (CONTEXT_BLOCK_TAGS.has(tag)) return ownDirectText(node);
170
+ return '';
171
+ }
172
+ return '';
173
+ }
174
+
175
+ function describedByContextText(el) {
176
+ const describedBy = el.getAttribute ? el.getAttribute('aria-describedby') : null;
177
+ if (!describedBy || !describedBy.trim() || !helpers.getTextFromIdRefs) return '';
178
+ try {
179
+ const info = helpers.getTextFromIdRefs(describedBy, ctx);
180
+ return info && info.text ? info.text.replace(/\s+/g, ' ').trim() : '';
181
+ } catch {
182
+ return '';
183
+ }
184
+ }
185
+
186
+ // A table's first-row header text, when the link sits in a later row of
187
+ // the same table -- naming the row's subject is exactly what turns a
188
+ // bare format name ("HTML") into a link whose destination is clear.
189
+ function firstRowHeaderText(el) {
190
+ const cell = el.closest ? el.closest('td, th') : null;
191
+ if (!cell) return '';
192
+ const table = cell.closest ? cell.closest('table') : null;
193
+ if (!table || !table.rows || !table.rows.length) return '';
194
+ const headerRow = table.rows[0];
195
+ const cellRow = cell.closest ? cell.closest('tr') : null;
196
+ if (!cellRow || headerRow === cellRow) return '';
197
+ const ths = headerRow.querySelectorAll ? headerRow.querySelectorAll('th') : [];
198
+ if (!ths.length) return '';
199
+ return Array.prototype.map
200
+ .call(ths, (th) => th.textContent || '')
201
+ .join(' ')
202
+ .replace(/\s+/g, ' ')
203
+ .trim();
204
+ }
205
+
206
+ function hasAdequateContext(el, tier) {
207
+ if (isSubstantiveContext(describedByContextText(el))) return true;
208
+ if (isSubstantiveContext(nearestBlockContextText(el))) return true;
209
+ if (tier === 'format' && isSubstantiveContext(firstRowHeaderText(el))) return true;
210
+ return false;
211
+ }
212
+
213
+ const selector = 'a[href], area[href], [role="link"]';
105
214
  const nodes = helpers.queryAllSmart
106
215
  ? helpers.queryAllSmart(selector)
107
216
  : helpers.queryAll(selector);
@@ -131,27 +240,47 @@ function runInPage(ctx) {
131
240
 
132
241
  applicableCount += 1;
133
242
 
134
- if (!GENERIC_LINK_TEXT.has(normalized)) continue;
243
+ const isGeneric = GENERIC_LINK_TEXT.has(normalized);
244
+ const isFormatName = !isGeneric && FORMAT_NAME_LINK_TEXT.has(normalized);
245
+ if (!isGeneric && !isFormatName) continue;
246
+
247
+ const tier = isGeneric ? 'generic' : 'format';
248
+ if (hasAdequateContext(el, tier)) continue;
135
249
 
136
250
  const eligInfo = helpers.getEligibilityInfo
137
251
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
138
252
  : null;
139
253
 
140
- occurrences.push(
141
- helpers.reportOccurrence(el, {
142
- summary: `This link's accessible name ("${rawName.trim()}") is a generic, non-descriptive phrase.`,
143
- hint: 'Make the link text itself describe its destination/purpose (e.g. "Download the 2026 pricing guide" instead of "Download"), or confirm the surrounding context already makes the purpose clear.',
144
- i18n: {
145
- summaryKey: 'linkNameQuality_summary_cantTell',
146
- hintKey: 'linkNameQuality_hint_cantTell',
147
- params: { name: rawName.trim() }
148
- },
149
- data: {
150
- details: { reasonCode: 'GENERIC_LINK_TEXT', normalizedName: normalized },
151
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
254
+ const name = rawName.trim();
255
+ const reportOpts = isGeneric
256
+ ? {
257
+ summary: `This link's accessible name ("${name}") is a generic, non-descriptive phrase.`,
258
+ hint: 'Make the link text itself describe its destination/purpose (e.g. "Download the 2026 pricing guide" instead of "Download"), or confirm the surrounding context already makes the purpose clear.',
259
+ i18n: {
260
+ summaryKey: 'linkNameQuality_summary_cantTell',
261
+ hintKey: 'linkNameQuality_hint_cantTell',
262
+ params: { name }
263
+ },
264
+ data: {
265
+ details: { reasonCode: 'GENERIC_LINK_TEXT', normalizedName: normalized },
266
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
267
+ }
152
268
  }
153
- })
154
- );
269
+ : {
270
+ summary: `This link's accessible name ("${name}") names a file format/type but not the document it belongs to.`,
271
+ hint: 'Name the document in the link text or in nearby text/a heading the link is associated with (e.g. "Download the annual report (HTML)" instead of a bare "HTML").',
272
+ i18n: {
273
+ summaryKey: 'linkNameQuality_summary_cantTell_formatName',
274
+ hintKey: 'linkNameQuality_hint_cantTell_formatName',
275
+ params: { name }
276
+ },
277
+ data: {
278
+ details: { reasonCode: 'AMBIGUOUS_FORMAT_NAME', normalizedName: normalized },
279
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
280
+ }
281
+ };
282
+
283
+ occurrences.push(helpers.reportOccurrence(el, reportOpts));
155
284
  }
156
285
 
157
286
  if (applicableCount === 0) {
@@ -18,10 +18,9 @@
18
18
  const id = 'media-alternative-transcript-evidence';
19
19
 
20
20
  const meta = {
21
- title: 'Time-based media: transcript / media alternative evidence',
21
+ title: 'Time-based media: transcript or text alternative evidence',
22
22
  description:
23
- 'Finds <audio>/<video> elements where a transcript or other text alternative is not strongly evidenced in-page. ' +
24
- 'This rule is conservative and returns cantTell when evidence is missing or unverified.',
23
+ 'Finds audio and video elements where a transcript or other text alternative is not strongly evidenced in the page content. This rule is conservative and reports cantTell when evidence is missing or cannot be verified.',
25
24
  i18n: {
26
25
  titleKey: 'mediaTranscriptPresent_title',
27
26
  descriptionKey: 'mediaTranscriptPresent_description'
@@ -6,7 +6,7 @@
6
6
  * @check meta-viewport-large
7
7
  * @atomic true
8
8
  * @summary Viewport meta tag should allow zooming up to 500% (AAA-level)
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
11
  * Applies to <meta name="viewport"> elements that carry a non-empty
12
12
  * content attribute.
@@ -17,7 +17,7 @@
17
17
  * enforces the AA 200% minimum as a hard, WCAG-normative fail); this
18
18
  * rule is advisory best-practice guidance toward the higher AAA bar.
19
19
  * @implementation-notes
20
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
20
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
21
21
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
22
22
  * header comment for the shared rationale/precedent.
23
23
  * - Distinct, atomic decision from meta-viewport-zoom-enabled
@@ -18,31 +18,31 @@
18
18
  * The element also carries at least one keyboard-reachable inline
19
19
  * handler: `onkeydown`, `onkeyup`, `onkeypress` (the direct keyboard-
20
20
  * event equivalents), or `onfocus`/`onblur` (the standard substitute
21
- * for hover-triggered behavior — focus/blur are the keyboard-
21
+ * for hover-triggered behavior: focus/blur are the keyboard-
22
22
  * navigable analog to mouseover/mouseout, per WCAG technique G90).
23
23
  * Otherwise the element's mouse-driven behavior (a hover tooltip, a
24
24
  * custom dropdown, a drag interaction) has no way to be triggered by a
25
25
  * keyboard-only user.
26
26
  * @implementation-notes
27
27
  * - Authored as `type: 'manual'` (cantTell-capped, never fail), not
28
- * `automatic`: this can only see inline `on*="..."` HTML attributes —
29
- * a keyboard handler attached elsewhere via `addEventListener` (the
28
+ * `automatic`: this can only see inline `on*="..."` HTML attributes.
29
+ * A keyboard handler attached elsewhere via `addEventListener` (the
30
30
  * norm in most modern frameworks) is invisible to a static markup
31
31
  * scan and would make a `fail` a false positive. Surfaced by a diff
32
- * against a legacy ruleset's WCAG2AA rules (see ROADMAP.md's Tier 4
33
- * research notes) — this is a real, well-known WCAG 2.1.1 anti-pattern
32
+ * against a legacy ruleset's WCAG2AA rules; this is a real, well-known
33
+ * WCAG 2.1.1 anti-pattern
34
34
  * (technique G90/F54) that nothing else in this rule set checks.
35
- * - Deliberately does NOT treat `onclick` as a keyboard-equivalent
36
- * excuse: whether `onclick` is keyboard-reachable depends on the
35
+ * - Does NOT treat `onclick` as a keyboard-equivalent excuse on purpose:
36
+ * whether `onclick` is keyboard-reachable depends on the
37
37
  * element's separate focusability (native interactive tag or
38
- * `tabindex`), which this rule does not attempt to cross-check — and
38
+ * `tabindex`), which this rule does not attempt to cross-check, and
39
39
  * for the specific hover-triggered handlers this rule targets
40
40
  * (`onmouseover`/`onmouseout`/etc.), `onclick` isn't actually an
41
41
  * equivalent interaction model regardless of focusability (hover and
42
42
  * click are different gestures with different semantics).
43
43
  * - Only inline HTML attribute handlers are detectable; JS-attached
44
44
  * listeners (`addEventListener('mouseover', ...)`) are invisible to a
45
- * static DOM scan — a documented limitation, not an oversight.
45
+ * static DOM scan. That's a documented limitation, not an oversight.
46
46
  */
47
47
 
48
48
  const id = 'mouse-only-event-handlers';
@@ -14,8 +14,8 @@
14
14
  * SC 1.4.2 only applies when audio plays automatically for MORE than 3
15
15
  * seconds; clip duration is not knowable from static markup (jsdom does
16
16
  * not decode media), so this rule cannot determine whether the SC even
17
- * applies to a given element. It is deliberately authored as `type:
18
- * 'manual'` (cantTell-capped, never fail) rather than guessing: an
17
+ * applies to a given element. It is authored as `type:
18
+ * 'manual'` (cantTell-capped, never fail) on purpose rather than guessing: an
19
19
  * autoplaying unmuted element with no `controls` attribute (the native,
20
20
  * statically-verifiable mechanism to pause/stop or adjust volume) is
21
21
  * flagged for human review rather than treated as a deterministic
@@ -28,7 +28,7 @@
28
28
  * audible, so the SC's condition ("plays automatically... audio")
29
29
  * does not apply.
30
30
  * - Custom (JS-built) controls that don't use the native `controls`
31
- * attribute cannot be detected statically — a documented limitation,
31
+ * attribute cannot be detected statically. That's a documented limitation,
32
32
  * same class as `iframe-focusable-content`'s `contentDocument` gap.
33
33
  * - Not gated on `isAccTreeEligible`: unlike most rules, a `display:none`
34
34
  * or `aria-hidden` audio/video element still plays audible sound in a
@@ -9,6 +9,14 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.1.1
11
11
  * @type manual
12
+ * @applicability
13
+ * Applies to <object> elements that already carry a text alternative:
14
+ * fallback text content, a non-empty aria-label, an aria-labelledby that
15
+ * resolves to non-empty text, or a non-empty title. An <object> with none
16
+ * of those is object-text-alternative-present's failure, not a quality
17
+ * question. The element must be included in the accessibility tree, and
18
+ * role="presentation"/"none" takes it out of scope unless it is focusable,
19
+ * which restores its role.
12
20
  * @expectation
13
21
  * Human review is required to confirm that the provided text alternative is accurate and appropriate.
14
22
  */
@@ -17,18 +17,18 @@
17
17
  * Text styled to visually read as a heading (bold, larger-than-body
18
18
  * size, short) should be marked up with a real heading element
19
19
  * (`<h1>`-`<h6>` or `role="heading"`) so its structural role is
20
- * programmatically determinable — the same 1.3.1 concern as any other
20
+ * programmatically determinable, the same 1.3.1 concern as any other
21
21
  * "structure conveyed through presentation only" issue.
22
22
  * @implementation-notes
23
23
  * - This is a stylistic heuristic (bold + large + short), not a
24
- * deterministic structural check — a short bold sentence is not
24
+ * deterministic structural check: a short bold sentence is not
25
25
  * necessarily wrong as a `<p>`. Authored as `type: 'manual'`
26
26
  * (cantTell-capped, never fail) to avoid false-flagging legitimate
27
27
  * emphasis, matching this repo's other heuristic-heavy Tier 2/3
28
28
  * rules (e.g. `scrollable-region-focusable`).
29
29
  * - Uses an absolute 18px size threshold rather than comparing against
30
- * surrounding text (unlike `link-in-text-block`) — deliberately
31
- * simpler, since "looks like a heading" is closer to an absolute
30
+ * surrounding text (unlike `link-in-text-block`); it's simpler on
31
+ * purpose, since "looks like a heading" is closer to an absolute
32
32
  * judgment than a relative-contrast one.
33
33
  */
34
34
 
@@ -6,9 +6,9 @@
6
6
  * @check page-has-heading-one
7
7
  * @atomic true
8
8
  * @summary The page should have at least one level-one heading
9
- * @standard Best Practices (no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
10
10
  * @applicability
11
- * Always applicable to any HTML document with a <body> element —
11
+ * Always applicable to any HTML document with a <body> element:
12
12
  * "does the page have an h1" is a whole-page concern, matching
13
13
  * bypass-blocks-present's pattern of evaluating the document
14
14
  * directly.
@@ -18,18 +18,18 @@
18
18
  * heading has no clear entry point for assistive technology users
19
19
  * navigating by heading.
20
20
  * @implementation-notes
21
- * - Not WCAG-normative — authored as an advisory, cantTell-capped
21
+ * - Not WCAG-normative, authored as an advisory, cantTell-capped
22
22
  * `type: 'manual'` rule; see landmark-banner-is-top-level's
23
23
  * header comment for the shared rationale/precedent.
24
24
  * - Filters candidates through `isAccTreeEligible` (hidden/aria-hidden/
25
25
  * display:none/inert elements don't count as "the page has a heading
26
- * one"), matching `landmark-one-main`'s own precedent — a raw
26
+ * one"), matching `landmark-one-main`'s own precedent. A raw
27
27
  * `document.querySelectorAll` would wrongly credit an `<h1>` that sits
28
- * inside a `display:none` ancestor, genuinely unreachable by sighted and
28
+ * inside a `display:none` ancestor, unreachable by sighted and
29
29
  * screen reader users alike, as satisfying this check. This does NOT
30
30
  * regress purely-visually-clipped-but-AT-exposed headings (e.g. an `<h1>`
31
31
  * hidden via clip-path/off-screen positioning, `visibility:visible`, no
32
- * `aria-hidden`) — `isAccTreeEligible` only excludes elements actually
32
+ * `aria-hidden`): `isAccTreeEligible` only excludes elements actually
33
33
  * removed from the accessibility tree, not ones merely clipped from the
34
34
  * visual viewport.
35
35
  */
@@ -2,13 +2,36 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check page-title-patterns
7
+ * @atomic true
8
+ * @summary Manual review: page titles that may not describe their page
9
+ * @standard WCAG 2.2
10
+ * @sc 2.4.2
11
+ * @applicability
12
+ * Applies to a run over a whole document whose <title> resolves to
13
+ * non-empty text; a missing or empty title is page-title-present's
14
+ * failure, not a pattern to review. A run narrowed by contextSelector or
15
+ * by engineOptions.fragment is notApplicable, as is a title matching none
16
+ * of the patterns below.
17
+ * @expectation
18
+ * The title carries none of the conservative low-descriptiveness signals:
19
+ * one of the generic titles home, homepage, welcome, untitled, page or
20
+ * document; fewer than eight characters; or a template shape pairing a
21
+ * generic token with a brand, such as "Home | Brand". When the
22
+ * crawl.pageTitles probe supplies at least ten pages, cross-page signals
23
+ * are used instead: one title repeated across distinct URLs, or a prefix
24
+ * or suffix of twelve characters or more shared across the set. Every
25
+ * signal is reported as cantTell: whether a title describes its page is a
26
+ * judgment, so the rule never fails on a pattern alone.
27
+ */
28
+
5
29
  const id = 'page-title-patterns';
6
30
 
7
31
  const meta = {
8
- title: 'Page title patterns that may indicate low descriptiveness',
32
+ title: 'Page title patterns that may be insufficiently descriptive',
9
33
  description:
10
- 'Flags page titles that are likely too generic or templated as review signals (WCAG 2.2 SC 2.4.2). ' +
11
- 'This rule is conservative and does not fail based on patterns alone.',
34
+ 'Identifies page title patterns that may indicate low descriptiveness, such as generic, duplicated, or overly templated titles. This rule provides review signals and does not fail automatically.',
12
35
  i18n: {
13
36
  titleKey: 'pageTitlePatterns_title',
14
37
  descriptionKey: 'pageTitlePatterns_description'