Add color detection to browser visualizer script

Mirrors all 5 color anti-pattern checks from the CLI:
- pure-black-white (computed bg + Tailwind classes)
- gray-on-color (computed style + ancestor bg resolution + Tailwind)
- low-contrast (WCAG AA ratio via computed styles)
- gradient-text (computed background-clip + Tailwind bg-clip-text)
- ai-color-palette (computed hue analysis + Tailwind purple classes)

Browser version uses real getComputedStyle so bg resolution works
properly (unlike jsdom). Tailwind class checks are shared logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-17 13:06:19 -07:00
co-authored by Claude Opus 4.6
parent 27715edf8a
commit e45d6cde1a
+144 -1
View File
@@ -80,6 +80,144 @@
return findings;
}
function parseRgb(color) {
if (!color || color === 'transparent') return null;
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m) return null;
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
}
function relativeLuminance({ r, g, b }) {
const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
);
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function contrastRatio(c1, c2) {
const l1 = relativeLuminance(c1), l2 = relativeLuminance(c2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
function hasChroma(c, threshold = 30) {
return c && (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
}
function getHue(c) {
const r = c.r / 255, g = c.g / 255, b = c.b / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
if (max === min) return 0;
const d = max - min;
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return Math.round(h * 360);
}
function resolveBackground(el) {
let current = el;
while (current && current.nodeType === 1) {
const bg = parseRgb(getComputedStyle(current).backgroundColor);
if (bg && bg.a > 0.1) return bg;
current = current.parentElement;
}
return { r: 255, g: 255, b: 255 };
}
function checkElementColors(el) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag)) return [];
const rect = el.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return [];
const findings = [];
const style = getComputedStyle(el);
const textColor = parseRgb(style.color);
const bgColor = parseRgb(style.backgroundColor);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
const classList = el.getAttribute('class') || '';
// --- Pure black/white background ---
if (bgColor && bgColor.a > 0.1) {
if ((bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) ||
(bgColor.r === 255 && bgColor.g === 255 && bgColor.b === 255)) {
const label = bgColor.r === 0 ? '#000 background' : '#fff background';
findings.push({ type: 'pure-black-white', detail: label });
}
}
if (hasDirectText && textColor) {
const effectiveBg = resolveBackground(el);
// --- Gray on colored background ---
const textLum = relativeLuminance(textColor);
const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
if (isGray && hasChroma(effectiveBg, 40)) {
findings.push({ type: 'gray-on-color', detail: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
}
// --- Low contrast ---
const ratio = contrastRatio(textColor, effectiveBg);
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || ['h1','h2','h3'].includes(tag);
const threshold = isLargeText ? 3.0 : 4.5;
if (ratio < threshold) {
findings.push({ type: 'low-contrast', detail: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}` });
}
// --- AI palette: purple/violet on headings ---
if (hasChroma(textColor, 50)) {
const hue = getHue(textColor);
if (hue >= 260 && hue <= 310 && (['h1','h2','h3'].includes(tag) || fontSize >= 20)) {
findings.push({ type: 'ai-color-palette', detail: `Purple text (${colorToHex(textColor)}) on heading` });
}
}
}
// --- Gradient text ---
const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';
if (bgClip === 'text' && (style.backgroundImage || '').includes('gradient')) {
findings.push({ type: 'gradient-text', detail: 'background-clip: text + gradient' });
}
// --- Tailwind class checks ---
if (classList) {
const TW_GRAY_TEXT = /\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/;
const TW_COLORED_BG = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/;
if (/\bbg-black\b/.test(classList)) findings.push({ type: 'pure-black-white', detail: 'bg-black' });
if (/\bbg-white\b/.test(classList)) findings.push({ type: 'pure-black-white', detail: 'bg-white' });
if (/\btext-black\b/.test(classList)) findings.push({ type: 'pure-black-white', detail: 'text-black' });
const grayMatch = classList.match(TW_GRAY_TEXT);
const colorBgMatch = classList.match(TW_COLORED_BG);
if (grayMatch && colorBgMatch) {
findings.push({ type: 'gray-on-color', detail: `${grayMatch[0]} on ${colorBgMatch[0]}` });
}
if (/\bbg-clip-text\b/.test(classList) && /\bbg-gradient-to-/.test(classList)) {
findings.push({ type: 'gradient-text', detail: 'bg-clip-text + bg-gradient (Tailwind)' });
}
const purpleText = classList.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
if (purpleText && (['h1','h2','h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classList))) {
findings.push({ type: 'ai-color-palette', detail: `${purpleText[0]} on heading` });
}
if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classList) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classList)) {
findings.push({ type: 'ai-color-palette', detail: 'Purple gradient (Tailwind)' });
}
}
return findings;
}
function checkTypography() {
const findings = [];
@@ -150,6 +288,11 @@
'overused-font': 'overused font',
'single-font': 'single font',
'flat-type-hierarchy': 'flat hierarchy',
'pure-black-white': 'pure #000/#fff',
'gray-on-color': 'gray on color',
'low-contrast': 'low contrast',
'gradient-text': 'gradient text',
'ai-color-palette': 'AI palette',
};
function highlight(el, findings) {
@@ -276,7 +419,7 @@
if (el.classList.contains('impeccable-overlay') ||
el.classList.contains('impeccable-label') ||
el.classList.contains('impeccable-tooltip')) continue;
const findings = checkElementBorders(el);
const findings = [...checkElementBorders(el), ...checkElementColors(el)];
if (findings.length > 0) {
highlight(el, findings);
allFindings.push({ el, findings });