@surea11y/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/bin/core.js +244 -0
  5. package/docs/BINDING_AUTHORS_GUIDE.md +41 -0
  6. package/docs/CLI.md +49 -0
  7. package/docs/ENGINE_OPTIONS.md +155 -0
  8. package/docs/I18N.md +47 -0
  9. package/docs/INTEGRATION.md +156 -0
  10. package/docs/LIMITATIONS.md +31 -0
  11. package/docs/OUTPUT_SCHEMA.md +237 -0
  12. package/docs/POLICY.md +71 -0
  13. package/docs/RULE_AUTHORING.md +375 -0
  14. package/docs/RULE_CATALOG.md +180 -0
  15. package/docs/RULE_TAXONOMY.md +145 -0
  16. package/docs/TROUBLESHOOTING.md +48 -0
  17. package/docs/WCAG_CONFORMANCE.md +49 -0
  18. package/package.json +60 -0
  19. package/src/catalogs/composites.wcag.js +490 -0
  20. package/src/checks/automatic/area-alt-present.js +225 -0
  21. package/src/checks/automatic/aria-allowed-attr.js +206 -0
  22. package/src/checks/automatic/aria-allowed-role.js +102 -0
  23. package/src/checks/automatic/aria-braille-equivalent.js +139 -0
  24. package/src/checks/automatic/aria-conditional-attr.js +110 -0
  25. package/src/checks/automatic/aria-deprecated-role.js +106 -0
  26. package/src/checks/automatic/aria-hidden-body.js +87 -0
  27. package/src/checks/automatic/aria-hidden-focus.js +480 -0
  28. package/src/checks/automatic/aria-prohibited-attr.js +156 -0
  29. package/src/checks/automatic/aria-prohibited-children.js +265 -0
  30. package/src/checks/automatic/aria-required-attr.js +154 -0
  31. package/src/checks/automatic/aria-required-children.js +274 -0
  32. package/src/checks/automatic/aria-required-parent.js +222 -0
  33. package/src/checks/automatic/aria-role-name-present.js +201 -0
  34. package/src/checks/automatic/aria-roles-valid.js +110 -0
  35. package/src/checks/automatic/aria-valid-attr-value.js +123 -0
  36. package/src/checks/automatic/aria-valid-attr.js +109 -0
  37. package/src/checks/automatic/autocomplete-valid.js +134 -0
  38. package/src/checks/automatic/avoid-inline-spacing.js +107 -0
  39. package/src/checks/automatic/binary-control-name-present.js +294 -0
  40. package/src/checks/automatic/button-name-present.js +146 -0
  41. package/src/checks/automatic/bypass-blocks-present.js +162 -0
  42. package/src/checks/automatic/canvas-text-alternative-present.js +140 -0
  43. package/src/checks/automatic/combobox-name-present.js +267 -0
  44. package/src/checks/automatic/contrast-computable.js +378 -0
  45. package/src/checks/automatic/contrast-enhanced.js +517 -0
  46. package/src/checks/automatic/contrast-minimum.js +512 -0
  47. package/src/checks/automatic/css-orientation-lock.js +206 -0
  48. package/src/checks/automatic/definition-list-children-valid.js +148 -0
  49. package/src/checks/automatic/deprecated-elements-not-used.js +91 -0
  50. package/src/checks/automatic/dialog-name-present.js +209 -0
  51. package/src/checks/automatic/dlitem-parent-valid.js +100 -0
  52. package/src/checks/automatic/duplicate-id-aria.js +126 -0
  53. package/src/checks/automatic/embed-text-alternative-present.js +190 -0
  54. package/src/checks/automatic/form-control-programmatic-label-present.js +409 -0
  55. package/src/checks/automatic/form-control-single-label.js +117 -0
  56. package/src/checks/automatic/html-xml-lang-mismatch.js +91 -0
  57. package/src/checks/automatic/iframe-focusable-content.js +141 -0
  58. package/src/checks/automatic/iframe-name-present.js +102 -0
  59. package/src/checks/automatic/iframe-title-unique.js +107 -0
  60. package/src/checks/automatic/img-alt-present.js +223 -0
  61. package/src/checks/automatic/input-image-alt-present.js +155 -0
  62. package/src/checks/automatic/label-in-name.js +326 -0
  63. package/src/checks/automatic/language-page-present.js +159 -0
  64. package/src/checks/automatic/link-in-text-block.js +218 -0
  65. package/src/checks/automatic/link-name-present.js +114 -0
  66. package/src/checks/automatic/list-children-valid.js +152 -0
  67. package/src/checks/automatic/listbox-name-present.js +236 -0
  68. package/src/checks/automatic/listitem-parent-valid.js +118 -0
  69. package/src/checks/automatic/menuitem-name-present.js +201 -0
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +105 -0
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +107 -0
  72. package/src/checks/automatic/meta-viewport-zoom-enabled.js +118 -0
  73. package/src/checks/automatic/meter-name-present.js +160 -0
  74. package/src/checks/automatic/nested-interactive-controls-absent.js +135 -0
  75. package/src/checks/automatic/object-text-alternative-present.js +193 -0
  76. package/src/checks/automatic/option-name-present.js +157 -0
  77. package/src/checks/automatic/page-title-present.js +86 -0
  78. package/src/checks/automatic/progressbar-name-present.js +165 -0
  79. package/src/checks/automatic/role-img-alt-present.js +206 -0
  80. package/src/checks/automatic/searchbox-name-present.js +236 -0
  81. package/src/checks/automatic/server-side-image-map-absent.js +88 -0
  82. package/src/checks/automatic/slider-name-present.js +276 -0
  83. package/src/checks/automatic/spinbutton-name-present.js +236 -0
  84. package/src/checks/automatic/summary-name-present.js +153 -0
  85. package/src/checks/automatic/svg-image-text-alternative-present.js +220 -0
  86. package/src/checks/automatic/svg-text-alternative-present.js +298 -0
  87. package/src/checks/automatic/tab-name-present.js +200 -0
  88. package/src/checks/automatic/table-headers-attr-valid.js +122 -0
  89. package/src/checks/automatic/table-th-has-data-cells.js +117 -0
  90. package/src/checks/automatic/target-size-minimum.js +605 -0
  91. package/src/checks/automatic/td-has-header.js +151 -0
  92. package/src/checks/automatic/textbox-name-present.js +236 -0
  93. package/src/checks/automatic/tooltip-name-present.js +158 -0
  94. package/src/checks/automatic/treeitem-name-present.js +157 -0
  95. package/src/checks/automatic/valid-lang.js +100 -0
  96. package/src/checks/automatic/video-poster-text-alternative-present.js +193 -0
  97. package/src/checks/manual/accesskeys-manual.js +93 -0
  98. package/src/checks/manual/area-alt-decorative-manual.js +247 -0
  99. package/src/checks/manual/area-alt-quality-manual.js +204 -0
  100. package/src/checks/manual/aria-checked-state-mismatch-manual.js +141 -0
  101. package/src/checks/manual/aria-text-manual.js +109 -0
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +170 -0
  103. package/src/checks/manual/css-hidden-focus.js +259 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +204 -0
  105. package/src/checks/manual/empty-heading-manual.js +182 -0
  106. package/src/checks/manual/empty-table-header-manual.js +163 -0
  107. package/src/checks/manual/focus-order-semantics-manual.js +117 -0
  108. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +291 -0
  109. package/src/checks/manual/heading-order-manual.js +130 -0
  110. package/src/checks/manual/identical-links-same-purpose-manual.js +142 -0
  111. package/src/checks/manual/image-redundant-alt-manual.js +118 -0
  112. package/src/checks/manual/img-alt-decorative-manual.js +148 -0
  113. package/src/checks/manual/img-alt-quality-manual.js +182 -0
  114. package/src/checks/manual/input-image-alt-decorative-manual.js +144 -0
  115. package/src/checks/manual/input-image-alt-quality-manual.js +144 -0
  116. package/src/checks/manual/label-title-only-manual.js +115 -0
  117. package/src/checks/manual/landmark-banner-is-top-level-manual.js +180 -0
  118. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +169 -0
  119. package/src/checks/manual/landmark-main-is-top-level-manual.js +167 -0
  120. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +177 -0
  121. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +169 -0
  122. package/src/checks/manual/landmark-no-duplicate-main-manual.js +132 -0
  123. package/src/checks/manual/landmark-one-main-manual.js +151 -0
  124. package/src/checks/manual/landmark-unique-manual.js +252 -0
  125. package/src/checks/manual/link-name-quality-manual.js +143 -0
  126. package/src/checks/manual/media-transcript-present-manual.js +373 -0
  127. package/src/checks/manual/meta-viewport-large-manual.js +119 -0
  128. package/src/checks/manual/mouse-only-event-handlers-manual.js +134 -0
  129. package/src/checks/manual/no-autoplay-audio-manual.js +116 -0
  130. package/src/checks/manual/object-text-alternative-quality-manual.js +194 -0
  131. package/src/checks/manual/p-as-heading-manual.js +163 -0
  132. package/src/checks/manual/page-has-heading-one-manual.js +110 -0
  133. package/src/checks/manual/page-title-patterns-manual.js +262 -0
  134. package/src/checks/manual/presentation-role-conflict-manual.js +159 -0
  135. package/src/checks/manual/region-manual.js +183 -0
  136. package/src/checks/manual/scope-attr-valid-manual.js +93 -0
  137. package/src/checks/manual/scrollable-region-focusable-manual.js +168 -0
  138. package/src/checks/manual/skip-link-manual.js +150 -0
  139. package/src/checks/manual/svg-text-alternative-quality-manual.js +209 -0
  140. package/src/checks/manual/tabindex-manual.js +94 -0
  141. package/src/checks/manual/table-duplicate-name-manual.js +99 -0
  142. package/src/checks/manual/table-fake-caption-manual.js +122 -0
  143. package/src/checks/manual/video-caption-manual.js +118 -0
  144. package/src/checks/manual-review.js +95 -0
  145. package/src/checks/rules-and-tags.full.csv +19 -0
  146. package/src/checks/rules-and-tags.full.json +259 -0
  147. package/src/core/aria-helpers.js +906 -0
  148. package/src/core/contrast-helpers.js +1147 -0
  149. package/src/core/dom-helpers.js +4085 -0
  150. package/src/core/dom-runner.js +627 -0
  151. package/src/core/frame-messaging.js +210 -0
  152. package/src/core/frame-scan.js +178 -0
  153. package/src/core/rollup-composites.js +135 -0
  154. package/src/core/rule-meta.js +140 -0
  155. package/src/core.js +79055 -0
  156. package/src/coverage/wcag-facets.js +1079 -0
  157. package/src/coverage/wcag-version-map.js +84 -0
  158. package/src/i18n/en.js +919 -0
  159. package/src/i18n/fr.js +527 -0
  160. package/src/index.js +4 -0
  161. package/src/policy/contracts.js +18 -0
  162. package/src/policy/resolvePolicy.js +55 -0
  163. package/src/policy/schemas/engine-options.schema.json +103 -0
  164. package/src/policy/schemas/policy-contract.schema.json +40 -0
