@surea11y/core 1.2.0 → 1.4.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 (168) hide show
  1. package/CHANGELOG.md +81 -7
  2. package/LICENSE +373 -21
  3. package/README.md +175 -35
  4. package/bin/surea11y-core.js +20 -0
  5. package/docs/API_STABILITY.md +27 -1
  6. package/docs/BINDING_AUTHORS_GUIDE.md +9 -9
  7. package/docs/CI_INTEGRATIONS.md +103 -0
  8. package/docs/ENGINE_OPTIONS.md +2 -0
  9. package/docs/I18N.md +12 -9
  10. package/docs/INTEGRATION.md +19 -1
  11. package/docs/LIMITATIONS.md +1 -1
  12. package/docs/OUTPUT_SCHEMA.md +1 -1
  13. package/docs/REPORT.md +1 -1
  14. package/docs/RULE_CATALOG.md +1 -1
  15. package/docs/SARIF.md +59 -0
  16. package/package.json +63 -18
  17. package/src/baseline.js +0 -0
  18. package/src/checks/automatic/area-alt-present.js +63 -31
  19. package/src/checks/automatic/aria-allowed-attr.js +204 -80
  20. package/src/checks/automatic/aria-allowed-role.js +23 -7
  21. package/src/checks/automatic/aria-braille-equivalent.js +34 -10
  22. package/src/checks/automatic/aria-conditional-attr.js +32 -14
  23. package/src/checks/automatic/aria-deprecated-role.js +26 -11
  24. package/src/checks/automatic/aria-hidden-body.js +48 -23
  25. package/src/checks/automatic/aria-hidden-focus.js +420 -66
  26. package/src/checks/automatic/aria-prohibited-attr.js +327 -60
  27. package/src/checks/automatic/aria-prohibited-children.js +111 -103
  28. package/src/checks/automatic/aria-required-attr.js +29 -15
  29. package/src/checks/automatic/aria-required-children.js +44 -24
  30. package/src/checks/automatic/aria-required-parent.js +64 -35
  31. package/src/checks/automatic/aria-role-name-present.js +49 -21
  32. package/src/checks/automatic/aria-roles-valid.js +24 -12
  33. package/src/checks/automatic/aria-valid-attr-value.js +46 -22
  34. package/src/checks/automatic/aria-valid-attr.js +19 -5
  35. package/src/checks/automatic/autocomplete-valid.js +76 -16
  36. package/src/checks/automatic/avoid-inline-spacing.js +23 -8
  37. package/src/checks/automatic/binary-control-name-present.js +62 -50
  38. package/src/checks/automatic/button-name-present.js +54 -24
  39. package/src/checks/automatic/bypass-blocks-present.js +51 -32
  40. package/src/checks/automatic/canvas-text-alternative-present.js +59 -26
  41. package/src/checks/automatic/combobox-name-present.js +40 -45
  42. package/src/checks/automatic/contrast-computable.js +363 -341
  43. package/src/checks/automatic/contrast-enhanced.js +489 -466
  44. package/src/checks/automatic/contrast-minimum.js +488 -465
  45. package/src/checks/automatic/css-orientation-lock.js +51 -35
  46. package/src/checks/automatic/definition-list-children-valid.js +46 -25
  47. package/src/checks/automatic/deprecated-elements-not-used.js +25 -9
  48. package/src/checks/automatic/dialog-name-present.js +47 -85
  49. package/src/checks/automatic/dlitem-parent-valid.js +25 -8
  50. package/src/checks/automatic/duplicate-id-aria.js +28 -9
  51. package/src/checks/automatic/embed-text-alternative-present.js +88 -35
  52. package/src/checks/automatic/form-control-programmatic-label-present.js +81 -196
  53. package/src/checks/automatic/form-control-single-label.js +50 -14
  54. package/src/checks/automatic/html-xml-lang-mismatch.js +36 -18
  55. package/src/checks/automatic/iframe-focusable-content.js +265 -22
  56. package/src/checks/automatic/iframe-name-present.js +33 -9
  57. package/src/checks/automatic/iframe-title-unique.js +32 -9
  58. package/src/checks/automatic/img-alt-present.js +54 -52
  59. package/src/checks/automatic/input-image-alt-present.js +141 -112
  60. package/src/checks/automatic/label-in-name.js +65 -41
  61. package/src/checks/automatic/language-page-present.js +111 -109
  62. package/src/checks/automatic/link-in-text-block.js +61 -19
  63. package/src/checks/automatic/link-name-present.js +47 -14
  64. package/src/checks/automatic/list-children-valid.js +40 -33
  65. package/src/checks/automatic/listbox-name-present.js +41 -19
  66. package/src/checks/automatic/listitem-parent-valid.js +48 -13
  67. package/src/checks/automatic/menuitem-name-present.js +41 -61
  68. package/src/checks/automatic/meta-refresh-no-exceptions.js +32 -11
  69. package/src/checks/automatic/meta-refresh-timing-absent.js +22 -6
  70. package/src/checks/automatic/meta-viewport-zoom-enabled.js +26 -7
  71. package/src/checks/automatic/meter-name-present.js +40 -36
  72. package/src/checks/automatic/nested-interactive-controls-absent.js +58 -15
  73. package/src/checks/automatic/object-text-alternative-present.js +93 -39
  74. package/src/checks/automatic/option-name-present.js +40 -21
  75. package/src/checks/automatic/page-title-present.js +19 -6
  76. package/src/checks/automatic/progressbar-name-present.js +49 -44
  77. package/src/checks/automatic/role-img-alt-present.js +211 -159
  78. package/src/checks/automatic/searchbox-name-present.js +41 -19
  79. package/src/checks/automatic/server-side-image-map-absent.js +27 -11
  80. package/src/checks/automatic/slider-name-present.js +42 -47
  81. package/src/checks/automatic/spinbutton-name-present.js +41 -19
  82. package/src/checks/automatic/summary-name-present.js +39 -17
  83. package/src/checks/automatic/svg-image-text-alternative-present.js +116 -47
  84. package/src/checks/automatic/svg-text-alternative-present.js +262 -230
  85. package/src/checks/automatic/tab-name-present.js +39 -60
  86. package/src/checks/automatic/table-headers-attr-valid.js +27 -10
  87. package/src/checks/automatic/table-th-has-data-cells.js +24 -8
  88. package/src/checks/automatic/target-size-minimum.js +123 -48
  89. package/src/checks/automatic/td-has-header.js +53 -12
  90. package/src/checks/automatic/textbox-name-present.js +41 -19
  91. package/src/checks/automatic/tooltip-name-present.js +39 -18
  92. package/src/checks/automatic/treeitem-name-present.js +40 -21
  93. package/src/checks/automatic/valid-lang.js +22 -6
  94. package/src/checks/automatic/video-poster-text-alternative-present.js +81 -36
  95. package/src/checks/manual/accesskeys-manual.js +17 -6
  96. package/src/checks/manual/area-alt-decorative-manual.js +194 -193
  97. package/src/checks/manual/area-alt-quality-manual.js +184 -141
  98. package/src/checks/manual/aria-checked-state-mismatch-manual.js +48 -34
  99. package/src/checks/manual/aria-text-manual.js +20 -11
  100. package/src/checks/manual/canvas-text-alternative-quality-manual.js +151 -114
  101. package/src/checks/manual/css-hidden-focus.js +375 -169
  102. package/src/checks/manual/embed-text-alternative-quality-manual.js +178 -162
  103. package/src/checks/manual/empty-heading-manual.js +41 -24
  104. package/src/checks/manual/empty-table-header-manual.js +69 -31
  105. package/src/checks/manual/focus-order-semantics-manual.js +60 -13
  106. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +209 -246
  107. package/src/checks/manual/heading-order-manual.js +50 -8
  108. package/src/checks/manual/identical-links-same-purpose-manual.js +36 -12
  109. package/src/checks/manual/image-redundant-alt-manual.js +38 -8
  110. package/src/checks/manual/img-alt-decorative-manual.js +133 -96
  111. package/src/checks/manual/img-alt-quality-manual.js +178 -127
  112. package/src/checks/manual/input-image-alt-decorative-manual.js +127 -92
  113. package/src/checks/manual/input-image-alt-quality-manual.js +127 -92
  114. package/src/checks/manual/label-title-only-manual.js +44 -28
  115. package/src/checks/manual/landmark-banner-is-top-level-manual.js +95 -38
  116. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +85 -32
  117. package/src/checks/manual/landmark-main-is-top-level-manual.js +69 -27
  118. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +45 -33
  119. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +43 -31
  120. package/src/checks/manual/landmark-no-duplicate-main-manual.js +27 -21
  121. package/src/checks/manual/landmark-one-main-manual.js +38 -43
  122. package/src/checks/manual/landmark-unique-manual.js +78 -67
  123. package/src/checks/manual/link-name-quality-manual.js +45 -12
  124. package/src/checks/manual/media-transcript-present-manual.js +37 -22
  125. package/src/checks/manual/meta-viewport-large-manual.js +19 -6
  126. package/src/checks/manual/mouse-only-event-handlers-manual.js +40 -11
  127. package/src/checks/manual/no-autoplay-audio-manual.js +22 -6
  128. package/src/checks/manual/object-text-alternative-quality-manual.js +177 -154
  129. package/src/checks/manual/p-as-heading-manual.js +24 -7
  130. package/src/checks/manual/page-has-heading-one-manual.js +42 -32
  131. package/src/checks/manual/page-title-patterns-manual.js +80 -50
  132. package/src/checks/manual/presentation-role-conflict-manual.js +101 -47
  133. package/src/checks/manual/region-manual.js +244 -60
  134. package/src/checks/manual/scope-attr-valid-manual.js +13 -4
  135. package/src/checks/manual/scrollable-region-focusable-manual.js +39 -11
  136. package/src/checks/manual/skip-link-manual.js +42 -18
  137. package/src/checks/manual/svg-text-alternative-quality-manual.js +208 -165
  138. package/src/checks/manual/tabindex-manual.js +13 -4
  139. package/src/checks/manual/table-duplicate-name-manual.js +22 -11
  140. package/src/checks/manual/table-fake-caption-manual.js +48 -10
  141. package/src/checks/manual/video-caption-manual.js +17 -4
  142. package/src/checks/manual-review.js +58 -12
  143. package/src/core.js +41705 -29650
  144. package/src/index.js +2 -0
  145. package/src/report.js +109 -47
  146. package/src/sarif.js +190 -0
  147. package/surea11y.browser.js +37774 -0
  148. package/bin/core.js +0 -348
  149. package/docs/CLI.md +0 -75
  150. package/src/catalogs/composites.wcag.js +0 -490
  151. package/src/checks/rules-and-tags.full.csv +0 -19
  152. package/src/checks/rules-and-tags.full.json +0 -259
  153. package/src/core/aria-helpers.js +0 -970
  154. package/src/core/contrast-helpers.js +0 -1147
  155. package/src/core/dom-helpers.js +0 -4235
  156. package/src/core/dom-runner.js +0 -671
  157. package/src/core/frame-messaging.js +0 -210
  158. package/src/core/frame-scan.js +0 -178
  159. package/src/core/rollup-composites.js +0 -135
  160. package/src/core/rule-meta.js +0 -159
  161. package/src/coverage/wcag-facets.js +0 -1079
  162. package/src/coverage/wcag-version-map.js +0 -84
  163. package/src/i18n/en.js +0 -923
  164. package/src/i18n/fr.js +0 -844
  165. package/src/policy/contracts.js +0 -18
  166. package/src/policy/resolvePolicy.js +0 -55
  167. package/src/policy/schemas/engine-options.schema.json +0 -103
  168. package/src/policy/schemas/policy-contract.schema.json +0 -40
