@govtechsg/oobee 0.10.87 → 0.10.89

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 (28) hide show
  1. package/.github/workflows/docker-push-ghcr.yml +49 -0
  2. package/DETAILS_OUTPUT_EXAMPLES.md +178 -0
  3. package/Dockerfile +6 -7
  4. package/dist/combine.js +1 -0
  5. package/dist/crawlers/commonCrawlerFunc.js +525 -2
  6. package/dist/crawlers/crawlLocalFile.js +2 -2
  7. package/dist/crawlers/custom/extractAndGradeText.js +1 -1
  8. package/dist/crawlers/custom/getAxeConfiguration.js +26 -21
  9. package/dist/crawlers/custom/gradeReadability.js +1 -1
  10. package/dist/npmIndex.js +16 -12
  11. package/dist/screenshotFunc/htmlScreenshotFunc.js +67 -0
  12. package/dist/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +7 -4
  13. package/dist/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +7 -4
  14. package/dist/static/ejs/partials/scripts/ruleModal/utilities.ejs +2 -1
  15. package/examples/oobee-test-details-runner.js +214 -0
  16. package/examples/test-violations.html +42 -0
  17. package/package.json +1 -1
  18. package/src/combine.ts +1 -0
  19. package/src/crawlers/commonCrawlerFunc.ts +627 -2
  20. package/src/crawlers/crawlLocalFile.ts +4 -1
  21. package/src/crawlers/custom/extractAndGradeText.ts +1 -1
  22. package/src/crawlers/custom/getAxeConfiguration.ts +25 -23
  23. package/src/crawlers/custom/gradeReadability.ts +1 -1
  24. package/src/npmIndex.ts +17 -12
  25. package/src/screenshotFunc/htmlScreenshotFunc.ts +81 -1
  26. package/src/static/ejs/partials/scripts/ruleModal/itemCardRenderer.ejs +7 -4
  27. package/src/static/ejs/partials/scripts/ruleModal/pageAccordionBuilder.ejs +7 -4
  28. package/src/static/ejs/partials/scripts/ruleModal/utilities.ejs +2 -1
@@ -2,7 +2,7 @@ import { Dataset, RequestQueue, log, playwrightUtils } from 'crawlee';
2
2
  import axe from 'axe-core';
3
3
  import { axeScript, disallowedListOfPatterns, guiInfoStatusTypes, RuleFlags, saflyIconSelector, } from '../constants/constants.js';
4
4
  import { consoleLogger, guiInfoLog } from '../logs.js';
5
- import { takeScreenshotForHTMLElements } from '../screenshotFunc/htmlScreenshotFunc.js';
5
+ import { enrichColorContrastDOMContext, takeScreenshotForHTMLElements } from '../screenshotFunc/htmlScreenshotFunc.js';
6
6
  import { isFilePath } from '../constants/common.js';
7
7
  import { extractAndGradeText } from './custom/extractAndGradeText.js';
8
8
  import { evaluateAltText } from './custom/evaluateAltText.js';
@@ -35,6 +35,524 @@ const truncateHtml = (html, maxBytes = 1024, suffix = '…') => {
35
35
  }
36
36
  return result;
37
37
  };