@@ -0,0 +1,517 @@
1
+ 'use strict';
2
+
3
+ const id = 'contrast-enhanced';
4
+
5
+ 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'] } }
31
+ };
32
+
33
+ function runInPage(ctx) {
34
+ const { helpers, rule, engineOptions } = ctx;
35
+
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
+ }
45
+ }
46
+
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;
51
+
52
+ const __elBgCache = __contrastSharedCache
53
+ ? (__contrastSharedCache.__elBgCache || (__contrastSharedCache.__elBgCache = new WeakMap()))
54
+ : null;
55
+
56
+ const __elFgCache = __contrastSharedCache
57
+ ? (__contrastSharedCache.__elFgCache || (__contrastSharedCache.__elFgCache = new WeakMap()))
58
+ : null;
59
+
60
+ const __elFontCache = __contrastSharedCache
61
+ ? (__contrastSharedCache.__elFontCache || (__contrastSharedCache.__elFontCache = new WeakMap()))
62
+ : new WeakMap();
63
+
64
+ function safeComputedStyle(el) {
65
+ try {
66
+ if (!el || el.nodeType !== 1) return null;
67
+
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
+ }
73
+
74
+ const view = (el.ownerDocument && el.ownerDocument.defaultView)
75
+ ? el.ownerDocument.defaultView
76
+ : null;
77
+
78
+ if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
79
+ } catch {}
80
+ return null;
81
+ }
82
+
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
+ }
96
+
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
+ };
117
+
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
+ };
130
+ }
131
+ }
132
+
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) {
154
+ 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
+ });
180
+ } catch {
181
+ // no-throw
182
+ }
183
+ }
184
+
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
+ };
206
+
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 || '';
231
+
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
+ }
240
+ }
241
+
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
+ }
251
+ }
252
+
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'
259
+ ? helpers.contrast.getTextScan(ctx, helpers, engineOptions)
260
+ : null;
261
+ } catch {
262
+ scan = null;
263
+ }
264
+
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)) {
267
+ 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);
343
+ }
344
+
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;
433
+ }
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
+
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
+ }
492
+
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
+ };
501
+ }
502
+
503
+ // All computable text passed
504
+ if (!occurrences.length) {
505
+ pushPassOccurrence(eligibleTextCount, computableTextCount);
506
+ }
507
+
508
+ return {
509
+ ruleId: rule.ruleId,
510
+ outcome: 'pass',
511
+ severity: rule.defaultSeverity || 'serious',
512
+ confidence: rule.defaultConfidence || 'high',
513
+ occurrences
514
+ };
515
+ }
516
+
517
+ module.exports = { id, meta, runInPage };