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 };
+4 -19
View File
@@ -20,8 +20,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: linear-gradient(180deg, #3b82f6 0%, #8b5cf6 100%);
border-radius: 24px;
@@ -150,22 +150,7 @@
border: none;
}
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 10;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -216,7 +201,7 @@
<button class="footer-btn">Get Started</button>
</div>
<div class="antipattern-label">🚨 AI SLOP: Gray on Color, Gray Labels, Absolute Black/White</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+4 -19
View File
@@ -20,8 +20,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: #e5e7eb;
border-radius: 24px;
@@ -283,22 +283,7 @@
.depth-4 { background: #f9fafb; border: 1px solid #e5e7eb; }
.depth-5 { background: #ffffff; border: 1px solid #e5e7eb; }
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 10;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -384,7 +369,7 @@
</div>
</div>
<div class="antipattern-label">🚨 AI SLOP: Cardocalypse (Cards Within Cards)</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
@@ -20,8 +20,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: #ffffff;
border-radius: 24px;
@@ -251,22 +251,7 @@
margin-top: 6px;
}
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 10;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -326,7 +311,7 @@
</div>
</div>
<div class="antipattern-label">🚨 AI SLOP: Inter Font Everywhere</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
@@ -20,8 +20,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: #ffffff;
border-radius: 24px;
@@ -213,22 +213,7 @@
text-transform: uppercase;
}
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 10;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -331,7 +316,7 @@
</div>
</div>
<div class="antipattern-label">🚨 AI SLOP: Hero Metric Layout Template (×5)</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+4 -19
View File
@@ -20,8 +20,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: linear-gradient(135deg, #0f0f23 0%, #0a0a1a 100%);
border-radius: 24px;
@@ -261,22 +261,7 @@
color: #ff00ff;
}
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 10;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -329,7 +314,7 @@
</div>
</div>
<div class="antipattern-label">🚨 AI SLOP: Lazy "Cool" (Glass, Glow, Neon, Mono)</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+4 -19
View File
@@ -20,8 +20,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 50%, #0f0f23 100%);
border-radius: 24px;
@@ -281,22 +281,7 @@
line-height: 1.4;
}
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 10;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -387,7 +372,7 @@
</div>
</div>
<div class="antipattern-label">🚨 AI SLOP: Lazy "Impact" (Gradients, Sparklines, Elastic)</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+4 -19
View File
@@ -21,8 +21,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: #ffffff;
border-radius: 24px;
@@ -129,22 +129,7 @@
text-align: left;
}
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 10;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -204,11 +189,11 @@
</div>
</div>
<div class="antipattern-label">🚨 AI SLOP: Massive Rounded Lucide Icons in Cards</div>
</div>
<script>
lucide.createIcons();
</script>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+4 -19
View File
@@ -20,8 +20,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: #f8fafc;
border-radius: 24px;
@@ -283,22 +283,7 @@
color: white;
}
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 200;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -445,7 +430,7 @@
</div>
</div>
<div class="antipattern-label">🚨 AI SLOP: Advanced Features Hidden in Modal</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
@@ -21,8 +21,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: linear-gradient(145deg, #2a1a4a 0%, #1a0a2e 100%);
border-radius: 32px;
@@ -189,22 +189,7 @@
margin-top: 4px;
}
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 10;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -250,7 +235,7 @@
</div>
</div>
<div class="antipattern-label">🚨 AI SLOP: Purple Gradients Everywhere</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
@@ -20,8 +20,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: #ffffff;
border-radius: 24px;
@@ -148,22 +148,7 @@
gap: 16px;
}
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 10;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -216,7 +201,7 @@
</div>
</div>
<div class="antipattern-label">🚨 AI SLOP: Extreme UX Writing Redundancy</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
@@ -20,8 +20,8 @@
}
.container {
width: 1080px;
height: 1080px;
max-width: 960px;
width: 100%;
margin: 0 auto;
background: #ffffff;
border-radius: 24px;
@@ -140,22 +140,7 @@
font-weight: 500;
}
/* Anti-pattern label */
.antipattern-label {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.9);
color: #f87171;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
z-index: 10;
border: 1px solid rgba(248, 113, 113, 0.3);
}
</style>
</head>
<body>
@@ -214,7 +199,7 @@
</div>
</div>
<div class="antipattern-label">🚨 AI SLOP: Left Border Accent on Rounded Cards</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 170 KiB

