pour-engine 1.1.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/config/project.config.js +11 -0
  4. package/engine/COVERAGE.md +115 -0
  5. package/engine/README.md +115 -0
  6. package/engine/index.js +258 -0
  7. package/engine/lib/accessible-name.js +126 -0
  8. package/engine/lib/contrast.js +628 -0
  9. package/engine/lib/dom.js +116 -0
  10. package/engine/lib/roles.js +125 -0
  11. package/engine/rules/best-practice/accesskeys.js +29 -0
  12. package/engine/rules/best-practice/aria-label-misuse.js +22 -0
  13. package/engine/rules/best-practice/button-type.js +15 -0
  14. package/engine/rules/best-practice/empty-heading.js +19 -0
  15. package/engine/rules/best-practice/fieldset-legend.js +35 -0
  16. package/engine/rules/best-practice/heading-order.js +30 -0
  17. package/engine/rules/best-practice/landmark-one-main.js +22 -0
  18. package/engine/rules/best-practice/landmark-top-level.js +46 -0
  19. package/engine/rules/best-practice/landmark-unique.js +84 -0
  20. package/engine/rules/best-practice/multiple-labels.js +23 -0
  21. package/engine/rules/best-practice/new-window-link.js +21 -0
  22. package/engine/rules/best-practice/no-autofocus.js +16 -0
  23. package/engine/rules/best-practice/page-heading-one.js +17 -0
  24. package/engine/rules/best-practice/positive-tabindex.js +17 -0
  25. package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
  26. package/engine/rules/best-practice/redundant-aria-label.js +18 -0
  27. package/engine/rules/best-practice/redundant-aria.js +32 -0
  28. package/engine/rules/best-practice/redundant-image-alt.js +20 -0
  29. package/engine/rules/best-practice/redundant-role.js +62 -0
  30. package/engine/rules/best-practice/redundant-tabindex.js +15 -0
  31. package/engine/rules/best-practice/region.js +73 -0
  32. package/engine/rules/index.js +179 -0
  33. package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
  34. package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
  35. package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
  36. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  37. package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
  38. package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
  39. package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
  40. package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
  41. package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
  42. package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
  43. package/engine/rules/wcag/1.3.1-form-label.js +81 -0
  44. package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
  45. package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
  46. package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
  47. package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
  48. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
  49. package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
  50. package/engine/rules/wcag/1.4.10-reflow.js +25 -0
  51. package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
  52. package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
  53. package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
  54. package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
  55. package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
  56. package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
  57. package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
  58. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
  59. package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
  60. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
  61. package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
  62. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
  63. package/engine/rules/wcag/2.4.2-document-title.js +18 -0
  64. package/engine/rules/wcag/2.4.4-link-name.js +19 -0
  65. package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
  66. package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
  67. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
  68. package/engine/rules/wcag/2.5.8-target-size.js +159 -0
  69. package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
  70. package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
  71. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
  72. package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
  73. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
  74. package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
  75. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
  76. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
  77. package/engine/rules/wcag/4.1.2-button-name.js +17 -0
  78. package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
  79. package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
  80. package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
  81. package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
  82. package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
  83. package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
  84. package/engine/wcag22.js +116 -0
  85. package/engine/wcag30-draft.js +149 -0
  86. package/package.json +34 -0
