Add anti-pattern detection CLI, browser visualizer, gallery page, and build DRY refactor

- Anti-pattern detector script (source/skills/critique/scripts/detect-antipatterns.mjs):
  CLI tool that scans files/dirs for UI anti-patterns via regex. Detects side-tab
  accent borders and border-accent-on-rounded patterns across Tailwind, CSS, JSX.
  Context-aware: skips safe elements (blockquotes, nav, inputs, code), neutral
  colors, and adjusts thresholds based on border-radius co-occurrence.

- Browser visualizer (public/js/detect-antipatterns-browser.js):
  Drop-in script that highlights anti-patterns directly in the browser with
  labeled overlays. Two modes: "static" (regex, matches CLI) and "computed"
  (getComputedStyle, catches CSS cascade). Scans both inline styles and
  <style> blocks.

- Gallery of Shame (public/gallery.html):
  Standalone page showcasing 11 AI anti-pattern examples with thumbnails
  and links. Anti-pattern example pages updated from 1080x1080 Twitter
  format to responsive layouts, labels removed, screenshots retaken at 16:10.

- Critique skill updated to run detector before manual review.

- Build system: skills now support scripts/ directories alongside reference/.
  All 8 provider transformers refactored to use shared.js (DRY).

- 58 new tests covering detection logic, fixtures, CLI integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-03-17 10:54:06 -07:00
co-authored by Claude Opus 4.6
parent 2bf7fce2d6
commit f9bfe18d26
46 changed files with 2600 additions and 4658 deletions
+12
View File
@@ -14,6 +14,18 @@ Use the frontend-design skill — it contains design principles, anti-patterns,
---
## AUTOMATED ANTI-PATTERN SCAN
Before the manual critique, run the deterministic anti-pattern detector bundled with this skill (`scripts/detect-antipatterns.mjs`):
```bash
node scripts/detect-antipatterns.mjs [target-area]
```
Include the results in your Anti-Patterns Verdict. If the script finds issues, they MUST appear in the Priority Issues list.
---
Conduct a holistic design critique, evaluating whether the interface actually works—not just technically, but as a designed experience. Think like a design director giving feedback.
## Design Critique
@@ -0,0 +1,377 @@
#!/usr/bin/env node
/**
* Anti-Pattern Detector for Impeccable
*
* Scans files/directories for known UI anti-patterns (starting with "side-tab").
* Used by the critique skill and as a future hook.
*
* Usage:
* node detect-antipatterns.mjs [file-or-dir...] # scan files/dirs
* node detect-antipatterns.mjs # scan cwd
* node detect-antipatterns.mjs --json # JSON output
* echo '{"tool_input":{"file_path":"f.html"}}' | node detect-antipatterns.mjs # stdin
*
* Exit codes: 0 = clean, 2 = findings
*/
import fs from 'fs';
import path from 'path';
// ---------------------------------------------------------------------------
// Line-level context helpers
// ---------------------------------------------------------------------------
/** Check if Tailwind `rounded-*` appears on the same line */
const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
/** Check if CSS `border-radius` appears on the same line (inline styles) */
const hasBorderRadius = (line) => /border-radius/i.test(line);
/** Check if line contains an HTML element that legitimately uses side borders */
const SAFE_ELEMENTS = /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i;
const isSafeElement = (line) => SAFE_ELEMENTS.test(line);
/** Check if the border color in a CSS declaration looks neutral (gray/structural) */
function isNeutralBorderColor(matchStr) {
const colorMatch = matchStr.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!colorMatch) return false;
const c = colorMatch[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
// ---------------------------------------------------------------------------
// Anti-pattern definitions
// ---------------------------------------------------------------------------
const ANTIPATTERNS = [
{
id: 'side-tab',
name: 'Side-tab accent border',
description:
'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.',
matchers: [
// Tailwind: border-[lrse]-N — threshold depends on context
// With rounded: any N >= 1 (even thin borders look wrong on rounded cards)
// Without rounded: N >= 4 (thick enough to always be suspicious)
{
regex: /\bborder-[lrse]-(\d+)\b/g,
test: (match, line) => {
const n = parseInt(match[1], 10);
if (hasRounded(line)) return n >= 1;
return n >= 4;
},
format: (match) => match[0],
},
// CSS shorthand: border-left/right: Npx solid [color]
{
regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (match, line) => {
if (isSafeElement(line)) return false;
if (isNeutralBorderColor(match[0])) return false;
const n = parseInt(match[1], 10);
if (hasBorderRadius(line)) return n >= 1;
return n >= 3;
},
format: (match) => match[0].replace(/\s*;?\s*$/, ''),
},
// CSS longhand: border-left/right-width: Npx
{
regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (match, line) => {
if (isSafeElement(line)) return false;
const n = parseInt(match[1], 10);
return n >= 3;
},
format: (match) => match[0],
},
// CSS logical: border-inline-start/end: Npx solid
{
regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
test: (match, line) => {
if (isSafeElement(line)) return false;
const n = parseInt(match[1], 10);
return n >= 3;
},
format: (match) => match[0],
},
// CSS logical longhand: border-inline-start/end-width: Npx
{
regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
test: (match, line) => {
if (isSafeElement(line)) return false;
const n = parseInt(match[1], 10);
return n >= 3;
},
format: (match) => match[0],
},
// JSX inline: borderLeft/borderRight with thickness
{
regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
test: (match) => parseInt(match[1], 10) >= 3,
format: (match) => match[0],
},
],
},
{
id: 'border-accent-on-rounded',
name: 'Border accent on rounded element',
description:
'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
matchers: [
// Tailwind: border-[tb]-N + rounded-* on same line
{
regex: /\bborder-[tb]-(\d+)\b/g,
test: (match, line) => {
const n = parseInt(match[1], 10);
return hasRounded(line) && n >= 1;
},
format: (match) => match[0],
},
// CSS: border-top/bottom with border-radius on same line (inline styles)
{
regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
test: (match, line) => {
const n = parseInt(match[1], 10);
return n >= 3 && hasBorderRadius(line);
},
format: (match) => match[0],
},
],
},
];
// ---------------------------------------------------------------------------
// Detection engine
// ---------------------------------------------------------------------------
/**
* Scan content for anti-patterns.
* @param {string} content File content
* @param {string} filePath File path (for reporting)
* @returns {Array<{antipattern: string, name: string, description: string, file: string, line: number, snippet: string}>}
*/
function detectAntiPatterns(content, filePath) {
const findings = [];
const lines = content.split('\n');
for (const ap of ANTIPATTERNS) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const matcher of ap.matchers) {
// Reset regex state for each line
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
if (matcher.test(m, line)) {
findings.push({
antipattern: ap.id,
name: ap.name,
description: ap.description,
file: filePath,
line: i + 1,
snippet: matcher.format(m),
});
}
}
}
}
}
return findings;
}
// ---------------------------------------------------------------------------
// File walker
// ---------------------------------------------------------------------------
const SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
'.svelte-kit', '__pycache__', '.turbo', '.vercel',
]);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
]);
function walkDir(dir) {
const files = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return files;
}
for (const entry of entries) {
if (entry.name.startsWith('.') && SKIP_DIRS.has(entry.name)) continue;
if (SKIP_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walkDir(full));
} else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
files.push(full);
}
}
return files;
}
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
function formatFindings(findings, jsonMode) {
if (jsonMode) {
return JSON.stringify(findings, null, 2);
}
const grouped = {};
for (const f of findings) {
if (!grouped[f.file]) grouped[f.file] = [];
grouped[f.file].push(f);
}
const lines = [];
for (const [file, items] of Object.entries(grouped)) {
lines.push(`\n${file}`);
for (const item of items) {
lines.push(` line ${item.line}: [${item.antipattern}] ${item.snippet}`);
lines.push(`${item.description}`);
}
}
const count = findings.length;
lines.push(`\n${count} anti-pattern${count === 1 ? '' : 's'} found.`);
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// Stdin handling (for future hook use)
// ---------------------------------------------------------------------------
async function readStdin() {
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf-8');
}
async function handleStdin() {
const input = await readStdin();
let parsed;
try {
parsed = JSON.parse(input);
} catch {
// Not JSON — treat as raw content
return detectAntiPatterns(input, '<stdin>');
}
// Hook format: { tool_input: { file_path: "..." } }
const filePath = parsed?.tool_input?.file_path;
if (filePath && fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
return detectAntiPatterns(content, filePath);
}
// Fallback: scan the raw JSON as content
return detectAntiPatterns(input, '<stdin>');
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function printUsage() {
console.log(`Usage: node detect-antipatterns.mjs [options] [file-or-dir...]
Scan files for known UI anti-patterns.
Options:
--json Output results as JSON
--help Show this help message
Examples:
node detect-antipatterns.mjs src/
node detect-antipatterns.mjs index.html styles.css
node detect-antipatterns.mjs --json .
echo '{"tool_input":{"file_path":"f.html"}}' | node detect-antipatterns.mjs`);
}
async function main() {
const args = process.argv.slice(2);
const jsonMode = args.includes('--json');
const helpMode = args.includes('--help');
const targets = args.filter((a) => a !== '--json' && a !== '--help');
if (helpMode) {
printUsage();
process.exit(0);
}
let allFindings = [];
// Check if stdin is piped
if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin();
} else {
// Default to cwd if no targets
const paths = targets.length > 0 ? targets : [process.cwd()];
for (const target of paths) {
const resolved = path.resolve(target);
let stat;
try {
stat = fs.statSync(resolved);
} catch {
process.stderr.write(`Warning: cannot access ${target}\n`);
continue;
}
if (stat.isDirectory()) {
for (const file of walkDir(resolved)) {
const content = fs.readFileSync(file, 'utf-8');
allFindings.push(...detectAntiPatterns(content, file));
}
} else if (stat.isFile()) {
const content = fs.readFileSync(resolved, 'utf-8');
allFindings.push(...detectAntiPatterns(content, resolved));
}
}
}
if (allFindings.length > 0) {
const output = formatFindings(allFindings, jsonMode);
process.stderr.write(output + '\n');
process.exit(2);
}
if (jsonMode) {
process.stdout.write('[]\n');
}
process.exit(0);
}
// Run CLI when executed directly; export for testing when imported
const isMainModule = process.argv[1] && (
process.argv[1].endsWith('detect-antipatterns.mjs') ||
process.argv[1].endsWith('detect-antipatterns.mjs/')
);
if (isMainModule) {
main();
}
export { ANTIPATTERNS, detectAntiPatterns, walkDir, formatFindings, SCANNABLE_EXTENSIONS, SKIP_DIRS };