@@ -1,512 +1,535 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'contrast-minimum';
4
6
 
5
7
  const meta = {
6
- title: 'Text must meet the minimum color contrast ratio',
7
- description:
8
- 'Checks that visible text has a contrast ratio of at least 4.5:1 (normal) or 3:1 (large), when contrast is computable from CSS.',
9
- i18n: {
10
- titleKey: 'contrastMinimum_title',
11
- descriptionKey: 'contrastMinimum_description'
12
- },
13
- helpUrl: null,
14
- tags: ['wcag2aa', 'wcag143', 'contrast', 'color',
15
- 'structure', 'atomic', 'automatic', 'dom'],
16
- wcagSc: ['1.4.3'],
17
- normativeMappings: [
18
- {
19
- standard: 'WCAG',
20
- version: '2.2',
21
- requirement: '1.4.3',
22
- title: 'Contrast (Minimum)',
23
- conformanceLevel: 'AA'
24
- }
25
- ],
26
- defaultSeverity: 'serious',
27
- category: 'perceivable',
28
- type: 'automatic',
29
- defaultConfidence: 'high',
30
- coverage: {facetsBySc: {'1.4.3': ['contrast-minimum-text']}}
8
+ title: 'Text must meet the minimum color contrast ratio',
9
+ description:
10
+ 'Checks that visible text has a contrast ratio of at least 4.5:1 (normal) or 3:1 (large), when contrast is computable from CSS.',
11
+ i18n: {
12
+ titleKey: 'contrastMinimum_title',
13
+ descriptionKey: 'contrastMinimum_description'
14
+ },
15
+ helpUrl: null,
16
+ tags: ['wcag2aa', 'wcag143', 'contrast', 'color', 'structure', 'atomic', 'automatic', 'dom'],
17
+ wcagSc: ['1.4.3'],
18
+ normativeMappings: [
19
+ {
20
+ standard: 'WCAG',
21
+ version: '2.2',
22
+ requirement: '1.4.3',
23
+ title: 'Contrast (Minimum)',
24
+ conformanceLevel: 'AA'
25
+ }
26
+ ],
27
+ defaultSeverity: 'serious',
28
+ category: 'perceivable',
29
+ type: 'automatic',
30
+ defaultConfidence: 'high',
31
+ coverage: { facetsBySc: { '1.4.3': ['contrast-minimum-text'] } }
31
32
  };
