@surea11y/core 1.7.0 → 1.8.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 (103) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +2 -2
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +1 -1
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +152 -26
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14419 -2231
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -25,12 +25,17 @@
25
25
  * - Within one declaration block, importance outranks order, so the effective
26
26
  * declaration is the last `!important` one for that property.
27
27
  * - ACT 78fd32/24afc2/9e45ec additionally require the text to contain a soft
28
- * wrap break, which layout would settle and this cannot. Two shapes do
29
- * establish that no wrap is possible -- text not allowed to wrap, and a
30
- * fixed-width element inside a horizontally scrolling ancestor -- and those
31
- * are reported for review rather than failed. Anything else is treated as
32
- * wrapping, so a forced value on text that never wraps for some other reason
33
- * is still reported.
28
+ * wrap break, which layout would settle and this cannot. Three shapes
29
+ * establish that no wrap is possible -- text not allowed to wrap, a
30
+ * fixed-width element inside a horizontally scrolling ancestor, and text
31
+ * with no break opportunity (one word, no CJK characters) -- and those are
32
+ * reported for review rather than failed.
33
+ * - Text short enough to fit on one line may never wrap either, so it is also
34
+ * reviewed rather than failed. "Short" is an estimate made without layout:
35
+ * the text's length at half an em per character (a full em for CJK and
36
+ * other wide characters) fits within 320 CSS pixels, the narrowest width
37
+ * WCAG 1.4.10 asks content to reflow to. Longer text is treated as wrapping
38
+ * and fails.
34
39
  */
35
40
 
36
41
  const id = 'avoid-inline-spacing';
@@ -89,6 +94,7 @@ function runInPage(ctx) {
89
94
  let applicableCount = 0;
90
95
  const undecided = [];
91
96
  const noWrap = [];
97
+ const shortText = [];
92
98
 
93
99
  // Within one declaration block, importance wins over order, so the last
94
100
  // important declaration is the one that takes effect. Passed Example 5 of ACT
@@ -184,6 +190,32 @@ function runInPage(ctx) {
184
190
  return false;
185
191
  }
186
192
 
193
+ // Text with no break opportunity cannot take a soft wrap break: a single
194
+ // word of a script that separates words with spaces. CJK text can break
195
+ // between most characters, so it never counts as unbreakable here.
196
+ const WIDE_CHAR =
197
+ /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]/;
198
+ function blockText(el) {
199
+ return String(el.textContent || '')
200
+ .replace(/\s+/g, ' ')
201
+ .trim();
202
+ }
203
+
204
+ function hasNoBreakOpportunity(text) {
205
+ return !!text && !/\s/.test(text) && !WIDE_CHAR.test(text);
206
+ }
207
+
208
+ // Whether the text may fit on one line at the narrowest width WCAG 1.4.10
209
+ // asks content to reflow to (320 CSS pixels), estimated without layout at
210
+ // half an em per character and a full em per wide character.
211
+ const REFLOW_WIDTH_PX = 320;
212
+ function mayFitOnOneLine(el, text) {
213
+ const fontSize = fontSizeOf(computedStyleOf(el)) || 16;
214
+ let ems = 0;
215
+ for (const ch of text) ems += WIDE_CHAR.test(ch) ? 1 : 0.5;
216
+ return ems * fontSize <= REFLOW_WIDTH_PX;
217
+ }
218
+
187
219
  // ACT scopes these rules to text visible on screen, and text pushed far off
188
220
  // canvas is the one hidden shape the shared eligibility check keeps eligible.
