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
178 lines
5.8 KiB
JavaScript
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);
|
|
});
|