38
+ const formatContrastFontSize = (fontSize) => {
39
+ if (!fontSize)
40
+ return 'unknown size';
41
+ const pxMatch = fontSize.match(/\(([\d.]+px)\)/i);
42
+ return pxMatch ? pxMatch[1] : fontSize;
43
+ };
44
+ /**
45
+ * Parses a CSS color string into an [R, G, B] tuple (values 0–255).
46
+ *
47
+ * axe-core serialises colours via its internal `Color.toHexString()`, which
48
+ * always produces lowercase 6-digit hex (#rrggbb). The parser also accepts
49
+ * 3-digit hex (#rgb) and functional rgb() notation for robustness.
50
+ *
51
+ * Returns null if the string does not match any supported format.
52
+ */
53
+ const parseColor = (color) => {
54
+ const hex6 = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
55
+ if (hex6)
56
+ return [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
57
+ const hex3 = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
58
+ if (hex3)
59
+ return [
60
+ parseInt(hex3[1] + hex3[1], 16),
61
+ parseInt(hex3[2] + hex3[2], 16),
62
+ parseInt(hex3[3] + hex3[3], 16),
63
+ ];
64
+ const rgb = color.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
65
+ if (rgb)
66
+ return [parseInt(rgb[1]), parseInt(rgb[2]), parseInt(rgb[3])];
67
+ return null;
68
+ };
69
+ /**
70
+ * Computes the WCAG 2.x relative luminance of an sRGB colour.
71
+ *
72
+ * Algorithm (WCAG 2.1 §1.4.3 / IEC 61966-2-1 sRGB standard):
73
+ * 1. Normalise each 8-bit channel to [0, 1] by dividing by 255.
74
+ * 2. Gamma-expand (linearise) each normalised channel:
75
+ * if sRGB ≤ 0.04045 → linear = sRGB / 12.92
76
+ * else → linear = ((sRGB + 0.055) / 1.055) ^ 2.4
77
+ * The threshold 0.04045 and the slope 1/12.92 describe the near-black
78
+ * linear segment of the sRGB electro-optical transfer function (EOTF).
79
+ * The power 2.4 (≈ gamma 2.2) and the offset 0.055 handle the rest.
80
+ * 3. Weight the linear channels by the CIE 1931 XYZ D65 Y-row coefficients
81
+ * projected onto the sRGB primaries:
82
+ * L = 0.2126 R_lin + 0.7152 G_lin + 0.0722 B_lin
83
+ * These weights reflect human eye sensitivity: the eye is most sensitive
84
+ * to green, moderately to red, and least to blue.
85
+ *
86
+ * Returns a value in [0, 1]: 0 = absolute black, 1 = perfect white.
87
+ */
88
+ const relativeLuminance = (r, g, b) => {
89
+ const linearise = (c) => {
90
+ const s = c / 255;
91
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
92
+ };
93
+ return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
94
+ };
95
+ /**
96
+ * Computes the WCAG 2.x contrast ratio between two relative luminances.
97
+ *
98
+ * Formula: (L_lighter + 0.05) / (L_darker + 0.05)
99
+ *
100
+ * The additive offset 0.05 models the luminance of ambient flare (reflected
101
+ * light) assumed in a reference viewing environment. It prevents the ratio
102
+ * from reaching infinity for pure black and anchors the scale so that
103
+ * white-on-black yields exactly 21:1. The lighter luminance is always placed
104
+ * in the numerator so the result is always ≥ 1.
105
+ *
106
+ * WCAG thresholds:
107
+ * AA normal text ≥ 4.5:1 | large text ≥ 3:1
108
+ * AAA normal text ≥ 7:1 | large text ≥ 4.5:1
109
+ */
110
+ const wcagContrastRatio = (l1, l2) => {
111
+ const lighter = Math.max(l1, l2);
112
+ const darker = Math.min(l1, l2);
113
+ return (lighter + 0.05) / (darker + 0.05);
114
+ };
115
+ /**
116
+ * Converts an sRGB colour to HSL cylindrical coordinates.
117
+ * H (hue) ∈ [0°, 360°)
118
+ * S (saturation) ∈ [0, 1]
119
+ * L (lightness) ∈ [0, 1]
120
+ *
121
+ * We work in HSL because adjusting L alone changes perceived brightness while
122
+ * preserving the hue and saturation of the original brand colour — the
123
+ * smallest perceptible change needed to satisfy the contrast requirement.
124
+ * Achromatic colours (R = G = B) return H = 0, S = 0.
125
+ */
126
+ const rgbToHsl = (r, g, b) => {
127
+ const R = r / 255, G = g / 255, B = b / 255;
128
+ const max = Math.max(R, G, B), min = Math.min(R, G, B);
129
+ const l = (max + min) / 2;
130
+ if (max === min)
131
+ return [0, 0, l];
132
+ const d = max - min;
133
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
134
+ let h;
135
+ if (max === R)
136
+ h = ((G - B) / d + (G < B ? 6 : 0)) / 6;
137
+ else if (max === G)
138
+ h = ((B - R) / d + 2) / 6;
139
+ else
140
+ h = ((R - G) / d + 4) / 6;
141
+ return [h * 360, s, l];
142
+ };
143
+ /**
144
+ * Converts HSL back to sRGB (inverse of rgbToHsl).
145
+ *
146
+ * Uses the standard two-step piecewise-linear hue reconstruction:
147
+ * q = L < 0.5 ? L(1+S) : L+S−L·S (upper chroma boundary)
148
+ * p = 2L − q (lower chroma boundary)
149
+ * Each R, G, B channel is obtained by evaluating a piecewise hue function
150
+ * with offsets of +1/3 (120°) and −1/3 (−120°) for R and B respectively.
151
+ */
152
+ const hslToRgb = (h, s, l) => {
153
+ h = h / 360;
154
+ if (s === 0) {
155
+ const v = Math.round(l * 255);
156
+ return [v, v, v];
157
+ }
158
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
159
+ const p = 2 * l - q;
160
+ const hue2rgb = (p, q, t) => {
161
+ if (t < 0)
162
+ t += 1;
163
+ if (t > 1)
164
+ t -= 1;
165
+ if (t < 1 / 6)
166
+ return p + (q - p) * 6 * t;
167
+ if (t < 1 / 2)
168
+ return q;
169
+ if (t < 2 / 3)
170
+ return p + (q - p) * (2 / 3 - t) * 6;
171
+ return p;
172
+ };
173
+ return [
174
+ Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
175
+ Math.round(hue2rgb(p, q, h) * 255),
176
+ Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
177
+ ];
178
+ };
179
+ /** Converts an [R, G, B] tuple (0–255) to a lowercase 6-digit hex string. */
180
+ const rgbToHex = (r, g, b) => '#' +
181
+ [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
182
+ /**
183
+ * Finds the lightness-adjusted version of `adjustRgb` that is as close as
184
+ * possible to the original colour while achieving at least `requiredRatio`
185
+ * contrast against `fixedRgb`.
186
+ *
187
+ * Strategy — binary search on HSL lightness L ∈ [0, 1]:
188
+ * direction = 'darker': searches L ∈ [0, original_L] for the MAXIMUM L
189
+ * (least dark, closest to original) at which contrast ≥ required.
190
+ * direction = 'lighter': searches L ∈ [original_L, 1] for the MINIMUM L
191
+ * (least light, closest to original) at which contrast ≥ required.
192
+ *
193
+ * Hue (H) and saturation (S) are held constant so the result stays as close
194
+ * to the original brand/design colour as possible.
195
+ *
196
+ * 30 iterations yield a lightness precision of 1/2^30 ≈ 10⁻⁹, well below
197
+ * the 1/255 ≈ 0.004 resolution of 8-bit channels, so the result is
198
+ * effectively exact at 8-bit depth.
199
+ *
200
+ * Returns null if even the extreme value for this direction (pure black at
201
+ * L = 0 or pure white at L = 1 for the given H and S) cannot achieve the
202
+ * required ratio — an edge case that only arises for very low required ratios
203
+ * or highly chromatic near-neutral colours.
204
+ */
205
+ const findCompliantColorByLightness = (adjustRgb, fixedRgb, requiredRatio, direction) => {
206
+ const fixedLum = relativeLuminance(...fixedRgb);
207
+ const [h, s, origHslL] = rgbToHsl(...adjustRgb);
208
+ // Verify the extreme value in this direction is sufficient at all.
209
+ const extremeHslL = direction === 'darker' ? 0 : 1;
210
+ const extremeRgb = hslToRgb(h, s, extremeHslL);
211
+ if (wcagContrastRatio(fixedLum, relativeLuminance(...extremeRgb)) < requiredRatio) {
212
+ return null;
213
+ }
214
+ let lo = direction === 'darker' ? 0 : origHslL;
215
+ let hi = direction === 'darker' ? origHslL : 1;
216
+ let best = extremeHslL; // guaranteed-passing extreme
217
+ for (let i = 0; i < 30; i++) {
218
+ const mid = (lo + hi) / 2;
219
+ const midRgb = hslToRgb(h, s, mid);
220
+ const passes = wcagContrastRatio(fixedLum, relativeLuminance(...midRgb)) >= requiredRatio;
221
+ if (passes) {
222
+ best = mid; // This midpoint works; try to get even closer to the original.
223
+ if (direction === 'darker')
224
+ lo = mid; // Can we raise L further (less dark)?
225
+ else
226
+ hi = mid; // Can we lower L further (less light)?
227
+ }
228
+ else {
229
+ if (direction === 'darker')
230
+ hi = mid; // Too light; go darker.
231
+ else
232
+ lo = mid; // Too dark; go lighter.
233
+ }
234
+ }
235
+ return hslToRgb(h, s, best);
236
+ };
237
+ /**
238
+ * Builds a human-readable recommendation for a single failing contrast pair.
239
+ *
240
+ * WCAG 1.4.3 "Contrast (Minimum)" defines two situations based on font size
241
+ * and weight (1 pt = 1.333 px at 96 dpi):
242
+ *
243
+ * Situation A — normal text (< 18 pt non-bold / < 14 pt bold,
244
+ * i.e. < ~24 px / < ~18.5 px):
245
+ * Required contrast ≥ 4.5:1 (G18)
246
+ *
247
+ * Situation B — large text (≥ 18 pt non-bold / ≥ 14 pt bold,
248
+ * i.e. ≥ ~24 px / ≥ ~18.5 px):
249
+ * Required contrast ≥ 3:1 (G145)
250
+ *
251
+ * axe-core applies this rule upstream using:
252
+ * ptSize = Math.ceil(fontSize_px × 72) / 96 // px → pt
253
+ * isSmallFont = (bold && ptSize < boldTextPt) // default 14 pt
254
+ * || (!bold && ptSize < largeTextPt) // default 18 pt
255
+ * bold = fontWeight ≥ 700 || fontWeight === 'bold' // boldValue = 700
256
+ * and stores the result as `expectedContrastRatio: "4.5:1"` (Situation A) or
257
+ * `"3:1"` (Situation B). The `required` value used here is derived from that
258
+ * field via `parseFloat("4.5:1") → 4.5` or `parseFloat("3:1") → 3`, so the
259
+ * binary search automatically targets the correct threshold for each
260
+ * combination's font size and weight — no re-classification is needed here.
261
+ *
262
+ * WCAG 1.4.3 exceptions (no contrast requirement — filtered by axe-core
263
+ * before the data ever reaches this function):
264
+ * • Pure decoration (no informational purpose, rearrangeable/substitutable)
265
+ * • Inactive / disabled user-interface components
266
+ * • Logotypes and brand names
267
+ * • Text inside photographs or images with significant other visual content
268
+ *
269
+ * Algorithm for each failing pair:
270
+ * 1. Parse both colours to [R, G, B] (axe-core supplies #rrggbb hex).
271
+ * 2. Convert each colour to HSL. Search for the nearest compliant
272
+ * foreground by binary-searching HSL lightness L in both the 'darker'
273
+ * and 'lighter' directions while keeping H and S constant (preserves
274
+ * hue/saturation of the original brand colour).
275
+ * 3. Repeat step 2 for the background (holding the foreground fixed).
276
+ * 4. For each colour role, pick whichever direction (darker/lighter)
277
+ * requires the smaller change in L — i.e. the least visually
278
+ * disruptive compliant alternative.
279
+ * 5. Report both the adjusted foreground and the adjusted background
280
+ * (hex + rgb) so developers can choose whichever fits their design
281
+ * system. The target ratio is included so the output is unambiguous
282
+ * when a page contains a mix of normal-text (4.5:1) and large-text
283
+ * (3:1) failures.
284
+ *
285
+ * Returns null if the colours cannot be parsed or no compliant alternative
286
+ * can be found (extremely rare; only arises when the colour is already at
287
+ * the luminance extreme for its hue/saturation).
288
+ */
289
+ const buildContrastRecommendation = (example) => {
290
+ const fgRgb = parseColor(example.fgColor);
291
+ const bgRgb = parseColor(example.bgColor);
292
+ if (!fgRgb || !bgRgb)
293
+ return null;
294
+ // parseFloat handles "4.5:1" → 4.5 because it stops at the non-numeric ':'.
295
+ // The value is either 4.5 (Situation A, normal text) or 3 (Situation B,
296
+ // large text), as determined by axe-core from the element's font metrics.
297
+ const CONTRAST_BUFFER = 1.05;
298
+ const required = parseFloat(example.expectedContrastRatio) * CONTRAST_BUFFER;
299
+ if (isNaN(required))
300
+ return null;
301
+ // Find the nearest compliant foreground (try both directions, pick closest).
302
+ const fgDarker = findCompliantColorByLightness(fgRgb, bgRgb, required, 'darker');
303
+ const fgLighter = findCompliantColorByLightness(fgRgb, bgRgb, required, 'lighter');
304
+ const [, , origFgHslL] = rgbToHsl(...fgRgb);
305
+ let recFg = null;
306
+ if (fgDarker && fgLighter) {
307
+ const [, , dL] = rgbToHsl(...fgDarker);
308
+ const [, , lL] = rgbToHsl(...fgLighter);
309
+ recFg = Math.abs(dL - origFgHslL) <= Math.abs(lL - origFgHslL) ? fgDarker : fgLighter;
310
+ }
311
+ else {
312
+ recFg = fgDarker ?? fgLighter;
313
+ }
314
+ // Find the nearest compliant background (try both directions, pick closest).
315
+ const bgDarker = findCompliantColorByLightness(bgRgb, fgRgb, required, 'darker');
316
+ const bgLighter = findCompliantColorByLightness(bgRgb, fgRgb, required, 'lighter');
317
+ const [, , origBgHslL] = rgbToHsl(...bgRgb);
318
+ let recBg = null;
319
+ if (bgDarker && bgLighter) {
320
+ const [, , dL] = rgbToHsl(...bgDarker);
321
+ const [, , lL] = rgbToHsl(...bgLighter);
322
+ recBg = Math.abs(dL - origBgHslL) <= Math.abs(lL - origBgHslL) ? bgDarker : bgLighter;
323
+ }
324
+ else {
325
+ recBg = bgDarker ?? bgLighter;
326
+ }
327
+ if (!recFg && !recBg)
328
+ return null;
329
+ const parts = [];
330
+ if (recFg) {
331
+ const [rr, gg, bb] = recFg;
332
+ parts.push(`foreground text color to ${rgbToHex(rr, gg, bb)} (rgb(${rr}, ${gg}, ${bb}))`);
333
+ }
334
+ if (recBg) {
335
+ const [rr, gg, bb] = recBg;
336
+ parts.push(`background to ${rgbToHex(rr, gg, bb)} (rgb(${rr}, ${gg}, ${bb}))`);
337
+ }
338
+ // Include the target ratio in the string so the message is unambiguous when
339
+ // a single element has a mix of normal-text (4.5:1) and large-text (3:1)
340
+ // failing combinations with different required thresholds.
341
+ return `${parts.join(' or ')}`;
342
+ };
343
+ /**
344
+ * Builds the augmented issue description for a single axe-core color-contrast
345
+ * violation node.
346
+ *
347
+ * ─── WCAG rules applied ──────────────────────────────────────────────────────
348
+ *
349
+ * WCAG 1.4.3 "Contrast (Minimum)" distinguishes two situations:
350
+ *
351
+ * Situation A — normal text (< 18 pt non-bold / < 14 pt bold,
352
+ * i.e. < ~24 px / < ~18.5 px at 96 dpi):
353
+ * Required contrast ratio ≥ 4.5:1
354
+ *
355
+ * Situation B — large text (≥ 18 pt non-bold / ≥ 14 pt bold,
356
+ * i.e. ≥ ~24 px / ≥ ~18.5 px at 96 dpi):
357
+ * Required contrast ratio ≥ 3:1
358
+ *
359
+ * axe-core classifies each element before this function runs:
360
+ * ptSize = Math.ceil(fontSize_px × 72) / 96 // px → pt
361
+ * isSmallFont = (bold && ptSize < 14) // Situation A bold
362
+ * || (!bold && ptSize < 18) // Situation A normal
363
+ * bold = fontWeight ≥ 700 || fontWeight === 'bold'
364
+ * …and stores the result in check.data.expectedContrastRatio as "4.5:1" or "3:1".
365
+ *
366
+ * Exceptions (no contrast requirement — already excluded by axe-core upstream):
367
+ * • Pure decoration (no informational purpose)
368
+ * • Inactive / disabled UI components
369
+ * • Logotypes and brand names
370
+ * • Text inside photographs with significant other visual content
371
+ *
372
+ * ─── Function flow ───────────────────────────────────────────────────────────
373
+ *
374
+ * 1. Collect checks — flatten node.any, node.all, node.none into one array.
375
+ * Each entry may carry a ContrastCheckData payload with fgColor, bgColor,
376
+ * contrastRatio, fontSize, fontWeight, and expectedContrastRatio.
377
+ *
378
+ * 2. Deduplicate — key each combination on
379
+ * [fgColor, bgColor, fontSize, fontWeight, expectedContrastRatio].
380
+ * A single DOM node can generate multiple identical checks; the Map ensures
381
+ * each distinct failing pair is reported once.
382
+ *
383
+ * 3. Build the base message — lists every unique failing combination with its
384
+ * current contrast ratio and the required ratio, and instructs the developer
385
+ * to fix all failing text in the component, not just the first element.
386
+ *
387
+ * 4. Build per-combo recommendations (via buildContrastRecommendation) —
388
+ * for each failing pair, binary-search HSL lightness to find the nearest
389
+ * compliant foreground (background fixed) and the nearest compliant
390
+ * background (foreground fixed), both expressed as hex + rgb(). The binary
391
+ * search targets the combination's own expectedContrastRatio (4.5 or 3),
392
+ * so large-text recommendations are correctly held to the 3:1 threshold
393
+ * and normal-text recommendations to 4.5:1.
394
+ *
395
+ * 5. Concatenate — append "Recommendation: …" after the base message so the
396
+ * existing description is never modified, only extended.
397
+ *
398
+ * Returns null when no check carries usable contrast data (axe-core may omit
399
+ * it for pseudo-element or out-of-viewport cases).
400
+ */
401
+ const buildColorContrastMessage = (node) => {
402
+ const checks = [...(node.any || []), ...(node.all || []), ...(node.none || [])];
403
+ const uniqueCombos = new Map();
404
+ checks.forEach(check => {
405
+ const data = check.data || {};
406
+ const hasContrastData = data.fgColor ||
407
+ data.bgColor ||
408
+ data.contrastRatio !== undefined ||
409
+ data.expectedContrastRatio;
410
+ if (!hasContrastData)
411
+ return;
412
+ if (!data.fgColor || !data.bgColor)
413
+ return;
414
+ const fgColor = data.fgColor;
415
+ const bgColor = data.bgColor;
416
+ const contrastRatio = String(data.contrastRatio ?? 'unknown');
417
+ const fontSize = formatContrastFontSize(data.fontSize);
418
+ const fontWeight = data.fontWeight || 'normal';
419
+ const expectedContrastRatio = data.expectedContrastRatio || '4.5:1';
420
+ const key = [fgColor, bgColor, fontSize, fontWeight, expectedContrastRatio].join('|');
421
+ if (!uniqueCombos.has(key)) {
422
+ uniqueCombos.set(key, {
423
+ fgColor,
424
+ bgColor,
425
+ contrastRatio,
426
+ fontSize,
427
+ fontWeight,
428
+ expectedContrastRatio,
429
+ });
430
+ }
431
+ });
432
+ if (!uniqueCombos.size)
433
+ return null;
434
+ const combos = [...uniqueCombos.values()];
435
+ const examples = combos
436
+ .map(example => `foreground ${example.fgColor} on ${example.bgColor} at ${example.fontSize} ${example.fontWeight === 'bold' ? 'bold' : 'regular'} text`)
437
+ .join(', and ');
438
+ const targetRatio = combos[0]?.expectedContrastRatio || '4.5:1';
439
+ const currentRatio = combos[0]?.contrastRatio || 'unknown';
440
+ 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}.`;
441
+ const recommendations = combos
442
+ .map(buildContrastRecommendation)
443
+ .filter((r) => r !== null);
444
+ const recSection = recommendations.length > 0
445
+ ? `\n Recommendation: Adjust ${recommendations.join('; ')}.`
446
+ : '';
447
+ const ctx = node.contrastDOMContext;
448
+ if (!ctx)
449
+ return `${base}${recSection}`;
450
+ const notes = [];
451
+ if (ctx.hasGradient) {
452
+ notes.push(`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`);
453
+ }
454
+ else if (ctx.ancestorHasGradient) {
455
+ notes.push(`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`);
456
+ }
457
+ if (ctx.hasBackgroundImage) {
458
+ notes.push(`background image detected: contrast cannot be fully determined from a sampled color alone — ensure text remains readable across all image content and states`);
459
+ }
460
+ else if (ctx.ancestorHasBackgroundImage) {
461
+ notes.push(`an ancestor has a background image: the effective background under this text may differ from the sampled value`);
462
+ }
463
+ if (ctx.hasReducedOpacity) {
464
+ notes.push(`opacity less than 1 detected on this element or an ancestor: the rendered contrast is lower than the computed color values indicate`);
465
+ }
466
+ if (ctx.mixBlendMode) {
467
+ notes.push(`mix-blend-mode: ${ctx.mixBlendMode} is applied: actual rendered colors depend on the underlying layers`);
468
+ }
469
+ if (ctx.backdropFilter) {
470
+ notes.push(`backdrop-filter: ${ctx.backdropFilter} is applied: the effective background appearance is modified`);
471
+ }
472
+ if (ctx.filter) {
473
+ notes.push(`CSS filter: ${ctx.filter} is applied to this element: rendered colors may differ from computed values`);
474
+ }
475
+ const ctxSection = notes.length > 0
476
+ ? `\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.`
477
+ : '';
478
+ return `${base}${recSection}${ctxSection}`;
479
+ };
480
+ // Enriches axe violation failureSummaries with additional DOM context gathered via Playwright,
481
+ // providing LLMs with the specific details they need to apply correct fixes.
482
+ export const enrichViolationMessages = async (results, page) => {
483
+ for (const violation of results.violations) {
484
+ if (violation.id !== 'target-size' && violation.id !== 'valid-lang')
485
+ continue;
486
+ for (const node of violation.nodes) {
487
+ const cssSelector = node.target.length === 1 && typeof node.target[0] === 'string' ? node.target[0] : null;
488
+ if (!cssSelector)
489
+ continue;
490
+ if (violation.id === 'target-size') {
491
+ const ctx = await page
492
+ .evaluate((sel) => {
493
+ try {
494
+ const el = document.querySelector(sel);
495
+ if (!el)
496
+ return null;
497
+ const rect = el.getBoundingClientRect();
498
+ const computed = window.getComputedStyle(el);
499
+ return {
500
+ renderedWidth: Math.round(rect.width),
501
+ renderedHeight: Math.round(rect.height),
502
+ boxSizing: computed.boxSizing,
503
+ inlineWidth: el.style.width || null,
504
+ inlineHeight: el.style.height || null,
505
+ tagName: el.tagName.toLowerCase(),
506
+ };
507
+ }
508
+ catch {
509
+ return null;
510
+ }
511
+ }, cssSelector)
512
+ .catch(() => null);
513
+ if (ctx) {
514
+ const spacingMatch = node.failureSummary?.match(/diameter of (\d+)px/);
515
+ const spacing = spacingMatch ? spacingMatch[1] : null;
516
+ 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.`;
517
+ if (spacing) {
518
+ message += `\n Target has insufficient space to its adjacent element of ${spacing}px. Ensure it has a safe clickable space of at least 24px.`;
519
+ }
520
+ if (ctx.boxSizing === 'border-box' &&
521
+ (ctx.inlineWidth !== null || ctx.inlineHeight !== null)) {
522
+ 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.`;
523
+ }
524
+ node.failureSummary = message;
525
+ }
526
+ }
527
+ else if (violation.id === 'valid-lang') {
528
+ const ctx = await page
529
+ .evaluate((sel) => {
530
+ try {
531
+ const el = document.querySelector(sel);
532
+ if (!el)
533
+ return null;
534
+ return {
535
+ langValue: el.getAttribute('lang') ?? '',
536
+ textSnippet: (el.textContent ?? '').trim().slice(0, 120),
537
+ };
538
+ }
539
+ catch {
540
+ return null;
541
+ }
542
+ }, cssSelector)
543
+ .catch(() => null);
544
+ if (ctx) {
545
+ let message = `Value of lang attribute is not a valid language.\n Use a registered IANA language code instead of "${ctx.langValue}".`;
546
+ if (ctx.langValue.startsWith('x-')) {
547
+ message += `\n Axe-core valid-lang rule also rejects private-use subtags.`;
548
+ }
549
+ 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).`;
550
+ node.failureSummary = message;
551
+ }
552
+ }
553
+ }
554
+ }
555
+ };
38
556
  export const filterAxeResults = (results, pageTitle, customFlowDetails) => {
39
557
  const { violations, passes, incomplete, url } = results;
40
558
  let totalItems = 0;
@@ -72,9 +590,12 @@ export const filterAxeResults = (results, pageTitle, customFlowDetails) => {
72
590
  items: [],
73
591
  };
74
592
  }
