@surea11y/core 1.4.1 → 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 (151) hide show
  1. package/CHANGELOG.md +212 -128
  2. package/README.md +46 -9
  3. package/docs/ACT_RULE_MAPPING.md +243 -0
  4. package/docs/API_STABILITY.md +4 -4
  5. package/docs/BINDING_AUTHORS_GUIDE.md +3 -3
  6. package/docs/DESIGN_CHALLENGES.md +301 -0
  7. package/docs/ENGINE_OPTIONS.md +30 -14
  8. package/docs/I18N.md +176 -20
  9. package/docs/INTEGRATION.md +29 -7
  10. package/docs/LIMITATIONS.md +6 -4
  11. package/docs/OUTPUT_SCHEMA.md +13 -3
  12. package/docs/REPORT.md +3 -1
  13. package/docs/RULE_AUTHORING.md +104 -23
  14. package/docs/RULE_CATALOG.md +1878 -169
  15. package/docs/RULE_TAXONOMY.md +2 -2
  16. package/docs/TROUBLESHOOTING.md +4 -4
  17. package/docs/WCAG_CONFORMANCE.md +25 -9
  18. package/package.json +8 -7
  19. package/src/baseline.js +3 -3
  20. package/src/checks/automatic/area-alt-present.js +2 -2
  21. package/src/checks/automatic/aria-allowed-attr.js +95 -40
  22. package/src/checks/automatic/aria-allowed-role.js +16 -18
  23. package/src/checks/automatic/aria-braille-equivalent.js +19 -21
  24. package/src/checks/automatic/aria-conditional-attr.js +22 -24
  25. package/src/checks/automatic/aria-deprecated-role.js +63 -50
  26. package/src/checks/automatic/aria-hidden-body.js +4 -11
  27. package/src/checks/automatic/aria-hidden-focus.js +104 -23
  28. package/src/checks/automatic/aria-prohibited-attr.js +71 -72
  29. package/src/checks/automatic/aria-prohibited-children.js +154 -61
  30. package/src/checks/automatic/aria-required-attr.js +74 -29
  31. package/src/checks/automatic/aria-required-children.js +38 -34
  32. package/src/checks/automatic/aria-required-parent.js +78 -29
  33. package/src/checks/automatic/aria-role-name-present.js +36 -22
  34. package/src/checks/automatic/aria-roles-valid.js +37 -23
  35. package/src/checks/automatic/aria-valid-attr-value.js +33 -33
  36. package/src/checks/automatic/aria-valid-attr.js +15 -18
  37. package/src/checks/automatic/autocomplete-valid.js +17 -19
  38. package/src/checks/automatic/avoid-inline-spacing.js +14 -16
  39. package/src/checks/automatic/binary-control-name-present.js +46 -26
  40. package/src/checks/automatic/button-name-present.js +115 -34
  41. package/src/checks/automatic/combobox-name-present.js +40 -22
  42. package/src/checks/automatic/contrast-computable.js +32 -0
  43. package/src/checks/automatic/contrast-enhanced.js +21 -1
  44. package/src/checks/automatic/contrast-minimum.js +21 -1
  45. package/src/checks/automatic/css-orientation-lock.js +118 -41
  46. package/src/checks/automatic/definition-list-children-valid.js +25 -29
  47. package/src/checks/automatic/deprecated-elements-not-used.js +15 -17
  48. package/src/checks/automatic/dialog-name-present.js +36 -20
  49. package/src/checks/automatic/dlitem-parent-valid.js +15 -17
  50. package/src/checks/automatic/duplicate-id-aria.js +50 -40
  51. package/src/checks/automatic/duplicate-id.js +198 -0
  52. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  53. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  54. package/src/checks/automatic/form-control-single-label.js +39 -41
  55. package/src/checks/automatic/html-xml-lang-mismatch.js +2 -9
  56. package/src/checks/automatic/iframe-focusable-content.js +92 -38
  57. package/src/checks/automatic/iframe-name-present.js +53 -21
  58. package/src/checks/automatic/iframe-title-unique.js +19 -24
  59. package/src/checks/automatic/img-alt-present.js +12 -4
  60. package/src/checks/automatic/label-in-name.js +198 -49
  61. package/src/checks/automatic/link-in-text-block.js +29 -31
  62. package/src/checks/automatic/link-name-present.js +47 -31
  63. package/src/checks/automatic/list-children-valid.js +21 -23
  64. package/src/checks/automatic/listbox-name-present.js +42 -24
  65. package/src/checks/automatic/listitem-parent-valid.js +18 -21
  66. package/src/checks/automatic/menuitem-name-present.js +36 -20
  67. package/src/checks/automatic/meta-refresh-no-exceptions.js +39 -31
  68. package/src/checks/automatic/meta-refresh-timing-absent.js +29 -25
  69. package/src/checks/automatic/meta-viewport-zoom-enabled.js +14 -17
  70. package/src/checks/automatic/meter-name-present.js +38 -21
  71. package/src/checks/automatic/nested-interactive-controls-absent.js +22 -24
  72. package/src/checks/automatic/option-name-present.js +39 -22
  73. package/src/checks/automatic/page-title-present.js +21 -3
  74. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  75. package/src/checks/automatic/progressbar-name-present.js +41 -24
  76. package/src/checks/automatic/role-img-alt-present.js +64 -16
  77. package/src/checks/automatic/searchbox-name-present.js +46 -24
  78. package/src/checks/automatic/server-side-image-map-absent.js +16 -19
  79. package/src/checks/automatic/slider-name-present.js +42 -23
  80. package/src/checks/automatic/spinbutton-name-present.js +46 -24
  81. package/src/checks/automatic/summary-name-present.js +34 -20
  82. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  83. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  84. package/src/checks/automatic/tab-name-present.js +37 -20
  85. package/src/checks/automatic/table-headers-attr-valid.js +57 -24
  86. package/src/checks/automatic/table-th-has-data-cells.js +76 -24
  87. package/src/checks/automatic/target-size-minimum.js +172 -131
  88. package/src/checks/automatic/td-has-header.js +20 -25
  89. package/src/checks/automatic/textbox-name-present.js +42 -24
  90. package/src/checks/automatic/tooltip-name-present.js +37 -20
  91. package/src/checks/automatic/treeitem-name-present.js +39 -22
  92. package/src/checks/automatic/valid-lang.js +107 -24
  93. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  94. package/src/checks/manual/accesskeys-manual.js +21 -22
  95. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  96. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  97. package/src/checks/manual/aria-checked-state-mismatch-manual.js +23 -26
  98. package/src/checks/manual/aria-text-manual.js +4 -4
  99. package/src/checks/manual/bypass-blocks-present-manual.js +48 -38
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  101. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/empty-heading-manual.js +73 -28
  104. package/src/checks/manual/empty-table-header-manual.js +33 -36
  105. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  106. package/src/checks/manual/form-control-label-quality-manual.js +453 -0
  107. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +4 -11
  108. package/src/checks/manual/heading-order-manual.js +20 -25
  109. package/src/checks/manual/heading-quality-manual.js +338 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  111. package/src/checks/manual/image-redundant-alt-manual.js +18 -21
  112. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  113. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  116. package/src/checks/manual/label-title-only-manual.js +19 -21
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +21 -24
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +23 -26
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +20 -23
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +8 -13
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +8 -13
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +4 -9
  123. package/src/checks/manual/landmark-one-main-manual.js +8 -15
  124. package/src/checks/manual/landmark-unique-manual.js +31 -36
  125. package/src/checks/manual/link-name-quality-manual.js +162 -35
  126. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  127. package/src/checks/manual/meta-viewport-large-manual.js +16 -19
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +26 -28
  129. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  131. package/src/checks/manual/p-as-heading-manual.js +4 -4
  132. package/src/checks/manual/page-has-heading-one-manual.js +8 -15
  133. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  134. package/src/checks/manual/presentation-role-conflict-manual.js +74 -46
  135. package/src/checks/manual/region-manual.js +32 -25
  136. package/src/checks/manual/scope-attr-valid-manual.js +16 -19
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  138. package/src/checks/manual/skip-link-manual.js +44 -52
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  140. package/src/checks/manual/tabindex-manual.js +16 -19
  141. package/src/checks/manual/table-duplicate-name-manual.js +16 -19
  142. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  143. package/src/checks/manual/video-caption-manual.js +3 -3
  144. package/src/checks/manual-review.js +17 -1
  145. package/src/core.js +13086 -5169
  146. package/src/report.js +16 -2
  147. package/surea11y.browser.js +5665 -4219
  148. package/surea11y.i18n.de.js +22 -0
  149. package/surea11y.i18n.es.js +22 -0
  150. package/surea11y.i18n.fr.js +22 -0
  151. package/bin/surea11y-core.js +0 -20
