From 8a357d4754921cbb47ee4cfac4c5146e6c3f5467 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 17 Mar 2026 12:41:45 -0700 Subject: [PATCH] Add color and contrast anti-pattern detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../critique/scripts/detect-antipatterns.mjs | 270 +++++++++++++++++- .../critique/scripts/detect-antipatterns.mjs | 270 +++++++++++++++++- tests/detect-antipatterns.test.js | 14 + .../antipatterns/color-should-flag.html | 66 +++++ .../antipatterns/color-should-pass.html | 49 ++++ .../antipatterns/typography-should-pass.html | 2 +- 6 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/antipatterns/color-should-flag.html create mode 100644 tests/fixtures/antipatterns/color-should-pass.html diff --git a/.claude/skills/critique/scripts/detect-antipatterns.mjs b/.claude/skills/critique/scripts/detect-antipatterns.mjs index e7bd6479c..ae0d15431 100644 --- a/.claude/skills/critique/scripts/detect-antipatterns.mjs +++ b/.claude/skills/critique/scripts/detect-antipatterns.mjs @@ -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 = [ diff --git a/source/skills/critique/scripts/detect-antipatterns.mjs b/source/skills/critique/scripts/detect-antipatterns.mjs index e7bd6479c..ae0d15431 100644 --- a/source/skills/critique/scripts/detect-antipatterns.mjs +++ b/source/skills/critique/scripts/detect-antipatterns.mjs @@ -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 = [ diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index cacba15ce..d571f0919 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -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'); diff --git a/tests/fixtures/antipatterns/color-should-flag.html b/tests/fixtures/antipatterns/color-should-flag.html new file mode 100644 index 000000000..a90451d41 --- /dev/null +++ b/tests/fixtures/antipatterns/color-should-flag.html @@ -0,0 +1,66 @@ + + + + + + Color Anti-Patterns — Should Flag + + + +

Color Anti-Patterns

+ + +

Pure Black & White

+
+
+

Pure #000 background

+
+
+

Pure #000 text on pure #fff background

+
+
+ + +

Gray on Color

+
+
+

Gray text on blue background

+
+
+

Gray text on green background

+
+
+ + +

Low Contrast

+
+
+

Light gray text on white — very low contrast

+
+
+

Dark gray text on near-black — low contrast

+
+
+ + +

Gradient Text

+
+

+ Gradient Heading +

+
+ + +

AI Color Palette

+
+

Purple heading text

+
+ + + diff --git a/tests/fixtures/antipatterns/color-should-pass.html b/tests/fixtures/antipatterns/color-should-pass.html new file mode 100644 index 000000000..71afbce66 --- /dev/null +++ b/tests/fixtures/antipatterns/color-should-pass.html @@ -0,0 +1,49 @@ + + + + + + Color — Clean Patterns + + + +

Clean Color Patterns

+ + +

Tinted Neutrals

+
+
+

Near-black bg, near-white text — tinted, not pure

+
+
+

Near-white bg, near-black text — good contrast, tinted

+
+
+ + +

Good Contrast

+
+
+

Near-white text on dark blue — high contrast, not pure white

+
+
+

Dark green text on green bg — same hue family

+
+
+ + +

Distinctive Accents

+
+

Red heading — not AI purple

+

Amber heading — distinctive

+
+ + + diff --git a/tests/fixtures/antipatterns/typography-should-pass.html b/tests/fixtures/antipatterns/typography-should-pass.html index 67864823b..f4a91c47f 100644 --- a/tests/fixtures/antipatterns/typography-should-pass.html +++ b/tests/fixtures/antipatterns/typography-should-pass.html @@ -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; }