pour-engine 1.32.0 → 1.34.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.
- package/config/project.config.js +1 -1
- package/engine/COVERAGE.md +5 -5
- package/engine/index.js +11 -4
- package/engine/lib/accessible-name.js +15 -4
- package/engine/lib/contrast.js +316 -11
- package/engine/lib/roles.js +30 -6
- package/engine/rules/best-practice/button-type.js +23 -2
- package/engine/rules/best-practice/fieldset-legend.js +7 -2
- package/engine/rules/best-practice/heading-order.js +2 -1
- package/engine/rules/best-practice/landmark-top-level.js +10 -0
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/new-window-link.js +34 -1
- package/engine/rules/best-practice/no-autofocus.js +15 -3
- package/engine/rules/best-practice/page-heading-one.js +15 -1
- package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
- package/engine/rules/best-practice/redundant-aria-label.js +24 -0
- package/engine/rules/best-practice/redundant-role.js +71 -20
- package/engine/rules/best-practice/region.js +6 -2
- package/engine/rules/index.js +13 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
- package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
- package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
- package/engine/rules/wcag/1.3.1-form-label.js +39 -1
- package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
- package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
- package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
- package/engine/rules/wcag/1.3.4-orientation.js +21 -7
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +254 -13
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
- package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
- package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
- package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
- package/engine/rules/wcag/2.4.2-document-title.js +8 -2
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
- package/engine/rules/wcag/2.4.4-link-name.js +11 -0
- package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
- package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
- package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
- package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
- package/engine/rules/wcag/2.5.8-target-size.js +141 -7
- package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
- package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
- package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +1 -1
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
- package/engine/rules/wcag/4.1.2-aria-label-misuse.js +1 -1
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +44 -73
- package/engine/rules/wcag/4.1.2-button-name.js +11 -0
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
- package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
- package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
- package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
- package/engine/wcag22.js +4 -4
- package/package.json +1 -1
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
import {
|
|
3
3
|
parseColor, contrastRatio, composite, effectiveBackground, backgroundObscured,
|
|
4
4
|
backgroundImageSource, backgroundImagePaintRect, imagePaintRectInBox, imageLuminanceRange,
|
|
5
|
-
|
|
5
|
+
sampledGradientRange, gradientStops, rangeVerdict, rangeWithBackdrop, isLargeText, cumulativeOpacity,
|
|
6
6
|
opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
|
|
7
7
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
8
8
|
pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
|
|
9
|
-
showRatio,
|
|
9
|
+
showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors,
|
|
10
10
|
} from '../../lib/contrast.js';
|
|
11
11
|
|
|
12
|
+
/** The first url() among a background-image list's layers, or null. */
|
|
13
|
+
const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
|
|
14
|
+
|
|
15
|
+
/** The painted size of a rect, for choosing an image sampling grid. */
|
|
16
|
+
const extentOf = (rect) => (rect ? { width: rect.right - rect.left, height: rect.bottom - rect.top } : null);
|
|
17
|
+
|
|
12
18
|
/** The strict separator set for the pure-decoration exemption: pipes,
|
|
13
19
|
* interpuncts, slashes, dashes and guillemets — one glyph, optionally
|
|
14
20
|
* repeated ("···"). Deliberately excludes brackets, colons, stars and
|
|
@@ -34,6 +40,58 @@ const veiledIncomplete = (resting, veiled) => ({
|
|
|
34
40
|
+ ' — judge the page with the overlay dismissed, or the dimmed state by eye if the overlay is permanent.',
|
|
35
41
|
});
|
|
36
42
|
|
|
43
|
+
/** Roles whose element (and, for composites and groups, whose focusable
|
|
44
|
+
* descendants) aria-disabled="true" really switches off, per WAI-ARIA's
|
|
45
|
+
* list of roles that support the attribute. On anything else, above all a
|
|
46
|
+
* plain <div>, the attribute has no effect on the content it wraps. */
|
|
47
|
+
const ARIA_DISABLED_HOSTS = 'button, a[href], input, select, textarea, fieldset, optgroup, option, '
|
|
48
|
+
+ '[role="button"], [role="link"], [role="checkbox"], [role="radio"], [role="switch"], [role="tab"], '
|
|
49
|
+
+ '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], '
|
|
50
|
+
+ '[role="slider"], [role="spinbutton"], [role="textbox"], [role="searchbox"], [role="combobox"], '
|
|
51
|
+
+ '[role="gridcell"], [role="treeitem"], [role="scrollbar"], [role="separator"], [role="application"], '
|
|
52
|
+
+ '[role="group"], [role="listbox"], [role="menu"], [role="menubar"], [role="radiogroup"], '
|
|
53
|
+
+ '[role="tablist"], [role="tree"], [role="treegrid"], [role="grid"], [role="toolbar"]';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Is this text part of an inactive user interface component? 1.4.3 exempts
|
|
57
|
+
* exactly that, and the glossary defines a component as "a part of the
|
|
58
|
+
* content that is perceived by users as a single control for a distinct
|
|
59
|
+
* function". Two attributes used to exempt whatever they sat on:
|
|
60
|
+
* - aria-disabled="true" on a wrapper that is not a control (a <div>
|
|
61
|
+
* around paragraphs) disables nothing, so its prose keeps its verdict.
|
|
62
|
+
* - a disabled <fieldset> leaves the content of its first <legend> active
|
|
63
|
+
* (HTML: "except any descendants of the first legend element child");
|
|
64
|
+
* the legend is the group's caption, not a disabled control.
|
|
65
|
+
* (2026-08-25 overnight audit, defect 7.)
|
|
66
|
+
*/
|
|
67
|
+
function inactiveComponentText(element) {
|
|
68
|
+
for (let node = element; node; node = node.parentElement) {
|
|
69
|
+
const disabled = node.closest(':disabled');
|
|
70
|
+
if (!disabled) break;
|
|
71
|
+
if (disabled.tagName !== 'FIELDSET') return true;
|
|
72
|
+
const legend = element.closest('legend');
|
|
73
|
+
if (!legend || legend.parentElement !== disabled
|
|
74
|
+
|| disabled.querySelector(':scope > legend') !== legend) return true;
|
|
75
|
+
// Inside the first legend of this disabled fieldset: HTML keeps that
|
|
76
|
+
// content active. An outer disabled fieldset could still disable it,
|
|
77
|
+
// so the walk continues from above this one.
|
|
78
|
+
node = disabled;
|
|
79
|
+
}
|
|
80
|
+
const ariaDisabled = element.closest('[aria-disabled="true"]');
|
|
81
|
+
return Boolean(ariaDisabled && ariaDisabled.matches(ARIA_DISABLED_HOSTS));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A control the keyboard cannot reach either: with pointer-events: none
|
|
85
|
+
* on top, nothing can operate it, which is what "inactive" means. Native
|
|
86
|
+
* controls are focusable unless told otherwise; a role-only widget is
|
|
87
|
+
* focusable only when the author gave it a tabindex. */
|
|
88
|
+
function unreachableControl(control) {
|
|
89
|
+
if (control.matches(':disabled, [aria-disabled="true"], [tabindex="-1"]')) return true;
|
|
90
|
+
if (control.closest('[inert]')) return true;
|
|
91
|
+
const nativeFocusable = control.matches('button, a[href], input, select');
|
|
92
|
+
return !nativeFocusable && !control.hasAttribute('tabindex') && !control.isContentEditable;
|
|
93
|
+
}
|
|
94
|
+
|
|
37
95
|
/**
|
|
38
96
|
* Is a duplicate copy of the same text painted exactly on top of this
|
|
39
97
|
* element? The gradient-text pattern ships two copies — a plain-colour
|
|
@@ -91,7 +149,7 @@ function textIntersects(element, rect) {
|
|
|
91
149
|
* `intrinsic` rides along so the caller can reason about icon-sized images.
|
|
92
150
|
*/
|
|
93
151
|
async function imageVsText(imageSource, element, doc) {
|
|
94
|
-
const url = imageSource.css
|
|
152
|
+
const url = firstLayerUrl(imageSource.css);
|
|
95
153
|
let intrinsic = null;
|
|
96
154
|
if (url) {
|
|
97
155
|
try { intrinsic = await imageLuminanceRange(new URL(url, doc.baseURI).href); } catch { /* unknowable */ }
|
|
@@ -116,10 +174,10 @@ async function imageVsText(imageSource, element, doc) {
|
|
|
116
174
|
sizedSmall = px.length > 0 && px.every((value) => value <= 40);
|
|
117
175
|
}
|
|
118
176
|
const paint = backgroundImagePaintRect(imageSource.element, dimensionless ? null : intrinsic);
|
|
119
|
-
if (!paint) return { relation: 'unknown', intrinsic, dimensionless, isGradient, sizedSmall };
|
|
177
|
+
if (!paint) return { relation: 'unknown', intrinsic, dimensionless, isGradient, sizedSmall, paint: null };
|
|
120
178
|
return {
|
|
121
179
|
relation: textIntersects(element, paint) ? 'under' : 'clear',
|
|
122
|
-
intrinsic, dimensionless, isGradient, sizedSmall,
|
|
180
|
+
intrinsic, dimensionless, isGradient, sizedSmall, paint,
|
|
123
181
|
};
|
|
124
182
|
}
|
|
125
183
|
|
|
@@ -150,10 +208,20 @@ function textVisuallyHidden(element, style, foreground) {
|
|
|
150
208
|
}
|
|
151
209
|
|
|
152
210
|
// Turn a luminance-range bracket into a rule outcome (or null = can't tell).
|
|
153
|
-
|
|
211
|
+
// `reduced`: the image was sampled through a grid coarser than four painted
|
|
212
|
+
// pixels per sample, so glyph-scale detail may be missing from the range; a
|
|
213
|
+
// pass read off it is not provable and goes to a human instead.
|
|
214
|
+
// (2026-08-25 overnight audit, defect 4.)
|
|
215
|
+
function verdictFromRange(range, foreground, required, what, reduced = false) {
|
|
154
216
|
if (!range) return null;
|
|
155
217
|
const { verdict, worst, best } = rangeVerdict(foreground, range, required);
|
|
156
|
-
if (verdict === 'pass')
|
|
218
|
+
if (verdict === 'pass') {
|
|
219
|
+
if (!reduced) return { status: 'pass' };
|
|
220
|
+
return {
|
|
221
|
+
status: 'incomplete',
|
|
222
|
+
message: `The ${what} behind this text is too large to sample at glyph scale, so detail narrower than the sampling grid could sit under the text unseen. Every sampled pixel passes (worst ${showRatio(worst)}:1), so confirm by eye.`,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
157
225
|
if (verdict === 'fail') {
|
|
158
226
|
return {
|
|
159
227
|
status: 'fail',
|
|
@@ -222,24 +290,54 @@ function paintUnderImage(carrier) {
|
|
|
222
290
|
// knows it: an image with see-through pixels shows that colour wherever the
|
|
223
291
|
// ink is absent, so the judged range must include it — and without it no
|
|
224
292
|
// verdict can be asserted from such an image at all.
|
|
225
|
-
|
|
293
|
+
// `extent` is the painted size of the layer in CSS pixels, when the caller
|
|
294
|
+
// knows it: the sampling grid is chosen from it (sampleGridFor).
|
|
295
|
+
async function sampledVerdict(source, foreground, required, doc, overlays = [], under = null, extent = null) {
|
|
226
296
|
if (!source) return null;
|
|
227
297
|
let range = null;
|
|
228
298
|
let what = 'image';
|
|
299
|
+
const grid = sampleGridFor(extent);
|
|
229
300
|
if (source.tagName === 'IMG') {
|
|
230
|
-
range = await imageLuminanceRange(source.currentSrc || source.src, overlays);
|
|
301
|
+
range = await imageLuminanceRange(source.currentSrc || source.src, overlays, grid);
|
|
231
302
|
} else {
|
|
232
303
|
const css = source.css ?? getComputedStyle(source).backgroundImage;
|
|
233
304
|
if (!css || css === 'none') return null;
|
|
234
|
-
|
|
235
|
-
|
|
305
|
+
// Layers are counted at the top level of the list only: a url( or
|
|
306
|
+
// gradient( INSIDE a data URI (an SVG whose fill references url(#p))
|
|
307
|
+
// is part of that one layer, not a second one. And each abstention
|
|
308
|
+
// names its own reason, because "several stacked layers" was being
|
|
309
|
+
// said of a single gradient whose stops merely failed to parse.
|
|
310
|
+
// (2026-08-25 overnight audit, defect 8.)
|
|
311
|
+
const layers = splitBackgroundLayers(css).filter((layer) => layer !== 'none');
|
|
312
|
+
if (layers.length > 1) return null; // stacked layers: too complex
|
|
313
|
+
const layer = layers[0] ?? '';
|
|
314
|
+
const url = backgroundLayerUrl(layer);
|
|
236
315
|
if (url) {
|
|
237
316
|
let absolute;
|
|
238
317
|
try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
|
|
239
|
-
range = await imageLuminanceRange(absolute, overlays);
|
|
240
|
-
} else if (
|
|
241
|
-
range = gradientLuminanceRange(css, overlays);
|
|
318
|
+
range = await imageLuminanceRange(absolute, overlays, grid);
|
|
319
|
+
} else if (layer.includes('gradient(')) {
|
|
242
320
|
what = 'gradient';
|
|
321
|
+
const stops = gradientStops(layer);
|
|
322
|
+
if (!stops.colors.length || stops.unparsed) {
|
|
323
|
+
return {
|
|
324
|
+
status: 'incomplete',
|
|
325
|
+
message: 'The gradient behind this text has colour stops this engine could not parse, so its range of colours could not be sampled. Check the contrast by eye.',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (stops.space) {
|
|
329
|
+
return {
|
|
330
|
+
status: 'incomplete',
|
|
331
|
+
message: `The gradient behind this text interpolates in ${stops.space}, not sRGB, so the colours between its stops cannot be computed here. Check the contrast by eye.`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (stops.translucent) {
|
|
335
|
+
return {
|
|
336
|
+
status: 'incomplete',
|
|
337
|
+
message: 'The gradient behind this text has translucent colour stops, so what shows behind the glyphs is a blend with whatever is painted beneath the gradient. Check the contrast by eye.',
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
range = sampledGradientRange(layer, overlays);
|
|
243
341
|
} else {
|
|
244
342
|
return null;
|
|
245
343
|
}
|
|
@@ -299,7 +397,7 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
|
|
|
299
397
|
message: 'The image painted behind this text can’t be read: it is cross-origin, or it failed to load, so a script can’t sample its colours. Check the contrast by eye.',
|
|
300
398
|
};
|
|
301
399
|
}
|
|
302
|
-
return verdictFromRange(range, foreground, required, what);
|
|
400
|
+
return verdictFromRange(range, foreground, required, what, Boolean(range?.reduced));
|
|
303
401
|
}
|
|
304
402
|
|
|
305
403
|
/**
|
|
@@ -308,7 +406,33 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
|
|
|
308
406
|
* 1.4.6 Contrast (Enhanced), AAA — 7:1, or 4.5:1 for large text
|
|
309
407
|
*/
|
|
310
408
|
export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
311
|
-
|
|
409
|
+
// When the colour pair already clears the LARGE-scale minimum, the miss
|
|
410
|
+
// is about scale, not colour: name the boundary (14pt bold / 18pt), or
|
|
411
|
+
// a bold-but-just-under button reads as the checker ignoring its weight.
|
|
412
|
+
const largeScaleNoteFor = (style, required, ratio) => {
|
|
413
|
+
if (required !== thresholds.normal || ratio < thresholds.large) return '';
|
|
414
|
+
const sizePx = parseFloat(style.fontSize) || 0;
|
|
415
|
+
const weightNum = parseInt(style.fontWeight, 10) || 400;
|
|
416
|
+
// One decimal rounds 18.66px up to "18.7px", which then reads as
|
|
417
|
+
// clearing the 18.67px cutoff the same sentence names. Within a
|
|
418
|
+
// twentieth of a pixel of a cutoff the size is shown to two decimals,
|
|
419
|
+
// and when even that rounds onto the cutoff it is named as just under
|
|
420
|
+
// it. (2026-08-25 overnight audit, defect 9.)
|
|
421
|
+
const cutoff = weightNum >= 700 ? 56 / 3 : 24;
|
|
422
|
+
const cutoffText = weightNum >= 700 ? '18.67px' : '24px';
|
|
423
|
+
const nearCutoff = Math.abs(sizePx - cutoff) < 0.05;
|
|
424
|
+
let shown = nearCutoff ? `${sizePx.toFixed(2)}px` : `${Math.round(sizePx * 10) / 10}px`;
|
|
425
|
+
if (nearCutoff && parseFloat(shown) >= parseFloat(cutoffText)) shown = `just under ${cutoffText}`;
|
|
426
|
+
if (weightNum >= 700 && sizePx < 56 / 3) {
|
|
427
|
+
return ` This text is bold at ${shown}; bold text counts as large scale from 18.67px (14pt), where the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
|
|
428
|
+
}
|
|
429
|
+
if (weightNum < 700 && sizePx >= 56 / 3 && sizePx < 24) {
|
|
430
|
+
return ` At ${shown} regular weight this is not large scale; from 24px (18pt), or bold at this size, the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
|
|
431
|
+
}
|
|
432
|
+
return '';
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const rule = {
|
|
312
436
|
id,
|
|
313
437
|
impact: 'serious',
|
|
314
438
|
tags,
|
|
@@ -329,7 +453,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
329
453
|
if (!ownText(element)) return { status: 'pass' }; // no text of its own to judge
|
|
330
454
|
|
|
331
455
|
// The criterion exempts "inactive user interface components".
|
|
332
|
-
if (element
|
|
456
|
+
if (inactiveComponentText(element)) return { status: 'pass' };
|
|
333
457
|
|
|
334
458
|
// A shadow HOST's own text renders through its shadow tree: when a
|
|
335
459
|
// <slot> projects it, the text inherits styles from the slot's
|
|
@@ -357,8 +481,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
357
481
|
// inline, outside code and interactive content, with rendered content
|
|
358
482
|
// on BOTH sides — so Python's `>>>` prompt, code operators, maths in
|
|
359
483
|
// prose and meaning-bearing brackets all keep their verdicts.
|
|
484
|
+
// "Inline" here means LAID inline, not computed inline: a flex or grid
|
|
485
|
+
// container blockifies its children, so a separator <li> in a flex-row
|
|
486
|
+
// footer nav computes display:block while sitting visually between its
|
|
487
|
+
// neighbours on one line — the layout mode does not change what the
|
|
488
|
+
// glyph is (wasexamprep.com's footer dots, adjudicated 2026-08-22).
|
|
489
|
+
const laidInline = style.display.startsWith('inline')
|
|
490
|
+
|| /flex|grid/.test(element.parentElement
|
|
491
|
+
? getComputedStyle(element.parentElement).display : '');
|
|
360
492
|
if (SEPARATOR_GLYPHS.test(ownText(element))
|
|
361
|
-
&&
|
|
493
|
+
&& laidInline
|
|
362
494
|
&& !element.closest('code, pre, samp, kbd, var, a[href], button, [role="button"], [role="link"], [role="menuitem"], [role="tab"], [role="option"]')) {
|
|
363
495
|
const hasContent = (start, dir) => {
|
|
364
496
|
for (let node = start; node; node = node[dir]) {
|
|
@@ -382,8 +514,17 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
382
514
|
// content someone is expected to read, and whether "temporarily
|
|
383
515
|
// non-interactive" makes it an exempt inactive component under 1.4.3 is
|
|
384
516
|
// a human judgement — never a silent pass.
|
|
517
|
+
//
|
|
518
|
+
// pointer-events only switches off mouse and touch hit-testing. It does
|
|
519
|
+
// not disable the control, drop it from the tab order or stop Enter from
|
|
520
|
+
// activating it, so a link styled that way is still a live component
|
|
521
|
+
// for the keyboard (a Tab reaches it and Enter fires its click). The
|
|
522
|
+
// shortcut therefore needs the control to be unreachable as well:
|
|
523
|
+
// tabindex="-1", inert, disabled, or a role-only widget with no tabindex
|
|
524
|
+
// at all. A reachable control is judged like any other text.
|
|
525
|
+
// (2026-08-25 overnight audit, defect 5.)
|
|
385
526
|
const control = element.closest('button, a[href], input, select, [role="button"], [role="link"], [role="option"], [role="tab"]');
|
|
386
|
-
if (control && getComputedStyle(control).pointerEvents === 'none') {
|
|
527
|
+
if (control && getComputedStyle(control).pointerEvents === 'none' && unreachableControl(control)) {
|
|
387
528
|
if ((control.textContent || '').trim().length <= 80) return { status: 'pass' };
|
|
388
529
|
return {
|
|
389
530
|
status: 'incomplete',
|
|
@@ -398,7 +539,8 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
398
539
|
const clipsText = /\btext\b/.test(style.webkitBackgroundClip ?? '') || /\btext\b/.test(style.backgroundClip ?? '');
|
|
399
540
|
if (clipsText) {
|
|
400
541
|
const behind = effectiveBackground(element.parentElement ?? element);
|
|
401
|
-
const fgRange = style.backgroundImage !== 'none'
|
|
542
|
+
const fgRange = style.backgroundImage !== 'none'
|
|
543
|
+
? sampledGradientRange(splitBackgroundLayers(style.backgroundImage)[0] ?? '') : null;
|
|
402
544
|
if (fgRange && behind) {
|
|
403
545
|
// Symmetric bracket: text luminance spans the gradient's stops.
|
|
404
546
|
const required = isLargeText(style) ? thresholds.large : thresholds.normal;
|
|
@@ -419,9 +561,39 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
419
561
|
if (!foreground) {
|
|
420
562
|
return { status: 'incomplete', message: 'The text colour could not be parsed — check contrast by eye.' };
|
|
421
563
|
}
|
|
422
|
-
|
|
564
|
+
let required = isLargeText(style) ? thresholds.large : thresholds.normal;
|
|
423
565
|
const doc = element.ownerDocument;
|
|
424
566
|
|
|
567
|
+
// ::first-line and ::first-letter repaint part of this same text in
|
|
568
|
+
// another colour, and 1.4.3 applies to the visual presentation of all
|
|
569
|
+
// of it: a pale first line over a dark base colour was judged by the
|
|
570
|
+
// base alone and passed. Every colour that paints the text is a
|
|
571
|
+
// candidate, each with its own large-scale threshold (a drop cap is
|
|
572
|
+
// usually bigger), and the one with the least margin over its threshold
|
|
573
|
+
// is judged. The pick is made against the walked background here so
|
|
574
|
+
// the image and gradient paths below see it too, and remade against
|
|
575
|
+
// the resolved background on the flat-colour path.
|
|
576
|
+
// (2026-08-25 overnight audit, defect 6.)
|
|
577
|
+
let foregroundOrigin = null;
|
|
578
|
+
const baseForeground = foreground;
|
|
579
|
+
const baseRequired = required;
|
|
580
|
+
const alternates = pseudoTextColors(element, style)
|
|
581
|
+
.map(({ pseudo, color, style: pseudoStyle }) => ({
|
|
582
|
+
origin: pseudo, color, required: isLargeText(pseudoStyle) ? thresholds.large : thresholds.normal,
|
|
583
|
+
}));
|
|
584
|
+
const worstCandidate = (candidates, backdrop) => candidates.reduce((worst, candidate) => {
|
|
585
|
+
const shown = candidate.color.a < 1 ? composite(candidate.color, backdrop) : candidate.color;
|
|
586
|
+
const margin = contrastRatio(shown, backdrop) / candidate.required;
|
|
587
|
+
return !worst || margin < worst.margin ? { ...candidate, margin } : worst;
|
|
588
|
+
}, null);
|
|
589
|
+
if (alternates.length) {
|
|
590
|
+
const estimate = effectiveBackground(styleSource) ?? { r: 255, g: 255, b: 255, a: 1 };
|
|
591
|
+
const pick = worstCandidate([{ origin: null, color: foreground, required }, ...alternates], estimate);
|
|
592
|
+
foreground = pick.color;
|
|
593
|
+
required = pick.required;
|
|
594
|
+
foregroundOrigin = pick.origin;
|
|
595
|
+
}
|
|
596
|
+
|
|
425
597
|
// Judge the opacity the text RESTS at, not a transient frame. Elements
|
|
426
598
|
// with a RUNNING opacity animation anywhere up the flat tree are judged
|
|
427
599
|
// at the animation's landing value (a fill-forwards entrance ending at
|
|
@@ -431,20 +603,114 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
431
603
|
// Near-zero resting opacity means the text isn't visually presented.
|
|
432
604
|
const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
|
|
433
605
|
if (opacity < 0.05) return { status: 'pass' };
|
|
434
|
-
if (opacity < 1)
|
|
606
|
+
if (opacity < 1) {
|
|
607
|
+
// Group opacity with a background INSIDE the group: the browser blends
|
|
608
|
+
// text and background together over what lies behind the carrier, so
|
|
609
|
+
// the presented pair is neither "dimmed text on the group's own
|
|
610
|
+
// background" nor "dimmed text on the page". opacityGroupPaint
|
|
611
|
+
// composites both the way the renderer does; when the backdrop behind
|
|
612
|
+
// the carrier is an image the pair is a human call.
|
|
613
|
+
// (2026-08-25 overnight audit, defect 1.)
|
|
614
|
+
// Text-shadow halos and unplaceable pseudo films are bracketed by the
|
|
615
|
+
// flat path below and not by this one, so text carrying either keeps
|
|
616
|
+
// that path (and its human calls) rather than a group verdict.
|
|
617
|
+
const groupEligible = (!style.textShadow || style.textShadow === 'none')
|
|
618
|
+
&& !(pseudoBackdropForText(element)?.film > 0);
|
|
619
|
+
const group = groupEligible ? opacityGroupPaint(element, foreground) : null;
|
|
620
|
+
if (group?.unresolved) {
|
|
621
|
+
return {
|
|
622
|
+
status: 'incomplete',
|
|
623
|
+
message: `This text and its own background render together at ${Math.round(group.opacity * 100)}% opacity over an image or gradient, so the presented colours are a blend a script cannot resolve. Check the contrast by eye.`,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (group) {
|
|
627
|
+
const groupRatio = contrastRatio(group.foreground, group.background);
|
|
628
|
+
// The backdrop behind the carrier came from the ancestor walk, which
|
|
629
|
+
// cannot see media or positioned panels painted where this text is
|
|
630
|
+
// (a promo card at opacity .999 over its own photo, BBC's carousel):
|
|
631
|
+
// the same hazard bracket the unverified flat path applies, before
|
|
632
|
+
// either verdict is asserted.
|
|
633
|
+
const groupRect = element.getBoundingClientRect();
|
|
634
|
+
const near = (r) => Math.min(groupRect.right, r.right) - Math.max(groupRect.left, r.left) >= 3
|
|
635
|
+
&& Math.min(groupRect.bottom, r.bottom) - Math.max(groupRect.top, r.top) >= 3;
|
|
636
|
+
const overMedia = mediaRects(doc).some(({ element: media, rect: mediaRect }) =>
|
|
637
|
+
near(mediaRect) && !media.contains(element) && !element.contains(media) && textIntersects(element, mediaRect));
|
|
638
|
+
if (overMedia) {
|
|
639
|
+
return {
|
|
640
|
+
status: 'incomplete',
|
|
641
|
+
message: 'This text overlaps an image or video, so its real background can’t be computed — contrast must be checked by eye.',
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const flipping = opaquePanelRects(doc).some(({ element: panel, rect, color }) =>
|
|
645
|
+
near(rect) && !panel.contains(element) && !element.contains(panel) && textIntersects(element, rect)
|
|
646
|
+
&& (contrastRatio(composite(group.text, color), composite(group.paint, color)) >= required) !== (groupRatio >= required));
|
|
647
|
+
if (flipping) {
|
|
648
|
+
return {
|
|
649
|
+
status: 'incomplete',
|
|
650
|
+
message: 'This text overlaps a coloured block that isn’t its DOM ancestor, so its real background is ambiguous — check contrast by eye.',
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
// A translucent full-page veil (consent dimmer, modal scrim) over the
|
|
654
|
+
// text dims the presented pair exactly as it dims the flat path's,
|
|
655
|
+
// and the adjudicated overlay-state policy applies unchanged: assert
|
|
656
|
+
// only when the resting and the veiled states agree, otherwise ask.
|
|
657
|
+
// Returning before this check silenced every review under the
|
|
658
|
+
// OneTrust dimmer on pages whose content wrapper sits at opacity .99
|
|
659
|
+
// (apnews.com: 85 lost reviews in the corpus differential).
|
|
660
|
+
const groupPainted = paintedBackdrop(element);
|
|
661
|
+
let groupScrim = groupPainted?.scrim ?? null;
|
|
662
|
+
if (!groupScrim && groupPainted === 'offscreen') {
|
|
663
|
+
const veil = viewportVeil(doc);
|
|
664
|
+
if (veil && !veil.contains(element)) groupScrim = [veil];
|
|
665
|
+
}
|
|
666
|
+
if (groupScrim) {
|
|
667
|
+
const groupVeil = scrimPaint(groupScrim);
|
|
668
|
+
if (!groupVeil) return veiledIncomplete();
|
|
669
|
+
const veiledRatio = contrastRatio(applyOverlays(group.foreground, groupVeil), applyOverlays(group.background, groupVeil));
|
|
670
|
+
if ((veiledRatio >= required) !== (groupRatio >= required)) return veiledIncomplete(groupRatio, veiledRatio);
|
|
671
|
+
}
|
|
672
|
+
if (groupRatio >= required) return { status: 'pass' };
|
|
673
|
+
if (!groupRect.width || !groupRect.height) return { status: 'pass' };
|
|
674
|
+
const groupFg = asRgb(group.foreground);
|
|
675
|
+
const groupBg = asRgb(group.background);
|
|
676
|
+
return {
|
|
677
|
+
status: 'fail',
|
|
678
|
+
message: `Contrast is ${showRatio(groupRatio)}:1, below the ${required}:1 WCAG minimum for this text size.${largeScaleNoteFor(style, required, groupRatio)}`,
|
|
679
|
+
fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${groupFg} on ${groupBg}).`
|
|
680
|
+
+ (style.color !== groupFg ? ` Your CSS writes the text as ${style.color}, so searching it for the sRGB values above won't find them.` : '')
|
|
681
|
+
+ ` The gap is opacity: this element or an ancestor renders at ${Math.round(group.opacity * 100)}% opacity, so the text and its own background are both blended toward what lies behind them, into the sRGB values above.`,
|
|
682
|
+
data: {
|
|
683
|
+
foreground: groupFg,
|
|
684
|
+
background: groupBg,
|
|
685
|
+
ratio: Number(showRatio(groupRatio)),
|
|
686
|
+
required,
|
|
687
|
+
},
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
foreground = { ...foreground, a: foreground.a * opacity };
|
|
691
|
+
}
|
|
435
692
|
|
|
436
693
|
// A background image on an ancestor: sample its pixels and bracket —
|
|
437
694
|
// but only when it's actually painted under this text. Icon-in-padding
|
|
438
695
|
// images (external-link arrows etc.) don't affect the text's contrast.
|
|
439
696
|
const imageSource = backgroundImageSource(element);
|
|
440
697
|
if (imageSource) {
|
|
441
|
-
const { relation, intrinsic, dimensionless, isGradient, sizedSmall } = await imageVsText(imageSource, element, doc);
|
|
698
|
+
const { relation, intrinsic, dimensionless, isGradient, sizedSmall, paint } = await imageVsText(imageSource, element, doc);
|
|
442
699
|
if (relation !== 'clear') {
|
|
443
700
|
// imageSource.overlays are the translucent layers painted between the
|
|
444
701
|
// image and this text (hero scrims, tint panels). Judging the raw
|
|
445
702
|
// image pixels would describe a page the user never sees.
|
|
703
|
+
// The painted extent picks the sampling grid: the paint rect when
|
|
704
|
+
// it resolved, otherwise the larger of the carrier's box and the
|
|
705
|
+
// image's own size (tiling paints at intrinsic scale; cover and
|
|
706
|
+
// contain within the box), which errs toward more samples.
|
|
707
|
+
const carrierBox = imageSource.element.getBoundingClientRect();
|
|
708
|
+
const extent = paint ? extentOf(paint) : {
|
|
709
|
+
width: Math.max(carrierBox.width, dimensionless ? 0 : intrinsic.width),
|
|
710
|
+
height: Math.max(carrierBox.height, dimensionless ? 0 : intrinsic.height),
|
|
711
|
+
};
|
|
446
712
|
let sampled = await sampledVerdict(imageSource, foreground, required, doc, imageSource.overlays,
|
|
447
|
-
paintUnderImage(imageSource.element));
|
|
713
|
+
paintUnderImage(imageSource.element), extent);
|
|
448
714
|
// A layer with no opaque pixels changes nothing about what is behind
|
|
449
715
|
// the glyphs, so it must not block the verdict: fall through and
|
|
450
716
|
// judge the background that actually paints.
|
|
@@ -466,7 +732,6 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
466
732
|
// can never exceed the carrier — a 512px nominal SVG painted on an
|
|
467
733
|
// inline link is still an icon). Only backdrop-scale images earn a
|
|
468
734
|
// definite fail from sampling.
|
|
469
|
-
const carrierBox = imageSource.element.getBoundingClientRect();
|
|
470
735
|
const iconScale = isGradient
|
|
471
736
|
? (sizedSmall || carrierBox.height <= 40)
|
|
472
737
|
: (dimensionless || (intrinsic.width <= 32 && intrinsic.height <= 32) || carrierBox.height <= 40);
|
|
@@ -547,7 +812,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
547
812
|
// background-image path has always applied.
|
|
548
813
|
let pseudoIconScale = false;
|
|
549
814
|
if (pseudoBack?.image?.meta && pseudoBack.image.box) {
|
|
550
|
-
const pseudoUrl = pseudoBack.image.css
|
|
815
|
+
const pseudoUrl = firstLayerUrl(pseudoBack.image.css);
|
|
551
816
|
if (pseudoUrl && pseudoBack.image.meta.repeat === 'no-repeat') {
|
|
552
817
|
let pseudoIntrinsic = null;
|
|
553
818
|
try { pseudoIntrinsic = await imageLuminanceRange(new URL(pseudoUrl, doc.baseURI).href); } catch { /* unknowable */ }
|
|
@@ -598,7 +863,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
598
863
|
// else, so the host's resolved colour is what shows through any
|
|
599
864
|
// transparent pixels the image has.
|
|
600
865
|
const sampled = await sampledVerdict(pseudoBack.image, foreground, required, doc, [],
|
|
601
|
-
effectiveBackground(pseudoBack.image.element));
|
|
866
|
+
effectiveBackground(pseudoBack.image.element), extentOf(pseudoBack.image.box));
|
|
602
867
|
// PAINTS_NOTHING: the pseudo-element's image is fully transparent, so
|
|
603
868
|
// it never displaced the background the walk resolved.
|
|
604
869
|
if (sampled !== PAINTS_NOTHING) {
|
|
@@ -615,7 +880,11 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
615
880
|
}
|
|
616
881
|
}
|
|
617
882
|
if (painted?.image && !pseudoBack) {
|
|
618
|
-
|
|
883
|
+
// painted.overlays: the translucent layers the hit-test walk crossed
|
|
884
|
+
// between the text and the image (a scrim div over a hero <img>), seen
|
|
885
|
+
// through exactly as the ancestor walk's overlays are.
|
|
886
|
+
const sampled = await sampledVerdict(painted.image, foreground, required, doc, painted.overlays ?? [],
|
|
887
|
+
null, extentOf(painted.image.getBoundingClientRect()));
|
|
619
888
|
if (sampled !== PAINTS_NOTHING) {
|
|
620
889
|
return sampled ?? {
|
|
621
890
|
status: 'incomplete',
|
|
@@ -637,7 +906,8 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
637
906
|
// hazards still reach a human instead of a wrong assertion.
|
|
638
907
|
const obscuring = backgroundObscured(element);
|
|
639
908
|
if (obscuring && obscuring !== 'unverifiable') {
|
|
640
|
-
const sampled = await sampledVerdict(obscuring, foreground, required, doc
|
|
909
|
+
const sampled = await sampledVerdict(obscuring, foreground, required, doc, [], null,
|
|
910
|
+
extentOf(obscuring.getBoundingClientRect()));
|
|
641
911
|
if (sampled !== PAINTS_NOTHING) {
|
|
642
912
|
return sampled ?? {
|
|
643
913
|
status: 'incomplete',
|
|
@@ -683,6 +953,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
683
953
|
return { status: 'incomplete', message: 'The background could not be determined — check contrast by eye.' };
|
|
684
954
|
}
|
|
685
955
|
|
|
956
|
+
// With the background now resolved, the ::first-line / ::first-letter
|
|
957
|
+
// pick is remade against it: the estimate above only had the walk.
|
|
958
|
+
if (alternates.length) {
|
|
959
|
+
const dim = (color) => (opacity < 1 ? { ...color, a: color.a * opacity } : color);
|
|
960
|
+
const base = { origin: null, color: dim(baseForeground), required: baseRequired };
|
|
961
|
+
const pick = worstCandidate([base, ...alternates.map((a) => ({ ...a, color: dim(a.color) }))], background);
|
|
962
|
+
foreground = pick.color;
|
|
963
|
+
required = pick.required;
|
|
964
|
+
foregroundOrigin = pick.origin;
|
|
965
|
+
}
|
|
686
966
|
// Semi-transparent text is really text-colour blended into the background.
|
|
687
967
|
if (foreground.a < 1) foreground = composite(foreground, background);
|
|
688
968
|
|
|
@@ -858,7 +1138,13 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
858
1138
|
const parsed = parseColor(css);
|
|
859
1139
|
return parsed != null && parsed.a >= 1 && asRgb(parsed) === judged;
|
|
860
1140
|
};
|
|
861
|
-
|
|
1141
|
+
// A colour painted by ::first-line or ::first-letter is not the
|
|
1142
|
+
// element's own color, so the "your CSS writes" comparison would name
|
|
1143
|
+
// the wrong declaration; the pseudo is named instead.
|
|
1144
|
+
const authoredFg = !foregroundOrigin && style.color !== foregroundRgb ? style.color : null;
|
|
1145
|
+
const pseudoNote = foregroundOrigin
|
|
1146
|
+
? ` The judged text colour comes from a ${foregroundOrigin} rule on this element, which paints that part of the text in place of its color.`
|
|
1147
|
+
: '';
|
|
862
1148
|
const backgroundSource = backgroundColorSource(element);
|
|
863
1149
|
const authoredBg = backgroundSource && backgroundSource !== backgroundRgb ? backgroundSource : null;
|
|
864
1150
|
const foregroundCss = authoredFg && sameValue(authoredFg, foregroundRgb) ? authoredFg : null;
|
|
@@ -878,28 +1164,14 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
878
1164
|
: ''))
|
|
879
1165
|
: '';
|
|
880
1166
|
|
|
881
|
-
|
|
882
|
-
// is about scale, not colour: name the boundary (14pt bold / 18pt), or
|
|
883
|
-
// a bold-but-just-under button reads as the checker ignoring its weight.
|
|
884
|
-
const largeScaleNote = (() => {
|
|
885
|
-
if (required !== thresholds.normal || ratio < thresholds.large) return '';
|
|
886
|
-
const sizePx = parseFloat(style.fontSize) || 0;
|
|
887
|
-
const weightNum = parseInt(style.fontWeight, 10) || 400;
|
|
888
|
-
const shown = `${Math.round(sizePx * 10) / 10}px`;
|
|
889
|
-
if (weightNum >= 700 && sizePx < 56 / 3) {
|
|
890
|
-
return ` This text is bold at ${shown}; bold text counts as large scale from 18.67px (14pt), where the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
|
|
891
|
-
}
|
|
892
|
-
if (weightNum < 700 && sizePx >= 56 / 3 && sizePx < 24) {
|
|
893
|
-
return ` At ${shown} regular weight this is not large scale; from 24px (18pt), or bold at this size, the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
|
|
894
|
-
}
|
|
895
|
-
return '';
|
|
896
|
-
})();
|
|
1167
|
+
const largeScaleNote = largeScaleNoteFor(style, required, ratio);
|
|
897
1168
|
return {
|
|
898
1169
|
status: 'fail',
|
|
899
1170
|
message: `Contrast is ${showRatio(ratio)}:1 — below the ${required}:1 WCAG minimum for this text size.${largeScaleNote}`,
|
|
900
1171
|
fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${foregroundRgb} on ${backgroundRgb}).`
|
|
901
1172
|
+ (authored ? ` Your CSS writes ${authored}, so searching it for the sRGB values above won't find them.` : '')
|
|
902
|
-
+ dimmedNote
|
|
1173
|
+
+ dimmedNote
|
|
1174
|
+
+ pseudoNote,
|
|
903
1175
|
// The exact pair, for the UIs to link out to an interactive checker.
|
|
904
1176
|
data: {
|
|
905
1177
|
foreground: foregroundRgb,
|
|
@@ -912,6 +1184,24 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
912
1184
|
};
|
|
913
1185
|
},
|
|
914
1186
|
};
|
|
1187
|
+
|
|
1188
|
+
// The perennial objection, answered where it is raised: a contrast failure
|
|
1189
|
+
// inside aria-hidden content is still a failure, because aria-hidden only
|
|
1190
|
+
// removes content from the accessibility tree and 1.4.3 exists for sighted
|
|
1191
|
+
// low-vision readers — the one audience the attribute cannot reach. Every
|
|
1192
|
+
// fail path (solid, image-range, translucent blend, halo) gets the note,
|
|
1193
|
+
// and both criteria built from this factory inherit it. Adjudicated
|
|
1194
|
+
// 2026-08-22 against a challenged mockup finding; axe reaches the same
|
|
1195
|
+
// verdict on the same node.
|
|
1196
|
+
const judge = rule.evaluate.bind(rule);
|
|
1197
|
+
rule.evaluate = async (element, helpers) => {
|
|
1198
|
+
const verdict = await judge(element, helpers);
|
|
1199
|
+
if (verdict.status === 'fail' && element.closest('[aria-hidden="true"]')) {
|
|
1200
|
+
verdict.message += ' aria-hidden hides this from screen readers, not from sighted users; contrast is judged for the people who see it.';
|
|
1201
|
+
}
|
|
1202
|
+
return verdict;
|
|
1203
|
+
};
|
|
1204
|
+
return rule;
|
|
915
1205
|
}
|
|
916
1206
|
|
|
917
1207
|
export default createContrastRule({
|
|
@@ -31,6 +31,15 @@ export default {
|
|
|
31
31
|
// Text rendered at font-size 0 (icon buttons whose value is a
|
|
32
32
|
// screen-reader label) is not visually presented — nothing to judge.
|
|
33
33
|
if (parseFloat(style.fontSize) === 0) return { status: 'pass' };
|
|
34
|
+
// A submit, button or reset input whose value attribute is present and
|
|
35
|
+
// empty renders no label at all (HTML: the button's label is its value
|
|
36
|
+
// when the attribute is present), the icon-button-with-aria-label
|
|
37
|
+
// pattern. No glyph is presented, so there is nothing for 1.4.3 to
|
|
38
|
+
// judge (2026-08-25 overnight audit).
|
|
39
|
+
if (/^(submit|button|reset)$/i.test(element.getAttribute('type') ?? '')
|
|
40
|
+
&& element.hasAttribute('value') && element.getAttribute('value') === '') {
|
|
41
|
+
return { status: 'pass' };
|
|
42
|
+
}
|
|
34
43
|
// Controls parked wholly left of or above the document are unreachable
|
|
35
44
|
// by scrolling (sr-only duplicates): same exemption as the main rule.
|
|
36
45
|
{
|
|
@@ -71,12 +80,13 @@ export default {
|
|
|
71
80
|
}
|
|
72
81
|
const required = isLargeText(style) ? 3 : 4.5;
|
|
73
82
|
|
|
74
|
-
const judge = (color, what) => {
|
|
83
|
+
const judge = (color, what, ownOpacity = 1) => {
|
|
75
84
|
const parsed = parseColor(color);
|
|
76
85
|
if (!parsed || parsed.a === 0) return null;
|
|
77
86
|
// Same treatment the main rule gives faded text: what reaches the eye
|
|
78
87
|
// is the declared colour thinned by the opacity it is painted at.
|
|
79
|
-
const
|
|
88
|
+
const painted = opacity * ownOpacity;
|
|
89
|
+
const faded = painted < 1 ? { ...parsed, a: parsed.a * painted } : parsed;
|
|
80
90
|
const fg = faded.a < 1 ? composite(faded, background) : faded;
|
|
81
91
|
const ratio = contrastRatio(fg, background);
|
|
82
92
|
if (ratio >= required) return null;
|
|
@@ -90,9 +100,20 @@ export default {
|
|
|
90
100
|
// computed colour via the ::placeholder pseudo-element.
|
|
91
101
|
if (element.getAttribute('placeholder')?.trim()) {
|
|
92
102
|
let placeholderColor = null;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
103
|
+
let placeholderOpacity = 1;
|
|
104
|
+
try {
|
|
105
|
+
const placeholderStyle = getComputedStyle(element, '::placeholder');
|
|
106
|
+
placeholderColor = placeholderStyle.color;
|
|
107
|
+
// ::placeholder { opacity: .3 } is a widespread idiom (framework
|
|
108
|
+
// resets, Firefox's own UA default of 0.54), and the criterion is on
|
|
109
|
+
// the visual presentation of the text, which is the faded colour.
|
|
110
|
+
// The opacity folds into the colour's alpha exactly as element
|
|
111
|
+
// opacity already does for value text (2026-08-25 overnight audit).
|
|
112
|
+
const parsedOpacity = parseFloat(placeholderStyle.opacity);
|
|
113
|
+
if (Number.isFinite(parsedOpacity)) placeholderOpacity = Math.min(1, Math.max(0, parsedOpacity));
|
|
114
|
+
} catch { /* unsupported */ }
|
|
115
|
+
if (placeholderColor && (placeholderColor !== style.color || placeholderOpacity < 1)) {
|
|
116
|
+
const verdict = judge(placeholderColor, 'placeholder text', placeholderOpacity);
|
|
96
117
|
if (verdict) failures.push(verdict);
|
|
97
118
|
}
|
|
98
119
|
}
|