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 + ? `` + : ''; + const ruleIdDisplay = rule.layer === 'llm' ? '' : `${escapeHtml(rule.id)}`; return ` -
-
- ${escapeHtml(rule.id)} - ${categoryLabel} +
+ ${visual} +
+
+ ${ruleIdDisplay} + + ${categoryLabel} + ${escapeHtml(layerLabel)} + +
+

${escapeHtml(rule.name)}

+

${escapeHtml(rule.description)}

+ ${skillLink}
-

${escapeHtml(rule.name)}

-

${escapeHtml(rule.description)}

- ${skillLink}
`; } +function escapeAttr(str) { + return String(str || '').replace(/"/g, '"'); +} + /** * Render the /tutorials index main content. */ @@ -439,17 +476,27 @@ ${rules.map(renderRuleCard).join('\n')} `; } + const detectedCount = grouped.order + .flatMap((s) => grouped.bySection[s] || []) + .filter((r) => r.layer !== 'llm').length; + const llmCount = totalRules - detectedCount; + return `
-

${totalRules} detection rules

+

${totalRules} rules

Anti-patterns

-

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');