Fix nested card detection, drop identical-card-grid

- Nested cards: fixed dedup to use WeakSet on actual elements instead
  of tag-name key, so all nested card instances are found (not just
  the first div-in-div pair). Now catches all 4+ nesting examples.
- Dropped identical-card-grid: too many legitimate uses (data displays,
  pricing cards, navigation tiles) make false positives unavoidable.
- Removed from CLI, browser script, tests, and ANTIPATTERNS registry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-17 15:54:00 -07:00
co-authored by Claude Opus 4.6
parent 94a20d811b
commit f90e640892
4 changed files with 12 additions and 129 deletions
@@ -119,12 +119,6 @@ const ANTIPATTERNS = [
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',
@@ -602,71 +596,32 @@ function checkPageLayout(document, window) {
// --- Nested cards ---
const allEls = document.querySelectorAll('*');
const flaggedNested = new Set();
const flaggedEls = new WeakSet();
for (const el of allEls) {
if (!isCardLike(el, window)) continue;
if (flaggedEls.has(el)) 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()})` });
}
flaggedEls.add(el);
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
-19
View File
@@ -319,24 +319,6 @@
}
}
// --- Identical card grid ---
for (const grid of document.querySelectorAll('[class*="grid"], [style*="display: grid"], [style*="display: flex"]')) {
const children = [...grid.children].filter(c => !['script','style'].includes(c.tagName.toLowerCase()));
if (children.length < 3) continue;
function fingerprint(el) {
const tags = [...el.children].map(c => c.tagName.toLowerCase());
const hasIcon = tags.includes('svg') || tags.includes('img') ||
[...el.children].some(c => /\bw-\d+\b.*\bh-\d+\b/.test(c.getAttribute('class') || '') && /\brounded/.test(c.getAttribute('class') || ''));
return `icon:${hasIcon}|h:${tags.some(t => /^h[1-6]$/.test(t))}|p:${tags.includes('p')}|n:${tags.length}`;
}
const fps = children.map(fingerprint);
if (fps.every(f => f === fps[0]) && fps[0].includes('icon:true') && fps[0].includes('h:true') && fps[0].includes('p:true')) {
findings.push({ type: 'identical-card-grid', detail: `${children.length} identical cards`, el: grid });
}
}
return findings;
}
@@ -357,7 +339,6 @@
'gradient-text': 'gradient text',
'ai-color-palette': 'AI palette',
'nested-cards': 'nested cards',
'identical-card-grid': 'identical grid',
};
function highlight(el, findings) {
@@ -119,12 +119,6 @@ const ANTIPATTERNS = [
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',
@@ -602,71 +596,32 @@ function checkPageLayout(document, window) {
// --- Nested cards ---
const allEls = document.querySelectorAll('*');
const flaggedNested = new Set();
const flaggedEls = new WeakSet();
for (const el of allEls) {
if (!isCardLike(el, window)) continue;
if (flaggedEls.has(el)) 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()})` });
}
flaggedEls.add(el);
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
+4 -12
View File
@@ -282,14 +282,11 @@ describe('partials skip page-level checks', () => {
// ---------------------------------------------------------------------------
describe('detectHtml — layout', () => {
test('layout-should-flag: detects nested cards', async () => {
test('layout-should-flag: detects all 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);
const nested = f.filter(r => r.antipattern === 'nested-cards');
// Classic, 3-level (2 inner cards), CSS, shadcn = at least 5 nested card findings
expect(nested.length).toBeGreaterThanOrEqual(4);
});
test('detects monotonous spacing via regex', () => {
@@ -320,11 +317,6 @@ describe('detectHtml — layout', () => {
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);