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
@@ -133,6 +133,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 ─────────────────────────────────────────────
@@ -291,6 +309,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) {
@@ -364,6 +459,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) {
@@ -394,6 +512,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
@@ -746,6 +879,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) {
@@ -872,6 +1070,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) {
@@ -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,
};
@@ -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,
};
+306 -1
View File
@@ -3,7 +3,7 @@ import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import {
ANTIPATTERNS, checkElementBorders, isNeutralColor, isFullPage,
ANTIPATTERNS, checkElementBorders, checkElementMotion, checkElementGlow, isNeutralColor, isFullPage,
detectHtml, detectText,
walkDir, SCANNABLE_EXTENSIONS,
} from '../source/skills/critique/scripts/detect-antipatterns.mjs';
@@ -328,6 +328,311 @@ describe('detectHtml — layout', () => {
});
});
// ---------------------------------------------------------------------------
// Motion anti-patterns
// ---------------------------------------------------------------------------
describe('checkElementMotion', () => {
function mockStyle(overrides) {
return { transitionProperty: '', animationName: 'none', animationTimingFunction: '', transitionTimingFunction: '', ...overrides };
}
test('detects bounce animation name', () => {
const f = checkElementMotion('div', mockStyle({ animationName: 'bounce' }));
expect(f.some(r => r.id === 'bounce-easing')).toBe(true);
});
test('detects elastic animation name', () => {
const f = checkElementMotion('div', mockStyle({ animationName: 'elastic-in' }));
expect(f.some(r => r.id === 'bounce-easing')).toBe(true);
});
test('detects overshoot cubic-bezier in animation timing', () => {
const f = checkElementMotion('div', mockStyle({
animationTimingFunction: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
}));
expect(f.some(r => r.id === 'bounce-easing')).toBe(true);
});
test('detects overshoot cubic-bezier in transition timing', () => {
const f = checkElementMotion('div', mockStyle({
transitionTimingFunction: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
}));
expect(f.some(r => r.id === 'bounce-easing')).toBe(true);
});
test('passes standard ease-out-quart', () => {
const f = checkElementMotion('div', mockStyle({
transitionTimingFunction: 'cubic-bezier(0.25, 1, 0.5, 1)',
}));
expect(f.filter(r => r.id === 'bounce-easing')).toHaveLength(0);
});
test('passes standard ease', () => {
const f = checkElementMotion('div', mockStyle({
transitionTimingFunction: 'cubic-bezier(0.25, 0.1, 0.25, 1.0)',
}));
expect(f.filter(r => r.id === 'bounce-easing')).toHaveLength(0);
});
test('detects width transition', () => {
const f = checkElementMotion('div', mockStyle({ transitionProperty: 'width' }));
expect(f.some(r => r.id === 'layout-transition')).toBe(true);
});
test('detects height transition', () => {
const f = checkElementMotion('div', mockStyle({ transitionProperty: 'height' }));
expect(f.some(r => r.id === 'layout-transition')).toBe(true);
});
test('detects padding transition', () => {
const f = checkElementMotion('div', mockStyle({ transitionProperty: 'padding' }));
expect(f.some(r => r.id === 'layout-transition')).toBe(true);
});
test('detects margin transition', () => {
const f = checkElementMotion('div', mockStyle({ transitionProperty: 'margin' }));
expect(f.some(r => r.id === 'layout-transition')).toBe(true);
});
test('detects max-height transition', () => {
const f = checkElementMotion('div', mockStyle({ transitionProperty: 'max-height' }));
expect(f.some(r => r.id === 'layout-transition')).toBe(true);
});
test('detects layout prop among mixed transitions', () => {
const f = checkElementMotion('div', mockStyle({ transitionProperty: 'opacity, width, color' }));
expect(f.some(r => r.id === 'layout-transition')).toBe(true);
});
test('passes transform transition', () => {
const f = checkElementMotion('div', mockStyle({ transitionProperty: 'transform' }));
expect(f.filter(r => r.id === 'layout-transition')).toHaveLength(0);
});
test('passes opacity transition', () => {
const f = checkElementMotion('div', mockStyle({ transitionProperty: 'opacity' }));
expect(f.filter(r => r.id === 'layout-transition')).toHaveLength(0);
});
test('skips transition: all', () => {
const f = checkElementMotion('div', mockStyle({ transitionProperty: 'all' }));
expect(f.filter(r => r.id === 'layout-transition')).toHaveLength(0);
});
test('skips safe tags', () => {
const f = checkElementMotion('button', mockStyle({
animationName: 'bounce', transitionProperty: 'width',
}));
expect(f).toHaveLength(0);
});
});
describe('detectText — motion', () => {
test('detects animate-bounce Tailwind class', () => {
const f = detectText('<div class="animate-bounce">loading</div>', 'test.html');
expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
});
test('detects animation: bounce CSS', () => {
const f = detectText('.icon { animation: bounce 1s infinite; }', 'test.css');
expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
});
test('detects animation-name: elastic', () => {
const f = detectText('.card { animation-name: elastic; }', 'test.css');
expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
});
test('detects overshoot cubic-bezier', () => {
const f = detectText('.btn { transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); }', 'test.css');
expect(f.some(r => r.antipattern === 'bounce-easing')).toBe(true);
});
test('passes standard cubic-bezier', () => {
const f = detectText('.btn { transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1); }', 'test.css');
expect(f.filter(r => r.antipattern === 'bounce-easing')).toHaveLength(0);
});
test('detects transition: width', () => {
const f = detectText('.sidebar { transition: width 0.3s ease; }', 'test.css');
expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
});
test('detects transition: height', () => {
const f = detectText('.panel { transition: height 0.4s ease-out; }', 'test.css');
expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
});
test('detects transition: max-height', () => {
const f = detectText('.accordion { transition: max-height 0.5s ease; }', 'test.css');
expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
});
test('detects transition-property: width', () => {
const f = detectText('.box { transition-property: width; transition-duration: 0.3s; }', 'test.css');
expect(f.some(r => r.antipattern === 'layout-transition')).toBe(true);
});
test('skips transition: all', () => {
const f = detectText('.card { transition: all 0.3s ease; }', 'test.css');
expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0);
});
test('skips transition: transform', () => {
const f = detectText('.card { transition: transform 0.3s ease; }', 'test.css');
expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0);
});
test('skips transition: opacity', () => {
const f = detectText('.btn { transition: opacity 0.2s ease; }', 'test.css');
expect(f.filter(r => r.antipattern === 'layout-transition')).toHaveLength(0);
});
});
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
// ---------------------------------------------------------------------------
describe('checkElementGlow', () => {
function mockStyle(overrides) {
return { boxShadow: 'none', backgroundColor: '', ...overrides };
}
// Dark bg = luminance < 0.1 (e.g. #111827 = gray-900)
const darkBg = { r: 17, g: 24, b: 39 }; // #111827
const lightBg = { r: 249, g: 250, b: 251 }; // #f9fafb
const mediumBg = { r: 107, g: 114, b: 128 }; // #6b7280
test('detects blue glow on dark background', () => {
const f = checkElementGlow('div', mockStyle({
boxShadow: 'rgba(59, 130, 246, 0.4) 0px 0px 20px 0px',
}), darkBg);
expect(f.some(r => r.id === 'dark-glow')).toBe(true);
});
test('detects purple glow on dark background', () => {
const f = checkElementGlow('div', mockStyle({
boxShadow: 'rgba(139, 92, 246, 0.35) 0px 0px 25px 0px',
}), darkBg);
expect(f.some(r => r.id === 'dark-glow')).toBe(true);
});
test('detects glow in multi-shadow', () => {
const f = checkElementGlow('div', mockStyle({
boxShadow: 'rgba(0, 0, 0, 0.3) 0px 4px 6px 0px, rgba(168, 85, 247, 0.3) 0px 0px 30px 0px',
}), darkBg);
expect(f.some(r => r.id === 'dark-glow')).toBe(true);
});
test('passes gray shadow on dark background', () => {
const f = checkElementGlow('div', mockStyle({
boxShadow: 'rgba(0, 0, 0, 0.4) 0px 4px 12px 0px',
}), darkBg);
expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
});
test('passes colored shadow on light background', () => {
const f = checkElementGlow('div', mockStyle({
boxShadow: 'rgba(59, 130, 246, 0.4) 0px 0px 20px 0px',
}), lightBg);
expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
});
test('passes colored shadow on medium gray background', () => {
const f = checkElementGlow('div', mockStyle({
boxShadow: 'rgba(59, 130, 246, 0.5) 0px 0px 20px 0px',
}), mediumBg);
expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
});
test('passes focus ring (spread only, no blur)', () => {
const f = checkElementGlow('div', mockStyle({
boxShadow: 'rgba(59, 130, 246, 0.5) 0px 0px 0px 3px',
}), darkBg);
expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
});
test('passes subtle shadow (blur < 5px)', () => {
const f = checkElementGlow('div', mockStyle({
boxShadow: 'rgba(59, 130, 246, 0.2) 0px 1px 3px 0px',
}), darkBg);
expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
});
test('passes no shadow', () => {
const f = checkElementGlow('div', mockStyle({ boxShadow: 'none' }), darkBg);
expect(f.filter(r => r.id === 'dark-glow')).toHaveLength(0);
});
test('skips safe tags', () => {
const f = checkElementGlow('button', mockStyle({
boxShadow: 'rgba(59, 130, 246, 0.4) 0px 0px 20px 0px',
}), darkBg);
expect(f).toHaveLength(0);
});
});
describe('detectText — dark glow', () => {
test('detects colored box-shadow glow on dark background', () => {
const html = '<!DOCTYPE html><html><body style="background: #111827;"><div style="box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);">glow</div></body></html>';
const f = detectText(html, 'test.html');
expect(f.some(r => r.antipattern === 'dark-glow')).toBe(true);
});
test('skips gray shadow on dark background', () => {
const html = '<!DOCTYPE html><html><body style="background: #111827;"><div style="box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);">shadow</div></body></html>';
const f = detectText(html, 'test.html');
expect(f.filter(r => r.antipattern === 'dark-glow')).toHaveLength(0);
});
test('skips colored shadow on light page', () => {
const html = '<!DOCTYPE html><html><body style="background: #f9fafb;"><div style="box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);">glow</div></body></html>';
const f = detectText(html, 'test.html');
expect(f.filter(r => r.antipattern === 'dark-glow')).toHaveLength(0);
});
});
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
// ---------------------------------------------------------------------------
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dark Glow Anti-Patterns That Should Be Flagged</title>
<style>
body { font-family: system-ui, sans-serif; background: #111827; color: #f3f4f6; padding: 2rem; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.125rem; margin: 2.5rem 0 0.75rem; color: #9ca3af; border-bottom: 1px solid #374151; padding-bottom: 0.5rem; }
p.intro { color: #9ca3af; margin-bottom: 2rem; max-width: 36rem; font-size: 0.875rem; }
.cards { display: grid; gap: 1rem; max-width: 28rem; }
.card { padding: 1.5rem; border-radius: 0.75rem; background: #1f2937; }
.card h3 { font-weight: 600; font-size: 0.875rem; color: #f9fafb; }
.card p { font-size: 0.8125rem; color: #9ca3af; margin-top: 0.25rem; }
/* Blue glow */
.glow-blue {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
}
/* Purple glow */
.glow-purple {
box-shadow: 0 0 25px rgba(139, 92, 246, 0.35);
}
/* Cyan glow */
.glow-cyan {
box-shadow: 0 0 15px rgba(6, 182, 212, 0.5);
}
/* Multi-shadow with glow */
.glow-multi {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3), 0 0 30px rgba(168, 85, 247, 0.3);
}
/* Neon button */
.glow-button {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
background: #2563eb;
color: white;
font-weight: 600;
box-shadow: 0 0 20px rgba(59, 130, 246, 0.6), 0 0 60px rgba(59, 130, 246, 0.2);
}
/* Inline style glow (for regex detection) */
</style>
</head>
<body>
<h1>Dark Glow: Should Flag</h1>
<p class="intro">Every glow effect on this dark page should be detected.</p>
<h2>CSS Colored Glows on Dark Background</h2>
<div class="cards">
<div class="card glow-blue">
<h3>Blue glow</h3>
<p>box-shadow: 0 0 20px rgba(59, 130, 246, 0.4)</p>
</div>
<div class="card glow-purple">
<h3>Purple glow</h3>
<p>box-shadow: 0 0 25px rgba(139, 92, 246, 0.35)</p>
</div>
<div class="card glow-cyan">
<h3>Cyan glow</h3>
<p>box-shadow: 0 0 15px rgba(6, 182, 212, 0.5)</p>
</div>
<div class="card glow-multi">
<h3>Multi-shadow with colored glow</h3>
<p>Normal shadow + purple glow combined</p>
</div>
</div>
<h2>Neon Buttons</h2>
<div class="cards">
<div class="card">
<div class="glow-button">Glowing Button</div>
<p>Neon glow effect on button.</p>
</div>
</div>
<h2>Inline Style Glow</h2>
<div class="cards">
<div class="card" style="box-shadow: 0 0 20px rgba(236, 72, 153, 0.5);">
<h3>Inline pink glow</h3>
<p>Inline style for regex detection path.</p>
</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dark/Shadow Patterns That Should Pass</title>
<style>
body { font-family: system-ui, sans-serif; padding: 2rem; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.125rem; margin: 2.5rem 0 0.75rem; color: #6b7280; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; font-size: 0.875rem; }
.cards { display: grid; gap: 1rem; max-width: 28rem; }
.card { padding: 1.5rem; border-radius: 0.75rem; }
.card h3 { font-weight: 600; font-size: 0.875rem; }
.card p { font-size: 0.8125rem; color: #6b7280; margin-top: 0.25rem; }
/* Light page with tinted shadow — not glow */
.light-colored-shadow {
background: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.15);
}
/* Dark element with normal gray shadow — not glow */
.dark-normal-shadow {
background: #1f2937;
color: #f3f4f6;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
/* Dark element with focus ring (spread only, no blur) */
.dark-focus-ring {
background: #1f2937;
color: #f3f4f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.5);
}
/* Dark element with tiny blur (< 5px) — subtle, not glow */
.dark-subtle-shadow {
background: #1f2937;
color: #f3f4f6;
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.2);
}
/* Dark element with no shadow */
.dark-no-shadow {
background: #1f2937;
color: #f3f4f6;
}
/* Medium gray background — not dark enough */
.medium-gray {
background: #6b7280;
color: white;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
}
</style>
</head>
<body style="background: #f9fafb;">
<h1>Glow Patterns: Should Pass</h1>
<p class="intro">None of these should trigger dark-glow warnings.</p>
<h2>Light Page + Colored Shadow</h2>
<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>
</div>
</div>
<h2>Dark Element + Normal Shadow</h2>
<div class="cards">
<div class="card dark-normal-shadow">
<h3>Normal gray shadow on dark card</h3>
<p>Gray/black shadow is not a glow.</p>
</div>
</div>
<h2>Dark Element + Focus Ring</h2>
<div class="cards">
<div class="card dark-focus-ring">
<h3>Focus ring (spread, no blur)</h3>
<p>Functional ring, not decorative glow.</p>
</div>
</div>
<h2>Dark Element + Subtle Shadow</h2>
<div class="cards">
<div class="card dark-subtle-shadow">
<h3>Tiny blur (&lt;5px)</h3>
<p>Too subtle to be "glowing".</p>
</div>
</div>
<h2>Dark Element + No Shadow</h2>
<div class="cards">
<div class="card dark-no-shadow">
<h3>No shadow at all</h3>
<p>Just a dark card, no glow.</p>
</div>
</div>
<h2>Medium Gray + Colored Shadow</h2>
<div class="cards">
<div class="card medium-gray">
<h3>Not dark enough background</h3>
<p>Medium gray doesn't qualify as "dark mode".</p>
</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Motion Anti-Patterns That Should Be Flagged</title>
<style>
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.125rem; margin: 2.5rem 0 0.75rem; color: #6b7280; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; font-size: 0.875rem; }
.demo { max-width: 28rem; margin-bottom: 1rem; padding: 1rem; background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.demo h3 { font-weight: 600; font-size: 0.875rem; }
.demo p { font-size: 0.8125rem; color: #6b7280; margin-top: 0.25rem; }
/* Bounce easing */
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.bounce-animation {
animation: bounce 1s infinite;
}
/* Elastic cubic-bezier (overshoot) */
.elastic-transition {
transition: transform 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
/* Layout property transitions */
.width-transition {
transition: width 0.3s ease;
}
.height-transition {
transition: height 0.4s ease-out;
}
.padding-transition {
transition: padding 0.2s linear;
}
.margin-transition {
transition: margin 0.3s ease-in;
}
.max-height-transition {
transition: max-height 0.5s ease;
}
.multi-layout-transition {
transition: width 0.3s ease, height 0.3s ease;
}
.mixed-transition {
transition: width 0.3s ease, opacity 0.3s ease;
}
.transition-property-width {
transition-property: width;
transition-duration: 0.3s;
}
</style>
</head>
<body>
<h1>Motion Anti-Patterns: Should Flag</h1>
<p class="intro">Every example on this page should be detected by the motion anti-pattern scanner.</p>
<h2>Bounce / Elastic Easing</h2>
<div class="demo bounce-animation">
<h3>CSS bounce animation</h3>
<p>animation: bounce 1s infinite</p>
</div>
<div class="demo elastic-transition">
<h3>Elastic cubic-bezier</h3>
<p>cubic-bezier(0.68, -0.55, 0.265, 1.55)</p>
</div>
<h2>Layout Property Transitions</h2>
<div class="demo width-transition">
<h3>transition: width</h3>
<p>Animating width causes layout thrash.</p>
</div>
<div class="demo height-transition">
<h3>transition: height</h3>
<p>Animating height causes layout thrash.</p>
</div>
<div class="demo padding-transition">
<h3>transition: padding</h3>
<p>Animating padding causes layout thrash.</p>
</div>
<div class="demo margin-transition">
<h3>transition: margin</h3>
<p>Animating margin causes layout thrash.</p>
</div>
<div class="demo max-height-transition">
<h3>transition: max-height</h3>
<p>Use grid-template-rows instead.</p>
</div>
<div class="demo multi-layout-transition">
<h3>transition: width, height</h3>
<p>Multiple layout properties.</p>
</div>
<div class="demo mixed-transition">
<h3>transition: width, opacity</h3>
<p>Layout property mixed with OK property.</p>
</div>
<div class="demo transition-property-width">
<h3>transition-property: width</h3>
<p>Longhand form.</p>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Motion Patterns That Should Pass</title>
<style>
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.125rem; margin: 2.5rem 0 0.75rem; color: #6b7280; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; font-size: 0.875rem; }
.demo { max-width: 28rem; margin-bottom: 1rem; padding: 1rem; background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.demo h3 { font-weight: 600; font-size: 0.875rem; }
.demo p { font-size: 0.8125rem; color: #6b7280; margin-top: 0.25rem; }
/* Good easing — smooth exponential deceleration */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Good transitions — transform and opacity only */
.transform-transition {
transition: transform 0.3s ease-out;
}
.opacity-transition {
transition: opacity 0.2s ease;
}
.color-transition {
transition: color 0.15s ease, background-color 0.15s ease;
}
.shadow-transition {
transition: box-shadow 0.2s ease;
}
.all-transition {
transition: all 0.3s ease;
}
.multi-safe-transition {
transition: transform 0.3s ease, opacity 0.3s ease, box-shadow 0.2s ease;
}
/* Standard easing curves (y values within [0, 1]) */
.ease-out-quart {
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
}
.ease-out-expo {
transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
</style>
</head>
<body>
<h1>Motion Patterns: Should Pass</h1>
<p class="intro">None of these should trigger motion anti-pattern warnings.</p>
<h2>Good Easing</h2>
<div class="demo fade-in">
<h3>Smooth fade in</h3>
<p>animation: fadeIn with exponential ease-out</p>
</div>
<div class="demo ease-out-quart">
<h3>Ease-out quart</h3>
<p>cubic-bezier(0.25, 1, 0.5, 1) — smooth deceleration</p>
</div>
<div class="demo ease-out-expo">
<h3>Ease-out expo</h3>
<p>cubic-bezier(0.16, 1, 0.3, 1) — natural feel</p>
</div>
<h2>Good Transitions (transform/opacity/color only)</h2>
<div class="demo transform-transition">
<h3>transition: transform</h3>
<p>Transform is GPU-accelerated and safe.</p>
</div>
<div class="demo opacity-transition">
<h3>transition: opacity</h3>
<p>Opacity is GPU-accelerated and safe.</p>
</div>
<div class="demo color-transition">
<h3>transition: color, background-color</h3>
<p>Color transitions are paint-only, no layout.</p>
</div>
<div class="demo shadow-transition">
<h3>transition: box-shadow</h3>
<p>Shadow transitions are paint-only.</p>
</div>
<div class="demo all-transition">
<h3>transition: all</h3>
<p>Too common to flag — might include layout but usually paired with transform/opacity.</p>
</div>
<div class="demo multi-safe-transition">
<h3>transition: transform, opacity, box-shadow</h3>
<p>Multiple safe properties combined.</p>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>