@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,378 +1,400 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
1
3
  'use strict';
2
4
 
3
5
  const id = 'contrast-computable';
4
6
 
5
7
  const meta = {
6
- title: 'Color contrast is computable for rendered text',
7
- description:
8
- 'Determines whether sufficient information is available to compute WCAG color contrast for visible text (e.g., no gradients/images/blend modes that make background indeterminate).',
9
- i18n: {
10
- titleKey: 'contrastComputable_title',
11
- descriptionKey: 'contrastComputable_description'
8
+ title: 'Color contrast is computable for rendered text',
9
+ description:
10
+ 'Determines whether sufficient information is available to compute WCAG color contrast for visible text (e.g., no gradients/images/blend modes that make background indeterminate).',
11
+ i18n: {
12
+ titleKey: 'contrastComputable_title',
13
+ descriptionKey: 'contrastComputable_description'
14
+ },
15
+ helpUrl: null,
16
+ tags: [
17
+ 'wcag2aa',
18
+ 'wcag2aaa',
19
+ 'wcag143',
20
+ 'wcag146',
21
+ 'contrast',
22
+ 'color',
23
+ 'structure',
24
+ 'atomic',
25
+ 'automatic',
26
+ 'dom'
27
+ ],
28
+ wcagSc: ['1.4.3', '1.4.6'],
29
+ normativeMappings: [
30
+ {
31
+ standard: 'WCAG',
32
+ version: '2.2',
33
+ requirement: '1.4.3',
34
+ title: 'Contrast (Minimum)',
35
+ conformanceLevel: 'AA'
12
36
  },
13
- helpUrl: null,
14
- tags: [
15
- 'wcag2aa',
16
- 'wcag2aaa',
17
- 'wcag143',
18
- 'wcag146',
19
- 'contrast',
20
- 'color',
21
- 'structure',
22
- 'atomic',
23
- 'automatic',
24
- 'dom'
25
- ],
26
- wcagSc: ['1.4.3', '1.4.6'],
27
- normativeMappings: [
28
- {
29
- standard: 'WCAG',
30
- version: '2.2',
31
- requirement: '1.4.3',
32
- title: 'Contrast (Minimum)',
33
- conformanceLevel: 'AA'
34
- },
35
- {
36
- standard: 'WCAG',
37
- version: '2.2',
38
- requirement: '1.4.6',
39
- title: 'Contrast (Enhanced)',
40
- conformanceLevel: 'AAA'
41
- }
42
- ],
43
- defaultSeverity: 'serious',
44
- category: 'perceivable',
45
- type: 'automatic',
46
- defaultConfidence: 'high',
47
- coverage: {
48
- facetsBySc: {
49
- '1.4.3': ['contrast-computability-143'],
50
- '1.4.6': ['contrast-computability-146']
51
- }
37
+ {
38
+ standard: 'WCAG',
39
+ version: '2.2',
40
+ requirement: '1.4.6',
41
+ title: 'Contrast (Enhanced)',
42
+ conformanceLevel: 'AAA'
52
43
  }
44
+ ],
45
+ defaultSeverity: 'serious',
46
+ category: 'perceivable',
47
+ type: 'automatic',
48
+ defaultConfidence: 'high',
49
+ coverage: {
50
+ facetsBySc: {
51
+ '1.4.3': ['contrast-computability-143'],
52
+ '1.4.6': ['contrast-computability-146']
53
+ }
54
+ }
53
55
  };
54
56
 
55
57
  function runInPage(ctx) {
56
- const {helpers, rule, engineOptions} = ctx;
58
+ const { helpers, rule, engineOptions } = ctx;
57
59
 
58
- const __contrastSharedCache = helpers && helpers.contrast && helpers.contrast.sharedCache ? helpers.contrast.sharedCache : null;
60
+ const __contrastSharedCache =
61
+ helpers && helpers.contrast && helpers.contrast.sharedCache
62
+ ? helpers.contrast.sharedCache
63
+ : null;
59
64
 
60
- const contrast = engineOptions && typeof engineOptions.contrast === 'object' && engineOptions.contrast
61
- ? engineOptions.contrast
62
- : {};
65
+ const contrast =
66
+ engineOptions && typeof engineOptions.contrast === 'object' && engineOptions.contrast
67
+ ? engineOptions.contrast
68
+ : {};
63
69
 
64
- const mode = contrast.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
70
+ const mode = contrast.mode === 'auditorAssist' ? 'auditorAssist' : 'strictConformance';
65
71
 
66
- const rootCanvasFallback =
67
- typeof contrast.rootCanvasFallback === 'string' && contrast.rootCanvasFallback.trim()
68
- ? contrast.rootCanvasFallback.trim()
69
- : '#ffffff';
72
+ const rootCanvasFallback =
73
+ typeof contrast.rootCanvasFallback === 'string' && contrast.rootCanvasFallback.trim()
74
+ ? contrast.rootCanvasFallback.trim()
75
+ : '#ffffff';
70
76
 
71
- const MAX_OCCURRENCES = 50;
77
+ const MAX_OCCURRENCES = 50;
72
78
 
73
- const occurrences = [];
74
- let eligibleTextCount = 0;
75
- let cantTellCount = 0;
76
- let assumptionsCount = 0;
77
- const assumptionsAppliedSet = new Set();
79
+ const occurrences = [];
80
+ let eligibleTextCount = 0;
81
+ let cantTellCount = 0;
82
+ let assumptionsCount = 0;
83
+ const assumptionsAppliedSet = new Set();
78
84
 
79
- const seenFailEls = new Set();
85
+ const seenFailEls = new Set();
80
86
 
81
- function pushCantTellOccurrence(el, reasonCode, extraDetails) {
82
- try {
83
- if (!el || seenFailEls.has(el)) return;
84
- if (occurrences.length >= MAX_OCCURRENCES) return;
85
- seenFailEls.add(el);
86
-
87
- const rc = String(reasonCode || 'UNKNOWN');
88
-
89
- // Match test contract: choose a specific summaryKey per computability blocker.
90
- // Fall back to the generic notComputable key.
91
- let summaryKey = 'contrastComputable_cantTell_notComputable';
92
- if (rc === 'BACKGROUND_IMAGE_OR_GRADIENT') {
93
- const t =
94
- extraDetails && typeof extraDetails === 'object'
95
- ? String(extraDetails.backgroundFillType || '')
96
- : '';
97
-
98
- if (t === 'image') summaryKey = 'contrastComputable_cantTell_bgImage';
99
- else if (t === 'gradient') summaryKey = 'contrastComputable_cantTell_bgGradient';
100
- else if (t === 'imageAndGradient') summaryKey = 'contrastComputable_cantTell_bgImageAndGradient';
101
- else summaryKey = 'contrastComputable_cantTell_bgImageOrGradient';
102
- } else if (rc === 'MIX_BLEND_MODE') summaryKey = 'contrastComputable_cantTell_mixBlendMode';
103
- else if (rc === 'BACKGROUND_FILTER_OR_BACKDROP_FILTER') {
104
- const bp = extraDetails && typeof extraDetails === 'object' ? String(extraDetails.blockerProperty || '') : '';
105
- if (bp === 'filter') summaryKey = 'contrastComputable_cantTell_filter';
106
- else if (bp === 'backdrop-filter') summaryKey = 'contrastComputable_cantTell_backdropFilter';
107
- else summaryKey = 'contrastComputable_cantTell_filterOrBackdropFilter';
108
- } else if (rc === 'BACKGROUND_NOT_OPAQUE_AT_ROOT') summaryKey = 'contrastComputable_cantTell_rootNotOpaque';
109
-
110
- const details =
111
- Object.assign(
112
- {reasonCode: rc},
113
- extraDetails && typeof extraDetails === 'object' ? extraDetails : {}
114
- );
115
-
116
- const occBase = {
117
- selector: '',
118
- html: '',
119
- summary: '',
120
- hint: '',
121
- i18n: {
122
- summaryKey,
123
- hintKey: '',
124
- params: Object.assign(
125
- {reasonCode: rc},
126
- (details && typeof details === 'object')
127
- ? {
128
- blockerProperty: details.blockerProperty ? String(details.blockerProperty) : '',
129
- blockerValue: details.blockerValue ? String(details.blockerValue) : '',
130
- backgroundAlpha:
131
- (details.backgroundAlpha !== undefined && details.backgroundAlpha !== null)
132
- ? String(details.backgroundAlpha)
133
- : ''
134
- }
135
- : {}
136
- )
137
- },
138
- data: {details}
139
- };
140
-
141
- if (helpers && typeof helpers.reportOccurrence === 'function') {
142
- occurrences.push(helpers.reportOccurrence(el, occBase));
143
- } else {
144
- // Never compute selector/snippet in the rule.
145
- occurrences.push({...occBase});
146
- }
147
- } catch {
148
- // no-throw
149
- }
150
- }
151
-
152
- // perf: cache per-element analysis so multiple text nodes in same element don't repeat expensive work
153
- const __elBlockerCache = __contrastSharedCache
154
- ? (__contrastSharedCache.__elBlockerCache || (__contrastSharedCache.__elBlockerCache = new WeakMap()))
155
- : new WeakMap(); // Element -> { ok:boolean, reasonCode, blockerSelector, blockerProperty, blockerValue }
156
- const __elBgCache = __contrastSharedCache
157
- ? (__contrastSharedCache.__elBgCache || (__contrastSharedCache.__elBgCache = new WeakMap()))
158
- : new WeakMap(); // Element -> { ok, rgba, alpha, reasonCode } (no stack)
159
- const __elFgCache = __contrastSharedCache
160
- ? (__contrastSharedCache.__elFgCache || (__contrastSharedCache.__elFgCache = new WeakMap()))
161
- : new WeakMap(); // Element -> { rgba, alpha, opacityProduct }
162
-
163
- // perf: fast-path memo for self-opaque background (common) to avoid ancestor walk
164
- const __elBgSelfOpaqueCache = __contrastSharedCache
165
- ? (__contrastSharedCache.__elBgSelfOpaqueCache || (__contrastSharedCache.__elBgSelfOpaqueCache = new WeakMap()))
166
- : new WeakMap(); // Element -> { ok, rgba, alpha, reasonCode }
167
-
168
- // Shared deterministic text scan (computed once per run and reused across contrast checks)
169
- let scan = null;
87
+ function pushCantTellOccurrence(el, reasonCode, extraDetails) {
170
88
  try {
171
- scan = helpers && helpers.contrast && typeof helpers.contrast.getTextScan === 'function'
172
- ? helpers.contrast.getTextScan(ctx, helpers, engineOptions)
173
- : null;
89
+ if (!el || seenFailEls.has(el)) return;
90
+ if (occurrences.length >= MAX_OCCURRENCES) return;
91
+ seenFailEls.add(el);
92
+
93
+ const rc = String(reasonCode || 'UNKNOWN');
94
+
95
+ // Match test contract: choose a specific summaryKey per computability blocker.
96
+ // Fall back to the generic notComputable key.
97
+ let summaryKey = 'contrastComputable_cantTell_notComputable';
98
+ if (rc === 'BACKGROUND_IMAGE_OR_GRADIENT') {
99
+ const t =
100
+ extraDetails && typeof extraDetails === 'object'
101
+ ? String(extraDetails.backgroundFillType || '')
102
+ : '';
103
+
104
+ if (t === 'image') summaryKey = 'contrastComputable_cantTell_bgImage';
105
+ else if (t === 'gradient') summaryKey = 'contrastComputable_cantTell_bgGradient';
106
+ else if (t === 'imageAndGradient')
107
+ summaryKey = 'contrastComputable_cantTell_bgImageAndGradient';
108
+ else summaryKey = 'contrastComputable_cantTell_bgImageOrGradient';
109
+ } else if (rc === 'MIX_BLEND_MODE') summaryKey = 'contrastComputable_cantTell_mixBlendMode';
110
+ else if (rc === 'BACKGROUND_FILTER_OR_BACKDROP_FILTER') {
111
+ const bp =
112
+ extraDetails && typeof extraDetails === 'object'
113
+ ? String(extraDetails.blockerProperty || '')
114
+ : '';
115
+ if (bp === 'filter') summaryKey = 'contrastComputable_cantTell_filter';
116
+ else if (bp === 'backdrop-filter')
117
+ summaryKey = 'contrastComputable_cantTell_backdropFilter';
118
+ else summaryKey = 'contrastComputable_cantTell_filterOrBackdropFilter';
119
+ } else if (rc === 'BACKGROUND_NOT_OPAQUE_AT_ROOT')
120
+ summaryKey = 'contrastComputable_cantTell_rootNotOpaque';
121
+
122
+ const details = Object.assign(
123
+ { reasonCode: rc },
124
+ extraDetails && typeof extraDetails === 'object' ? extraDetails : {}
125
+ );
126
+
127
+ const occBase = {
128
+ selector: '',
129
+ html: '',
130
+ summary: '',
131
+ hint: '',
132
+ i18n: {
133
+ summaryKey,
134
+ hintKey: '',
135
+ params: Object.assign(
136
+ { reasonCode: rc },
137
+ details && typeof details === 'object'
138
+ ? {
139
+ blockerProperty: details.blockerProperty ? String(details.blockerProperty) : '',
140
+ blockerValue: details.blockerValue ? String(details.blockerValue) : '',
141
+ backgroundAlpha:
142
+ details.backgroundAlpha !== undefined && details.backgroundAlpha !== null
143
+ ? String(details.backgroundAlpha)
144
+ : ''
145
+ }
146
+ : {}
147
+ )
148
+ },
149
+ data: { details }
150
+ };
151
+
152
+ if (helpers && typeof helpers.reportOccurrence === 'function') {
153
+ occurrences.push(helpers.reportOccurrence(el, occBase));
154
+ } else {
155
+ // Never compute selector/snippet in the rule.
156
+ occurrences.push({ ...occBase });
157
+ }
174
158
  } catch {
175
- scan = null;
159
+ // no-throw
176
160
  }
161
+ }
162
+
163
+ // perf: cache per-element analysis so multiple text nodes in same element don't repeat expensive work
164
+ const __elBlockerCache = __contrastSharedCache
165
+ ? __contrastSharedCache.__elBlockerCache ||
166
+ (__contrastSharedCache.__elBlockerCache = new WeakMap())
167
+ : new WeakMap(); // Element -> { ok:boolean, reasonCode, blockerSelector, blockerProperty, blockerValue }
168
+ const __elBgCache = __contrastSharedCache
169
+ ? __contrastSharedCache.__elBgCache || (__contrastSharedCache.__elBgCache = new WeakMap())
170
+ : new WeakMap(); // Element -> { ok, rgba, alpha, reasonCode } (no stack)
171
+ const __elFgCache = __contrastSharedCache
172
+ ? __contrastSharedCache.__elFgCache || (__contrastSharedCache.__elFgCache = new WeakMap())
173
+ : new WeakMap(); // Element -> { rgba, alpha, opacityProduct }
174
+
175
+ // perf: fast-path memo for self-opaque background (common) to avoid ancestor walk
176
+ const __elBgSelfOpaqueCache = __contrastSharedCache
177
+ ? __contrastSharedCache.__elBgSelfOpaqueCache ||
178
+ (__contrastSharedCache.__elBgSelfOpaqueCache = new WeakMap())
179
+ : new WeakMap(); // Element -> { ok, rgba, alpha, reasonCode }
180
+
181
+ // Shared deterministic text scan (computed once per run and reused across contrast checks)
182
+ let scan;
183
+ try {
184
+ scan =
185
+ helpers && helpers.contrast && typeof helpers.contrast.getTextScan === 'function'
186
+ ? helpers.contrast.getTextScan(ctx, helpers, engineOptions)
187
+ : null;
188
+ } catch {
189
+ scan = null;
190
+ }
191
+
192
+ // Walk eligible visible text nodes (counted per text node), but compute blockers/bg/fg once per element.
193
+ if (scan && scan.elements && Array.isArray(scan.elements)) {
194
+ try {
195
+ eligibleTextCount = Number(scan.eligibleTextCount) || 0;
196
+
197
+ for (const rec of scan.elements) {
198
+ // If we're already capped on occurrences and already know the final outcome is cantTell,
199
+ // stop scanning to avoid wasting time. Deterministic: we don't randomize; we just stop
200
+ // once further work cannot change the output (cantTell + capped occurrences).
201
+ if (cantTellCount > 0 && occurrences.length >= MAX_OCCURRENCES) break;
202
+
203
+ const el = rec && rec.el;
204
+ const textCount = rec && Number(rec.textCount) ? Number(rec.textCount) : 0;
205
+ if (!el || textCount <= 0) continue;
206
+
207
+ // 1) Blockers in ancestor chain (blend/filter/bg-image/gradient)
208
+ let blocker = __elBlockerCache.get(el);
209
+ if (!blocker) {
210
+ blocker = helpers.contrast.getComputabilityBlocker(el);
211
+ // Normalize to stable shape
212
+ blocker = blocker || {
213
+ ok: true,
214
+ reasonCode: null,
215
+ blockerSelector: '',
216
+ blockerProperty: '',
217
+ blockerValue: ''
218
+ };
219
+ __elBlockerCache.set(el, blocker);
220
+ }
221
+ if (blocker && blocker.ok === false) {
222
+ cantTellCount += textCount;
223
+ // occurrence is deduped per element (seenFailEls)
224
+ pushCantTellOccurrence(el, blocker.reasonCode, {
225
+ blockerProperty: blocker.blockerProperty,
226
+ blockerValue: blocker.blockerValue,
227
+ blockerSelector: blocker.blockerSelector,
228
+ backgroundFillType: blocker.backgroundFillType
229
+ });
230
+ continue;
231
+ }
177
232
 
178
- // Walk eligible visible text nodes (counted per text node), but compute blockers/bg/fg once per element.
179
- if (scan && scan.elements && Array.isArray(scan.elements)) {
180
- try {
181
- eligibleTextCount = Number(scan.eligibleTextCount) || 0;
182
-
183
- for (const rec of scan.elements) {
184
- // If we're already capped on occurrences and already know the final outcome is cantTell,
185
- // stop scanning to avoid wasting time. Deterministic: we don't randomize; we just stop
186
- // once further work cannot change the output (cantTell + capped occurrences).
187
- if (cantTellCount > 0 && occurrences.length >= MAX_OCCURRENCES) break;
188
-
189
- const el = rec && rec.el;
190
- const textCount = rec && Number(rec.textCount) ? Number(rec.textCount) : 0;
191
- if (!el || textCount <= 0) continue;
192
-
193
- // 1) Blockers in ancestor chain (blend/filter/bg-image/gradient)
194
- let blocker = __elBlockerCache.get(el);
195
- if (!blocker) {
196
- blocker = helpers.contrast.getComputabilityBlocker(el);
197
- // Normalize to stable shape
198
- blocker = blocker || {
199
- ok: true,
200
- reasonCode: null,
201
- blockerSelector: '',
202
- blockerProperty: '',
203
- blockerValue: ''
233
+ // 2) Background resolution (CSS-only), strict vs. reference-engine-compatible mode
234
+ let bg = __elBgCache.get(el);
235
+ if (!bg) {
236
+ // 2a) Fast-path: if the element itself paints an opaque background-color and opacity is 1,
237
+ // then the background behind its text is computable without walking ancestors.
238
+ // This is a strict subset of the full algorithm, so it is behavior-preserving:
239
+ // if it doesn't match, we fall back to computeEffectiveBackground.
240
+ let bgSelfOpaque = __elBgSelfOpaqueCache.get(el);
241
+ if (bgSelfOpaque === undefined) {
242
+ bgSelfOpaque = null;
243
+ try {
244
+ if (
245
+ helpers &&
246
+ typeof helpers.getComputedStyle === 'function' &&
247
+ helpers.contrast &&
248
+ typeof helpers.contrast.parseCssColorToRgba === 'function'
249
+ ) {
250
+ const cs = helpers.getComputedStyle(el);
251
+ const op = Number.parseFloat(cs && cs.opacity != null ? cs.opacity : '1');
252
+ // Only treat as self-opaque if opacity is exactly 1 (string '1' or number 1)
253
+ // to avoid float/serialization quirks and preserve determinism.
254
+ if (Number.isFinite(op) && op === 1) {
255
+ const c = helpers.contrast.parseCssColorToRgba(cs && cs.backgroundColor);
256
+ if (c && typeof c.a === 'number' && c.a === 1) {
257
+ bgSelfOpaque = {
258
+ ok: true,
259
+ rgba: { r: c.r, g: c.g, b: c.b, a: 1 },
260
+ alpha: 1,
261
+ reasonCode: null
204
262
  };
205
- __elBlockerCache.set(el, blocker);
206
- }
207
- if (blocker && blocker.ok === false) {
208
- cantTellCount += textCount;
209
- // occurrence is deduped per element (seenFailEls)
210
- pushCantTellOccurrence(el, blocker.reasonCode, {
211
- blockerProperty: blocker.blockerProperty,
212
- blockerValue: blocker.blockerValue,
213
- blockerSelector: blocker.blockerSelector,
214
- backgroundFillType: blocker.backgroundFillType
215
- });
216
- continue;
217
- }
218
-
219
- // 2) Background resolution (CSS-only), strict vs. reference-engine-compatible mode
220
- let bg = __elBgCache.get(el);
221
- if (!bg) {
222
- // 2a) Fast-path: if the element itself paints an opaque background-color and opacity is 1,
223
- // then the background behind its text is computable without walking ancestors.
224
- // This is a strict subset of the full algorithm, so it is behavior-preserving:
225
- // if it doesn't match, we fall back to computeEffectiveBackground.
226
- let bgSelfOpaque = __elBgSelfOpaqueCache.get(el);
227
- if (bgSelfOpaque === undefined) {
228
- bgSelfOpaque = null;
229
- try {
230
- if (helpers && typeof helpers.getComputedStyle === 'function' && helpers.contrast && typeof helpers.contrast.parseCssColorToRgba === 'function') {
231
- const cs = helpers.getComputedStyle(el);
232
- const op = Number.parseFloat(cs && cs.opacity != null ? cs.opacity : '1');
233
- // Only treat as self-opaque if opacity is exactly 1 (string '1' or number 1)
234
- // to avoid float/serialization quirks and preserve determinism.
235
- if (Number.isFinite(op) && op === 1) {
236
- const c = helpers.contrast.parseCssColorToRgba(cs && cs.backgroundColor);
237
- if (c && typeof c.a === 'number' && c.a === 1) {
238
- bgSelfOpaque = {
239
- ok: true,
240
- rgba: {r: c.r, g: c.g, b: c.b, a: 1},
241
- alpha: 1,
242
- reasonCode: null
243
- };
244
- }
245
- }
246
- }
247
- } catch {
248
- bgSelfOpaque = null;
249
- }
250
- __elBgSelfOpaqueCache.set(el, bgSelfOpaque);
251
- }
252
-
253
- if (bgSelfOpaque && bgSelfOpaque.ok === true) {
254
- bg = bgSelfOpaque;
255
- } else {
256
- bg = helpers.contrast.computeEffectiveBackground(el, {
257
- contrast: {mode, rootCanvasFallback},
258
- collectStack: false
259
- });
260
- bg = bg || {ok: false, reasonCode: 'BACKGROUND_NOT_COMPUTABLE', rgba: null, alpha: 0};
261
- }
262
-
263
- __elBgCache.set(el, bg);
264
- }
265
- if (!bg || bg.ok === false) {
266
- cantTellCount += textCount;
267
- pushCantTellOccurrence(el, (bg && bg.reasonCode) || 'BACKGROUND_NOT_COMPUTABLE', {
268
- background: bg && bg.rgba ? helpers.contrast.rgbaToString(bg.rgba) : '',
269
- backgroundAlpha: bg && typeof bg.alpha === 'number' ? helpers.contrast.round2(bg.alpha) : ''
270
- });
271
- continue;
272
- }
273
-
274
- // Track any explicit assumptions applied by background resolution.
275
- try {
276
- if (bg && Array.isArray(bg.assumptionsApplied) && bg.assumptionsApplied.length) {
277
- assumptionsCount += textCount;
278
- for (const a of bg.assumptionsApplied) assumptionsAppliedSet.add(String(a));
279
- }
280
- } catch { /* no-throw */
281
- }
282
-
283
- // 3) Foreground parsability (computed color should be rgb/rgba)
284
- let fg = __elFgCache.get(el);
285
- if (!fg) {
286
- fg = helpers.contrast.computeEffectiveForeground(el);
287
- fg = fg || {rgba: null, alpha: 0, opacityProduct: 1};
288
- __elFgCache.set(el, fg);
263
+ }
289
264
  }
290
- if (!fg || !fg.rgba) {
291
- cantTellCount += textCount;
292
- pushCantTellOccurrence(el, 'FOREGROUND_UNPARSABLE', {
293
- background: bg && bg.rgba ? helpers.contrast.rgbaToString(bg.rgba) : '',
294
- backgroundAlpha: bg && typeof bg.alpha === 'number' ? helpers.contrast.round2(bg.alpha) : ''
295
- });
296
- continue;
297
- }
298
-
299
- // If we got here, this element's eligible text is computable.
300
- // (We intentionally do not compute contrast ratio in this gatekeeper rule.)
265
+ }
266
+ } catch {
267
+ bgSelfOpaque = null;
301
268
  }
302
- } catch {
303
- // If scan processing fails unexpectedly, keep determinism: treat as cantTell with one occurrence
304
- return {
305
- ruleId: rule.ruleId,
306
- outcome: 'cantTell',
307
- severity: rule.defaultSeverity || 'serious',
308
- confidence: rule.defaultConfidence || 'high',
309
- occurrences: [
310
- {
311
- selector: '',
312
- summary: '',
313
- hint: '',
314
- html: '',
315
- i18n: {
316
- summaryKey: 'contrastComputable_cantTell_engineFailure',
317
- hintKey: '',
318
- params: {reasonCode: 'ENGINE_EXCEPTION'}
319
- },
320
- data: {details: {reasonCode: 'ENGINE_EXCEPTION'}}
321
- }
322
- ]
323
- };
269
+ __elBgSelfOpaqueCache.set(el, bgSelfOpaque);
270
+ }
271
+
272
+ if (bgSelfOpaque && bgSelfOpaque.ok === true) {
273
+ bg = bgSelfOpaque;
274
+ } else {
275
+ bg = helpers.contrast.computeEffectiveBackground(el, {
276
+ contrast: { mode, rootCanvasFallback },
277
+ collectStack: false
278
+ });
279
+ bg = bg || { ok: false, reasonCode: 'BACKGROUND_NOT_COMPUTABLE', rgba: null, alpha: 0 };
280
+ }
281
+
282
+ __elBgCache.set(el, bg);
283
+ }
284
+ if (!bg || bg.ok === false) {
285
+ cantTellCount += textCount;
286
+ pushCantTellOccurrence(el, (bg && bg.reasonCode) || 'BACKGROUND_NOT_COMPUTABLE', {
287
+ background: bg && bg.rgba ? helpers.contrast.rgbaToString(bg.rgba) : '',
288
+ backgroundAlpha:
289
+ bg && typeof bg.alpha === 'number' ? helpers.contrast.round2(bg.alpha) : ''
290
+ });
291
+ continue;
324
292
  }
325
- }
326
293
 
327
- if (eligibleTextCount === 0) {
328
- return {
329
- ruleId: rule.ruleId,
330
- outcome: 'notApplicable',
331
- severity: rule.defaultSeverity || 'serious',
332
- confidence: rule.defaultConfidence || 'high',
333
- occurrences: []
334
- };
335
- }
294
+ // Track any explicit assumptions applied by background resolution.
295
+ try {
296
+ if (bg && Array.isArray(bg.assumptionsApplied) && bg.assumptionsApplied.length) {
297
+ assumptionsCount += textCount;
298
+ for (const a of bg.assumptionsApplied) assumptionsAppliedSet.add(String(a));
299
+ }
300
+ } catch {
301
+ /* no-throw */
302
+ }
336
303
 
337
- if (cantTellCount > 0) {
338
- return {
339
- ruleId: rule.ruleId,
340
- outcome: 'cantTell',
341
- severity: rule.defaultSeverity || 'serious',
342
- confidence: rule.defaultConfidence || 'high',
343
- occurrences
344
- };
345
- }
304
+ // 3) Foreground parsability (computed color should be rgb/rgba)
305
+ let fg = __elFgCache.get(el);
306
+ if (!fg) {
307
+ fg = helpers.contrast.computeEffectiveForeground(el);
308
+ fg = fg || { rgba: null, alpha: 0, opacityProduct: 1 };
309
+ __elFgCache.set(el, fg);
310
+ }
311
+ if (!fg || !fg.rgba) {
312
+ cantTellCount += textCount;
313
+ pushCantTellOccurrence(el, 'FOREGROUND_UNPARSABLE', {
314
+ background: bg && bg.rgba ? helpers.contrast.rgbaToString(bg.rgba) : '',
315
+ backgroundAlpha:
316
+ bg && typeof bg.alpha === 'number' ? helpers.contrast.round2(bg.alpha) : ''
317
+ });
318
+ continue;
319
+ }
346
320
 
347
- // All eligible text is computable
348
- return {
321
+ // If we got here, this element's eligible text is computable.
322
+ // (We intentionally do not compute contrast ratio in this gatekeeper rule.)
323
+ }
324
+ } catch {
325
+ // If scan processing fails unexpectedly, keep determinism: treat as cantTell with one occurrence
326
+ return {
349
327
  ruleId: rule.ruleId,
350
- outcome: 'pass',
328
+ outcome: 'cantTell',
351
329
  severity: rule.defaultSeverity || 'serious',
352
330
  confidence: rule.defaultConfidence || 'high',
353
331
  occurrences: [
354
- {
355
- selector: '',
356
- summary: '',
357
- hint: '',
358
- html: '',
359
- i18n: {
360
- summaryKey: 'contrastComputable_pass_allComputable',
361
- hintKey: '',
362
- params: {
363
- eligibleTextCount: String(eligibleTextCount)
364
- }
365
- },
366
- data: {
367
- details: {
368
- eligibleTextCount,
369
- assumptionsCount,
370
- assumptionsApplied: Array.from(assumptionsAppliedSet).sort()
371
- }
372
- }
373
- }
332
+ {
333
+ selector: '',
334
+ summary: '',
335
+ hint: '',
336
+ html: '',
337
+ i18n: {
338
+ summaryKey: 'contrastComputable_cantTell_engineFailure',
339
+ hintKey: '',
340
+ params: { reasonCode: 'ENGINE_EXCEPTION' }
341
+ },
342
+ data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
343
+ }
374
344
  ]
345
+ };
346
+ }
347
+ }
348
+
349
+ if (eligibleTextCount === 0) {
350
+ return {
351
+ ruleId: rule.ruleId,
352
+ outcome: 'notApplicable',
353
+ severity: rule.defaultSeverity || 'serious',
354
+ confidence: rule.defaultConfidence || 'high',
355
+ occurrences: []
356
+ };
357
+ }
358
+
359
+ if (cantTellCount > 0) {
360
+ return {
361
+ ruleId: rule.ruleId,
362
+ outcome: 'cantTell',
363
+ severity: rule.defaultSeverity || 'serious',
364
+ confidence: rule.defaultConfidence || 'high',
365
+ occurrences
375
366
  };
367
+ }
368
+
369
+ // All eligible text is computable
370
+ return {
371
+ ruleId: rule.ruleId,
372
+ outcome: 'pass',
373
+ severity: rule.defaultSeverity || 'serious',
374
+ confidence: rule.defaultConfidence || 'high',
375
+ occurrences: [
376
+ {
377
+ selector: '',
378
+ summary: '',
379
+ hint: '',
380
+ html: '',
381
+ i18n: {
382
+ summaryKey: 'contrastComputable_pass_allComputable',
383
+ hintKey: '',
384
+ params: {
385
+ eligibleTextCount: String(eligibleTextCount)
386
+ }
387
+ },
388
+ data: {
389
+ details: {
390
+ eligibleTextCount,
391
+ assumptionsCount,
392
+ assumptionsApplied: Array.from(assumptionsAppliedSet).sort()
393
+ }
394
+ }
395
+ }
396
+ ]
397
+ };
376
398
  }
377
399
 
378
- module.exports = {id, meta, runInPage};
400
+ module.exports = { id, meta, runInPage };