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>
@@ -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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 968 KiB |
|
Before Width: | Height: | Size: 952 KiB After Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 170 KiB |
@@ -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>
|
||||
@@ -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 →</a></p>
|
||||
<p class="contribute-inline">Want to see these in action? <a href="/gallery" class="cheatsheet-link">Gallery of Shame →</a> Missing something? <a href="https://github.com/pbakaus/impeccable/issues/new?labels=pattern&title=Pattern%20suggestion%3A%20">Suggest a pattern →</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>
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||