Add color and contrast anti-pattern detection

Five new detections:
- pure-black-white: flags #000/#fff in styles via regex (jsdom bg
  resolution unreliable for this)
- gray-on-color: gray text (low chroma, mid luminance) on colored
  backgrounds via getComputedStyle + ancestor bg walk
- low-contrast: WCAG AA violation (4.5:1 body, 3:1 large text) via
  computed contrast ratio with resolved effective background
- gradient-text: background-clip:text + gradient combo via regex
  (jsdom doesn't compute background-clip)
- ai-color-palette: conservative purple/violet accent detection via
  regex on known hex values in prominent contexts

Background resolution handles jsdom limitation where background
shorthand isn't decomposed — falls back to parsing raw style attribute
for hex colors.

Color fixtures added for both should-flag (all 5 types) and
should-pass (tinted neutrals, good contrast, non-purple accents).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-17 12:41:45 -07:00
co-authored by Claude Opus 4.6
parent ed1579ee37
commit 8a357d4754
6 changed files with 668 additions and 3 deletions
@@ -77,6 +77,39 @@ const ANTIPATTERNS = [
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
},
// -------------------------------------------------------------------------
// Color & contrast anti-patterns
// -------------------------------------------------------------------------
{
id: 'pure-black-white',
name: 'Pure black or white',
description:
'Pure #000 or #fff never appears in nature. Tint your blacks and whites slightly toward your brand hue for a more natural, cohesive feel.',
},
{
id: 'gray-on-color',
name: 'Gray text on colored background',
description:
'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
},
{
id: 'low-contrast',
name: 'Low contrast text',
description:
'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
},
{
id: 'gradient-text',
name: 'Gradient text',
description:
'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
},
{
id: 'ai-color-palette',
name: 'AI color palette',
description:
'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
},
];
/** Check if content looks like a full page (not a component/partial) */
@@ -110,6 +143,187 @@ function isNeutralColor(color) {
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
/**
* Parse an RGB/RGBA color string into { r, g, b, a } (0-255 for rgb, 0-1 for a).
* Returns null if unparseable.
*/
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 };
}
/**
* Compute relative luminance (WCAG 2.x formula).
* Input: { r, g, b } with values 0-255.
*/
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;
}
/**
* Compute WCAG contrast ratio between two colors.
* Returns a number >= 1.
*/
function contrastRatio(c1, c2) {
const l1 = relativeLuminance(c1);
const l2 = relativeLuminance(c2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Check if a color is pure black or pure white.
*/
function isPureBlackOrWhite(c) {
if (!c) return false;
return (c.r === 0 && c.g === 0 && c.b === 0) ||
(c.r === 255 && c.g === 255 && c.b === 255);
}
/**
* Check if a color has meaningful chroma (is "colored" vs gray/neutral).
* Uses simple RGB saturation check.
*/
function hasChroma(c, threshold = 30) {
if (!c) return false;
return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
}
/**
* Get the approximate hue (0-360) from RGB.
*/
function getHue(c) {
if (!c) return 0;
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 colorToHex(c) {
if (!c) return '?';
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
/**
* Resolve the effective background color for an element by walking up ancestors.
* Returns { r, g, b } or { r: 255, g: 255, b: 255 } as fallback (white).
*/
function resolveBackground(el, window) {
let current = el;
while (current && current.nodeType === 1) {
const style = window.getComputedStyle(current);
// Try backgroundColor first, then fall back to parsing the inline background shorthand
// (jsdom doesn't decompose background shorthand to backgroundColor reliably)
let bg = parseRgb(style.backgroundColor);
if (!bg || bg.a < 0.1) {
// jsdom doesn't reliably decompose background shorthand — parse raw style attr
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
bg = parseRgb(inlineBg);
if (!bg && inlineBg) {
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
if (hexMatch) {
const h = hexMatch[1];
if (h.length === 6) {
bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
} else {
bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
}
}
}
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.5) return bg;
}
current = current.parentElement;
}
return { r: 255, g: 255, b: 255 }; // default to white
}
/**
* Analyze an element's colors for anti-patterns.
* Needs the element, its computed style, AND access to the window for ancestor bg resolution.
*/
function checkElementColors(el, style, tag, window) {
if (SAFE_TAGS.has(tag)) return [];
const findings = [];
const textColor = parseRgb(style.color);
const bgColor = parseRgb(style.backgroundColor);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
// Skip non-text elements (no text content)
const hasText = el.textContent?.trim().length > 0;
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
// Pure black/white is handled via regex on raw HTML (jsdom's computed bg is unreliable)
if (hasDirectText && textColor) {
// --- Gray text on colored background ---
const effectiveBg = resolveBackground(el, window);
// Gray = low chroma AND mid-range luminance (not near-white or near-black)
const textLum = relativeLuminance(textColor);
const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
if (isGray && hasChroma(effectiveBg, 40)) {
findings.push({
id: 'gray-on-color',
snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}`,
});
}
// --- Low contrast (WCAG AA) ---
{
const ratio = contrastRatio(textColor, effectiveBg);
// jsdom may return fontSize in non-px units — also check tag-based heuristic
const isHeading = ['h1', 'h2', 'h3'].includes(tag);
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
const threshold = isLargeText ? 3.0 : 4.5;
if (ratio < threshold) {
findings.push({
id: 'low-contrast',
snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}`,
});
}
}
}
// --- Gradient text ---
const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';
const bgImage = style.backgroundImage || '';
if (bgClip === 'text' && bgImage.includes('gradient')) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
}
// --- AI color palette: purple/violet accent ---
// Only flag vivid purple/violet as text color or background on accent-like elements
if (hasDirectText && textColor && hasChroma(textColor, 50)) {
const hue = getHue(textColor);
// Purple/violet range: roughly 260-310
if (hue >= 260 && hue <= 310 && relativeLuminance(textColor) < 0.3) {
// Check if it's used on a heading or prominent text
if (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20) {
findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
}
}
}
return findings;
}
/**
* Analyze a single element's computed styles for border anti-patterns.
* Returns array of { id, snippet } findings.
@@ -236,6 +450,45 @@ function checkPageTypography(document, window) {
}
}
// --- Pure black/white (regex on raw HTML — jsdom doesn't resolve inline bg colors) ---
const pureRe = /(?:color|background(?:-color)?)\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
if (pureRe.test(html)) {
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 in styles' });
}
const pureWhiteRe = /(?:color|background(?:-color)?)\s*:\s*(?:#ffffff|#fff|rgb\(\s*255,\s*255,\s*255\s*\))\b/gi;
if (pureWhiteRe.test(html)) {
findings.push({ id: 'pure-black-white', snippet: 'Pure #fff in styles' });
}
// --- AI color palette: purple/violet in raw CSS ---
// Very conservative — only flag vivid purple in prominent contexts
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(html)) {
// Check if used on text (not just borders or backgrounds)
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(html)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
}
}
// --- Gradient text (regex on raw HTML — jsdom doesn't compute background-clip) ---
const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
let gm;
while ((gm = gradientRe.exec(html)) !== null) {
// Check nearby context for gradient
const start = Math.max(0, gm.index - 200);
const context = html.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
break; // one finding is enough
}
}
// Also check Tailwind gradient text
if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
}
return findings;
}
@@ -287,13 +540,16 @@ async function detectHtml(filePath) {
const findings = [];
// Element-level border checks
// Element-level checks (borders + colors)
for (const el of document.querySelectorAll('*')) {
const tag = el.tagName.toLowerCase();
const style = window.getComputedStyle(el);
for (const f of checkElementBorders(tag, style)) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkElementColors(el, style, tag, window)) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
// Page-level typography checks (only for full pages, not partials)
@@ -477,6 +733,18 @@ const REGEX_MATCHERS = [
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Pure black/white ---
{ id: 'pure-black-white', regex: /(?:color|background(?:-color)?)\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\)|#ffffff|#fff|rgb\(255,\s*255,\s*255\))\b/gi,
test: () => true,
fmt: (m) => `${m[0]}` },
// --- Gradient text ---
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
test: (m, line) => /gradient/i.test(line),
fmt: () => 'background-clip: text + gradient' },
// --- Gradient text (Tailwind) ---
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
test: (m, line) => /\bbg-gradient-to-/i.test(line),
fmt: () => 'bg-clip-text + bg-gradient' },
];
const REGEX_ANALYZERS = [
@@ -77,6 +77,39 @@ const ANTIPATTERNS = [
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
},
// -------------------------------------------------------------------------
// Color & contrast anti-patterns
// -------------------------------------------------------------------------
{
id: 'pure-black-white',
name: 'Pure black or white',
description:
'Pure #000 or #fff never appears in nature. Tint your blacks and whites slightly toward your brand hue for a more natural, cohesive feel.',
},
{
id: 'gray-on-color',
name: 'Gray text on colored background',
description:
'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
},
{
id: 'low-contrast',
name: 'Low contrast text',
description:
'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
},
{
id: 'gradient-text',
name: 'Gradient text',
description:
'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
},
{
id: 'ai-color-palette',
name: 'AI color palette',
description:
'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
},
];
/** Check if content looks like a full page (not a component/partial) */
@@ -110,6 +143,187 @@ function isNeutralColor(color) {
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
/**
* Parse an RGB/RGBA color string into { r, g, b, a } (0-255 for rgb, 0-1 for a).
* Returns null if unparseable.
*/
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 };
}
/**
* Compute relative luminance (WCAG 2.x formula).
* Input: { r, g, b } with values 0-255.
*/
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;
}
/**
* Compute WCAG contrast ratio between two colors.
* Returns a number >= 1.
*/
function contrastRatio(c1, c2) {
const l1 = relativeLuminance(c1);
const l2 = relativeLuminance(c2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Check if a color is pure black or pure white.
*/
function isPureBlackOrWhite(c) {
if (!c) return false;
return (c.r === 0 && c.g === 0 && c.b === 0) ||
(c.r === 255 && c.g === 255 && c.b === 255);
}
/**
* Check if a color has meaningful chroma (is "colored" vs gray/neutral).
* Uses simple RGB saturation check.
*/
function hasChroma(c, threshold = 30) {
if (!c) return false;
return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
}
/**
* Get the approximate hue (0-360) from RGB.
*/
function getHue(c) {
if (!c) return 0;
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 colorToHex(c) {
if (!c) return '?';
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
/**
* Resolve the effective background color for an element by walking up ancestors.
* Returns { r, g, b } or { r: 255, g: 255, b: 255 } as fallback (white).
*/
function resolveBackground(el, window) {
let current = el;
while (current && current.nodeType === 1) {
const style = window.getComputedStyle(current);
// Try backgroundColor first, then fall back to parsing the inline background shorthand
// (jsdom doesn't decompose background shorthand to backgroundColor reliably)
let bg = parseRgb(style.backgroundColor);
if (!bg || bg.a < 0.1) {
// jsdom doesn't reliably decompose background shorthand — parse raw style attr
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
bg = parseRgb(inlineBg);
if (!bg && inlineBg) {
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
if (hexMatch) {
const h = hexMatch[1];
if (h.length === 6) {
bg = { r: parseInt(h.slice(0,2), 16), g: parseInt(h.slice(2,4), 16), b: parseInt(h.slice(4,6), 16), a: 1 };
} else {
bg = { r: parseInt(h[0]+h[0], 16), g: parseInt(h[1]+h[1], 16), b: parseInt(h[2]+h[2], 16), a: 1 };
}
}
}
}
if (bg && bg.a > 0.1) {
if (bg.a >= 0.5) return bg;
}
current = current.parentElement;
}
return { r: 255, g: 255, b: 255 }; // default to white
}
/**
* Analyze an element's colors for anti-patterns.
* Needs the element, its computed style, AND access to the window for ancestor bg resolution.
*/
function checkElementColors(el, style, tag, window) {
if (SAFE_TAGS.has(tag)) return [];
const findings = [];
const textColor = parseRgb(style.color);
const bgColor = parseRgb(style.backgroundColor);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
// Skip non-text elements (no text content)
const hasText = el.textContent?.trim().length > 0;
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
// Pure black/white is handled via regex on raw HTML (jsdom's computed bg is unreliable)
if (hasDirectText && textColor) {
// --- Gray text on colored background ---
const effectiveBg = resolveBackground(el, window);
// Gray = low chroma AND mid-range luminance (not near-white or near-black)
const textLum = relativeLuminance(textColor);
const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
if (isGray && hasChroma(effectiveBg, 40)) {
findings.push({
id: 'gray-on-color',
snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}`,
});
}
// --- Low contrast (WCAG AA) ---
{
const ratio = contrastRatio(textColor, effectiveBg);
// jsdom may return fontSize in non-px units — also check tag-based heuristic
const isHeading = ['h1', 'h2', 'h3'].includes(tag);
const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading;
const threshold = isLargeText ? 3.0 : 4.5;
if (ratio < threshold) {
findings.push({
id: 'low-contrast',
snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(effectiveBg)}`,
});
}
}
}
// --- Gradient text ---
const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';
const bgImage = style.backgroundImage || '';
if (bgClip === 'text' && bgImage.includes('gradient')) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
}
// --- AI color palette: purple/violet accent ---
// Only flag vivid purple/violet as text color or background on accent-like elements
if (hasDirectText && textColor && hasChroma(textColor, 50)) {
const hue = getHue(textColor);
// Purple/violet range: roughly 260-310
if (hue >= 260 && hue <= 310 && relativeLuminance(textColor) < 0.3) {
// Check if it's used on a heading or prominent text
if (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20) {
findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
}
}
}
return findings;
}
/**
* Analyze a single element's computed styles for border anti-patterns.
* Returns array of { id, snippet } findings.
@@ -236,6 +450,45 @@ function checkPageTypography(document, window) {
}
}
// --- Pure black/white (regex on raw HTML — jsdom doesn't resolve inline bg colors) ---
const pureRe = /(?:color|background(?:-color)?)\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
if (pureRe.test(html)) {
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 in styles' });
}
const pureWhiteRe = /(?:color|background(?:-color)?)\s*:\s*(?:#ffffff|#fff|rgb\(\s*255,\s*255,\s*255\s*\))\b/gi;
if (pureWhiteRe.test(html)) {
findings.push({ id: 'pure-black-white', snippet: 'Pure #fff in styles' });
}
// --- AI color palette: purple/violet in raw CSS ---
// Very conservative — only flag vivid purple in prominent contexts
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(html)) {
// Check if used on text (not just borders or backgrounds)
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(html)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
}
}
// --- Gradient text (regex on raw HTML — jsdom doesn't compute background-clip) ---
const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
let gm;
while ((gm = gradientRe.exec(html)) !== null) {
// Check nearby context for gradient
const start = Math.max(0, gm.index - 200);
const context = html.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
break; // one finding is enough
}
}
// Also check Tailwind gradient text
if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
}
return findings;
}
@@ -287,13 +540,16 @@ async function detectHtml(filePath) {
const findings = [];
// Element-level border checks
// Element-level checks (borders + colors)
for (const el of document.querySelectorAll('*')) {
const tag = el.tagName.toLowerCase();
const style = window.getComputedStyle(el);
for (const f of checkElementBorders(tag, style)) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkElementColors(el, style, tag, window)) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
// Page-level typography checks (only for full pages, not partials)
@@ -477,6 +733,18 @@ const REGEX_MATCHERS = [
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Pure black/white ---
{ id: 'pure-black-white', regex: /(?:color|background(?:-color)?)\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\)|#ffffff|#fff|rgb\(255,\s*255,\s*255\))\b/gi,
test: () => true,
fmt: (m) => `${m[0]}` },
// --- Gradient text ---
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
test: (m, line) => /gradient/i.test(line),
fmt: () => 'background-clip: text + gradient' },
// --- Gradient text (Tailwind) ---
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
test: (m, line) => /\bbg-gradient-to-/i.test(line),
fmt: () => 'bg-clip-text + bg-gradient' },
];
const REGEX_ANALYZERS = [
+14
View File
@@ -202,6 +202,20 @@ describe('detectHtml — jsdom', () => {
expect(f.filter(r => r.antipattern === 'overused-font')).toHaveLength(0);
});
test('color-should-flag detects all five color issues', async () => {
const f = await detectHtml(path.join(FIXTURES, 'color-should-flag.html'));
expect(f.some(r => r.antipattern === 'pure-black-white')).toBe(true);
expect(f.some(r => r.antipattern === 'gray-on-color')).toBe(true);
expect(f.some(r => r.antipattern === 'low-contrast')).toBe(true);
expect(f.some(r => r.antipattern === 'gradient-text')).toBe(true);
expect(f.some(r => r.antipattern === 'ai-color-palette')).toBe(true);
});
test('color-should-pass has zero findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'color-should-pass.html'));
expect(f).toHaveLength(0);
});
test('legitimate-borders has minimal false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded');
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Color Anti-Patterns — Should Flag</title>
<style>
body { font-family: system-ui, sans-serif; padding: 2rem; background: #fafafa; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.125rem; margin: 2rem 0 0.75rem; color: #6b7280; }
.cards { display: grid; gap: 1rem; max-width: 28rem; }
.card { padding: 1rem; border-radius: 8px; }
</style>
</head>
<body>
<h1>Color Anti-Patterns</h1>
<!-- 1. Pure black/white -->
<h2>Pure Black &amp; White</h2>
<div class="cards">
<div class="card" style="background: #000000; color: white; padding: 1rem;">
<p>Pure #000 background</p>
</div>
<div class="card" style="background: #ffffff; color: black;">
<p style="color: #000000;">Pure #000 text on pure #fff background</p>
</div>
</div>
<!-- 2. Gray on colored background -->
<h2>Gray on Color</h2>
<div class="cards">
<div class="card" style="background: rgb(59, 130, 246); padding: 1rem;">
<p style="color: rgb(150, 150, 150);">Gray text on blue background</p>
</div>
<div class="card" style="background: rgb(16, 185, 129); padding: 1rem;">
<p style="color: rgb(180, 180, 180);">Gray text on green background</p>
</div>
</div>
<!-- 3. Low contrast -->
<h2>Low Contrast</h2>
<div class="cards">
<div class="card" style="background: white;">
<p style="color: rgb(200, 200, 200);">Light gray text on white — very low contrast</p>
</div>
<div class="card" style="background: rgb(30, 30, 30);">
<p style="color: rgb(80, 80, 80);">Dark gray text on near-black — low contrast</p>
</div>
</div>
<!-- 4. Gradient text -->
<h2>Gradient Text</h2>
<div class="cards">
<h3 style="background: linear-gradient(135deg, #667eea, #764ba2); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; font-size: 2rem;">
Gradient Heading
</h3>
</div>
<!-- 5. AI color palette -->
<h2>AI Color Palette</h2>
<div class="cards">
<h1 style="color: rgb(139, 92, 246); font-size: 2rem;">Purple heading text</h1>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Color — Clean Patterns</title>
<style>
body { font-family: system-ui, sans-serif; padding: 2rem; background: #fafafa; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; color: #1a1a1a; }
h2 { font-size: 1.5rem; margin: 2rem 0 0.75rem; color: #374151; }
p { font-size: 1rem; color: #4b5563; }
.cards { display: grid; gap: 1rem; max-width: 28rem; }
.card { padding: 1rem; border-radius: 8px; }
</style>
</head>
<body>
<h1>Clean Color Patterns</h1>
<!-- Tinted blacks and whites -->
<h2>Tinted Neutrals</h2>
<div class="cards">
<div class="card" style="background: rgb(26, 26, 30); padding: 1rem;">
<p style="color: rgb(240, 240, 245);">Near-black bg, near-white text — tinted, not pure</p>
</div>
<div class="card" style="background: rgb(250, 250, 252);">
<p style="color: rgb(20, 20, 25);">Near-white bg, near-black text — good contrast, tinted</p>
</div>
</div>
<!-- Good contrast -->
<h2>Good Contrast</h2>
<div class="cards">
<div class="card" style="background: rgb(30, 64, 175); padding: 1rem;">
<p style="color: rgb(240, 240, 245);">Near-white text on dark blue — high contrast, not pure white</p>
</div>
<div class="card" style="background: rgb(16, 185, 129); padding: 1rem;">
<p style="color: rgb(5, 46, 32);">Dark green text on green bg — same hue family</p>
</div>
</div>
<!-- Non-purple accent colors -->
<h2>Distinctive Accents</h2>
<div class="cards">
<h3 style="color: rgb(220, 38, 38); font-size: 1.5rem;">Red heading — not AI purple</h3>
<h3 style="color: rgb(180, 83, 9); font-size: 1.5rem;">Amber heading — distinctive</h3>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+1 -1
View File
@@ -27,7 +27,7 @@
}
h3 { font-size: 24px; font-weight: 600; }
p { font-size: 16px; color: #6b7280; margin-top: 0.25rem; }
.caption { font-size: 12px; color: #9ca3af; }
.caption { font-size: 12px; color: #6b7280; }
</style>
</head>
<body>