+1 -3933
View File
File diff suppressed because one or more lines are too long
+316
View File
@@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gallery of Shame — AI Anti-Patterns in the Wild | Impeccable</title>
<meta name="description" content="A curated collection of the most common AI-generated UI anti-patterns. Learn to spot them, laugh at them, then fix them.">
<link rel="icon" type="image/svg+xml" href="./favicon.svg">
<link rel="canonical" href="https://impeccable.style/gallery">
<meta property="og:title" content="Gallery of Shame — AI Anti-Patterns | Impeccable">
<meta property="og:description" content="A curated collection of the most common AI-generated UI anti-patterns. Learn to spot them, laugh at them, then fix them.">
<meta property="og:url" content="https://impeccable.style/gallery">
<meta property="og:image" content="https://impeccable.style/og-image.jpg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400;1,600&family=Instrument+Sans:wght@400;500;600&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--font-display: 'Cormorant Garamond', Georgia, serif;
--font-body: 'Instrument Sans', system-ui, sans-serif;
--font-mono: 'Space Grotesk', monospace;
--color-ink: oklch(10% 0 0);
--color-paper: oklch(98% 0 0);
--color-cream: oklch(96% 0.005 350);
--color-charcoal: oklch(25% 0 0);
--color-ash: oklch(55% 0 0);
--color-mist: oklch(92% 0 0);
--color-bg: oklch(96% 0.005 350);
--color-accent: oklch(60% 0.25 350);
--color-accent-hover: oklch(52% 0.25 350);
--color-shame-red: oklch(55% 0.2 25);
--spacing-xs: 8px;
--spacing-sm: 16px;
--spacing-md: 24px;
--spacing-lg: 32px;
--spacing-xl: 48px;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
}
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
body {
font-family: var(--font-body);
background: var(--color-paper);
color: var(--color-ink);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 3px; }
a:hover { color: var(--color-accent-hover); }
/* Header */
.site-header {
padding: var(--spacing-sm) var(--spacing-lg);
border-bottom: 1px solid var(--color-mist);
display: flex;
align-items: center;
justify-content: space-between;
}
.site-header a { text-decoration: none; }
.header-brand {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 600;
font-style: italic;
color: var(--color-ink);
}
.header-nav { display: flex; gap: var(--spacing-md); font-size: 0.875rem; }
.header-nav a { color: var(--color-ash); }
.header-nav a:hover { color: var(--color-ink); }
/* Hero */
.hero {
max-width: 720px;
margin: 0 auto;
padding: var(--spacing-xl) var(--spacing-lg);
text-align: center;
}
.hero-eyebrow {
font-family: var(--font-mono);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--color-shame-red);
margin-bottom: var(--spacing-sm);
}
.hero h1 {
font-family: var(--font-display);
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 400;
font-style: italic;
line-height: 1.1;
color: var(--color-ink);
margin-bottom: var(--spacing-md);
}
.hero p {
font-size: 1.125rem;
color: var(--color-charcoal);
max-width: 52ch;
margin: 0 auto;
}
/* Grid */
.gallery-grid {
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-lg) var(--spacing-xl);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: var(--spacing-lg);
}
/* Card */
.card {
border: 1px solid var(--color-mist);
border-radius: 6px;
overflow: hidden;
background: white;
cursor: pointer;
transition: box-shadow 0.3s var(--ease-out), border-color 0.3s var(--ease-out);
}
.card:hover {
box-shadow: 0 12px 40px -8px rgba(0,0,0,0.12);
border-color: var(--color-ash);
}
.card-thumb {
aspect-ratio: 16 / 10;
overflow: hidden;
position: relative;
border-bottom: 1px solid var(--color-mist);
background: var(--color-cream);
}
.card-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-thumb-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.25s var(--ease-out);
}
.card:hover .card-thumb-overlay { opacity: 1; }
.card-thumb-overlay span {
color: white;
font-family: var(--font-mono);
font-size: 0.8125rem;
padding: 8px 16px;
border: 1px solid rgba(255,255,255,0.4);
border-radius: 4px;
letter-spacing: 0.05em;
}
.card-body { padding: var(--spacing-md); }
.card-title {
font-family: var(--font-display);
font-size: 1.5rem;
font-weight: 600;
font-style: italic;
color: var(--color-ink);
margin-bottom: var(--spacing-xs);
line-height: 1.2;
}
.card-desc {
font-size: 0.875rem;
color: var(--color-ash);
line-height: 1.5;
}
.card-link {
text-decoration: none;
color: inherit;
display: block;
}
/* Footer */
.site-footer {
margin-top: var(--spacing-xl);
padding: var(--spacing-lg);
border-top: 1px solid var(--color-mist);
text-align: center;
font-size: 0.875rem;
color: var(--color-ash);
}
.site-footer a { color: var(--color-ash); }
.site-footer a:hover { color: var(--color-ink); }
.footer-links { display: flex; gap: var(--spacing-md); justify-content: center; flex-wrap: wrap; }
@media (max-width: 600px) {
.hero { padding: var(--spacing-lg) var(--spacing-sm); }
.gallery-grid {
grid-template-columns: 1fr;
padding: 0 var(--spacing-sm) var(--spacing-lg);
}
.site-header { padding: var(--spacing-sm); }
.header-nav { gap: var(--spacing-sm); }
}
</style>
</head>
<body>
<header class="site-header">
<a href="/" class="header-brand">Impeccable</a>
<nav class="header-nav">
<a href="/#antidote">Anti-Patterns</a>
<a href="/cheatsheet">Cheatsheet</a>
<a href="https://github.com/pbakaus/impeccable">GitHub</a>
</nav>
</header>
<section class="hero">
<p class="hero-eyebrow">Gallery of Shame</p>
<h1>The Tell-Tale Signs of AI-Generated UI</h1>
</section>
<div class="gallery-grid" id="gallery"></div>
<footer class="site-footer">
<div class="footer-links">
<a href="/">Home</a>
<a href="/#antidote">Anti-Patterns</a>
<a href="/cheatsheet">Cheatsheet</a>
<a href="https://github.com/pbakaus/impeccable">GitHub</a>
</div>
</footer>
<script>
const items = [
{
id: 'purple-gradients',
title: 'Purple Gradients Everywhere',
desc: 'The AI color palette: purple-to-blue gradients on everything. Buttons, text, backgrounds, orbs. It\'s the new "make it pop."',
},
{
id: 'lazy-cool',
title: 'Lazy "Cool"',
desc: 'Glassmorphism, neon glows, blurred orbs, monospace everything. Looks like a hackathon project, not a product.',
},
{
id: 'lazy-impact',
title: 'Lazy "Impact"',
desc: 'When in doubt, animate everything. Bouncing buttons, wiggling icons, gradient text, floating badges. Motion without meaning.',
},
{
id: 'thick-border-cards',
title: 'Side-Tab Cards',
desc: 'A thick colored border on one side of a rounded card. The single most recognizable tell of AI-generated UI.',
},
{
id: 'cardocalypse',
title: 'Cardocalypse',
desc: 'Cards inside cards inside cards. Five levels of nesting, each with its own padding and shadow. The inception of containers.',
},
{
id: 'layout-templates',
title: 'Copy-Paste Layouts',
desc: 'The same hero-metric-features template repeated with different colors. When every section looks the same, nothing stands out.',
},
{
id: 'inter-everywhere',
title: 'Inter Everywhere',
desc: 'One font for everything. Headings, body, labels, buttons. No typographic hierarchy, no personality, no design.',
},
{
id: 'massive-icons',
title: 'Massive Icons',
desc: 'Icon containers larger than the content they introduce. When the decoration is bigger than the message, priorities are backwards.',
},
{
id: 'bad-contrast',
title: 'Bad Contrast Choices',
desc: 'Gray text on colored backgrounds, low-contrast labels, unreadable combinations. Looking good and being readable shouldn\'t conflict.',
},
{
id: 'redundant-ux-writing',
title: 'Redundant UX Writing',
desc: 'Label, sublabel, helper text, and hint text all saying the same thing in slightly different words. Say it once, say it well.',
},
{
id: 'modal-abuse',
title: 'Modal Abuse',
desc: 'Complex settings crammed into a modal. If it needs a scroll bar and three columns, it deserves its own page.',
},
];
const grid = document.getElementById('gallery');
grid.innerHTML = items.map(item => `
<a class="card-link" href="/antipattern-examples/${item.id}.html" target="_blank" rel="noopener">
<article class="card">
<div class="card-thumb">
<img src="/antipattern-images/${item.id}.png"
alt="${item.title} anti-pattern example"
loading="lazy"
width="540" height="540">
<div class="card-thumb-overlay"><span>View live example</span></div>
</div>
<div class="card-body">
<h2 class="card-title">${item.title}</h2>
<p class="card-desc">${item.desc}</p>
</div>
</article>
</a>
`).join('');
</script>
</body>
</html>
+2 -1
View File
@@ -151,7 +151,7 @@
<!-- Rendered by JS -->
</div>
<p class="contribute-inline">Missing something? <a href="https://github.com/pbakaus/impeccable/issues/new?labels=pattern&title=Pattern%20suggestion%3A%20">Suggest a pattern &rarr;</a></p>
<p class="contribute-inline">Want to see these in action? <a href="/gallery" class="cheatsheet-link">Gallery of Shame &rarr;</a> &nbsp;Missing something? <a href="https://github.com/pbakaus/impeccable/issues/new?labels=pattern&title=Pattern%20suggestion%3A%20">Suggest a pattern &rarr;</a></p>
</div>
</section>
@@ -476,6 +476,7 @@
</div>
<div class="footer-links">
<a href="#antidote">Anti-Patterns</a>
<a href="/gallery">Gallery of Shame</a>
<a href="#commands-section">Commands</a>
<a href="/cheatsheet">Cheatsheet</a>
<a href="#downloads">Downloads</a>
+460
View File
@@ -0,0 +1,460 @@
/**
* Anti-Pattern Browser Detector for Impeccable
*
* Drop this script into any page to visually highlight UI anti-patterns.
*
* Two detection modes:
* - "static" (default): regex on HTML source same logic as the CLI script,
* so fixture pages test exactly what the CLI tests.
* - "computed": getComputedStyle() catches CSS cascade, inherited styles.
* More accurate but may diverge from CLI results.
*
* Set mode via data attribute on the script tag:
* <script src="detect-antipatterns-browser.js" data-mode="computed"></script>
*
* Or call: window.impeccableScan({ mode: 'computed' })
*/
(function () {
if (typeof window === 'undefined') return;
const LABEL_BG = 'oklch(55% 0.25 350)';
const OUTLINE_COLOR = 'oklch(60% 0.25 350)';
// Read mode from script tag data attribute (default: static)
const scriptTag = document.currentScript;
const defaultMode = scriptTag?.dataset?.mode || 'static';
// -----------------------------------------------------------------------
// Static detection (mirrors CLI regex logic)
// -----------------------------------------------------------------------
const SAFE_ELEMENTS_RE = /^(blockquote|nav|a|input|textarea|select|pre|code|span|th|td|tr|li|label|button|hr)$/i;
function hasRoundedClass(str) { return /\brounded(?:-\w+)?\b/.test(str); }
/**
* Scan <style> blocks for CSS rules with anti-pattern border properties.
* Returns a Map of element findings[] for elements matching those selectors.
*/
function scanStyleBlocks() {
const elementFindings = new Map();
const styleTags = document.querySelectorAll('style');
for (const styleTag of styleTags) {
const css = styleTag.textContent;
// Simple CSS rule parser: extract selector { ... } blocks
const ruleRe = /([^{}]+)\{([^}]+)\}/g;
let rule;
while ((rule = ruleRe.exec(css)) !== null) {
const selector = rule[1].trim();
const body = rule[2];
const findings = [];
let m;
// Check for border-radius in the same rule
const ruleHasRadius = /border-radius/i.test(body);
// Collect border patterns from this rule (as templates — radius check deferred to element)
const borderPatterns = [];
// Side borders: border-left/right shorthand
const cssSide = /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi;
while ((m = cssSide.exec(body)) !== null) {
const n = parseInt(m[1], 10);
const neutral = isNeutralInline(m[0]);
borderPatterns.push({ n, text: m[0].trim(), direction: 'side', neutral });
}
// Side borders: longhand
const cssLong = /border-(?:left|right)-width\s*:\s*(\d+)px/gi;
while ((m = cssLong.exec(body)) !== null) {
borderPatterns.push({ n: parseInt(m[1], 10), text: m[0], direction: 'side', neutral: false });
}
// Side borders: logical
const cssLogical = /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi;
while ((m = cssLogical.exec(body)) !== null) {
borderPatterns.push({ n: parseInt(m[1], 10), text: m[0], direction: 'side', neutral: false });
}
// Side borders: logical longhand
const cssLogLong = /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi;
while ((m = cssLogLong.exec(body)) !== null) {
borderPatterns.push({ n: parseInt(m[1], 10), text: m[0], direction: 'side', neutral: false });
}
// Top/bottom borders
const cssTB = /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid[^;]*/gi;
while ((m = cssTB.exec(body)) !== null) {
borderPatterns.push({ n: parseInt(m[1], 10), text: m[0].trim(), direction: 'tb', neutral: false });
}
if (borderPatterns.length === 0) continue;
// Map findings to matching DOM elements, using computed radius for context
try {
const els = document.querySelectorAll(selector);
for (const el of els) {
if (SAFE_ELEMENTS_RE.test(el.tagName.toLowerCase())) continue;
const elRadius = ruleHasRadius || (parseFloat(getComputedStyle(el).borderRadius) || 0) > 0;
const findings = [];
for (const bp of borderPatterns) {
if (bp.direction === 'side') {
if (bp.neutral) continue;
if (elRadius && bp.n >= 1) {
findings.push({ type: 'side-tab', detail: `${bp.text} + border-radius` });
} else if (bp.n >= 3) {
findings.push({ type: 'side-tab', detail: bp.text });
}
} else {
// top/bottom: only with radius
if (elRadius && bp.n >= 1) {
findings.push({ type: 'border-accent-on-rounded', detail: `${bp.text} + border-radius` });
}
}
}
if (findings.length > 0) {
const existing = elementFindings.get(el) || [];
existing.push(...findings);
elementFindings.set(el, existing);
}
}
} catch {
// Invalid selector, skip
}
}
}
return elementFindings;
}
/** Check if an inline CSS color value looks neutral (gray/white/black) */
function isNeutralInline(cssText) {
// Extract the color from "Npx solid #color" or "Npx solid rgb(...)"
const colorMatch = cssText.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!colorMatch) return false;
const color = colorMatch[1].toLowerCase();
// Named grays
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(color)) return true;
// Hex grays: all channels within 30 of each other
const hex = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
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;
}
// Short hex
const shex = color.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
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;
}
function scanElementStatic(el) {
const findings = [];
const tag = el.tagName.toLowerCase();
// Get the raw class list and inline style as strings to regex against
const classList = el.getAttribute('class') || '';
const inlineStyle = el.getAttribute('style') || '';
const hasRounded = hasRoundedClass(classList);
const isSafe = SAFE_ELEMENTS_RE.test(tag);
// Use computed style for border-radius — catches radius from CSS classes
const computedRadius = parseFloat(getComputedStyle(el).borderRadius) || 0;
const hasRadius = hasRounded || computedRadius > 0;
// --- Tailwind side borders: border-[lrse]-N ---
const twSide = /\bborder-([lrse])-(\d+)\b/g;
let m;
while ((m = twSide.exec(classList)) !== null) {
const n = parseInt(m[2], 10);
if (hasRadius && n >= 1) {
findings.push({ type: 'side-tab', detail: `${m[0]} + rounded` });
} else if (n >= 4) {
findings.push({ type: 'side-tab', detail: m[0] });
}
}
// --- Tailwind top/bottom borders: border-[tb]-N ---
const twTB = /\bborder-([tb])-(\d+)\b/g;
while ((m = twTB.exec(classList)) !== null) {
const n = parseInt(m[2], 10);
if (hasRadius && n >= 1) {
findings.push({ type: 'border-accent-on-rounded', detail: `${m[0]} + rounded` });
}
}
// --- CSS shorthand: border-left/right: Npx solid ---
if (!isSafe) {
const cssSide = /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi;
while ((m = cssSide.exec(inlineStyle)) !== null) {
const n = parseInt(m[1], 10);
if (isNeutralInline(m[0])) continue; // skip gray/structural borders
if (hasRadius && n >= 1) {
findings.push({ type: 'side-tab', detail: `${m[0].split(';')[0].trim()} + border-radius` });
} else if (n >= 3) {
findings.push({ type: 'side-tab', detail: m[0].split(';')[0].trim() });
}
}
}
// --- CSS shorthand: border-top/bottom + border-radius ---
const cssTB = /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid[^;]*/gi;
while ((m = cssTB.exec(inlineStyle)) !== null) {
const n = parseInt(m[1], 10);
if (hasRadius && n >= 1) {
findings.push({ type: 'border-accent-on-rounded', detail: `${m[0].split(';')[0].trim()} + border-radius` });
}
}
// --- CSS longhand: border-left/right-width ---
if (!isSafe) {
const cssLong = /border-(?:left|right)-width\s*:\s*(\d+)px/gi;
while ((m = cssLong.exec(inlineStyle)) !== null) {
if (parseInt(m[1], 10) >= 3) {
findings.push({ type: 'side-tab', detail: m[0] });
}
}
}
// --- CSS logical: border-inline-start/end ---
if (!isSafe) {
const cssLogical = /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi;
while ((m = cssLogical.exec(inlineStyle)) !== null) {
if (parseInt(m[1], 10) >= 3) {
findings.push({ type: 'side-tab', detail: m[0] });
}
}
}
return findings;
}
// -----------------------------------------------------------------------
// Computed style detection (more accurate, for skill/production use)
// -----------------------------------------------------------------------
const SAFE_TAGS_COMPUTED = new Set(['blockquote', 'nav', 'a', 'input', 'textarea', 'select', 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', 'button', 'hr']);
function parseColor(color) {
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m) return null;
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
}
function isTransparent(color) {
const c = parseColor(color);
return !c || c.a === 0;
}
function isNeutral(color) {
const c = parseColor(color);
if (!c || c.a === 0) return true;
return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) < 30;
}
function scanElementComputed(el) {
const findings = [];
const tag = el.tagName.toLowerCase();
if (SAFE_TAGS_COMPUTED.has(tag)) return findings;
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return findings;
const style = getComputedStyle(el);
const sides = ['Top', 'Right', 'Bottom', 'Left'];
const widths = {};
const colors = {};
for (const s of sides) {
widths[s] = parseFloat(style[`border${s}Width`]) || 0;
colors[s] = style[`border${s}Color`];
}
const radius = parseFloat(style.borderRadius) || 0;
for (const side of sides) {
const w = widths[side];
if (w < 1 || isTransparent(colors[side])) continue;
const otherSides = sides.filter(s => s !== side);
const maxOther = Math.max(...otherSides.map(s => widths[s]));
const isAccent = w >= 2 && (maxOther <= 1 || w >= maxOther * 2);
if (!isAccent) continue;
const isSide = side === 'Left' || side === 'Right';
if (isSide) {
if (radius > 0) {
findings.push({ side, type: 'side-tab', detail: `border-${side.toLowerCase()}: ${w}px + border-radius: ${radius}px` });
} else if (w >= 3 && !isNeutral(colors[side])) {
findings.push({ side, type: 'side-tab', detail: `border-${side.toLowerCase()}: ${w}px (colored)` });
} else if (w >= 4) {
findings.push({ side, type: 'side-tab', detail: `border-${side.toLowerCase()}: ${w}px` });
}
} else {
if (radius > 0) {
findings.push({ side, type: 'border-accent-on-rounded', detail: `border-${side.toLowerCase()}: ${w}px + border-radius: ${radius}px` });
}
}
}
return findings;
}
// -----------------------------------------------------------------------
// Highlighting
// -----------------------------------------------------------------------
const overlays = [];
function highlight(el, findings) {
const rect = el.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const outline = document.createElement('div');
outline.className = 'impeccable-overlay';
Object.assign(outline.style, {
position: 'absolute',
top: `${rect.top + scrollY - 2}px`,
left: `${rect.left + scrollX - 2}px`,
width: `${rect.width + 4}px`,
height: `${rect.height + 4}px`,
border: `2px solid ${OUTLINE_COLOR}`,
borderRadius: '4px',
pointerEvents: 'none',
zIndex: '99999',
boxSizing: 'border-box',
});
const label = document.createElement('div');
label.className = 'impeccable-label';
const text = findings.map(f => f.type === 'side-tab' ? 'side-tab' : 'accent+rounded').join(', ');
label.textContent = text;
Object.assign(label.style, {
position: 'absolute',
top: '-20px',
left: '0',
background: LABEL_BG,
color: 'white',
fontSize: '11px',
fontFamily: 'system-ui, sans-serif',
fontWeight: '600',
padding: '2px 8px',
borderRadius: '3px',
whiteSpace: 'nowrap',
lineHeight: '16px',
letterSpacing: '0.02em',
});
outline.appendChild(label);
const tooltip = document.createElement('div');
tooltip.className = 'impeccable-tooltip';
tooltip.innerHTML = findings.map(f => f.detail).join('<br>');
Object.assign(tooltip.style, {
position: 'absolute',
bottom: '-28px',
left: '0',
background: 'rgba(0,0,0,0.85)',
color: '#e5e5e5',
fontSize: '11px',
fontFamily: 'ui-monospace, monospace',
padding: '4px 8px',
borderRadius: '3px',
whiteSpace: 'nowrap',
lineHeight: '16px',
display: 'none',
zIndex: '100000',
});
outline.appendChild(tooltip);
outline.addEventListener('mouseenter', () => {
outline.style.pointerEvents = 'auto';
tooltip.style.display = 'block';
outline.style.background = 'oklch(60% 0.25 350 / 0.08)';
});
outline.addEventListener('mouseleave', () => {
outline.style.pointerEvents = 'none';
tooltip.style.display = 'none';
outline.style.background = 'none';
});
document.body.appendChild(outline);
overlays.push(outline);
}
// -----------------------------------------------------------------------
// Console summary
// -----------------------------------------------------------------------
function printSummary(allFindings, mode) {
if (allFindings.length === 0) {
console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
return;
}
console.group(
`%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found (${mode} mode)`,
'color: oklch(60% 0.25 350); font-weight: bold'
);
for (const { el, findings } of allFindings) {
for (const f of findings) {
console.log(`%c${f.type}%c ${f.detail}`, 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el);
}
}
console.groupEnd();
}
// -----------------------------------------------------------------------
// Main scan
// -----------------------------------------------------------------------
function scan(opts = {}) {
const mode = opts.mode || defaultMode;
const scanner = mode === 'computed' ? scanElementComputed : scanElementStatic;
// Remove previous overlays
for (const o of overlays) o.remove();
overlays.length = 0;
// In static mode, pre-scan <style> blocks to find CSS-rule-based findings
const styleBlockFindings = (mode === 'static') ? scanStyleBlocks() : new Map();
const allFindings = [];
const elements = document.querySelectorAll('*');
for (const el of elements) {
if (el.classList.contains('impeccable-overlay') ||
el.classList.contains('impeccable-label') ||
el.classList.contains('impeccable-tooltip')) continue;
// Merge per-element findings with style-block findings
const findings = scanner(el);
const fromStyles = styleBlockFindings.get(el);
if (fromStyles) findings.push(...fromStyles);
if (findings.length > 0) {
highlight(el, findings);
allFindings.push({ el, findings });
}
}
printSummary(allFindings, mode);
return allFindings;
}
// Run after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100));
} else {
setTimeout(scan, 100);
}
// Expose for manual re-scan (supports mode override)
window.impeccableScan = scan;
})();
+1 -1
View File
@@ -82,6 +82,7 @@ async function buildStaticSite() {
const entrypoints = [
path.join(ROOT_DIR, 'public', 'index.html'),
path.join(ROOT_DIR, 'public', 'cheatsheet.html'),
path.join(ROOT_DIR, 'public', 'gallery.html'),
];
const outdir = path.join(ROOT_DIR, 'build');
@@ -383,7 +384,6 @@ async function build() {
// Remove existing and copy fresh
if (fs.existsSync(skillsDest)) fs.rmSync(skillsDest, { recursive: true });
copyDirSync(skillsSrc, skillsDest);
console.log(`📋 Synced to .claude/: skills`);
+17 -62
View File
@@ -1,69 +1,24 @@
import path from 'path';
import { cleanDir, ensureDir, writeFile, generateYamlFrontmatter, replacePlaceholders, prefixSkillReferences } from '../utils.js';
import { transformProvider } from './shared.js';
/**
* Agents Transformer (VS Code Copilot + Antigravity)
*
* All skills output to .agents/skills/{name}/SKILL.md
* Frontmatter: name, description, user-invokable (if true), argument-hint (from args)
*
* @param {Array} skills - All skills (including user-invokable ones)
* @param {string} distDir - Distribution output directory
* @param {Object} patterns - Design patterns data (unused)
* @param {Object} options - Optional settings
* Output: .agents/skills/{name}/SKILL.md
*/
export function transformAgents(skills, distDir, patterns = null, options = {}) {
const { prefix = '', outputSuffix = '' } = options;
const agentsDir = path.join(distDir, `agents${outputSuffix}`);
const skillsDir = path.join(agentsDir, '.agents/skills');
cleanDir(agentsDir);
ensureDir(skillsDir);
const allSkillNames = skills.map(s => s.name);
const commandNames = skills.filter(s => s.userInvokable).map(s => `${prefix}${s.name}`);
let refCount = 0;
for (const skill of skills) {
const skillName = `${prefix}${skill.name}`;
const skillDir = path.join(skillsDir, skillName);
const frontmatterObj = {
name: skillName,
description: skill.description,
};
if (skill.userInvokable) frontmatterObj['user-invokable'] = true;
// Build argument-hint from args array for user-invokable skills
if (skill.userInvokable && skill.args && skill.args.length > 0) {
const hints = skill.args.map(arg => {
return arg.required ? `<${arg.name}>` : `[${arg.name.toUpperCase()}=<value>]`;
});
frontmatterObj['argument-hint'] = hints.join(' ');
}
const frontmatter = generateYamlFrontmatter(frontmatterObj);
let skillBody = replacePlaceholders(skill.body, 'agents', commandNames);
if (prefix) skillBody = prefixSkillReferences(skillBody, prefix, allSkillNames);
const content = `${frontmatter}\n\n${skillBody}`;
const outputPath = path.join(skillDir, 'SKILL.md');
writeFile(outputPath, content);
// Copy reference files if they exist
if (skill.references && skill.references.length > 0) {
const refDir = path.join(skillDir, 'reference');
ensureDir(refDir);
for (const ref of skill.references) {
const refOutputPath = path.join(refDir, `${ref.name}.md`);
const refContent = replacePlaceholders(ref.content, 'agents');
writeFile(refOutputPath, refContent);
refCount++;
transformProvider({
provider: 'agents',
displayName: 'Agents',
configDir: '.agents',
buildFrontmatter: (skill, skillName) => {
const obj = { name: skillName, description: skill.description };
if (skill.userInvokable) obj['user-invokable'] = true;
if (skill.userInvokable && skill.args && skill.args.length > 0) {
const hints = skill.args.map(arg =>
arg.required ? `<${arg.name}>` : `[${arg.name.toUpperCase()}=<value>]`
);
obj['argument-hint'] = hints.join(' ');
}
}
}
const userInvokableCount = skills.filter(s => s.userInvokable).length;
const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
const prefixInfo = prefix ? ` [${prefix}prefixed]` : '';
console.log(`✓ Agents${prefixInfo}: ${skills.length} skills (${userInvokableCount} user-invokable)${refInfo}`);
return obj;
},
}, skills, distDir, options);
}
+18 -63
View File
@@ -1,68 +1,23 @@
import path from 'path';
import { cleanDir, ensureDir, writeFile, generateYamlFrontmatter, replacePlaceholders, prefixSkillReferences } from '../utils.js';
import { transformProvider } from './shared.js';
/**
* Claude Code Transformer (Skills Only)
*
* All skills output to .claude/skills/{name}/SKILL.md
* User-invokable skills get args support in frontmatter.
*
* @param {Array} skills - All skills (including user-invokable ones)
* @param {string} distDir - Distribution output directory
* @param {Object} patterns - Design patterns data (unused, kept for interface consistency)
* @param {Object} options - Optional settings
* @param {string} options.prefix - Prefix to add to user-invokable skill names (e.g., 'i-')
* @param {string} options.outputSuffix - Suffix for output directory (e.g., '-prefixed')
* Claude Code Transformer
* Output: .claude/skills/{name}/SKILL.md
*/
export function transformClaudeCode(skills, distDir, patterns = null, options = {}) {
const { prefix = '', outputSuffix = '' } = options;
const claudeDir = path.join(distDir, `claude-code${outputSuffix}`);
const skillsDir = path.join(claudeDir, '.claude/skills');
cleanDir(claudeDir);
ensureDir(skillsDir);
const allSkillNames = skills.map(s => s.name);
const commandNames = skills.filter(s => s.userInvokable).map(s => `${prefix}${s.name}`);
let refCount = 0;
for (const skill of skills) {
const skillName = `${prefix}${skill.name}`;
const skillDir = path.join(skillsDir, skillName);
const frontmatterObj = {
name: skillName,
description: skill.description,
};
if (skill.userInvokable) frontmatterObj['user-invokable'] = true;
if (skill.args && skill.args.length > 0) frontmatterObj.args = skill.args;
if (skill.license) frontmatterObj.license = skill.license;
if (skill.compatibility) frontmatterObj.compatibility = skill.compatibility;
if (skill.metadata) frontmatterObj.metadata = skill.metadata;
if (skill.allowedTools) frontmatterObj['allowed-tools'] = skill.allowedTools;
const frontmatter = generateYamlFrontmatter(frontmatterObj);
let skillBody = replacePlaceholders(skill.body, 'claude-code', commandNames);
if (prefix) skillBody = prefixSkillReferences(skillBody, prefix, allSkillNames);
const content = `${frontmatter}\n\n${skillBody}`;
const outputPath = path.join(skillDir, 'SKILL.md');
writeFile(outputPath, content);
// Copy reference files if they exist
if (skill.references && skill.references.length > 0) {
const refDir = path.join(skillDir, 'reference');
ensureDir(refDir);
for (const ref of skill.references) {
const refOutputPath = path.join(refDir, `${ref.name}.md`);
const refContent = replacePlaceholders(ref.content, 'claude-code');
writeFile(refOutputPath, refContent);
refCount++;
}
}
}
const userInvokableCount = skills.filter(s => s.userInvokable).length;
const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
const prefixInfo = prefix ? ` [${prefix}prefixed]` : '';
console.log(`✓ Claude Code${prefixInfo}: ${skills.length} skills (${userInvokableCount} user-invokable)${refInfo}`);
transformProvider({
provider: 'claude-code',
displayName: 'Claude Code',
configDir: '.claude',
buildFrontmatter: (skill, skillName) => {
const obj = { name: skillName, description: skill.description };
if (skill.userInvokable) obj['user-invokable'] = true;
if (skill.args && skill.args.length > 0) obj.args = skill.args;
if (skill.license) obj.license = skill.license;
if (skill.compatibility) obj.compatibility = skill.compatibility;
if (skill.metadata) obj.metadata = skill.metadata;
if (skill.allowedTools) obj['allowed-tools'] = skill.allowedTools;
return obj;
},
}, skills, distDir, options);
}
+25 -73
View File
@@ -1,79 +1,31 @@
import path from 'path';
import { cleanDir, ensureDir, writeFile, generateYamlFrontmatter, replacePlaceholders, prefixSkillReferences } from '../utils.js';
import { transformProvider } from './shared.js';
/**
* Codex Transformer (Skills Only)
*
* All skills output to .codex/skills/{name}/SKILL.md
* Frontmatter: name, description, argument-hint (from args for user-invokable)
* For user-invokable skills: {{argname}} becomes $ARGNAME in body
*
* @param {Array} skills - All skills (including user-invokable ones)
* @param {string} distDir - Distribution output directory
* @param {Object} patterns - Design patterns data (unused)
* @param {Object} options - Optional settings
* @param {string} options.prefix - Prefix to add to user-invokable skill names (e.g., 'i-')
* @param {string} options.outputSuffix - Suffix for output directory (e.g., '-prefixed')
* Codex Transformer
* Output: .codex/skills/{name}/SKILL.md
* User-invokable: {{argname}} becomes $ARGNAME, argument-hint in frontmatter
*/
export function transformCodex(skills, distDir, patterns = null, options = {}) {
const { prefix = '', outputSuffix = '' } = options;
const codexDir = path.join(distDir, `codex${outputSuffix}`);
const skillsDir = path.join(codexDir, '.codex/skills');
cleanDir(codexDir);
ensureDir(skillsDir);
const allSkillNames = skills.map(s => s.name);
const commandNames = skills.filter(s => s.userInvokable).map(s => `${prefix}${s.name}`);
let refCount = 0;
for (const skill of skills) {
const skillName = `${prefix}${skill.name}`;
const skillDir = path.join(skillsDir, skillName);
const frontmatterObj = {
name: skillName,
description: skill.description,
};
// Build argument-hint from args array for user-invokable skills
if (skill.userInvokable && skill.args && skill.args.length > 0) {
const hints = skill.args.map(arg => {
return arg.required ? `<${arg.name}>` : `[${arg.name.toUpperCase()}=<value>]`;
});
frontmatterObj['argument-hint'] = hints.join(' ');
}
if (skill.license) frontmatterObj.license = skill.license;
const frontmatter = generateYamlFrontmatter(frontmatterObj);
let skillBody = replacePlaceholders(skill.body, 'codex', commandNames);
if (prefix) skillBody = prefixSkillReferences(skillBody, prefix, allSkillNames);
// For user-invokable skills, transform remaining {{argname}} to $ARGNAME
if (skill.userInvokable) {
skillBody = skillBody.replace(/\{\{([^}]+)\}\}/g, (match, argName) => {
return `$${argName.toUpperCase()}`;
});
}
const content = `${frontmatter}\n\n${skillBody}`;
const outputPath = path.join(skillDir, 'SKILL.md');
writeFile(outputPath, content);
// Copy reference files if they exist
if (skill.references && skill.references.length > 0) {
const refDir = path.join(skillDir, 'reference');
ensureDir(refDir);
for (const ref of skill.references) {
const refOutputPath = path.join(refDir, `${ref.name}.md`);
const refContent = replacePlaceholders(ref.content, 'codex');
writeFile(refOutputPath, refContent);
refCount++;
transformProvider({
provider: 'codex',
displayName: 'Codex',
configDir: '.codex',
buildFrontmatter: (skill, skillName) => {
const obj = { name: skillName, description: skill.description };
if (skill.userInvokable && skill.args && skill.args.length > 0) {
const hints = skill.args.map(arg =>
arg.required ? `<${arg.name}>` : `[${arg.name.toUpperCase()}=<value>]`
);
obj['argument-hint'] = hints.join(' ');
}
}
}
const userInvokableCount = skills.filter(s => s.userInvokable).length;
const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
const prefixInfo = prefix ? ` [${prefix}prefixed]` : '';
console.log(`✓ Codex${prefixInfo}: ${skills.length} skills (${userInvokableCount} user-invokable)${refInfo}`);
if (skill.license) obj.license = skill.license;
return obj;
},
transformBody: (body, skill) => {
if (skill.userInvokable) {
return body.replace(/\{\{([^}]+)\}\}/g, (_, argName) => `$${argName.toUpperCase()}`);
}
return body;
},
}, skills, distDir, options);
}
+13 -57
View File
@@ -1,62 +1,18 @@
import path from 'path';
import { cleanDir, ensureDir, writeFile, generateYamlFrontmatter, replacePlaceholders, prefixSkillReferences } from '../utils.js';
import { transformProvider } from './shared.js';
/**
* Cursor Transformer (Skills Only)
*
* All skills output to .cursor/skills/{name}/SKILL.md
* Frontmatter: name, description, license
*
* @param {Array} skills - All skills (including user-invokable ones)
* @param {string} distDir - Distribution output directory
* @param {Object} patterns - Design patterns data (unused)
* @param {Object} options - Optional settings
* @param {string} options.prefix - Prefix to add to user-invokable skill names (e.g., 'i-')
* @param {string} options.outputSuffix - Suffix for output directory (e.g., '-prefixed')
* Cursor Transformer
* Output: .cursor/skills/{name}/SKILL.md
*/
export function transformCursor(skills, distDir, patterns = null, options = {}) {
const { prefix = '', outputSuffix = '' } = options;
const cursorDir = path.join(distDir, `cursor${outputSuffix}`);
const skillsDir = path.join(cursorDir, '.cursor/skills');
cleanDir(cursorDir);
ensureDir(skillsDir);
const allSkillNames = skills.map(s => s.name);
const commandNames = skills.filter(s => s.userInvokable).map(s => `${prefix}${s.name}`);
let refCount = 0;
for (const skill of skills) {
const skillName = `${prefix}${skill.name}`;
const skillDir = path.join(skillsDir, skillName);
const frontmatterObj = {
name: skillName,
description: skill.description,
};
if (skill.license) frontmatterObj.license = skill.license;
const frontmatter = generateYamlFrontmatter(frontmatterObj);
let skillBody = replacePlaceholders(skill.body, 'cursor', commandNames);
if (prefix) skillBody = prefixSkillReferences(skillBody, prefix, allSkillNames);
const content = `${frontmatter}\n\n${skillBody}`;
const outputPath = path.join(skillDir, 'SKILL.md');
writeFile(outputPath, content);
// Copy reference files if they exist
if (skill.references && skill.references.length > 0) {
const refDir = path.join(skillDir, 'reference');
ensureDir(refDir);
for (const ref of skill.references) {
const refOutputPath = path.join(refDir, `${ref.name}.md`);
const refContent = replacePlaceholders(ref.content, 'cursor');
writeFile(refOutputPath, refContent);
refCount++;
}
}
}
const userInvokableCount = skills.filter(s => s.userInvokable).length;
const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
const prefixInfo = prefix ? ` [${prefix}prefixed]` : '';
console.log(`✓ Cursor${prefixInfo}: ${skills.length} skills (${userInvokableCount} user-invokable)${refInfo}`);
transformProvider({
provider: 'cursor',
displayName: 'Cursor',
configDir: '.cursor',
buildFrontmatter: (skill, skillName) => {
const obj = { name: skillName, description: skill.description };
if (skill.license) obj.license = skill.license;
return obj;
},
}, skills, distDir, options);
}
+16 -58
View File
@@ -1,66 +1,24 @@
import path from 'path';
import { cleanDir, ensureDir, writeFile, generateYamlFrontmatter, replacePlaceholders, prefixSkillReferences } from '../utils.js';
import { transformProvider } from './shared.js';
/**
* Gemini Transformer (Skills Only)
*
* All skills output to .gemini/skills/{name}/SKILL.md
* Frontmatter: name, description
* For user-invokable skills: {{arg}} placeholders become {{args}} in body
*
* @param {Array} skills - All skills (including user-invokable ones)
* @param {string} distDir - Distribution output directory
* @param {Object} patterns - Design patterns data (unused)
* @param {Object} options - Optional settings
* @param {string} options.prefix - Prefix to add to user-invokable skill names (e.g., 'i-')
* @param {string} options.outputSuffix - Suffix for output directory (e.g., '-prefixed')
* Gemini Transformer
* Output: .gemini/skills/{name}/SKILL.md
* User-invokable: {{arg}} placeholders become {{args}}
*/
export function transformGemini(skills, distDir, patterns = null, options = {}) {
const { prefix = '', outputSuffix = '' } = options;
const geminiDir = path.join(distDir, `gemini${outputSuffix}`);
const skillsDir = path.join(geminiDir, '.gemini/skills');
cleanDir(geminiDir);
ensureDir(skillsDir);
const allSkillNames = skills.map(s => s.name);
const commandNames = skills.filter(s => s.userInvokable).map(s => `${prefix}${s.name}`);
let refCount = 0;
for (const skill of skills) {
const skillName = `${prefix}${skill.name}`;
const skillDir = path.join(skillsDir, skillName);
const frontmatter = generateYamlFrontmatter({
transformProvider({
provider: 'gemini',
displayName: 'Gemini',
configDir: '.gemini',
buildFrontmatter: (skill, skillName) => ({
name: skillName,
description: skill.description,
});
let skillBody = replacePlaceholders(skill.body, 'gemini', commandNames);
if (prefix) skillBody = prefixSkillReferences(skillBody, prefix, allSkillNames);
// For user-invokable skills, replace remaining {{arg}} placeholders with {{args}}
if (skill.userInvokable) {
skillBody = skillBody.replace(/\{\{[^}]+\}\}/g, '{{args}}');
}
const content = `${frontmatter}\n\n${skillBody}`;
const outputPath = path.join(skillDir, 'SKILL.md');
writeFile(outputPath, content);
// Copy reference files if they exist
if (skill.references && skill.references.length > 0) {
const refDir = path.join(skillDir, 'reference');
ensureDir(refDir);
for (const ref of skill.references) {
const refOutputPath = path.join(refDir, `${ref.name}.md`);
const refContent = replacePlaceholders(ref.content, 'gemini');
writeFile(refOutputPath, refContent);
refCount++;
}),
transformBody: (body, skill) => {
if (skill.userInvokable) {
return body.replace(/\{\{[^}]+\}\}/g, '{{args}}');
}
}
}
const userInvokableCount = skills.filter(s => s.userInvokable).length;
const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
const prefixInfo = prefix ? ` [${prefix}prefixed]` : '';
console.log(`✓ Gemini${prefixInfo}: ${skills.length} skills (${userInvokableCount} user-invokable)${refInfo}`);
return body;
},
}, skills, distDir, options);
}
+15 -60
View File
@@ -1,65 +1,20 @@
import path from 'path';
import { cleanDir, ensureDir, writeFile, generateYamlFrontmatter, replacePlaceholders, prefixSkillReferences } from '../utils.js';
import { transformProvider } from './shared.js';
/**
* Kiro Transformer (Skills Only)
*
* All skills output to .kiro/skills/{name}/SKILL.md
* Frontmatter: name, description, license, compatibility, metadata
*
* @param {Array} skills - All skills (including user-invokable ones)
* @param {string} distDir - Distribution output directory
* @param {Object} patterns - Design patterns data (unused)
* @param {Object} options - Optional settings
* @param {string} options.prefix - Prefix to add to skill names (e.g., 'i-')
* @param {string} options.outputSuffix - Suffix for output directory (e.g., '-prefixed')
* Kiro Transformer
* Output: .kiro/skills/{name}/SKILL.md
*/
export function transformKiro(skills, distDir, patterns = null, options = {}) {
const { prefix = '', outputSuffix = '' } = options;
const kiroDir = path.join(distDir, `kiro${outputSuffix}`);
const skillsDir = path.join(kiroDir, '.kiro/skills');
cleanDir(kiroDir);
ensureDir(skillsDir);
const allSkillNames = skills.map(s => s.name);
const commandNames = skills.filter(s => s.userInvokable).map(s => `${prefix}${s.name}`);
let refCount = 0;
for (const skill of skills) {
const skillName = `${prefix}${skill.name}`;
const skillDir = path.join(skillsDir, skillName);
const frontmatterObj = {
name: skillName,
description: skill.description,
};
if (skill.license) frontmatterObj.license = skill.license;
if (skill.compatibility) frontmatterObj.compatibility = skill.compatibility;
if (skill.metadata) frontmatterObj.metadata = skill.metadata;
const frontmatter = generateYamlFrontmatter(frontmatterObj);
let skillBody = replacePlaceholders(skill.body, 'kiro', commandNames);
if (prefix) skillBody = prefixSkillReferences(skillBody, prefix, allSkillNames);
const content = `${frontmatter}\n\n${skillBody}`;
const outputPath = path.join(skillDir, 'SKILL.md');
writeFile(outputPath, content);
// Copy reference files if they exist
if (skill.references && skill.references.length > 0) {
const refDir = path.join(skillDir, 'reference');
ensureDir(refDir);
for (const ref of skill.references) {
const refOutputPath = path.join(refDir, `${ref.name}.md`);
const refContent = replacePlaceholders(ref.content, 'kiro');
writeFile(refOutputPath, refContent);
refCount++;
}
}
}
const userInvokableCount = skills.filter(s => s.userInvokable).length;
const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
const prefixInfo = prefix ? ` [${prefix}prefixed]` : '';
console.log(`✓ Kiro${prefixInfo}: ${skills.length} skills (${userInvokableCount} user-invokable)${refInfo}`);
transformProvider({
provider: 'kiro',
displayName: 'Kiro',
configDir: '.kiro',
buildFrontmatter: (skill, skillName) => {
const obj = { name: skillName, description: skill.description };
if (skill.license) obj.license = skill.license;
if (skill.compatibility) obj.compatibility = skill.compatibility;
if (skill.metadata) obj.metadata = skill.metadata;
return obj;
},
}, skills, distDir, options);
}
+19 -80
View File
@@ -1,84 +1,23 @@
import path from 'path';
import {
cleanDir,
ensureDir,
generateYamlFrontmatter,
prefixSkillReferences,
replacePlaceholders,
writeFile,
} from '../utils.js';
import { transformProvider } from './shared.js';
/**
* OpenCode Transformer (Skills Only)
*
* All skills output to .opencode/skills/{name}/SKILL.md
* User-invokable skills get args support in frontmatter.
*
* @param {Array} skills - All skills (including user-invokable ones)
* @param {string} distDir - Distribution output directory
* @param {Object} patterns - Design patterns data (unused, kept for interface consistency)
* @param {Object} options - Optional settings
* @param {string} options.prefix - Prefix to add to user-invokable skill names (e.g., 'i-')
* @param {string} options.outputSuffix - Suffix for output directory (e.g., '-prefixed')
* OpenCode Transformer
* Output: .opencode/skills/{name}/SKILL.md
*/
export function transformOpenCode(
skills,
distDir,
patterns = null,
options = {},
) {
const { prefix = '', outputSuffix = '' } = options;
const opencodeDir = path.join(distDir, `opencode${outputSuffix}`);
const skillsDir = path.join(opencodeDir, '.opencode/skills');
cleanDir(opencodeDir);
ensureDir(skillsDir);
const allSkillNames = skills.map((s) => s.name);
const commandNames = skills
.filter((s) => s.userInvokable)
.map((s) => `${prefix}${s.name}`);
let refCount = 0;
for (const skill of skills) {
const skillName = `${prefix}${skill.name}`;
const skillDir = path.join(skillsDir, skillName);
const frontmatterObj = {
name: skillName,
description: skill.description,
};
if (skill.userInvokable) frontmatterObj['user-invokable'] = true;
if (skill.args && skill.args.length > 0) frontmatterObj.args = skill.args;
if (skill.license) frontmatterObj.license = skill.license;
if (skill.compatibility) frontmatterObj.compatibility = skill.compatibility;
if (skill.metadata) frontmatterObj.metadata = skill.metadata;
if (skill.allowedTools)
frontmatterObj['allowed-tools'] = skill.allowedTools;
const frontmatter = generateYamlFrontmatter(frontmatterObj);
let skillBody = replacePlaceholders(skill.body, 'opencode', commandNames);
if (prefix)
skillBody = prefixSkillReferences(skillBody, prefix, allSkillNames);
const content = `${frontmatter}\n\n${skillBody}`;
const outputPath = path.join(skillDir, 'SKILL.md');
writeFile(outputPath, content);
// Copy reference files if they exist
if (skill.references && skill.references.length > 0) {
const refDir = path.join(skillDir, 'reference');
ensureDir(refDir);
for (const ref of skill.references) {
const refOutputPath = path.join(refDir, `${ref.name}.md`);
const refContent = replacePlaceholders(ref.content, 'opencode');
writeFile(refOutputPath, refContent);
refCount++;
}
}
}
const userInvokableCount = skills.filter((s) => s.userInvokable).length;
const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
const prefixInfo = prefix ? ` [${prefix}prefixed]` : '';
console.log(`✓ OpenCode${prefixInfo}: ${skills.length} skills (${userInvokableCount} user-invokable)${refInfo}`);
export function transformOpenCode(skills, distDir, patterns = null, options = {}) {
transformProvider({
provider: 'opencode',
displayName: 'OpenCode',
configDir: '.opencode',
buildFrontmatter: (skill, skillName) => {
const obj = { name: skillName, description: skill.description };
if (skill.userInvokable) obj['user-invokable'] = true;
if (skill.args && skill.args.length > 0) obj.args = skill.args;
if (skill.license) obj.license = skill.license;
if (skill.compatibility) obj.compatibility = skill.compatibility;
if (skill.metadata) obj.metadata = skill.metadata;
if (skill.allowedTools) obj['allowed-tools'] = skill.allowedTools;
return obj;
},
}, skills, distDir, options);
}
+15 -60
View File
@@ -1,65 +1,20 @@
import path from 'path';
import { cleanDir, ensureDir, writeFile, generateYamlFrontmatter, replacePlaceholders, prefixSkillReferences } from '../utils.js';
import { transformProvider } from './shared.js';
/**
* Pi Transformer (Skills Only)
*
* All skills output to .pi/skills/{name}/SKILL.md
* Frontmatter: name, description, license, compatibility, metadata
*
* @param {Array} skills - All skills (including user-invokable ones)
* @param {string} distDir - Distribution output directory
* @param {Object} patterns - Design patterns data (unused)
* @param {Object} options - Optional settings
* @param {string} options.prefix - Prefix to add to skill names (e.g., 'i-')
* @param {string} options.outputSuffix - Suffix for output directory (e.g., '-prefixed')
* Pi Transformer
* Output: .pi/skills/{name}/SKILL.md
*/
export function transformPi(skills, distDir, patterns = null, options = {}) {
const { prefix = '', outputSuffix = '' } = options;
const piDir = path.join(distDir, `pi${outputSuffix}`);
const skillsDir = path.join(piDir, '.pi/skills');
cleanDir(piDir);
ensureDir(skillsDir);
const allSkillNames = skills.map(s => s.name);
const commandNames = skills.filter(s => s.userInvokable).map(s => `${prefix}${s.name}`);
let refCount = 0;
for (const skill of skills) {
const skillName = `${prefix}${skill.name}`;
const skillDir = path.join(skillsDir, skillName);
const frontmatterObj = {
name: skillName,
description: skill.description,
};
if (skill.license) frontmatterObj.license = skill.license;
if (skill.compatibility) frontmatterObj.compatibility = skill.compatibility;
if (skill.metadata) frontmatterObj.metadata = skill.metadata;
const frontmatter = generateYamlFrontmatter(frontmatterObj);
let skillBody = replacePlaceholders(skill.body, 'pi', commandNames);
if (prefix) skillBody = prefixSkillReferences(skillBody, prefix, allSkillNames);
const content = `${frontmatter}\n\n${skillBody}`;
const outputPath = path.join(skillDir, 'SKILL.md');
writeFile(outputPath, content);
// Copy reference files if they exist
if (skill.references && skill.references.length > 0) {
const refDir = path.join(skillDir, 'reference');
ensureDir(refDir);
for (const ref of skill.references) {
const refOutputPath = path.join(refDir, `${ref.name}.md`);
const refContent = replacePlaceholders(ref.content, 'pi');
writeFile(refOutputPath, refContent);
refCount++;
}
}
}
const userInvokableCount = skills.filter(s => s.userInvokable).length;
const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
const prefixInfo = prefix ? ` [${prefix}prefixed]` : '';
console.log(`✓ Pi${prefixInfo}: ${skills.length} skills (${userInvokableCount} user-invokable)${refInfo}`);
transformProvider({
provider: 'pi',
displayName: 'Pi',
configDir: '.pi',
buildFrontmatter: (skill, skillName) => {
const obj = { name: skillName, description: skill.description };
if (skill.license) obj.license = skill.license;
if (skill.compatibility) obj.compatibility = skill.compatibility;
if (skill.metadata) obj.metadata = skill.metadata;
return obj;
},
}, skills, distDir, options);
}
+74
View File
@@ -0,0 +1,74 @@
import path from 'path';
import { cleanDir, ensureDir, writeFile, generateYamlFrontmatter, replacePlaceholders, prefixSkillReferences } from '../utils.js';
/**
* Shared transformer logic for all providers.
*
* @param {Object} config - Provider-specific configuration
* @param {string} config.provider - Provider key for placeholders (e.g., 'claude-code')
* @param {string} config.displayName - Display name for logging (e.g., 'Claude Code')
* @param {string} config.configDir - Dot-directory name (e.g., '.claude')
* @param {Function} config.buildFrontmatter - (skill, skillName) => frontmatter object
* @param {Function} [config.transformBody] - Optional (body, skill) => transformed body
* @param {Array} skills - All skills
* @param {string} distDir - Distribution output directory
* @param {Object} options - Optional settings (prefix, outputSuffix)
*/
export function transformProvider(config, skills, distDir, options = {}) {
const { provider, displayName, configDir, buildFrontmatter, transformBody } = config;
const { prefix = '', outputSuffix = '' } = options;
const providerDir = path.join(distDir, `${provider}${outputSuffix}`);
const skillsDir = path.join(providerDir, `${configDir}/skills`);
cleanDir(providerDir);
ensureDir(skillsDir);
const allSkillNames = skills.map(s => s.name);
const commandNames = skills.filter(s => s.userInvokable).map(s => `${prefix}${s.name}`);
let refCount = 0;
let scriptCount = 0;
for (const skill of skills) {
const skillName = `${prefix}${skill.name}`;
const skillDir = path.join(skillsDir, skillName);
const frontmatterObj = buildFrontmatter(skill, skillName);
const frontmatter = generateYamlFrontmatter(frontmatterObj);
let skillBody = replacePlaceholders(skill.body, provider, commandNames);
if (prefix) skillBody = prefixSkillReferences(skillBody, prefix, allSkillNames);
if (transformBody) skillBody = transformBody(skillBody, skill);
const content = `${frontmatter}\n\n${skillBody}`;
writeFile(path.join(skillDir, 'SKILL.md'), content);
// Copy reference files if they exist
if (skill.references && skill.references.length > 0) {
const refDir = path.join(skillDir, 'reference');
ensureDir(refDir);
for (const ref of skill.references) {
writeFile(
path.join(refDir, `${ref.name}.md`),
replacePlaceholders(ref.content, provider)
);
refCount++;
}
}
// Copy script files if they exist
if (skill.scripts && skill.scripts.length > 0) {
const scriptsOutDir = path.join(skillDir, 'scripts');
ensureDir(scriptsOutDir);
for (const script of skill.scripts) {
writeFile(path.join(scriptsOutDir, script.name), script.content);
scriptCount++;
}
}
}
const userInvokableCount = skills.filter(s => s.userInvokable).length;
const refInfo = refCount > 0 ? ` (${refCount} reference files)` : '';
const scriptInfo = scriptCount > 0 ? ` (${scriptCount} script files)` : '';
const prefixInfo = prefix ? ` [${prefix}prefixed]` : '';
console.log(`${displayName}${prefixInfo}: ${skills.length} skills (${userInvokableCount} user-invokable)${refInfo}${scriptInfo}`);
}
+18 -1
View File
@@ -140,6 +140,22 @@ export function readSourceFiles(rootDir) {
}
}
// Read script files if they exist
const scripts = [];
const scriptsDir = path.join(entryPath, 'scripts');
if (fs.existsSync(scriptsDir)) {
const scriptFiles = fs.readdirSync(scriptsDir).filter(f => fs.statSync(path.join(scriptsDir, f)).isFile());
for (const scriptFile of scriptFiles) {
const scriptPath = path.join(scriptsDir, scriptFile);
const scriptContent = fs.readFileSync(scriptPath, 'utf-8');
scripts.push({
name: scriptFile,
content: scriptContent,
filePath: scriptPath
});
}
}
skills.push({
name: frontmatter.name || entry.name,
description: frontmatter.description || '',
@@ -152,7 +168,8 @@ export function readSourceFiles(rootDir) {
context: frontmatter.context || null,
body,
filePath: skillMdPath,
references
references,
scripts
});
}
}
+14
View File
@@ -1,6 +1,7 @@
import { serve, file } from "bun";
import homepage from "../public/index.html";
import cheatsheet from "../public/cheatsheet.html";
import gallery from "../public/gallery.html";
import {
getSkills,
getCommands,
@@ -16,6 +17,7 @@ const server = serve({
routes: {
"/": homepage,
"/cheatsheet": cheatsheet,
"/gallery": gallery,
// Static assets - all public subdirectories
"/assets/*": async (req) => {
@@ -54,6 +56,18 @@ const server = serve({
}
return new Response("Not Found", { status: 404 });
},
"/antipattern-images/*": async (req) => {
const url = new URL(req.url);
if (url.pathname.includes('..')) return new Response("Bad Request", { status: 400 });
const filePath = `./public${url.pathname}`;
const assetFile = file(filePath);
if (await assetFile.exists()) {
return new Response(assetFile, {
headers: { "X-Content-Type-Options": "nosniff" }
});
}
return new Response("Not Found", { status: 404 });
},
"/antipattern-examples/*": async (req) => {
const url = new URL(req.url);
if (url.pathname.includes('..')) return new Response("Bad Request", { status: 400 });
+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 };
+421
View File
@@ -0,0 +1,421 @@
import { describe, test, expect } from 'bun:test';
import fs from 'fs';
import path from 'path';
import { spawnSync } from 'child_process';
import { detectAntiPatterns, ANTIPATTERNS, walkDir, SCANNABLE_EXTENSIONS } from '../source/skills/critique/scripts/detect-antipatterns.mjs';
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
const SCRIPT = path.join(import.meta.dir, '..', 'source', 'skills', 'critique', 'scripts', 'detect-antipatterns.mjs');
// ---------------------------------------------------------------------------
// Core detection: Tailwind side-tab
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — Tailwind side-tab', () => {
test('detects border-l-4 (always, thick enough)', () => {
const findings = detectAntiPatterns('<div class="border-l-4 border-blue-500">', 'test.html');
expect(findings).toHaveLength(1);
expect(findings[0].antipattern).toBe('side-tab');
expect(findings[0].snippet).toBe('border-l-4');
});
test('detects border-e-8 (always, thick enough)', () => {
const findings = detectAntiPatterns('<div class="border-e-8 border-red-500">', 'test.html');
expect(findings).toHaveLength(1);
expect(findings[0].snippet).toBe('border-e-8');
});
test('ignores border-r-2 without rounded (below threshold)', () => {
const findings = detectAntiPatterns('<div class="border-r-2 border-red-400">', 'test.html');
expect(findings).toHaveLength(0);
});
test('detects border-r-2 WITH rounded (context-aware)', () => {
const findings = detectAntiPatterns('<div class="border-r-2 border-red-400 rounded-lg">', 'test.html');
expect(findings).toHaveLength(1);
expect(findings[0].snippet).toBe('border-r-2');
});
test('detects border-l-1 with rounded (even thin borders)', () => {
const findings = detectAntiPatterns('<div class="border-l-1 border-blue-500 rounded-md">', 'test.html');
expect(findings).toHaveLength(1);
});
test('ignores border-l-1 without rounded', () => {
const findings = detectAntiPatterns('<div class="border-l-1 border-gray-300">', 'test.html');
expect(findings).toHaveLength(0);
});
test('ignores border-l-0', () => {
const findings = detectAntiPatterns('<div class="border-l-0">', 'test.html');
expect(findings).toHaveLength(0);
});
test('detects multiple on same line', () => {
const findings = detectAntiPatterns('<div class="border-l-4 border-r-4">', 'test.html');
expect(findings).toHaveLength(2);
});
test('does not flag border-t or border-b without rounded', () => {
const findings = detectAntiPatterns('<div class="border-t-4 border-b-4">', 'test.html');
expect(findings).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Context-aware detection: rounded corners
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — rounded context', () => {
test('border-s-2 + rounded-xl triggers', () => {
const findings = detectAntiPatterns('<div class="border-s-2 border-amber-500 rounded-xl">', 'test.html');
expect(findings).toHaveLength(1);
});
test('border-l-3 + rounded triggers', () => {
const findings = detectAntiPatterns('<div class="border-l-3 rounded bg-white">', 'test.html');
expect(findings).toHaveLength(1);
});
test('border-l-3 without rounded does not trigger', () => {
const findings = detectAntiPatterns('<div class="border-l-3 bg-white">', 'test.html');
expect(findings).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Safe element exclusions
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — safe elements', () => {
test('skips blockquote', () => {
const findings = detectAntiPatterns('<blockquote style="border-left: 4px solid #ccc;">', 'test.html');
expect(findings).toHaveLength(0);
});
test('skips nav link', () => {
const findings = detectAntiPatterns('<a href="#" style="border-left: 3px solid blue;">', 'test.html');
expect(findings).toHaveLength(0);
});
test('skips input', () => {
const findings = detectAntiPatterns('<input style="border-left: 3px solid red; border-radius: 6px;">', 'test.html');
expect(findings).toHaveLength(0);
});
test('skips code/pre', () => {
const findings = detectAntiPatterns('<code style="border-left: 3px solid green;">', 'test.html');
expect(findings).toHaveLength(0);
});
test('skips span (code diff lines)', () => {
const findings = detectAntiPatterns('<span style="border-left: 3px solid #ef4444;">', 'test.html');
expect(findings).toHaveLength(0);
});
test('does NOT skip div (still flags)', () => {
const findings = detectAntiPatterns('<div style="border-left: 4px solid blue;">', 'test.html');
expect(findings).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// Core detection: CSS shorthand
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — CSS shorthand', () => {
test('detects border-left: Npx solid', () => {
const findings = detectAntiPatterns('.card { border-left: 4px solid #3b82f6; }', 'test.css');
expect(findings).toHaveLength(1);
expect(findings[0].snippet).toContain('border-left');
});
test('detects border-right: Npx solid', () => {
const findings = detectAntiPatterns('.card { border-right: 5px solid purple; }', 'test.css');
expect(findings).toHaveLength(1);
});
test('ignores border-left: 2px solid (below threshold)', () => {
const findings = detectAntiPatterns('.card { border-left: 2px solid blue; }', 'test.css');
expect(findings).toHaveLength(0);
});
test('ignores border-top: 4px solid', () => {
const findings = detectAntiPatterns('.card { border-top: 4px solid blue; }', 'test.css');
expect(findings).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Core detection: CSS longhand
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — CSS longhand', () => {
test('detects border-left-width: Npx', () => {
const findings = detectAntiPatterns('.card { border-left-width: 3px; }', 'test.css');
expect(findings).toHaveLength(1);
});
test('detects border-right-width: Npx', () => {
const findings = detectAntiPatterns('.card { border-right-width: 6px; }', 'test.css');
expect(findings).toHaveLength(1);
});
test('ignores border-left-width: 2px', () => {
const findings = detectAntiPatterns('.card { border-left-width: 2px; }', 'test.css');
expect(findings).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Core detection: CSS logical properties
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — CSS logical properties', () => {
test('detects border-inline-start: Npx solid', () => {
const findings = detectAntiPatterns('.card { border-inline-start: 4px solid gold; }', 'test.css');
expect(findings).toHaveLength(1);
});
test('detects border-inline-end: Npx solid', () => {
const findings = detectAntiPatterns('.card { border-inline-end: 3px solid pink; }', 'test.css');
expect(findings).toHaveLength(1);
});
test('detects border-inline-start-width: Npx', () => {
const findings = detectAntiPatterns('.card { border-inline-start-width: 5px; }', 'test.css');
expect(findings).toHaveLength(1);
});
test('detects border-inline-end-width: Npx', () => {
const findings = detectAntiPatterns('.card { border-inline-end-width: 4px; }', 'test.css');
expect(findings).toHaveLength(1);
});
test('ignores border-inline-start: 2px solid', () => {
const findings = detectAntiPatterns('.card { border-inline-start: 2px solid blue; }', 'test.css');
expect(findings).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Core detection: JSX inline styles
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — JSX inline styles', () => {
test('detects borderLeft with px value', () => {
const findings = detectAntiPatterns('borderLeft: "4px solid #3b82f6"', 'test.jsx');
expect(findings).toHaveLength(1);
});
test('detects borderRight with px value', () => {
const findings = detectAntiPatterns("borderRight: '5px solid purple'", 'test.tsx');
expect(findings).toHaveLength(1);
});
test('ignores borderLeft: 2px (below threshold)', () => {
const findings = detectAntiPatterns('borderLeft: "2px solid blue"', 'test.jsx');
expect(findings).toHaveLength(0);
});
test('ignores borderTop', () => {
const findings = detectAntiPatterns('borderTop: "4px solid blue"', 'test.jsx');
expect(findings).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Top/bottom + rounded detection
// ---------------------------------------------------------------------------
describe('detectAntiPatterns — border accent on rounded', () => {
test('border-t-4 + rounded-lg triggers', () => {
const findings = detectAntiPatterns('<div class="border-t-4 border-blue-500 rounded-lg">', 'test.html');
expect(findings).toHaveLength(1);
expect(findings[0].antipattern).toBe('border-accent-on-rounded');
});
test('border-b-2 + rounded-xl triggers', () => {
const findings = detectAntiPatterns('<div class="border-b-2 border-purple-500 rounded-xl">', 'test.html');
expect(findings).toHaveLength(1);
});
test('border-t-1 + rounded triggers (even thin)', () => {
const findings = detectAntiPatterns('<div class="border-t-1 border-emerald-500 rounded-md">', 'test.html');
expect(findings).toHaveLength(1);
});
test('border-t-4 WITHOUT rounded does not trigger', () => {
const findings = detectAntiPatterns('<div class="border-t-4 border-blue-500">', 'test.html');
expect(findings).toHaveLength(0);
});
test('border-b-4 WITHOUT rounded does not trigger', () => {
const findings = detectAntiPatterns('<div class="border-b-4 border-purple-500">', 'test.html');
expect(findings).toHaveLength(0);
});
test('CSS border-top + border-radius on same line triggers', () => {
const findings = detectAntiPatterns('<div style="border-top: 4px solid blue; border-radius: 12px;">', 'test.html');
expect(findings).toHaveLength(1);
expect(findings[0].antipattern).toBe('border-accent-on-rounded');
});
test('CSS border-bottom + border-radius on same line triggers', () => {
const findings = detectAntiPatterns('<div style="border-bottom: 3px solid purple; border-radius: 8px;">', 'test.html');
expect(findings).toHaveLength(1);
});
test('CSS border-top WITHOUT border-radius does not trigger', () => {
const findings = detectAntiPatterns('.section { border-top: 4px solid blue; }', 'test.css');
expect(findings).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Fixture files
// ---------------------------------------------------------------------------
describe('fixture file scanning', () => {
test('should-flag.html detects side-tabs and accent borders', () => {
const content = fs.readFileSync(path.join(FIXTURES, 'should-flag.html'), 'utf-8');
const findings = detectAntiPatterns(content, 'should-flag.html');
// Tailwind (5) + CSS (7) + top/bottom Tailwind (3) + top/bottom CSS (2)
expect(findings.length).toBeGreaterThanOrEqual(13);
expect(findings.some(f => f.antipattern === 'side-tab')).toBe(true);
expect(findings.some(f => f.antipattern === 'border-accent-on-rounded')).toBe(true);
});
test('should-pass.html has zero findings', () => {
const content = fs.readFileSync(path.join(FIXTURES, 'should-pass.html'), 'utf-8');
const findings = detectAntiPatterns(content, 'should-pass.html');
expect(findings).toHaveLength(0);
});
test('legitimate-borders.html has minimal false positives', () => {
const content = fs.readFileSync(path.join(FIXTURES, 'legitimate-borders.html'), 'utf-8');
const findings = detectAntiPatterns(content, 'legitimate-borders.html');
// Alert banner (colored left border on div) is an acceptable true positive
// Blockquotes, nav, inputs, code spans, timeline (gray) should be skipped
expect(findings.length).toBeLessThanOrEqual(1);
});
});
// ---------------------------------------------------------------------------
// Finding structure
// ---------------------------------------------------------------------------
describe('finding structure', () => {
test('finding has all required fields', () => {
const findings = detectAntiPatterns('<div class="border-l-4 border-blue-500">', 'app.html');
expect(findings).toHaveLength(1);
const f = findings[0];
expect(f.antipattern).toBe('side-tab');
expect(f.name).toBe('Side-tab accent border');
expect(f.description).toBeTypeOf('string');
expect(f.file).toBe('app.html');
expect(f.line).toBe(1);
expect(f.snippet).toBe('border-l-4');
});
test('reports correct line numbers', () => {
const content = 'line 1\nline 2\n<div class="border-l-4">\nline 4';
const findings = detectAntiPatterns(content, 'test.html');
expect(findings).toHaveLength(1);
expect(findings[0].line).toBe(3);
});
});
// ---------------------------------------------------------------------------
// ANTIPATTERNS registry
// ---------------------------------------------------------------------------
describe('ANTIPATTERNS registry', () => {
test('has at least two entries', () => {
expect(ANTIPATTERNS.length).toBeGreaterThanOrEqual(2);
});
test('each entry has required fields', () => {
for (const ap of ANTIPATTERNS) {
expect(ap.id).toBeTypeOf('string');
expect(ap.name).toBeTypeOf('string');
expect(ap.description).toBeTypeOf('string');
expect(ap.matchers).toBeArray();
expect(ap.matchers.length).toBeGreaterThan(0);
}
});
});
// ---------------------------------------------------------------------------
// walkDir
// ---------------------------------------------------------------------------
describe('walkDir', () => {
test('finds scannable files', () => {
const files = walkDir(FIXTURES);
expect(files.length).toBeGreaterThanOrEqual(3);
expect(files.every(f => SCANNABLE_EXTENSIONS.has(path.extname(f)))).toBe(true);
});
test('returns empty array for nonexistent dir', () => {
const files = walkDir('/nonexistent/path/12345');
expect(files).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// CLI integration
// ---------------------------------------------------------------------------
describe('CLI', () => {
function run(...args) {
const result = spawnSync('node', [SCRIPT, ...args], {
encoding: 'utf-8',
timeout: 10000,
});
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status };
}
test('--help exits 0 and shows usage', () => {
const { stdout, code } = run('--help');
expect(code).toBe(0);
expect(stdout).toContain('Usage:');
});
test('clean file exits 0', () => {
const { code } = run(path.join(FIXTURES, 'should-pass.html'));
expect(code).toBe(0);
});
test('file with anti-patterns exits 2', () => {
const { code, stderr } = run(path.join(FIXTURES, 'should-flag.html'));
expect(code).toBe(2);
expect(stderr).toContain('side-tab');
});
test('--json outputs valid JSON array', () => {
const { stderr, code } = run('--json', path.join(FIXTURES, 'should-flag.html'));
expect(code).toBe(2);
const parsed = JSON.parse(stderr.trim());
expect(parsed).toBeArray();
expect(parsed.length).toBeGreaterThan(0);
expect(parsed[0].antipattern).toBe('side-tab');
});
test('--json on clean file outputs empty array on stdout', () => {
const { stdout, code } = run('--json', path.join(FIXTURES, 'should-pass.html'));
expect(code).toBe(0);
expect(JSON.parse(stdout.trim())).toEqual([]);
});
test('scans directory recursively', () => {
const { code, stderr } = run(FIXTURES);
expect(code).toBe(2);
expect(stderr).toContain('anti-pattern');
});
test('warns on nonexistent path', () => {
const { stderr } = run('/nonexistent/file/xyz.html');
expect(stderr).toContain('Warning');
});
});
+113
View File
@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Legitimate Border Patterns — Should NOT Flag</title>
<style>
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; color: #111827; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.125rem; margin: 2rem 0 0.75rem; color: #6b7280; }
.demo { max-width: 36rem; margin-bottom: 1.5rem; }
</style>
</head>
<body>
<h1>Legitimate Border Patterns</h1>
<p style="color: #6b7280; margin-bottom: 2rem;">Every border here is a well-established web pattern. None should be flagged.</p>
<!-- 1. BLOCKQUOTE — the classic border-left usage -->
<h2>Blockquotes</h2>
<div class="demo">
<blockquote style="border-left: 4px solid #d1d5db; padding-left: 1rem; margin: 0; color: #4b5563; font-style: italic;">
"Design is not just what it looks like and feels like. Design is how it works."
</blockquote>
</div>
<!-- 2. SIDEBAR NAV — active state indicator -->
<h2>Sidebar Navigation (Active State)</h2>
<div class="demo">
<nav style="width: 200px; background: white; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
<a href="#" style="display: block; padding: 0.75rem 1rem; color: #6b7280; text-decoration: none; border-left: 3px solid transparent;">Dashboard</a>
<a href="#" style="display: block; padding: 0.75rem 1rem; color: #1d4ed8; text-decoration: none; border-left: 3px solid #3b82f6; background: #eff6ff; font-weight: 500;">Projects</a>
<a href="#" style="display: block; padding: 0.75rem 1rem; color: #6b7280; text-decoration: none; border-left: 3px solid transparent;">Settings</a>
</nav>
</div>
<!-- 3. FORM VALIDATION — error indicator on input -->
<h2>Form Validation</h2>
<div class="demo">
<div style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">Email</label>
<input type="email" value="not-an-email" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #fca5a5; border-left: 3px solid #ef4444; border-radius: 6px; outline: none; font-size: 0.875rem;">
<p style="color: #ef4444; font-size: 0.75rem; margin-top: 0.25rem;">Please enter a valid email address.</p>
</div>
</div>
<!-- 4. TIMELINE / STEPPER -->
<h2>Timeline</h2>
<div class="demo">
<div style="border-left: 3px solid #e5e7eb; padding-left: 1.5rem; margin-left: 0.75rem;">
<div style="position: relative; padding-bottom: 1.5rem;">
<div style="position: absolute; left: -2.05rem; top: 0.125rem; width: 10px; height: 10px; border-radius: 50%; background: #3b82f6; border: 2px solid white;"></div>
<p style="font-weight: 500; margin: 0;">Order placed</p>
<p style="font-size: 0.75rem; color: #6b7280; margin: 0.125rem 0 0;">March 15, 2026</p>
</div>
<div style="position: relative; padding-bottom: 1.5rem;">
<div style="position: absolute; left: -2.05rem; top: 0.125rem; width: 10px; height: 10px; border-radius: 50%; background: #3b82f6; border: 2px solid white;"></div>
<p style="font-weight: 500; margin: 0;">Shipped</p>
<p style="font-size: 0.75rem; color: #6b7280; margin: 0.125rem 0 0;">March 16, 2026</p>
</div>
<div style="position: relative;">
<div style="position: absolute; left: -2.05rem; top: 0.125rem; width: 10px; height: 10px; border-radius: 50%; background: #d1d5db; border: 2px solid white;"></div>
<p style="font-weight: 500; margin: 0; color: #9ca3af;">Delivered</p>
<p style="font-size: 0.75rem; color: #6b7280; margin: 0.125rem 0 0;">Expected March 18</p>
</div>
</div>
</div>
<!-- 5. CODE BLOCK — diff highlighting -->
<h2>Code Diff Highlighting</h2>
<div class="demo">
<pre style="background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; font-size: 0.8125rem; overflow-x: auto; margin: 0;">
<code><span style="display: block; border-left: 3px solid #ef4444; padding-left: 0.75rem; background: rgba(239,68,68,0.1);">- const old = getValue();</span>
<span style="display: block; border-left: 3px solid #22c55e; padding-left: 0.75rem; background: rgba(34,197,94,0.1);">+ const value = getNewValue();</span>
<span style="display: block; padding-left: calc(3px + 0.75rem);"> return value;</span></code></pre>
</div>
<!-- 6. TAB ACTIVE STATE — bottom border -->
<h2>Tab Navigation</h2>
<div class="demo">
<div style="display: flex; border-bottom: 1px solid #e5e7eb;">
<button style="padding: 0.75rem 1.25rem; border: none; background: none; font-size: 0.875rem; color: #1d4ed8; border-bottom: 2px solid #3b82f6; font-weight: 500; cursor: pointer;">Overview</button>
<button style="padding: 0.75rem 1.25rem; border: none; background: none; font-size: 0.875rem; color: #6b7280; border-bottom: 2px solid transparent; cursor: pointer;">Analytics</button>
<button style="padding: 0.75rem 1.25rem; border: none; background: none; font-size: 0.875rem; color: #6b7280; border-bottom: 2px solid transparent; cursor: pointer;">Settings</button>
</div>
</div>
<!-- 7. TABLE CELL BORDERS -->
<h2>Data Table</h2>
<div class="demo">
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
<thead>
<tr>
<th style="text-align: left; padding: 0.5rem; border-bottom: 2px solid #111827;">Name</th>
<th style="text-align: right; padding: 0.5rem; border-bottom: 2px solid #111827;">Revenue</th>
</tr>
</thead>
<tbody>
<tr><td style="padding: 0.5rem; border-bottom: 1px solid #e5e7eb;">Acme Corp</td><td style="text-align: right; padding: 0.5rem; border-bottom: 1px solid #e5e7eb;">$1.2M</td></tr>
<tr><td style="padding: 0.5rem; border-bottom: 1px solid #e5e7eb;">Globex</td><td style="text-align: right; padding: 0.5rem; border-bottom: 1px solid #e5e7eb;">$850K</td></tr>
</tbody>
</table>
</div>
<!-- 8. ALERT BANNER — full-width, not a card -->
<h2>Alert Banner</h2>
<div class="demo">
<div style="border-left: 4px solid #f59e0b; background: #fffbeb; padding: 0.75rem 1rem; font-size: 0.875rem; color: #92400e;">
<strong>Warning:</strong> Your trial expires in 3 days. <a href="#" style="color: #d97706;">Upgrade now</a>
</div>
</div>
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+136
View File
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anti-Patterns That Should Be Flagged</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; }
.cards { display: grid; gap: 1rem; max-width: 28rem; }
.card { background: white; padding: 1rem; border-radius: 0.375rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card h3 { font-weight: 600; color: #111827; font-size: 0.875rem; }
.card p { font-size: 0.8125rem; color: #6b7280; margin-top: 0.25rem; }
/* CSS side-tab variants */
.card-shorthand-left { border-left: 4px solid #3b82f6; }
.card-shorthand-right { border-right: 5px solid #8b5cf6; }
.card-longhand-left { border-left-width: 3px; border-left-style: solid; border-left-color: #10b981; }
.card-longhand-right { border-right-width: 6px; border-right-style: solid; border-right-color: #ef4444; }
.card-logical-start { border-inline-start: 4px solid #f59e0b; }
.card-logical-end { border-inline-end: 3px solid #ec4899; }
.card-logical-start-width { border-inline-start-width: 5px; border-inline-start-style: solid; border-inline-start-color: #06b6d4; }
/* CSS top/bottom + border-radius */
.card-css-top { border-radius: 12px; border-top: 4px solid #3b82f6; }
.card-css-bottom { border-radius: 12px; border-bottom: 3px solid #8b5cf6; }
</style>
</head>
<body>
<h1>Anti-Patterns: Should Flag</h1>
<p class="intro">Every example on this page should be detected by the anti-pattern scanner.</p>
<!-- TAILWIND SIDE-TAB -->
<h2>Tailwind Side-Tab</h2>
<div class="cards">
<div class="border-l-4 border-blue-500 bg-white p-4 rounded-r shadow-sm">
<h3>border-l-4 + rounded-r</h3>
<p>The classic AI tell.</p>
</div>
<div class="border-l-2 border-emerald-500 bg-white p-4 rounded-r shadow-sm">
<h3>border-l-2 + rounded-r</h3>
<p>Thin but still recognizable with rounded corners.</p>
</div>
<div class="border-r-4 border-purple-500 bg-white p-4 rounded-l shadow-sm">
<h3>border-r-4 + rounded-l</h3>
<p>Right side variant.</p>
</div>
<div class="border-s-3 border-amber-500 bg-white p-4 rounded-r shadow-sm">
<h3>border-s-3 + rounded-r</h3>
<p>Logical inline-start.</p>
</div>
<div class="border-e-8 border-red-500 bg-white p-4 rounded-l shadow-sm">
<h3>border-e-8 + rounded-l</h3>
<p>Logical inline-end, extra thick.</p>
</div>
</div>
<!-- CSS SIDE-TAB -->
<h2>CSS Side-Tab</h2>
<div class="cards">
<div class="card card-shorthand-left">
<h3>border-left: 4px solid</h3>
<p>CSS shorthand.</p>
</div>
<div class="card card-shorthand-right">
<h3>border-right: 5px solid</h3>
<p>CSS shorthand right.</p>
</div>
<div class="card card-longhand-left">
<h3>border-left-width: 3px</h3>
<p>CSS longhand.</p>
</div>
<div class="card card-longhand-right">
<h3>border-right-width: 6px</h3>
<p>CSS longhand right.</p>
</div>
<div class="card card-logical-start">
<h3>border-inline-start: 4px solid</h3>
<p>CSS logical start.</p>
</div>
<div class="card card-logical-end">
<h3>border-inline-end: 3px solid</h3>
<p>CSS logical end.</p>
</div>
<div class="card card-logical-start-width">
<h3>border-inline-start-width: 5px</h3>
<p>CSS logical longhand.</p>
</div>
</div>
<!-- TOP/BOTTOM + ROUNDED -->
<h2>Top/Bottom + Rounded</h2>
<div class="cards">
<div class="border-t-4 border-blue-500 rounded-lg bg-white p-4 shadow-sm">
<h3 class="font-semibold text-sm">border-t-4 + rounded-lg</h3>
<p class="text-sm text-gray-600">Top accent on rounded card.</p>
</div>
<div class="border-b-4 border-purple-500 rounded-xl bg-white p-4 shadow-sm">
<h3 class="font-semibold text-sm">border-b-4 + rounded-xl</h3>
<p class="text-sm text-gray-600">Bottom accent on rounded card.</p>
</div>
<div class="border-t-2 border-emerald-500 rounded-md bg-white p-4 shadow-sm">
<h3 class="font-semibold text-sm">border-t-2 + rounded-md</h3>
<p class="text-sm text-gray-600">Even thin top border on rounded.</p>
</div>
<div class="card card-css-top">
<h3>CSS border-top + border-radius</h3>
<p>Top border from style block.</p>
</div>
<div class="card card-css-bottom">
<h3>CSS border-bottom + border-radius</h3>
<p>Bottom border from style block.</p>
</div>
</div>
<!-- DARK MODE VARIANTS -->
<h2>Dark Mode</h2>
<div class="cards">
<div class="border-l-4 border-cyan-400 rounded-lg bg-gray-800 p-4 shadow-sm">
<h3 class="font-semibold text-sm text-white">Dark card + border-l-4 + rounded</h3>
<p class="text-sm text-gray-400">Dark background doesn't make the side-tab OK.</p>
</div>
<div class="border-t-2 border-violet-500 rounded-xl bg-slate-900 p-4 shadow-sm">
<h3 class="font-semibold text-sm text-white">Dark card + border-t-2 + rounded</h3>
<p class="text-sm text-gray-400">Top accent on dark rounded card.</p>
</div>
<div style="background: #1f2937; padding: 1rem; border-radius: 12px; border-left: 4px solid #f472b6; max-width: 28rem;">
<h3 style="font-weight: 600; font-size: 0.875rem; color: white;">Dark CSS card + side border + radius</h3>
<p style="font-size: 0.8125rem; color: #9ca3af;">Inline dark card with side-tab.</p>
</div>
</div>
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clean Patterns — Should NOT Flag</title>
<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; }
.section { max-width: 28rem; }
.card-rounded { background: white; padding: 1.5rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); max-width: 28rem; margin-bottom: 1rem; }
.card-rounded h3 { font-weight: 600; color: #111827; }
.card-rounded p { font-size: 0.875rem; color: #6b7280; margin-top: 0.25rem; }
.card-flat { background: white; padding: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); max-width: 28rem; margin-bottom: 1rem; }
.badge { display: inline-block; font-size: 0.75rem; padding: 0.125rem 0.5rem; border-radius: 9999px; background: #dbeafe; color: #1d4ed8; }
/* Below-threshold side borders without radius */
.thin-left { border-left: 1px solid #3b82f6; }
.thin-right { border-right: 2px solid #8b5cf6; }
</style>
</head>
<body>
<h1>Clean Patterns: Should Pass</h1>
<p class="intro">None of these should be flagged. They're all intentional, well-established web patterns.</p>
<!-- CLEAN CARDS -->
<h2>Clean Cards</h2>
<div class="section">
<div class="card-rounded" style="border: 1px solid #e5e7eb;">
<h3>Full border card</h3>
<p>Subtle 1px border all around. Clean and intentional.</p>
</div>
<div style="max-width: 28rem; margin-bottom: 1rem; padding: 1.5rem; background: white; border-top: 3px solid #3b82f6; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h3 style="font-weight: 600; color: #111827;">Top border, no radius</h3>
<p style="font-size: 0.875rem; color: #6b7280; margin-top: 0.25rem;">Top accent without rounded corners is a clean section divider.</p>
</div>
<div style="max-width: 28rem; margin-bottom: 1rem; padding: 1.5rem; background: white; border-bottom: 2px solid #10b981; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h3 style="font-weight: 600; color: #111827;">Bottom border, no radius</h3>
<p style="font-size: 0.875rem; color: #6b7280; margin-top: 0.25rem;">Bottom accent without rounded corners is also clean.</p>
</div>
<div class="card-rounded">
<span class="badge">New</span>
<h3>No border at all</h3>
<p>Just a shadow. Simple and effective.</p>
</div>
</div>
<!-- BELOW THRESHOLD -->
<h2>Below Threshold (Thin, No Radius)</h2>
<div class="section">
<div class="card-flat thin-left">
<p>border-left: 1px solid, no radius — not flagged</p>
</div>
<div class="card-flat thin-right">
<p>border-right: 2px solid, no radius — not flagged</p>
</div>
<div class="card-flat" style="border-left: 1px solid #10b981;">
<p>1px inline border-left, no radius — not flagged</p>
</div>
</div>
<!-- DARK MODE CLEAN -->
<h2>Dark Mode (Clean)</h2>
<div class="section">
<div style="background: #1f2937; padding: 1.5rem; border-radius: 0.5rem; border: 1px solid #374151; max-width: 28rem; margin-bottom: 1rem;">
<h3 style="font-weight: 600; color: white;">Dark card, full border</h3>
<p style="font-size: 0.875rem; color: #9ca3af; margin-top: 0.25rem;">Uniform 1px border all around. Clean.</p>
</div>
<div style="background: #111827; padding: 1.5rem; max-width: 28rem; margin-bottom: 1rem; border-bottom: 2px solid #6366f1;">
<h3 style="font-weight: 600; color: white;">Dark section, bottom border, no radius</h3>
<p style="font-size: 0.875rem; color: #9ca3af; margin-top: 0.25rem;">Bottom accent without radius is fine.</p>
</div>
<div style="background: #1e293b; padding: 1.5rem; border-radius: 0.5rem; max-width: 28rem; margin-bottom: 1rem; box-shadow: 0 4px 6px rgba(0,0,0,0.3);">
<h3 style="font-weight: 600; color: white;">Dark card, no border</h3>
<p style="font-size: 0.875rem; color: #94a3b8; margin-top: 0.25rem;">Shadow only. Clean.</p>
</div>
</div>
<script src="../../../public/js/detect-antipatterns-browser.js"></script>
</body>
</html>