Add motion and dark-glow anti-pattern detection (15 → 16)

New detections:
- bounce-easing: flags bounce/elastic animation names, animate-bounce
  (Tailwind), and cubic-bezier curves with overshoot (y values outside
  [0, 1])
- layout-transition: flags explicit transition of width, height, padding,
  margin, and max-height/min-width variants; skips transition: all
- dark-glow: flags colored box-shadow with blur > 4px on dark backgrounds
  (luminance < 0.1); skips gray shadows, focus rings (no blur), and
  non-dark backgrounds

Includes 48 new tests across unit, regex, and jsdom fixture tests with
dedicated should-flag and should-pass HTML fixtures for both categories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-18 08:36:21 -07:00
co-authored by Claude Opus 4.6
parent d1d8929dc2
commit c751015fa1
8 changed files with 1481 additions and 5 deletions
@@ -131,6 +131,24 @@ const ANTIPATTERNS = [
description:
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
},
{
id: 'bounce-easing',
name: 'Bounce or elastic easing',
description:
'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
},
{
id: 'layout-transition',
name: 'Layout property animation',
description:
'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
},
{
id: 'dark-glow',
name: 'Dark mode with glowing accents',
description:
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
},
];
// ─── Section 2: Color Utilities ─────────────────────────────────────────────
@@ -289,6 +307,83 @@ function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
return hasRadius || hasBg;
}
const LAYOUT_TRANSITION_PROPS = new Set([
'width', 'height', 'padding', 'margin',
'max-height', 'max-width', 'min-height', 'min-width',
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
]);
function checkMotion(opts) {
const { tag, transitionProperty, animationName, timingFunctions, classList } = opts;
if (SAFE_TAGS.has(tag)) return [];
const findings = [];
// --- Bounce/elastic easing ---
if (animationName && animationName !== 'none' && /bounce|elastic|wobble|jiggle|spring/i.test(animationName)) {
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationName}` });
}
if (classList && /\banimate-bounce\b/.test(classList)) {
findings.push({ id: 'bounce-easing', snippet: 'animate-bounce (Tailwind)' });
}
// Check timing functions for overshoot cubic-bezier (y values outside [0, 1])
if (timingFunctions) {
const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
let m;
while ((m = bezierRe.exec(timingFunctions)) !== 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 transition ---
if (transitionProperty && transitionProperty !== 'all' && transitionProperty !== 'none') {
const props = transitionProperty.split(',').map(p => p.trim().toLowerCase());
const layoutFound = props.filter(p => LAYOUT_TRANSITION_PROPS.has(p));
if (layoutFound.length > 0) {
findings.push({ id: 'layout-transition', snippet: `transition: ${layoutFound.join(', ')}` });
}
}
return findings;
}
function checkGlow(opts) {
const { tag, boxShadow, effectiveBg } = opts;
if (SAFE_TAGS.has(tag)) return [];
if (!boxShadow || boxShadow === 'none') return [];
// Only flag on dark backgrounds (luminance < 0.1)
const bgLum = relativeLuminance(effectiveBg);
if (bgLum >= 0.1) return [];
// Split multiple shadows (commas not inside parentheses)
const parts = boxShadow.split(/,(?![^(]*\))/);
for (const shadow of parts) {
const colorMatch = shadow.match(/rgba?\([^)]+\)/);
if (!colorMatch) continue;
const color = parseRgb(colorMatch[0]);
if (!color || !hasChroma(color, 30)) continue;
// Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
.map(m => parseFloat(m[1]));
// Third value is blur (offset-x, offset-y, blur, [spread])
if (pxVals.length >= 3 && pxVals[2] > 4) {
return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
}
}
return [];
}
// ─── Section 4: resolveBackground (unified) ─────────────────────────────────
function resolveBackground(el, win) {
@@ -362,6 +457,29 @@ function checkElementColorsDOM(el) {
});
}
function checkElementMotionDOM(el) {
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS.has(tag)) return [];
const style = getComputedStyle(el);
return checkMotion({
tag,
transitionProperty: style.transitionProperty || '',
animationName: style.animationName || '',
timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
classList: el.getAttribute('class') || '',
});
}
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);
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
}
// Node adapters — take pre-extracted jsdom computed style
function checkElementBorders(tag, style) {
@@ -392,6 +510,21 @@ function checkElementColors(el, style, tag, window) {
});
}
function checkElementMotion(tag, style) {
return checkMotion({
tag,
transitionProperty: style.transitionProperty || '',
animationName: style.animationName || '',
timingFunctions: [style.animationTimingFunction, style.transitionTimingFunction].filter(Boolean).join(' '),
classList: '',
});
}
function checkElementGlow(tag, style, effectiveBg) {
if (!style.boxShadow || style.boxShadow === 'none') return [];
return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
}
// ─── Section 6: Page-Level Checks ───────────────────────────────────────────
// Browser page-level checks — use document/getComputedStyle globals
@@ -744,6 +877,71 @@ 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) {
@@ -870,6 +1068,8 @@ if (IS_BROWSER) {
const findings = [
...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...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 })),
];
if (findings.length > 0) {
@@ -970,7 +1170,7 @@ async function detectHtml(filePath) {
const findings = [];
// Element-level checks (borders + colors)
// Element-level checks (borders + colors + motion)
for (const el of document.querySelectorAll('*')) {
const tag = el.tagName.toLowerCase();
const style = window.getComputedStyle(el);
@@ -980,6 +1180,12 @@ async function detectHtml(filePath) {
for (const f of checkElementColors(el, style, tag, window)) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of checkElementMotion(tag, style)) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
// Page-level checks (only for full pages, not partials)
@@ -990,6 +1196,12 @@ 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)) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
window.close();
@@ -1126,6 +1338,40 @@ const REGEX_MATCHERS = [
{ id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
fmt: (m) => `${m[0]} gradient` },
// --- Bounce/elastic easing ---
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
},
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
// --- Layout property transition ---
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
test: (m) => {
const val = m[1].toLowerCase();
if (/\ball\b/.test(val)) return false;
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
},
fmt: (m) => {
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
} },
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
test: (m) => {
const val = m[1].toLowerCase();
if (/\ball\b/.test(val)) return false;
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
},
fmt: (m) => {
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
} },
];
const REGEX_ANALYZERS = [
@@ -1212,6 +1458,32 @@ const REGEX_ANALYZERS = [
if (total < 5 || centered / total <= 0.7) return [];
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
(content, filePath) => {
// Check if page has a 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/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== 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; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
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) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
},
];
function detectText(content, filePath) {
@@ -1413,7 +1685,7 @@ if (!IS_BROWSER) {
export {
ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS,
checkElementBorders, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
checkElementBorders, checkElementMotion, checkElementGlow, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
detectHtml, detectUrl, detectText,
walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
};