diff --git a/content/site/anti-patterns-catalog.js b/content/site/anti-patterns-catalog.js
new file mode 100644
index 000000000..ab93c3b9f
--- /dev/null
+++ b/content/site/anti-patterns-catalog.js
@@ -0,0 +1,256 @@
+/**
+ * Manual metadata for the /anti-patterns page.
+ *
+ * The detection rules themselves live in src/detect-antipatterns.mjs and
+ * are parsed at build time. This file adds three pieces of content that
+ * can't be automated:
+ *
+ * 1. DETECTION_LAYERS — which layer (cli, browser, or llm) catches the
+ * rule. Manually classified by reading the detector source and the
+ * browser-only test file.
+ *
+ * 2. VISUAL_EXAMPLES — a tiny inline HTML snippet showing what the
+ * bad pattern actually looks like. Rendered inside each rule card.
+ * Snippets should be self-contained with inline styles, use the
+ * cream/paper/ink palette when possible, and sit naturally at
+ * ~100% width × ~120px height.
+ *
+ * 3. LLM_ONLY_RULES — DON'T lines from source/skills/impeccable/SKILL.md
+ * that don't map to any detection rule. These can only be caught by
+ * the /critique skill's LLM pass. They appear on the /anti-patterns
+ * page alongside detected rules with an 'llm' layer badge.
+ */
+
+// ─── Detection layers ────────────────────────────────────────────────
+
+/**
+ * Which layer catches each rule.
+ *
+ * 'cli' — static analysis or jsdom (works with `npx impeccable detect`
+ * on files, no browser required)
+ * 'browser' — requires real browser layout (getBoundingClientRect with
+ * actual dimensions). Works via Puppeteer or the browser
+ * extension, NOT via the CLI on raw HTML.
+ * 'llm' — no deterministic detector; only caught by /critique's LLM
+ * assessment pass.
+ *
+ * Per tests/detect-antipatterns-browser.test.mjs: only two rules genuinely
+ * need real browser layout. Everything else is 'cli'.
+ */
+export const DETECTION_LAYERS = {
+ 'side-tab': 'cli',
+ 'border-accent-on-rounded': 'cli',
+ 'overused-font': 'cli',
+ 'single-font': 'cli',
+ 'flat-type-hierarchy': 'cli',
+ 'icon-tile-stack': 'cli',
+ 'gradient-text': 'cli',
+ 'ai-color-palette': 'cli',
+ 'dark-glow': 'cli',
+ 'nested-cards': 'cli',
+ 'monotonous-spacing': 'cli',
+ 'everything-centered': 'cli',
+ 'bounce-easing': 'cli',
+ 'all-caps-body': 'cli',
+ 'pure-black-white': 'cli',
+ 'gray-on-color': 'cli',
+ 'low-contrast': 'cli',
+ 'layout-transition': 'cli',
+ 'tight-leading': 'cli',
+ 'skipped-heading': 'cli',
+ 'justified-text': 'cli',
+ 'tiny-text': 'cli',
+ 'wide-tracking': 'cli',
+ // Browser-only: need real layout measurements.
+ 'cramped-padding': 'browser',
+ 'line-length': 'browser',
+};
+
+export const LAYER_LABELS = {
+ cli: 'CLI',
+ browser: 'Browser',
+ llm: 'LLM only',
+};
+
+export const LAYER_DESCRIPTIONS = {
+ cli: 'Deterministic. Runs from `npx impeccable detect` on files, no browser required.',
+ browser: 'Deterministic, but needs real browser layout. Runs via the browser extension or Puppeteer, not the plain CLI.',
+ llm: 'Not caught by any deterministic detector. Flagged by /critique during its LLM design review.',
+};
+
+// ─── Visual examples ─────────────────────────────────────────────────
+
+/**
+ * One tiny inline HTML snippet per rule showing what the bad pattern
+ * looks like. Snippets use inline styles only and are sized to fit the
+ * rule card preview area (~100% wide, ~120px tall).
+ */
+export const VISUAL_EXAMPLES = {
+ 'side-tab': `
Alert title
Thick colored stripe on one side.
`,
+
+ 'border-accent-on-rounded': `
Rounded card
Thick colored border clashes with the radius.
`,
+
+ 'overused-font': `
Just another Inter headline
Every SaaS homepage looks like this.
`,
+
+ 'single-font': `
Heading in the body font
Body in the same font. No contrast. Flat.
`,
+
+ 'flat-type-hierarchy': `
Heading
Subheading
Body text at almost the same size.
`,
+
+ 'icon-tile-stack': `
✦
Feature name
Rounded icon tile above heading.
`,
+
+ 'gradient-text': `
Build the Future
Gradient text kills scannability.
`,
+
+ 'ai-color-palette': `
`,
+
+ 'dark-glow': `
Neon on dark
Cyberpunk-by-default slop.
`,
+
+ 'nested-cards': `
Card inside card inside card.
`,
+
+ 'monotonous-spacing': `
`,
+
+ 'everything-centered': `
Centered headline
Everything centered by default.
Call to action
`,
+
+ 'bounce-easing': `
Bounce + elastic easing feels dated.
`,
+
+ 'all-caps-body': `
Long passages in uppercase are hard to read. We recognize words by their shape, which all-caps removes.
`,
+
+ 'pure-black-white': `
Pure black on pure white
Neither exists in nature. Always tint.
`,
+
+ 'gray-on-color': `
Gray text on a colored background. Washed out and hard to read.
`,
+
+ 'low-contrast': `
Light gray text on a white background. 1.6:1 contrast, fails WCAG.
`,
+
+ 'layout-transition': `
Animating width/height causes layout jank.
`,
+
+ 'cramped-padding': `
2px vertical padding.
`,
+
+ 'tight-leading': `
Tight leading makes multi-line body text feel crammed and hard for the eye to track between lines.
`,
+
+ 'skipped-heading': `
Page title (h1)
Subsection (h3) — skipped h2
`,
+
+ 'justified-text': `
Justified text on screens creates rivers of whitespace because browsers can't hyphenate well. Leave this for print.
`,
+
+ 'tiny-text': `
Regular body text
And then fine print at 9 pixels that no one will ever read.
`,
+
+ 'wide-tracking': `
Wide tracking on body text slows reading by breaking up natural character groupings.
`,
+
+ 'line-length': `
Paragraphs wider than roughly 75 characters per line become fatiguing because the eye has to track an excessive distance back to the start of the next line, losing its place.
`,
+};
+
+// ─── LLM-only rules ──────────────────────────────────────────────────
+
+/**
+ * Anti-patterns that live in the /impeccable skill's DON'T list but
+ * don't have a deterministic detector. These can only be caught by
+ * /critique running an LLM assessment pass.
+ *
+ * Each entry looks like a detection rule: id, category, name,
+ * description, skillSection. The generator merges these into the
+ * grouped sections alongside detected rules with an 'llm' layer badge.
+ */
+export const LLM_ONLY_RULES = [
+ {
+ id: 'syne-display-font',
+ category: 'slop',
+ name: 'Syne as display font',
+ description:
+ 'Syne is the most overused "distinctive" display font and reads as an instant AI design tell. Pick something else.',
+ skillSection: 'Typography',
+ },
+ {
+ id: 'monospace-as-technical',
+ category: 'slop',
+ name: 'Monospace as "technical" shorthand',
+ description:
+ 'Using a monospace typeface to signal "developer / technical" vibes. Reach for real type choices instead of a lazy stereotype.',
+ skillSection: 'Typography',
+ },
+ {
+ id: 'dark-mode-default',
+ category: 'slop',
+ name: 'Defaulting to dark mode for "safety"',
+ description:
+ 'Defaulting to light mode to be safe is the inverse of defaulting to dark mode to look cool. Either way you are retreating from a decision.',
+ skillSection: 'Color & Contrast',
+ },
+ {
+ id: 'everything-in-cards',
+ category: 'slop',
+ name: 'Wrapping everything in cards',
+ description:
+ 'Not every piece of content needs a bordered container. Spacing and alignment create visual grouping without the overhead of a card.',
+ skillSection: 'Layout & Space',
+ },
+ {
+ id: 'identical-card-grids',
+ category: 'slop',
+ name: 'Identical card grids',
+ description:
+ 'Same-sized cards with icon + heading + text repeated endlessly. The default AI homepage layout.',
+ skillSection: 'Layout & Space',
+ },
+ {
+ id: 'hero-metric-layout',
+ category: 'slop',
+ name: 'Hero metric layout',
+ description:
+ 'Big number, small label, three supporting stats, gradient accent. Used everywhere, trusted nowhere.',
+ skillSection: 'Layout & Space',
+ },
+ {
+ id: 'glassmorphism',
+ category: 'slop',
+ name: 'Glassmorphism everywhere',
+ description:
+ 'Blur effects, glass cards, and glow borders used as decoration rather than to solve a real layering problem.',
+ skillSection: 'Visual Details',
+ },
+ {
+ id: 'sparkline-decoration',
+ category: 'slop',
+ name: 'Sparklines as decoration',
+ description:
+ 'Tiny charts that look sophisticated but convey no meaningful information. If the data matters, give it room.',
+ skillSection: 'Visual Details',
+ },
+ {
+ id: 'generic-drop-shadows',
+ category: 'slop',
+ name: 'Rounded rectangles with generic drop shadows',
+ description:
+ 'The safest, most forgettable shape on the web. Could be the output of any AI. Commit to a stronger visual treatment.',
+ skillSection: 'Visual Details',
+ },
+ {
+ id: 'modal-reflex',
+ category: 'slop',
+ name: 'Reaching for modals by reflex',
+ description:
+ 'Modals interrupt the user and are lazy as a design default. Use them only when there is truly no better place for the interaction.',
+ skillSection: 'Visual Details',
+ },
+ {
+ id: 'every-button-primary',
+ category: 'quality',
+ name: 'Every button is a primary button',
+ description:
+ 'When every button looks equally important, nothing reads as the primary action. Use ghost buttons, text links, and secondary styles to build hierarchy.',
+ skillSection: 'Interaction',
+ },
+ {
+ id: 'redundant-headers',
+ category: 'quality',
+ name: 'Redundant information',
+ description:
+ 'Intros that restate the heading. Section labels that repeat the page title. Cards that echo their own caption. Make every word earn its place.',
+ skillSection: 'Interaction',
+ },
+ {
+ id: 'mobile-amputation',
+ category: 'quality',
+ name: 'Amputating features on mobile',
+ description:
+ 'Hiding critical functionality on mobile because it is inconvenient. Adapt the interface to the context, do not strip it.',
+ skillSection: 'Responsive',
+ },
+];
diff --git a/public/css/sub-pages.css b/public/css/sub-pages.css
index d29691480..66de69d04 100644
--- a/public/css/sub-pages.css
+++ b/public/css/sub-pages.css
@@ -729,13 +729,12 @@ main#main {
}
.rule-card {
- padding: var(--spacing-md);
background: var(--color-paper);
border: 1px solid var(--color-mist);
- border-radius: 8px;
+ border-radius: 10px;
display: flex;
flex-direction: column;
- gap: 8px;
+ overflow: hidden;
transition: border-color var(--duration-fast) var(--ease-out);
}
@@ -743,12 +742,44 @@ main#main {
border-color: var(--color-ash);
}
+/* Visual example preview at the top of each card. */
+.rule-card-visual {
+ position: relative;
+ height: 140px;
+ background: var(--color-cream);
+ border-bottom: 1px solid var(--color-mist);
+ overflow: hidden;
+ /* The inline demo snippets often contain text they don't want to
+ inherit from the card; isolate their context with `all: revert`
+ on children via .rule-card-visual-inner. */
+}
+
+.rule-card-visual-inner {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-md);
+ /* Prevent the inline snippet's styles from bleeding outside the box. */
+ overflow: hidden;
+}
+
+.rule-card-body {
+ padding: var(--spacing-md);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ flex: 1;
+}
+
.rule-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
margin-bottom: 2px;
+ min-height: 18px;
}
.rule-card-id {
@@ -761,6 +792,13 @@ main#main {
border: none;
}
+.rule-card-badges {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
.rule-card-category {
font-family: var(--font-mono);
font-size: 0.625rem;
@@ -781,6 +819,71 @@ main#main {
background: var(--color-mist);
}
+/* Detection layer badge: CLI, Browser, or LLM only. */
+.rule-card-layer {
+ font-family: var(--font-mono);
+ font-size: 0.625rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ padding: 3px 8px;
+ border-radius: 99px;
+ border: 1px solid var(--color-mist);
+}
+
+.rule-card-layer[data-layer="cli"] {
+ color: var(--color-charcoal);
+ border-color: var(--color-mist);
+ background: var(--color-paper);
+}
+
+.rule-card-layer[data-layer="browser"] {
+ color: oklch(40% 0.12 230);
+ border-color: oklch(90% 0.05 230);
+ background: oklch(97% 0.02 230);
+}
+
+.rule-card-layer[data-layer="llm"] {
+ color: oklch(45% 0.15 45);
+ border-color: oklch(92% 0.08 45);
+ background: oklch(98% 0.03 45);
+}
+
+/* Layer legend dl inside the How-to-read block. */
+.anti-patterns-legend-layers {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ margin-top: var(--spacing-md);
+}
+
+.anti-patterns-legend-layers > div {
+ display: grid;
+ grid-template-columns: 90px 1fr;
+ gap: var(--spacing-md);
+ align-items: baseline;
+}
+
+.anti-patterns-legend-layers dt {
+ margin: 0;
+}
+
+.anti-patterns-legend-layers dd {
+ margin: 0;
+ font-size: 0.875rem;
+ line-height: 1.55;
+ color: var(--color-charcoal);
+}
+
+.anti-patterns-legend-layers dd code {
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ background: var(--color-cream);
+ border: 1px solid var(--color-mist);
+ padding: 1px 6px;
+ border-radius: 4px;
+}
+
.rule-card-name {
font-family: var(--font-body);
font-size: 1rem;
diff --git a/public/js/components/glass-terminal.js b/public/js/components/glass-terminal.js
index be239b944..716846e5f 100644
--- a/public/js/components/glass-terminal.js
+++ b/public/js/components/glass-terminal.js
@@ -82,9 +82,9 @@ function renderDesktopLayout(container, commands) {
};
// Preferred order within each category (unlisted commands append at end)
const categoryCommandOrder = {
- 'create': ['impeccable', 'shape', 'onboard', 'overdrive'],
+ 'create': ['impeccable', 'shape'],
'evaluate': ['critique', 'audit'],
- 'refine': ['typeset', 'arrange', 'colorize', 'animate', 'delight', 'bolder', 'quieter'],
+ 'refine': ['typeset', 'arrange', 'colorize', 'animate', 'delight', 'bolder', 'quieter', 'onboard', 'overdrive'],
'simplify': ['distill', 'clarify', 'adapt'],
'harden': ['normalize', 'polish', 'optimize', 'harden'],
'system': ['extract']
diff --git a/public/js/data.js b/public/js/data.js
index 8761a68fd..c19072b3d 100644
--- a/public/js/data.js
+++ b/public/js/data.js
@@ -83,7 +83,6 @@ export const commandCategories = {
'shape': 'create',
'impeccable craft': 'create',
'impeccable': 'create',
- 'overdrive': 'create',
// EVALUATE - review and assess
'critique': 'evaluate',
'audit': 'evaluate',
@@ -96,6 +95,7 @@ export const commandCategories = {
'bolder': 'refine',
'quieter': 'refine',
'onboard': 'refine',
+ 'overdrive': 'refine',
// SIMPLIFY - reduce and clarify
'distill': 'simplify',
'clarify': 'simplify',
@@ -124,7 +124,7 @@ export const commandRelationships = {
'impeccable craft': { flow: 'Create: Full shape-then-build flow with visual iteration' },
'impeccable': { flow: 'Create: Freeform design with full design intelligence' },
'onboard': { combinesWith: ['clarify', 'delight'], flow: 'Create: Onboarding flows and empty states' },
- 'overdrive': { combinesWith: ['animate', 'delight'], flow: 'Create: Technically extraordinary effects' },
+ 'overdrive': { combinesWith: ['animate', 'delight'], flow: 'Refine: Technically extraordinary effects' },
'critique': { leadsTo: ['polish', 'distill', 'bolder', 'quieter', 'typeset', 'arrange'], flow: 'Evaluate: UX and design review with scoring' },
'audit': { leadsTo: ['normalize', 'harden', 'optimize', 'adapt', 'clarify'], flow: 'Evaluate: Technical quality audit' },
'typeset': { combinesWith: ['bolder', 'normalize'], flow: 'Refine: Fix typography and type hierarchy' },
diff --git a/scripts/build-sub-pages.js b/scripts/build-sub-pages.js
index e2670218c..c009ef646 100644
--- a/scripts/build-sub-pages.js
+++ b/scripts/build-sub-pages.js
@@ -17,6 +17,8 @@ import {
CATEGORY_ORDER,
CATEGORY_LABELS,
CATEGORY_DESCRIPTIONS,
+ LAYER_LABELS,
+ LAYER_DESCRIPTIONS,
} from './lib/sub-pages-data.js';
import { renderMarkdown, slugify } from './lib/render-markdown.js';
import { renderPage } from './lib/render-page.js';
@@ -294,21 +296,28 @@ ${mainHtml}
* Rules without a skillSection fall into a 'General quality' bucket.
*/
function groupRulesBySection(rules) {
- const order = [
+ // Canonical ordering. Additional sections referenced by rules (e.g.
+ // 'Interaction', 'Responsive' from LLM-only entries) are appended to
+ // the end, before 'General quality', so every rule renders.
+ const primaryOrder = [
'Visual Details',
'Typography',
'Color & Contrast',
'Layout & Space',
'Motion',
- 'General quality',
+ 'Interaction',
+ 'Responsive',
];
const bySection = {};
- for (const name of order) bySection[name] = [];
+ for (const name of primaryOrder) bySection[name] = [];
+ bySection['General quality'] = [];
+
for (const rule of rules) {
const section = rule.skillSection || 'General quality';
if (!bySection[section]) bySection[section] = [];
bySection[section].push(rule);
}
+
// Sort each bucket: slop first (they're the named tells), then quality.
for (const name of Object.keys(bySection)) {
bySection[name].sort((a, b) => {
@@ -316,6 +325,17 @@ function groupRulesBySection(rules) {
return a.name.localeCompare(b.name);
});
}
+
+ // Final render order: primary sections first, then any extras that
+ // rules introduced, then General quality last.
+ const order = [...primaryOrder];
+ for (const name of Object.keys(bySection)) {
+ if (!order.includes(name) && name !== 'General quality') {
+ order.push(name);
+ }
+ }
+ order.push('General quality');
+
return { order, bySection };
}
@@ -352,21 +372,38 @@ ${entries}
*/
function renderRuleCard(rule) {
const categoryLabel = rule.category === 'slop' ? 'AI slop' : 'Quality';
+ const layer = rule.layer || 'cli';
+ const layerLabel = LAYER_LABELS[layer] || layer;
+ const layerTitle = LAYER_DESCRIPTIONS[layer] || '';
const skillLink = rule.skillSection
? `See in /impeccable`
: '';
+ const visual = rule.visual
+ ? `
These are the visible tells of AI-generated interfaces. Every rule in this catalog is implemented as a deterministic check in npx impeccable detect and in the browser extension. Run /critique on any page to see which ones it triggers.
+
The full catalog of patterns /impeccable teaches against. ${detectedCount} are caught by a deterministic detector (npx impeccable detect or the browser extension). ${llmCount} can only be flagged by /critique's LLM review pass.
How to read this
-
Rules are grouped by the section of the /impeccable skill that teaches the pattern to avoid. AI slop rules flag the specific visual tells (gradient text, purple palettes, side-tab borders, nested cards). Quality rules flag general design mistakes that are not AI-specific but still hurt the work.
+
AI slop rules flag the visible tells of AI-generated UIs. Quality rules flag general design mistakes that are not AI-specific but still hurt the work. Each rule also shows how it is detected:
+
+
CLI
Deterministic. Runs from npx impeccable detect on files, no browser required.
+
Browser
Deterministic, but needs real browser layout. Runs via the browser extension or Puppeteer, not the plain CLI.
+
LLM only
No deterministic detector. Caught by /critique during its LLM design review.
+
diff --git a/scripts/lib/sub-pages-data.js b/scripts/lib/sub-pages-data.js
index c402840f6..882480c7a 100644
--- a/scripts/lib/sub-pages-data.js
+++ b/scripts/lib/sub-pages-data.js
@@ -14,6 +14,13 @@ import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { readSourceFiles, parseFrontmatter } from './utils.js';
+import {
+ DETECTION_LAYERS,
+ VISUAL_EXAMPLES,
+ LLM_ONLY_RULES,
+} from '../../content/site/anti-patterns-catalog.js';
+
+export { LAYER_LABELS, LAYER_DESCRIPTIONS } from '../../content/site/anti-patterns-catalog.js';
/**
* Skills that should be excluded from the index and not get a detail page.
@@ -33,7 +40,6 @@ const SKILL_CATEGORIES = {
// CREATE - build something new
impeccable: 'create',
shape: 'create',
- overdrive: 'create',
// EVALUATE - review and assess
critique: 'evaluate',
audit: 'evaluate',
@@ -46,6 +52,7 @@ const SKILL_CATEGORIES = {
bolder: 'refine',
quieter: 'refine',
onboard: 'refine',
+ overdrive: 'refine',
// SIMPLIFY - reduce and clarify
distill: 'simplify',
clarify: 'simplify',
@@ -191,8 +198,19 @@ export async function buildSubPageData(rootDir) {
for (const cat of CATEGORY_ORDER) skillsByCategory[cat] = [];
for (const skill of skills) skillsByCategory[skill.category].push(skill);
- // Anti-pattern rules, grouped for the index.
- const rules = readAntipatternRules(rootDir);
+ // Anti-pattern rules, enriched with catalog metadata and merged with
+ // LLM-only rules from the skill's DON'T list.
+ const detectedRules = readAntipatternRules(rootDir).map((r) => ({
+ ...r,
+ layer: DETECTION_LAYERS[r.id] || 'cli',
+ visual: VISUAL_EXAMPLES[r.id] || null,
+ }));
+ const llmRules = LLM_ONLY_RULES.map((r) => ({
+ ...r,
+ layer: 'llm',
+ visual: VISUAL_EXAMPLES[r.id] || null,
+ }));
+ const rules = [...detectedRules, ...llmRules];
// Tutorials: each required file in content/site/tutorials/.
const tutorialsDir = path.join(contentDir, 'tutorials');