@surea11y/core 1.0.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 (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,373 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check media-alternative-transcript-evidence
5
+ * @atomic
6
+ * @summary Detects time-based media elements (<audio>/<video>) where a transcript / text alternative
7
+ * is not strongly evidenced in-page. Designed for WCAG 2.2 SC 1.2.1 (A).
8
+ * @standard WCAG
9
+ * @sc 1.2.1
10
+ * @applicability Any eligible <audio> or <video> element in the composed DOM.
11
+ * @expectation If a strong transcript/text-alternative signal is present (e.g., aria-describedby binding to
12
+ * a visible transcript block, or a nearby clearly labeled Transcript section/link), no occurrence is reported.
13
+ * Otherwise, the rule reports cantTell (insufficient evidence) for that media element.
14
+ */
15
+
16
+ const id = 'media-alternative-transcript-evidence';
17
+
18
+ const meta = {
19
+ title: 'Time-based media: transcript / media alternative evidence',
20
+ description:
21
+ 'Finds <audio>/<video> elements where a transcript or other text alternative is not strongly evidenced in-page. ' +
22
+ 'This rule is conservative and returns cantTell when evidence is missing or unverified.',
23
+ i18n: {
24
+ titleKey: 'mediaTranscriptPresent_title',
25
+ descriptionKey: 'mediaTranscriptPresent_description'
26
+ },
27
+ helpUrl: null,
28
+ tags: ['wcag2a', 'wcag121', 'timebasedmedia', 'media', 'atomic', 'manual'],
29
+ wcagSc: ['1.2.1'],
30
+ normativeMappings: [
31
+ {
32
+ standard: 'WCAG',
33
+ version: '2.2',
34
+ requirement: '1.2.1',
35
+ title: 'Audio-only and Video-only (Prerecorded)',
36
+ conformanceLevel: 'A'
37
+ }
38
+ ],
39
+ defaultSeverity: 'moderate',
40
+ category: 'perceivable',
41
+ type: 'manual',
42
+ defaultConfidence: 'low',
43
+ coverage: { facetsBySc: { '1.2.1': ['transcript-evidence'] } }
44
+ };
45
+
46
+ function runInPage(ctx) {
47
+ const { document, root, helpers, rule } = ctx;
48
+ const safeRoot = root || document;
49
+
50
+ // Conservative keyword set (deterministic). Includes common EN/FR terms.
51
+ // Keep this list strict to avoid false positives.
52
+ const TRANSCRIPT_TOKENS = [
53
+ 'transcript',
54
+ 'transcription',
55
+ 'texte intégral',
56
+ 'compte rendu',
57
+ 'verbatim'
58
+ ];
59
+
60
+ // Minimum transcript body length to be considered "substantial" when used as evidence.
61
+ // (Avoids treating short summaries as transcripts.)
62
+ const MIN_TRANSCRIPT_CHARS = 200;
63
+
64
+ // If aria-describedby contains no transcript token, require a larger body to consider it evidence.
65
+ const MIN_DESCRIBEDBY_CHARS_WITHOUT_TOKEN = 400;
66
+
67
+ function normText(s) {
68
+ if (!s) return '';
69
+ return String(s).replace(/\s+/g, ' ').trim().toLowerCase();
70
+ }
71
+
72
+ function containsTranscriptToken(s) {
73
+ const t = normText(s);
74
+ if (!t) return false;
75
+ for (const tok of TRANSCRIPT_TOKENS) {
76
+ if (t.includes(tok)) return true;
77
+ }
78
+ return false;
79
+ }
80
+
81
+ function textLen(s) {
82
+ const t = normText(s);
83
+ return t ? t.length : 0;
84
+ }
85
+
86
+ function getNodeText(el) {
87
+ try {
88
+ if (!el) return '';
89
+ return el.textContent || '';
90
+ } catch (e) {
91
+ return '';
92
+ }
93
+ }
94
+
95
+ function isElement(el) {
96
+ return !!(el && el.nodeType === 1);
97
+ }
98
+
99
+ const __eligCache = new WeakMap();
100
+
101
+ function getEligibility(node) {
102
+ if (!node || node.nodeType !== 1) return { eligible: true, reasons: [], targetSet: 'acc', accEligible: null };
103
+ const cached = __eligCache.get(node);
104
+ if (cached) return cached;
105
+
106
+ let info = null;
107
+ try {
108
+ info = (helpers && typeof helpers.getEligibilityInfo === 'function')
109
+ ? helpers.getEligibilityInfo(node, ctx, { targetSet: 'acc' })
110
+ : null;
111
+ } catch {
112
+ info = null;
113
+ }
114
+
115
+ const norm = (info && typeof info === 'object')
116
+ ? info
117
+ : { eligible: true, reasons: [], targetSet: 'acc', accEligible: null };
118
+
119
+ __eligCache.set(node, norm);
120
+ return norm;
121
+ }
122
+
123
+ function isEligible(node) {
124
+ const info = getEligibility(node);
125
+ return !!(info && info.eligible);
126
+ }
127
+
128
+ function safeSelector(node) {
129
+ return helpers.buildSelector ? helpers.buildSelector(node) : 'html';
130
+ }
131
+
132
+ // Evidence object includes strength, so the rule can treat "unverified external link" as insufficient proof.
133
+ function evidenceNone() {
134
+ return {
135
+ strength: 'none', // none | weak | strong
136
+ method: 'none',
137
+ transcriptNodeSelector: null,
138
+ transcriptLinkHref: null,
139
+ notes: []
140
+ };
141
+ }
142
+
143
+ function nodeRef(el) {
144
+ try {
145
+ if (!el || el.nodeType !== 1) return null;
146
+ const elementId = el.getAttribute && el.getAttribute('id');
147
+ if (elementId) return { type: 'id', value: String(elementId) };
148
+ const tag = (el.tagName || '').toLowerCase();
149
+ return { type: 'tag', value: tag };
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ const __evidenceCache = new WeakMap();
156
+
157
+ // Walk a small neighborhood around the media element to find transcript cues.
158
+ // Bounded for determinism and performance.
159
+ function findTranscriptEvidence(mediaEl) {
160
+ const evidence = evidenceNone();
161
+
162
+ // 1) aria-describedby strong binding
163
+ const descInfo = helpers.getAccessibleDescriptionInfo
164
+ ? helpers.getAccessibleDescriptionInfo(mediaEl, ctx)
165
+ : null;
166
+
167
+ if (descInfo && descInfo.mechanism === 'aria-describedby') {
168
+ const descText = descInfo.value || '';
169
+ const hasToken = containsTranscriptToken(descText);
170
+ const len = textLen(descText);
171
+
172
+ // Strong signal if the described text clearly indicates transcript, or is very substantial.
173
+ if (hasToken || len >= MIN_DESCRIBEDBY_CHARS_WITHOUT_TOKEN) {
174
+ evidence.strength = 'strong';
175
+ evidence.method = 'aria-describedby';
176
+ evidence.notes.push('media has aria-describedby with explicit or substantial transcript text');
177
+ return evidence;
178
+ }
179
+ }
180
+
181
+ // 2) In-container transcript heading + substantial visible text
182
+ const parent = mediaEl.parentElement;
183
+ if (isElement(parent) && isEligible(parent)) {
184
+ const headings = parent.querySelectorAll('h1,h2,h3,h4,h5,h6');
185
+ for (const h of headings) {
186
+ if (!isEligible(h)) continue;
187
+ const hText = getNodeText(h);
188
+ if (!containsTranscriptToken(hText)) continue;
189
+
190
+ // Look at a small set of following siblings for substantial text (and ensure visibility).
191
+ let sib = h.nextElementSibling;
192
+ let steps = 0;
193
+ while (isElement(sib) && steps < 4) {
194
+ if (isEligible(sib)) {
195
+ const sibText = getNodeText(sib);
196
+ if (textLen(sibText) >= MIN_TRANSCRIPT_CHARS) {
197
+ evidence.strength = 'strong';
198
+ evidence.method = 'adjacent-heading';
199
+ evidence.transcriptNodeSelector = nodeRef(h);
200
+ evidence.notes.push('found transcript heading with substantial visible adjacent text');
201
+ return evidence;
202
+ }
203
+ }
204
+ sib = sib.nextElementSibling;
205
+ steps += 1;
206
+ }
207
+ }
208
+ }
209
+
210
+ // 3) Nearby explicit transcript link
211
+ // - Same-document anchors can be verified (strong).
212
+ // - Cross-document links are unverified (weak).
213
+ if (isElement(parent) && isEligible(parent)) {
214
+ const links = parent.querySelectorAll('a[href]');
215
+ for (const a of links) {
216
+ if (!isEligible(a)) continue;
217
+
218
+ const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(a, ctx) : null;
219
+ const linkName = nameInfo && nameInfo.value ? nameInfo.value : getNodeText(a);
220
+ if (!containsTranscriptToken(linkName)) continue;
221
+
222
+ const href = a.getAttribute('href') || '';
223
+ evidence.transcriptLinkHref = href;
224
+ evidence.transcriptNodeSelector = nodeRef(a);
225
+
226
+ // 3a) Same-document anchor: resolve and verify target content has transcript heading + substance.
227
+ if (href.startsWith('#')) {
228
+ const targetId = href.slice(1);
229
+ const target = targetId
230
+ ? (safeRoot.getElementById ? safeRoot.getElementById(targetId) : document.getElementById(targetId))
231
+ : null;
232
+
233
+ if (isElement(target) && isEligible(target)) {
234
+ // Find a transcript heading in the target, and ensure there is substantial text in the target subtree.
235
+ const targetHeadings = target.querySelectorAll('h1,h2,h3,h4,h5,h6');
236
+ let hasTranscriptHeading = false;
237
+ for (const th of targetHeadings) {
238
+ if (!isEligible(th)) continue;
239
+ if (containsTranscriptToken(getNodeText(th))) {
240
+ hasTranscriptHeading = true;
241
+ break;
242
+ }
243
+ }
244
+ const targetText = getNodeText(target);
245
+
246
+ if (hasTranscriptHeading && textLen(targetText) >= MIN_TRANSCRIPT_CHARS) {
247
+ evidence.strength = 'strong';
248
+ evidence.method = 'anchor-target';
249
+ evidence.notes.push('resolved transcript link to an on-page section with transcript heading and substantial text');
250
+ return evidence;
251
+ }
252
+ }
253
+
254
+ // If anchor cannot be verified, treat as weak (still better than nothing, but not proof).
255
+ evidence.strength = 'weak';
256
+ evidence.method = 'anchor-unverified';
257
+ evidence.notes.push('transcript link found but anchor target could not be verified as a transcript section');
258
+ return evidence;
259
+ }
260
+
261
+ // 3b) External or cross-document link: do not treat as proof without crawling.
262
+ evidence.strength = 'weak';
263
+ evidence.method = 'external-link';
264
+ evidence.notes.push('transcript link found but cannot verify content without crawling');
265
+ return evidence;
266
+ }
267
+ }
268
+
269
+ return evidence;
270
+ }
271
+
272
+ const occurrences = [];
273
+ let applicableCount = 0;
274
+
275
+ const nodes = helpers.queryAllSmart
276
+ ? helpers.queryAllSmart('audio,video')
277
+ : helpers.queryAll('audio,video');
278
+
279
+ for (const el of nodes) {
280
+ const eligInfo = getEligibility(el);
281
+ if (!eligInfo || !eligInfo.eligible) continue;
282
+
283
+ applicableCount += 1;
284
+
285
+ // Evidence must be computed and cached per media element: two sibling
286
+ // <audio>/<video> elements under the same container do not necessarily
287
+ // share the same transcript evidence (e.g. one has a strong
288
+ // aria-describedby binding, the other has none). Keying this cache by
289
+ // the shared container instead of the element itself previously caused
290
+ // an undocumented sibling to silently inherit another element's
291
+ // evidence classification.
292
+ let evidence = __evidenceCache.get(el);
293
+ if (!evidence) {
294
+ evidence = findTranscriptEvidence(el);
295
+ __evidenceCache.set(el, evidence);
296
+ }
297
+
298
+ const mediaTag = (el.tagName || '').toLowerCase();
299
+
300
+ if (evidence.strength === 'none') {
301
+ const baseOccurrence = {
302
+ summary:
303
+ 'A transcript or other text alternative for this time-based media is not strongly evidenced on the page.',
304
+ hint:
305
+ 'Provide a clearly identified transcript or other text alternative for audio-only/video-only prerecorded media (for example, a “Transcript” section or link).',
306
+ i18n: {
307
+ summaryKey: 'mediaTranscriptPresent_summary_cantTell_missing',
308
+ hintKey: 'mediaTranscriptPresent_hint_cantTell_missing',
309
+ params: { element: mediaTag }
310
+ },
311
+ data: {
312
+ details: {
313
+ reasonCode: 'transcriptNotDetected',
314
+ mediaTag,
315
+ evidence
316
+ },
317
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
318
+ }
319
+ };
320
+
321
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
322
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
323
+ } else {
324
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
325
+ }
326
+ continue;
327
+ }
328
+
329
+ if (evidence.strength === 'weak') {
330
+
331
+ const baseOccurrence = {
332
+ summary:
333
+ 'A transcript or other text alternative may be available for this time-based media, but it could not be verified from the page content.',
334
+ hint:
335
+ 'Ensure a clearly identified transcript or other text alternative is available and programmatically or visibly associated with the media on the page.',
336
+ i18n: {
337
+ summaryKey: 'mediaTranscriptPresent_summary_cantTell_unverified',
338
+ hintKey: 'mediaTranscriptPresent_hint_cantTell_unverified',
339
+ params: { element: mediaTag }
340
+ },
341
+ data: {
342
+ details: {
343
+ reasonCode: 'transcriptEvidenceUnverified',
344
+ mediaTag,
345
+ evidence
346
+ },
347
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
348
+ }
349
+ };
350
+
351
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
352
+ occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
353
+ } else {
354
+ occurrences.push({ selector: '', html: '', ...baseOccurrence });
355
+ }
356
+ }
357
+ }
358
+
359
+ if (applicableCount === 0) {
360
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
361
+ }
362
+
363
+ if (occurrences.length) {
364
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
365
+ }
366
+
367
+ // Manual rules may only emit cantTell/notApplicable (never pass/fail).
368
+ // Strong evidence was found for all applicable media elements, so there
369
+ // is nothing to flag for review.
370
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
371
+ }
372
+
373
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check meta-viewport-large
5
+ * @atomic true
6
+ * @summary Viewport meta tag should allow zooming up to 500% (AAA-level)
7
+ * @standard Best Practices (a widely-used reference engine's classification; no formal WCAG Success Criterion — see ROADMAP.md Tier 1b)
8
+ * @applicability
9
+ * Applies to <meta name="viewport"> elements that carry a non-empty
10
+ * content attribute.
11
+ * @expectation
12
+ * The content attribute does not set user-scalable to "no"/"0", and
13
+ * does not set maximum-scale below 5 (500%). This is the AAA-level,
14
+ * stricter counterpart of meta-viewport-zoom-enabled (which
15
+ * enforces the AA 200% minimum as a hard, WCAG-normative fail); this
16
+ * rule is advisory best-practice guidance toward the higher AAA bar.
17
+ * @implementation-notes
18
+ * - Not WCAG-normative — authored as an advisory, cantTell-capped
19
+ * `type: 'manual'` rule; see landmark-banner-is-top-level's
20
+ * header comment for the shared rationale/precedent.
21
+ * - Distinct, atomic decision from meta-viewport-zoom-enabled
22
+ * (that rule is the hard-fail 200% floor; this one is the softer 500%
23
+ * best-practice ceiling target).
24
+ */
25
+
26
+ const id = 'meta-viewport-large';
27
+
28
+ const meta = {
29
+ title: 'Viewport meta tag should allow zooming up to 500%',
30
+ description: 'Checks that <meta name="viewport"> does not set user-scalable=no or maximum-scale below 5 (500%).',
31
+ i18n: {
32
+ titleKey: 'metaViewportLarge_title',
33
+ descriptionKey: 'metaViewportLarge_description'
34
+ },
35
+ helpUrl: null,
36
+ tags: ['best-practice', 'structure', 'atomic', 'manual'],
37
+ wcagSc: [],
38
+ normativeMappings: [],
39
+ defaultSeverity: 'minor',
40
+ category: 'perceivable',
41
+ type: 'manual',
42
+ defaultConfidence: 'medium',
43
+ coverage: {}
44
+ };
45
+
46
+ function runInPage(ctx) {
47
+ const { document, helpers, rule } = ctx;
48
+
49
+ function parseContent(raw) {
50
+ const out = {};
51
+ for (const pair of String(raw || '').split(/[,;]/)) {
52
+ const eq = pair.indexOf('=');
53
+ if (eq === -1) continue;
54
+ const key = pair.slice(0, eq).trim().toLowerCase();
55
+ const value = pair.slice(eq + 1).trim().toLowerCase();
56
+ if (key) out[key] = value;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const nodes = document.querySelectorAll ? document.querySelectorAll('meta[name="viewport" i]') : [];
62
+
63
+ const occurrences = [];
64
+ let applicableCount = 0;
65
+
66
+ for (const el of nodes) {
67
+ if (!el || !el.getAttribute) continue;
68
+ const raw = String(el.getAttribute('content') || '').trim();
69
+ if (!raw) continue;
70
+
71
+ applicableCount += 1;
72
+
73
+ const parsed = parseContent(raw);
74
+ const reasons = [];
75
+
76
+ const userScalable = parsed['user-scalable'];
77
+ if (userScalable === 'no' || userScalable === '0') {
78
+ reasons.push('user-scalable=' + userScalable);
79
+ }
80
+
81
+ const maxScaleRaw = parsed['maximum-scale'];
82
+ if (maxScaleRaw !== undefined) {
83
+ const maxScale = parseFloat(maxScaleRaw);
84
+ if (!Number.isNaN(maxScale) && maxScale < 5) {
85
+ reasons.push('maximum-scale=' + maxScaleRaw);
86
+ }
87
+ }
88
+
89
+ if (!reasons.length) continue;
90
+
91
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
92
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
93
+
94
+ occurrences.push({
95
+ selector: stableSelector,
96
+ html,
97
+ summary: 'This viewport meta tag restricts zoom below the 500% best-practice target.',
98
+ hint: 'Remove user-scalable=no and raise maximum-scale to at least 5 (500%) if possible.',
99
+ i18n: {
100
+ summaryKey: 'metaViewportLarge_summary_cantTell',
101
+ hintKey: 'metaViewportLarge_hint_cantTell',
102
+ params: { reasons: reasons.join(', ') }
103
+ },
104
+ data: {
105
+ details: { reasonCode: 'VIEWPORT_ZOOM_BELOW_500', reasons }
106
+ }
107
+ });
108
+ }
109
+
110
+ if (applicableCount === 0) {
111
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
112
+ }
113
+ if (occurrences.length) {
114
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'minor', occurrences };
115
+ }
116
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
117
+ }
118
+
119
+ module.exports = { id, meta, runInPage };
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @check mouse-only-event-handlers
5
+ * @atomic true
6
+ * @summary Pointer-only inline event handlers should have a keyboard-reachable equivalent
7
+ * @standard WCAG 2.2
8
+ * @sc 2.1.1
9
+ * @applicability
10
+ * Elements carrying at least one inline pointer-only event-handler
11
+ * attribute (`onmouseover`, `onmouseout`, `onmousedown`, `onmouseup`,
12
+ * `ondblclick`, `onmousemove`, `onmouseenter`, `onmouseleave`) with a
13
+ * non-empty value, that are also eligible/reachable (not
14
+ * hidden/`aria-hidden`/`display:none`).
15
+ * @expectation
16
+ * The element also carries at least one keyboard-reachable inline
17
+ * handler: `onkeydown`, `onkeyup`, `onkeypress` (the direct keyboard-
18
+ * event equivalents), or `onfocus`/`onblur` (the standard substitute
19
+ * for hover-triggered behavior — focus/blur are the keyboard-
20
+ * navigable analog to mouseover/mouseout, per WCAG technique G90).
21
+ * Otherwise the element's mouse-driven behavior (a hover tooltip, a
22
+ * custom dropdown, a drag interaction) has no way to be triggered by a
23
+ * keyboard-only user.
24
+ * @implementation-notes
25
+ * - Authored as `type: 'manual'` (cantTell-capped, never fail), not
26
+ * `automatic`: this can only see inline `on*="..."` HTML attributes —
27
+ * a keyboard handler attached elsewhere via `addEventListener` (the
28
+ * norm in most modern frameworks) is invisible to a static markup
29
+ * scan and would make a `fail` a false positive. Surfaced by a diff
30
+ * against a legacy ruleset's WCAG2AA rules (see ROADMAP.md's Tier 4
31
+ * research notes) — this is a real, well-known WCAG 2.1.1 anti-pattern
32
+ * (technique G90/F54) that nothing else in this rule set checks.
33
+ * - Deliberately does NOT treat `onclick` as a keyboard-equivalent
34
+ * excuse: whether `onclick` is keyboard-reachable depends on the
35
+ * element's separate focusability (native interactive tag or
36
+ * `tabindex`), which this rule does not attempt to cross-check — and
37
+ * for the specific hover-triggered handlers this rule targets
38
+ * (`onmouseover`/`onmouseout`/etc.), `onclick` isn't actually an
39
+ * equivalent interaction model regardless of focusability (hover and
40
+ * click are different gestures with different semantics).
41
+ * - Only inline HTML attribute handlers are detectable; JS-attached
42
+ * listeners (`addEventListener('mouseover', ...)`) are invisible to a
43
+ * static DOM scan — a documented limitation, not an oversight.
44
+ */
45
+
46
+ const id = 'mouse-only-event-handlers';
47
+
48
+ const meta = {
49
+ title: 'Pointer-only inline event handlers should have a keyboard-reachable equivalent',
50
+ description:
51
+ 'Flags elements with an inline pointer-only event handler (onmouseover, onmouseout, onmousedown, onmouseup, ondblclick, onmousemove, onmouseenter, onmouseleave) and no keyboard-reachable equivalent (onkeydown/onkeyup/onkeypress/onfocus/onblur), for manual review.',
52
+ i18n: {
53
+ titleKey: 'mouseOnlyEventHandlers_title',
54
+ descriptionKey: 'mouseOnlyEventHandlers_description'
55
+ },
56
+ helpUrl: null,
57
+ tags: ['wcag2a', 'wcag211', 'structure', 'atomic', 'manual'],
58
+ wcagSc: ['2.1.1'],
59
+ normativeMappings: [
60
+ { standard: 'WCAG', version: '2.2', requirement: '2.1.1', title: 'Keyboard', conformanceLevel: 'A' }
61
+ ],
62
+ defaultSeverity: 'moderate',
63
+ category: 'operable',
64
+ type: 'manual',
65
+ defaultConfidence: 'low',
66
+ coverage: { facetsBySc: { '2.1.1': ['mouse-only-event-handlers-evidence'] } }
67
+ };
68
+
69
+ function runInPage(ctx) {
70
+ const { document, root, helpers, rule } = ctx;
71
+ const safeRoot = root || document;
72
+
73
+ const MOUSE_ONLY_ATTRS = [
74
+ 'onmouseover', 'onmouseout', 'onmousedown', 'onmouseup',
75
+ 'ondblclick', 'onmousemove', 'onmouseenter', 'onmouseleave'
76
+ ];
77
+ const KEYBOARD_EQUIV_ATTRS = ['onkeydown', 'onkeyup', 'onkeypress', 'onfocus', 'onblur'];
78
+
79
+ function trim(v) { return (v == null ? '' : String(v)).trim(); }
80
+
81
+ const selector = MOUSE_ONLY_ATTRS.map((a) => `[${a}]`).join(', ');
82
+ const nodes = helpers.queryAllSmart ? helpers.queryAllSmart(selector, safeRoot) : helpers.queryAll(selector, safeRoot);
83
+
84
+ const occurrences = [];
85
+ let applicableCount = 0;
86
+
87
+ for (const el of nodes) {
88
+ if (!el || !el.getAttribute) continue;
89
+
90
+ const presentMouseAttrs = MOUSE_ONLY_ATTRS.filter((a) => trim(el.getAttribute(a)));
91
+ if (!presentMouseAttrs.length) continue;
92
+
93
+ const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
94
+ const eligible = typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
95
+ if (!eligible) continue;
96
+
97
+ applicableCount += 1;
98
+
99
+ const hasKeyboardEquiv = KEYBOARD_EQUIV_ATTRS.some((a) => trim(el.getAttribute(a)));
100
+ if (hasKeyboardEquiv) continue;
101
+
102
+ const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
103
+ const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : (el.outerHTML || '');
104
+ const eligInfo = helpers.getEligibilityInfo ? helpers.getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
105
+
106
+ occurrences.push({
107
+ selector: stableSelector,
108
+ html,
109
+ summary: `This element has ${presentMouseAttrs.join(', ')} but no keyboard-reachable equivalent handler.`,
110
+ hint: 'Add onkeydown/onkeyup/onkeypress (or onfocus/onblur for hover-triggered behavior) so this functionality is also reachable by keyboard.',
111
+ i18n: {
112
+ summaryKey: 'mouseOnlyEventHandlers_summary_cantTell',
113
+ hintKey: 'mouseOnlyEventHandlers_hint_cantTell',
114
+ params: { attrs: presentMouseAttrs.join(', ') }
115
+ },
116
+ data: {
117
+ details: { reasonCode: 'MOUSE_ONLY_HANDLER_NO_KEYBOARD_EQUIVALENT', mouseAttrs: presentMouseAttrs },
118
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
119
+ }
120
+ });
121
+ }
122
+
123
+ if (applicableCount === 0) {
124
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
125
+ }
126
+
127
+ if (occurrences.length) {
128
+ return { ruleId: rule.ruleId, outcome: 'cantTell', severity: rule.defaultSeverity || 'moderate', occurrences };
129
+ }
130
+
131
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
132
+ }
133
+
134
+ module.exports = { id, meta, runInPage };