75
- const message = displayNeedsReview
593
+ const defaultMessage = displayNeedsReview
76
594
  ? failureSummary.slice(failureSummary.indexOf('\n') + 1).trim()
77
595
  : failureSummary;
596
+ const message = rule === 'color-contrast' || rule === 'color-contrast-enhanced'
597
+ ? buildColorContrastMessage(node) || defaultMessage
598
+ : defaultMessage;
78
599
  let finalHtml = html;
79
600
  if (html.includes('</script>')) {
80
601
  finalHtml = html.replaceAll('</script>', '&lt;/script>');
@@ -320,6 +841,8 @@ export const runAxeScript = async ({ includeScreenshots, page, randomToken, cust
320
841
  flagUnlabelledClickableElementsFunctionString: flagUnlabelledClickableElements.toString(),
321
842
  xPathToCssFunctionString: xPathToCss.toString(),
322
843
  });
844
+ await enrichViolationMessages(results, page);
845
+ await enrichColorContrastDOMContext(results.violations, page);
323
846
  if (includeScreenshots) {
324
847
  results.violations = await takeScreenshotForHTMLElements(results.violations, page, randomToken);
325
848
  results.incomplete = await takeScreenshotForHTMLElements(results.incomplete, page, randomToken);
@@ -8,7 +8,7 @@ import { runPdfScan, mapPdfScanResults, doPdfScreenshots } from './pdfScanFunc.j
8
8
  import { guiInfoLog } from '../logs.js';
9
9
  import crawlSitemap from './crawlSitemap.js';
10
10
  import { getPdfStoragePath, register } from '../utils.js';
11
- export const crawlLocalFile = async ({ url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, extraHTTPHeaders, scanDuration = 0, fromCrawlIntelligentSitemap = false, userUrlInputFromIntelligent = null, datasetFromIntelligent = null, urlsCrawledFromIntelligent = null, }) => {
11
+ export const crawlLocalFile = async ({ url, randomToken, host, viewportSettings, maxRequestsPerCrawl, browser, userDataDirectory, specifiedMaxConcurrency, fileTypes, blacklistedPatterns, includeScreenshots, extraHTTPHeaders, scanDuration = 0, ruleset = [], fromCrawlIntelligentSitemap = false, userUrlInputFromIntelligent = null, datasetFromIntelligent = null, urlsCrawledFromIntelligent = null, }) => {
12
12
  let dataset;
13
13
  let urlsCrawled;
14
14
  let linksFromSitemap = [];
@@ -105,7 +105,7 @@ export const crawlLocalFile = async ({ url, randomToken, host, viewportSettings,
105
105
  await browserContext.close().catch(() => { });
106
106
  return urlsCrawled;
107
107
  }
108
- const results = await runAxeScript({ includeScreenshots, page, randomToken });
108
+ const results = await runAxeScript({ includeScreenshots, page, randomToken, ruleset });
109
109
  const actualUrl = page.url() || request.loadedUrl || url;
110
110
  guiInfoLog(guiInfoStatusTypes.SCANNED, {
111
111
  numScanned: urlsCrawled.scanned.length,
@@ -34,7 +34,7 @@ export async function extractAndGradeText(page) {
34
34
  const readabilityScore = wordCount >= 20 ? textReadability.fleschReadingEase(filteredText) : 0;
35
35
  // Log details for debugging
36
36
  // Determine the return value
37
- const result = readabilityScore === 0 || readabilityScore > 50 ? '' : readabilityScore.toString(); // Convert readabilityScore to string
37
+ const result = readabilityScore <= 0 || readabilityScore > 50 ? '' : readabilityScore.toString();
38
38
  return result;
39
39
  }
40
40
  catch (error) {
@@ -1,5 +1,13 @@
1
1
  import { evaluateAltText } from "./evaluateAltText.js";
2
2
  export function getAxeConfiguration({ enableWcagAaa = false, gradingReadabilityFlag = '', disableOobee = false, }) {
3
+ function getReadabilityInterpretation(score) {
4
+ const num = parseFloat(score);
5
+ if (Number.isNaN(num))
6
+ return '';
7
+ if (num > 30)
8
+ return 'It is targeted for junior college (JC) level comprehension and above.';
9
+ return 'It is targeted for university graduate level comprehension and above.';
10
+ }
3
11
  return {
4
12
  branding: {
5
13
  application: 'oobee',
@@ -29,7 +37,7 @@ export function getAxeConfiguration({ enableWcagAaa = false, gradingReadabilityF
29
37
  return !node.dataset.flagged; // fail any element with a data-flagged attribute set to true
30
38
  },
31
39
  },
32
- ...(enableWcagAaa
40
+ ...((enableWcagAaa && gradingReadabilityFlag !== '')
33
41
  ? [
34
42
  {
35
43
  id: 'oobee-grading-text-contents',
@@ -37,16 +45,11 @@ export function getAxeConfiguration({ enableWcagAaa = false, gradingReadabilityF
37
45
  impact: 'moderate',
38
46
  messages: {
39
47
  pass: 'The text content is easy to understand.',
40
- fail: 'The text content is potentially difficult to understand.',
41
- incomplete: `The text content is potentially difficult to read, with a Flesch-Kincaid Reading Ease score of ${gradingReadabilityFlag}.\nThe target passing score is above 50, indicating content readable by university students and lower grade levels.\nA higher score reflects better readability.`,
48
+ fail: `Text content is potentially difficult to read.\n It scored ${gradingReadabilityFlag} out of 50 on the Flesch-Kincaid Readability Test.\n ${getReadabilityInterpretation(gradingReadabilityFlag)}`,
49
+ incomplete: `Text content is potentially difficult to read.\n It scored ${gradingReadabilityFlag} out of 50 on the Flesch-Kincaid Readability Test.\n ${getReadabilityInterpretation(gradingReadabilityFlag)}`,
42
50
  },
43
51
  },
44
- evaluate: (_node) => {
45
- if (gradingReadabilityFlag === '') {
46
- return true; // Pass if no readability issues
47
- }
48
- // Fail if readability issues are detected
49
- },
52
+ evaluate: (_node) => false,
50
53
  },
51
54
  ]
52
55
  : []),
@@ -77,18 +80,20 @@ export function getAxeConfiguration({ enableWcagAaa = false, gradingReadabilityF
77
80
  helpUrl: 'https://www.deque.com/blog/accessible-aria-buttons',
78
81
  },
79
82
  },
80
- {
81
- id: 'oobee-grading-text-contents',
82
- selector: 'html',
83
- enabled: true,
84
- any: ['oobee-grading-text-contents'],
85
- tags: ['wcag2aaa', 'wcag315'],
86
- metadata: {
87
- description: 'Text content should be easy to understand for individuals with education levels up to university graduates. If the text content is difficult to understand, provide supplemental content or a version that is easy to understand.',
88
- help: 'Text content should be clear and plain to ensure that it is easily understood.',
89
- helpUrl: 'https://www.wcag.com/uncategorized/3-1-5-reading-level/',
90
- },
91
- },
83
+ ...((enableWcagAaa && gradingReadabilityFlag !== '')
84
+ ? [{
85
+ id: 'oobee-grading-text-contents',
86
+ selector: 'html',
87
+ enabled: true,
88
+ any: ['oobee-grading-text-contents'],
89
+ tags: ['wcag2aaa', 'wcag315'],
90
+ metadata: {
91
+ description: 'Text content should be easy to understand for individuals with education levels up to university graduates. If the text content is difficult to understand, provide supplemental content or a version that is easy to understand.',
92
+ help: 'Text content should be clear and plain to ensure that it is easily understood.',
93
+ helpUrl: 'https://www.wcag.com/uncategorized/3-1-5-reading-level/',
94
+ },
95
+ }]
96
+ : []),
92
97
  ]
93
98
  .filter(rule => (disableOobee ? !rule.id.startsWith('oobee') : true))
94
99
  .concat(enableWcagAaa
@@ -13,7 +13,7 @@ export function gradeReadability(sentences) {
13
13
  const readabilityScore = wordCount >= 20 ? textReadability.fleschReadingEase(filteredText) : 0;
14
14
  // Log details for debugging
15
15
  // Determine the return value
16
- const result = readabilityScore === 0 || readabilityScore > 50 ? '' : readabilityScore.toString(); // Convert readabilityScore to string
16
+ const result = readabilityScore <= 0 || readabilityScore > 50 ? '' : readabilityScore.toString();
17
17
  return result;
18
18
  }
19
19
  catch (error) {