Fix glow/gradient detection, fix test performance (280s -> 5s)

Detection improvements:
- Remove SAFE_TAGS from glow check (buttons/links with glows are valid)
- Add gradient color parsing (parseGradientColors) for AI palette
  detection on gradient backgrounds including buttons
- Detect cyan neon text on dark backgrounds as AI palette
- Resolve gradient backgrounds as dark for glow detection
- Fix pure-black false positive on semi-transparent overlays (a >= 0.9)
- Skip low-contrast/gray-on-color when background is a gradient
- Fix "Only font:" double-colon in browser labels

Test performance:
- Split jsdom fixture tests to Node's test runner (bun + jsdom hangs
  after ~13 instances due to resource leak)
- bun test for unit/regex/CLI tests (94 tests, 4s)
- node --test for jsdom fixtures (15 tests, 1.3s)
- Total: 109 tests in ~5s (was 280s+)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-18 14:49:40 -07:00
co-authored by Claude Opus 4.6
parent 0574bd0be2
commit 4d2ef64935
7 changed files with 622 additions and 344 deletions
@@ -246,8 +246,8 @@ function checkColors(opts) {
if (SAFE_TAGS.has(tag)) return [];
const findings = [];
// Pure black background
if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
// Pure black background (only solid or near-solid, not semi-transparent overlays)
if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
}
@@ -365,8 +365,7 @@ function checkMotion(opts) {
}
function checkGlow(opts) {
const { tag, boxShadow, effectiveBg } = opts;
if (SAFE_TAGS.has(tag)) return [];
const { boxShadow, effectiveBg } = opts;
if (!boxShadow || boxShadow === 'none') return [];
if (!effectiveBg) return [];
@@ -628,29 +627,84 @@ function checkElementMotionDOM(el) {
function checkElementGlowDOM(el) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag)) return [];
const style = getComputedStyle(el);
if (!style.boxShadow || style.boxShadow === 'none') return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
// Gradient background — sample its colors to determine if it's dark
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
avg.r = Math.round(avg.r / gradColors.length);
avg.g = Math.round(avg.g / gradColors.length);
avg.b = Math.round(avg.b / gradColors.length);
parentBg = avg;
break;
}
cur = cur.parentElement;
}
}
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
}
function checkElementGradientDOM(el) {
function checkElementAIPaletteDOM(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) {
// Check gradient backgrounds for purple/violet or cyan
const bgImage = style.backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
for (const c of gradColors) {
if (hasChroma(c, 50)) {
const hue = getHue(c);
if (hue >= 260 && hue <= 310) {
findings.push({ id: 'ai-color-palette', snippet: `Purple/violet gradient background` });
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
break;
}
if (hue >= 160 && hue <= 200) {
findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
break;
}
}
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
const gc = parseGradientColors(gi);
if (gc.length > 0) {
const avg = { r: 0, g: 0, b: 0 };
for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
avg.r = Math.round(avg.r / gc.length);
avg.g = Math.round(avg.g / gc.length);
avg.b = Math.round(avg.b / gc.length);
effectiveBg = avg;
break;
}
cur = cur.parentElement;
}
}
if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
}
}
}
@@ -1110,7 +1164,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 })),
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
];
if (findings.length > 0) {
@@ -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;
@@ -237,26 +244,29 @@ function checkColors(opts) {
if (SAFE_TAGS.has(tag)) return [];
const findings = [];
// Pure black background
if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
// Pure black background (only solid or near-solid, not semi-transparent overlays)
if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
}
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
@@ -353,9 +363,9 @@ function checkMotion(opts) {
}
function checkGlow(opts) {
const { tag, boxShadow, effectiveBg } = opts;
if (SAFE_TAGS.has(tag)) return [];
const { boxShadow, effectiveBg } = opts;
if (!boxShadow || boxShadow === 'none') return [];
if (!effectiveBg) return [];
// Only flag on dark backgrounds (luminance < 0.1)
const bgLum = relativeLuminance(effectiveBg);
@@ -384,18 +394,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);
@@ -472,14 +625,90 @@ function checkElementMotionDOM(el) {
function checkElementGlowDOM(el) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag)) return [];
const style = getComputedStyle(el);
if (!style.boxShadow || style.boxShadow === 'none') return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
// Gradient background — sample its colors to determine if it's dark
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
avg.r = Math.round(avg.r / gradColors.length);
avg.g = Math.round(avg.g / gradColors.length);
avg.b = Math.round(avg.b / gradColors.length);
parentBg = avg;
break;
}
cur = cur.parentElement;
}
}
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
}
function checkElementAIPaletteDOM(el) {
const style = getComputedStyle(el);
const findings = [];
// Check gradient backgrounds for purple/violet or cyan
const bgImage = style.backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
for (const c of gradColors) {
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;
}
if (hue >= 160 && hue <= 200) {
findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
break;
}
}
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
const gc = parseGradientColors(gi);
if (gc.length > 0) {
const avg = { r: 0, g: 0, b: 0 };
for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
avg.r = Math.round(avg.r / gc.length);
avg.g = Math.round(avg.g / gc.length);
avg.b = Math.round(avg.b / gc.length);
effectiveBg = avg;
break;
}
cur = cur.parentElement;
}
}
if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
}
}
}
return findings;
}
// Node adapters — take pre-extracted jsdom computed style
function checkElementBorders(tag, style) {
@@ -565,7 +794,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 +914,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 +934,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 +1000,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 +1034,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 +1043,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 +1162,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 })),
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
];
if (findings.length > 0) {
@@ -1092,6 +1185,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;
};
@@ -1161,13 +1262,10 @@ async function detectHtml(filePath) {
const dom = new JSDOM(processedHtml, {
url: `file://${resolvedPath}`,
pretendToBeVisual: true,
});
const { window } = dom;
const { document } = window;
await new Promise(r => setTimeout(r, 50));
const findings = [];
// Element-level checks (borders + colors + motion)
@@ -1196,10 +1294,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 +1489,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) => {
+4 -4
View File
@@ -4,8 +4,8 @@
"author": "Paul Bakaus",
"dependencies": {
"archiver": "^7.0.1",
"motion": "^12.23.26",
"playwright": "^1.57.0"
"motion": "^12.38.0",
"playwright": "^1.58.2"
},
"description": "Cross-provider design skills and commands for LLM-powered development tools",
"keywords": [
@@ -25,7 +25,7 @@
"dev": "bun run server/index.js",
"preview": "bun run build && wrangler pages dev",
"deploy": "bun run build && wrangler pages deploy build/",
"test": "bun test",
"test": "bun test tests/build.test.js tests/detect-antipatterns.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs",
"screenshot": "bun run scripts/screenshot-antipatterns.js",
"og-image": "bun run scripts/generate-og-image.js"
},
@@ -33,6 +33,6 @@
"devDependencies": {
"jsdom": "^29.0.0",
"puppeteer": "^24.39.1",
"wrangler": "^4.71.0"
"wrangler": "^4.75.0"
}
}
@@ -244,8 +244,8 @@ function checkColors(opts) {
if (SAFE_TAGS.has(tag)) return [];
const findings = [];
// Pure black background
if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
// Pure black background (only solid or near-solid, not semi-transparent overlays)
if (bgColor && bgColor.a >= 0.9 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
}
@@ -363,8 +363,7 @@ function checkMotion(opts) {
}
function checkGlow(opts) {
const { tag, boxShadow, effectiveBg } = opts;
if (SAFE_TAGS.has(tag)) return [];
const { boxShadow, effectiveBg } = opts;
if (!boxShadow || boxShadow === 'none') return [];
if (!effectiveBg) return [];
@@ -626,29 +625,84 @@ function checkElementMotionDOM(el) {
function checkElementGlowDOM(el) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag)) return [];
const style = getComputedStyle(el);
if (!style.boxShadow || style.boxShadow === 'none') return [];
// Use parent's background — glow radiates outward, so the surrounding context matters
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
// Gradient background — sample its colors to determine if it's dark
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
for (const c of gradColors) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
avg.r = Math.round(avg.r / gradColors.length);
avg.g = Math.round(avg.g / gradColors.length);
avg.b = Math.round(avg.b / gradColors.length);
parentBg = avg;
break;
}
cur = cur.parentElement;
}
}
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
}
function checkElementGradientDOM(el) {
function checkElementAIPaletteDOM(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) {
// Check gradient backgrounds for purple/violet or cyan
const bgImage = style.backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
for (const c of gradColors) {
if (hasChroma(c, 50)) {
const hue = getHue(c);
if (hue >= 260 && hue <= 310) {
findings.push({ id: 'ai-color-palette', snippet: `Purple/violet gradient background` });
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient background' });
break;
}
if (hue >= 160 && hue <= 200) {
findings.push({ id: 'ai-color-palette', snippet: 'Cyan gradient background' });
break;
}
}
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
if (isAIPalette) {
const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
// Also check gradient parents
let effectiveBg = parentBg;
if (!effectiveBg) {
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const gi = getComputedStyle(cur).backgroundImage || '';
const gc = parseGradientColors(gi);
if (gc.length > 0) {
const avg = { r: 0, g: 0, b: 0 };
for (const c of gc) { avg.r += c.r; avg.g += c.g; avg.b += c.b; }
avg.r = Math.round(avg.r / gc.length);
avg.g = Math.round(avg.g / gc.length);
avg.b = Math.round(avg.b / gc.length);
effectiveBg = avg;
break;
}
cur = cur.parentElement;
}
}
if (effectiveBg && relativeLuminance(effectiveBg) < 0.1) {
const label = hue >= 260 ? 'Purple/violet' : 'Cyan';
findings.push({ id: 'ai-color-palette', snippet: `${label} neon text on dark background` });
}
}
}
@@ -1108,7 +1162,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 })),
...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
];
if (findings.length > 0) {
@@ -1208,13 +1262,10 @@ async function detectHtml(filePath) {
const dom = new JSDOM(processedHtml, {
url: `file://${resolvedPath}`,
pretendToBeVisual: true,
});
const { window } = dom;
const { document } = window;
await new Promise(r => setTimeout(r, 50));
const findings = [];
// Element-level checks (borders + colors + motion)
+114
View File
@@ -0,0 +1,114 @@
/**
* jsdom fixture tests for anti-pattern detection.
* Run via Node's built-in test runner (not bun) to avoid jsdom resource limits.
*
* Usage: node --test tests/detect-antipatterns-fixtures.test.mjs
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import path from 'path';
import { fileURLToPath } from 'url';
import {
detectHtml,
} from '../source/skills/critique/scripts/detect-antipatterns.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
describe('detectHtml — jsdom fixtures', () => {
it('should-flag: catches border anti-patterns', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'side-tab'));
assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
});
it('should-pass: zero border findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-pass.html'));
assert.equal(f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded').length, 0);
});
it('linked-stylesheet: catches borders, no false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
assert.ok(f.some(r => r.antipattern === 'side-tab'));
assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
assert.equal(f.filter(r => r.snippet?.includes('clean')).length, 0);
});
it('partial-component: flags borders, skips page-level', async () => {
const f = await detectHtml(path.join(FIXTURES, 'partial-component.html'));
assert.ok(f.some(r => r.antipattern === 'side-tab'));
assert.equal(f.filter(r => r.antipattern === 'flat-type-hierarchy').length, 0);
});
it('color-should-flag: detects all five color issues', async () => {
const f = await detectHtml(path.join(FIXTURES, 'color-should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'pure-black-white'));
assert.ok(f.some(r => r.antipattern === 'gray-on-color'));
assert.ok(f.some(r => r.antipattern === 'low-contrast'));
assert.ok(f.some(r => r.antipattern === 'gradient-text'));
assert.ok(f.some(r => r.antipattern === 'ai-color-palette'));
});
it('color-should-pass: zero findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'color-should-pass.html'));
assert.equal(f.length, 0);
});
it('legitimate-borders: 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');
assert.ok(borderFindings.length <= 1);
});
it('typography-should-flag: detects all three issues', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography-should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'overused-font'));
assert.ok(f.some(r => r.antipattern === 'single-font'));
assert.ok(f.some(r => r.antipattern === 'flat-type-hierarchy'));
});
it('typography-should-pass: zero findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html'));
assert.equal(f.length, 0);
});
});
describe('detectHtml — layout fixtures', () => {
it('layout-should-flag: detects nested cards', async () => {
const f = await detectHtml(path.join(FIXTURES, 'layout-should-flag.html'));
assert.ok(f.filter(r => r.antipattern === 'nested-cards').length >= 4);
});
it('layout-should-pass: no layout false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'layout-should-pass.html'));
assert.equal(f.filter(r => r.antipattern === 'nested-cards').length, 0);
assert.equal(f.filter(r => r.antipattern === 'monotonous-spacing').length, 0);
assert.equal(f.filter(r => r.antipattern === 'everything-centered').length, 0);
});
});
describe('detectHtml — motion fixtures', () => {
it('motion-should-flag: detects both motion issues', async () => {
const f = await detectHtml(path.join(FIXTURES, 'motion-should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'bounce-easing'));
assert.ok(f.some(r => r.antipattern === 'layout-transition'));
});
it('motion-should-pass: no motion false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'motion-should-pass.html'));
assert.equal(f.filter(r => r.antipattern === 'bounce-easing').length, 0);
assert.equal(f.filter(r => r.antipattern === 'layout-transition').length, 0);
});
});
describe('detectHtml — dark glow fixtures', () => {
it('glow-should-flag: detects dark-glow', async () => {
const f = await detectHtml(path.join(FIXTURES, 'glow-should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'dark-glow'));
});
it('glow-should-pass: no dark-glow false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'glow-should-pass.html'));
assert.equal(f.filter(r => r.antipattern === 'dark-glow').length, 0);
});
});
+5 -141
View File
@@ -4,13 +4,14 @@ import path from 'path';
import { spawnSync } from 'child_process';
import {
ANTIPATTERNS, checkElementBorders, checkElementMotion, checkElementGlow, isNeutralColor, isFullPage,
detectHtml, detectText,
detectText,
walkDir, SCANNABLE_EXTENSIONS,
} from '../source/skills/critique/scripts/detect-antipatterns.mjs';
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
const SCRIPT = path.join(import.meta.dir, '..', 'source', 'skills', 'critique', 'scripts', 'detect-antipatterns.mjs');
// ---------------------------------------------------------------------------
// Core: checkElementBorders (computed style simulation)
// ---------------------------------------------------------------------------
@@ -157,84 +158,7 @@ describe('detectText — flat type hierarchy', () => {
});
});
// ---------------------------------------------------------------------------
// jsdom detection (detectHtml)
// ---------------------------------------------------------------------------
describe('detectHtml — jsdom', () => {
test('catches side-tab from inline style', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
});
test('catches border-accent-on-rounded', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
expect(f.some(r => r.antipattern === 'border-accent-on-rounded')).toBe(true);
});
test('should-pass has zero border findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-pass.html'));
const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded');
expect(borderFindings).toHaveLength(0);
});
test('catches side-tab from linked stylesheet', async () => {
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
});
test('catches border-accent-on-rounded from linked stylesheet', async () => {
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
expect(f.some(r => r.antipattern === 'border-accent-on-rounded')).toBe(true);
});
test('does not flag clean card from linked stylesheet', async () => {
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
const cleanFindings = f.filter(r => r.snippet?.includes('clean'));
expect(cleanFindings).toHaveLength(0);
});
test('partial-component: flags borders, skips page-level', async () => {
const f = await detectHtml(path.join(FIXTURES, 'partial-component.html'));
expect(f.some(r => r.antipattern === 'side-tab')).toBe(true);
expect(f.filter(r => r.antipattern === 'flat-type-hierarchy')).toHaveLength(0);
expect(f.filter(r => r.antipattern === 'single-font')).toHaveLength(0);
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');
// Alert banner is the only expected detection
expect(borderFindings.length).toBeLessThanOrEqual(1);
});
test('typography-should-flag detects all three issues', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography-should-flag.html'));
expect(f.some(r => r.antipattern === 'overused-font')).toBe(true);
expect(f.some(r => r.antipattern === 'single-font')).toBe(true);
expect(f.some(r => r.antipattern === 'flat-type-hierarchy')).toBe(true);
});
test('typography-should-pass has zero findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography-should-pass.html'));
expect(f).toHaveLength(0);
});
});
// jsdom fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test)
// ---------------------------------------------------------------------------
// Full page vs partial detection
@@ -282,13 +206,6 @@ describe('partials skip page-level checks', () => {
// ---------------------------------------------------------------------------
describe('detectHtml — layout', () => {
test('layout-should-flag: detects all nested cards', async () => {
const f = await detectHtml(path.join(FIXTURES, 'layout-should-flag.html'));
const nested = f.filter(r => r.antipattern === 'nested-cards');
// Classic, level 3, CSS inner, shadcn inner + any other innermost nested cards
expect(nested.length).toBeGreaterThanOrEqual(4);
});
test('detects monotonous spacing via regex', () => {
// A page where every padding/margin is 16px
const html = '<!DOCTYPE html><html><body>' +
@@ -312,20 +229,6 @@ describe('detectHtml — layout', () => {
expect(f.some(r => r.antipattern === 'everything-centered')).toBe(true);
});
test('layout-should-pass: no nested-cards false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'layout-should-pass.html'));
expect(f.filter(r => r.antipattern === 'nested-cards')).toHaveLength(0);
});
test('layout-should-pass: no monotonous-spacing false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'layout-should-pass.html'));
expect(f.filter(r => r.antipattern === 'monotonous-spacing')).toHaveLength(0);
});
test('layout-should-pass: no everything-centered false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'layout-should-pass.html'));
expect(f.filter(r => r.antipattern === 'everything-centered')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
@@ -490,28 +393,6 @@ describe('detectText — motion', () => {
});
});
describe('detectHtml — motion', () => {
test('motion-should-flag: detects bounce easing', async () => {
const f = await detectHtml(path.join(FIXTURES, 'motion-should-flag.html'));
expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
});
test('motion-should-flag: detects layout transitions', async () => {
const f = await detectHtml(path.join(FIXTURES, 'motion-should-flag.html'));
expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
});
test('motion-should-pass: no bounce-easing false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'motion-should-pass.html'));
expect(f.filter(r => r.antipattern === 'bounce-easing')).toHaveLength(0);
});
test('motion-should-pass: no layout-transition false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'motion-should-pass.html'));
expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Dark glow anti-pattern
// ---------------------------------------------------------------------------
@@ -587,11 +468,11 @@ describe('checkElementGlow', () => {
expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
});
test('skips safe tags', () => {
test('detects glow on buttons (not skipped by safe tags)', () => {
const f = checkElementGlow('button', mockStyle({
boxShadow: 'rgba(59, 130, 246, 0.4) 0px 0px 20px 0px',
}), darkBg);
expect(f).toHaveLength(0);
expect(f.some(r => r.id === 'dark-glow')).toBe(true);
});
});
@@ -615,23 +496,6 @@ describe('detectText — dark glow', () => {
});
});
describe('detectHtml — dark glow', () => {
test('glow-should-flag: detects dark-glow', async () => {
const f = await detectHtml(path.join(FIXTURES, 'glow-should-flag.html'));
expect(f.some(r => r.antipattern === 'dark-glow')).toBe(true);
});
test('glow-should-flag: finds glow findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'glow-should-flag.html'));
const glowFindings = f.filter(r => r.antipattern === 'dark-glow');
expect(glowFindings.length).toBeGreaterThanOrEqual(1);
});
test('glow-should-pass: no dark-glow false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'glow-should-pass.html'));
expect(f.filter(r => r.antipattern === 'dark-glow')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// ANTIPATTERNS registry
+101 -1
View File
@@ -53,6 +53,56 @@
color: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
/* --- Typical elevated cards on light backgrounds --- */
.elevated-sm {
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
}
.elevated-md {
background: white;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.elevated-lg {
background: white;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
}
.elevated-xl {
background: white;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
}
.elevated-warm {
background: white;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.15);
}
/* --- Typical elevated cards on dark backgrounds --- */
.dark-section { background: #111827; padding: 2rem; border-radius: 0.75rem; margin-top: 1rem; }
.dark-elevated-sm {
background: #1f2937;
color: #f3f4f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.dark-elevated-md {
background: #1f2937;
color: #f3f4f6;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
}
.dark-elevated-lg {
background: #1f2937;
color: #f3f4f6;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
}
.dark-elevated-inset {
background: #1f2937;
color: #f3f4f6;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3);
}
.dark-elevated-multi {
background: #1f2937;
color: #f3f4f6;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
}
</style>
</head>
<body style="background: #f9fafb;">
@@ -63,7 +113,57 @@
<div class="cards">
<div class="card light-colored-shadow">
<h3>Colored shadow on light background</h3>
<p>Not dark mode colored shadow is fine.</p>
<p>Not dark mode, colored shadow is fine.</p>
</div>
</div>
<h2>Typical Light Elevated Cards</h2>
<div class="cards">
<div class="card elevated-sm">
<h3>Small elevation (Tailwind shadow-sm)</h3>
<p>Standard subtle card shadow.</p>
</div>
<div class="card elevated-md">
<h3>Medium elevation (Tailwind shadow-md)</h3>
<p>Standard card shadow.</p>
</div>
<div class="card elevated-lg">
<h3>Large elevation (Tailwind shadow-lg)</h3>
<p>Prominent card shadow.</p>
</div>
<div class="card elevated-xl">
<h3>Extra large elevation (Tailwind shadow-xl)</h3>
<p>Modal-style shadow.</p>
</div>
<div class="card elevated-warm">
<h3>Warm deep shadow</h3>
<p>Large spread neutral shadow.</p>
</div>
</div>
<h2>Typical Dark Elevated Cards</h2>
<div class="dark-section">
<div class="cards">
<div class="card dark-elevated-sm">
<h3>Small dark elevation</h3>
<p>Subtle shadow on dark card.</p>
</div>
<div class="card dark-elevated-md">
<h3>Medium dark elevation</h3>
<p>Standard dark card shadow.</p>
</div>
<div class="card dark-elevated-lg">
<h3>Large dark elevation</h3>
<p>Prominent shadow on dark card.</p>
</div>
<div class="card dark-elevated-inset">
<h3>Inset shadow</h3>
<p>Inner shadow, not a glow.</p>
</div>
<div class="card dark-elevated-multi">
<h3>Multi-shadow dark card</h3>
<p>Layered gray shadows, no color glow.</p>
</div>
</div>
</div>