189
221
  function isOffScreen(el) {
@@ -290,10 +322,15 @@ function runInPage(ctx) {
290
322
  // A forced value on text that cannot wrap is outside these ACT rules'
291
323
  // applicability, and whether it wraps is not decidable here, so it is
292
324
  // reported for review rather than failed.
293
- if (cannotSoftWrap(el)) {
325
+ const text = blockText(el);
326
+ if (cannotSoftWrap(el) || hasNoBreakOpportunity(text)) {
294
327
  noWrap.push({ el, props: flagged.slice() });
295
328
  continue;
296
329
  }
330
+ if (mayFitOnOneLine(el, text)) {
331
+ shortText.push({ el, props: flagged.slice() });
332
+ continue;
333
+ }
297
334
 
298
335
  const tag = el.tagName.toLowerCase();
299
336
 
@@ -316,27 +353,48 @@ function runInPage(ctx) {
316
353
  if (applicableCount === 0) {
317
354
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
318
355
  }
319
- if (occurrences.length) {
320
- return {
321
- ruleId: rule.ruleId,
322
- outcome: 'fail',
323
- severity: rule.defaultSeverity || 'moderate',
324
- occurrences
325
- };
326
- }
327
- if (noWrap.length) {
328
- return {
329
- ruleId: rule.ruleId,
330
- outcome: 'cantTell',
331
- severity: rule.defaultSeverity || 'moderate',
332
- confidence: 'low',
333
- occurrences: noWrap.map(({ el, props }) =>
356
+
357
+ const cantTellOccurrences = noWrap
358
+ .map(({ el, props }) =>
359
+ helpers.reportOccurrence(el, {
360
+ occurrenceOutcome: 'cantTell',
361
+ summary: `This element's inline style forces ${props.join(', ')} with !important, but its text does not appear able to wrap, so the text-spacing criterion may not apply to it.`,
362
+ hint: 'Confirm whether this text ever wraps. If it cannot, the criterion does not apply; if it can, remove !important or set a value that already meets the metric.',
363
+ i18n: {
364
+ summaryKey: 'avoidInlineSpacing_summary_cantTell_noSoftWrap',
365
+ hintKey: 'avoidInlineSpacing_hint_cantTell_noSoftWrap',
366
+ params: {
367
+ element: (el.tagName || '').toLowerCase(),
368
+ properties: props.join(', ')
369
+ }
370
+ },
371
+ uncertainty: {
372
+ code: 'not-computable',
373
+ needed: 'Whether this text ever contains a soft wrap break, which needs layout.',
374
+ evidence: {
375
+ element: (el.tagName || '').toLowerCase(),
376
+ properties: props,
377
+ reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP'
378
+ }
379
+ },
380
+ data: {
381
+ details: {
382
+ reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP',
383
+ element: (el.tagName || '').toLowerCase(),
384
+ properties: props
385
+ }
386
+ }
387
+ })
388
+ )
389
+ .concat(
390
+ shortText.map(({ el, props }) =>
334
391
  helpers.reportOccurrence(el, {
335
- summary: `This element's inline style forces ${props.join(', ')} with !important, but its text does not appear able to wrap, so the text-spacing criterion may not apply to it.`,
336
- hint: 'Confirm whether this text ever wraps. If it cannot, the criterion does not apply; if it can, remove !important or set a value that already meets the metric.',
392
+ occurrenceOutcome: 'cantTell',
393
+ summary: `This element's inline style forces ${props.join(', ')} with !important, but its text is short enough to fit on one line, so it may never wrap and the text-spacing criterion may not apply to it.`,
394
+ hint: 'Confirm whether this text wraps at narrow widths. If it always fits on one line, the criterion does not apply; if it can wrap, remove !important or set a value that already meets the metric.',
337
395
  i18n: {
338
- summaryKey: 'avoidInlineSpacing_summary_cantTell_noSoftWrap',
339
- hintKey: 'avoidInlineSpacing_hint_cantTell_noSoftWrap',
396
+ summaryKey: 'avoidInlineSpacing_summary_cantTell_shortText',
397
+ hintKey: 'avoidInlineSpacing_hint_cantTell_shortText',
340
398
  params: {
341
399
  element: (el.tagName || '').toLowerCase(),
342
400
  properties: props.join(', ')
@@ -348,28 +406,23 @@ function runInPage(ctx) {
348
406
  evidence: {
349
407
  element: (el.tagName || '').toLowerCase(),
350
408
  properties: props,
351
- reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP'
409
+ reasonCode: 'INLINE_SPACING_SHORT_TEXT'
352
410
  }
353
411
  },
354
412
  data: {
355
413
  details: {
356
- reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP',
414
+ reasonCode: 'INLINE_SPACING_SHORT_TEXT',
357
415
  element: (el.tagName || '').toLowerCase(),
358
416
  properties: props
359
417
  }
360
418
  }
361
419
  })
362
420
  )
363
- };
364
- }
365
- if (undecided.length) {
366
- return {
367
- ruleId: rule.ruleId,
368
- outcome: 'cantTell',
369
- severity: rule.defaultSeverity || 'moderate',
370
- confidence: 'low',
371
- occurrences: undecided.map(({ el, props }) =>
421
+ )
422
+ .concat(
423
+ undecided.map(({ el, props }) =>
372
424
  helpers.reportOccurrence(el, {
425
+ occurrenceOutcome: 'cantTell',
373
426
  summary: `This element's inline style sets ${props.join(', ')} with !important, but the value could not be resolved, so whether it meets the WCAG text-spacing metric could not be determined.`,
374
427
  hint: 'Check this value by hand against the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em), or express it in a unit the engine can resolve against the element’s computed font size.',
375
428
  i18n: {
@@ -398,9 +451,21 @@ function runInPage(ctx) {
398
451
  }
399
452
  })
400
453
  )
401
- };
402
- }
403
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
454
+ );
455
+
456
+ // See helpers.resolveTieredOutcome (src/core/dom-helpers.js): a fail-tier
457
+ // finding never silently discards the cantTell-tier findings from the same
458
+ // run, so an undecided element survives a failure elsewhere on the page.
459
+ const resolved = helpers.resolveTieredOutcome(
460
+ occurrences,
461
+ cantTellOccurrences,
462
+ rule.defaultSeverity || 'moderate'
463
+ );
464
+ return {
465
+ ruleId: rule.ruleId,
466
+ ...resolved,
467
+ ...(resolved.outcome === 'cantTell' ? { confidence: 'low' } : null)
468
+ };
404
469
  }
405
470
 
406
471
  module.exports = { id, meta, runInPage };
@@ -22,7 +22,8 @@
22
22
  * "Submit"/"Reset" default, which is why those two are never nameless.
23
23
  * Failing both, a button whose role is name-from-content falls back to its
24
24
  * subtree text, counting each descendant's own name (an <img alt>,
25
- * aria-label or title) rather than only text nodes.
25
+ * aria-label, an <svg>'s <title> child, or title) rather than only text
26
+ * nodes.
26
27
  */
27
28
 
28
29
  // NOTE: Repo ruleId contract requires ENGINE_TAG prefix in the rule id.
@@ -12,7 +12,16 @@
12
12
  * Applies to <canvas> elements included in the accessibility tree.
13
13
  * Hidden elements are excluded whether or not they are focusable.
14
14
  * @expectation
15
- * Each applicable <canvas> provides a text alternative via fallback content or an accessible name.
15
+ * Each applicable <canvas> provides a text alternative via fallback content
16
+ * or an accessible name, with two exceptions:
17
+ * - role="img" (first role token) makes the canvas's children
18
+ * presentational and its name comes from the author only, so fallback
19
+ * content does not count: aria-labelledby, aria-label or title must name
20
+ * it.
21
+ * - role="none"/"presentation" marks the canvas decorative, and it passes.
22
+ * The role is ignored (presentational role conflict) when the canvas is
23
+ * focusable or carries aria-label/aria-labelledby, and the canvas is then
24
+ * judged like any other.
16
25
  */
17
26
 
18
27
  const id = 'canvas-text-alternative-present';
@@ -85,6 +94,64 @@ function runInPage(ctx) {
85
94
  ? helpers.getTextAlternativeInfo
86
95
  : null;
87
96
 
97
+ const getAriaNameInfo =
98
+ helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
99
+
100
+ const getFocusableInfo =
101
+ helpers && typeof helpers.getFocusableInfo === 'function' ? helpers.getFocusableInfo : null;
102
+
103
+ function attrText(el, name) {
104
+ try {
105
+ const v = el.getAttribute(name);
106
+ return v == null ? '' : String(v).trim();
107
+ } catch {
108
+ return '';
109
+ }
110
+ }
111
+
112
+ function firstRoleToken(el) {
113
+ const raw = attrText(el, 'role').toLowerCase();
114
+ return raw ? raw.split(/\s+/)[0] : '';
115
+ }
116
+
117
+ // ARIA's presentational role conflict: a focusable element, or one with a
118
+ // global naming attribute, keeps its native role.
119
+ function isPresentationHonoured(el) {
120
+ if (attrText(el, 'aria-label') || attrText(el, 'aria-labelledby')) return false;
121
+ if (getFocusableInfo) {
122
+ try {
123
+ const fi = getFocusableInfo(el, ctx);
124
+ if (fi && fi.focusable) return false;
125
+ } catch {}
126
+ } else if (attrText(el, 'tabindex') !== '') {
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+
132
+ // role="img": the name comes from the author (aria-labelledby, aria-label,
133
+ // then title), never from the children.
134
+ function getRoleImgNameInfo(el) {
135
+ let aria = null;
136
+ if (getAriaNameInfo) {
137
+ try {
138
+ aria = getAriaNameInfo(el, ctx);
139
+ } catch {
140
+ aria = null;
141
+ }
142
+ }
143
+ if (aria && aria.present && aria.value) {
144
+ return { present: true, value: aria.value, mechanism: aria.mechanism };
145
+ }
146
+ if (!getAriaNameInfo) {
147
+ const label = attrText(el, 'aria-label');
148
+ if (label) return { present: true, value: label, mechanism: 'aria-label' };
149
+ }
150
+ const title = attrText(el, 'title');
151
+ if (title) return { present: true, value: title, mechanism: 'title' };
152
+ return { present: false, value: '', mechanism: 'none' };
153
+ }
154
+
88
155
  const canvases = (() => {
89
156
  try {
90
157
  return Array.from((queryAllSmart ? queryAllSmart('canvas') : queryAll('canvas')) || []);
@@ -118,16 +185,25 @@ function runInPage(ctx) {
118
185
 
119
186
  applicableCount += 1;
120
187
 
188
+ const role = firstRoleToken(el);
189
+
190
+ // A decorative canvas needs no text alternative.
191
+ if ((role === 'none' || role === 'presentation') && isPresentationHonoured(el)) continue;
192
+
193
+ const isRoleImg = role === 'img';
194
+
121
195
  // Expectation: must provide a text alternative.
122
- const ti = getTextAlternativeInfo
123
- ? (() => {
124
- try {
125
- return getTextAlternativeInfo(el, ctx);
126
- } catch {
127
- return null;
128
- }
129
- })()
130
- : null;
196
+ const ti = isRoleImg
197
+ ? getRoleImgNameInfo(el)
198
+ : getTextAlternativeInfo
199
+ ? (() => {
200
+ try {
201
+ return getTextAlternativeInfo(el, ctx);
202
+ } catch {
203
+ return null;
204
+ }
205
+ })()
206
+ : null;
131
207
 
132
208
  const hasTextAlt = !!(ti && ti.present);
133
209
 
@@ -135,14 +211,29 @@ function runInPage(ctx) {
135
211
 
136
212
  const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
137
213
 
214
+ const messages = isRoleImg
215
+ ? {
216
+ summary:
217
+ 'This <canvas role="img"> has no accessible name; with role="img" its fallback content does not count.',
218
+ hint: 'Name it with aria-label or aria-labelledby, or remove role="img" so that its fallback content can serve as the text alternative.',
219
+ summaryKey: 'canvas_textAltPresent_summary_fail_roleImg',
220
+ hintKey: 'canvas_textAltPresent_hint_fail_roleImg'
221
+ }
222
+ : {
223
+ summary: 'Missing text alternative for <canvas>.',
224
+ hint: 'Provide fallback text inside <canvas> or an accessible name (e.g., aria-label/aria-labelledby).',
225
+ summaryKey: 'canvas_textAltPresent_summary_fail',
226
+ hintKey: 'canvas_textAltPresent_hint_fail'
227
+ };
228
+
138
229
  const baseOccurrence = {
139
230
  selector: '',
140
231
  html: '',
141
- summary: 'Missing text alternative for <canvas>.',
142
- hint: 'Provide fallback text inside <canvas> or an accessible name (e.g., aria-label/aria-labelledby).',
232
+ summary: messages.summary,
233
+ hint: messages.hint,
143
234
  i18n: {
144
- summaryKey: 'canvas_textAltPresent_summary_fail',
145
- hintKey: 'canvas_textAltPresent_hint_fail',
235
+ summaryKey: messages.summaryKey,
236
+ hintKey: messages.hintKey,
146
237
  params: { element: 'canvas' }
147
238
  },
148
239
  data: {
@@ -20,6 +20,9 @@
20
20
  * accepted: text sitting inside a custom combobox widget is not reliably
21
21
  * exposed as its name. On a labelable element (<input role="combobox">) an
22
22
  * associated <label> counts as well.
23
+ * On a text-like <input> or a <textarea>, the placeholder counts last
24
+ * (HTML-AAM's final name source): a placeholder-only label is a 3.3.2
25
+ * question, not a missing name.
23
26
  */
24
27
 
25
28
  const id = 'combobox-name-present';
@@ -70,21 +73,6 @@ function runInPage(ctx) {
70
73
  }
71
74
  }
72
75
 
73
- function buildLabelForMap(doc) {
74
- const map = new Map(); // id -> label element (first)
75
- try {
76
- const labels = doc && doc.getElementsByTagName ? doc.getElementsByTagName('label') : [];
77
- for (let i = 0; i < labels.length; i += 1) {
78
- const lab = labels[i];
79
- if (!lab || !lab.getAttribute) continue;
80
- const f = normalizeWs(lab.getAttribute('for'));
81
- if (!f) continue;
82
- if (!map.has(f)) map.set(f, lab);
83
- }
84
- } catch {}
85
- return map;
86
- }
87
-
88
76
  function getConservativeSubtreeText(document, container) {
89
77
  // "Name from content", recurses into descendants and uses each one's
90
78
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
@@ -172,45 +160,30 @@ function runInPage(ctx) {
172
160
  ? helpers.queryAllSmart(selector)
173
161
  : helpers.queryAll(selector);
174
162
 
175
- // Precompute label[for] map for combobox elements that are labelable
176
- // native form controls (e.g. <input role="combobox">).
177
- const labelForMap = buildLabelForMap(document);
178
-
163
+ // Delegates to the shared, spec-guarded lookup (dom-helpers.js's
164
+ // getAssociatedLabelElements): a <label> -- wrapping or via `for` --
165
+ // only ever associates with a genuinely labelable element, so this
166
+ // correctly returns nothing for role-only widgets a <label> merely sits
167
+ // next to, and the real thing for a labelable element like
168
+ // <input role="combobox">.
179
169
  function getNativeLabelText(el) {
170
+ if (!helpers || typeof helpers.getAssociatedLabelElements !== 'function') return '';
171
+ let labels;
180
172
  try {
181
- if ('labels' in el && el.labels && el.labels.length) {
182
- const parts = [];
183
- const max = Math.min(4, el.labels.length);
184
- for (let i = 0; i < max; i += 1) {
185
- const lab = el.labels[i];
186
- const t = lab ? getLabelText(lab) : '';
187
- if (t) parts.push(t);
188
- }
189
- const joined = normalizeWs(parts.join(' '));
190
- if (joined) return joined;
191
- }
192
- } catch {}
193
- try {
194
- if (el.closest) {
195
- const wrap = el.closest('label');
196
- if (wrap) {
197
- const t = getLabelText(wrap);
198
- if (t) return t;
199
- }
200
- }
201
- } catch {}
202
- try {
203
- const idAttr = getAttr(el, 'id');
204
- if (idAttr && labelForMap.has(idAttr)) {
205
- const lab = labelForMap.get(idAttr);
206
- const t = lab ? getLabelText(lab) : '';
207
- if (t) return t;
208
- }
209
- } catch {}
210
- return '';
173
+ labels = helpers.getAssociatedLabelElements(el) || [];
174
+ } catch {
175
+ labels = [];
176
+ }
177
+ const parts = [];
178
+ const max = Math.min(4, labels.length);
179
+ for (let i = 0; i < max; i += 1) {
180
+ const t = getLabelText(labels[i]);
181
+ if (t) parts.push(t);
182
+ }
183
+ return normalizeWs(parts.join(' '));
211
184
  }
212
185
 
213
- function evaluate(el, controlType) {
186
+ function evaluate(el) {
214
187
  const ariaLabel = getAttr(el, 'aria-label');
215
188
  if (ariaLabel) return { ok: true, method: 'aria-label' };
216
189
 
@@ -224,6 +197,16 @@ function runInPage(ctx) {
224
197
  const lab = getNativeLabelText(el);
225
198
  if (lab) return { ok: true, method: 'label' };
226
199
 
200
+ // HTML-AAM's last name source for a text-like <input> or a <textarea>,
201
+ // after title: the placeholder. A placeholder-only label is a 3.3.2
202
+ // question, not a missing name.
203
+ if (helpers.getNativeHostNameInfo) {
204
+ try {
205
+ const host = helpers.getNativeHostNameInfo(el, ctx, { placeholder: true });
206
+ if (host && host.present) return { ok: true, method: host.mechanism };
207
+ } catch {}
208
+ }
209
+
227
210
  // role="combobox" is name-from-author-only per WAI-ARIA: it must NOT
228
211
  // fall back to subtree content. Visible text near/inside a custom
229
212
  // combobox widget is not reliably exposed as its accessible name.
@@ -239,7 +222,7 @@ function runInPage(ctx) {
239
222
 
240
223
  applicableCount += 1;
241
224
 
242
- const res = evaluate(el, 'combobox');
225
+ const res = evaluate(el);
243
226
  if (res.ok) continue;
244
227
 
245
228
  const eligInfo = getEligibilityInfo
@@ -115,6 +115,25 @@ function runInPage(ctx) {
115
115
 
116
116
  const seenFailEls = new Set();
117
117
 
118
+ // Defined inside runInPage, which is serialized into the page and cannot
119
+ // reach module scope. English fallbacks for the dictionary keys below.
120
+ const CANT_TELL_HINT_KEYS = {
121
+ background: 'contrastComputable_hint_cantTell_background',
122
+ effect: 'contrastComputable_hint_cantTell_effect',
123
+ rootNotOpaque: 'contrastComputable_hint_cantTell_rootNotOpaque',
124
+ generic: 'contrast_hint_cantTell_manual'
125
+ };
126
+ const CANT_TELL_HINTS = {
127
+ background:
128
+ 'Measure the contrast by hand on the rendered page, where the background behind the text gives the least contrast. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).',
129
+ effect:
130
+ 'Measure the contrast by hand on the rendered page with the effect applied, since the declared CSS colors are not what ends up on screen. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).',
131
+ rootNotOpaque:
132
+ 'No element behind this text, up to the root, has a fully opaque background, so the color it sits on depends on where the page is displayed. Set an opaque background color on html or body, or measure the contrast against the background the page is actually shown on.',
133
+ generic:
134
+ "Measure this text's contrast by hand on the rendered page. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA)."
135
+ };
136
+
118
137
  function pushCantTellOccurrence(el, reasonCode, extraDetails) {
119
138
  try {
120
139
  if (!el || seenFailEls.has(el)) return;
@@ -151,6 +170,18 @@ function runInPage(ctx) {
151
170
  summaryKey = 'contrastComputable_cantTell_rootNotOpaque';
152
171
  else if (rc === 'TEXT_SHADOW') summaryKey = 'contrastComputable_cantTell_textShadow';
153
172
 
173
+ // Every cantTell leaves a person to measure the contrast, so each one
174
+ // says how, grouped by what blocked the calculation.
175
+ let hintKind = 'generic';
176
+ if (rc === 'BACKGROUND_IMAGE_OR_GRADIENT') hintKind = 'background';
177
+ else if (
178
+ rc === 'MIX_BLEND_MODE' ||
179
+ rc === 'BACKGROUND_FILTER_OR_BACKDROP_FILTER' ||
180
+ rc === 'TEXT_SHADOW'
181
+ )
182
+ hintKind = 'effect';
183
+ else if (rc === 'BACKGROUND_NOT_OPAQUE_AT_ROOT') hintKind = 'rootNotOpaque';
184
+
154
185
  const details = Object.assign(
155
186
  { reasonCode: rc },
156
187
  extraDetails && typeof extraDetails === 'object' ? extraDetails : {}
@@ -160,10 +191,10 @@ function runInPage(ctx) {
160
191
  selector: '',
161
192
  html: '',
162
193
  summary: '',
163
- hint: '',
194
+ hint: CANT_TELL_HINTS[hintKind],
164
195
  i18n: {
165
196
  summaryKey,
166
- hintKey: '',
197
+ hintKey: CANT_TELL_HINT_KEYS[hintKind],
167
198
  params: Object.assign(
168
199
  { reasonCode: rc },
169
200
  details && typeof details === 'object'
@@ -374,11 +405,11 @@ function runInPage(ctx) {
374
405
  {
375
406
  selector: '',
376
407
  summary: '',
377
- hint: '',
408
+ hint: CANT_TELL_HINTS.generic,
378
409
  html: '',
379
410
  i18n: {
380
411
  summaryKey: 'contrastComputable_cantTell_engineFailure',
381
- hintKey: '',
412
+ hintKey: CANT_TELL_HINT_KEYS.generic,
382
413
  params: { reasonCode: 'ENGINE_EXCEPTION' }
383
414
  },
384
415
  data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
@@ -231,10 +231,10 @@ function runInPage(ctx) {
231
231
  selector: '',
232
232
  html: '',
233
233
  summary: '',
234
- hint: '',
234
+ hint: `Change the text color, the background color, or both, so the contrast ratio reaches at least ${params && params.threshold}:1.`,
235
235
  i18n: {
236
236
  summaryKey: 'contrastEnhanced_fail_belowThreshold',
237
- hintKey: '',
237
+ hintKey: 'contrastEnhanced_hint_fail',
238
238
  params: params && typeof params === 'object' ? params : {}
239
239
  },
240
240
  ...(uncertainty ? { uncertainty } : {}),
@@ -498,11 +498,11 @@ function runInPage(ctx) {
498
498
  {
499
499
  selector: '',
500
500
  summary: '',
501
- hint: '',
501
+ hint: "Measure this text's contrast by hand on the rendered page. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).",
502
502
  html: '',
503
503
  i18n: {
504
504
  summaryKey: 'contrastEnhanced_cantTell_engineFailure',
505
- hintKey: '',
505
+ hintKey: 'contrast_hint_cantTell_manual',
506
506
  params: { reasonCode: 'ENGINE_EXCEPTION' }
507
507
  },
508
508
  data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }