@surea11y/core 1.7.0 → 1.8.1

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 +103 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +8 -7
  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 +2 -2
  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 +26 -11
  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 +14285 -2219
  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
@@ -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' } }
@@ -20,6 +20,10 @@
20
20
  * size: 3:1 for large text, 4.5:1 for everything else. Text is large at
21
21
  * 24px or more, or at 14pt (about 18.667px) or more when the computed font
22
22
  * weight is 700 or higher.
23
+ * @implementation-notes
24
+ * - Its thresholds are settings (`settings` below), so another standard's
25
+ * contrast requirement is a variant of this rule rather than a copy
26
+ * (docs/RULE_AUTHORING.md, "Rule variants"). Without settings it is WCAG's.
23
27
  */
24
28
 
25
29
  const id = 'contrast-minimum';
@@ -65,6 +69,21 @@ function runInPage(ctx) {
65
69
  }
66
70
  }
67
71
 
72
+ // Thresholds (see `settings` below): WCAG 1.4.3's by default. A variant of
73
+ // this rule, another standard's contrast requirement, passes its own in
74
+ // ctx.config (docs/RULE_AUTHORING.md, "Rule variants").
75
+ const cfg = ctx.config && typeof ctx.config === 'object' ? ctx.config : {};
76
+ const setting = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
77
+ const BOLD_LARGE_MIN_PX = setting(cfg.boldLargeMinPx, null);
78
+ const LARGE_TEXT_RATIO = setting(cfg.largeTextRatio, 3);
79
+ const NORMAL_TEXT_RATIO = setting(cfg.normalTextRatio, 4.5);
80
+ // The font and analysis caches hold verdicts that depend on those
81
+ // thresholds, so other thresholds get caches of their own.
82
+ const SETTINGS_KEY =
83
+ BOLD_LARGE_MIN_PX === null && LARGE_TEXT_RATIO === 3 && NORMAL_TEXT_RATIO === 4.5
84
+ ? ''
85
+ : '|' + [BOLD_LARGE_MIN_PX, LARGE_TEXT_RATIO, NORMAL_TEXT_RATIO].join('|');
86
+
68
87
  const __contrastSharedCache =
69
88
  helpers && helpers.contrast && helpers.contrast.sharedCache
70
89
  ? helpers.contrast.sharedCache
@@ -82,8 +101,9 @@ function runInPage(ctx) {
82
101
  ? __contrastSharedCache.__elFgCache || (__contrastSharedCache.__elFgCache = new WeakMap())
83
102
  : null;
84
103
 
104
+ const FONT_CACHE = '__elFontCache' + SETTINGS_KEY;
85
105
  const __elFontCache = __contrastSharedCache
86
- ? __contrastSharedCache.__elFontCache || (__contrastSharedCache.__elFontCache = new WeakMap())
106
+ ? __contrastSharedCache[FONT_CACHE] || (__contrastSharedCache[FONT_CACHE] = new WeakMap())
87
107
  : new WeakMap();
88
108
 
89
109
  function safeComputedStyle(el) {
@@ -124,7 +144,10 @@ function runInPage(ctx) {
124
144
 
125
145
  const sizePx = Number.isFinite(fontSizePx) ? fontSizePx : 0;
126
146
  const isBold = Number.isFinite(fontWeightNum) && fontWeightNum >= 700;
127
- const isLarge = helpers.contrast.isLargeText(sizePx, fontWeightNum);
147
+ const isLarge =
148
+ BOLD_LARGE_MIN_PX === null
149
+ ? helpers.contrast.isLargeText(sizePx, fontWeightNum)
150
+ : helpers.contrast.isLargeText(sizePx, fontWeightNum, BOLD_LARGE_MIN_PX);
128
151
 
129
152
  const out = {
130
153
  fontSizePx: sizePx,
@@ -228,10 +251,10 @@ function runInPage(ctx) {
228
251
  selector: '',
229
252
  html: '',
230
253
  summary: '',
231
- hint: '',
254
+ hint: `Change the text color, the background color, or both, so the contrast ratio reaches at least ${params && params.threshold}:1.`,
232
255
  i18n: {
233
256
  summaryKey: 'contrastMinimum_fail_belowThreshold',
234
- hintKey: '',
257
+ hintKey: 'contrastMinimum_hint_fail',
235
258
  params: params && typeof params === 'object' ? params : {}
236
259
  },
237
260
  ...(uncertainty ? { uncertainty } : {}),
@@ -278,9 +301,10 @@ function runInPage(ctx) {
278
301
  let __elAnalysisCache = new WeakMap();
279
302
  if (__contrastSharedCache) {
280
303
  try {
281
- if (!__contrastSharedCache.__elAnalysisCacheAA)
282
- __contrastSharedCache.__elAnalysisCacheAA = new WeakMap();
283
- __elAnalysisCache = __contrastSharedCache.__elAnalysisCacheAA;
304
+ const ANALYSIS_CACHE = '__elAnalysisCacheAA' + SETTINGS_KEY;
305
+ if (!__contrastSharedCache[ANALYSIS_CACHE])
306
+ __contrastSharedCache[ANALYSIS_CACHE] = new WeakMap();
307
+ __elAnalysisCache = __contrastSharedCache[ANALYSIS_CACHE];
284
308
  } catch {
285
309
  __elAnalysisCache = new WeakMap();
286
310
  }
@@ -372,7 +396,12 @@ function runInPage(ctx) {
372
396
  const ratio = helpers.contrast.contrastRatio(fgOpaque, bgOpaque);
373
397
 
374
398
  const font = getFontInfo(el);
375
- const threshold = helpers.contrast.requiredRatio('AA', font.isLargeText);
399
+ const threshold =
400
+ SETTINGS_KEY === ''
401
+ ? helpers.contrast.requiredRatio('AA', font.isLargeText)
402
+ : font.isLargeText
403
+ ? LARGE_TEXT_RATIO
404
+ : NORMAL_TEXT_RATIO;
376
405
 
377
406
  analysis = {
378
407
  computable: true,
@@ -491,11 +520,11 @@ function runInPage(ctx) {
491
520
  {
492
521
  selector: '',
493
522
  summary: '',
494
- hint: '',
523
+ 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).",
495
524
  html: '',
496
525
  i18n: {
497
526
  summaryKey: 'contrastMinimum_cantTell_engineFailure',
498
- hintKey: '',
527
+ hintKey: 'contrast_hint_cantTell_manual',
499
528
  params: { reasonCode: 'ENGINE_EXCEPTION' }
500
529
  },
501
530
  data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
@@ -564,4 +593,9 @@ function runInPage(ctx) {
564
593
  };
565
594
  }
566
595
 
567
- module.exports = { id, meta, runInPage };
596
+ // The thresholds a variant may change (docs/RULE_AUTHORING.md, "Rule
597
+ // variants"), with WCAG 1.4.3's as defaults: the size from which bold text is
598
+ // large (null: WCAG's 14pt), and the ratios large and other text need.
599
+ const settings = { boldLargeMinPx: null, largeTextRatio: 3, normalTextRatio: 4.5 };
600
+
601
+ module.exports = { id, meta, runInPage, settings };