mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-13 06:36:26 +03:00
Add layout anti-pattern detection: nested cards, identical grids, spacing, centering
Four new layout detections: - nested-cards: jsdom DOM walk finds card-like elements (shadow + rounded + bg) nested inside other card-like elements. Excludes dropdowns (absolute/fixed), form inputs, code blocks, badges (<20 chars), and known component classes. - identical-card-grid: detects grid/flex parents with 3+ children sharing the same structural fingerprint (icon + heading + paragraph template pattern). - monotonous-spacing: regex on raw HTML collects padding/margin/gap values (px, rem, Tailwind classes), rounds to nearest 4px, flags when >60% use the same value with <=3 distinct values. - everything-centered: regex counts text-align:center and Tailwind text-center on text elements, flags when >70% of 5+ text elements are centered. Also narrowed pure-black-white to only flag #000 as background color — text-black, text-white, bg-white, and #fff are no longer flagged (too common, per user feedback). Extensive should-pass fixture covers: shadcn card sub-components, cards with form inputs/dropdowns/code blocks/badges/accordions/tabs/images, pricing cards, varied spacing, mixed centered/left-aligned layouts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e45d6cde1a
commit
72b9ca2941
@@ -82,9 +82,9 @@ const ANTIPATTERNS = [
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'pure-black-white',
|
||||
name: 'Pure black or white',
|
||||
name: 'Pure black background',
|
||||
description:
|
||||
'Pure #000 or #fff never appears in nature. Tint your blacks and whites slightly toward your brand hue for a more natural, cohesive feel.',
|
||||
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
|
||||
},
|
||||
{
|
||||
id: 'gray-on-color',
|
||||
@@ -110,6 +110,33 @@ const ANTIPATTERNS = [
|
||||
description:
|
||||
'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Layout & space anti-patterns
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'nested-cards',
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
},
|
||||
{
|
||||
id: 'identical-card-grid',
|
||||
name: 'Identical card grid',
|
||||
description:
|
||||
'Same-sized cards with identical icon + heading + text structure, repeated endlessly. Vary card sizes, layouts, or content structure to create visual interest.',
|
||||
},
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
},
|
||||
{
|
||||
id: 'everything-centered',
|
||||
name: 'Everything centered',
|
||||
description:
|
||||
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Check if content looks like a full page (not a component/partial) */
|
||||
@@ -270,7 +297,10 @@ function checkElementColors(el, style, tag, window) {
|
||||
const hasText = el.textContent?.trim().length > 0;
|
||||
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
|
||||
|
||||
// Pure black/white is handled via regex on raw HTML (jsdom's computed bg is unreliable)
|
||||
// Pure black background — only flag #000 as background (not text, not white)
|
||||
if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
|
||||
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
|
||||
}
|
||||
|
||||
if (hasDirectText && textColor) {
|
||||
// --- Gray text on colored background ---
|
||||
@@ -327,26 +357,10 @@ function checkElementColors(el, style, tag, window) {
|
||||
const TW_GRAY_FAMILIES = /\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/;
|
||||
const TW_COLORED_BG = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/;
|
||||
|
||||
// Tailwind pure black/white
|
||||
// Only flag bg-black (pure black background) — text colors are fine
|
||||
if (/\bbg-black\b/.test(classList)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
|
||||
}
|
||||
if (/\bbg-white\b/.test(classList)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'bg-white' });
|
||||
}
|
||||
if (/\btext-black\b/.test(classList)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'text-black' });
|
||||
}
|
||||
// text-white: only flag if there's no dark background on the same element
|
||||
// (text-white on dark bg is a standard, intentional pattern)
|
||||
if (/\btext-white\b/.test(classList)) {
|
||||
const hasDarkBg = /\bbg-black\b/.test(classList) ||
|
||||
/\bbg-(?:gray|slate|zinc|neutral|stone)-(?:7|8|9)\d{2}\b/.test(classList) ||
|
||||
/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[5-9]\d{2}|[6-9]\d{2})\b/.test(classList);
|
||||
if (!hasDarkBg) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'text-white (no dark bg class)' });
|
||||
}
|
||||
}
|
||||
|
||||
// Tailwind gray text on colored background
|
||||
const grayMatch = classList.match(TW_GRAY_FAMILIES);
|
||||
@@ -501,14 +515,10 @@ function checkPageTypography(document, window) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pure black/white (regex on raw HTML — jsdom doesn't resolve inline bg colors) ---
|
||||
const pureRe = /(?:color|background(?:-color)?)\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
|
||||
if (pureRe.test(html)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 in styles' });
|
||||
}
|
||||
const pureWhiteRe = /(?:color|background(?:-color)?)\s*:\s*(?:#ffffff|#fff|rgb\(\s*255,\s*255,\s*255\s*\))\b/gi;
|
||||
if (pureWhiteRe.test(html)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'Pure #fff in styles' });
|
||||
// --- Pure black background (regex on raw HTML — only flag #000 as background, not text) ---
|
||||
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 ---
|
||||
@@ -543,6 +553,206 @@ function checkPageTypography(document, window) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element looks like a "card" (has shadow, border-radius, and background).
|
||||
*/
|
||||
function isCardLike(el, window) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
// Skip non-visual elements
|
||||
if (SAFE_TAGS.has(tag)) return false;
|
||||
// Skip form elements (inputs, selects, textareas have shadow/rounded)
|
||||
if (['input', 'select', 'textarea'].includes(tag)) return false;
|
||||
// Skip images, media
|
||||
if (['img', 'video', 'canvas', 'picture'].includes(tag)) return false;
|
||||
|
||||
const shadow = style.boxShadow || '';
|
||||
const hasShadow = shadow && shadow !== 'none';
|
||||
const radius = parseFloat(style.borderRadius) || 0;
|
||||
const hasRadius = radius > 0;
|
||||
|
||||
// Check background: card-like if it has an opaque bg different from transparent
|
||||
const rawBg = el.getAttribute?.('style')?.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||
const hasBg = rawBg && !/transparent/i.test(rawBg[1]);
|
||||
|
||||
// Also check Tailwind classes for card indicators
|
||||
const cls = el.getAttribute?.('class') || '';
|
||||
const twShadow = /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
|
||||
const twRounded = /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
|
||||
const twBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
|
||||
const twBorder = /\bborder\b/.test(cls);
|
||||
|
||||
// A "card" needs at least 2 of: shadow, rounded, bg/border
|
||||
const signals = [
|
||||
hasShadow || twShadow,
|
||||
hasRadius || twRounded,
|
||||
hasBg || twBg || twBorder,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return signals >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-level layout checks.
|
||||
* Returns array of { id, snippet } findings.
|
||||
*/
|
||||
function checkPageLayout(document, window) {
|
||||
const findings = [];
|
||||
|
||||
// --- Nested cards ---
|
||||
const allEls = document.querySelectorAll('*');
|
||||
const flaggedNested = new Set();
|
||||
for (const el of allEls) {
|
||||
if (!isCardLike(el, window)) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const cls = el.getAttribute?.('class') || '';
|
||||
const rawStyle = el.getAttribute?.('style') || '';
|
||||
|
||||
// Exclude elements that look like non-card components
|
||||
if (['pre', 'code'].includes(tag)) continue;
|
||||
// Exclude absolutely/fixed positioned elements (dropdowns, modals, tooltips)
|
||||
if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
|
||||
// Exclude small elements (badges, chips, icons) — text < 20 chars
|
||||
if ((el.textContent?.trim().length || 0) < 20) continue;
|
||||
// Exclude form elements that happen to match card heuristics
|
||||
if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
|
||||
|
||||
// Walk up to find card-like ancestor
|
||||
let parent = el.parentElement;
|
||||
while (parent) {
|
||||
if (isCardLike(parent, window)) {
|
||||
const key = `${parent.tagName}:${el.tagName}`;
|
||||
if (!flaggedNested.has(key)) {
|
||||
flaggedNested.add(key);
|
||||
findings.push({ id: 'nested-cards', snippet: `Card inside card (${tag} in ${parent.tagName.toLowerCase()})` });
|
||||
}
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Identical card grid ---
|
||||
const gridParents = document.querySelectorAll('[class*="grid"], [style*="display: grid"], [style*="display: flex"]');
|
||||
for (const grid of gridParents) {
|
||||
const children = [...grid.children].filter(c => {
|
||||
const tag = c.tagName.toLowerCase();
|
||||
return tag !== 'script' && tag !== 'style';
|
||||
});
|
||||
if (children.length < 3) continue;
|
||||
|
||||
// Compare structural fingerprint of each child
|
||||
function fingerprint(el) {
|
||||
const childTags = [...el.children].map(c => c.tagName.toLowerCase());
|
||||
// Check for icon-like element (svg, img, or div with fixed size classes)
|
||||
const hasIcon = childTags.includes('svg') || childTags.includes('img') ||
|
||||
[...el.children].some(c => {
|
||||
const cls = c.getAttribute?.('class') || '';
|
||||
return /\bw-\d+\b.*\bh-\d+\b/.test(cls) && /\brounded/.test(cls);
|
||||
});
|
||||
const hasHeading = childTags.some(t => /^h[1-6]$/.test(t));
|
||||
const hasParagraph = childTags.includes('p');
|
||||
return `icon:${hasIcon}|h:${hasHeading}|p:${hasParagraph}|children:${childTags.length}`;
|
||||
}
|
||||
|
||||
const fps = children.map(fingerprint);
|
||||
const allSame = fps.every(f => f === fps[0]);
|
||||
// Only flag if structure includes icon + heading + paragraph (the template pattern)
|
||||
if (allSame && fps[0].includes('icon:true') && fps[0].includes('h:true') && fps[0].includes('p:true')) {
|
||||
findings.push({
|
||||
id: 'identical-card-grid',
|
||||
snippet: `${children.length} identical cards (icon + heading + text)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Monotonous spacing ---
|
||||
// Regex on raw HTML — jsdom doesn't compute inline px spacing reliably
|
||||
const spacingValues = [];
|
||||
const html = document.documentElement?.outerHTML || '';
|
||||
|
||||
// CSS inline: padding/margin with px values
|
||||
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);
|
||||
}
|
||||
// CSS gap
|
||||
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
||||
while ((sm = gapRe.exec(html)) !== null) {
|
||||
spacingValues.push(parseInt(sm[1], 10));
|
||||
}
|
||||
// Tailwind spacing classes
|
||||
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);
|
||||
}
|
||||
// rem values (convert at 16px base)
|
||||
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);
|
||||
}
|
||||
|
||||
// Round to nearest 4px to group similar values (e.g., 15px and 16px are effectively the same)
|
||||
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);
|
||||
// Flag if the dominant spacing value is used > 60% of the time with few distinct values
|
||||
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 ---
|
||||
// Check inline styles and Tailwind classes for text-align: center
|
||||
// Also walk up ancestors for inherited centering
|
||||
const textEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
|
||||
let centeredCount = 0;
|
||||
let totalText = 0;
|
||||
for (const el of textEls) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
|
||||
if (!hasDirectText) continue;
|
||||
totalText++;
|
||||
|
||||
// Check element and ancestors for centering
|
||||
let cur = el;
|
||||
let isCentered = false;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const rawStyle = cur.getAttribute?.('style') || '';
|
||||
const cls = cur.getAttribute?.('class') || '';
|
||||
if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
|
||||
isCentered = true;
|
||||
break;
|
||||
}
|
||||
// Stop at body
|
||||
if (cur.tagName === 'BODY') break;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
if (isCentered) centeredCount++;
|
||||
}
|
||||
|
||||
if (totalText >= 5 && centeredCount / totalText > 0.7) {
|
||||
findings.push({
|
||||
id: 'everything-centered',
|
||||
snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jsdom detection (default for HTML files)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -603,11 +813,14 @@ async function detectHtml(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Page-level typography checks (only for full pages, not partials)
|
||||
// Page-level checks (only for full pages, not partials)
|
||||
if (isFullPage(html)) {
|
||||
for (const f of checkPageTypography(document, window)) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of checkPageLayout(document, window)) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
|
||||
window.close();
|
||||
@@ -784,10 +997,10 @@ const REGEX_MATCHERS = [
|
||||
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
|
||||
// --- Pure black/white ---
|
||||
{ id: 'pure-black-white', regex: /(?:color|background(?:-color)?)\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\)|#ffffff|#fff|rgb\(255,\s*255,\s*255\))\b/gi,
|
||||
// --- Pure black background ---
|
||||
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => `${m[0]}` },
|
||||
fmt: (m) => m[0] },
|
||||
// --- Gradient text ---
|
||||
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
|
||||
test: (m, line) => /gradient/i.test(line),
|
||||
@@ -796,8 +1009,8 @@ const REGEX_MATCHERS = [
|
||||
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
|
||||
test: (m, line) => /\bbg-gradient-to-/i.test(line),
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind pure black/white ---
|
||||
{ id: 'pure-black-white', regex: /\b(bg-black|bg-white|text-black)\b/g,
|
||||
// --- Tailwind pure black background ---
|
||||
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
|
||||
test: () => true,
|
||||
fmt: (m) => m[0] },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
@@ -861,6 +1074,43 @@ const REGEX_ANALYZERS = [
|
||||
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
|
||||
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
|
||||
},
|
||||
// Monotonous spacing (regex)
|
||||
(content, filePath) => {
|
||||
const vals = [];
|
||||
let m;
|
||||
const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
|
||||
while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
|
||||
const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
|
||||
while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
|
||||
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
||||
while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
|
||||
const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
|
||||
while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
|
||||
const rounded = vals.map(v => Math.round(v / 4) * 4);
|
||||
if (rounded.length < 10) return [];
|
||||
const counts = {};
|
||||
for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
|
||||
const maxCount = Math.max(...Object.values(counts));
|
||||
const pct = maxCount / rounded.length;
|
||||
const unique = [...new Set(rounded)].filter(v => v > 0);
|
||||
if (pct <= 0.6 || unique.length > 3) return [];
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
||||
},
|
||||
// Everything centered (regex)
|
||||
(content, filePath) => {
|
||||
const lines = content.split('\n');
|
||||
let centered = 0, total = 0;
|
||||
for (const line of lines) {
|
||||
// Check lines that have text content elements
|
||||
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
|
||||
total++;
|
||||
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
|
||||
}
|
||||
}
|
||||
if (total < 5 || centered / total <= 0.7) return [];
|
||||
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -1058,7 +1308,7 @@ if (isMainModule) main();
|
||||
|
||||
export {
|
||||
ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS,
|
||||
checkElementBorders, checkPageTypography, isNeutralColor, isFullPage,
|
||||
checkElementBorders, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
|
||||
detectHtml, detectUrl, detectText,
|
||||
walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
|
||||
};
|
||||
|
||||
@@ -82,9 +82,9 @@ const ANTIPATTERNS = [
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'pure-black-white',
|
||||
name: 'Pure black or white',
|
||||
name: 'Pure black background',
|
||||
description:
|
||||
'Pure #000 or #fff never appears in nature. Tint your blacks and whites slightly toward your brand hue for a more natural, cohesive feel.',
|
||||
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
|
||||
},
|
||||
{
|
||||
id: 'gray-on-color',
|
||||
@@ -110,6 +110,33 @@ const ANTIPATTERNS = [
|
||||
description:
|
||||
'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
|
||||
},
|
||||
// -------------------------------------------------------------------------
|
||||
// Layout & space anti-patterns
|
||||
// -------------------------------------------------------------------------
|
||||
{
|
||||
id: 'nested-cards',
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
},
|
||||
{
|
||||
id: 'identical-card-grid',
|
||||
name: 'Identical card grid',
|
||||
description:
|
||||
'Same-sized cards with identical icon + heading + text structure, repeated endlessly. Vary card sizes, layouts, or content structure to create visual interest.',
|
||||
},
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
},
|
||||
{
|
||||
id: 'everything-centered',
|
||||
name: 'Everything centered',
|
||||
description:
|
||||
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Check if content looks like a full page (not a component/partial) */
|
||||
@@ -270,7 +297,10 @@ function checkElementColors(el, style, tag, window) {
|
||||
const hasText = el.textContent?.trim().length > 0;
|
||||
const hasDirectText = hasText && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim());
|
||||
|
||||
// Pure black/white is handled via regex on raw HTML (jsdom's computed bg is unreliable)
|
||||
// Pure black background — only flag #000 as background (not text, not white)
|
||||
if (bgColor && bgColor.a > 0.1 && bgColor.r === 0 && bgColor.g === 0 && bgColor.b === 0) {
|
||||
findings.push({ id: 'pure-black-white', snippet: '#000000 background' });
|
||||
}
|
||||
|
||||
if (hasDirectText && textColor) {
|
||||
// --- Gray text on colored background ---
|
||||
@@ -327,26 +357,10 @@ function checkElementColors(el, style, tag, window) {
|
||||
const TW_GRAY_FAMILIES = /\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/;
|
||||
const TW_COLORED_BG = /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/;
|
||||
|
||||
// Tailwind pure black/white
|
||||
// Only flag bg-black (pure black background) — text colors are fine
|
||||
if (/\bbg-black\b/.test(classList)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'bg-black' });
|
||||
}
|
||||
if (/\bbg-white\b/.test(classList)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'bg-white' });
|
||||
}
|
||||
if (/\btext-black\b/.test(classList)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'text-black' });
|
||||
}
|
||||
// text-white: only flag if there's no dark background on the same element
|
||||
// (text-white on dark bg is a standard, intentional pattern)
|
||||
if (/\btext-white\b/.test(classList)) {
|
||||
const hasDarkBg = /\bbg-black\b/.test(classList) ||
|
||||
/\bbg-(?:gray|slate|zinc|neutral|stone)-(?:7|8|9)\d{2}\b/.test(classList) ||
|
||||
/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:[5-9]\d{2}|[6-9]\d{2})\b/.test(classList);
|
||||
if (!hasDarkBg) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'text-white (no dark bg class)' });
|
||||
}
|
||||
}
|
||||
|
||||
// Tailwind gray text on colored background
|
||||
const grayMatch = classList.match(TW_GRAY_FAMILIES);
|
||||
@@ -501,14 +515,10 @@ function checkPageTypography(document, window) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pure black/white (regex on raw HTML — jsdom doesn't resolve inline bg colors) ---
|
||||
const pureRe = /(?:color|background(?:-color)?)\s*:\s*(?:#000000|#000|rgb\(\s*0,\s*0,\s*0\s*\))\b/gi;
|
||||
if (pureRe.test(html)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'Pure #000 in styles' });
|
||||
}
|
||||
const pureWhiteRe = /(?:color|background(?:-color)?)\s*:\s*(?:#ffffff|#fff|rgb\(\s*255,\s*255,\s*255\s*\))\b/gi;
|
||||
if (pureWhiteRe.test(html)) {
|
||||
findings.push({ id: 'pure-black-white', snippet: 'Pure #fff in styles' });
|
||||
// --- Pure black background (regex on raw HTML — only flag #000 as background, not text) ---
|
||||
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 ---
|
||||
@@ -543,6 +553,206 @@ function checkPageTypography(document, window) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an element looks like a "card" (has shadow, border-radius, and background).
|
||||
*/
|
||||
function isCardLike(el, window) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
// Skip non-visual elements
|
||||
if (SAFE_TAGS.has(tag)) return false;
|
||||
// Skip form elements (inputs, selects, textareas have shadow/rounded)
|
||||
if (['input', 'select', 'textarea'].includes(tag)) return false;
|
||||
// Skip images, media
|
||||
if (['img', 'video', 'canvas', 'picture'].includes(tag)) return false;
|
||||
|
||||
const shadow = style.boxShadow || '';
|
||||
const hasShadow = shadow && shadow !== 'none';
|
||||
const radius = parseFloat(style.borderRadius) || 0;
|
||||
const hasRadius = radius > 0;
|
||||
|
||||
// Check background: card-like if it has an opaque bg different from transparent
|
||||
const rawBg = el.getAttribute?.('style')?.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||
const hasBg = rawBg && !/transparent/i.test(rawBg[1]);
|
||||
|
||||
// Also check Tailwind classes for card indicators
|
||||
const cls = el.getAttribute?.('class') || '';
|
||||
const twShadow = /\bshadow(?:-sm|-md|-lg|-xl|-2xl)?\b/.test(cls);
|
||||
const twRounded = /\brounded(?:-sm|-md|-lg|-xl|-2xl|-full)?\b/.test(cls);
|
||||
const twBg = /\bbg-(?:white|gray-\d+|slate-\d+)\b/.test(cls);
|
||||
const twBorder = /\bborder\b/.test(cls);
|
||||
|
||||
// A "card" needs at least 2 of: shadow, rounded, bg/border
|
||||
const signals = [
|
||||
hasShadow || twShadow,
|
||||
hasRadius || twRounded,
|
||||
hasBg || twBg || twBorder,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return signals >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-level layout checks.
|
||||
* Returns array of { id, snippet } findings.
|
||||
*/
|
||||
function checkPageLayout(document, window) {
|
||||
const findings = [];
|
||||
|
||||
// --- Nested cards ---
|
||||
const allEls = document.querySelectorAll('*');
|
||||
const flaggedNested = new Set();
|
||||
for (const el of allEls) {
|
||||
if (!isCardLike(el, window)) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const cls = el.getAttribute?.('class') || '';
|
||||
const rawStyle = el.getAttribute?.('style') || '';
|
||||
|
||||
// Exclude elements that look like non-card components
|
||||
if (['pre', 'code'].includes(tag)) continue;
|
||||
// Exclude absolutely/fixed positioned elements (dropdowns, modals, tooltips)
|
||||
if (/\b(?:absolute|fixed)\b/.test(cls) || /position\s*:\s*(?:absolute|fixed)/i.test(rawStyle)) continue;
|
||||
// Exclude small elements (badges, chips, icons) — text < 20 chars
|
||||
if ((el.textContent?.trim().length || 0) < 20) continue;
|
||||
// Exclude form elements that happen to match card heuristics
|
||||
if (/\b(?:dropdown|popover|tooltip|menu|modal|dialog)\b/i.test(cls)) continue;
|
||||
|
||||
// Walk up to find card-like ancestor
|
||||
let parent = el.parentElement;
|
||||
while (parent) {
|
||||
if (isCardLike(parent, window)) {
|
||||
const key = `${parent.tagName}:${el.tagName}`;
|
||||
if (!flaggedNested.has(key)) {
|
||||
flaggedNested.add(key);
|
||||
findings.push({ id: 'nested-cards', snippet: `Card inside card (${tag} in ${parent.tagName.toLowerCase()})` });
|
||||
}
|
||||
break;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Identical card grid ---
|
||||
const gridParents = document.querySelectorAll('[class*="grid"], [style*="display: grid"], [style*="display: flex"]');
|
||||
for (const grid of gridParents) {
|
||||
const children = [...grid.children].filter(c => {
|
||||
const tag = c.tagName.toLowerCase();
|
||||
return tag !== 'script' && tag !== 'style';
|
||||
});
|
||||
if (children.length < 3) continue;
|
||||
|
||||
// Compare structural fingerprint of each child
|
||||
function fingerprint(el) {
|
||||
const childTags = [...el.children].map(c => c.tagName.toLowerCase());
|
||||
// Check for icon-like element (svg, img, or div with fixed size classes)
|
||||
const hasIcon = childTags.includes('svg') || childTags.includes('img') ||
|
||||
[...el.children].some(c => {
|
||||
const cls = c.getAttribute?.('class') || '';
|
||||
return /\bw-\d+\b.*\bh-\d+\b/.test(cls) && /\brounded/.test(cls);
|
||||
});
|
||||
const hasHeading = childTags.some(t => /^h[1-6]$/.test(t));
|
||||
const hasParagraph = childTags.includes('p');
|
||||
return `icon:${hasIcon}|h:${hasHeading}|p:${hasParagraph}|children:${childTags.length}`;
|
||||
}
|
||||
|
||||
const fps = children.map(fingerprint);
|
||||
const allSame = fps.every(f => f === fps[0]);
|
||||
// Only flag if structure includes icon + heading + paragraph (the template pattern)
|
||||
if (allSame && fps[0].includes('icon:true') && fps[0].includes('h:true') && fps[0].includes('p:true')) {
|
||||
findings.push({
|
||||
id: 'identical-card-grid',
|
||||
snippet: `${children.length} identical cards (icon + heading + text)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Monotonous spacing ---
|
||||
// Regex on raw HTML — jsdom doesn't compute inline px spacing reliably
|
||||
const spacingValues = [];
|
||||
const html = document.documentElement?.outerHTML || '';
|
||||
|
||||
// CSS inline: padding/margin with px values
|
||||
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);
|
||||
}
|
||||
// CSS gap
|
||||
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
||||
while ((sm = gapRe.exec(html)) !== null) {
|
||||
spacingValues.push(parseInt(sm[1], 10));
|
||||
}
|
||||
// Tailwind spacing classes
|
||||
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);
|
||||
}
|
||||
// rem values (convert at 16px base)
|
||||
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);
|
||||
}
|
||||
|
||||
// Round to nearest 4px to group similar values (e.g., 15px and 16px are effectively the same)
|
||||
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);
|
||||
// Flag if the dominant spacing value is used > 60% of the time with few distinct values
|
||||
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 ---
|
||||
// Check inline styles and Tailwind classes for text-align: center
|
||||
// Also walk up ancestors for inherited centering
|
||||
const textEls = document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, li, div, button');
|
||||
let centeredCount = 0;
|
||||
let totalText = 0;
|
||||
for (const el of textEls) {
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length >= 3);
|
||||
if (!hasDirectText) continue;
|
||||
totalText++;
|
||||
|
||||
// Check element and ancestors for centering
|
||||
let cur = el;
|
||||
let isCentered = false;
|
||||
while (cur && cur.nodeType === 1) {
|
||||
const rawStyle = cur.getAttribute?.('style') || '';
|
||||
const cls = cur.getAttribute?.('class') || '';
|
||||
if (/text-align\s*:\s*center/i.test(rawStyle) || /\btext-center\b/.test(cls)) {
|
||||
isCentered = true;
|
||||
break;
|
||||
}
|
||||
// Stop at body
|
||||
if (cur.tagName === 'BODY') break;
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
if (isCentered) centeredCount++;
|
||||
}
|
||||
|
||||
if (totalText >= 5 && centeredCount / totalText > 0.7) {
|
||||
findings.push({
|
||||
id: 'everything-centered',
|
||||
snippet: `${centeredCount}/${totalText} text elements centered (${Math.round(centeredCount / totalText * 100)}%)`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// jsdom detection (default for HTML files)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -603,11 +813,14 @@ async function detectHtml(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Page-level typography checks (only for full pages, not partials)
|
||||
// Page-level checks (only for full pages, not partials)
|
||||
if (isFullPage(html)) {
|
||||
for (const f of checkPageTypography(document, window)) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of checkPageLayout(document, window)) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
}
|
||||
|
||||
window.close();
|
||||
@@ -784,10 +997,10 @@ const REGEX_MATCHERS = [
|
||||
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat)\b/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
|
||||
// --- Pure black/white ---
|
||||
{ id: 'pure-black-white', regex: /(?:color|background(?:-color)?)\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\)|#ffffff|#fff|rgb\(255,\s*255,\s*255\))\b/gi,
|
||||
// --- Pure black background ---
|
||||
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
|
||||
test: () => true,
|
||||
fmt: (m) => `${m[0]}` },
|
||||
fmt: (m) => m[0] },
|
||||
// --- Gradient text ---
|
||||
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
|
||||
test: (m, line) => /gradient/i.test(line),
|
||||
@@ -796,8 +1009,8 @@ const REGEX_MATCHERS = [
|
||||
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
|
||||
test: (m, line) => /\bbg-gradient-to-/i.test(line),
|
||||
fmt: () => 'bg-clip-text + bg-gradient' },
|
||||
// --- Tailwind pure black/white ---
|
||||
{ id: 'pure-black-white', regex: /\b(bg-black|bg-white|text-black)\b/g,
|
||||
// --- Tailwind pure black background ---
|
||||
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
|
||||
test: () => true,
|
||||
fmt: (m) => m[0] },
|
||||
// --- Tailwind gray on colored bg ---
|
||||
@@ -861,6 +1074,43 @@ const REGEX_ANALYZERS = [
|
||||
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
|
||||
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
|
||||
},
|
||||
// Monotonous spacing (regex)
|
||||
(content, filePath) => {
|
||||
const vals = [];
|
||||
let m;
|
||||
const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
|
||||
while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
|
||||
const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
|
||||
while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
|
||||
const gapRe = /gap\s*:\s*(\d+)px/gi;
|
||||
while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
|
||||
const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
|
||||
while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
|
||||
const rounded = vals.map(v => Math.round(v / 4) * 4);
|
||||
if (rounded.length < 10) return [];
|
||||
const counts = {};
|
||||
for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
|
||||
const maxCount = Math.max(...Object.values(counts));
|
||||
const pct = maxCount / rounded.length;
|
||||
const unique = [...new Set(rounded)].filter(v => v > 0);
|
||||
if (pct <= 0.6 || unique.length > 3) return [];
|
||||
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
|
||||
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
|
||||
},
|
||||
// Everything centered (regex)
|
||||
(content, filePath) => {
|
||||
const lines = content.split('\n');
|
||||
let centered = 0, total = 0;
|
||||
for (const line of lines) {
|
||||
// Check lines that have text content elements
|
||||
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
|
||||
total++;
|
||||
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
|
||||
}
|
||||
}
|
||||
if (total < 5 || centered / total <= 0.7) return [];
|
||||
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -1058,7 +1308,7 @@ if (isMainModule) main();
|
||||
|
||||
export {
|
||||
ANTIPATTERNS, SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS,
|
||||
checkElementBorders, checkPageTypography, isNeutralColor, isFullPage,
|
||||
checkElementBorders, checkPageTypography, checkPageLayout, isNeutralColor, isFullPage,
|
||||
detectHtml, detectUrl, detectText,
|
||||
walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS,
|
||||
};
|
||||
|
||||
@@ -277,6 +277,65 @@ describe('partials skip page-level checks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout anti-patterns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('detectHtml — layout', () => {
|
||||
test('layout-should-flag: detects nested cards', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'layout-should-flag.html'));
|
||||
expect(f.some(r => r.antipattern === 'nested-cards')).toBe(true);
|
||||
});
|
||||
|
||||
test('layout-should-flag: detects identical card grid', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'layout-should-flag.html'));
|
||||
expect(f.some(r => r.antipattern === 'identical-card-grid')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects monotonous spacing via regex', () => {
|
||||
// A page where every padding/margin is 16px
|
||||
const html = '<!DOCTYPE html><html><body>' +
|
||||
'<div style="padding: 16px; margin-bottom: 16px;"><p style="margin-bottom: 16px;">a</p></div>'.repeat(5) +
|
||||
'</body></html>';
|
||||
const f = detectText(html, 'test.html');
|
||||
expect(f.some(r => r.antipattern === 'monotonous-spacing')).toBe(true);
|
||||
});
|
||||
|
||||
test('detects everything centered via regex', () => {
|
||||
const html = `<!DOCTYPE html><html><body>
|
||||
<h1 style="text-align: center;">Title</h1>
|
||||
<p style="text-align: center;">Paragraph one more text here</p>
|
||||
<p style="text-align: center;">Paragraph two more text here</p>
|
||||
<p style="text-align: center;">Paragraph three more text here</p>
|
||||
<p style="text-align: center;">Paragraph four more text here</p>
|
||||
<p style="text-align: center;">Paragraph five more text here</p>
|
||||
<p style="text-align: center;">Paragraph six more text here</p>
|
||||
</body></html>`;
|
||||
const f = detectText(html, 'test.html');
|
||||
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 identical-card-grid false positives', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'layout-should-pass.html'));
|
||||
expect(f.filter(r => r.antipattern === 'identical-card-grid')).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);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ANTIPATTERNS registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Layout Anti-Patterns — Should Flag</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; 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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Layout Anti-Patterns</h1>
|
||||
<p class="intro">These should all be flagged by the detector.</p>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 1. NESTED CARDS — Cardocalypse -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Nested Cards (Cardocalypse)</h2>
|
||||
|
||||
<!-- Classic: card inside card, both with shadow + rounded + bg -->
|
||||
<div class="bg-white rounded-lg shadow-md p-6 max-w-md mb-4">
|
||||
<h3 class="text-lg font-semibold mb-3">Outer Card</h3>
|
||||
<div class="bg-white rounded-lg shadow-md p-4">
|
||||
<h4 class="font-medium">Inner Card</h4>
|
||||
<p class="text-sm text-gray-600">Card inside card — the classic cardocalypse.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Three levels deep -->
|
||||
<div class="bg-white rounded-xl shadow-lg p-6 max-w-md mb-4">
|
||||
<h3 class="text-lg font-semibold mb-3">Level 1</h3>
|
||||
<div class="bg-gray-50 rounded-lg shadow-sm p-4 mb-3">
|
||||
<h4 class="font-medium mb-2">Level 2</h4>
|
||||
<div class="bg-white rounded-md shadow-sm p-3">
|
||||
<p class="text-sm">Level 3 — nesting inception.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CSS variant: border + bg + shadow nesting -->
|
||||
<div style="background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); padding: 1.5rem; max-width: 28rem; margin-bottom: 1rem;">
|
||||
<h3 style="font-weight: 600; margin-bottom: 0.75rem;">Outer (CSS)</h3>
|
||||
<div style="background: #f9fafb; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); padding: 1rem;">
|
||||
<p style="font-size: 0.875rem; color: #6b7280;">Inner card via CSS.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- shadcn-style Card nesting (Card inside Card, not Card sub-components) -->
|
||||
<div class="rounded-lg border bg-white shadow-sm p-6 max-w-md mb-4" data-component="card">
|
||||
<h3 class="font-semibold mb-3">shadcn-style Outer Card</h3>
|
||||
<div class="rounded-lg border bg-white shadow-sm p-4" data-component="card">
|
||||
<p class="text-sm text-gray-600">Another shadcn Card nested inside — still bad.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 2. IDENTICAL CARD GRIDS -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Identical Card Grid</h2>
|
||||
|
||||
<!-- Classic: 4 identical cards with icon + heading + text -->
|
||||
<div class="grid grid-cols-2 gap-4 max-w-2xl mb-4">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-2">Fast Performance</h3>
|
||||
<p class="text-sm text-gray-600">Lightning fast response times with optimized infrastructure.</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-2">Reliable Uptime</h3>
|
||||
<p class="text-sm text-gray-600">99.99% uptime guarantee with automatic failover.</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-2">Bank-Level Security</h3>
|
||||
<p class="text-sm text-gray-600">Enterprise-grade encryption and security controls.</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<div class="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
</div>
|
||||
<h3 class="font-semibold mb-2">Team Collaboration</h3>
|
||||
<p class="text-sm text-gray-600">Built for teams with real-time collaboration tools.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 3. MONOTONOUS SPACING -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Monotonous Spacing</h2>
|
||||
|
||||
<!-- Everything padded with the same value, same gaps -->
|
||||
<div style="max-width: 28rem; margin-bottom: 1rem;">
|
||||
<div style="background: white; padding: 16px; margin-bottom: 16px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h3 style="margin-bottom: 16px; font-weight: 600;">Section One</h3>
|
||||
<p style="margin-bottom: 16px; font-size: 0.875rem; color: #6b7280;">Every margin and padding is exactly 16px.</p>
|
||||
<div style="padding: 16px; background: #f3f4f6; border-radius: 8px;">
|
||||
<p style="font-size: 0.875rem; color: #6b7280; margin-bottom: 16px;">Even the inner padding is 16px.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: white; padding: 16px; margin-bottom: 16px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h3 style="margin-bottom: 16px; font-weight: 600;">Section Two</h3>
|
||||
<p style="margin-bottom: 16px; font-size: 0.875rem; color: #6b7280;">No rhythm, no variation, just 16px everywhere.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- 4. EVERYTHING CENTERED -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Everything Centered</h2>
|
||||
|
||||
<div style="text-align: center; max-width: 32rem; margin: 0 auto 1rem;">
|
||||
<h3 style="text-align: center; font-size: 1.25rem; font-weight: 700; margin-bottom: 1rem;">Welcome to Our Platform</h3>
|
||||
<p style="text-align: center; color: #6b7280; margin-bottom: 1rem;">We provide the best solutions for your business needs.</p>
|
||||
<div style="text-align: center; display: flex; gap: 1rem; justify-content: center; margin-bottom: 1rem;">
|
||||
<button style="text-align: center; padding: 0.5rem 1rem; background: #3b82f6; color: white; border: none; border-radius: 6px;">Get Started</button>
|
||||
<button style="text-align: center; padding: 0.5rem 1rem; background: white; color: #374151; border: 1px solid #d1d5db; border-radius: 6px;">Learn More</button>
|
||||
</div>
|
||||
<p style="text-align: center; font-size: 0.75rem; color: #9ca3af;">Trusted by over 10,000 companies worldwide.</p>
|
||||
<div style="text-align: center; padding: 1rem; background: #f3f4f6; border-radius: 8px; margin-top: 1rem;">
|
||||
<p style="text-align: center; font-size: 0.875rem; color: #6b7280;">Every. Single. Element. Is. Centered.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Layout — Clean Patterns (Should NOT Flag)</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; color: #111827; }
|
||||
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||
h2 { font-size: 1.25rem; margin: 2.5rem 0 0.75rem; color: #6b7280; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }
|
||||
p.intro { color: #6b7280; margin-bottom: 2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Layout — Should Pass</h1>
|
||||
<p class="intro">All patterns here are legitimate. None should be flagged.</p>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- shadcn-style Card sub-components (NOT nested cards) -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>shadcn Card Sub-Components</h2>
|
||||
|
||||
<!-- Card with CardHeader / CardContent / CardFooter structure -->
|
||||
<div class="rounded-lg border bg-white shadow-sm max-w-md mb-4">
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<h3 class="text-lg font-semibold">Card Title</h3>
|
||||
<p class="text-sm text-gray-500">Card description goes here.</p>
|
||||
</div>
|
||||
<div class="p-6 pt-0">
|
||||
<p class="text-sm text-gray-600">This is CardContent — a sub-section, not a nested card. No shadow, no border-radius of its own.</p>
|
||||
</div>
|
||||
<div class="flex items-center p-6 pt-0">
|
||||
<button class="px-4 py-2 bg-gray-900 text-white text-sm rounded-md">Action</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Card with form inputs (inputs have shadow/rounded) -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Card with Form Inputs</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 max-w-md mb-4 border">
|
||||
<h3 class="font-semibold mb-4">Settings</h3>
|
||||
<div class="space-y-3">
|
||||
<input type="text" placeholder="Name" class="w-full px-3 py-2 border rounded-md shadow-sm text-sm focus:outline-none focus:ring-2">
|
||||
<input type="email" placeholder="Email" class="w-full px-3 py-2 border rounded-md shadow-sm text-sm focus:outline-none focus:ring-2">
|
||||
<select class="w-full px-3 py-2 border rounded-md shadow-sm text-sm bg-white">
|
||||
<option>Select option</option>
|
||||
</select>
|
||||
<textarea placeholder="Bio" class="w-full px-3 py-2 border rounded-md shadow-sm text-sm h-20 resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Card with dropdown/popover (has shadow + rounded) -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Card with Dropdown</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 max-w-md mb-4 border relative">
|
||||
<h3 class="font-semibold mb-2">Select Plan</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">Choose your subscription tier.</p>
|
||||
<!-- Simulated dropdown menu -->
|
||||
<div class="absolute top-full left-6 mt-1 w-48 bg-white rounded-md shadow-lg border py-1 z-10">
|
||||
<div class="px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer">Free</div>
|
||||
<div class="px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer">Pro</div>
|
||||
<div class="px-3 py-2 text-sm hover:bg-gray-50 cursor-pointer">Enterprise</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 80px;"></div> <!-- spacer for dropdown -->
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Card with code block inside -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Card with Code Block</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 max-w-md mb-4 border">
|
||||
<h3 class="font-semibold mb-3">Installation</h3>
|
||||
<pre class="bg-gray-900 text-gray-100 rounded-md p-4 text-sm overflow-x-auto"><code>npm install @acme/sdk</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Card with badge/chip inside -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Card with Badges</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 max-w-md mb-4 border">
|
||||
<h3 class="font-semibold mb-3">Tags</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-2 py-1 bg-blue-100 text-blue-700 rounded-full text-xs font-medium">React</span>
|
||||
<span class="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">TypeScript</span>
|
||||
<span class="px-2 py-1 bg-purple-100 text-purple-700 rounded-full text-xs font-medium">Tailwind</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Card with accordion/collapsible inside -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Card with Accordion</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm max-w-md mb-4 border">
|
||||
<div class="p-4 border-b cursor-pointer flex justify-between items-center">
|
||||
<span class="font-medium text-sm">How does billing work?</span>
|
||||
<span class="text-gray-400">+</span>
|
||||
</div>
|
||||
<div class="p-4 border-b cursor-pointer flex justify-between items-center">
|
||||
<span class="font-medium text-sm">Can I cancel anytime?</span>
|
||||
<span class="text-gray-400">+</span>
|
||||
</div>
|
||||
<div class="p-4 cursor-pointer flex justify-between items-center">
|
||||
<span class="font-medium text-sm">Do you offer refunds?</span>
|
||||
<span class="text-gray-400">+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Card with image (rounded + shadow on image) -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Card with Styled Image</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 max-w-md mb-4 border">
|
||||
<div class="w-full h-40 bg-gradient-to-br from-blue-400 to-purple-500 rounded-lg shadow-inner mb-4"></div>
|
||||
<h3 class="font-semibold">Project Preview</h3>
|
||||
<p class="text-sm text-gray-600 mt-1">Image placeholder with rounded corners and shadow.</p>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Card with tab panels inside -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Card with Tabs</h2>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm max-w-md mb-4 border">
|
||||
<div class="flex border-b">
|
||||
<button class="px-4 py-3 text-sm font-medium text-blue-600 border-b-2 border-blue-600">Overview</button>
|
||||
<button class="px-4 py-3 text-sm text-gray-500">Analytics</button>
|
||||
<button class="px-4 py-3 text-sm text-gray-500">Settings</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-gray-600">Tab content area — structured content inside a card, not a nested card.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Pricing cards (similar but intentionally comparative) -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Pricing Cards (Intentionally Similar)</h2>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 max-w-3xl mb-4">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 border">
|
||||
<h3 class="font-semibold mb-1">Free</h3>
|
||||
<div class="text-2xl font-bold mb-4">$0</div>
|
||||
<ul class="space-y-2 text-sm text-gray-600">
|
||||
<li>5 projects</li>
|
||||
<li>1 GB storage</li>
|
||||
<li>Community support</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 border-2 border-blue-500">
|
||||
<h3 class="font-semibold mb-1">Pro</h3>
|
||||
<div class="text-2xl font-bold mb-4">$29</div>
|
||||
<ul class="space-y-2 text-sm text-gray-600">
|
||||
<li>Unlimited projects</li>
|
||||
<li>50 GB storage</li>
|
||||
<li>Priority support</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 border">
|
||||
<h3 class="font-semibold mb-1">Enterprise</h3>
|
||||
<div class="text-2xl font-bold mb-4">Custom</div>
|
||||
<ul class="space-y-2 text-sm text-gray-600">
|
||||
<li>Everything in Pro</li>
|
||||
<li>Unlimited storage</li>
|
||||
<li>Dedicated support</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Good spacing variety -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Varied Spacing (Good Rhythm)</h2>
|
||||
|
||||
<div style="max-width: 28rem; margin-bottom: 1rem;">
|
||||
<div style="background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 2rem;">
|
||||
<h3 style="font-weight: 600; margin-bottom: 0.5rem; font-size: 1.25rem;">Heading with tight spacing</h3>
|
||||
<p style="font-size: 0.875rem; color: #6b7280; margin-bottom: 1.5rem;">Body text with medium spacing below.</p>
|
||||
<div style="padding: 1rem; background: #f3f4f6; border-radius: 6px;">
|
||||
<p style="font-size: 0.8125rem; color: #6b7280;">Nested content with different padding.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
||||
<h3 style="font-weight: 600; margin-bottom: 0.75rem;">Another section</h3>
|
||||
<p style="font-size: 0.875rem; color: #6b7280;">Different padding than above — intentional rhythm.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Centered hero section (legitimately centered) -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Centered Hero (Legitimate)</h2>
|
||||
|
||||
<div style="text-align: center; max-width: 32rem; margin: 0 auto 1rem; padding: 2rem 0;">
|
||||
<h3 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 0.5rem;">Hero Heading</h3>
|
||||
<p style="color: #6b7280; margin-bottom: 1.5rem;">A centered hero section is fine — it's the rest of the page that shouldn't all be centered too.</p>
|
||||
<button style="padding: 0.75rem 1.5rem; background: #111827; color: white; border: none; border-radius: 6px; font-weight: 500;">Get Started</button>
|
||||
</div>
|
||||
|
||||
<!-- Left-aligned content following the hero -->
|
||||
<div style="max-width: 32rem; margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem;">Features</h3>
|
||||
<p style="color: #6b7280; margin-bottom: 1rem;">This content is left-aligned, creating contrast with the centered hero above.</p>
|
||||
<ul style="color: #4b5563; font-size: 0.875rem; padding-left: 1.25rem; list-style: disc;">
|
||||
<li style="margin-bottom: 0.5rem;">Left-aligned list items</li>
|
||||
<li style="margin-bottom: 0.5rem;">Natural reading direction</li>
|
||||
<li>Intentional layout variety</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Non-card grid (varied structure) -->
|
||||
<!-- ============================================================ -->
|
||||
<h2>Grid with Varied Card Structures</h2>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 max-w-2xl mb-4">
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 border col-span-2">
|
||||
<h3 class="font-semibold text-lg mb-2">Featured Item</h3>
|
||||
<p class="text-sm text-gray-600">This card spans the full width — different from the others.</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 border">
|
||||
<h3 class="font-semibold text-sm">Compact Card</h3>
|
||||
<p class="text-xs text-gray-500 mt-1">Smaller, less padding.</p>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||
<h3 class="font-semibold text-sm text-blue-900">Highlighted</h3>
|
||||
<p class="text-xs text-blue-700 mt-1">Different bg, no shadow — visual variety.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/detect-antipatterns-browser.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user