@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,517 +1,540 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'contrast-enhanced';
4
6
 
5
7
  const meta = {
6
- title: 'Text must meet the enhanced color contrast ratio',
7
- description:
8
- 'Checks that visible text has a contrast ratio of at least 7:1 (normal) or 4.5:1 (large), when contrast is computable from CSS.',
9
- i18n: {
10
- titleKey: 'contrastEnhanced_title',
11
- descriptionKey: 'contrastEnhanced_description'
12
- },
13
- helpUrl: null,
14
- tags: ['wcag2aaa', 'wcag146', 'contrast', 'color',
15
- 'structure', 'atomic', 'automatic', 'dom'],
16
- wcagSc: ['1.4.6'],
17
- normativeMappings: [
18
- {
19
- standard: 'WCAG',
20
- version: '2.2',
21
- requirement: '1.4.6',
22
- title: 'Contrast (Enhanced)',
23
- conformanceLevel: 'AAA'
24
- }
25
- ],
26
- defaultSeverity: 'serious',
27
- category: 'perceivable',
28
- type: 'automatic',
29
- defaultConfidence: 'high',
30
- coverage: { facetsBySc: { '1.4.6': ['contrast-enhanced-text'] } }
8
+ title: 'Text must meet the enhanced color contrast ratio',
9
+ description:
10
+ 'Checks that visible text has a contrast ratio of at least 7:1 (normal) or 4.5:1 (large), when contrast is computable from CSS.',
11
+ i18n: {
12
+ titleKey: 'contrastEnhanced_title',
13
+ descriptionKey: 'contrastEnhanced_description'
14
+ },
15
+ helpUrl: null,
16
+ tags: ['wcag2aaa', 'wcag146', 'contrast', 'color', 'structure', 'atomic', 'automatic', 'dom'],
17
+ wcagSc: ['1.4.6'],
18
+ normativeMappings: [
19
+ {
20
+ standard: 'WCAG',
21
+ version: '2.2',
22
+ requirement: '1.4.6',
23
+ title: 'Contrast (Enhanced)',
24
+ conformanceLevel: 'AAA'
25
+ }
26
+ ],
27
+ defaultSeverity: 'serious',
28
+ category: 'perceivable',
29
+ type: 'automatic',
30
+ defaultConfidence: 'high',
31
+ coverage: { facetsBySc: { '1.4.6': ['contrast-enhanced-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
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;
46
72
 
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;
73
+ // Prefer engine helper (matches checks/engine behavior + may be cached)
74
+ if (helpers && typeof helpers.computedStyle === 'function') {
75
+ const cs = helpers.computedStyle(el);
76
+ if (cs) return cs;
77
+ }
51
78
 
52
- const __elBgCache = __contrastSharedCache
53
- ? (__contrastSharedCache.__elBgCache || (__contrastSharedCache.__elBgCache = new WeakMap()))
54
- : null;
79
+ const view =
80
+ el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView : null;
55
81
 
56
- const __elFgCache = __contrastSharedCache
57
- ? (__contrastSharedCache.__elFgCache || (__contrastSharedCache.__elFgCache = new WeakMap()))
58
- : null;
82
+ if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
83
+ } catch {}
84
+ return null;
85
+ }
59
86
 
60
- const __elFontCache = __contrastSharedCache
61
- ? (__contrastSharedCache.__elFontCache || (__contrastSharedCache.__elFontCache = new WeakMap()))
62
- : new WeakMap();
87
+ function getFontInfo(el) {
88
+ try {
89
+ if (!el || el.nodeType !== 1) {
90
+ return {
91
+ fontSizePx: 0,
92
+ fontSizePt: '',
93
+ fontWeightNum: 400,
94
+ fontWeight: 'normal',
95
+ isBold: false,
96
+ isLarge: false,
97
+ isLargeText: false
98
+ };
99
+ }
100
+
101
+ const cached = __elFontCache.get(el);
102
+ if (cached) return cached;
103
+
104
+ const cs = safeComputedStyle(el);
105
+ const fontSizePx = cs ? helpers.contrast.parsePx(cs.fontSize) : null;
106
+ const fontWeightNum = cs ? helpers.contrast.normalizeFontWeight(cs.fontWeight) : 400;
107
+
108
+ const sizePx = Number.isFinite(fontSizePx) ? fontSizePx : 0;
109
+ const isBold = Number.isFinite(fontWeightNum) && fontWeightNum >= 700;
110
+ const isLarge = helpers.contrast.isLargeText(sizePx, fontWeightNum);
111
+
112
+ const out = {
113
+ fontSizePx: sizePx,
114
+ fontSizePt: helpers.contrast.pxToPt(sizePx),
115
+ fontWeightNum,
116
+ fontWeight: helpers.contrast.fontWeightLabel(fontWeightNum),
117
+ isBold,
118
+ isLarge,
119
+ isLargeText: isLarge
120
+ };
121
+
122
+ __elFontCache.set(el, out);
123
+ return out;
124
+ } catch {
125
+ return {
126
+ fontSizePx: 0,
127
+ fontSizePt: '',
128
+ fontWeightNum: 400,
129
+ fontWeight: 'normal',
130
+ isBold: false,
131
+ isLarge: false,
132
+ isLargeText: false
133
+ };
134
+ }
135
+ }
63
136
 
64
- function safeComputedStyle(el) {
65
- try {
66
- if (!el || el.nodeType !== 1) return null;
137
+ const contrast =
138
+ engineOptions && typeof engineOptions.contrast === 'object' && engineOptions.contrast
139
+ ? engineOptions.contrast
140
+ : {};
67
141
 
68
- // Prefer engine helper (matches checks/engine behavior + may be cached)
69
- if (helpers && typeof helpers.computedStyle === 'function') {
70
- const cs = helpers.computedStyle(el);
71
- if (cs) return cs;
72
- }
142
+ const mode = contrast.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
73
143
 
74
- const view = (el.ownerDocument && el.ownerDocument.defaultView)
75
- ? el.ownerDocument.defaultView
76
- : null;
144
+ const rootCanvasFallback =
145
+ typeof contrast.rootCanvasFallback === 'string' && contrast.rootCanvasFallback.trim()
146
+ ? contrast.rootCanvasFallback.trim()
147
+ : '#ffffff';
77
148
 
78
- if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
79
- } catch {}
80
- return null;
81
- }
149
+ const MAX_OCCURRENCES = 50;
82
150
 
83
- function getFontInfo(el) {
84
- try {
85
- if (!el || el.nodeType !== 1) {
86
- return {
87
- fontSizePx: 0,
88
- fontSizePt: '',
89
- fontWeightNum: 400,
90
- fontWeight: 'normal',
91
- isBold: false,
92
- isLarge: false,
93
- isLargeText: false
94
- };
95
- }
151
+ const occurrences = [];
152
+ let eligibleTextCount = 0;
153
+ let computableTextCount = 0;
154
+ let failCount = 0;
96
155
 
97
- const cached = __elFontCache.get(el);
98
- if (cached) return cached;
99
-
100
- const cs = safeComputedStyle(el);
101
- const fontSizePx = cs ? helpers.contrast.parsePx(cs.fontSize) : null;
102
- const fontWeightNum = cs ? helpers.contrast.normalizeFontWeight(cs.fontWeight) : 400;
103
-
104
- const sizePx = Number.isFinite(fontSizePx) ? fontSizePx : 0;
105
- const isBold = Number.isFinite(fontWeightNum) && fontWeightNum >= 700;
106
- const isLarge = helpers.contrast.isLargeText(sizePx, fontWeightNum);
107
-
108
- const out = {
109
- fontSizePx: sizePx,
110
- fontSizePt: helpers.contrast.pxToPt(sizePx),
111
- fontWeightNum,
112
- fontWeight: helpers.contrast.fontWeightLabel(fontWeightNum),
113
- isBold,
114
- isLarge,
115
- isLargeText: isLarge
116
- };
156
+ const seenFailEls = new Set();
117
157
 
118
- __elFontCache.set(el, out);
119
- return out;
120
- } catch {
121
- return {
122
- fontSizePx: 0,
123
- fontSizePt: '',
124
- fontWeightNum: 400,
125
- fontWeight: 'normal',
126
- isBold: false,
127
- isLarge: false,
128
- isLargeText: false
129
- };
158
+ function pushPassOccurrence(eligibleCount, computableCount) {
159
+ try {
160
+ occurrences.push({
161
+ selector: '',
162
+ summary: 'All computable text meets the enhanced (AAA) contrast threshold.',
163
+ hint: '',
164
+ html: '',
165
+ i18n: {
166
+ summaryKey: 'contrastEnhanced_pass_allAboveThreshold',
167
+ hintKey: '',
168
+ params: {
169
+ eligibleTextCount: String(Number(eligibleCount) || 0),
170
+ computableTextCount: String(Number(computableCount) || 0)
171
+ }
172
+ },
173
+ data: {
174
+ details: {
175
+ reasonCode: 'ALL_ABOVE_THRESHOLD',
176
+ eligibleTextCount: Number(eligibleCount) || 0,
177
+ computableTextCount: Number(computableCount) || 0,
178
+ metrics: {
179
+ eligibleTextCount: Number(eligibleCount) || 0,
180
+ computableTextCount: Number(computableCount) || 0
181
+ }
182
+ }
130
183
  }
184
+ });
185
+ } catch {
186
+ // no-throw
131
187
  }
188
+ }
132
189
 
133
- const contrast = engineOptions && typeof engineOptions.contrast === 'object' && engineOptions.contrast
134
- ? engineOptions.contrast
135
- : {};
136
-
137
- const mode = contrast.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
138
-
139
- const rootCanvasFallback =
140
- typeof contrast.rootCanvasFallback === 'string' && contrast.rootCanvasFallback.trim()
141
- ? contrast.rootCanvasFallback.trim()
142
- : '#ffffff';
143
-
144
- const MAX_OCCURRENCES = 50;
145
-
146
- const occurrences = [];
147
- let eligibleTextCount = 0;
148
- let computableTextCount = 0;
149
- let failCount = 0;
150
-
151
- const seenFailEls = new Set();
152
-
153
- function pushPassOccurrence(eligibleCount, computableCount) {
190
+ function pushFailOccurrence(el, params, details) {
191
+ try {
192
+ if (!el || seenFailEls.has(el)) return;
193
+ if (occurrences.length >= MAX_OCCURRENCES) return;
194
+
195
+ seenFailEls.add(el);
196
+
197
+ const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
198
+
199
+ const occBase = {
200
+ selector: '',
201
+ html: '',
202
+ summary: '',
203
+ hint: '',
204
+ i18n: {
205
+ summaryKey: 'contrastEnhanced_fail_belowThreshold',
206
+ hintKey: '',
207
+ params: params && typeof params === 'object' ? params : {}
208
+ },
209
+ data: { details: det }
210
+ };
211
+
212
+ // Bind the occurrence to an element deterministically, but still let the engine
213
+ // attach canonical selector + HTML snippet via reportOccurrence when available.
214
+ let nodeSelector = '';
215
+ try {
216
+ const elementId =
217
+ el && typeof el.getAttribute === 'function' ? el.getAttribute('id') || '' : '';
218
+ if (elementId) nodeSelector = `#${elementId}`;
219
+ } catch {
220
+ // no-throw
221
+ }
222
+ const tagName = el && el.tagName ? String(el.tagName).toLowerCase() : 'element';
223
+
224
+ let occ = { ...occBase };
225
+
226
+ // IMPORTANT: this is what populates occ.selector and occ.html (snippet)
227
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
154
228
  try {
155
- occurrences.push({
156
- selector: '',
157
- summary: 'All computable text meets the enhanced (AAA) contrast threshold.',
158
- hint: '',
159
- html: '',
160
- i18n: {
161
- summaryKey: 'contrastEnhanced_pass_allAboveThreshold',
162
- hintKey: '',
163
- params: {
164
- eligibleTextCount: String(Number(eligibleCount) || 0),
165
- computableTextCount: String(Number(computableCount) || 0)
166
- }
167
- },
168
- data: {
169
- details: {
170
- reasonCode: 'ALL_ABOVE_THRESHOLD',
171
- eligibleTextCount: Number(eligibleCount) || 0,
172
- computableTextCount: Number(computableCount) || 0,
173
- metrics: {
174
- eligibleTextCount: Number(eligibleCount) || 0,
175
- computableTextCount: Number(computableCount) || 0
176
- }
177
- }
178
- }
179
- });
229
+ const reported = helpers.reportOccurrence(el, occBase);
230
+ if (reported && typeof reported === 'object') occ = reported;
180
231
  } catch {
181
- // no-throw
232
+ // no-throw
182
233
  }
183
- }
234
+ }
184
235
 
185
- function pushFailOccurrence(el, params, details) {
186
- try {
187
- if (!el || seenFailEls.has(el)) return;
188
- if (occurrences.length >= MAX_OCCURRENCES) return;
189
-
190
- seenFailEls.add(el);
191
-
192
- const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
193
-
194
- const occBase = {
195
- selector: '',
196
- html: '',
197
- summary: '',
198
- hint: '',
199
- i18n: {
200
- summaryKey: 'contrastEnhanced_fail_belowThreshold',
201
- hintKey: '',
202
- params: params && typeof params === 'object' ? params : {}
203
- },
204
- data: { details: det }
205
- };
236
+ occ.selector = occ.selector || nodeSelector || '';
206
237
 
207
- // Bind the occurrence to an element deterministically, but still let the engine
208
- // attach canonical selector + HTML snippet via reportOccurrence when available.
209
- let nodeSelector = '';
210
- try {
211
- const elementId = el && typeof el.getAttribute === 'function' ? (el.getAttribute('id') || '') : '';
212
- if (elementId) nodeSelector = `#${elementId}`;
213
- } catch {
214
- // no-throw
215
- }
216
- const tagName = el && el.tagName ? String(el.tagName).toLowerCase() : 'element';
217
-
218
- let occ = { ...occBase };
219
-
220
- // IMPORTANT: this is what populates occ.selector and occ.html (snippet)
221
- if (helpers && typeof helpers.reportOccurrence === 'function') {
222
- try {
223
- const reported = helpers.reportOccurrence(el, occBase);
224
- if (reported && typeof reported === 'object') occ = reported;
225
- } catch {
226
- // no-throw
227
- }
228
- }
229
-
230
- occ.selector = occ.selector || nodeSelector || '';
238
+ occ.data = occ.data || {};
239
+ occ.data.details = occ.data.details || {};
240
+ occ.data.details.node = { selector: nodeSelector, tagName };
231
241
 
232
- occ.data = occ.data || {};
233
- occ.data.details = occ.data.details || {};
234
- occ.data.details.node = { selector: nodeSelector, tagName };
235
-
236
- occurrences.push(occ);
237
- } catch {
238
- // no-throw
239
- }
242
+ occurrences.push(occ);
243
+ } catch {
244
+ // no-throw
240
245
  }
246
+ }
241
247
 
242
- // perf: per-element analysis cache (per run)
243
- let __elAnalysisCache = new WeakMap();
244
- if (__contrastSharedCache) {
245
- try {
246
- if (!__contrastSharedCache.__elAnalysisCacheAAA) __contrastSharedCache.__elAnalysisCacheAAA = new WeakMap();
247
- __elAnalysisCache = __contrastSharedCache.__elAnalysisCacheAAA;
248
- } catch {
249
- __elAnalysisCache = new WeakMap();
250
- }
248
+ // perf: per-element analysis cache (per run)
249
+ let __elAnalysisCache = new WeakMap();
250
+ if (__contrastSharedCache) {
251
+ try {
252
+ if (!__contrastSharedCache.__elAnalysisCacheAAA)
253
+ __contrastSharedCache.__elAnalysisCacheAAA = new WeakMap();
254
+ __elAnalysisCache = __contrastSharedCache.__elAnalysisCacheAAA;
255
+ } catch {
256
+ __elAnalysisCache = new WeakMap();
251
257
  }
258
+ }
252
259
 
253
-
254
-
255
- // Shared deterministic text scan (computed once per run and reused across contrast checks)
256
- let scan = null;
257
- try {
258
- scan = helpers && helpers.contrast && typeof helpers.contrast.getTextScan === 'function'
260
+ // Shared deterministic text scan (computed once per run and reused across contrast checks)
261
+ let scan;
262
+ try {
263
+ scan =
264
+ helpers && helpers.contrast && typeof helpers.contrast.getTextScan === 'function'
259
265
  ? helpers.contrast.getTextScan(ctx, helpers, engineOptions)
260
266
  : null;
261
- } catch {
267
+ } catch {
262
268
  scan = null;
263
- }
269
+ }
264
270
 
265
- // Walk eligible visible text nodes (counted per text node), but compute expensive analysis once per element.
266
- if (scan && scan.elements && Array.isArray(scan.elements)) {
271
+ // Walk eligible visible text nodes (counted per text node), but compute expensive analysis once per element.
272
+ if (scan && scan.elements && Array.isArray(scan.elements)) {
267
273
  try {
268
- eligibleTextCount = Number(scan.eligibleTextCount) || 0;
269
-
270
- for (const rec of scan.elements) {
271
- const el = toElement(rec && rec.el);
272
- const textCount = rec && Number(rec.textCount) ? Number(rec.textCount) : 0;
273
- if (!el || textCount <= 0) continue;
274
-
275
- // Computability + contrast analysis (cached per element)
276
- let analysis = __elAnalysisCache.get(el);
277
- if (!analysis) {
278
- // Computability gate (do NOT emit cantTell here; Rule 1 is responsible for that)
279
- let blocker = __elBlockerCache ? __elBlockerCache.get(el) : null;
280
- if (!blocker) {
281
- blocker = helpers.contrast.getComputabilityBlocker(el);
282
- blocker = blocker || { ok: true, reasonCode: null, blockerSelector: '', blockerProperty: '', blockerValue: '' };
283
- if (__elBlockerCache) __elBlockerCache.set(el, blocker);
284
- }
285
-
286
- if (blocker && blocker.ok === false) {
287
- analysis = { computable: false };
288
- } else {
289
- let bg = __elBgCache ? __elBgCache.get(el) : null;
290
- if (!bg) {
291
- bg = helpers.contrast.computeEffectiveBackground(el, {
292
- contrast: {mode, rootCanvasFallback},
293
- collectStack: false
294
- });
295
- bg = bg || { ok: false, reasonCode: 'BACKGROUND_NOT_COMPUTABLE', rgba: null, alpha: 0 };
296
- if (__elBgCache) __elBgCache.set(el, bg);
297
- }
298
-
299
- const bgAssumptionsApplied = (bg && Array.isArray(bg.assumptionsApplied) && bg.assumptionsApplied.length)
300
- ? bg.assumptionsApplied.slice(0)
301
- : null;
302
- const bgAssumedRootCanvasColor = (bg && typeof bg.assumedRootCanvasColor === 'string')
303
- ? bg.assumedRootCanvasColor
304
- : null;
305
-
306
- let fg = __elFgCache ? __elFgCache.get(el) : null;
307
- if (!fg) {
308
- fg = helpers.contrast.computeEffectiveForeground(el);
309
- fg = fg || { rgba: null, alpha: 0, opacityProduct: 1 };
310
- if (__elFgCache) __elFgCache.set(el, fg);
311
- }
312
-
313
- if (!bg || bg.ok === false || !bg.rgba || !fg || !fg.rgba) {
314
- analysis = { computable: false };
315
- } else {
316
- // Compose FG over BG if FG has alpha (effective fg may be < 1 due to opacity chain)
317
- const fgOpaque = (fg.rgba.a != null && fg.rgba.a < 1)
318
- ? helpers.contrast.compositeRgba(fg.rgba, bg.rgba)
319
- : { r: fg.rgba.r, g: fg.rgba.g, b: fg.rgba.b, a: 1 };
320
-
321
- const bgOpaque = { r: bg.rgba.r, g: bg.rgba.g, b: bg.rgba.b, a: 1 };
322
-
323
- const ratio = helpers.contrast.contrastRatio(fgOpaque, bgOpaque);
324
-
325
- const font = getFontInfo(el);
326
- const threshold = helpers.contrast.requiredRatio('AAA', font.isLargeText);
327
-
328
- analysis = {
329
- computable: true,
330
- ratio,
331
- bgAssumptionsApplied,
332
- bgAssumedRootCanvasColor,
333
- ratioStr: helpers.contrast.round2(ratio),
334
- threshold,
335
- thresholdStr: `${threshold}`,
336
- font,
337
- fgOpaque,
338
- bgOpaque
339
- };
340
- }
341
- }
342
- __elAnalysisCache.set(el, analysis);
274
+ eligibleTextCount = Number(scan.eligibleTextCount) || 0;
275
+
276
+ for (const rec of scan.elements) {
277
+ const el = toElement(rec && rec.el);
278
+ const textCount = rec && Number(rec.textCount) ? Number(rec.textCount) : 0;
279
+ if (!el || textCount <= 0) continue;
280
+
281
+ // Computability + contrast analysis (cached per element)
282
+ let analysis = __elAnalysisCache.get(el);
283
+ if (!analysis) {
284
+ // Computability gate (do NOT emit cantTell here; Rule 1 is responsible for that)
285
+ let blocker = __elBlockerCache ? __elBlockerCache.get(el) : null;
286
+ if (!blocker) {
287
+ blocker = helpers.contrast.getComputabilityBlocker(el);
288
+ blocker = blocker || {
289
+ ok: true,
290
+ reasonCode: null,
291
+ blockerSelector: '',
292
+ blockerProperty: '',
293
+ blockerValue: ''
294
+ };
295
+ if (__elBlockerCache) __elBlockerCache.set(el, blocker);
296
+ }
297
+
298
+ if (blocker && blocker.ok === false) {
299
+ analysis = { computable: false };
300
+ } else {
301
+ let bg = __elBgCache ? __elBgCache.get(el) : null;
302
+ if (!bg) {
303
+ bg = helpers.contrast.computeEffectiveBackground(el, {
304
+ contrast: { mode, rootCanvasFallback },
305
+ collectStack: false
306
+ });
307
+ bg = bg || {
308
+ ok: false,
309
+ reasonCode: 'BACKGROUND_NOT_COMPUTABLE',
310
+ rgba: null,
311
+ alpha: 0
312
+ };
313
+ if (__elBgCache) __elBgCache.set(el, bg);
343
314
  }
344
315
 
345
- if (!analysis || analysis.computable !== true) continue;
346
-
347
- computableTextCount += textCount;
348
-
349
- const ratio = analysis.ratio;
350
- const font = analysis.font;
351
- const threshold = analysis.threshold;
352
- const ratioStr = analysis.ratioStr;
353
- const thresholdStr = analysis.thresholdStr;
354
- const fgOpaque = analysis.fgOpaque;
355
- const bgOpaque = analysis.bgOpaque;
356
-
357
- if (!(ratio >= threshold)) {
358
- failCount += textCount;
359
-
360
- const fgHex = (helpers.contrast.rgbToHex)
361
- ? helpers.contrast.rgbToHex(fgOpaque)
362
- : '';
363
-
364
- const bgHex = (helpers.contrast.rgbToHex)
365
- ? helpers.contrast.rgbToHex(bgOpaque)
366
- : '';
367
-
368
- const fontPxNum = font.fontSizePx ? parseFloat(font.fontSizePx) : NaN;
369
-
370
- const fontPtStr = (helpers.contrast.pxToPt && Number.isFinite(fontPxNum))
371
- ? helpers.contrast.pxToPt(fontPxNum)
372
- : '';
373
-
374
- const fwNum = Number(font.fontWeightNum);
375
- const fwLabel = (helpers.contrast.fontWeightLabel)
376
- ? helpers.contrast.fontWeightLabel(fwNum)
377
- : (font.isBold ? 'bold' : 'normal');
378
-
379
- const fgRgbaStr = helpers.contrast.rgbaToString(fgOpaque);
380
- const bgRgbaStr = helpers.contrast.rgbaToString(bgOpaque);
381
-
382
- const params = {
383
- reasonCode: 'BELOW_THRESHOLD',
384
-
385
- foreground: fgRgbaStr,
386
- background: bgRgbaStr,
387
-
388
- foregroundHex: fgHex,
389
- backgroundHex: bgHex,
390
- fontSizePt: fontPtStr,
391
- fontWeightLabel: fwLabel,
392
-
393
- ratio: ratioStr,
394
- threshold: thresholdStr,
395
-
396
- fontSizePx: font.fontSizePx,
397
- fontWeight: font.fontWeight,
398
- isBold: font.isBold,
399
- isLargeText: font.isLargeText
400
- };
401
-
402
- const assumptionsApplied = (analysis && Array.isArray(analysis.bgAssumptionsApplied) && analysis.bgAssumptionsApplied.length)
403
- ? analysis.bgAssumptionsApplied.slice(0)
404
- : null;
405
- const assumedRootCanvasColor = (analysis && typeof analysis.bgAssumedRootCanvasColor === 'string')
406
- ? analysis.bgAssumedRootCanvasColor
407
- : null;
408
-
409
- const details = {
410
- reasonCode: 'BELOW_THRESHOLD',
411
- metrics: { ratio, threshold },
412
- typography: {
413
- fontSizePx: Number.isFinite(fontPxNum) ? fontPxNum : null,
414
- fontSizePt: Number.isFinite(fontPxNum) ? (fontPxNum * 0.75) : null,
415
- fontWeight: Number.isFinite(fwNum) ? fwNum : null,
416
- fontWeightLabel: fwLabel,
417
- isBold: !!font.isBold,
418
- isLargeText: !!font.isLargeText
419
- },
420
- colors: {
421
- foregroundHex: fgHex,
422
- backgroundHex: bgHex,
423
- foregroundRgba: fgRgbaStr,
424
- backgroundRgba: bgRgbaStr
425
- },
426
- assumptionsApplied: assumptionsApplied,
427
- assumedRootCanvasColor: assumedRootCanvasColor
428
- };
429
-
430
- pushFailOccurrence(el, params, details);
431
-
432
- if (occurrences.length >= MAX_OCCURRENCES) break;
316
+ const bgAssumptionsApplied =
317
+ bg && Array.isArray(bg.assumptionsApplied) && bg.assumptionsApplied.length
318
+ ? bg.assumptionsApplied.slice(0)
319
+ : null;
320
+ const bgAssumedRootCanvasColor =
321
+ bg && typeof bg.assumedRootCanvasColor === 'string'
322
+ ? bg.assumedRootCanvasColor
323
+ : null;
324
+
325
+ let fg = __elFgCache ? __elFgCache.get(el) : null;
326
+ if (!fg) {
327
+ fg = helpers.contrast.computeEffectiveForeground(el);
328
+ fg = fg || { rgba: null, alpha: 0, opacityProduct: 1 };
329
+ if (__elFgCache) __elFgCache.set(el, fg);
433
330
  }
434
- }
435
- } catch {
436
- // No-throw: deterministic cantTell on internal failure
437
- return {
438
- ruleId: rule.ruleId,
439
- outcome: 'cantTell',
440
- severity: rule.defaultSeverity || 'serious',
441
- confidence: rule.defaultConfidence || 'high',
442
- occurrences: [
443
- {
444
- selector: '',
445
- summary: '',
446
- hint: '',
447
- html: '',
448
- i18n: {
449
- summaryKey: 'contrastEnhanced_cantTell_engineFailure',
450
- hintKey: '',
451
- params: { reasonCode: 'ENGINE_EXCEPTION' }
452
- },
453
- data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
454
- }
455
- ]
456
- };
457
- }
458
- }
459
- if (eligibleTextCount === 0) {
460
- return {
461
- ruleId: rule.ruleId,
462
- outcome: 'notApplicable',
463
- severity: rule.defaultSeverity || 'serious',
464
- confidence: rule.defaultConfidence || 'high',
465
- occurrences: []
466
- };
467
- }
468
331
 
469
- // If eligible text exists but none is computable, stay notApplicable; Rule 1 reports cantTell.
470
- if (computableTextCount === 0) {
471
- return {
472
- ruleId: rule.ruleId,
473
- outcome: 'notApplicable',
474
- severity: rule.defaultSeverity || 'serious',
475
- confidence: rule.defaultConfidence || 'high',
476
- occurrences: [
477
- {
478
- selector: '',
479
- summary: '',
480
- hint: '',
481
- html: '',
482
- i18n: {
483
- summaryKey: 'contrastEnhanced_notApplicable_noComputableText',
484
- hintKey: '',
485
- params: { eligibleTextCount: String(eligibleTextCount) }
486
- },
487
- data: { details: { eligibleTextCount, computableTextCount } }
488
- }
489
- ]
490
- };
491
- }
332
+ if (!bg || bg.ok === false || !bg.rgba || !fg || !fg.rgba) {
333
+ analysis = { computable: false };
334
+ } else {
335
+ // Compose FG over BG if FG has alpha (effective fg may be < 1 due to opacity chain)
336
+ const fgOpaque =
337
+ fg.rgba.a != null && fg.rgba.a < 1
338
+ ? helpers.contrast.compositeRgba(fg.rgba, bg.rgba)
339
+ : { r: fg.rgba.r, g: fg.rgba.g, b: fg.rgba.b, a: 1 };
340
+
341
+ const bgOpaque = { r: bg.rgba.r, g: bg.rgba.g, b: bg.rgba.b, a: 1 };
342
+
343
+ const ratio = helpers.contrast.contrastRatio(fgOpaque, bgOpaque);
344
+
345
+ const font = getFontInfo(el);
346
+ const threshold = helpers.contrast.requiredRatio('AAA', font.isLargeText);
347
+
348
+ analysis = {
349
+ computable: true,
350
+ ratio,
351
+ bgAssumptionsApplied,
352
+ bgAssumedRootCanvasColor,
353
+ ratioStr: helpers.contrast.round2(ratio),
354
+ threshold,
355
+ thresholdStr: `${threshold}`,
356
+ font,
357
+ fgOpaque,
358
+ bgOpaque
359
+ };
360
+ }
361
+ }
362
+ __elAnalysisCache.set(el, analysis);
363
+ }
492
364
 
493
- if (failCount > 0) {
494
- return {
495
- ruleId: rule.ruleId,
496
- outcome: 'fail',
497
- severity: rule.defaultSeverity || 'serious',
498
- confidence: rule.defaultConfidence || 'high',
499
- occurrences
500
- };
365
+ if (!analysis || analysis.computable !== true) continue;
366
+
367
+ computableTextCount += textCount;
368
+
369
+ const ratio = analysis.ratio;
370
+ const font = analysis.font;
371
+ const threshold = analysis.threshold;
372
+ const ratioStr = analysis.ratioStr;
373
+ const thresholdStr = analysis.thresholdStr;
374
+ const fgOpaque = analysis.fgOpaque;
375
+ const bgOpaque = analysis.bgOpaque;
376
+
377
+ if (!(ratio >= threshold)) {
378
+ failCount += textCount;
379
+
380
+ const fgHex = helpers.contrast.rgbToHex ? helpers.contrast.rgbToHex(fgOpaque) : '';
381
+
382
+ const bgHex = helpers.contrast.rgbToHex ? helpers.contrast.rgbToHex(bgOpaque) : '';
383
+
384
+ const fontPxNum = font.fontSizePx ? parseFloat(font.fontSizePx) : NaN;
385
+
386
+ const fontPtStr =
387
+ helpers.contrast.pxToPt && Number.isFinite(fontPxNum)
388
+ ? helpers.contrast.pxToPt(fontPxNum)
389
+ : '';
390
+
391
+ const fwNum = Number(font.fontWeightNum);
392
+ const fwLabel = helpers.contrast.fontWeightLabel
393
+ ? helpers.contrast.fontWeightLabel(fwNum)
394
+ : font.isBold
395
+ ? 'bold'
396
+ : 'normal';
397
+
398
+ const fgRgbaStr = helpers.contrast.rgbaToString(fgOpaque);
399
+ const bgRgbaStr = helpers.contrast.rgbaToString(bgOpaque);
400
+
401
+ const params = {
402
+ reasonCode: 'BELOW_THRESHOLD',
403
+
404
+ foreground: fgRgbaStr,
405
+ background: bgRgbaStr,
406
+
407
+ foregroundHex: fgHex,
408
+ backgroundHex: bgHex,
409
+ fontSizePt: fontPtStr,
410
+ fontWeightLabel: fwLabel,
411
+
412
+ ratio: ratioStr,
413
+ threshold: thresholdStr,
414
+
415
+ fontSizePx: font.fontSizePx,
416
+ fontWeight: font.fontWeight,
417
+ isBold: font.isBold,
418
+ isLargeText: font.isLargeText
419
+ };
420
+
421
+ const assumptionsApplied =
422
+ analysis &&
423
+ Array.isArray(analysis.bgAssumptionsApplied) &&
424
+ analysis.bgAssumptionsApplied.length
425
+ ? analysis.bgAssumptionsApplied.slice(0)
426
+ : null;
427
+ const assumedRootCanvasColor =
428
+ analysis && typeof analysis.bgAssumedRootCanvasColor === 'string'
429
+ ? analysis.bgAssumedRootCanvasColor
430
+ : null;
431
+
432
+ const details = {
433
+ reasonCode: 'BELOW_THRESHOLD',
434
+ metrics: { ratio, threshold },
435
+ typography: {
436
+ fontSizePx: Number.isFinite(fontPxNum) ? fontPxNum : null,
437
+ fontSizePt: Number.isFinite(fontPxNum) ? fontPxNum * 0.75 : null,
438
+ fontWeight: Number.isFinite(fwNum) ? fwNum : null,
439
+ fontWeightLabel: fwLabel,
440
+ isBold: !!font.isBold,
441
+ isLargeText: !!font.isLargeText
442
+ },
443
+ colors: {
444
+ foregroundHex: fgHex,
445
+ backgroundHex: bgHex,
446
+ foregroundRgba: fgRgbaStr,
447
+ backgroundRgba: bgRgbaStr
448
+ },
449
+ assumptionsApplied: assumptionsApplied,
450
+ assumedRootCanvasColor: assumedRootCanvasColor
451
+ };
452
+
453
+ pushFailOccurrence(el, params, details);
454
+
455
+ if (occurrences.length >= MAX_OCCURRENCES) break;
456
+ }
457
+ }
458
+ } catch {
459
+ // No-throw: deterministic cantTell on internal failure
460
+ return {
461
+ ruleId: rule.ruleId,
462
+ outcome: 'cantTell',
463
+ severity: rule.defaultSeverity || 'serious',
464
+ confidence: rule.defaultConfidence || 'high',
465
+ occurrences: [
466
+ {
467
+ selector: '',
468
+ summary: '',
469
+ hint: '',
470
+ html: '',
471
+ i18n: {
472
+ summaryKey: 'contrastEnhanced_cantTell_engineFailure',
473
+ hintKey: '',
474
+ params: { reasonCode: 'ENGINE_EXCEPTION' }
475
+ },
476
+ data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
477
+ }
478
+ ]
479
+ };
501
480
  }
481
+ }
482
+ if (eligibleTextCount === 0) {
483
+ return {
484
+ ruleId: rule.ruleId,
485
+ outcome: 'notApplicable',
486
+ severity: rule.defaultSeverity || 'serious',
487
+ confidence: rule.defaultConfidence || 'high',
488
+ occurrences: []
489
+ };
490
+ }
502
491
 
503
- // All computable text passed
504
- if (!occurrences.length) {
505
- pushPassOccurrence(eligibleTextCount, computableTextCount);
506
- }
492
+ // If eligible text exists but none is computable, stay notApplicable; Rule 1 reports cantTell.
493
+ if (computableTextCount === 0) {
494
+ return {
495
+ ruleId: rule.ruleId,
496
+ outcome: 'notApplicable',
497
+ severity: rule.defaultSeverity || 'serious',
498
+ confidence: rule.defaultConfidence || 'high',
499
+ occurrences: [
500
+ {
501
+ selector: '',
502
+ summary: '',
503
+ hint: '',
504
+ html: '',
505
+ i18n: {
506
+ summaryKey: 'contrastEnhanced_notApplicable_noComputableText',
507
+ hintKey: '',
508
+ params: { eligibleTextCount: String(eligibleTextCount) }
509
+ },
510
+ data: { details: { eligibleTextCount, computableTextCount } }
511
+ }
512
+ ]
513
+ };
514
+ }
507
515
 
516
+ if (failCount > 0) {
508
517
  return {
509
- ruleId: rule.ruleId,
510
- outcome: 'pass',
511
- severity: rule.defaultSeverity || 'serious',
512
- confidence: rule.defaultConfidence || 'high',
513
- occurrences
518
+ ruleId: rule.ruleId,
519
+ outcome: 'fail',
520
+ severity: rule.defaultSeverity || 'serious',
521
+ confidence: rule.defaultConfidence || 'high',
522
+ occurrences
514
523
  };
524
+ }
525
+
526
+ // All computable text passed
527
+ if (!occurrences.length) {
528
+ pushPassOccurrence(eligibleTextCount, computableTextCount);
529
+ }
530
+
531
+ return {
532
+ ruleId: rule.ruleId,
533
+ outcome: 'pass',
534
+ severity: rule.defaultSeverity || 'serious',
535
+ confidence: rule.defaultConfidence || 'high',
536
+ occurrences
537
+ };
515
538
  }
516
539
 
517
- module.exports = { id, meta, runInPage };
540
+ module.exports = { id, meta, runInPage };