32
33
 
33
34
  function runInPage(ctx) {
34
- const {helpers, rule, engineOptions} = ctx;
35
+ const { helpers, rule, engineOptions } = ctx;
35
36
 
36
- function toElement(node) {
37
- try {
38
- if (!node) return null;
39
- if (node.nodeType === 1) return node; // ELEMENT_NODE
40
- if (node.nodeType === 3) return node.parentElement || null; // TEXT_NODE
41
- return null;
42
- } catch {
43
- return null;
44
- }
37
+ function toElement(node) {
38
+ try {
39
+ if (!node) return null;
40
+ if (node.nodeType === 1) return node; // ELEMENT_NODE
41
+ if (node.nodeType === 3) return node.parentElement || null; // TEXT_NODE
42
+ return null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ const __contrastSharedCache =
49
+ helpers && helpers.contrast && helpers.contrast.sharedCache
50
+ ? helpers.contrast.sharedCache
51
+ : null;
52
+ const __elBlockerCache = __contrastSharedCache
53
+ ? __contrastSharedCache.__elBlockerCache ||
54
+ (__contrastSharedCache.__elBlockerCache = new WeakMap())
55
+ : null;
56
+
57
+ const __elBgCache = __contrastSharedCache
58
+ ? __contrastSharedCache.__elBgCache || (__contrastSharedCache.__elBgCache = new WeakMap())
59
+ : null;
60
+
61
+ const __elFgCache = __contrastSharedCache
62
+ ? __contrastSharedCache.__elFgCache || (__contrastSharedCache.__elFgCache = new WeakMap())
63
+ : null;
64
+
65
+ const __elFontCache = __contrastSharedCache
66
+ ? __contrastSharedCache.__elFontCache || (__contrastSharedCache.__elFontCache = new WeakMap())
67
+ : new WeakMap();
68
+
69
+ function safeComputedStyle(el) {
70
+ try {
71
+ if (!el || el.nodeType !== 1) return null;
72
+
73
+ if (helpers && typeof helpers.computedStyle === 'function') {
74
+ const cs = helpers.computedStyle(el);
75
+ if (cs) return cs;
76
+ }
77
+ const view =
78
+ el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView : null;
79
+ if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
80
+ } catch {}
81
+ return null;
82
+ }
83
+
84
+ function getFontInfo(el) {
85
+ try {
86
+ if (!el || el.nodeType !== 1) {
87
+ return {
88
+ fontSizePx: 0,
89
+ fontSizePt: '',
90
+ fontWeightNum: 400,
91
+ fontWeight: 'normal',
92
+ isBold: false,
93
+ isLarge: false,
94
+ isLargeText: false
95
+ };
96
+ }
97
+
98
+ const cached = __elFontCache.get(el);
99
+ if (cached) return cached;
100
+
101
+ const cs = safeComputedStyle(el);
102
+ const fontSizePx = cs ? helpers.contrast.parsePx(cs.fontSize) : null;
103
+ const fontWeightNum = cs ? helpers.contrast.normalizeFontWeight(cs.fontWeight) : 400;
104
+
105
+ const sizePx = Number.isFinite(fontSizePx) ? fontSizePx : 0;
106
+ const isBold = Number.isFinite(fontWeightNum) && fontWeightNum >= 700;
107
+ const isLarge = helpers.contrast.isLargeText(sizePx, fontWeightNum);
108
+
109
+ const out = {
110
+ fontSizePx: sizePx,
111
+ fontSizePt: helpers.contrast.pxToPt(sizePx),
112
+ fontWeightNum,
113
+ fontWeight: helpers.contrast.fontWeightLabel(fontWeightNum),
114
+ isBold,
115
+ isLarge,
116
+ isLargeText: isLarge
117
+ };
118
+
119
+ __elFontCache.set(el, out);
120
+ return out;
121
+ } catch {
122
+ return {
123
+ fontSizePx: 0,
124
+ fontSizePt: '',
125
+ fontWeightNum: 400,
126
+ fontWeight: 'normal',
127
+ isBold: false,
128
+ isLarge: false,
129
+ isLargeText: false
130
+ };
45
131
  }
132
+ }
46
133
 
47
- const __contrastSharedCache = helpers && helpers.contrast && helpers.contrast.sharedCache ? helpers.contrast.sharedCache : null;
48
- const __elBlockerCache = __contrastSharedCache
49
- ? (__contrastSharedCache.__elBlockerCache || (__contrastSharedCache.__elBlockerCache = new WeakMap()))
50
- : null;
134
+ const contrast =
135
+ engineOptions && typeof engineOptions.contrast === 'object' && engineOptions.contrast
136
+ ? engineOptions.contrast
137
+ : {};
51
138
 
52
- const __elBgCache = __contrastSharedCache
53
- ? (__contrastSharedCache.__elBgCache || (__contrastSharedCache.__elBgCache = new WeakMap()))
54
- : null;
139
+ const mode = contrast.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
55
140
 
56
- const __elFgCache = __contrastSharedCache
57
- ? (__contrastSharedCache.__elFgCache || (__contrastSharedCache.__elFgCache = new WeakMap()))
58
- : null;
141
+ const rootCanvasFallback =
142
+ typeof contrast.rootCanvasFallback === 'string' && contrast.rootCanvasFallback.trim()
143
+ ? contrast.rootCanvasFallback.trim()
144
+ : '#ffffff';
59
145
 
60
- const __elFontCache = __contrastSharedCache
61
- ? (__contrastSharedCache.__elFontCache || (__contrastSharedCache.__elFontCache = new WeakMap()))
62
- : new WeakMap();
146
+ const MAX_OCCURRENCES = 50;
63
147
 
64
- function safeComputedStyle(el) {
65
- try {
66
- if (!el || el.nodeType !== 1) return null;
148
+ const occurrences = [];
149
+ let eligibleTextCount = 0;
150
+ let computableTextCount = 0;
151
+ let failCount = 0;
152
+
153
+ const seenFailEls = new Set();
67
154
 
68
- if (helpers && typeof helpers.computedStyle === 'function') {
69
- const cs = helpers.computedStyle(el);
70
- if (cs) return cs;
155
+ function pushPassOccurrence(eligibleCount, computableCount) {
156
+ try {
157
+ occurrences.push({
158
+ selector: '',
159
+ summary: 'All computable text meets the minimum (AA) contrast threshold.',
160
+ hint: '',
161
+ html: '',
162
+ i18n: {
163
+ summaryKey: 'contrastMinimum_pass_allAboveThreshold',
164
+ hintKey: '',
165
+ params: {
166
+ eligibleTextCount: String(Number(eligibleCount) || 0),
167
+ computableTextCount: String(Number(computableCount) || 0)
168
+ }
169
+ },
170
+ data: {
171
+ details: {
172
+ reasonCode: 'ALL_ABOVE_THRESHOLD',
173
+ eligibleTextCount: Number(eligibleCount) || 0,
174
+ computableTextCount: Number(computableCount) || 0,
175
+ metrics: {
176
+ eligibleTextCount: Number(eligibleCount) || 0,
177
+ computableTextCount: Number(computableCount) || 0
71
178
  }
72
- const view = (el.ownerDocument && el.ownerDocument.defaultView)
73
- ? el.ownerDocument.defaultView
74
- : null;
75
- if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
76
- } catch {}
77
- return null;
179
+ }
180
+ }
181
+ });
182
+ } catch {
183
+ // no-throw
78
184
  }
185
+ }
79
186
 
80
- function getFontInfo(el) {
187
+ function pushFailOccurrence(el, params, details) {
188
+ try {
189
+ if (!el || seenFailEls.has(el)) return;
190
+ if (occurrences.length >= MAX_OCCURRENCES) return;
191
+
192
+ seenFailEls.add(el);
193
+
194
+ const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
195
+
196
+ const occBase = {
197
+ selector: '',
198
+ html: '',
199
+ summary: '',
200
+ hint: '',
201
+ i18n: {
202
+ summaryKey: 'contrastMinimum_fail_belowThreshold',
203
+ hintKey: '',
204
+ params: params && typeof params === 'object' ? params : {}
205
+ },
206
+ data: { details: det }
207
+ };
208
+
209
+ // Bind the occurrence to an element deterministically, but still let the engine
210
+ // attach canonical selector + HTML snippet via reportOccurrence when available.
211
+ let nodeSelector = '';
212
+ try {
213
+ const elementId =
214
+ el && typeof el.getAttribute === 'function' ? el.getAttribute('id') || '' : '';
215
+ if (elementId) nodeSelector = `#${elementId}`;
216
+ } catch {
217
+ // no-throw
218
+ }
219
+ const tagName = el && el.tagName ? String(el.tagName).toLowerCase() : 'element';
220
+
221
+ let occ = { ...occBase };
222
+
223
+ // IMPORTANT: this populates occ.selector and occ.html (snippet) when available
224
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
81
225
  try {
82
- if (!el || el.nodeType !== 1) {
83
- return {
84
- fontSizePx: 0,
85
- fontSizePt: '',
86
- fontWeightNum: 400,
87
- fontWeight: 'normal',
88
- isBold: false,
89
- isLarge: false,
90
- isLargeText: false
91
- };
92
- }
93
-
94
- const cached = __elFontCache.get(el);
95
- if (cached) return cached;
96
-
97
- const cs = safeComputedStyle(el);
98
- const fontSizePx = cs ? helpers.contrast.parsePx(cs.fontSize) : null;
99
- const fontWeightNum = cs ? helpers.contrast.normalizeFontWeight(cs.fontWeight) : 400;
100
-
101
- const sizePx = Number.isFinite(fontSizePx) ? fontSizePx : 0;
102
- const isBold = Number.isFinite(fontWeightNum) && fontWeightNum >= 700;
103
- const isLarge = helpers.contrast.isLargeText(sizePx, fontWeightNum);
104
-
105
- const out = {
106
- fontSizePx: sizePx,
107
- fontSizePt: helpers.contrast.pxToPt(sizePx),
108
- fontWeightNum,
109
- fontWeight: helpers.contrast.fontWeightLabel(fontWeightNum),
110
- isBold,
111
- isLarge,
112
- isLargeText: isLarge
113
- };
114
-
115
- __elFontCache.set(el, out);
116
- return out;
226
+ const reported = helpers.reportOccurrence(el, occBase);
227
+ if (reported && typeof reported === 'object') occ = reported;
117
228
  } catch {
118
- return {
119
- fontSizePx: 0,
120
- fontSizePt: '',
121
- fontWeightNum: 400,
122
- fontWeight: 'normal',
123
- isBold: false,
124
- isLarge: false,
125
- isLargeText: false
126
- };
229
+ // no-throw
127
230
  }
128
- }
129
-
130
- const contrast = engineOptions && typeof engineOptions.contrast === 'object' && engineOptions.contrast
131
- ? engineOptions.contrast
132
- : {};
133
-
134
- const mode = contrast.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
231
+ }
135
232
 
136
- const rootCanvasFallback =
137
- typeof contrast.rootCanvasFallback === 'string' && contrast.rootCanvasFallback.trim()
138
- ? contrast.rootCanvasFallback.trim()
139
- : '#ffffff';
233
+ occ.selector = occ.selector || nodeSelector || '';
140
234
 
141
- const MAX_OCCURRENCES = 50;
235
+ occ.data = occ.data || {};
236
+ occ.data.details = occ.data.details || {};
237
+ occ.data.details.node = { selector: nodeSelector, tagName };
142
238
 
143
- const occurrences = [];
144
- let eligibleTextCount = 0;
145
- let computableTextCount = 0;
146
- let failCount = 0;
147
-
148
- const seenFailEls = new Set();
239
+ occurrences.push(occ);
240
+ } catch {
241
+ // no-throw
242
+ }
243
+ }
149
244
 
150
- function pushPassOccurrence(eligibleCount, computableCount) {
151
- try {
152
- occurrences.push({
153
- selector: '',
154
- summary: 'All computable text meets the minimum (AA) contrast threshold.',
155
- hint: '',
156
- html: '',
157
- i18n: {
158
- summaryKey: 'contrastMinimum_pass_allAboveThreshold',
159
- hintKey: '',
160
- params: {
161
- eligibleTextCount: String(Number(eligibleCount) || 0),
162
- computableTextCount: String(Number(computableCount) || 0)
163
- }
164
- },
165
- data: {
166
- details: {
167
- reasonCode: 'ALL_ABOVE_THRESHOLD',
168
- eligibleTextCount: Number(eligibleCount) || 0,
169
- computableTextCount: Number(computableCount) || 0,
170
- metrics: {
171
- eligibleTextCount: Number(eligibleCount) || 0,
172
- computableTextCount: Number(computableCount) || 0
173
- }
174
- }
175
- }
176
- });
177
- } catch {
178
- // no-throw
179
- }
245
+ // perf: per-element analysis cache (per run)
246
+ let __elAnalysisCache = new WeakMap();
247
+ if (__contrastSharedCache) {
248
+ try {
249
+ if (!__contrastSharedCache.__elAnalysisCacheAA)
250
+ __contrastSharedCache.__elAnalysisCacheAA = new WeakMap();
251
+ __elAnalysisCache = __contrastSharedCache.__elAnalysisCacheAA;
252
+ } catch {
253
+ __elAnalysisCache = new WeakMap();
180
254
  }
255
+ }
256
+
257
+ // Shared deterministic text scan (computed once per run and reused across contrast checks)
258
+ let scan;
259
+ try {
260
+ scan =
261
+ helpers && helpers.contrast && typeof helpers.contrast.getTextScan === 'function'
262
+ ? helpers.contrast.getTextScan(ctx, helpers, engineOptions)
263
+ : null;
264
+ } catch {
265
+ scan = null;
266
+ }
181
267
 
182
- function pushFailOccurrence(el, params, details) {
183
- try {
184
- if (!el || seenFailEls.has(el)) return;
185
- if (occurrences.length >= MAX_OCCURRENCES) return;
186
-
187
- seenFailEls.add(el);
188
-
189
- const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
190
-
191
- const occBase = {
192
- selector: '',
193
- html: '',
194
- summary: '',
195
- hint: '',
196
- i18n: {
197
- summaryKey: 'contrastMinimum_fail_belowThreshold',
198
- hintKey: '',
199
- params: params && typeof params === 'object' ? params : {}
200
- },
201
- data: { details: det }
268
+ // Walk eligible visible text nodes (counted per text node), but compute expensive analysis once per element.
269
+ if (scan && scan.elements && Array.isArray(scan.elements)) {
270
+ try {
271
+ eligibleTextCount = Number(scan.eligibleTextCount) || 0;
272
+
273
+ for (const rec of scan.elements) {
274
+ const el = toElement(rec && rec.el);
275
+ const textCount = rec && Number(rec.textCount) ? Number(rec.textCount) : 0;
276
+ if (!el || textCount <= 0) continue;
277
+
278
+ // Computability + contrast analysis (cached per element)
279
+ let analysis = __elAnalysisCache.get(el);
280
+ if (!analysis) {
281
+ // Computability gate (do NOT emit cantTell here; Rule 1 is responsible for that)
282
+ let blocker = __elBlockerCache ? __elBlockerCache.get(el) : null;
283
+ if (!blocker) {
284
+ blocker = helpers.contrast.getComputabilityBlocker(el);
285
+ blocker = blocker || {
286
+ ok: true,
287
+ reasonCode: null,
288
+ blockerSelector: '',
289
+ blockerProperty: '',
290
+ blockerValue: ''
202
291
  };
203
-
204
- // Optional deterministic node metadata
205
- let nodeMeta = null;
206
- try {
207
- const elementId = el && typeof el.getAttribute === 'function' ? (el.getAttribute('id') || '') : '';
208
- const tagName = el && el.tagName ? String(el.tagName).toLowerCase() : 'element';
209
- nodeMeta = { selector: elementId ? `#${elementId}` : '', tagName };
210
- } catch {
211
- // no-throw
292
+ if (__elBlockerCache) __elBlockerCache.set(el, blocker);
293
+ }
294
+
295
+ if (blocker && blocker.ok === false) {
296
+ analysis = { computable: false };
297
+ } else {
298
+ let bg = __elBgCache ? __elBgCache.get(el) : null;
299
+ if (!bg) {
300
+ bg = helpers.contrast.computeEffectiveBackground(el, {
301
+ contrast: { mode, rootCanvasFallback },
302
+ collectStack: false
303
+ });
304
+ bg = bg || {
305
+ ok: false,
306
+ reasonCode: 'BACKGROUND_NOT_COMPUTABLE',
307
+ rgba: null,
308
+ alpha: 0
309
+ };
310
+ if (__elBgCache) __elBgCache.set(el, bg);
212
311
  }
213
312
 
214
- let occ = { ...occBase };
313
+ const bgAssumptionsApplied =
314
+ bg && Array.isArray(bg.assumptionsApplied) && bg.assumptionsApplied.length
315
+ ? bg.assumptionsApplied.slice(0)
316
+ : null;
317
+ const bgAssumedRootCanvasColor =
318
+ bg && typeof bg.assumedRootCanvasColor === 'string'
319
+ ? bg.assumedRootCanvasColor
320
+ : null;
215
321
 
216
- // IMPORTANT: this populates occ.selector and occ.html (snippet) when available
217
- if (helpers && typeof helpers.reportOccurrence === 'function') {
218
- try {
219
- const reported = helpers.reportOccurrence(el, occBase);
220
- if (reported && typeof reported === 'object') occ = reported;
221
- } catch {
222
- // no-throw
223
- }
322
+ let fg = __elFgCache ? __elFgCache.get(el) : null;
323
+ if (!fg) {
324
+ fg = helpers.contrast.computeEffectiveForeground(el);
325
+ fg = fg || { rgba: null, alpha: 0, opacityProduct: 1 };
326
+ if (__elFgCache) __elFgCache.set(el, fg);
224
327
  }
225
328
 
226
- if (nodeMeta) {
227
- occ.node = nodeMeta;
228
- if (!occ.selector) occ.selector = nodeMeta.selector || '';
329
+ if (!bg || bg.ok === false || !bg.rgba || !fg || !fg.rgba) {
330
+ analysis = { computable: false };
331
+ } else {
332
+ // Compose FG over BG if FG has alpha (effective fg may be < 1 due to opacity chain)
333
+ const fgOpaque =
334
+ fg.rgba.a != null && fg.rgba.a < 1
335
+ ? helpers.contrast.compositeRgba(fg.rgba, bg.rgba)
336
+ : { r: fg.rgba.r, g: fg.rgba.g, b: fg.rgba.b, a: 1 };
337
+
338
+ const bgOpaque = { r: bg.rgba.r, g: bg.rgba.g, b: bg.rgba.b, a: 1 };
339
+
340
+ const ratio = helpers.contrast.contrastRatio(fgOpaque, bgOpaque);
341
+
342
+ const font = getFontInfo(el);
343
+ const threshold = helpers.contrast.requiredRatio('AA', font.isLargeText);
344
+
345
+ analysis = {
346
+ computable: true,
347
+ ratio,
348
+ bgAssumptionsApplied,
349
+ bgAssumedRootCanvasColor,
350
+ ratioStr: helpers.contrast.round2(ratio),
351
+ threshold,
352
+ thresholdStr: `${threshold}`,
353
+ font,
354
+ fgOpaque,
355
+ bgOpaque
356
+ };
229
357
  }
230
-
231
- occurrences.push(occ);
232
- } catch {
233
- // no-throw
358
+ }
359
+ __elAnalysisCache.set(el, analysis);
234
360
  }
235
- }
236
361
 
237
- // perf: per-element analysis cache (per run)
238
- let __elAnalysisCache = new WeakMap();
239
- if (__contrastSharedCache) {
240
- try {
241
- if (!__contrastSharedCache.__elAnalysisCacheAA) __contrastSharedCache.__elAnalysisCacheAA = new WeakMap();
242
- __elAnalysisCache = __contrastSharedCache.__elAnalysisCacheAA;
243
- } catch {
244
- __elAnalysisCache = new WeakMap();
362
+ if (!analysis || analysis.computable !== true) continue;
363
+
364
+ computableTextCount += textCount;
365
+
366
+ const ratio = analysis.ratio;
367
+ const font = analysis.font;
368
+ const threshold = analysis.threshold;
369
+ const ratioStr = analysis.ratioStr;
370
+ const thresholdStr = analysis.thresholdStr;
371
+ const fgOpaque = analysis.fgOpaque;
372
+ const bgOpaque = analysis.bgOpaque;
373
+
374
+ if (!(ratio >= threshold)) {
375
+ failCount += textCount;
376
+
377
+ const fgHex = helpers.contrast.rgbToHex ? helpers.contrast.rgbToHex(fgOpaque) : '';
378
+
379
+ const bgHex = helpers.contrast.rgbToHex ? helpers.contrast.rgbToHex(bgOpaque) : '';
380
+
381
+ const fontPxNum = font.fontSizePx ? parseFloat(font.fontSizePx) : NaN;
382
+
383
+ const fontPtStr =
384
+ helpers.contrast.pxToPt && Number.isFinite(fontPxNum)
385
+ ? helpers.contrast.pxToPt(fontPxNum)
386
+ : '';
387
+
388
+ const fwNum = Number(font.fontWeightNum);
389
+ const fwLabel = font.fontWeight || (font.isBold ? 'bold' : 'normal');
390
+
391
+ const fgRgbaStr = helpers.contrast.rgbaToString(fgOpaque);
392
+ const bgRgbaStr = helpers.contrast.rgbaToString(bgOpaque);
393
+
394
+ const params = {
395
+ reasonCode: 'BELOW_THRESHOLD',
396
+
397
+ foreground: fgRgbaStr,
398
+ background: bgRgbaStr,
399
+
400
+ foregroundHex: fgHex,
401
+ backgroundHex: bgHex,
402
+ fontSizePt: fontPtStr,
403
+ fontWeightLabel: fwLabel,
404
+
405
+ ratio: ratioStr,
406
+ threshold: thresholdStr,
407
+
408
+ fontSizePx: font.fontSizePx,
409
+ fontWeight: font.fontWeight,
410
+ isBold: font.isBold,
411
+ isLargeText: font.isLargeText
412
+ };
413
+
414
+ const assumptionsApplied =
415
+ analysis &&
416
+ Array.isArray(analysis.bgAssumptionsApplied) &&
417
+ analysis.bgAssumptionsApplied.length
418
+ ? analysis.bgAssumptionsApplied.slice(0)
419
+ : null;
420
+ const assumedRootCanvasColor =
421
+ analysis && typeof analysis.bgAssumedRootCanvasColor === 'string'
422
+ ? analysis.bgAssumedRootCanvasColor
423
+ : null;
424
+
425
+ const details = {
426
+ reasonCode: 'BELOW_THRESHOLD',
427
+ metrics: { ratio, threshold },
428
+ typography: {
429
+ fontSizePx: Number.isFinite(fontPxNum) ? fontPxNum : null,
430
+ fontSizePt: Number.isFinite(fontPxNum) ? fontPxNum * 0.75 : null,
431
+ fontWeight: Number.isFinite(fwNum) ? fwNum : null,
432
+ fontWeightLabel: fwLabel,
433
+ isBold: !!font.isBold,
434
+ isLargeText: !!font.isLargeText
435
+ },
436
+ colors: {
437
+ foregroundHex: fgHex,
438
+ backgroundHex: bgHex,
439
+ foregroundRgba: fgRgbaStr,
440
+ backgroundRgba: bgRgbaStr
441
+ },
442
+ assumptionsApplied: assumptionsApplied,
443
+ assumedRootCanvasColor: assumedRootCanvasColor
444
+ };
445
+
446
+ pushFailOccurrence(el, params, details);
447
+
448
+ if (occurrences.length >= MAX_OCCURRENCES) break;
245
449
  }
246
- }
247
-
248
-
249
-
250
- // Shared deterministic text scan (computed once per run and reused across contrast checks)
251
- let scan = null;
252
- try {
253
- scan = helpers && helpers.contrast && typeof helpers.contrast.getTextScan === 'function'
254
- ? helpers.contrast.getTextScan(ctx, helpers, engineOptions)
255
- : null;
450
+ }
256
451
  } catch {
257
- scan = null;
258
- }
259
-
260
- // Walk eligible visible text nodes (counted per text node), but compute expensive analysis once per element.
261
- if (scan && scan.elements && Array.isArray(scan.elements)) {
262
- try {
263
- eligibleTextCount = Number(scan.eligibleTextCount) || 0;
264
-
265
- for (const rec of scan.elements) {
266
- const el = toElement(rec && rec.el);
267
- const textCount = rec && Number(rec.textCount) ? Number(rec.textCount) : 0;
268
- if (!el || textCount <= 0) continue;
269
-
270
- // Computability + contrast analysis (cached per element)
271
- let analysis = __elAnalysisCache.get(el);
272
- if (!analysis) {
273
- // Computability gate (do NOT emit cantTell here; Rule 1 is responsible for that)
274
- let blocker = __elBlockerCache ? __elBlockerCache.get(el) : null;
275
- if (!blocker) {
276
- blocker = helpers.contrast.getComputabilityBlocker(el);
277
- blocker = blocker || { ok: true, reasonCode: null, blockerSelector: '', blockerProperty: '', blockerValue: '' };
278
- if (__elBlockerCache) __elBlockerCache.set(el, blocker);
279
- }
280
-
281
- if (blocker && blocker.ok === false) {
282
- analysis = { computable: false };
283
- } else {
284
- let bg = __elBgCache ? __elBgCache.get(el) : null;
285
- if (!bg) {
286
- bg = helpers.contrast.computeEffectiveBackground(el, {
287
- contrast: {mode, rootCanvasFallback},
288
- collectStack: false
289
- });
290
- bg = bg || { ok: false, reasonCode: 'BACKGROUND_NOT_COMPUTABLE', rgba: null, alpha: 0 };
291
- if (__elBgCache) __elBgCache.set(el, bg);
292
- }
293
-
294
- const bgAssumptionsApplied = (bg && Array.isArray(bg.assumptionsApplied) && bg.assumptionsApplied.length)
295
- ? bg.assumptionsApplied.slice(0)
296
- : null;
297
- const bgAssumedRootCanvasColor = (bg && typeof bg.assumedRootCanvasColor === 'string')
298
- ? bg.assumedRootCanvasColor
299
- : null;
300
-
301
- let fg = __elFgCache ? __elFgCache.get(el) : null;
302
- if (!fg) {
303
- fg = helpers.contrast.computeEffectiveForeground(el);
304
- fg = fg || { rgba: null, alpha: 0, opacityProduct: 1 };
305
- if (__elFgCache) __elFgCache.set(el, fg);
306
- }
307
-
308
- if (!bg || bg.ok === false || !bg.rgba || !fg || !fg.rgba) {
309
- analysis = { computable: false };
310
- } else {
311
- // Compose FG over BG if FG has alpha (effective fg may be < 1 due to opacity chain)
312
- const fgOpaque = (fg.rgba.a != null && fg.rgba.a < 1)
313
- ? helpers.contrast.compositeRgba(fg.rgba, bg.rgba)
314
- : { r: fg.rgba.r, g: fg.rgba.g, b: fg.rgba.b, a: 1 };
315
-
316
- const bgOpaque = { r: bg.rgba.r, g: bg.rgba.g, b: bg.rgba.b, a: 1 };
317
-
318
- const ratio = helpers.contrast.contrastRatio(fgOpaque, bgOpaque);
319
-
320
- const font = getFontInfo(el);
321
- const threshold = helpers.contrast.requiredRatio('AA', font.isLargeText);
322
-
323
- analysis = {
324
- computable: true,
325
- ratio,
326
- bgAssumptionsApplied,
327
- bgAssumedRootCanvasColor,
328
- ratioStr: helpers.contrast.round2(ratio),
329
- threshold,
330
- thresholdStr: `${threshold}`,
331
- font,
332
- fgOpaque,
333
- bgOpaque
334
- };
335
- }
336
- }
337
- __elAnalysisCache.set(el, analysis);
338
- }
339
-
340
- if (!analysis || analysis.computable !== true) continue;
341
-
342
- computableTextCount += textCount;
343
-
344
- const ratio = analysis.ratio;
345
- const font = analysis.font;
346
- const threshold = analysis.threshold;
347
- const ratioStr = analysis.ratioStr;
348
- const thresholdStr = analysis.thresholdStr;
349
- const fgOpaque = analysis.fgOpaque;
350
- const bgOpaque = analysis.bgOpaque;
351
-
352
- if (!(ratio >= threshold)) {
353
- failCount += textCount;
354
-
355
- const fgHex = (helpers.contrast.rgbToHex)
356
- ? helpers.contrast.rgbToHex(fgOpaque)
357
- : '';
358
-
359
- const bgHex = (helpers.contrast.rgbToHex)
360
- ? helpers.contrast.rgbToHex(bgOpaque)
361
- : '';
362
-
363
- const fontPxNum = font.fontSizePx ? parseFloat(font.fontSizePx) : NaN;
364
-
365
- const fontPtStr = (helpers.contrast.pxToPt && Number.isFinite(fontPxNum))
366
- ? helpers.contrast.pxToPt(fontPxNum)
367
- : '';
368
-
369
- const fwNum = Number(font.fontWeightNum);
370
- const fwLabel = font.fontWeight || (font.isBold ? 'bold' : 'normal');
371
-
372
- const fgRgbaStr = helpers.contrast.rgbaToString(fgOpaque);
373
- const bgRgbaStr = helpers.contrast.rgbaToString(bgOpaque);
374
-
375
- const params = {
376
- reasonCode: 'BELOW_THRESHOLD',
377
-
378
- foreground: fgRgbaStr,
379
- background: bgRgbaStr,
380
-
381
- foregroundHex: fgHex,
382
- backgroundHex: bgHex,
383
- fontSizePt: fontPtStr,
384
- fontWeightLabel: fwLabel,
385
-
386
- ratio: ratioStr,
387
- threshold: thresholdStr,
388
-
389
- fontSizePx: font.fontSizePx,
390
- fontWeight: font.fontWeight,
391
- isBold: font.isBold,
392
- isLargeText: font.isLargeText
393
- };
394
-
395
- const assumptionsApplied = (analysis && Array.isArray(analysis.bgAssumptionsApplied) && analysis.bgAssumptionsApplied.length)
396
- ? analysis.bgAssumptionsApplied.slice(0)
397
- : null;
398
- const assumedRootCanvasColor = (analysis && typeof analysis.bgAssumedRootCanvasColor === 'string')
399
- ? analysis.bgAssumedRootCanvasColor
400
- : null;
401
-
402
- const details = {
403
- reasonCode: 'BELOW_THRESHOLD',
404
- metrics: {ratio, threshold},
405
- typography: {
406
- fontSizePx: Number.isFinite(fontPxNum) ? fontPxNum : null,
407
- fontSizePt: Number.isFinite(fontPxNum) ? (fontPxNum * 0.75) : null,
408
- fontWeight: Number.isFinite(fwNum) ? fwNum : null,
409
- fontWeightLabel: fwLabel,
410
- isBold: !!font.isBold,
411
- isLargeText: !!font.isLargeText
412
- },
413
- colors: {
414
- foregroundHex: fgHex,
415
- backgroundHex: bgHex,
416
- foregroundRgba: fgRgbaStr,
417
- backgroundRgba: bgRgbaStr
418
- },
419
- assumptionsApplied: assumptionsApplied,
420
- assumedRootCanvasColor: assumedRootCanvasColor
421
- };
422
-
423
- pushFailOccurrence(el, params, details);
424
-
425
- if (occurrences.length >= MAX_OCCURRENCES) break;
426
- }
427
- }
428
- } catch {
429
- // No-throw: deterministic cantTell on internal failure
430
- return {
431
- ruleId: rule.ruleId,
432
- outcome: 'cantTell',
433
- severity: rule.defaultSeverity || 'serious',
434
- confidence: rule.defaultConfidence || 'high',
435
- occurrences: [
436
- {
437
- selector: '',
438
- summary: '',
439
- hint: '',
440
- html: '',
441
- i18n: {
442
- summaryKey: 'contrastMinimum_cantTell_engineFailure',
443
- hintKey: '',
444
- params: {reasonCode: 'ENGINE_EXCEPTION'}
445
- },
446
- data: {details: {reasonCode: 'ENGINE_EXCEPTION'}}
447
- }
448
- ]
449
- };
450
- }
451
- }
452
-
453
- if (eligibleTextCount === 0) {
454
- return {
455
- ruleId: rule.ruleId,
456
- outcome: 'notApplicable',
457
- severity: rule.defaultSeverity || 'serious',
458
- confidence: rule.defaultConfidence || 'high',
459
- occurrences: []
460
- };
461
- }
462
-
463
- // If there was eligible text but nothing computable, this rule stays notApplicable
464
- // because Rule 1 (computability) is responsible for cantTell.
465
- if (computableTextCount === 0) {
466
- return {
467
- ruleId: rule.ruleId,
468
- outcome: 'notApplicable',
469
- severity: rule.defaultSeverity || 'serious',
470
- confidence: rule.defaultConfidence || 'high',
471
- occurrences: [
472
- {
473
- selector: '',
474
- summary: '',
475
- hint: '',
476
- html: '',
477
- i18n: {
478
- summaryKey: 'contrastMinimum_notApplicable_noComputableText',
479
- hintKey: '',
480
- params: {eligibleTextCount: String(eligibleTextCount)}
481
- },
482
- data: {details: {eligibleTextCount, computableTextCount}}
483
- }
484
- ]
485
- };
452
+ // No-throw: deterministic cantTell on internal failure
453
+ return {
454
+ ruleId: rule.ruleId,
455
+ outcome: 'cantTell',
456
+ severity: rule.defaultSeverity || 'serious',
457
+ confidence: rule.defaultConfidence || 'high',
458
+ occurrences: [
459
+ {
460
+ selector: '',
461
+ summary: '',
462
+ hint: '',
463
+ html: '',
464
+ i18n: {
465
+ summaryKey: 'contrastMinimum_cantTell_engineFailure',
466
+ hintKey: '',
467
+ params: { reasonCode: 'ENGINE_EXCEPTION' }
468
+ },
469
+ data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
470
+ }
471
+ ]
472
+ };
486
473
  }
474
+ }
487
475
 
488
- if (failCount > 0) {
489
- return {
490
- ruleId: rule.ruleId,
491
- outcome: 'fail',
492
- severity: rule.defaultSeverity || 'serious',
493
- confidence: rule.defaultConfidence || 'high',
494
- occurrences
495
- };
496
- }
476
+ if (eligibleTextCount === 0) {
477
+ return {
478
+ ruleId: rule.ruleId,
479
+ outcome: 'notApplicable',
480
+ severity: rule.defaultSeverity || 'serious',
481
+ confidence: rule.defaultConfidence || 'high',
482
+ occurrences: []
483
+ };
484
+ }
497
485
 
498
- // All computable text passed
499
- if (!occurrences.length) {
500
- pushPassOccurrence(eligibleTextCount, computableTextCount);
501
- }
486
+ // If there was eligible text but nothing computable, this rule stays notApplicable
487
+ // because Rule 1 (computability) is responsible for cantTell.
488
+ if (computableTextCount === 0) {
489
+ return {
490
+ ruleId: rule.ruleId,
491
+ outcome: 'notApplicable',
492
+ severity: rule.defaultSeverity || 'serious',
493
+ confidence: rule.defaultConfidence || 'high',
494
+ occurrences: [
495
+ {
496
+ selector: '',
497
+ summary: '',
498
+ hint: '',
499
+ html: '',
500
+ i18n: {
501
+ summaryKey: 'contrastMinimum_notApplicable_noComputableText',
502
+ hintKey: '',
503
+ params: { eligibleTextCount: String(eligibleTextCount) }
504
+ },
505
+ data: { details: { eligibleTextCount, computableTextCount } }
506
+ }
507
+ ]
508
+ };
509
+ }
502
510
 
511
+ if (failCount > 0) {
503
512
  return {
504
- ruleId: rule.ruleId,
505
- outcome: 'pass',
506
- severity: rule.defaultSeverity || 'serious',
507
- confidence: rule.defaultConfidence || 'high',
508
- occurrences
513
+ ruleId: rule.ruleId,
514
+ outcome: 'fail',
515
+ severity: rule.defaultSeverity || 'serious',
516
+ confidence: rule.defaultConfidence || 'high',
517
+ occurrences
509
518
  };
519
+ }
520
+
521
+ // All computable text passed
522
+ if (!occurrences.length) {
523
+ pushPassOccurrence(eligibleTextCount, computableTextCount);
524
+ }
525
+
526
+ return {
527
+ ruleId: rule.ruleId,
528
+ outcome: 'pass',
529
+ severity: rule.defaultSeverity || 'serious',
530
+ confidence: rule.defaultConfidence || 'high',
531
+ occurrences
532
+ };
510
533
  }
511
534
 
512
- module.exports = {id, meta, runInPage};
535
+ module.exports = { id, meta, runInPage };