@@ -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,29 +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
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
137
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
138
250
  const eligInfo = helpers.getEligibilityInfo
139
251
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
140
252
  : null;
141
253
 
142
- occurrences.push({
143
- selector: stableSelector,
144
- html,
145
- summary: `This link's accessible name ("${rawName.trim()}") is a generic, non-descriptive phrase.`,
146
- 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.',
147
- i18n: {
148
- summaryKey: 'linkNameQuality_summary_cantTell',
149
- hintKey: 'linkNameQuality_hint_cantTell',
150
- params: { name: rawName.trim() }
151
- },
152
- data: {
153
- details: { reasonCode: 'GENERIC_LINK_TEXT', normalizedName: normalized },
154
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
155
- }
156
- });
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
+ }
268
+ }
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));
157
284
  }
158
285
 
159
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
@@ -104,23 +104,20 @@ function runInPage(ctx) {
104
104
 
105
105
  if (!reasons.length) continue;
106
106
 
107
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
108
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
109
-
110
- occurrences.push({
111
- selector: stableSelector,
112
- html,
113
- summary: 'This viewport meta tag restricts zoom below the 500% best-practice target.',
114
- hint: 'Remove user-scalable=no and raise maximum-scale to at least 5 (500%) if possible.',
115
- i18n: {
116
- summaryKey: 'metaViewportLarge_summary_cantTell',
117
- hintKey: 'metaViewportLarge_hint_cantTell',
118
- params: { reasons: reasons.join(', ') }
119
- },
120
- data: {
121
- details: { reasonCode: 'VIEWPORT_ZOOM_BELOW_500', reasons }
122
- }
123
- });
107
+ occurrences.push(
108
+ helpers.reportOccurrence(el, {
109
+ summary: 'This viewport meta tag restricts zoom below the 500% best-practice target.',
110
+ hint: 'Remove user-scalable=no and raise maximum-scale to at least 5 (500%) if possible.',
111
+ i18n: {
112
+ summaryKey: 'metaViewportLarge_summary_cantTell',
113
+ hintKey: 'metaViewportLarge_hint_cantTell',
114
+ params: { reasons: reasons.join(', ') }
115
+ },
116
+ data: {
117
+ details: { reasonCode: 'VIEWPORT_ZOOM_BELOW_500', reasons }
118
+ }
119
+ })
120
+ );
124
121
  }
