const { chromium } = require('playwright'); const fs = require('fs'); /** * Extract contrast ratios for all visible text elements on a page. * Writes JSON report with violations where contrast < threshold. * * Usage: * node extract-contrast.js https://example.com [--threshold 4.5] [--output contrast-report.json] */ function luminance(r, g, b) { const a = [r, g, b].map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; } function hexToRgb(hex) { const m = hex.replace('#', '').match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); if (!m) return null; return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) }; } function parseRgb(rgb) { const m = rgb.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/); if (!m) return null; return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? parseFloat(m[4]) : 1 }; } function colorToRgb(str) { if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return null; if (str.startsWith('#')) return hexToRgb(str); return parseRgb(str); } function contrastRatio(c1, c2) { if (!c1 || !c2) return null; const l1 = luminance(c1.r, c1.g, c1.b) + 0.05; const l2 = luminance(c2.r, c2.g, c2.b) + 0.05; return l1 > l2 ? l1 / l2 : l2 / l1; } function getEffectiveBackground(el) { while (el && el !== document.documentElement) { const style = window.getComputedStyle(el); const bg = style.backgroundColor; if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') { return bg; } el = el.parentElement; } return 'rgb(255, 255, 255)'; } async function extractContrast(url, threshold = 4.5) { const browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle' }); const issues = await page.evaluate(({ threshold }) => { function luminance(r, g, b) { const a = [r, g, b].map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; } function parseColor(str) { if (!str || str === 'transparent') return null; if (str.startsWith('#')) { const m = str.replace('#', '').match(/^([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); return m ? { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) } : null; } const m = str.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/); return m ? { r: +m[1], g: +m[2], b: +m[3] } : null; } function contrast(c1, c2) { if (!c1 || !c2) return 0; const l1 = luminance(c1.r, c1.g, c1.b) + 0.05; const l2 = luminance(c2.r, c2.g, c2.b) + 0.05; return l1 > l2 ? l1 / l2 : l2 / l1; } const results = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node; const seen = new Set(); while ((node = walker.nextNode())) { const el = node.parentElement; if (!el || !el.getBoundingClientRect) continue; const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) continue; const text = node.textContent.trim(); if (!text || text.length < 2) continue; let bg = window.getComputedStyle(el).backgroundColor; if (!bg || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent') { let p = el.parentElement; while (p) { const s = window.getComputedStyle(p); if (s.backgroundColor && s.backgroundColor !== 'rgba(0, 0, 0, 0)' && s.backgroundColor !== 'transparent') { bg = s.backgroundColor; break; } p = p.parentElement; } if (!bg) bg = 'rgb(255, 255, 255)'; } const style = window.getComputedStyle(el); const color = style.color; const colorRgb = parseColor(color); const bgRgb = parseColor(bg); const ratio = contrast(colorRgb, bgRgb); if (ratio < threshold) { const key = el.tagName + (el.className ? '.' + el.className.split(/\s+/).join('.') : '') + '::' + color + '::' + bg; if (seen.has(key)) continue; seen.add(key); results.push({ tagName: el.tagName, className: el.className || '', id: el.id || '', selector: el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (el.className ? '.' + el.className.split(/\s+/).join('.') : ''), color, backgroundColor: bg, ratio: Math.round(ratio * 100) / 100, textLength: text.length, textPreview: text.slice(0, 80).replace(/\s+/g, ' '), fontSize: style.fontSize, fontWeight: style.fontWeight, isInvisible: ratio <= 1.2, }); } } return results; }, { threshold }); await browser.close(); const report = { url, threshold, totalViolations: issues.length, invisibleCount: issues.filter(i => i.isInvisible).length, issues: issues.sort((a, b) => a.ratio - b.ratio), }; return report; } async function main() { const url = process.argv[2]; if (!url) { console.error('Usage: node extract-contrast.js [--threshold 4.5] [--output report.json]'); process.exit(1); } const threshold = (process.argv.indexOf('--threshold') >= 0 ? parseFloat(process.argv[process.argv.indexOf('--threshold') + 1]) : null) || 4.5; const outputIdx = process.argv.indexOf('--output'); const output = outputIdx >= 0 ? process.argv[outputIdx + 1] : null; const report = await extractContrast(url, threshold); const json = JSON.stringify(report, null, 2); if (output) { fs.writeFileSync(output, json); console.log(`Report written to ${output}`); } else { console.log(json); } } main().catch(e => { console.error(e); process.exit(1); });