DRY: extract checkHtmlPatterns, add gradient background detection

Extract all regex-on-HTML checks into shared checkHtmlPatterns()
function called by both browser and Node paths. Eliminates drift
between checkTypography/checkPageTypography and removes separate
checkPageMotion/checkPageGlow functions.

Add parseGradientColors() utility and checkElementGradientDOM()
to detect purple/violet gradient backgrounds on any element
including buttons (bypasses SAFE_TAGS). Fix false low-contrast
findings on gradient backgrounds by returning null from
resolveBackground when a gradient is encountered.

Fix "Only font:" double-colon in browser labels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-18 11:19:04 -07:00
co-authored by Claude Opus 4.6
parent d444d25988
commit 0574bd0be2
2 changed files with 402 additions and 311 deletions
@@ -182,6 +182,13 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
return [...bgImage.matchAll(/rgba?\([^)]+\)/g)]
.map(m => parseRgb(m[0]))
.filter(Boolean);
}
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;
@@ -245,20 +252,23 @@ function checkColors(opts) {
}
if (hasDirectText && textColor) {
// 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({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
}
// Skip background-dependent checks if we can't determine the background (e.g. gradient)
if (effectiveBg) {
// 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({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
}
// Low contrast (WCAG AA)
const ratio = contrastRatio(textColor, effectiveBg);
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)}` });
// Low contrast (WCAG AA)
const ratio = contrastRatio(textColor, effectiveBg);
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)}` });
}
}
// AI palette: purple/violet on headings
@@ -358,6 +368,7 @@ function checkGlow(opts) {
const { tag, boxShadow, effectiveBg } = opts;
if (SAFE_TAGS.has(tag)) return [];
if (!boxShadow || boxShadow === 'none') return [];
if (!effectiveBg) return [];
// Only flag on dark backgrounds (luminance < 0.1)
const bgLum = relativeLuminance(effectiveBg);
@@ -386,18 +397,161 @@ function checkGlow(opts) {
return [];
}
/**
* Regex-on-HTML checks shared between browser and Node page-level detection.
* These don't need DOM access, just the raw HTML string.
*/
function checkHtmlPatterns(html) {
const findings = [];
// --- Color ---
// Pure black background
const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
if (pureBlackBgRe.test(html)) {
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
}
// AI color palette: purple/violet
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(html)) {
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 (background-clip: text + gradient)
const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
let gm;
while ((gm = gradientRe.exec(html)) !== null) {
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;
}
}
if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
}
// --- Layout ---
// Monotonous spacing
const spacingValues = [];
const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
let sm;
while ((sm = spacingRe.exec(html)) !== null) {
const v = parseInt(sm[1], 10);
if (v > 0 && v < 200) spacingValues.push(v);
}
const gapRe = /gap\s*:\s*(\d+)px/gi;
while ((sm = gapRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10));
}
const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
while ((sm = twSpaceRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10) * 4);
}
const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
while ((sm = remSpacingRe.exec(html)) !== null) {
const v = Math.round(parseFloat(sm[1]) * 16);
if (v > 0 && v < 200) spacingValues.push(v);
}
const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
if (roundedSpacing.length >= 10) {
const counts = {};
for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
const maxCount = Math.max(...Object.values(counts));
const dominantPct = maxCount / roundedSpacing.length;
const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
if (dominantPct > 0.6 && unique.length <= 3) {
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
findings.push({
id: 'monotonous-spacing',
snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
});
}
}
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
}
// Overshoot cubic-bezier
const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
let bm;
while ((bm = bezierRe.exec(html)) !== null) {
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
break;
}
}
// Layout property transitions
const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
let tm;
while ((tm = transRe.exec(html)) !== null) {
const val = tm[1].toLowerCase();
if (/\ball\b/.test(val)) continue;
const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
if (found) {
findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
break;
}
}
// --- Dark glow ---
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
if (darkBgRe.test(html) || twDarkBg.test(html)) {
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let shm;
while ((shm = shadowRe.exec(html)) !== null) {
const val = shm[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
break;
}
}
}
return findings;
}
// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
function resolveBackground(el, win) {
let current = el;
while (current && current.nodeType === 1) {
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
// If this element has a gradient background, it's opaque but we can't determine the color
const bgImage = style.backgroundImage || '';
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
return null;
}
let bg = parseRgb(style.backgroundColor);
if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom doesn't 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() : '';
// Check for gradient in inline style too
if (/gradient/i.test(inlineBg)) return null;
bg = parseRgb(inlineBg);
if (!bg && inlineBg) {
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
@@ -482,6 +636,27 @@ function checkElementGlowDOM(el) {
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
}
function checkElementGradientDOM(el) {
const style = getComputedStyle(el);
const bgImage = style.backgroundImage || '';
const colors = parseGradientColors(bgImage);
if (colors.length === 0) return [];
const findings = [];
// AI palette: purple/violet gradient
for (const c of colors) {
if (hasChroma(c, 50)) {
const hue = getHue(c);
if (hue >= 260 && hue <= 310) {
findings.push({ id: 'ai-color-palette', snippet: `Purple/violet gradient background` });
break;
}
}
}
return findings;
}
// Node adapters — take pre-extracted jsdom computed style
function checkElementBorders(tag, style) {
@@ -567,7 +742,7 @@ function checkTypography() {
findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
}
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
findings.push({ type: 'single-font', detail: `Only font: ${[...fonts][0]}` });
findings.push({ type: 'single-font', detail: `only font used is ${[...fonts][0]}` });
}
const sizes = new Set();
@@ -687,7 +862,7 @@ function checkPageTypography(doc, win) {
if (fonts.size === 1) {
const els = doc.querySelectorAll('*');
if (els.length >= 20) {
findings.push({ id: 'single-font', snippet: `Only font: ${[...fonts][0]}` });
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
}
}
@@ -707,38 +882,6 @@ function checkPageTypography(doc, win) {
}
}
// Pure black background (regex on raw HTML)
const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
if (pureBlackBgRe.test(html)) {
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
}
// AI color palette: purple/violet in raw CSS
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(html)) {
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) {
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;
}
}
// 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;
}
@@ -805,46 +948,6 @@ function checkPageLayout(doc, win) {
}
}
// Monotonous spacing (regex on raw HTML)
const spacingValues = [];
const html = doc.documentElement?.outerHTML || '';
const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
let sm;
while ((sm = spacingRe.exec(html)) !== null) {
const v = parseInt(sm[1], 10);
if (v > 0 && v < 200) spacingValues.push(v);
}
const gapRe = /gap\s*:\s*(\d+)px/gi;
while ((sm = gapRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10));
}
const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
while ((sm = twSpaceRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10) * 4);
}
const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
while ((sm = remSpacingRe.exec(html)) !== null) {
const v = Math.round(parseFloat(sm[1]) * 16);
if (v > 0 && v < 200) spacingValues.push(v);
}
const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
if (roundedSpacing.length >= 10) {
const counts = {};
for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
const maxCount = Math.max(...Object.values(counts));
const dominantPct = maxCount / roundedSpacing.length;
const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
if (dominantPct > 0.6 && unique.length <= 3) {
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
findings.push({
id: 'monotonous-spacing',
snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
});
}
}
// Everything centered
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
let centeredCount = 0;
@@ -879,71 +982,6 @@ function checkPageLayout(doc, win) {
return findings;
}
function checkPageMotion(doc) {
const findings = [];
const html = doc.documentElement?.outerHTML || '';
// Bounce/elastic animation names (regex on raw CSS — jsdom doesn't compute animationName)
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
}
// Overshoot cubic-bezier
const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
let m;
while ((m = bezierRe.exec(html)) !== null) {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
break;
}
}
// Layout property transitions (regex on raw CSS — jsdom doesn't compute transitionProperty)
const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
let tm;
while ((tm = transRe.exec(html)) !== null) {
const val = tm[1].toLowerCase();
if (/\ball\b/.test(val)) continue;
const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
if (found) {
findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
break;
}
}
return findings;
}
function checkPageGlow(doc) {
const findings = [];
const html = doc.documentElement?.outerHTML || '';
// Check if page has dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
if (!darkBgRe.test(html) && !twDarkBg.test(html)) return findings;
// Look for colored box-shadow with blur > 4px (regex on raw HTML — jsdom doesn't always resolve box-shadow)
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(html)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
break;
}
}
return findings;
}
// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
if (IS_BROWSER) {
@@ -953,7 +991,7 @@ if (IS_BROWSER) {
const overlays = [];
const TYPE_LABELS = {};
for (const ap of ANTIPATTERNS) {
TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 20);
TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 26);
}
const highlight = function(el, findings) {
@@ -1072,6 +1110,7 @@ if (IS_BROWSER) {
...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGradientDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
];
if (findings.length > 0) {
@@ -1094,6 +1133,14 @@ if (IS_BROWSER) {
allFindings.push({ el, findings: [f] });
}
// Regex-on-HTML checks (shared with Node)
const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet }));
showPageBanner(mapped);
allFindings.push({ el: document.body, findings: mapped });
}
printSummary(allFindings);
return allFindings;
};
@@ -180,6 +180,13 @@ function contrastRatio(c1, c2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
return [...bgImage.matchAll(/rgba?\([^)]+\)/g)]
.map(m => parseRgb(m[0]))
.filter(Boolean);
}
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;
@@ -243,20 +250,23 @@ function checkColors(opts) {
}
if (hasDirectText && textColor) {
// 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({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
}
// Skip background-dependent checks if we can't determine the background (e.g. gradient)
if (effectiveBg) {
// 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({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${colorToHex(effectiveBg)}` });
}
// Low contrast (WCAG AA)
const ratio = contrastRatio(textColor, effectiveBg);
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)}` });
// Low contrast (WCAG AA)
const ratio = contrastRatio(textColor, effectiveBg);
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)}` });
}
}
// AI palette: purple/violet on headings
@@ -356,6 +366,7 @@ function checkGlow(opts) {
const { tag, boxShadow, effectiveBg } = opts;
if (SAFE_TAGS.has(tag)) return [];
if (!boxShadow || boxShadow === 'none') return [];
if (!effectiveBg) return [];
// Only flag on dark backgrounds (luminance < 0.1)
const bgLum = relativeLuminance(effectiveBg);
@@ -384,18 +395,161 @@ function checkGlow(opts) {
return [];
}
/**
* Regex-on-HTML checks shared between browser and Node page-level detection.
* These don't need DOM access, just the raw HTML string.
*/
function checkHtmlPatterns(html) {
const findings = [];
// --- Color ---
// Pure black background
const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
if (pureBlackBgRe.test(html)) {
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
}
// AI color palette: purple/violet
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(html)) {
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 (background-clip: text + gradient)
const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
let gm;
while ((gm = gradientRe.exec(html)) !== null) {
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;
}
}
if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
}
// --- Layout ---
// Monotonous spacing
const spacingValues = [];
const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
let sm;
while ((sm = spacingRe.exec(html)) !== null) {
const v = parseInt(sm[1], 10);
if (v > 0 && v < 200) spacingValues.push(v);
}
const gapRe = /gap\s*:\s*(\d+)px/gi;
while ((sm = gapRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10));
}
const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
while ((sm = twSpaceRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10) * 4);
}
const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
while ((sm = remSpacingRe.exec(html)) !== null) {
const v = Math.round(parseFloat(sm[1]) * 16);
if (v > 0 && v < 200) spacingValues.push(v);
}
const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
if (roundedSpacing.length >= 10) {
const counts = {};
for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
const maxCount = Math.max(...Object.values(counts));
const dominantPct = maxCount / roundedSpacing.length;
const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
if (dominantPct > 0.6 && unique.length <= 3) {
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
findings.push({
id: 'monotonous-spacing',
snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
});
}
}
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
}
// Overshoot cubic-bezier
const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
let bm;
while ((bm = bezierRe.exec(html)) !== null) {
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
break;
}
}
// Layout property transitions
const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
let tm;
while ((tm = transRe.exec(html)) !== null) {
const val = tm[1].toLowerCase();
if (/\ball\b/.test(val)) continue;
const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
if (found) {
findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
break;
}
}
// --- Dark glow ---
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
if (darkBgRe.test(html) || twDarkBg.test(html)) {
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let shm;
while ((shm = shadowRe.exec(html)) !== null) {
const val = shm[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
break;
}
}
}
return findings;
}
// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
function resolveBackground(el, win) {
let current = el;
while (current && current.nodeType === 1) {
const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
// If this element has a gradient background, it's opaque but we can't determine the color
const bgImage = style.backgroundImage || '';
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
return null;
}
let bg = parseRgb(style.backgroundColor);
if (!IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom doesn't 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() : '';
// Check for gradient in inline style too
if (/gradient/i.test(inlineBg)) return null;
bg = parseRgb(inlineBg);
if (!bg && inlineBg) {
const hexMatch = inlineBg.match(/#([0-9a-f]{6}|[0-9a-f]{3})\b/i);
@@ -480,6 +634,27 @@ function checkElementGlowDOM(el) {
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
}
function checkElementGradientDOM(el) {
const style = getComputedStyle(el);
const bgImage = style.backgroundImage || '';
const colors = parseGradientColors(bgImage);
if (colors.length === 0) return [];
const findings = [];
// AI palette: purple/violet gradient
for (const c of colors) {
if (hasChroma(c, 50)) {
const hue = getHue(c);
if (hue >= 260 && hue <= 310) {
findings.push({ id: 'ai-color-palette', snippet: `Purple/violet gradient background` });
break;
}
}
}
return findings;
}
// Node adapters — take pre-extracted jsdom computed style
function checkElementBorders(tag, style) {
@@ -565,7 +740,7 @@ function checkTypography() {
findings.push({ type: 'overused-font', detail: `Primary font: ${font}` });
}
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
findings.push({ type: 'single-font', detail: `Only font: ${[...fonts][0]}` });
findings.push({ type: 'single-font', detail: `only font used is ${[...fonts][0]}` });
}
const sizes = new Set();
@@ -685,7 +860,7 @@ function checkPageTypography(doc, win) {
if (fonts.size === 1) {
const els = doc.querySelectorAll('*');
if (els.length >= 20) {
findings.push({ id: 'single-font', snippet: `Only font: ${[...fonts][0]}` });
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
}
}
@@ -705,38 +880,6 @@ function checkPageTypography(doc, win) {
}
}
// Pure black background (regex on raw HTML)
const pureBlackBgRe = /background(?:-color)?\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
if (pureBlackBgRe.test(html)) {
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 background' });
}
// AI color palette: purple/violet in raw CSS
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(html)) {
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) {
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;
}
}
// 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;
}
@@ -803,46 +946,6 @@ function checkPageLayout(doc, win) {
}
}
// Monotonous spacing (regex on raw HTML)
const spacingValues = [];
const html = doc.documentElement?.outerHTML || '';
const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
let sm;
while ((sm = spacingRe.exec(html)) !== null) {
const v = parseInt(sm[1], 10);
if (v > 0 && v < 200) spacingValues.push(v);
}
const gapRe = /gap\s*:\s*(\d+)px/gi;
while ((sm = gapRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10));
}
const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
while ((sm = twSpaceRe.exec(html)) !== null) {
spacingValues.push(parseInt(sm[1], 10) * 4);
}
const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
while ((sm = remSpacingRe.exec(html)) !== null) {
const v = Math.round(parseFloat(sm[1]) * 16);
if (v > 0 && v < 200) spacingValues.push(v);
}
const roundedSpacing = spacingValues.map(v => Math.round(v / 4) * 4);
if (roundedSpacing.length >= 10) {
const counts = {};
for (const v of roundedSpacing) counts[v] = (counts[v] || 0) + 1;
const maxCount = Math.max(...Object.values(counts));
const dominantPct = maxCount / roundedSpacing.length;
const unique = [...new Set(roundedSpacing)].filter(v => v > 0);
if (dominantPct > 0.6 && unique.length <= 3) {
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
findings.push({
id: 'monotonous-spacing',
snippet: `~${dominant}px used ${maxCount}/${roundedSpacing.length} times (${Math.round(dominantPct * 100)}%)`,
});
}
}
// Everything centered
const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
let centeredCount = 0;
@@ -877,71 +980,6 @@ function checkPageLayout(doc, win) {
return findings;
}
function checkPageMotion(doc) {
const findings = [];
const html = doc.documentElement?.outerHTML || '';
// Bounce/elastic animation names (regex on raw CSS — jsdom doesn't compute animationName)
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
}
// Overshoot cubic-bezier
const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
let m;
while ((m = bezierRe.exec(html)) !== null) {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` });
break;
}
}
// Layout property transitions (regex on raw CSS — jsdom doesn't compute transitionProperty)
const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
let tm;
while ((tm = transRe.exec(html)) !== null) {
const val = tm[1].toLowerCase();
if (/\ball\b/.test(val)) continue;
const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
if (found) {
findings.push({ id: 'layout-transition', snippet: `transition: ${found.join(', ')}` });
break;
}
}
return findings;
}
function checkPageGlow(doc) {
const findings = [];
const html = doc.documentElement?.outerHTML || '';
// Check if page has dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
if (!darkBgRe.test(html) && !twDarkBg.test(html)) return findings;
// Look for colored box-shadow with blur > 4px (regex on raw HTML — jsdom doesn't always resolve box-shadow)
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(html)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
break;
}
}
return findings;
}
// ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
if (IS_BROWSER) {
@@ -951,7 +989,7 @@ if (IS_BROWSER) {
const overlays = [];
const TYPE_LABELS = {};
for (const ap of ANTIPATTERNS) {
TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 20);
TYPE_LABELS[ap.id] = ap.name.toLowerCase().substring(0, 26);
}
const highlight = function(el, findings) {
@@ -1070,6 +1108,7 @@ if (IS_BROWSER) {
...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGradientDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
];
if (findings.length > 0) {
@@ -1092,6 +1131,14 @@ if (IS_BROWSER) {
allFindings.push({ el, findings: [f] });
}
// Regex-on-HTML checks (shared with Node)
const htmlPatternFindings = checkHtmlPatterns(document.documentElement.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet }));
showPageBanner(mapped);
allFindings.push({ el: document.body, findings: mapped });
}
printSummary(allFindings);
return allFindings;
};
@@ -1196,10 +1243,7 @@ async function detectHtml(filePath) {
for (const f of checkPageLayout(document, window)) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkPageMotion(document)) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkPageGlow(document)) {
for (const f of checkHtmlPatterns(html)) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
@@ -1394,7 +1438,7 @@ const REGEX_ANALYZERS = [
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `Only font: ${name}`, line)];
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {