@govtechsg/oobee 0.10.87 → 0.10.88
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/.github/workflows/docker-push-ghcr.yml +49 -0
- package/DETAILS_OUTPUT_EXAMPLES.md +178 -0
- package/Dockerfile +6 -7
- package/dist/combine.js +1 -0
- package/dist/crawlers/commonCrawlerFunc.js +523 -2
- package/dist/crawlers/crawlLocalFile.js +2 -2
- package/dist/crawlers/custom/extractAndGradeText.js +1 -1
- package/dist/crawlers/custom/getAxeConfiguration.js +26 -21
- package/dist/crawlers/custom/gradeReadability.js +1 -1
- package/dist/npmIndex.js +16 -12
- package/dist/screenshotFunc/htmlScreenshotFunc.js +67 -0
- package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +7 -4
- package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +7 -4
- package/dist/static/ejs/partials/scripts/ruleModal/utilities.ejs +2 -1
- package/examples/oobee-test-details-runner.js +214 -0
- package/examples/test-violations.html +42 -0
- package/package.json +1 -1
- package/src/combine.ts +1 -0
- package/src/crawlers/commonCrawlerFunc.ts +625 -2
- package/src/crawlers/crawlLocalFile.ts +4 -1
- package/src/crawlers/custom/extractAndGradeText.ts +1 -1
- package/src/crawlers/custom/getAxeConfiguration.ts +25 -23
- package/src/crawlers/custom/gradeReadability.ts +1 -1
- package/src/npmIndex.ts +17 -12
- package/src/screenshotFunc/htmlScreenshotFunc.ts +81 -1
- package/src/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +7 -4
- package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +7 -4
- package/src/static/ejs/partials/scripts/ruleModal/utilities.ejs +2 -1
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
saflyIconSelector,
|
|
10
10
|
} from '../constants/constants.js';
|
|
11
11
|
import { consoleLogger, guiInfoLog, silentLogger } from '../logs.js';
|
|
12
|
-
import { takeScreenshotForHTMLElements } from '../screenshotFunc/htmlScreenshotFunc.js';
|
|
12
|
+
import { enrichColorContrastDOMContext, takeScreenshotForHTMLElements } from '../screenshotFunc/htmlScreenshotFunc.js';
|
|
13
13
|
import { isFilePath } from '../constants/common.js';
|
|
14
14
|
import { extractAndGradeText } from './custom/extractAndGradeText.js';
|
|
15
15
|
import { ItemsInfo } from '../mergeAxeResults.js';
|
|
@@ -36,8 +36,30 @@ export interface ResultWithScreenshot extends Result {
|
|
|
36
36
|
nodes: NodeResultWithScreenshot[];
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export type ContrastDOMContext = {
|
|
40
|
+
/** Raw computed background-image value on the element itself (empty string if none). */
|
|
41
|
+
backgroundImage: string;
|
|
42
|
+
/** True when the element's own background-image contains a CSS gradient. */
|
|
43
|
+
hasGradient: boolean;
|
|
44
|
+
/** True when the element's own background-image is a url() image (not a gradient). */
|
|
45
|
+
hasBackgroundImage: boolean;
|
|
46
|
+
/** True when any ancestor up to <body> has a gradient background. */
|
|
47
|
+
ancestorHasGradient: boolean;
|
|
48
|
+
/** True when any ancestor up to <body> has a url() image background. */
|
|
49
|
+
ancestorHasBackgroundImage: boolean;
|
|
50
|
+
/** True when the element or any ancestor has computed opacity < 1. */
|
|
51
|
+
hasReducedOpacity: boolean;
|
|
52
|
+
/** Non-null when the element's mix-blend-mode is not 'normal'. */
|
|
53
|
+
mixBlendMode: string | null;
|
|
54
|
+
/** Non-null when a backdrop-filter is applied to the element. */
|
|
55
|
+
backdropFilter: string | null;
|
|
56
|
+
/** Non-null when a CSS filter (e.g. brightness, contrast) is applied to the element. */
|
|
57
|
+
filter: string | null;
|
|
58
|
+
};
|
|
59
|
+
|
|
39
60
|
export interface NodeResultWithScreenshot extends NodeResult {
|
|
40
61
|
screenshotPath?: string;
|
|
62
|
+
contrastDOMContext?: ContrastDOMContext;
|
|
41
63
|
}
|
|
42
64
|
|
|
43
65
|
type RuleDetails = {
|
|
@@ -60,6 +82,24 @@ type CustomFlowDetails = {
|
|
|
60
82
|
pageImagePath?: any;
|
|
61
83
|
};
|
|
62
84
|
|
|
85
|
+
type ContrastCheckData = {
|
|
86
|
+
fgColor?: string;
|
|
87
|
+
bgColor?: string;
|
|
88
|
+
contrastRatio?: string | number;
|
|
89
|
+
fontSize?: string;
|
|
90
|
+
fontWeight?: string;
|
|
91
|
+
expectedContrastRatio?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
type ContrastExample = {
|
|
95
|
+
fgColor: string;
|
|
96
|
+
bgColor: string;
|
|
97
|
+
contrastRatio: string;
|
|
98
|
+
fontSize: string;
|
|
99
|
+
fontWeight: string;
|
|
100
|
+
expectedContrastRatio: string;
|
|
101
|
+
};
|
|
102
|
+
|
|
63
103
|
type FilteredResults = {
|
|
64
104
|
url: string;
|
|
65
105
|
pageTitle: string;
|
|
@@ -98,6 +138,582 @@ const truncateHtml = (html: string, maxBytes = 1024, suffix = '…'): string =>
|
|
|
98
138
|
return result;
|
|
99
139
|
};
|
|
100
140
|
|
|
141
|
+
const formatContrastFontSize = (fontSize?: string) => {
|
|
142
|
+
if (!fontSize) return 'unknown size';
|
|
143
|
+
|
|
144
|
+
const pxMatch = fontSize.match(/\(([\d.]+px)\)/i);
|
|
145
|
+
return pxMatch ? pxMatch[1] : fontSize;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parses a CSS color string into an [R, G, B] tuple (values 0–255).
|
|
150
|
+
*
|
|
151
|
+
* axe-core serialises colours via its internal `Color.toHexString()`, which
|
|
152
|
+
* always produces lowercase 6-digit hex (#rrggbb). The parser also accepts
|
|
153
|
+
* 3-digit hex (#rgb) and functional rgb() notation for robustness.
|
|
154
|
+
*
|
|
155
|
+
* Returns null if the string does not match any supported format.
|
|
156
|
+
*/
|
|
157
|
+
const parseColor = (color: string): [number, number, number] | null => {
|
|
158
|
+
const hex6 = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
159
|
+
if (hex6) return [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
|
|
160
|
+
|
|
161
|
+
const hex3 = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
|
|
162
|
+
if (hex3)
|
|
163
|
+
return [
|
|
164
|
+
parseInt(hex3[1] + hex3[1], 16),
|
|
165
|
+
parseInt(hex3[2] + hex3[2], 16),
|
|
166
|
+
parseInt(hex3[3] + hex3[3], 16),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const rgb = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
|
|
170
|
+
if (rgb) return [parseInt(rgb[1]), parseInt(rgb[2]), parseInt(rgb[3])];
|
|
171
|
+
|
|
172
|
+
return null;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Computes the WCAG 2.x relative luminance of an sRGB colour.
|
|
177
|
+
*
|
|
178
|
+
* Algorithm (WCAG 2.1 §1.4.3 / IEC 61966-2-1 sRGB standard):
|
|
179
|
+
* 1. Normalise each 8-bit channel to [0, 1] by dividing by 255.
|
|
180
|
+
* 2. Gamma-expand (linearise) each normalised channel:
|
|
181
|
+
* if sRGB ≤ 0.04045 → linear = sRGB / 12.92
|
|
182
|
+
* else → linear = ((sRGB + 0.055) / 1.055) ^ 2.4
|
|
183
|
+
* The threshold 0.04045 and the slope 1/12.92 describe the near-black
|
|
184
|
+
* linear segment of the sRGB electro-optical transfer function (EOTF).
|
|
185
|
+
* The power 2.4 (≈ gamma 2.2) and the offset 0.055 handle the rest.
|
|
186
|
+
* 3. Weight the linear channels by the CIE 1931 XYZ D65 Y-row coefficients
|
|
187
|
+
* projected onto the sRGB primaries:
|
|
188
|
+
* L = 0.2126 R_lin + 0.7152 G_lin + 0.0722 B_lin
|
|
189
|
+
* These weights reflect human eye sensitivity: the eye is most sensitive
|
|
190
|
+
* to green, moderately to red, and least to blue.
|
|
191
|
+
*
|
|
192
|
+
* Returns a value in [0, 1]: 0 = absolute black, 1 = perfect white.
|
|
193
|
+
*/
|
|
194
|
+
const relativeLuminance = (r: number, g: number, b: number): number => {
|
|
195
|
+
const linearise = (c: number) => {
|
|
196
|
+
const s = c / 255;
|
|
197
|
+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
198
|
+
};
|
|
199
|
+
return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Computes the WCAG 2.x contrast ratio between two relative luminances.
|
|
204
|
+
*
|
|
205
|
+
* Formula: (L_lighter + 0.05) / (L_darker + 0.05)
|
|
206
|
+
*
|
|
207
|
+
* The additive offset 0.05 models the luminance of ambient flare (reflected
|
|
208
|
+
* light) assumed in a reference viewing environment. It prevents the ratio
|
|
209
|
+
* from reaching infinity for pure black and anchors the scale so that
|
|
210
|
+
* white-on-black yields exactly 21:1. The lighter luminance is always placed
|
|
211
|
+
* in the numerator so the result is always ≥ 1.
|
|
212
|
+
*
|
|
213
|
+
* WCAG thresholds:
|
|
214
|
+
* AA normal text ≥ 4.5:1 | large text ≥ 3:1
|
|
215
|
+
* AAA normal text ≥ 7:1 | large text ≥ 4.5:1
|
|
216
|
+
*/
|
|
217
|
+
const wcagContrastRatio = (l1: number, l2: number): number => {
|
|
218
|
+
const lighter = Math.max(l1, l2);
|
|
219
|
+
const darker = Math.min(l1, l2);
|
|
220
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Converts an sRGB colour to HSL cylindrical coordinates.
|
|
225
|
+
* H (hue) ∈ [0°, 360°)
|
|
226
|
+
* S (saturation) ∈ [0, 1]
|
|
227
|
+
* L (lightness) ∈ [0, 1]
|
|
228
|
+
*
|
|
229
|
+
* We work in HSL because adjusting L alone changes perceived brightness while
|
|
230
|
+
* preserving the hue and saturation of the original brand colour — the
|
|
231
|
+
* smallest perceptible change needed to satisfy the contrast requirement.
|
|
232
|
+
* Achromatic colours (R = G = B) return H = 0, S = 0.
|
|
233
|
+
*/
|
|
234
|
+
const rgbToHsl = (r: number, g: number, b: number): [number, number, number] => {
|
|
235
|
+
const R = r / 255,
|
|
236
|
+
G = g / 255,
|
|
237
|
+
B = b / 255;
|
|
238
|
+
const max = Math.max(R, G, B),
|
|
239
|
+
min = Math.min(R, G, B);
|
|
240
|
+
const l = (max + min) / 2;
|
|
241
|
+
if (max === min) return [0, 0, l];
|
|
242
|
+
const d = max - min;
|
|
243
|
+
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
244
|
+
let h: number;
|
|
245
|
+
if (max === R) h = ((G - B) / d + (G < B ? 6 : 0)) / 6;
|
|
246
|
+
else if (max === G) h = ((B - R) / d + 2) / 6;
|
|
247
|
+
else h = ((R - G) / d + 4) / 6;
|
|
248
|
+
return [h * 360, s, l];
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Converts HSL back to sRGB (inverse of rgbToHsl).
|
|
253
|
+
*
|
|
254
|
+
* Uses the standard two-step piecewise-linear hue reconstruction:
|
|
255
|
+
* q = L < 0.5 ? L(1+S) : L+S−L·S (upper chroma boundary)
|
|
256
|
+
* p = 2L − q (lower chroma boundary)
|
|
257
|
+
* Each R, G, B channel is obtained by evaluating a piecewise hue function
|
|
258
|
+
* with offsets of +1/3 (120°) and −1/3 (−120°) for R and B respectively.
|
|
259
|
+
*/
|
|
260
|
+
const hslToRgb = (h: number, s: number, l: number): [number, number, number] => {
|
|
261
|
+
h = h / 360;
|
|
262
|
+
if (s === 0) {
|
|
263
|
+
const v = Math.round(l * 255);
|
|
264
|
+
return [v, v, v];
|
|
265
|
+
}
|
|
266
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
267
|
+
const p = 2 * l - q;
|
|
268
|
+
const hue2rgb = (p: number, q: number, t: number): number => {
|
|
269
|
+
if (t < 0) t += 1;
|
|
270
|
+
if (t > 1) t -= 1;
|
|
271
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
272
|
+
if (t < 1 / 2) return q;
|
|
273
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
274
|
+
return p;
|
|
275
|
+
};
|
|
276
|
+
return [
|
|
277
|
+
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
|
278
|
+
Math.round(hue2rgb(p, q, h) * 255),
|
|
279
|
+
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
|
280
|
+
];
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** Converts an [R, G, B] tuple (0–255) to a lowercase 6-digit hex string. */
|
|
284
|
+
const rgbToHex = (r: number, g: number, b: number): string =>
|
|
285
|
+
'#' +
|
|
286
|
+
[r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Finds the lightness-adjusted version of `adjustRgb` that is as close as
|
|
290
|
+
* possible to the original colour while achieving at least `requiredRatio`
|
|
291
|
+
* contrast against `fixedRgb`.
|
|
292
|
+
*
|
|
293
|
+
* Strategy — binary search on HSL lightness L ∈ [0, 1]:
|
|
294
|
+
* direction = 'darker': searches L ∈ [0, original_L] for the MAXIMUM L
|
|
295
|
+
* (least dark, closest to original) at which contrast ≥ required.
|
|
296
|
+
* direction = 'lighter': searches L ∈ [original_L, 1] for the MINIMUM L
|
|
297
|
+
* (least light, closest to original) at which contrast ≥ required.
|
|
298
|
+
*
|
|
299
|
+
* Hue (H) and saturation (S) are held constant so the result stays as close
|
|
300
|
+
* to the original brand/design colour as possible.
|
|
301
|
+
*
|
|
302
|
+
* 30 iterations yield a lightness precision of 1/2^30 ≈ 10⁻⁹, well below
|
|
303
|
+
* the 1/255 ≈ 0.004 resolution of 8-bit channels, so the result is
|
|
304
|
+
* effectively exact at 8-bit depth.
|
|
305
|
+
*
|
|
306
|
+
* Returns null if even the extreme value for this direction (pure black at
|
|
307
|
+
* L = 0 or pure white at L = 1 for the given H and S) cannot achieve the
|
|
308
|
+
* required ratio — an edge case that only arises for very low required ratios
|
|
309
|
+
* or highly chromatic near-neutral colours.
|
|
310
|
+
*/
|
|
311
|
+
const findCompliantColorByLightness = (
|
|
312
|
+
adjustRgb: [number, number, number],
|
|
313
|
+
fixedRgb: [number, number, number],
|
|
314
|
+
requiredRatio: number,
|
|
315
|
+
direction: 'darker' | 'lighter',
|
|
316
|
+
): [number, number, number] | null => {
|
|
317
|
+
const fixedLum = relativeLuminance(...fixedRgb);
|
|
318
|
+
const [h, s, origHslL] = rgbToHsl(...adjustRgb);
|
|
319
|
+
|
|
320
|
+
// Verify the extreme value in this direction is sufficient at all.
|
|
321
|
+
const extremeHslL = direction === 'darker' ? 0 : 1;
|
|
322
|
+
const extremeRgb = hslToRgb(h, s, extremeHslL);
|
|
323
|
+
if (wcagContrastRatio(fixedLum, relativeLuminance(...extremeRgb)) < requiredRatio) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let lo = direction === 'darker' ? 0 : origHslL;
|
|
328
|
+
let hi = direction === 'darker' ? origHslL : 1;
|
|
329
|
+
let best = extremeHslL; // guaranteed-passing extreme
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < 30; i++) {
|
|
332
|
+
const mid = (lo + hi) / 2;
|
|
333
|
+
const midRgb = hslToRgb(h, s, mid);
|
|
334
|
+
const passes = wcagContrastRatio(fixedLum, relativeLuminance(...midRgb)) >= requiredRatio;
|
|
335
|
+
|
|
336
|
+
if (passes) {
|
|
337
|
+
best = mid; // This midpoint works; try to get even closer to the original.
|
|
338
|
+
if (direction === 'darker') lo = mid; // Can we raise L further (less dark)?
|
|
339
|
+
else hi = mid; // Can we lower L further (less light)?
|
|
340
|
+
} else {
|
|
341
|
+
if (direction === 'darker') hi = mid; // Too light; go darker.
|
|
342
|
+
else lo = mid; // Too dark; go lighter.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return hslToRgb(h, s, best);
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Builds a human-readable recommendation for a single failing contrast pair.
|
|
351
|
+
*
|
|
352
|
+
* WCAG 1.4.3 "Contrast (Minimum)" defines two situations based on font size
|
|
353
|
+
* and weight (1 pt = 1.333 px at 96 dpi):
|
|
354
|
+
*
|
|
355
|
+
* Situation A — normal text (< 18 pt non-bold / < 14 pt bold,
|
|
356
|
+
* i.e. < ~24 px / < ~18.5 px):
|
|
357
|
+
* Required contrast ≥ 4.5:1 (G18)
|
|
358
|
+
*
|
|
359
|
+
* Situation B — large text (≥ 18 pt non-bold / ≥ 14 pt bold,
|
|
360
|
+
* i.e. ≥ ~24 px / ≥ ~18.5 px):
|
|
361
|
+
* Required contrast ≥ 3:1 (G145)
|
|
362
|
+
*
|
|
363
|
+
* axe-core applies this rule upstream using:
|
|
364
|
+
* ptSize = Math.ceil(fontSize_px × 72) / 96 // px → pt
|
|
365
|
+
* isSmallFont = (bold && ptSize < boldTextPt) // default 14 pt
|
|
366
|
+
* || (!bold && ptSize < largeTextPt) // default 18 pt
|
|
367
|
+
* bold = fontWeight ≥ 700 || fontWeight === 'bold' // boldValue = 700
|
|
368
|
+
* and stores the result as `expectedContrastRatio: "4.5:1"` (Situation A) or
|
|
369
|
+
* `"3:1"` (Situation B). The `required` value used here is derived from that
|
|
370
|
+
* field via `parseFloat("4.5:1") → 4.5` or `parseFloat("3:1") → 3`, so the
|
|
371
|
+
* binary search automatically targets the correct threshold for each
|
|
372
|
+
* combination's font size and weight — no re-classification is needed here.
|
|
373
|
+
*
|
|
374
|
+
* WCAG 1.4.3 exceptions (no contrast requirement — filtered by axe-core
|
|
375
|
+
* before the data ever reaches this function):
|
|
376
|
+
* • Pure decoration (no informational purpose, rearrangeable/substitutable)
|
|
377
|
+
* • Inactive / disabled user-interface components
|
|
378
|
+
* • Logotypes and brand names
|
|
379
|
+
* • Text inside photographs or images with significant other visual content
|
|
380
|
+
*
|
|
381
|
+
* Algorithm for each failing pair:
|
|
382
|
+
* 1. Parse both colours to [R, G, B] (axe-core supplies #rrggbb hex).
|
|
383
|
+
* 2. Convert each colour to HSL. Search for the nearest compliant
|
|
384
|
+
* foreground by binary-searching HSL lightness L in both the 'darker'
|
|
385
|
+
* and 'lighter' directions while keeping H and S constant (preserves
|
|
386
|
+
* hue/saturation of the original brand colour).
|
|
387
|
+
* 3. Repeat step 2 for the background (holding the foreground fixed).
|
|
388
|
+
* 4. For each colour role, pick whichever direction (darker/lighter)
|
|
389
|
+
* requires the smaller change in L — i.e. the least visually
|
|
390
|
+
* disruptive compliant alternative.
|
|
391
|
+
* 5. Report both the adjusted foreground and the adjusted background
|
|
392
|
+
* (hex + rgb) so developers can choose whichever fits their design
|
|
393
|
+
* system. The target ratio is included so the output is unambiguous
|
|
394
|
+
* when a page contains a mix of normal-text (4.5:1) and large-text
|
|
395
|
+
* (3:1) failures.
|
|
396
|
+
*
|
|
397
|
+
* Returns null if the colours cannot be parsed or no compliant alternative
|
|
398
|
+
* can be found (extremely rare; only arises when the colour is already at
|
|
399
|
+
* the luminance extreme for its hue/saturation).
|
|
400
|
+
*/
|
|
401
|
+
const buildContrastRecommendation = (example: ContrastExample): string | null => {
|
|
402
|
+
const fgRgb = parseColor(example.fgColor);
|
|
403
|
+
const bgRgb = parseColor(example.bgColor);
|
|
404
|
+
if (!fgRgb || !bgRgb) return null;
|
|
405
|
+
|
|
406
|
+
// parseFloat handles "4.5:1" → 4.5 because it stops at the non-numeric ':'.
|
|
407
|
+
// The value is either 4.5 (Situation A, normal text) or 3 (Situation B,
|
|
408
|
+
// large text), as determined by axe-core from the element's font metrics.
|
|
409
|
+
const CONTRAST_BUFFER = 1.05;
|
|
410
|
+
const required = parseFloat(example.expectedContrastRatio) * CONTRAST_BUFFER;
|
|
411
|
+
if (isNaN(required)) return null;
|
|
412
|
+
|
|
413
|
+
// Find the nearest compliant foreground (try both directions, pick closest).
|
|
414
|
+
const fgDarker = findCompliantColorByLightness(fgRgb, bgRgb, required, 'darker');
|
|
415
|
+
const fgLighter = findCompliantColorByLightness(fgRgb, bgRgb, required, 'lighter');
|
|
416
|
+
const [, , origFgHslL] = rgbToHsl(...fgRgb);
|
|
417
|
+
let recFg: [number, number, number] | null = null;
|
|
418
|
+
if (fgDarker && fgLighter) {
|
|
419
|
+
const [, , dL] = rgbToHsl(...fgDarker);
|
|
420
|
+
const [, , lL] = rgbToHsl(...fgLighter);
|
|
421
|
+
recFg = Math.abs(dL - origFgHslL) <= Math.abs(lL - origFgHslL) ? fgDarker : fgLighter;
|
|
422
|
+
} else {
|
|
423
|
+
recFg = fgDarker ?? fgLighter;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Find the nearest compliant background (try both directions, pick closest).
|
|
427
|
+
const bgDarker = findCompliantColorByLightness(bgRgb, fgRgb, required, 'darker');
|
|
428
|
+
const bgLighter = findCompliantColorByLightness(bgRgb, fgRgb, required, 'lighter');
|
|
429
|
+
const [, , origBgHslL] = rgbToHsl(...bgRgb);
|
|
430
|
+
let recBg: [number, number, number] | null = null;
|
|
431
|
+
if (bgDarker && bgLighter) {
|
|
432
|
+
const [, , dL] = rgbToHsl(...bgDarker);
|
|
433
|
+
const [, , lL] = rgbToHsl(...bgLighter);
|
|
434
|
+
recBg = Math.abs(dL - origBgHslL) <= Math.abs(lL - origBgHslL) ? bgDarker : bgLighter;
|
|
435
|
+
} else {
|
|
436
|
+
recBg = bgDarker ?? bgLighter;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (!recFg && !recBg) return null;
|
|
440
|
+
|
|
441
|
+
const parts: string[] = [];
|
|
442
|
+
if (recFg) {
|
|
443
|
+
const [rr, gg, bb] = recFg;
|
|
444
|
+
parts.push(`foreground text color to ${rgbToHex(rr, gg, bb)} (rgb(${rr}, ${gg}, ${bb}))`);
|
|
445
|
+
}
|
|
446
|
+
if (recBg) {
|
|
447
|
+
const [rr, gg, bb] = recBg;
|
|
448
|
+
parts.push(`background to ${rgbToHex(rr, gg, bb)} (rgb(${rr}, ${gg}, ${bb}))`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Include the target ratio in the string so the message is unambiguous when
|
|
452
|
+
// a single element has a mix of normal-text (4.5:1) and large-text (3:1)
|
|
453
|
+
// failing combinations with different required thresholds.
|
|
454
|
+
return `${parts.join(' or ')}`;
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Builds the augmented issue description for a single axe-core color-contrast
|
|
459
|
+
* violation node.
|
|
460
|
+
*
|
|
461
|
+
* ─── WCAG rules applied ──────────────────────────────────────────────────────
|
|
462
|
+
*
|
|
463
|
+
* WCAG 1.4.3 "Contrast (Minimum)" distinguishes two situations:
|
|
464
|
+
*
|
|
465
|
+
* Situation A — normal text (< 18 pt non-bold / < 14 pt bold,
|
|
466
|
+
* i.e. < ~24 px / < ~18.5 px at 96 dpi):
|
|
467
|
+
* Required contrast ratio ≥ 4.5:1
|
|
468
|
+
*
|
|
469
|
+
* Situation B — large text (≥ 18 pt non-bold / ≥ 14 pt bold,
|
|
470
|
+
* i.e. ≥ ~24 px / ≥ ~18.5 px at 96 dpi):
|
|
471
|
+
* Required contrast ratio ≥ 3:1
|
|
472
|
+
*
|
|
473
|
+
* axe-core classifies each element before this function runs:
|
|
474
|
+
* ptSize = Math.ceil(fontSize_px × 72) / 96 // px → pt
|
|
475
|
+
* isSmallFont = (bold && ptSize < 14) // Situation A bold
|
|
476
|
+
* || (!bold && ptSize < 18) // Situation A normal
|
|
477
|
+
* bold = fontWeight ≥ 700 || fontWeight === 'bold'
|
|
478
|
+
* …and stores the result in check.data.expectedContrastRatio as "4.5:1" or "3:1".
|
|
479
|
+
*
|
|
480
|
+
* Exceptions (no contrast requirement — already excluded by axe-core upstream):
|
|
481
|
+
* • Pure decoration (no informational purpose)
|
|
482
|
+
* • Inactive / disabled UI components
|
|
483
|
+
* • Logotypes and brand names
|
|
484
|
+
* • Text inside photographs with significant other visual content
|
|
485
|
+
*
|
|
486
|
+
* ─── Function flow ───────────────────────────────────────────────────────────
|
|
487
|
+
*
|
|
488
|
+
* 1. Collect checks — flatten node.any, node.all, node.none into one array.
|
|
489
|
+
* Each entry may carry a ContrastCheckData payload with fgColor, bgColor,
|
|
490
|
+
* contrastRatio, fontSize, fontWeight, and expectedContrastRatio.
|
|
491
|
+
*
|
|
492
|
+
* 2. Deduplicate — key each combination on
|
|
493
|
+
* [fgColor, bgColor, fontSize, fontWeight, expectedContrastRatio].
|
|
494
|
+
* A single DOM node can generate multiple identical checks; the Map ensures
|
|
495
|
+
* each distinct failing pair is reported once.
|
|
496
|
+
*
|
|
497
|
+
* 3. Build the base message — lists every unique failing combination with its
|
|
498
|
+
* current contrast ratio and the required ratio, and instructs the developer
|
|
499
|
+
* to fix all failing text in the component, not just the first element.
|
|
500
|
+
*
|
|
501
|
+
* 4. Build per-combo recommendations (via buildContrastRecommendation) —
|
|
502
|
+
* for each failing pair, binary-search HSL lightness to find the nearest
|
|
503
|
+
* compliant foreground (background fixed) and the nearest compliant
|
|
504
|
+
* background (foreground fixed), both expressed as hex + rgb(). The binary
|
|
505
|
+
* search targets the combination's own expectedContrastRatio (4.5 or 3),
|
|
506
|
+
* so large-text recommendations are correctly held to the 3:1 threshold
|
|
507
|
+
* and normal-text recommendations to 4.5:1.
|
|
508
|
+
*
|
|
509
|
+
* 5. Concatenate — append "Recommendation: …" after the base message so the
|
|
510
|
+
* existing description is never modified, only extended.
|
|
511
|
+
*
|
|
512
|
+
* Returns null when no check carries usable contrast data (axe-core may omit
|
|
513
|
+
* it for pseudo-element or out-of-viewport cases).
|
|
514
|
+
*/
|
|
515
|
+
const buildColorContrastMessage = (node: NodeResultWithScreenshot): string | null => {
|
|
516
|
+
const checks = [...(node.any || []), ...(node.all || []), ...(node.none || [])] as Array<{
|
|
517
|
+
data?: ContrastCheckData;
|
|
518
|
+
}>;
|
|
519
|
+
|
|
520
|
+
const uniqueCombos = new Map<string, ContrastExample>();
|
|
521
|
+
|
|
522
|
+
checks.forEach(check => {
|
|
523
|
+
const data = check.data || {};
|
|
524
|
+
const hasContrastData =
|
|
525
|
+
data.fgColor ||
|
|
526
|
+
data.bgColor ||
|
|
527
|
+
data.contrastRatio !== undefined ||
|
|
528
|
+
data.expectedContrastRatio;
|
|
529
|
+
|
|
530
|
+
if (!hasContrastData) return;
|
|
531
|
+
|
|
532
|
+
const fgColor = data.fgColor || 'unknown foreground';
|
|
533
|
+
const bgColor = data.bgColor || 'unknown background';
|
|
534
|
+
const contrastRatio = String(data.contrastRatio ?? 'unknown');
|
|
535
|
+
const fontSize = formatContrastFontSize(data.fontSize);
|
|
536
|
+
const fontWeight = data.fontWeight || 'normal';
|
|
537
|
+
const expectedContrastRatio = data.expectedContrastRatio || '4.5:1';
|
|
538
|
+
|
|
539
|
+
const key = [fgColor, bgColor, fontSize, fontWeight, expectedContrastRatio].join('|');
|
|
540
|
+
|
|
541
|
+
if (!uniqueCombos.has(key)) {
|
|
542
|
+
uniqueCombos.set(key, {
|
|
543
|
+
fgColor,
|
|
544
|
+
bgColor,
|
|
545
|
+
contrastRatio,
|
|
546
|
+
fontSize,
|
|
547
|
+
fontWeight,
|
|
548
|
+
expectedContrastRatio,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
if (!uniqueCombos.size) return null;
|
|
554
|
+
|
|
555
|
+
const combos = [...uniqueCombos.values()];
|
|
556
|
+
|
|
557
|
+
const examples = combos
|
|
558
|
+
.map(
|
|
559
|
+
example =>
|
|
560
|
+
`foreground ${example.fgColor} on ${example.bgColor} at ${example.fontSize} ${example.fontWeight === 'bold' ? 'bold' : 'regular'} text`,
|
|
561
|
+
)
|
|
562
|
+
.join(', and ');
|
|
563
|
+
|
|
564
|
+
const targetRatio = combos[0]?.expectedContrastRatio || '4.5:1';
|
|
565
|
+
const currentRatio = combos[0]?.contrastRatio || 'unknown';
|
|
566
|
+
|
|
567
|
+
const base = `Multiple text elements in this component fail WCAG 1.4.3 Color Contrast Minimum.\n Normal text should meet or exceed ${targetRatio} contrast ratio against its actual background.\n The current text contrast ratio of ${currentRatio} does not meet requirements. Failing combinations in this snippet include ${examples}.`;
|
|
568
|
+
|
|
569
|
+
const recommendations = combos
|
|
570
|
+
.map(buildContrastRecommendation)
|
|
571
|
+
.filter((r): r is string => r !== null);
|
|
572
|
+
|
|
573
|
+
const recSection =
|
|
574
|
+
recommendations.length > 0
|
|
575
|
+
? `\n Recommendation: Adjust ${recommendations.join('; ')}.`
|
|
576
|
+
: '';
|
|
577
|
+
|
|
578
|
+
const ctx = node.contrastDOMContext;
|
|
579
|
+
if (!ctx) return `${base}${recSection}`;
|
|
580
|
+
|
|
581
|
+
const notes: string[] = [];
|
|
582
|
+
|
|
583
|
+
if (ctx.hasGradient) {
|
|
584
|
+
notes.push(
|
|
585
|
+
`gradient background detected (${ctx.backgroundImage}): the sampled background color represents a single point — verify contrast at every gradient stop and position where text appears, then adjust the gradient stops or add a solid color fallback behind the text`,
|
|
586
|
+
);
|
|
587
|
+
} else if (ctx.ancestorHasGradient) {
|
|
588
|
+
notes.push(
|
|
589
|
+
`an ancestor provides a gradient background: the sampled background color may not match what is visually beneath the text — verify contrast against the actual rendered gradient`,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (ctx.hasBackgroundImage) {
|
|
594
|
+
notes.push(
|
|
595
|
+
`background image detected: contrast cannot be fully determined from a sampled color alone — ensure text remains readable across all image content and states`,
|
|
596
|
+
);
|
|
597
|
+
} else if (ctx.ancestorHasBackgroundImage) {
|
|
598
|
+
notes.push(
|
|
599
|
+
`an ancestor has a background image: the effective background under this text may differ from the sampled value`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (ctx.hasReducedOpacity) {
|
|
604
|
+
notes.push(
|
|
605
|
+
`opacity less than 1 detected on this element or an ancestor: the rendered contrast is lower than the computed color values indicate`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (ctx.mixBlendMode) {
|
|
610
|
+
notes.push(
|
|
611
|
+
`mix-blend-mode: ${ctx.mixBlendMode} is applied: actual rendered colors depend on the underlying layers`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (ctx.backdropFilter) {
|
|
616
|
+
notes.push(`backdrop-filter: ${ctx.backdropFilter} is applied: the effective background appearance is modified`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (ctx.filter) {
|
|
620
|
+
notes.push(
|
|
621
|
+
`CSS filter: ${ctx.filter} is applied to this element: rendered colors may differ from computed values`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const ctxSection =
|
|
626
|
+
notes.length > 0
|
|
627
|
+
? `\n Rendering complexity: ${notes.join('; ')}.\n The color fix recommendations above may not be accurate for this element — manual verification of the actual rendered contrast is strongly advised.`
|
|
628
|
+
: '';
|
|
629
|
+
|
|
630
|
+
return `${base}${recSection}${ctxSection}`;
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// Enriches axe violation failureSummaries with additional DOM context gathered via Playwright,
|
|
634
|
+
// providing LLMs with the specific details they need to apply correct fixes.
|
|
635
|
+
export const enrichViolationMessages = async (results: AxeResults, page: Page): Promise<void> => {
|
|
636
|
+
for (const violation of results.violations) {
|
|
637
|
+
if (violation.id !== 'target-size' && violation.id !== 'valid-lang') continue;
|
|
638
|
+
|
|
639
|
+
for (const node of violation.nodes) {
|
|
640
|
+
const cssSelector =
|
|
641
|
+
node.target.length === 1 && typeof node.target[0] === 'string' ? node.target[0] : null;
|
|
642
|
+
if (!cssSelector) continue;
|
|
643
|
+
|
|
644
|
+
if (violation.id === 'target-size') {
|
|
645
|
+
const ctx = await page
|
|
646
|
+
.evaluate((sel: string) => {
|
|
647
|
+
try {
|
|
648
|
+
const el = document.querySelector(sel) as HTMLElement | null;
|
|
649
|
+
if (!el) return null;
|
|
650
|
+
const rect = el.getBoundingClientRect();
|
|
651
|
+
const computed = window.getComputedStyle(el);
|
|
652
|
+
return {
|
|
653
|
+
renderedWidth: Math.round(rect.width),
|
|
654
|
+
renderedHeight: Math.round(rect.height),
|
|
655
|
+
boxSizing: computed.boxSizing,
|
|
656
|
+
inlineWidth: el.style.width || null,
|
|
657
|
+
inlineHeight: el.style.height || null,
|
|
658
|
+
tagName: el.tagName.toLowerCase(),
|
|
659
|
+
};
|
|
660
|
+
} catch {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
}, cssSelector)
|
|
664
|
+
.catch(() => null);
|
|
665
|
+
|
|
666
|
+
if (ctx) {
|
|
667
|
+
const spacingMatch = node.failureSummary?.match(/diameter of (\d+)px/);
|
|
668
|
+
const spacing = spacingMatch ? spacingMatch[1] : null;
|
|
669
|
+
|
|
670
|
+
let message = `Insufficient target size: ${ctx.renderedWidth}px by ${ctx.renderedHeight}px (box-sizing: ${ctx.boxSizing}).\n Ensure it is at least 24px by 24px.`;
|
|
671
|
+
|
|
672
|
+
if (spacing) {
|
|
673
|
+
message += `\n Target has insufficient space to its adjacent element of ${spacing}px. Ensure it has a safe clickable space of at least 24px.`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (
|
|
677
|
+
ctx.boxSizing === 'border-box' &&
|
|
678
|
+
(ctx.inlineWidth !== null || ctx.inlineHeight !== null)
|
|
679
|
+
) {
|
|
680
|
+
message += `\n Current button style code snippet does not increase the hit area.\n Remove the explicit width/height and use min-width: 24px; min-height: 24px instead.\n Or place the visual content in a child <span> element.`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
node.failureSummary = message;
|
|
684
|
+
}
|
|
685
|
+
} else if (violation.id === 'valid-lang') {
|
|
686
|
+
const ctx = await page
|
|
687
|
+
.evaluate((sel: string) => {
|
|
688
|
+
try {
|
|
689
|
+
const el = document.querySelector(sel);
|
|
690
|
+
if (!el) return null;
|
|
691
|
+
return {
|
|
692
|
+
langValue: el.getAttribute('lang') ?? '',
|
|
693
|
+
textSnippet: (el.textContent ?? '').trim().slice(0, 120),
|
|
694
|
+
};
|
|
695
|
+
} catch {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
}, cssSelector)
|
|
699
|
+
.catch(() => null);
|
|
700
|
+
|
|
701
|
+
if (ctx) {
|
|
702
|
+
let message = `Value of lang attribute is not a valid language.\n Use a registered IANA language code instead of "${ctx.langValue}".`;
|
|
703
|
+
|
|
704
|
+
if (ctx.langValue.startsWith('x-')) {
|
|
705
|
+
message += `\n Axe-core valid-lang rule also rejects private-use subtags.`;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
message += `\n Identify the actual language of this text and use its registered BCP 47 code (e.g., lang="it" Italian, "es" Spanish, "fr" French, "de" German, "zh" Chinese, "ja" Japanese, "ko" Korean, "pt" Portuguese, "ar" Arabic).`;
|
|
709
|
+
|
|
710
|
+
node.failureSummary = message;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
|
|
101
717
|
export const filterAxeResults = (
|
|
102
718
|
results: AxeResultsWithScreenshot,
|
|
103
719
|
pageTitle: string,
|
|
@@ -145,9 +761,13 @@ export const filterAxeResults = (
|
|
|
145
761
|
items: [],
|
|
146
762
|
};
|
|
147
763
|
}
|
|
148
|
-
const
|
|
764
|
+
const defaultMessage = displayNeedsReview
|
|
149
765
|
? failureSummary.slice(failureSummary.indexOf('\n') + 1).trim()
|
|
150
766
|
: failureSummary;
|
|
767
|
+
const message =
|
|
768
|
+
rule === 'color-contrast' || rule === 'color-contrast-enhanced'
|
|
769
|
+
? buildColorContrastMessage(node) || defaultMessage
|
|
770
|
+
: defaultMessage;
|
|
151
771
|
|
|
152
772
|
let finalHtml = html;
|
|
153
773
|
if (html.includes('</script>')) {
|
|
@@ -456,6 +1076,9 @@ export const runAxeScript = async ({
|
|
|
456
1076
|
},
|
|
457
1077
|
);
|
|
458
1078
|
|
|
1079
|
+
await enrichViolationMessages(results, page);
|
|
1080
|
+
await enrichColorContrastDOMContext(results.violations, page);
|
|
1081
|
+
|
|
459
1082
|
if (includeScreenshots) {
|
|
460
1083
|
results.violations = await takeScreenshotForHTMLElements(results.violations, page, randomToken);
|
|
461
1084
|
results.incomplete = await takeScreenshotForHTMLElements(results.incomplete, page, randomToken);
|