@icjia/contrastcap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,104 @@
1
+ import {
2
+ relativeLuminance,
3
+ contrastRatio,
4
+ requiredRatio,
5
+ rgbToHex,
6
+ } from './contrastCalc.js';
7
+
8
+ // Hex ⇄ HSL helpers. L is 0-100.
9
+
10
+ function hexToHsl(hex) {
11
+ let h = hex.startsWith('#') ? hex.slice(1) : hex;
12
+ if (h.length === 3) h = h.split('').map(c => c + c).join('');
13
+ const r = parseInt(h.slice(0, 2), 16) / 255;
14
+ const g = parseInt(h.slice(2, 4), 16) / 255;
15
+ const b = parseInt(h.slice(4, 6), 16) / 255;
16
+
17
+ const max = Math.max(r, g, b);
18
+ const min = Math.min(r, g, b);
19
+ const l = (max + min) / 2;
20
+
21
+ let s = 0;
22
+ let hue = 0;
23
+ if (max !== min) {
24
+ const d = max - min;
25
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
26
+ switch (max) {
27
+ case r: hue = (g - b) / d + (g < b ? 6 : 0); break;
28
+ case g: hue = (b - r) / d + 2; break;
29
+ case b: hue = (r - g) / d + 4; break;
30
+ }
31
+ hue *= 60;
32
+ }
33
+
34
+ return { h: hue, s: s * 100, l: l * 100 };
35
+ }
36
+
37
+ function hslToHex(h, s, l) {
38
+ const sNorm = Math.max(0, Math.min(100, s)) / 100;
39
+ const lNorm = Math.max(0, Math.min(100, l)) / 100;
40
+
41
+ const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
42
+ const hp = ((h % 360) + 360) % 360 / 60;
43
+ const x = c * (1 - Math.abs((hp % 2) - 1));
44
+ let r = 0, g = 0, b = 0;
45
+ if (0 <= hp && hp < 1) [r, g, b] = [c, x, 0];
46
+ else if (hp < 2) [r, g, b] = [x, c, 0];
47
+ else if (hp < 3) [r, g, b] = [0, c, x];
48
+ else if (hp < 4) [r, g, b] = [0, x, c];
49
+ else if (hp < 5) [r, g, b] = [x, 0, c];
50
+ else [r, g, b] = [c, 0, x];
51
+
52
+ const m = lNorm - c / 2;
53
+ return rgbToHex((r + m) * 255, (g + m) * 255, (b + m) * 255);
54
+ }
55
+
56
+ // Bisect in [loInit, hiInit] for an L that meets threshold.
57
+ // preferHigher=true → returns the MAX passing L in the range (used for darken: closer to original L)
58
+ // preferHigher=false → returns the MIN passing L in the range (used for lighten: closer to original L)
59
+ function findThresholdL(hsl, bgHex, loInit, hiInit, threshold, preferHigher) {
60
+ if (hiInit < loInit) return null;
61
+ const hiRatio = contrastRatio(hslToHex(hsl.h, hsl.s, hiInit), bgHex);
62
+ const loRatio = contrastRatio(hslToHex(hsl.h, hsl.s, loInit), bgHex);
63
+ if (hiRatio < threshold && loRatio < threshold) return null;
64
+
65
+ let lo = loInit, hi = hiInit;
66
+ for (let i = 0; i < 16; i++) {
67
+ const mid = (lo + hi) / 2;
68
+ const ratio = contrastRatio(hslToHex(hsl.h, hsl.s, mid), bgHex);
69
+ if (preferHigher) {
70
+ if (ratio >= threshold) lo = mid; else hi = mid;
71
+ } else {
72
+ if (ratio >= threshold) hi = mid; else lo = mid;
73
+ }
74
+ }
75
+ const finalL = preferHigher ? lo : hi;
76
+ // Sanity — confirm the final L actually meets the threshold.
77
+ const finalHex = hslToHex(hsl.h, hsl.s, finalL);
78
+ if (contrastRatio(finalHex, bgHex) < threshold) return null;
79
+ return { l: finalL, hex: finalHex };
80
+ }
81
+
82
+ /**
83
+ * Return the nearest hex color (by HSL lightness delta) to `fgHex`
84
+ * that meets the WCAG threshold against `bgHex`. Hue and saturation
85
+ * are preserved.
86
+ */
87
+ export function suggestFix(fgHex, bgHex, isLargeText, level = 'AA') {
88
+ const threshold = requiredRatio(isLargeText, level);
89
+ const hsl = hexToHsl(fgHex);
90
+
91
+ const darken = findThresholdL(hsl, bgHex, 0, hsl.l, threshold, /* preferHigher */ true);
92
+ const lighten = findThresholdL(hsl, bgHex, hsl.l, 100, threshold, /* preferHigher */ false);
93
+
94
+ if (darken && lighten) {
95
+ return Math.abs(darken.l - hsl.l) <= Math.abs(lighten.l - hsl.l) ? darken.hex : lighten.hex;
96
+ }
97
+ if (darken) return darken.hex;
98
+ if (lighten) return lighten.hex;
99
+
100
+ // Fallback: no solution preserving hue/sat — e.g. white-on-white.
101
+ return relativeLuminance(bgHex) > 0.5 ? '#000000' : '#ffffff';
102
+ }
103
+
104
+ export const _test = { hexToHsl, hslToHex, findThresholdL };
@@ -0,0 +1,63 @@
1
+ // WCAG 2.1 relative luminance and contrast ratio math.
2
+
3
+ function parseHex(hex) {
4
+ if (typeof hex !== 'string') throw new Error('Invalid hex color');
5
+ let h = hex.trim();
6
+ if (h.startsWith('#')) h = h.slice(1);
7
+ if (h.length === 3) h = h.split('').map(c => c + c).join('');
8
+ if (h.length !== 6 || !/^[0-9a-f]{6}$/i.test(h)) throw new Error('Invalid hex color');
9
+ return {
10
+ r: parseInt(h.slice(0, 2), 16),
11
+ g: parseInt(h.slice(2, 4), 16),
12
+ b: parseInt(h.slice(4, 6), 16),
13
+ };
14
+ }
15
+
16
+ function channelLum(c255) {
17
+ const c = c255 / 255;
18
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
19
+ }
20
+
21
+ export function relativeLuminance(hex) {
22
+ const { r, g, b } = parseHex(hex);
23
+ return 0.2126 * channelLum(r) + 0.7152 * channelLum(g) + 0.0722 * channelLum(b);
24
+ }
25
+
26
+ export function relativeLuminanceRgb(r, g, b) {
27
+ return 0.2126 * channelLum(r) + 0.7152 * channelLum(g) + 0.0722 * channelLum(b);
28
+ }
29
+
30
+ export function contrastRatio(hex1, hex2) {
31
+ const L1 = relativeLuminance(hex1);
32
+ const L2 = relativeLuminance(hex2);
33
+ const lighter = Math.max(L1, L2);
34
+ const darker = Math.min(L1, L2);
35
+ return (lighter + 0.05) / (darker + 0.05);
36
+ }
37
+
38
+ export function requiredRatio(isLargeText, level = 'AA') {
39
+ if (level === 'AAA') return isLargeText ? 4.5 : 7;
40
+ return isLargeText ? 3 : 4.5; // AA
41
+ }
42
+
43
+ export function meetsThreshold(ratio, isLargeText, level = 'AA') {
44
+ return ratio >= requiredRatio(isLargeText, level);
45
+ }
46
+
47
+ // Parse CSS rgb()/rgba() → { r, g, b } with 0-255 channels.
48
+ export function parseRgbString(str) {
49
+ if (typeof str !== 'string') throw new Error('Invalid rgb string');
50
+ const m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)/i);
51
+ if (!m) throw new Error('Invalid rgb string');
52
+ return {
53
+ r: Math.round(parseFloat(m[1])),
54
+ g: Math.round(parseFloat(m[2])),
55
+ b: Math.round(parseFloat(m[3])),
56
+ };
57
+ }
58
+
59
+ export function rgbToHex(r, g, b) {
60
+ const clamp = v => Math.max(0, Math.min(255, Math.round(v)));
61
+ const h = v => clamp(v).toString(16).padStart(2, '0');
62
+ return `#${h(r)}${h(g)}${h(b)}`;
63
+ }
@@ -0,0 +1,94 @@
1
+ import sharp from 'sharp';
2
+ import {
3
+ parseRgbString,
4
+ relativeLuminanceRgb,
5
+ rgbToHex,
6
+ } from './contrastCalc.js';
7
+ import { CONFIG } from '../config.js';
8
+
9
+ /**
10
+ * Sample the background color underneath an element by hiding its text
11
+ * and screenshotting the bounding-box region, then reading pixels with sharp.
12
+ *
13
+ * Returns: { hex, source, highVariance }
14
+ * source: 'pixel-sample' | 'pixel-sample-over-image'
15
+ */
16
+ export async function sampleBackgroundColor(page, elementHandle, box, fgColorCss) {
17
+ // Playwright screenshot can't clip to width/height < 1
18
+ const clip = {
19
+ x: Math.max(0, Math.floor(box.x)),
20
+ y: Math.max(0, Math.floor(box.y)),
21
+ width: Math.max(1, Math.floor(box.width)),
22
+ height: Math.max(1, Math.floor(box.height)),
23
+ };
24
+
25
+ // Save the prior inline `style.color` so we restore exactly — clearing
26
+ // inline style would lose an author-set inline color.
27
+ await elementHandle.evaluate((el) => {
28
+ el.dataset._ccPrevInlineColor = el.style.color || '';
29
+ el.style.color = 'transparent';
30
+ });
31
+
32
+ let buffer;
33
+ try {
34
+ buffer = await page.screenshot({ clip, type: 'png', omitBackground: false });
35
+ } finally {
36
+ await elementHandle.evaluate((el) => {
37
+ el.style.color = el.dataset._ccPrevInlineColor || '';
38
+ delete el.dataset._ccPrevInlineColor;
39
+ }).catch(() => { /* element may have detached — acceptable */ });
40
+ }
41
+
42
+ const { data, info } = await sharp(buffer)
43
+ .removeAlpha()
44
+ .raw()
45
+ .toBuffer({ resolveWithObject: true });
46
+
47
+ const { width, height, channels } = info; // channels = 3 after removeAlpha
48
+ const cols = 5, rows = 3;
49
+ const samples = [];
50
+ for (let r = 0; r < rows; r++) {
51
+ for (let c = 0; c < cols; c++) {
52
+ const px = Math.min(Math.floor((c + 0.5) * width / cols), width - 1);
53
+ const py = Math.min(Math.floor((r + 0.5) * height / rows), height - 1);
54
+ const idx = (py * width + px) * channels;
55
+ samples.push({ r: data[idx], g: data[idx + 1], b: data[idx + 2] });
56
+ }
57
+ }
58
+
59
+ const stddev = (k) => {
60
+ const vals = samples.map(s => s[k]);
61
+ const mean = vals.reduce((a, b) => a + b, 0) / vals.length;
62
+ const variance = vals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / vals.length;
63
+ return Math.sqrt(variance);
64
+ };
65
+
66
+ const highVariance =
67
+ stddev('r') > CONFIG.VARIANCE_STDDEV ||
68
+ stddev('g') > CONFIG.VARIANCE_STDDEV ||
69
+ stddev('b') > CONFIG.VARIANCE_STDDEV;
70
+
71
+ let bgRgb;
72
+ if (highVariance) {
73
+ // Worst-case: pick the sample that gives the least contrast against fg.
74
+ // Light fg → use the lightest bg pixel; dark fg → use the darkest bg pixel.
75
+ const fg = parseRgbString(fgColorCss);
76
+ const fgLum = relativeLuminanceRgb(fg.r, fg.g, fg.b);
77
+ const sorted = [...samples].sort(
78
+ (a, b) => relativeLuminanceRgb(a.r, a.g, a.b) - relativeLuminanceRgb(b.r, b.g, b.b)
79
+ );
80
+ bgRgb = fgLum > 0.5 ? sorted[sorted.length - 1] : sorted[0];
81
+ } else {
82
+ const median = (k) => {
83
+ const sorted = samples.map(s => s[k]).sort((a, b) => a - b);
84
+ return sorted[Math.floor(sorted.length / 2)];
85
+ };
86
+ bgRgb = { r: median('r'), g: median('g'), b: median('b') };
87
+ }
88
+
89
+ return {
90
+ hex: rgbToHex(bgRgb.r, bgRgb.g, bgRgb.b),
91
+ source: highVariance ? 'pixel-sample-over-image' : 'pixel-sample',
92
+ highVariance,
93
+ };
94
+ }
package/src/server.js ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'fs';
4
+ import { execFile } from 'child_process';
5
+ import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
6
+ import * as z from 'zod/v4';
7
+
8
+ import { CONFIG, log, setVerbosity } from './config.js';
9
+ import { closeBrowser } from './browser.js';
10
+ import { sanitizeError } from './utils/sanitizeError.js';
11
+ import { checkPageContrast } from './tools/checkPageContrast.js';
12
+ import { checkElementContrast } from './tools/checkElementContrast.js';
13
+ import { getContrastSummary } from './tools/getContrastSummary.js';
14
+
15
+ if (process.argv.includes('--verbose')) setVerbosity('verbose');
16
+ if (process.argv.includes('--quiet')) setVerbosity('quiet');
17
+
18
+ // ─── Version info ──────────────────────────────────────────────────
19
+
20
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)));
21
+ const serverVersion = pkg.version;
22
+
23
+ let axeVersion = 'unknown';
24
+ let playwrightVersion = 'unknown';
25
+ try {
26
+ axeVersion = JSON.parse(readFileSync(new URL('../node_modules/axe-core/package.json', import.meta.url))).version;
27
+ } catch { /* ignore */ }
28
+ try {
29
+ playwrightVersion = JSON.parse(readFileSync(new URL('../node_modules/playwright/package.json', import.meta.url))).version;
30
+ } catch { /* ignore */ }
31
+
32
+ let _latestVersion = null;
33
+ const _latestPromise = new Promise((resolve) => {
34
+ execFile('npm', ['view', '@icjia/contrastcap', 'version'], { timeout: 5000 }, (err, stdout) => {
35
+ const raw = err ? 'unknown' : stdout.trim();
36
+ _latestVersion = /^\d+\.\d+\.\d+/.test(raw) ? raw : 'unknown';
37
+ resolve(_latestVersion);
38
+ });
39
+ });
40
+
41
+ async function getLatestVersion() {
42
+ if (_latestVersion) return _latestVersion;
43
+ return _latestPromise;
44
+ }
45
+
46
+ log('info', `Server v${serverVersion} | axe-core v${axeVersion} | playwright v${playwrightVersion}`);
47
+
48
+ // ─── Concurrency queue ─────────────────────────────────────────────
49
+
50
+ let inFlight = 0;
51
+
52
+ async function runQueued(fn) {
53
+ if (inFlight >= CONFIG.MAX_CONCURRENT) {
54
+ throw new Error('Audit queue full — try again shortly');
55
+ }
56
+ inFlight++;
57
+ try { return await fn(); }
58
+ finally { inFlight--; }
59
+ }
60
+
61
+ // ─── MCP Server ────────────────────────────────────────────────────
62
+
63
+ const server = new McpServer({
64
+ name: 'contrastcap',
65
+ version: serverVersion,
66
+ });
67
+
68
+ function asJsonText(obj) {
69
+ return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] };
70
+ }
71
+
72
+ function asErrorText(err) {
73
+ return { content: [{ type: 'text', text: `Error: ${sanitizeError(err)}` }] };
74
+ }
75
+
76
+ // ─── get_contrast_summary ──────────────────────────────────────────
77
+
78
+ server.registerTool(
79
+ 'get_contrast_summary',
80
+ {
81
+ description: 'Run a contrast audit against a URL and return only summary counts (pass / fail / warning / skipped). Minimal token cost — use this first before requesting full detail. Defaults to WCAG AA.',
82
+ inputSchema: z.object({
83
+ url: z.string().max(CONFIG.MAX_URL_LENGTH).describe('HTTP or HTTPS URL to audit (localhost, staging, or prod)'),
84
+ level: z.enum(['AA', 'AAA']).default('AA').describe('WCAG conformance level. Defaults to AA. AAA must be explicitly requested.'),
85
+ }),
86
+ },
87
+ async (params) => {
88
+ try {
89
+ const result = await runQueued(() => getContrastSummary(params));
90
+ return asJsonText(result);
91
+ } catch (err) {
92
+ log('error', err.message);
93
+ return asErrorText(err);
94
+ }
95
+ }
96
+ );
97
+
98
+ // ─── check_page_contrast ───────────────────────────────────────────
99
+
100
+ server.registerTool(
101
+ 'check_page_contrast',
102
+ {
103
+ description: 'Run a full contrast audit against a URL. Returns detailed entries for failures and warnings (passes are counted but not itemized). Resolves axe-core "needs review" items via pixel sampling. Includes hex suggestions for failing foregrounds. Defaults to WCAG AA.',
104
+ inputSchema: z.object({
105
+ url: z.string().max(CONFIG.MAX_URL_LENGTH).describe('HTTP or HTTPS URL to audit (localhost, staging, or prod)'),
106
+ level: z.enum(['AA', 'AAA']).default('AA').describe('WCAG conformance level. Defaults to AA. AAA must be explicitly requested.'),
107
+ include_passes: z.boolean().default(false).describe('Reserved for future use — currently passing elements are always counted, never itemized, to keep token usage low.'),
108
+ }),
109
+ },
110
+ async (params) => {
111
+ try {
112
+ const result = await runQueued(() => checkPageContrast(params));
113
+ return asJsonText(result);
114
+ } catch (err) {
115
+ log('error', err.message);
116
+ return asErrorText(err);
117
+ }
118
+ }
119
+ );
120
+
121
+ // ─── check_element_contrast ────────────────────────────────────────
122
+
123
+ server.registerTool(
124
+ 'check_element_contrast',
125
+ {
126
+ description: 'Check contrast for a single element on a page, identified by CSS selector. Returns the computed ratio and a hex suggestion if failing. Useful for verifying a fix without re-running the whole page audit. Defaults to WCAG AA.',
127
+ inputSchema: z.object({
128
+ url: z.string().max(CONFIG.MAX_URL_LENGTH).describe('HTTP or HTTPS URL to load'),
129
+ selector: z.string().max(CONFIG.SELECTOR_MAX_LEN).describe('CSS selector for the target element'),
130
+ level: z.enum(['AA', 'AAA']).default('AA').describe('WCAG conformance level. Defaults to AA.'),
131
+ }),
132
+ },
133
+ async (params) => {
134
+ try {
135
+ const result = await runQueued(() => checkElementContrast(params));
136
+ return asJsonText(result);
137
+ } catch (err) {
138
+ log('error', err.message);
139
+ return asErrorText(err);
140
+ }
141
+ }
142
+ );
143
+
144
+ // ─── get_status ────────────────────────────────────────────────────
145
+
146
+ server.registerTool(
147
+ 'get_status',
148
+ {
149
+ description: 'Return contrastcap server version, installed axe-core and playwright versions, and whether a newer contrastcap is available on npm.',
150
+ inputSchema: z.object({}),
151
+ },
152
+ async () => {
153
+ try {
154
+ const latest = await getLatestVersion();
155
+ const updateNote = (latest === 'unknown' || latest === serverVersion)
156
+ ? '(latest)'
157
+ : `(latest: v${latest} — update available)`;
158
+
159
+ const text = [
160
+ 'contrastcap status',
161
+ ` Server: @icjia/contrastcap v${serverVersion} ${updateNote}`,
162
+ ` axe-core: v${axeVersion}`,
163
+ ` playwright: v${playwrightVersion}`,
164
+ ` Node: v${process.versions.node}`,
165
+ ` Platform: ${process.platform} ${process.arch}`,
166
+ ` Default: WCAG ${CONFIG.DEFAULT_LEVEL}`,
167
+ ].join('\n');
168
+
169
+ return { content: [{ type: 'text', text }] };
170
+ } catch (err) {
171
+ log('error', err.message);
172
+ return asErrorText(err);
173
+ }
174
+ }
175
+ );
176
+
177
+ // ─── Shutdown ──────────────────────────────────────────────────────
178
+
179
+ async function shutdown() {
180
+ await closeBrowser();
181
+ process.exit(0);
182
+ }
183
+ process.on('SIGINT', shutdown);
184
+ process.on('SIGTERM', shutdown);
185
+
186
+ // ─── Start ─────────────────────────────────────────────────────────
187
+
188
+ console.error('[contrastcap] Server started — tools: get_contrast_summary, check_page_contrast, check_element_contrast, get_status');
189
+ const transport = new StdioServerTransport();
190
+ await server.connect(transport);
@@ -0,0 +1,238 @@
1
+ import { CONFIG, log } from '../config.js';
2
+ import { validateUrl } from '../utils/urlValidate.js';
3
+ import { withPage } from '../browser.js';
4
+ import { runContrastAudit, selectorFromNode, axeColors } from '../engine/axeRunner.js';
5
+ import { sampleBackgroundColor } from '../engine/pixelSampler.js';
6
+ import {
7
+ contrastRatio,
8
+ requiredRatio,
9
+ parseRgbString,
10
+ rgbToHex,
11
+ } from '../engine/contrastCalc.js';
12
+ import { suggestFix } from '../engine/colorSuggest.js';
13
+ import { isLargeText } from '../utils/largeText.js';
14
+
15
+ function rgbStringToHex(rgbStr) {
16
+ const { r, g, b } = parseRgbString(rgbStr);
17
+ return rgbToHex(r, g, b);
18
+ }
19
+
20
+ function withinMarginalDelta(ratio, required) {
21
+ return ratio < required + CONFIG.MARGINAL_DELTA && ratio >= required;
22
+ }
23
+
24
+ async function elementMeta(element) {
25
+ return element.evaluate((el) => {
26
+ const cs = getComputedStyle(el);
27
+ return {
28
+ color: cs.color,
29
+ fontSize: cs.fontSize, // always resolved to "NNpx"
30
+ fontWeight: cs.fontWeight,
31
+ text: (el.textContent || '').trim(),
32
+ };
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Resolve a single axe `incomplete` node via pixel sampling.
38
+ * Returns one of: { kind: 'pass' | 'fail' | 'warning' | 'skipped', entry? }
39
+ */
40
+ async function resolveIncomplete(page, node, level) {
41
+ const selector = selectorFromNode(node);
42
+ if (!selector || Array.isArray(node.target[0])) {
43
+ // Nested context (shadow DOM or iframe) — we can't reach it via page.$()
44
+ return { kind: 'skipped', reason: 'unreachable selector (shadow DOM or iframe)' };
45
+ }
46
+
47
+ let element;
48
+ try { element = await page.$(selector); } catch { element = null; }
49
+ if (!element) return { kind: 'skipped', reason: 'element not found' };
50
+
51
+ try {
52
+ await element.scrollIntoViewIfNeeded({ timeout: 1500 }).catch(() => { /* ignore */ });
53
+
54
+ const box = await element.boundingBox();
55
+ if (!box || box.width * box.height < 4) {
56
+ return { kind: 'skipped', reason: 'zero-size element' };
57
+ }
58
+
59
+ const meta = await elementMeta(element);
60
+ const fgPx = parseFloat(meta.fontSize);
61
+ const large = isLargeText(fgPx, meta.fontWeight);
62
+ const required = requiredRatio(large, level);
63
+
64
+ const fgHex = rgbStringToHex(meta.color);
65
+
66
+ const bg = await sampleBackgroundColor(page, element, box, meta.color);
67
+ const ratio = contrastRatio(fgHex, bg.hex);
68
+
69
+ const passes = ratio >= required;
70
+ const marginal = !passes ? false : withinMarginalDelta(ratio, required);
71
+
72
+ const base = {
73
+ selector,
74
+ text: meta.text,
75
+ ratio,
76
+ required,
77
+ level,
78
+ fontSize: meta.fontSize,
79
+ fontWeight: meta.fontWeight,
80
+ isLargeText: large,
81
+ foreground: fgHex,
82
+ background: bg.hex,
83
+ backgroundSource: bg.source,
84
+ };
85
+
86
+ if (!passes) {
87
+ base.suggestion = suggestFix(fgHex, bg.hex, large, level);
88
+ return { kind: 'fail', entry: base };
89
+ }
90
+
91
+ // Passes — but mark warning if marginal or over high-variance background.
92
+ if (marginal || bg.highVariance) {
93
+ const notes = [];
94
+ if (marginal) notes.push(`Ratio within ${CONFIG.MARGINAL_DELTA} of threshold — marginal.`);
95
+ if (bg.highVariance) notes.push('Background sampled from gradient or image — may vary at other positions.');
96
+ base.note = notes.join(' ');
97
+ return { kind: 'warning', entry: base };
98
+ }
99
+ return { kind: 'pass' };
100
+ } finally {
101
+ await element.dispose().catch(() => {});
102
+ }
103
+ }
104
+
105
+ function buildFailureFromViolation(node, level) {
106
+ const selector = selectorFromNode(node);
107
+ if (!selector) return null;
108
+ const colors = axeColors(node);
109
+ if (!colors || !colors.fgColor || !colors.bgColor) return null;
110
+
111
+ const fgHex = rgbStringToHex(colors.fgColor);
112
+ const bgHex = rgbStringToHex(colors.bgColor);
113
+
114
+ const fgPx = parseFloat(colors.fontSize || '16');
115
+ const fontWeight = colors.fontWeight || '400';
116
+ const large = isLargeText(fgPx, fontWeight);
117
+ const required = typeof colors.expectedContrastRatio === 'number'
118
+ ? colors.expectedContrastRatio
119
+ : requiredRatio(large, level);
120
+ const ratio = typeof colors.contrastRatio === 'number'
121
+ ? colors.contrastRatio
122
+ : contrastRatio(fgHex, bgHex);
123
+
124
+ return {
125
+ selector,
126
+ text: '',
127
+ ratio,
128
+ required,
129
+ level,
130
+ fontSize: colors.fontSize,
131
+ fontWeight: fontWeight,
132
+ isLargeText: large,
133
+ foreground: fgHex,
134
+ background: bgHex,
135
+ backgroundSource: 'computed',
136
+ suggestion: suggestFix(fgHex, bgHex, large, level),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Run the full page-level audit pipeline and return aggregate counts + entries.
142
+ *
143
+ * @param {string} url
144
+ * @param {'AA'|'AAA'} level
145
+ * @returns {Promise<{ finalUrl, axePassCount, resolvedPassCount, failures, warnings, skippedCount }>}
146
+ */
147
+ export async function auditPage(url, level) {
148
+ const validated = await validateUrl(url);
149
+
150
+ return withPage(async (page) => {
151
+ await page.goto(validated, {
152
+ timeout: CONFIG.NAV_TIMEOUT,
153
+ waitUntil: 'networkidle',
154
+ }).catch(async (err) => {
155
+ // networkidle can be flaky on long-polling pages — fall back to 'load'
156
+ if (/Timeout/i.test(err.message || '')) {
157
+ await page.goto(validated, { timeout: CONFIG.NAV_TIMEOUT, waitUntil: 'load' });
158
+ } else {
159
+ throw err;
160
+ }
161
+ });
162
+
163
+ // Post-redirect SSRF guard.
164
+ await validateUrl(page.url());
165
+
166
+ const { violations, incomplete, passes } = await runContrastAudit(page);
167
+
168
+ // Fill in text snippets for violations so failure entries are readable.
169
+ const failures = [];
170
+ for (const node of violations) {
171
+ const entry = buildFailureFromViolation(node, level);
172
+ if (!entry) continue;
173
+ try {
174
+ const el = await page.$(entry.selector);
175
+ if (el) {
176
+ const txt = await el.evaluate((e) => (e.textContent || '').trim());
177
+ entry.text = txt;
178
+ await el.dispose().catch(() => {});
179
+ }
180
+ } catch { /* ignore */ }
181
+ failures.push(entry);
182
+ }
183
+
184
+ const warnings = [];
185
+ let resolvedPassCount = 0;
186
+ let skippedCount = 0;
187
+ let processed = 0;
188
+
189
+ for (const node of incomplete) {
190
+ if (processed >= CONFIG.MAX_ELEMENTS) {
191
+ skippedCount++;
192
+ continue;
193
+ }
194
+ processed++;
195
+
196
+ let result;
197
+ try {
198
+ result = await Promise.race([
199
+ resolveIncomplete(page, node, level),
200
+ new Promise((_, rej) => setTimeout(
201
+ () => rej(new Error('element timeout')),
202
+ CONFIG.ELEMENT_TIMEOUT,
203
+ )),
204
+ ]);
205
+ } catch (err) {
206
+ log('debug', `element timeout/error: ${err.message}`);
207
+ skippedCount++;
208
+ continue;
209
+ }
210
+
211
+ if (result.kind === 'pass') resolvedPassCount++;
212
+ else if (result.kind === 'fail') failures.push(result.entry);
213
+ else if (result.kind === 'warning') warnings.push(result.entry);
214
+ else skippedCount++;
215
+ }
216
+
217
+ return {
218
+ finalUrl: page.url(),
219
+ axePassCount: passes.length,
220
+ resolvedPassCount,
221
+ failures,
222
+ warnings,
223
+ skippedCount,
224
+ };
225
+ });
226
+ }
227
+
228
+ // ─── Audit timeout wrapper ────────────────────────────────────────
229
+
230
+ export function withAuditTimeout(promise, label = 'Audit') {
231
+ return Promise.race([
232
+ promise,
233
+ new Promise((_, rej) => setTimeout(
234
+ () => rej(new Error(`${label} timed out`)),
235
+ CONFIG.AUDIT_TIMEOUT,
236
+ )),
237
+ ]);
238
+ }