125
122
 
126
123
  if (applicableCount === 0) {
@@ -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';
@@ -117,30 +117,28 @@ function runInPage(ctx) {
117
117
  const hasKeyboardEquiv = KEYBOARD_EQUIV_ATTRS.some((a) => trim(el.getAttribute(a)));
118
118
  if (hasKeyboardEquiv) continue;
119
119
 
120
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
121
- const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
122
120
  const eligInfo = helpers.getEligibilityInfo
123
121
  ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' })
124
122
  : null;
125
123
 
126
- occurrences.push({
127
- selector: stableSelector,
128
- html,
129
- summary: `This element has ${presentMouseAttrs.join(', ')} but no keyboard-reachable equivalent handler.`,
130
- hint: 'Add onkeydown/onkeyup/onkeypress (or onfocus/onblur for hover-triggered behavior) so this functionality is also reachable by keyboard.',
131
- i18n: {
132
- summaryKey: 'mouseOnlyEventHandlers_summary_cantTell',
133
- hintKey: 'mouseOnlyEventHandlers_hint_cantTell',
134
- params: { attrs: presentMouseAttrs.join(', ') }
135
- },
136
- data: {
137
- details: {
138
- reasonCode: 'MOUSE_ONLY_HANDLER_NO_KEYBOARD_EQUIVALENT',
139
- mouseAttrs: presentMouseAttrs
124
+ occurrences.push(
125
+ helpers.reportOccurrence(el, {
126
+ summary: `This element has ${presentMouseAttrs.join(', ')} but no keyboard-reachable equivalent handler.`,
127
+ hint: 'Add onkeydown/onkeyup/onkeypress (or onfocus/onblur for hover-triggered behavior) so this functionality is also reachable by keyboard.',
128
+ i18n: {
129
+ summaryKey: 'mouseOnlyEventHandlers_summary_cantTell',
130
+ hintKey: 'mouseOnlyEventHandlers_hint_cantTell',
131
+ params: { attrs: presentMouseAttrs.join(', ') }
140
132
  },
141
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
142
- }
143
- });
133
+ data: {
134
+ details: {
135
+ reasonCode: 'MOUSE_ONLY_HANDLER_NO_KEYBOARD_EQUIVALENT',
136
+ mouseAttrs: presentMouseAttrs
137
+ },
138
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
139
+ }
140
+ })
141
+ );
144
142
  }
145
143
 
146
144
  if (applicableCount === 0) {
@@ -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
  */
@@ -126,19 +126,12 @@ function runInPage(ctx) {
126
126
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
127
127
  }
128
128
 
129
- const stableSelector = helpers.buildSelector ? helpers.buildSelector(body) : 'body';
130
- const html = helpers.getOuterHtmlSnippet
131
- ? helpers.getOuterHtmlSnippet(body)
132
- : (body.outerHTML || '').slice(0, 200);
133
-
134
129
  return {
135
130
  ruleId: rule.ruleId,
136
131
  outcome: 'cantTell',
137
132
  severity: rule.defaultSeverity || 'minor',
138
133
  occurrences: [
139
- {
140
- selector: stableSelector,
141
- html,
134
+ helpers.reportOccurrence(body, {
142
135
  summary: 'This page has no level-one heading.',
143
136
  hint: 'Add a level-one heading (<h1> or role="heading" aria-level="1") that identifies the page\'s main content.',
144
137
  i18n: {
@@ -149,7 +142,7 @@ function runInPage(ctx) {
149
142
  data: {
150
143
  details: { reasonCode: 'HEADING_ONE_MISSING' }
151
144
  }
152
- }
145
+ })
153
146
  ]
154
147
  };
155
148
  }
@@ -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'