@@ -0,0 +1,462 @@
1
+ // WCAG SC 1.4.3 Contrast (Minimum) (Level AA)
2
+ import {
3
+ parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
4
+ backgroundImageSource, backgroundImagePaintRect, imageLuminanceRange, gradientLuminanceRange,
5
+ rangeVerdict, isLargeText, cumulativeOpacity, opacityAnimating, restingOpacity, mediaRects,
6
+ paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
7
+ } from '../../lib/contrast.js';
8
+
9
+ /** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
10
+ * because "4.50:1 — below the 4.5:1 minimum" reads as a contradiction. */
11
+ const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
12
+
13
+ /**
14
+ * Is a duplicate copy of the same text painted exactly on top of this
15
+ * element? The gradient-text pattern ships two copies — a plain-colour
16
+ * fallback underneath and a background-clip:text copy above it — and only
17
+ * the top copy is ever seen; the covered one must not be judged.
18
+ */
19
+ function coveredByTextTwin(element) {
20
+ const doc = element.ownerDocument;
21
+ if (typeof doc.elementsFromPoint !== 'function') return false;
22
+ const text = element.textContent.trim();
23
+ if (!text) return false;
24
+ const rect = element.getBoundingClientRect();
25
+ const win = doc.defaultView;
26
+ const x = rect.left + rect.width / 2;
27
+ const y = rect.top + rect.height / 2;
28
+ if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) return false;
29
+ const stack = doc.elementsFromPoint(x, y);
30
+ const index = stack.indexOf(element);
31
+ if (index <= 0) return false;
32
+ return stack.slice(0, index).some((layer) =>
33
+ !layer.contains(element) && !element.contains(layer) && layer.textContent.trim() === text);
34
+ }
35
+
36
+ /** Do any of the element's own text boxes MEANINGFULLY overlap the given
37
+ * rect? Slivers under 3px per axis don't count: wrapped text reports
38
+ * hanging trailing spaces as part of the line box, which "overlaps"
39
+ * right-edge icons without a single glyph being anywhere near them. */
40
+ function textIntersects(element, rect) {
41
+ // Bounding-box prefilter: this now also runs for PASSING text on the
42
+ // unverified-backdrop path, i.e. for most elements on long pages — the
43
+ // Range machinery below must only spin up for genuine near-overlaps.
44
+ const box = element.getBoundingClientRect();
45
+ if (Math.min(box.right, rect.right) - Math.max(box.left, rect.left) < 3
46
+ || Math.min(box.bottom, rect.bottom) - Math.max(box.top, rect.top) < 3) return false;
47
+ const range = element.ownerDocument.createRange();
48
+ for (const node of element.childNodes) {
49
+ if (node.nodeType !== Node.TEXT_NODE || !node.textContent.trim()) continue;
50
+ range.selectNodeContents(node);
51
+ for (const r of range.getClientRects()) {
52
+ const overlapX = Math.min(r.right, rect.right) - Math.max(r.left, rect.left);
53
+ const overlapY = Math.min(r.bottom, rect.bottom) - Math.max(r.top, rect.top);
54
+ if (overlapX >= 3 && overlapY >= 3) return true;
55
+ }
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * Where does this background image stand relative to the element's text?
62
+ * 'clear' — provably NOT under any text box (single no-repeat image
63
+ * whose paint rect misses them, e.g. an icon in padding)
64
+ * 'under' — provably overlapping a text box
65
+ * 'unknown' — geometry not statically computable (repeats, cover,
66
+ * stacked layers) — judged conservatively by the caller
67
+ * `intrinsic` rides along so the caller can reason about icon-sized images.
68
+ */
69
+ async function imageVsText(imageSource, element, doc) {
70
+ const url = imageSource.css.match(/url\(["']?(.*?)["']?\)/)?.[1];
71
+ let intrinsic = null;
72
+ if (url) {
73
+ try { intrinsic = await imageLuminanceRange(new URL(url, doc.baseURI).href); } catch { /* unknowable */ }
74
+ }
75
+ // Browsers report 300×150 for SVGs with no intrinsic dimensions — a
76
+ // sentinel, not a size. Geometry computed from it is fiction, so the
77
+ // paint rect must not use it (icon SVGs would "cover" all the text).
78
+ const dimensionless = !intrinsic || !intrinsic.width
79
+ || (intrinsic.width === 300 && intrinsic.height === 150);
80
+ const paint = backgroundImagePaintRect(imageSource.element, dimensionless ? null : intrinsic);
81
+ if (!paint) return { relation: 'unknown', intrinsic, dimensionless };
82
+ return { relation: textIntersects(element, paint) ? 'under' : 'clear', intrinsic, dimensionless };
83
+ }
84
+
85
+ /**
86
+ * Text that screen readers announce but sighted users never see — 1.4.3 only
87
+ * applies to visually presented text, so these patterns are exempt:
88
+ * - image-replacement: text-indent flings the line box off-canvas
89
+ * (e.g. GOV.UK search buttons use text-indent: -5000px over an icon)
90
+ * - sr-only: a ≤1px box with the text clipped away
91
+ * - font-size: 0, fully transparent text, opacity: 0
92
+ */
93
+ function textVisuallyHidden(element, style, foreground) {
94
+ if (foreground?.a === 0) return true;
95
+ if (parseFloat(style.fontSize) === 0) return true;
96
+ if (parseFloat(style.opacity) === 0) return true;
97
+ const indent = parseFloat(style.textIndent) || 0;
98
+ const clipped = /hidden|clip/.test(`${style.overflow} ${style.overflowX} ${style.overflowY}`)
99
+ || style.clipPath !== 'none' || (style.clip !== 'auto' && style.position === 'absolute');
100
+ // Nothing hints at hiding: skip the getBoundingClientRect layout read —
101
+ // this runs for every text element on the page, and most are plainly shown.
102
+ if (!clipped && Math.abs(indent) <= 1000) return false;
103
+ const rect = element.getBoundingClientRect();
104
+ if ((rect.width <= 1 || rect.height <= 1) && clipped) return true;
105
+ if (Math.abs(indent) > rect.width && (clipped || Math.abs(indent) > 1000)) return true;
106
+ return false;
107
+ }
108
+
109
+ // Turn a luminance-range bracket into a rule outcome (or null = can't tell).
110
+ function verdictFromRange(range, foreground, required, what) {
111
+ if (!range) return null;
112
+ const { verdict, worst, best } = rangeVerdict(foreground, range, required);
113
+ if (verdict === 'pass') return { status: 'pass' };
114
+ if (verdict === 'fail') {
115
+ return {
116
+ status: 'fail',
117
+ message: `Contrast against the ${what} behind this text is at best ${showRatio(best)}:1 — below the ${required}:1 minimum against every part of it.`,
118
+ fix: 'Change the text colour, or place a solid overlay between the text and the image.',
119
+ };
120
+ }
121
+ return {
122
+ status: 'incomplete',
123
+ message: `The ${what} behind this text has both light and dark areas (contrast ranges ${showRatio(worst)}–${showRatio(best)}:1 for this text) — whether it passes depends on the exact overlap, check by eye.`,
124
+ };
125
+ }
126
+
127
+ // Bracket the verdict by sampling what's actually painted: image pixels
128
+ // (same-origin/CORS only) or gradient colour stops.
129
+ async function sampledVerdict(source, foreground, required, doc) {
130
+ if (!source) return null;
131
+ // Translucent text takes its presented colour from the very pixels
132
+ // beneath it — a luminance range of the backdrop cannot bracket the
133
+ // blend (dimmed white over a dark photo reads mid-grey, not white).
134
+ // Asserting either way from sampling would be a coin toss.
135
+ if (foreground.a < 1) {
136
+ return {
137
+ status: 'incomplete',
138
+ message: 'This text is translucent over an image or gradient, so its presented colour depends on the pixels beneath — contrast must be checked by eye.',
139
+ };
140
+ }
141
+ if (source.tagName === 'IMG') {
142
+ return verdictFromRange(await imageLuminanceRange(source.currentSrc || source.src), foreground, required, 'image');
143
+ }
144
+ const css = source.css ?? getComputedStyle(source).backgroundImage;
145
+ if (!css || css === 'none') return null;
146
+ if ((css.match(/url\(/g) ?? []).length + (css.match(/gradient\(/g) ?? []).length > 1) return null; // stacked layers: too complex
147
+ const url = css.match(/url\(["']?(.*?)["']?\)/)?.[1];
148
+ if (url) {
149
+ let absolute;
150
+ try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
151
+ return verdictFromRange(await imageLuminanceRange(absolute), foreground, required, 'image');
152
+ }
153
+ if (css.includes('gradient(')) {
154
+ return verdictFromRange(gradientLuminanceRange(css), foreground, required, 'gradient');
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Both contrast criteria run the same machinery with different thresholds:
161
+ * 1.4.3 Contrast (Minimum), AA — 4.5:1, or 3:1 for large text
162
+ * 1.4.6 Contrast (Enhanced), AAA — 7:1, or 4.5:1 for large text
163
+ */
164
+ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
165
+ return {
166
+ id,
167
+ impact: 'serious',
168
+ tags,
169
+ help,
170
+ helpUrl,
171
+ // div included deliberately: real-world layouts put lots of text in bare
172
+ // divs; the own-text check below keeps wrapper divs out of the results.
173
+ selector:
174
+ 'p, span, a, li, td, th, dt, dd, h1, h2, h3, h4, h5, h6, label, button, legend, caption, ' +
175
+ 'figcaption, div, blockquote, small, strong, em, b, i, code, pre, time, cite, q, summary',
176
+ visibility: 'visual', // contrast is seen by sighted users even in aria-hidden content
177
+ async evaluate(element, { ownText }) {
178
+ if (!ownText(element)) return { status: 'pass' }; // no text of its own to judge
179
+
180
+ // The criterion exempts "inactive user interface components".
181
+ if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
182
+
183
+ const style = getComputedStyle(element);
184
+ // Controls conveying "disabled" without the attribute: pointer-events:
185
+ // none on an interactive element is functional inertness — the low
186
+ // contrast IS the disabled styling (carousel arrows at the end of
187
+ // their range, steppers at their limit). But only for COMPACT widgets:
188
+ // a card-sized inert link full of prose (an inactive carousel slide) is
189
+ // content someone is expected to read, and whether "temporarily
190
+ // non-interactive" makes it an exempt inactive component under 1.4.3 is
191
+ // a human judgement — never a silent pass.
192
+ const control = element.closest('button, a[href], input, select, [role="button"], [role="link"], [role="option"], [role="tab"]');
193
+ if (control && getComputedStyle(control).pointerEvents === 'none') {
194
+ if ((control.textContent || '').trim().length <= 80) return { status: 'pass' };
195
+ return {
196
+ status: 'incomplete',
197
+ message: 'This text sits inside a non-interactive (pointer-events: none) control with substantial content — a disabled control\'s dimmed text is exempt from contrast, but an inactive content slide is not. Judge which this is by eye.',
198
+ };
199
+ }
200
+
201
+ // The colour that actually paints the glyphs. -webkit-text-fill-color
202
+ // overrides color when set; background-clip: text paints the text with
203
+ // the background layers instead — judged by their colours, not color.
204
+ const fill = parseColor(style.webkitTextFillColor);
205
+ const clipsText = /\btext\b/.test(style.webkitBackgroundClip ?? '') || /\btext\b/.test(style.backgroundClip ?? '');
206
+ if (clipsText) {
207
+ const behind = effectiveBackground(element.parentElement ?? element);
208
+ const fgRange = style.backgroundImage !== 'none' ? gradientLuminanceRange(style.backgroundImage) : null;
209
+ if (fgRange && behind) {
210
+ // Symmetric bracket: text luminance spans the gradient's stops.
211
+ const required = isLargeText(style) ? thresholds.large : thresholds.normal;
212
+ return verdictFromRange(fgRange, behind, required, 'gradient painting this text') ?? {
213
+ status: 'incomplete',
214
+ message: 'This text is painted with its background layer (background-clip: text) — contrast must be checked by eye.',
215
+ };
216
+ }
217
+ return {
218
+ status: 'incomplete',
219
+ message: 'This text is painted with its background layer (background-clip: text) — contrast must be checked by eye.',
220
+ };
221
+ }
222
+ let foreground = fill && style.webkitTextFillColor !== style.color ? fill : parseColor(style.color);
223
+ // Visually hidden text (sr-only, image replacement) has no visual
224
+ // presentation for contrast to apply to — screen readers still read it.
225
+ if (textVisuallyHidden(element, style, foreground)) return { status: 'pass' };
226
+ if (!foreground) {
227
+ return { status: 'incomplete', message: 'The text colour could not be parsed — check contrast by eye.' };
228
+ }
229
+ const required = isLargeText(style) ? thresholds.large : thresholds.normal;
230
+ const doc = element.ownerDocument;
231
+
232
+ // Judge the opacity the text RESTS at, not a transient frame. Elements
233
+ // with a RUNNING opacity animation anywhere up the flat tree are judged
234
+ // at the animation's landing value (a fill-forwards entrance ending at
235
+ // full is judged at full; one parking at 30% is judged dim — inactive
236
+ // carousel slides). Unknowable landings (infinite loops, no-fill) are
237
+ // judged at full — never failing text on a frame that won't persist.
238
+ // Near-zero resting opacity means the text isn't visually presented.
239
+ const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
240
+ if (opacity < 0.05) return { status: 'pass' };
241
+ if (opacity < 1) foreground = { ...foreground, a: foreground.a * opacity };
242
+
243
+ // A background image on an ancestor: sample its pixels and bracket —
244
+ // but only when it's actually painted under this text. Icon-in-padding
245
+ // images (external-link arrows etc.) don't affect the text's contrast.
246
+ const imageSource = backgroundImageSource(element);
247
+ if (imageSource) {
248
+ const { relation, intrinsic, dimensionless } = await imageVsText(imageSource, element, doc);
249
+ if (relation !== 'clear') {
250
+ const sampled = await sampledVerdict(imageSource, foreground, required, doc);
251
+ // Icon-scale images: bullets, arrows, and decorations painted
252
+ // beside or beneath a corner of the text — their pixels may flag
253
+ // for eyes, never assert a failure. Icon-scale means: no reliable
254
+ // intrinsic size, an intrinsically tiny image, OR a background
255
+ // carrier whose own box is line-height-sized (contain/cover paint
256
+ // can never exceed the carrier — a 512px nominal SVG painted on an
257
+ // inline link is still an icon). Only backdrop-scale images earn a
258
+ // definite fail from sampling.
259
+ const carrierBox = imageSource.element.getBoundingClientRect();
260
+ if (sampled?.status === 'fail'
261
+ && (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40)) {
262
+ return {
263
+ status: 'incomplete',
264
+ message: 'A small background icon is painted on this element — if it sits beside the text rather than under it, judge the contrast against the plain background by eye.',
265
+ };
266
+ }
267
+ return sampled ?? {
268
+ status: 'incomplete',
269
+ message: 'The background here is an image that can’t be read (cross-origin) — contrast must be checked by eye.',
270
+ };
271
+ }
272
+ }
273
+
274
+ let background = effectiveBackground(element);
275
+
276
+ // Browser-truth paint order at the text's own sample point: sees and
277
+ // RESOLVES backdrops painted by non-ancestors (positioned siblings,
278
+ // hero panels, overlay scrims) that the ancestor walk cannot — instead
279
+ // of merely flagging them for a human.
280
+ const painted = paintedBackdrop(element);
281
+ // A translucent viewport-scale veil is painted ON TOP of this text
282
+ // (modal scrim, loading overlay, consent dimmer): the user sees the
283
+ // text through it, so the resting colours are not the presented ones —
284
+ // and if the veil is transient, the resting state is what should be
285
+ // judged. Either way this is a human call, in both verdict directions.
286
+ if (painted?.scrim) {
287
+ return {
288
+ status: 'incomplete',
289
+ message: 'This text sits behind a translucent full-page overlay (a modal or loading veil), which changes its presented contrast — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
290
+ };
291
+ }
292
+ // Offscreen text can't be hit-tested against the veil — but a FIXED
293
+ // translucent overlay follows the viewport, so it dims scrolled-away
294
+ // content just the same. (The veil's own contents are fixed too, hence
295
+ // in-viewport, hence never routed through this branch.)
296
+ if (painted === 'offscreen') {
297
+ const veil = viewportVeil(doc);
298
+ if (veil && !veil.contains(element)) {
299
+ return {
300
+ status: 'incomplete',
301
+ message: 'This text sits behind a translucent full-page overlay (a modal or loading veil), which changes its presented contrast — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
302
+ };
303
+ }
304
+ }
305
+ if (painted?.image) {
306
+ const sampled = await sampledVerdict(painted.image, foreground, required, doc);
307
+ return sampled ?? {
308
+ status: 'incomplete',
309
+ message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
310
+ };
311
+ }
312
+ // Only a hit-test-resolved backdrop is VERIFIED paint truth. Anything
313
+ // else (offscreen sample point, element invisible to its own hit-test —
314
+ // stretched-link overlays do this) leaves the walked-up colour a guess
315
+ // that overlapping media or panels may contradict, in either direction.
316
+ let backgroundVerified = false;
317
+ if (painted?.color) {
318
+ background = painted.color;
319
+ backgroundVerified = true;
320
+ } else if (painted === 'unresolved') {
321
+ // The hit-test couldn't see the element (shadow retargeting, hidden
322
+ // mid-audit…): keep the previous overlay detection so the known
323
+ // hazards still reach a human instead of a wrong assertion.
324
+ const obscuring = backgroundObscured(element);
325
+ if (obscuring && obscuring !== 'unverifiable') {
326
+ const sampled = await sampledVerdict(obscuring, foreground, required, doc);
327
+ return sampled ?? {
328
+ status: 'incomplete',
329
+ message: 'An image or overlapping element is painted behind this text, so its real background isn’t the computed colour — contrast must be checked by eye.',
330
+ };
331
+ }
332
+ }
333
+ if (!background) {
334
+ return { status: 'incomplete', message: 'The background could not be determined — check contrast by eye.' };
335
+ }
336
+
337
+ // Semi-transparent text is really text-colour blended into the background.
338
+ if (foreground.a < 1) foreground = composite(foreground, background);
339
+
340
+ // The scroll-independent hazard bracket for UNVERIFIED backgrounds:
341
+ // could something painted where this text is make the walked-up colour
342
+ // the wrong one? Media (photos, video) makes the real backdrop
343
+ // unknowable in both directions — a dark card colour under a pale photo
344
+ // must not assert a pass any more than a fail. Opaque non-ancestor
345
+ // panels only demote the verdict when one would actually flip it.
346
+ // `blindOnly`: a hit-test-VERIFIED backdrop is still wrong when
347
+ // pointer-events:none media or panels paint where the text is — those
348
+ // layers never appear in elementsFromPoint stacks (Stripe's animated
349
+ // gradient canvas under its hero heading is exactly this shape), so
350
+ // even verified verdicts must be bracketed against them.
351
+ const backdropHazard = (direction, blindOnly) => {
352
+ // One box read for the element, then pure rect math per candidate —
353
+ // this runs for every unverified element on the page.
354
+ const box = element.getBoundingClientRect();
355
+ const near = (r) => Math.min(box.right, r.right) - Math.max(box.left, r.left) >= 3
356
+ && Math.min(box.bottom, r.bottom) - Math.max(box.top, r.top) >= 3;
357
+ const overMedia = mediaRects(doc).some(({ element: media, rect: mediaRect, hitTestBlind }) =>
358
+ (!blindOnly || hitTestBlind) && near(mediaRect)
359
+ && !media.contains(element) && !element.contains(media)
360
+ && textIntersects(element, mediaRect));
361
+ if (overMedia) {
362
+ return {
363
+ status: 'incomplete',
364
+ message: 'This text overlaps an image or video, so its real background can’t be computed — contrast must be checked by eye.',
365
+ };
366
+ }
367
+ const flipping = opaquePanelRects(doc).some(({ element: panel, rect, color, hitTestBlind }) =>
368
+ (!blindOnly || hitTestBlind) && near(rect)
369
+ && !panel.contains(element) && !element.contains(panel)
370
+ && textIntersects(element, rect)
371
+ && (contrastRatio(foreground, color) >= required) !== (direction === 'pass'));
372
+ if (flipping) {
373
+ return {
374
+ status: 'incomplete',
375
+ message: 'This text overlaps a coloured block that isn’t its DOM ancestor, so its real background is ambiguous — check contrast by eye.',
376
+ };
377
+ }
378
+ return null;
379
+ };
380
+
381
+ const ratio = contrastRatio(foreground, background);
382
+ if (ratio >= required) {
383
+ return backdropHazard('pass', backgroundVerified) ?? { status: 'pass' };
384
+ }
385
+
386
+ // About to assert a fail — make sure the resolved colour is what the
387
+ // user actually sees. Cheap layout reads run only on this path.
388
+ const rect = element.getBoundingClientRect();
389
+ // Zero-area text paints nothing (collapsed badges, empty counters).
390
+ if (!rect.width || !rect.height) return { status: 'pass' };
391
+ // A text-shadow can supply the contrast (technique G18 measures the
392
+ // letters against their halo): white text with a solid dark outline
393
+ // passes however the fill compares to the backdrop. A parseable solid
394
+ // halo gets a verdict; only genuinely ambiguous shadows go to a human,
395
+ // and faint diffuse glows stop blocking the verdict entirely.
396
+ if (style.textShadow && style.textShadow !== 'none') {
397
+ const fontSize = parseFloat(style.fontSize) || 16;
398
+ const halo = textShadowHalo(style.textShadow, fontSize);
399
+ if (halo) {
400
+ const haloRatio = contrastRatio(foreground, halo);
401
+ if (haloRatio >= required) return { status: 'pass' };
402
+ return {
403
+ status: 'fail',
404
+ message: `Contrast is ${showRatio(haloRatio)}:1 against this text's own outline and ${showRatio(ratio)}:1 against the background — below the ${required}:1 WCAG minimum for this text size.`,
405
+ fix: `Increase the difference between the text colour and its text-shadow outline (or the background) until the ratio reaches ${required}:1.`,
406
+ data: {
407
+ foreground: style.color,
408
+ background: `rgb(${Math.round(halo.r)}, ${Math.round(halo.g)}, ${Math.round(halo.b)})`,
409
+ ratio: Number(showRatio(haloRatio)),
410
+ required,
411
+ },
412
+ };
413
+ }
414
+ if (!textShadowNegligible(style.textShadow, fontSize)) {
415
+ return {
416
+ status: 'incomplete',
417
+ message: `Contrast against the background is ${showRatio(ratio)}:1, but this text has a text-shadow — if the shadow forms a solid halo, contrast may be measured against it instead. Check by eye.`,
418
+ };
419
+ }
420
+ // Negligible glow: judged like plain text on its background.
421
+ }
422
+ // Text in EXACTLY its background colour is an author hiding technique
423
+ // (icon links with colour-matched labels) at least as often as it is a
424
+ // defect — either way nothing is "low contrast": it's invisible.
425
+ if (Math.round(foreground.r) === Math.round(background.r)
426
+ && Math.round(foreground.g) === Math.round(background.g)
427
+ && Math.round(foreground.b) === Math.round(background.b)) {
428
+ return {
429
+ status: 'incomplete',
430
+ message: 'This text is exactly the same colour as its background — invisible. If that\'s a hiding technique for screen-reader-only labels, use the clip pattern instead; if the text is meant to be seen, this is a serious defect.',
431
+ };
432
+ }
433
+ // A duplicate of the same text painted on top (gradient-text fallback
434
+ // copies): the user reads the top copy, which is judged on its own.
435
+ if (coveredByTextTwin(element)) return { status: 'pass' };
436
+ {
437
+ const hazard = backdropHazard('fail', backgroundVerified);
438
+ if (hazard) return hazard;
439
+ }
440
+ return {
441
+ status: 'fail',
442
+ message: `Contrast is ${showRatio(ratio)}:1 — below the ${required}:1 WCAG minimum for this text size.`,
443
+ fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${style.color} on this background).`,
444
+ // The exact pair, for the UIs to link out to an interactive checker.
445
+ data: {
446
+ foreground: style.color,
447
+ background: `rgb(${Math.round(background.r)}, ${Math.round(background.g)}, ${Math.round(background.b)})`,
448
+ ratio: Number(showRatio(ratio)),
449
+ required,
450
+ },
451
+ };
452
+ },
453
+ };
454
+ }
455
+
456
+ export default createContrastRule({
457
+ id: 'color-contrast',
458
+ tags: ['wcag2aa', 'wcag143'],
459
+ help: 'Text must have sufficient contrast against its background',
460
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html',
461
+ thresholds: { normal: 4.5, large: 3 },
462
+ });
@@ -0,0 +1,72 @@
1
+ // WCAG SC 1.4.3 Contrast (Minimum) (Level AA) — form-control text.
2
+ // The main contrast rule judges flowed text; the text INSIDE controls
3
+ // (typed values, select options, and above all placeholder text — the
4
+ // endemic ~2.8:1 gray-on-white) was previously never measured by any rule.
5
+ import {
6
+ parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
7
+ } from '../../lib/contrast.js';
8
+
9
+ const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
10
+
11
+ export default {
12
+ id: 'control-contrast',
13
+ impact: 'serious',
14
+ tags: ['wcag2aa', 'wcag143'],
15
+ help: 'Text inside form controls must have sufficient contrast',
16
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html',
17
+ selector:
18
+ 'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="range"])'
19
+ + ':not([type="color"]):not([type="file"]):not([type="image"]), select, textarea',
20
+ visibility: 'visual',
21
+ evaluate(element) {
22
+ // 1.4.3 exempts inactive controls.
23
+ if (element.disabled || element.closest('[aria-disabled="true"]')) return { status: 'pass' };
24
+ // Zero-area controls (JS-widget hidden duplicates) present no text.
25
+ const rect = element.getBoundingClientRect();
26
+ if (rect.width <= 1 || rect.height <= 1) return { status: 'pass' };
27
+ const style = getComputedStyle(element);
28
+ // The control's own background wins; else whatever shows through it.
29
+ const own = parseColor(style.backgroundColor);
30
+ const background = own && own.a >= 1
31
+ ? own
32
+ : (() => {
33
+ const behind = effectiveBackground(element);
34
+ return behind && own && own.a > 0 ? composite(own, behind) : behind;
35
+ })();
36
+ if (!background) {
37
+ return { status: 'incomplete', message: 'The control’s background could not be determined — check its text contrast by eye.' };
38
+ }
39
+ const required = isLargeText(style) ? 3 : 4.5;
40
+
41
+ const judge = (color, what) => {
42
+ const parsed = parseColor(color);
43
+ if (!parsed || parsed.a === 0) return null;
44
+ const fg = parsed.a < 1 ? composite(parsed, background) : parsed;
45
+ const ratio = contrastRatio(fg, background);
46
+ if (ratio >= required) return null;
47
+ return { what, ratio };
48
+ };
49
+
50
+ const failures = [];
51
+ const valueVerdict = judge(style.color, 'value text');
52
+ if (valueVerdict) failures.push(valueVerdict);
53
+ // Placeholder text is real text users must read; browsers expose its
54
+ // computed colour via the ::placeholder pseudo-element.
55
+ if (element.getAttribute('placeholder')?.trim()) {
56
+ let placeholderColor = null;
57
+ try { placeholderColor = getComputedStyle(element, '::placeholder').color; } catch { /* unsupported */ }
58
+ if (placeholderColor && placeholderColor !== style.color) {
59
+ const verdict = judge(placeholderColor, 'placeholder text');
60
+ if (verdict) failures.push(verdict);
61
+ }
62
+ }
63
+ if (!failures.length) return { status: 'pass' };
64
+ const worst = failures.sort((a, b) => a.ratio - b.ratio)[0];
65
+ return {
66
+ status: 'fail',
67
+ message: `This field's ${worst.what} has ${showRatio(worst.ratio)}:1 contrast against the field background — below the ${required}:1 minimum.`,
68
+ fix: `Darken the ${worst.what.includes('placeholder') ? 'placeholder colour (::placeholder)' : 'text colour'} until it reaches ${required}:1 against the field background.`,
69
+ data: { ratio: Number(showRatio(worst.ratio)), required },
70
+ };
71
+ },
72
+ };
@@ -0,0 +1,22 @@
1
+ // WCAG SC 1.4.4 Resize Text (Level AA)
2
+ export default {
3
+ id: 'meta-viewport',
4
+ impact: 'critical',
5
+ tags: ['wcag2aa', 'wcag144'],
6
+ help: 'The viewport must let users zoom to at least 200%',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html',
8
+ selector: 'meta[name="viewport"]',
9
+ visibleOnly: false,
10
+ evaluate(element) {
11
+ const content = element.getAttribute('content') ?? '';
12
+ const disablesZoom = /user-scalable\s*=\s*(no|0)/i.test(content);
13
+ const maxScale = content.match(/maximum-scale\s*=\s*([\d.]+)/i);
14
+ const cappedZoom = maxScale && parseFloat(maxScale[1]) < 2;
15
+ if (!disablesZoom && !cappedZoom) return { status: 'pass' };
16
+ return {
17
+ status: 'fail',
18
+ message: 'This viewport meta tag stops low-vision users from zooming the page.',
19
+ fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag.',
20
+ };
21
+ },
22
+ };
@@ -0,0 +1,11 @@
1
+ // WCAG SC 1.4.6 Contrast (Enhanced) (Level AAA)
2
+ // Same machinery as 1.4.3, at the AAA thresholds: 7:1, or 4.5:1 for large text.
3
+ import { createContrastRule } from './1.4.3-color-contrast.js';
4
+
5
+ export default createContrastRule({
6
+ id: 'color-contrast-enhanced',
7
+ tags: ['wcag2aaa', 'wcag146'],
8
+ help: 'Text must have enhanced contrast against its background (AAA)',
9
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/contrast-enhanced.html',
10
+ thresholds: { normal: 7, large: 4.5 },
11
+ });
@@ -0,0 +1,48 @@
1
+ // WCAG SC 2.1.1 Keyboard (Level A) · SC 2.1.3 Keyboard (No Exception) (Level AAA)
2
+ const FOCUSABLE = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]';
3
+
4
+ /** Focusable content in the FLAT tree: the node's own descendants, nodes
5
+ * slotted in from the light DOM, and content of open shadow roots. */
6
+ function hasFocusableContent(node) {
7
+ if (node.querySelector?.(FOCUSABLE)) return true;
8
+ for (const slot of node.querySelectorAll?.('slot') ?? []) {
9
+ for (const assigned of slot.assignedElements?.() ?? []) {
10
+ if (assigned.matches?.(FOCUSABLE) || assigned.querySelector?.(FOCUSABLE)) return true;
11
+ }
12
+ }
13
+ for (const el of node.querySelectorAll?.('*') ?? []) {
14
+ if (el.shadowRoot && hasFocusableContent(el.shadowRoot)) return true;
15
+ }
16
+ return false;
17
+ }
18
+
19
+ export default {
20
+ id: 'scrollable-region-focusable',
21
+ impact: 'serious',
22
+ tags: ['wcag2a', 'wcag211', 'wcag213'], // 2.1.3 is 2.1.1's AAA twin — same check satisfies both
23
+ help: 'Scrollable regions must be reachable by keyboard',
24
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html',
25
+ selector: 'div, section, article, main, aside, ul, ol, dl, pre, output, figure',
26
+ evaluate(element) {
27
+ const style = getComputedStyle(element);
28
+ // Judged PER AXIS: an overflow the user can't scroll on its own axis
29
+ // (overflow-x: hidden marquees whose overflow-y merely computes to
30
+ // auto) is not a scrollable region — content moves by animation, not
31
+ // by user scrolling.
32
+ const scrollableX = /(auto|scroll)/.test(style.overflowX)
33
+ && element.scrollWidth > element.clientWidth + 1;
34
+ const scrollableY = /(auto|scroll)/.test(style.overflowY)
35
+ && element.scrollHeight > element.clientHeight + 1;
36
+ if (!scrollableX && !scrollableY) return { status: 'pass' };
37
+ if (element.tabIndex >= 0) return { status: 'pass' };
38
+ // Focusable content inside lets keyboard users scroll by moving focus —
39
+ // including content slotted in from the light DOM and content inside
40
+ // open shadow roots (carousels built as web components).
41
+ if (hasFocusableContent(element)) return { status: 'pass' };
42
+ return {
43
+ status: 'fail',
44
+ message: 'This region scrolls, but contains nothing focusable and isn’t focusable itself — keyboard users can never see the overflowed content.',
45
+ fix: 'Add tabindex="0" to the scrollable element (plus role="region" and an aria-label describing it).',
46
+ };
47
+ },
48
+ };
@@ -0,0 +1,20 @@
1
+ // WCAG SC 2.2.1 Timing Adjustable (Level A)
2
+ export default {
3
+ id: 'meta-refresh',
4
+ impact: 'critical',
5
+ tags: ['wcag2a', 'wcag221'],
6
+ help: 'The page must not use a timed refresh',
7
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html',
8
+ selector: 'meta[http-equiv="refresh" i]',
9
+ visibleOnly: false,
10
+ evaluate(element) {
11
+ const delay = parseInt(element.getAttribute('content') ?? '', 10);
12
+ if (!(delay > 0)) return { status: 'pass' }; // 0 = immediate redirect, allowed
13
+ if (delay >= 72000) return { status: 'pass' }; // 2.2.1 exempts limits over 20 hours
14
+ return {
15
+ status: 'fail',
16
+ message: `The page refreshes/redirects after ${delay}s — slow readers lose their place (or the whole page) with no control.`,
17
+ fix: 'Remove the timed refresh; let users act in their own time.',
18
+ };
19
+ },
20
+ };