Files
APAW/tests/scripts/extract-contrast.js
NW 9eb0a0ba34 feat(landing-visual): add landing-design-interpretation skill, landing-visual-debugging rule, extract-contrast script, and frontend-developer measurement-first protocol
Evolution to solve human-language design tasks (e.g., 'text blends into background').

New:
- .kilo/skills/landing-design-interpretation/SKILL.md — translation dictionary + contrast protocol
- .kilo/rules/landing-visual-debugging.md — mandatory 3-phase pipeline (Interpret→Measure→Fix)
- tests/scripts/extract-contrast.js — Playwright-based contrast extraction
- HOW_TO_SYNC_KILO.md — sync instructions for target projects

Updated:
- .kilo/agents/frontend-developer.md — mandatory measurement-first behavior for landing visual tasks
- kilo-meta.json — description updated for frontend-developer
- AGENTS.md, KILO_SPEC.md — auto-synced by script

Refs: landing visual debugging evolution
2026-05-22 18:27:44 +01:00

178 lines
5.8 KiB
JavaScript

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 <url> [--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);
});