@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Illinois Criminal Justice Information Authority (ICJIA)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # @icjia/contrastcap
2
+
3
+ **MCP server for automated WCAG contrast auditing via pixel-level analysis.**
4
+
5
+ `contrastcap` resolves the "needs review" gap that axe-core and SiteImprove leave behind. When text sits over a complex background (gradient, image, semi-transparent overlay), axe can't determine the rendered contrast ratio from the DOM alone and marks the element `incomplete`. `contrastcap` loads the page in headless Chromium, screenshots the element region with the text hidden, samples actual rendered pixels, and returns a decisive pass / fail / warning with a concrete hex color suggestion for failures.
6
+
7
+ Built for the same triage workflow as `@icjia/lightcap` and `@icjia/viewcap` — stdio transport, ESM, minimal token footprint, `get_status` tool, `publish.sh`.
8
+
9
+ ---
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pnpm install
15
+ # Playwright's Chromium is fetched automatically via postinstall.
16
+ # If that fails (offline, CI), run manually:
17
+ pnpm exec playwright install chromium
18
+ ```
19
+
20
+ Requires Node 20+.
21
+
22
+ ## Claude Desktop / Claude Code configuration
23
+
24
+ Add to `claude_desktop_config.json` (or your IDE's MCP config):
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "contrastcap": {
30
+ "command": "npx",
31
+ "args": ["-y", "@icjia/contrastcap"]
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Or, pointing at a local checkout:
38
+
39
+ ```json
40
+ {
41
+ "mcpServers": {
42
+ "contrastcap": {
43
+ "command": "node",
44
+ "args": ["/absolute/path/to/contrastcap-mcp/src/server.js"]
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ Restart Claude to pick up the new server.
51
+
52
+ ---
53
+
54
+ ## Tools
55
+
56
+ All four tools default to **WCAG AA**. `AAA` must be explicitly requested via `level: "AAA"`.
57
+
58
+ ### `get_contrast_summary`
59
+
60
+ Counts only — the cheapest token footprint. Use this first to decide whether a full audit is warranted.
61
+
62
+ ```json
63
+ { "url": "https://example.com/about" }
64
+ ```
65
+
66
+ Returns:
67
+
68
+ ```json
69
+ {
70
+ "url": "https://example.com/about",
71
+ "timestamp": "2026-04-13T14:30:00Z",
72
+ "wcag_level": "AA",
73
+ "counts": {
74
+ "total_elements_checked": 52,
75
+ "pass": 47,
76
+ "fail": 3,
77
+ "warning": 2,
78
+ "skipped": 0
79
+ }
80
+ }
81
+ ```
82
+
83
+ ### `check_page_contrast`
84
+
85
+ Full page audit. Returns detail for failures and warnings only — passing elements are counted, not itemized.
86
+
87
+ ```json
88
+ { "url": "https://example.com/about", "level": "AA" }
89
+ ```
90
+
91
+ Returns:
92
+
93
+ ```json
94
+ {
95
+ "url": "...",
96
+ "timestamp": "...",
97
+ "wcag_level": "AA",
98
+ "summary": { "total": 52, "pass": 47, "fail": 3, "warning": 2, "skipped": 0 },
99
+ "failures": [
100
+ {
101
+ "selector": "nav.main-nav > ul > li:nth-child(3) > a",
102
+ "text": "Grant Opportunities",
103
+ "ratio": 3.21,
104
+ "required": 4.5,
105
+ "level": "AA",
106
+ "fontSize": "14px",
107
+ "fontWeight": "400",
108
+ "isLargeText": false,
109
+ "foreground": "#6c757d",
110
+ "background": "#e9ecef",
111
+ "backgroundSource": "pixel-sample",
112
+ "suggestion": "#595f64"
113
+ }
114
+ ],
115
+ "warnings": [
116
+ {
117
+ "selector": ".hero-banner h1",
118
+ "text": "Criminal Justice Information…",
119
+ "ratio": 4.62,
120
+ "required": 4.5,
121
+ "level": "AA",
122
+ "foreground": "#ffffff",
123
+ "background": "#5a7a91",
124
+ "backgroundSource": "pixel-sample-over-image",
125
+ "note": "Ratio within 0.3 of threshold — marginal. Background sampled from gradient or image — may vary at other positions."
126
+ }
127
+ ]
128
+ }
129
+ ```
130
+
131
+ **Suggestion format is always hex** (e.g. `"#595f64"`). The caller formats prose.
132
+
133
+ ### `check_element_contrast`
134
+
135
+ Single-element check. Use this to verify a fix without re-running the full page audit.
136
+
137
+ ```json
138
+ {
139
+ "url": "http://localhost:3000/about",
140
+ "selector": "nav.main-nav > ul > li:nth-child(3) > a"
141
+ }
142
+ ```
143
+
144
+ Returns a single-element object with `pass: true|false`, the measured `ratio`, `foreground`, `background`, and a `suggestion` hex if failing.
145
+
146
+ ### `get_status`
147
+
148
+ Server + axe-core + Playwright versions, plus a non-blocking npm update check.
149
+
150
+ ---
151
+
152
+ ## How it works
153
+
154
+ 1. Playwright navigates to the URL (30s timeout, `networkidle` fallback to `load`).
155
+ 2. The server re-validates `page.url()` against the SSRF denylist (redirect guard).
156
+ 3. axe-core is injected via `page.evaluate` and run with `color-contrast` only. Its `violations` (definite failures) and `passes` (definite passes) are trusted as-is.
157
+ 4. For every `incomplete` (needs-review) node:
158
+ - Scroll into view
159
+ - Read computed `color`, `fontSize` (always resolved to px), `fontWeight`
160
+ - Save the element's prior inline `color`, set it to `transparent`, screenshot the bounding box, then restore
161
+ - Decode pixels via `sharp`, sample on a 5×3 grid
162
+ - If per-channel stddev > 15, treat as gradient/image and use worst-case pixel (darkest on light text, lightest on dark text)
163
+ - Otherwise take the median per channel
164
+ - Compute the WCAG 2.1 ratio and compare against the required threshold
165
+ 5. For failures, compute a hex color suggestion via 16-iteration HSL-lightness binary search in both directions; return whichever candidate has the smaller `|ΔL|` from the original foreground.
166
+ 6. Passes bump the `pass` count. Marginal passes or high-variance backgrounds are flagged as warnings, not failures.
167
+
168
+ ### Limits & timeouts
169
+
170
+ | Scope | Limit |
171
+ |-------|-------|
172
+ | Page navigation | 30 s |
173
+ | Per-element pixel sampling | 5 s (skipped on timeout, audit continues) |
174
+ | Total audit | 120 s (returns `Audit timed out`) |
175
+ | Max elements pixel-sampled per page | 200 |
176
+ | Concurrent audits per process | 2 (queue-full error beyond that) |
177
+
178
+ ### What's out of scope (v1)
179
+
180
+ - Authenticated pages (no cookie/session handling)
181
+ - Multi-page crawling (use `a11yscan` for that)
182
+ - Focus/hover state contrast
183
+ - Dark-mode toggling
184
+ - Non-text contrast (UI components, graphical objects)
185
+ - Elements inside shadow DOM or cross-origin iframes (counted under `skipped`)
186
+ - PDF contrast
187
+
188
+ ---
189
+
190
+ ## Environment variables
191
+
192
+ | Variable | Default | Purpose |
193
+ |----------|---------|---------|
194
+ | `CONTRASTCAP_NAV_TIMEOUT` | `30000` | Page navigation timeout (ms) |
195
+ | `CONTRASTCAP_ELEMENT_TIMEOUT` | `5000` | Per-element pixel sampling timeout (ms) |
196
+ | `CONTRASTCAP_AUDIT_TIMEOUT` | `120000` | Total audit cap (ms) |
197
+ | `CONTRASTCAP_LEVEL` | `AA` | Default WCAG level (`AA` or `AAA`) |
198
+ | `CONTRASTCAP_MAX_ELEMENTS` | `200` | Max elements to pixel-sample per page |
199
+ | `CONTRASTCAP_MAX_CONCURRENT` | `2` | Max concurrent audits per process |
200
+ | `CONTRASTCAP_VIEWPORT_WIDTH` | `1280` | Chromium viewport width |
201
+ | `CONTRASTCAP_VIEWPORT_HEIGHT` | `800` | Chromium viewport height |
202
+
203
+ ---
204
+
205
+ ## CLI
206
+
207
+ The package also exposes a CLI for local use without an MCP client:
208
+
209
+ ```bash
210
+ npx @icjia/contrastcap summary https://example.com/about
211
+ npx @icjia/contrastcap page https://example.com/about --level AAA
212
+ npx @icjia/contrastcap element http://localhost:3000 'nav a'
213
+ npx @icjia/contrastcap status
214
+ ```
215
+
216
+ With no subcommand, the binary starts the MCP server on stdio.
217
+
218
+ ---
219
+
220
+ ## Publishing
221
+
222
+ `./publish.sh` mirrors the pattern used by `@icjia/lightcap` and `@icjia/viewcap`:
223
+
224
+ ```bash
225
+ ./publish.sh # bump patch version and publish (default)
226
+ ./publish.sh minor # bump minor version and publish
227
+ ./publish.sh major # bump major version and publish
228
+ ./publish.sh --dry-run # dry run only, no publish
229
+ ```
230
+
231
+ First-time publish is auto-detected (no existing version on npm) — the current `package.json` version is used as-is. Subsequent releases bump + tag + push.
232
+
233
+ ---
234
+
235
+ ## Security
236
+
237
+ - Scheme allowlist: `http:` and `https:` only. `file:`, `javascript:`, `data:`, `ftp:` etc. are rejected with a generic `Blocked URL scheme` error.
238
+ - Cloud-metadata hostnames blocked: `169.254.169.254`, `metadata.google.internal`, `metadata.azure.com`, `0.0.0.0`.
239
+ - IP-prefix denylist (DNS-resolved): IPv4 link-local (`169.254.`), IPv6 unique-local (`fd00:`), link-local (`fe80:`), unspecified (`::`). DNS-resolution failures fail closed.
240
+ - Post-navigation re-check: after `page.goto` settles, `page.url()` is re-validated before any pixel sampling.
241
+ - Generic error messages — no filesystem paths or stack traces are returned to MCP clients.
242
+ - No file writes. Screenshots are in-memory buffers consumed by `sharp` and discarded.
243
+
244
+ Private / localhost IPs are **allowed** by design — the primary use case is auditing dev servers.
245
+
246
+ ---
247
+
248
+ ## License
249
+
250
+ MIT © 2026 Illinois Criminal Justice Information Authority (ICJIA)
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@icjia/contrastcap",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for automated WCAG contrast auditing via pixel-level analysis — resolves axe-core 'needs review' items by sampling actual rendered pixels in headless Chromium",
5
+ "type": "module",
6
+ "main": "src/server.js",
7
+ "bin": {
8
+ "contrastcap": "src/cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node src/server.js",
12
+ "test": "node --test test/*.test.js",
13
+ "postinstall": "node -e \"try { require('child_process').execSync('playwright install chromium', { stdio: 'inherit' }) } catch (e) { console.error('[contrastcap] playwright install chromium failed:', e.message); process.exit(0) }\""
14
+ },
15
+ "files": [
16
+ "src/",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "accessibility",
25
+ "wcag",
26
+ "contrast",
27
+ "axe-core",
28
+ "playwright",
29
+ "claude",
30
+ "a11y"
31
+ ],
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/ICJIA/contrastcap-mcp.git"
36
+ },
37
+ "dependencies": {
38
+ "@cfworker/json-schema": "^4.1.1",
39
+ "@modelcontextprotocol/server": "^2.0.0-alpha.2",
40
+ "axe-core": "^4.10.0",
41
+ "commander": "^14.0.3",
42
+ "playwright": "^1.49.0",
43
+ "sharp": "^0.34.0",
44
+ "zod": "^4.3.6"
45
+ }
46
+ }
package/src/browser.js ADDED
@@ -0,0 +1,58 @@
1
+ import { chromium } from 'playwright';
2
+ import { CONFIG, log } from './config.js';
3
+
4
+ let _browser = null;
5
+ let _launching = null;
6
+
7
+ export async function getBrowser() {
8
+ if (_browser) return _browser;
9
+ if (_launching) return _launching;
10
+
11
+ _launching = chromium.launch({
12
+ headless: true,
13
+ args: [
14
+ '--disable-dev-shm-usage',
15
+ '--disable-gpu',
16
+ '--disable-extensions',
17
+ '--disable-background-networking',
18
+ '--no-first-run',
19
+ '--no-default-browser-check',
20
+ ...(process.platform === 'linux' ? ['--no-sandbox', '--disable-setuid-sandbox'] : []),
21
+ ],
22
+ }).then((b) => {
23
+ _browser = b;
24
+ _launching = null;
25
+ log('info', 'Chromium launched');
26
+ return b;
27
+ }).catch((err) => {
28
+ _launching = null;
29
+ throw err;
30
+ });
31
+
32
+ return _launching;
33
+ }
34
+
35
+ export async function closeBrowser() {
36
+ if (!_browser) return;
37
+ try { await _browser.close(); } catch { /* ignore */ }
38
+ _browser = null;
39
+ }
40
+
41
+ /**
42
+ * Open a fresh context + page, run `fn`, then tear it down.
43
+ * Context is disposable per call to avoid state leaks between audits.
44
+ */
45
+ export async function withPage(fn) {
46
+ const browser = await getBrowser();
47
+ const context = await browser.newContext({
48
+ viewport: { width: CONFIG.VIEWPORT_WIDTH, height: CONFIG.VIEWPORT_HEIGHT },
49
+ deviceScaleFactor: 1, // pixel sampling needs CSS pixels, not retina
50
+ userAgent: CONFIG.USER_AGENT,
51
+ });
52
+ const page = await context.newPage();
53
+ try {
54
+ return await fn(page);
55
+ } finally {
56
+ await context.close().catch(() => { /* ignore */ });
57
+ }
58
+ }
package/src/cli.js ADDED
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { program } from 'commander';
4
+ import { readFileSync } from 'fs';
5
+ import { execFile } from 'child_process';
6
+ import { setVerbosity, CONFIG } from './config.js';
7
+ import { closeBrowser } from './browser.js';
8
+ import { checkPageContrast } from './tools/checkPageContrast.js';
9
+ import { checkElementContrast } from './tools/checkElementContrast.js';
10
+ import { getContrastSummary } from './tools/getContrastSummary.js';
11
+ import { sanitizeError } from './utils/sanitizeError.js';
12
+
13
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)));
14
+
15
+ program
16
+ .name('contrastcap')
17
+ .description('WCAG contrast auditor — pixel-level resolution of axe-core "needs review" items')
18
+ .version(pkg.version);
19
+
20
+ program
21
+ .option('--verbose', 'Verbose logging')
22
+ .option('--quiet', 'Errors only');
23
+
24
+ function applyGlobalOptions() {
25
+ const opts = program.opts();
26
+ if (opts.verbose) setVerbosity('verbose');
27
+ if (opts.quiet) setVerbosity('quiet');
28
+ }
29
+
30
+ function validLevel(v) {
31
+ return v === 'AAA' ? 'AAA' : 'AA';
32
+ }
33
+
34
+ async function runAndPrint(fn) {
35
+ try {
36
+ const result = await fn();
37
+ console.log(JSON.stringify(result, null, 2));
38
+ } catch (err) {
39
+ console.error(`Error: ${sanitizeError(err)}`);
40
+ process.exitCode = 1;
41
+ } finally {
42
+ await closeBrowser();
43
+ }
44
+ }
45
+
46
+ program
47
+ .command('summary <url>')
48
+ .description('Summary counts only (lowest token cost)')
49
+ .option('-l, --level <AA|AAA>', 'WCAG level', 'AA')
50
+ .action(async (url, opts) => {
51
+ applyGlobalOptions();
52
+ await runAndPrint(() => getContrastSummary({ url, level: validLevel(opts.level) }));
53
+ });
54
+
55
+ program
56
+ .command('page <url>')
57
+ .description('Full page audit — failures and warnings with detail')
58
+ .option('-l, --level <AA|AAA>', 'WCAG level', 'AA')
59
+ .action(async (url, opts) => {
60
+ applyGlobalOptions();
61
+ await runAndPrint(() => checkPageContrast({ url, level: validLevel(opts.level) }));
62
+ });
63
+
64
+ program
65
+ .command('element <url> <selector>')
66
+ .description('Check a single element by CSS selector')
67
+ .option('-l, --level <AA|AAA>', 'WCAG level', 'AA')
68
+ .action(async (url, selector, opts) => {
69
+ applyGlobalOptions();
70
+ await runAndPrint(() => checkElementContrast({ url, selector, level: validLevel(opts.level) }));
71
+ });
72
+
73
+ program
74
+ .command('status')
75
+ .description('Show version info')
76
+ .action(async () => {
77
+ let axeVersion = 'unknown';
78
+ let playwrightVersion = 'unknown';
79
+ try {
80
+ axeVersion = JSON.parse(readFileSync(new URL('../node_modules/axe-core/package.json', import.meta.url))).version;
81
+ } catch { /* ignore */ }
82
+ try {
83
+ playwrightVersion = JSON.parse(readFileSync(new URL('../node_modules/playwright/package.json', import.meta.url))).version;
84
+ } catch { /* ignore */ }
85
+
86
+ let latest = 'unknown';
87
+ try {
88
+ latest = await new Promise((resolve, reject) => {
89
+ execFile('npm', ['view', '@icjia/contrastcap', 'version'], { timeout: 5000 }, (err, stdout) => {
90
+ if (err) reject(err);
91
+ else {
92
+ const raw = stdout.trim();
93
+ resolve(/^\d+\.\d+\.\d+/.test(raw) ? raw : 'unknown');
94
+ }
95
+ });
96
+ });
97
+ } catch { /* ignore */ }
98
+
99
+ const updateNote = (latest === 'unknown' || latest === pkg.version)
100
+ ? '(latest)'
101
+ : `(latest: v${latest} — update available)`;
102
+
103
+ console.log('contrastcap status');
104
+ console.log(` Server: @icjia/contrastcap v${pkg.version} ${updateNote}`);
105
+ console.log(` axe-core: v${axeVersion}`);
106
+ console.log(` playwright: v${playwrightVersion}`);
107
+ console.log(` Node: v${process.versions.node}`);
108
+ console.log(` Platform: ${process.platform} ${process.arch}`);
109
+ console.log(` Default: WCAG ${CONFIG.DEFAULT_LEVEL}`);
110
+ });
111
+
112
+ // Default: start the MCP server when invoked with no subcommand (the npx entry)
113
+ const subcommands = ['summary', 'page', 'element', 'status', 'help'];
114
+ const arg2 = process.argv[2];
115
+ const isSubcommand = arg2 && (
116
+ subcommands.includes(arg2) ||
117
+ arg2 === '--help' || arg2 === '-h' ||
118
+ arg2 === '--version' || arg2 === '-V'
119
+ );
120
+
121
+ if (!arg2 || (!isSubcommand && arg2.startsWith('-'))) {
122
+ await import('./server.js');
123
+ } else {
124
+ program.parse();
125
+ }
package/src/config.js ADDED
@@ -0,0 +1,69 @@
1
+ const envInt = (key, fallback) => {
2
+ const v = process.env[key];
3
+ if (!v) return fallback;
4
+ const n = parseInt(v, 10);
5
+ return Number.isFinite(n) && n > 0 ? n : fallback;
6
+ };
7
+
8
+ const envEnum = (key, allowed, fallback) => {
9
+ const v = process.env[key];
10
+ return allowed.includes(v) ? v : fallback;
11
+ };
12
+
13
+ export const CONFIG = {
14
+ // Timeouts
15
+ NAV_TIMEOUT: envInt('CONTRASTCAP_NAV_TIMEOUT', 30_000),
16
+ ELEMENT_TIMEOUT: envInt('CONTRASTCAP_ELEMENT_TIMEOUT', 5_000),
17
+ AUDIT_TIMEOUT: envInt('CONTRASTCAP_AUDIT_TIMEOUT', 120_000),
18
+
19
+ // Audit behavior
20
+ DEFAULT_LEVEL: envEnum('CONTRASTCAP_LEVEL', ['AA', 'AAA'], 'AA'),
21
+ MAX_ELEMENTS: envInt('CONTRASTCAP_MAX_ELEMENTS', 200),
22
+ MAX_CONCURRENT: envInt('CONTRASTCAP_MAX_CONCURRENT', 2),
23
+
24
+ // Viewport
25
+ VIEWPORT_WIDTH: envInt('CONTRASTCAP_VIEWPORT_WIDTH', 1280),
26
+ VIEWPORT_HEIGHT: envInt('CONTRASTCAP_VIEWPORT_HEIGHT', 800),
27
+
28
+ // Input caps
29
+ MAX_URL_LENGTH: 2048,
30
+ SELECTOR_MAX_LEN: 1024,
31
+
32
+ // Warning heuristics
33
+ MARGINAL_DELTA: 0.3, // ratio within this of threshold → warning, not fail
34
+ VARIANCE_STDDEV: 15, // per-channel stddev above this → high variance
35
+
36
+ USER_AGENT: 'contrastcap-mcp/0.1 (WCAG contrast auditor)',
37
+
38
+ // SSRF denylist — mirrors lightcap.
39
+ BLOCKED_HOSTNAMES: [
40
+ '169.254.169.254',
41
+ 'metadata.google.internal',
42
+ 'metadata.azure.com',
43
+ '0.0.0.0',
44
+ ],
45
+ BLOCKED_IP_PREFIXES: [
46
+ '169.254.', // IPv4 link-local (AWS IMDS)
47
+ 'fd00:', // IPv6 unique-local
48
+ 'fe80:', // IPv6 link-local
49
+ '::', // IPv6 unspecified/loopback-equivalent
50
+ ],
51
+ LOCALHOST_HOSTS: [
52
+ 'localhost', '127.0.0.1', '::1', '[::1]',
53
+ ],
54
+ };
55
+
56
+ // ─── Logging ──────────────────────────────────────────────────────
57
+ // Verbosity: 'quiet' = errors only, 'normal' = error+info, 'verbose' = +debug
58
+
59
+ let verbosity = 'normal';
60
+
61
+ export function setVerbosity(level) {
62
+ if (['quiet', 'normal', 'verbose'].includes(level)) verbosity = level;
63
+ }
64
+
65
+ export function log(level, msg) {
66
+ if (verbosity === 'quiet' && level !== 'error') return;
67
+ if (verbosity === 'normal' && level === 'debug') return;
68
+ console.error(`[contrastcap] ${msg}`);
69
+ }
@@ -0,0 +1,73 @@
1
+ import axeCore from 'axe-core';
2
+
3
+ const AXE_SOURCE = axeCore.source;
4
+
5
+ /**
6
+ * Inject axe-core into the page and run the color-contrast rule only.
7
+ *
8
+ * Returns flattened arrays of nodes (not rules) for violations / incomplete / passes.
9
+ * Each node retains its original axe shape: { target, html, any, all, none, ... }.
10
+ *
11
+ * We trust axe's result for `violations` (definite failures with known colors)
12
+ * and `passes` (definite passes). Our job is to re-resolve every `incomplete`
13
+ * node via pixel sampling.
14
+ */
15
+ export async function runContrastAudit(page) {
16
+ await page.evaluate((source) => {
17
+ // Guard against double-injection on re-runs
18
+ if (!window.axe) {
19
+ const s = document.createElement('script');
20
+ s.textContent = source;
21
+ document.head.appendChild(s);
22
+ }
23
+ }, AXE_SOURCE);
24
+
25
+ const results = await page.evaluate(async () => {
26
+ return await window.axe.run(document, {
27
+ runOnly: { type: 'rule', values: ['color-contrast'] },
28
+ resultTypes: ['violations', 'incomplete', 'passes'],
29
+ });
30
+ });
31
+
32
+ const nodesOf = (arr) => (arr || []).flatMap(rule => rule.nodes || []);
33
+
34
+ return {
35
+ violations: nodesOf(results.violations),
36
+ incomplete: nodesOf(results.incomplete),
37
+ passes: nodesOf(results.passes),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Extract the first usable CSS selector from an axe node.
43
+ * axe sometimes returns nested selectors (shadow DOM, frames) — fall back to the
44
+ * first element of the path.
45
+ */
46
+ export function selectorFromNode(node) {
47
+ if (!node || !node.target) return null;
48
+ const t = node.target[0];
49
+ if (typeof t === 'string') return t;
50
+ if (Array.isArray(t)) return t[0]; // nested context (shadow/iframe) — not reachable via page.$()
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Pull axe's computed fg/bg (available on definite violations and many passes)
56
+ * from the standard `color-contrast` data shape.
57
+ */
58
+ export function axeColors(node) {
59
+ const checks = [...(node.any || []), ...(node.all || []), ...(node.none || [])];
60
+ for (const c of checks) {
61
+ if (c?.data && (c.data.fgColor || c.data.bgColor)) {
62
+ return {
63
+ fgColor: c.data.fgColor || null,
64
+ bgColor: c.data.bgColor || null,
65
+ contrastRatio: typeof c.data.contrastRatio === 'number' ? c.data.contrastRatio : null,
66
+ fontSize: c.data.fontSize || null,
67
+ fontWeight: c.data.fontWeight || null,
68
+ expectedContrastRatio: typeof c.data.expectedContrastRatio === 'number' ? c.data.expectedContrastRatio : null,
69
+ };
70
+ }
71
+ }
72
+ return null;
73
+ }