Overhaul /anti-patterns with visuals, detection layers, and LLM rules

Three additions to the anti-patterns catalog page, all sourced from a
new content/site/anti-patterns-catalog.js file so the user's parallel
edits to src/detect-antipatterns.mjs don't conflict with display metadata.

1. Detection layer badge per rule. Three layers:
     cli     - static analysis or jsdom. Runs from `npx impeccable detect`
               on files, no browser required. 23 of 25 current rules.
     browser - needs real browser layout (getBoundingClientRect).
               Runs via the browser extension or Puppeteer, not the
               plain CLI. Only 2 rules: cramped-padding and line-length,
               as documented in tests/detect-antipatterns-browser.test.mjs.
     llm     - no deterministic detector. Flagged by /critique's LLM
               review pass. 13 rules live only in the skill's DON'T list.
   Each card renders a mono pill with the layer label, color-coded per
   layer (neutral mist for CLI, blue tint for browser, amber tint for LLM).
   The How-to-read legend grows a dl explaining what each layer means.

2. Inline visual example per detected rule. All 25 detection rules get
   a ~140px tall preview area at the top of the card showing the bad
   pattern as live HTML (cream background, self-contained inline styles).
   Visuals for side-tab, gradient-text, dark-glow, nested-cards, and the
   rest let you see what the detector is actually flagging. LLM-only
   rules ship without visuals for now; their card bodies take the full
   card height.

3. LLM-only rules merged into the sections. Parsed out from
   source/skills/impeccable/SKILL.md DON'T lines that the detector
   doesn't cover: Syne, monospace-as-technical, dark-mode-default,
   everything-in-cards, identical-card-grids, hero-metric-layout,
   glassmorphism, sparkline-decoration, generic-drop-shadows,
   modal-reflex, every-button-primary, redundant-headers,
   mobile-amputation. Each renders like a detection rule card but
   shows the 'LLM only' layer badge and has no rule id chip. They
   slot into the same section groups as detected rules (Interaction
   and Responsive sections added to the section order so these get
   real headings).

- scripts/lib/sub-pages-data.js: imports the catalog, enriches
  detected rules with { layer, visual }, appends LLM_ONLY_RULES with
  layer: 'llm'. Re-exports LAYER_LABELS and LAYER_DESCRIPTIONS for
  the generator.
- scripts/build-sub-pages.js: renderRuleCard adds the visual block
  and the layer badge; LLM rules drop the rule id chip since their id
  is just an internal slug. groupRulesBySection now extends the
  primary order with whatever extra sections rules reference.
- public/css/sub-pages.css: .rule-card now has a .rule-card-visual
  preview area on top with border-bottom, body section below. New
  .rule-card-layer pill styling per layer. Layer legend dl using a
  2-column grid for badge -> description.

Dev server serves 38 total cards (25 detected + 13 LLM) across 8
sections: Visual Details, Typography, Color & Contrast, Layout & Space,
Motion, Interaction, Responsive, General quality.
This commit is contained in:
Paul Bakaus
2026-04-08 12:05:12 -07:00
parent a2a8627e94
commit 0d87b5afb5
6 changed files with 447 additions and 23 deletions
+256
View File
@@ -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': `<div style="background: #fff; border: 1px solid #e8e4df; border-left: 4px solid oklch(60% 0.22 265); border-radius: 6px; padding: 14px 16px; width: 220px; font-family: system-ui, sans-serif; font-size: 13px; color: #111;"><div style="font-weight: 600; margin-bottom: 4px;">Alert title</div><div style="color: #666; font-size: 12px;">Thick colored stripe on one side.</div></div>`,
'border-accent-on-rounded': `<div style="background: #fff; border: 2px solid oklch(60% 0.22 290); border-radius: 16px; padding: 14px 18px; width: 220px; font-family: system-ui, sans-serif; font-size: 13px; color: #111;"><div style="font-weight: 600;">Rounded card</div><div style="color: #666; font-size: 12px;">Thick colored border clashes with the radius.</div></div>`,
'overused-font': `<div style="font-family: Inter, system-ui, sans-serif; font-size: 15px; color: #111; line-height: 1.4;"><div style="font-weight: 600; margin-bottom: 4px;">Just another Inter headline</div><div style="color: #555; font-size: 13px;">Every SaaS homepage looks like this.</div></div>`,
'single-font': `<div style="font-family: system-ui, sans-serif; font-size: 14px; color: #111;"><div style="font-size: 19px; font-weight: 600; margin-bottom: 6px;">Heading in the body font</div><div style="color: #555;">Body in the same font. No contrast. Flat.</div></div>`,
'flat-type-hierarchy': `<div style="font-family: system-ui, sans-serif; color: #111; line-height: 1.3;"><div style="font-size: 17px; font-weight: 600;">Heading</div><div style="font-size: 16px; font-weight: 500; margin: 2px 0;">Subheading</div><div style="font-size: 15px; color: #555;">Body text at almost the same size.</div></div>`,
'icon-tile-stack': `<div style="font-family: system-ui, sans-serif; color: #111;"><div style="width: 44px; height: 44px; border-radius: 10px; background: linear-gradient(135deg, oklch(62% 0.22 265), oklch(70% 0.20 320)); display: flex; align-items: center; justify-content: center; font-size: 20px; color: #fff; margin-bottom: 10px;">✦</div><div style="font-size: 14px; font-weight: 600; margin-bottom: 2px;">Feature name</div><div style="font-size: 12px; color: #666;">Rounded icon tile above heading.</div></div>`,
'gradient-text': `<div style="font-family: system-ui, sans-serif;"><div style="font-size: 28px; font-weight: 700; background: linear-gradient(135deg, oklch(65% 0.25 320), oklch(60% 0.25 265)); -webkit-background-clip: text; background-clip: text; color: transparent; line-height: 1.1;">Build the Future</div><div style="font-size: 12px; color: #888; margin-top: 4px;">Gradient text kills scannability.</div></div>`,
'ai-color-palette': `<div style="display: flex; gap: 6px;"><div style="width: 44px; height: 44px; border-radius: 6px; background: oklch(60% 0.22 265);"></div><div style="width: 44px; height: 44px; border-radius: 6px; background: oklch(62% 0.25 300);"></div><div style="width: 44px; height: 44px; border-radius: 6px; background: oklch(64% 0.25 340);"></div><div style="width: 44px; height: 44px; border-radius: 6px; background: oklch(70% 0.20 200);"></div></div>`,
'dark-glow': `<div style="background: #0a0b14; padding: 18px 20px; border-radius: 10px; font-family: system-ui, sans-serif;"><div style="color: oklch(78% 0.22 280); text-shadow: 0 0 12px oklch(78% 0.22 280 / 0.7); font-size: 16px; font-weight: 600;">Neon on dark</div><div style="color: oklch(60% 0.12 260); font-size: 12px; margin-top: 4px;">Cyberpunk-by-default slop.</div></div>`,
'nested-cards': `<div style="background: #f5f3ef; border: 1px solid #e0dcd4; border-radius: 10px; padding: 10px;"><div style="background: #fff; border: 1px solid #e8e4df; border-radius: 8px; padding: 10px;"><div style="background: #f5f3ef; border: 1px solid #e8e4df; border-radius: 6px; padding: 8px; font-size: 12px; font-family: system-ui, sans-serif; color: #555;">Card inside card inside card.</div></div></div>`,
'monotonous-spacing': `<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;"><div style="background: #fff; border: 1px solid #e8e4df; border-radius: 6px; height: 48px;"></div><div style="background: #fff; border: 1px solid #e8e4df; border-radius: 6px; height: 48px;"></div><div style="background: #fff; border: 1px solid #e8e4df; border-radius: 6px; height: 48px;"></div><div style="background: #fff; border: 1px solid #e8e4df; border-radius: 6px; height: 48px;"></div><div style="background: #fff; border: 1px solid #e8e4df; border-radius: 6px; height: 48px;"></div><div style="background: #fff; border: 1px solid #e8e4df; border-radius: 6px; height: 48px;"></div></div>`,
'everything-centered': `<div style="font-family: system-ui, sans-serif; text-align: center; color: #111;"><div style="font-size: 16px; font-weight: 600; margin-bottom: 6px;">Centered headline</div><div style="font-size: 12px; color: #555; margin-bottom: 10px;">Everything centered by default.</div><div style="display: inline-block; background: #111; color: #fff; padding: 6px 14px; border-radius: 6px; font-size: 12px;">Call to action</div></div>`,
'bounce-easing': `<div style="font-family: system-ui, sans-serif; color: #111; display: flex; align-items: center; gap: 10px;"><div style="width: 36px; height: 36px; border-radius: 50%; background: oklch(65% 0.22 265); animation: bouncey 0.9s cubic-bezier(0.68, -0.55, 0.27, 1.55) infinite;"></div><div style="font-size: 12px; color: #555;">Bounce + elastic easing feels dated.</div><style>@keyframes bouncey { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-10px); } }</style></div>`,
'all-caps-body': `<div style="font-family: system-ui, sans-serif; color: #111; font-size: 12px; text-transform: uppercase; letter-spacing: 0.03em; line-height: 1.5;">Long passages in uppercase are hard to read. We recognize words by their shape, which all-caps removes.</div>`,
'pure-black-white': `<div style="background: #ffffff; padding: 16px 18px; color: #000000; font-family: system-ui, sans-serif; font-size: 14px;"><div style="font-weight: 600; margin-bottom: 4px;">Pure black on pure white</div><div style="font-size: 12px; color: #000;">Neither exists in nature. Always tint.</div></div>`,
'gray-on-color': `<div style="background: oklch(60% 0.20 265); padding: 16px 18px; border-radius: 6px; font-family: system-ui, sans-serif;"><div style="color: #9ca3af; font-size: 13px;">Gray text on a colored background. Washed out and hard to read.</div></div>`,
'low-contrast': `<div style="background: #fff; padding: 16px 18px; font-family: system-ui, sans-serif;"><div style="color: #d4d4d4; font-size: 13px;">Light gray text on a white background. 1.6:1 contrast, fails WCAG.</div></div>`,
'layout-transition': `<div style="font-family: system-ui, sans-serif; color: #111; display: flex; align-items: center; gap: 10px;"><div style="background: oklch(65% 0.22 265); border-radius: 6px; animation: janky 1.2s ease-in-out infinite; width: 60px; height: 30px;"></div><div style="font-size: 12px; color: #555;">Animating width/height causes layout jank.</div><style>@keyframes janky { 0%,100% { width: 60px; } 50% { width: 120px; } }</style></div>`,
'cramped-padding': `<div style="font-family: system-ui, sans-serif;"><button style="background: #111; color: #fff; border: none; border-radius: 4px; padding: 2px 6px; font-size: 13px; font-weight: 500;">Buy now</button> <span style="color: #555; font-size: 12px; margin-left: 8px;">2px vertical padding.</span></div>`,
'tight-leading': `<div style="font-family: system-ui, sans-serif; font-size: 13px; color: #111; line-height: 1.0; max-width: 220px;">Tight leading makes multi-line body text feel crammed and hard for the eye to track between lines.</div>`,
'skipped-heading': `<div style="font-family: system-ui, sans-serif; color: #111;"><h1 style="font-size: 20px; font-weight: 700; margin: 0 0 4px;">Page title (h1)</h1><h3 style="font-size: 13px; font-weight: 600; margin: 0; color: #555;">Subsection (h3) — skipped h2</h3></div>`,
'justified-text': `<div style="font-family: system-ui, sans-serif; font-size: 12px; color: #111; text-align: justify; max-width: 230px; line-height: 1.5;">Justified text on screens creates rivers of whitespace because browsers can't hyphenate well. Leave this for print.</div>`,
'tiny-text': `<div style="font-family: system-ui, sans-serif; color: #111;"><div style="font-size: 15px; margin-bottom: 6px;">Regular body text</div><div style="font-size: 9px; color: #555;">And then fine print at 9 pixels that no one will ever read.</div></div>`,
'wide-tracking': `<div style="font-family: system-ui, sans-serif; font-size: 13px; color: #111; letter-spacing: 0.22em; max-width: 230px; line-height: 1.6;">Wide tracking on body text slows reading by breaking up natural character groupings.</div>`,
'line-length': `<div style="font-family: system-ui, sans-serif; font-size: 13px; color: #111; line-height: 1.55; max-width: 100%;">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.</div>`,
};
// ─── 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',
},
];
+106 -3
View File
@@ -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;
+2 -2
View File
@@ -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']
+2 -2
View File
@@ -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' },
+60 -13
View File
@@ -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
? `<a class="rule-card-skill-link" href="/skills/impeccable#${slugify(rule.skillSection)}">See in /impeccable</a>`
: '';
const visual = rule.visual
? `<div class="rule-card-visual" aria-hidden="true"><div class="rule-card-visual-inner">${rule.visual}</div></div>`
: '';
const ruleIdDisplay = rule.layer === 'llm' ? '' : `<code class="rule-card-id">${escapeHtml(rule.id)}</code>`;
return `
<article class="rule-card" id="rule-${rule.id}">
<div class="rule-card-head">
<code class="rule-card-id">${escapeHtml(rule.id)}</code>
<span class="rule-card-category" data-category="${rule.category}">${categoryLabel}</span>
<article class="rule-card" id="rule-${rule.id}" data-layer="${layer}">
${visual}
<div class="rule-card-body">
<div class="rule-card-head">
${ruleIdDisplay}
<span class="rule-card-badges">
<span class="rule-card-category" data-category="${rule.category}">${categoryLabel}</span>
<span class="rule-card-layer" data-layer="${layer}" title="${escapeAttr(layerTitle)}">${escapeHtml(layerLabel)}</span>
</span>
</div>
<h3 class="rule-card-name">${escapeHtml(rule.name)}</h3>
<p class="rule-card-desc">${escapeHtml(rule.description)}</p>
${skillLink}
</div>
<h3 class="rule-card-name">${escapeHtml(rule.name)}</h3>
<p class="rule-card-desc">${escapeHtml(rule.description)}</p>
${skillLink}
</article>`;
}
function escapeAttr(str) {
return String(str || '').replace(/"/g, '&quot;');
}
/**
* Render the /tutorials index main content.
*/
@@ -439,17 +476,27 @@ ${rules.map(renderRuleCard).join('\n')}
</section>`;
}
const detectedCount = grouped.order
.flatMap((s) => grouped.bySection[s] || [])
.filter((r) => r.layer !== 'llm').length;
const llmCount = totalRules - detectedCount;
return `
<div class="anti-patterns-content">
<header class="anti-patterns-header">
<p class="sub-page-eyebrow">${totalRules} detection rules</p>
<p class="sub-page-eyebrow">${totalRules} rules</p>
<h1 class="sub-page-title">Anti-patterns</h1>
<p class="sub-page-lede">These are the visible tells of AI-generated interfaces. Every rule in this catalog is implemented as a deterministic check in <code>npx impeccable detect</code> and in the browser extension. Run <a href="/skills/critique">/critique</a> on any page to see which ones it triggers.</p>
<p class="sub-page-lede">The full catalog of patterns <a href="/skills/impeccable">/impeccable</a> teaches against. ${detectedCount} are caught by a deterministic detector (<code>npx impeccable detect</code> or the browser extension). ${llmCount} can only be flagged by <a href="/skills/critique">/critique</a>'s LLM review pass.</p>
</header>
<section class="anti-patterns-legend">
<h2 class="anti-patterns-legend-title">How to read this</h2>
<p>Rules are grouped by the section of the <a href="/skills/impeccable">/impeccable</a> skill that teaches the pattern to avoid. <strong>AI slop</strong> rules flag the specific visual tells (gradient text, purple palettes, side-tab borders, nested cards). <strong>Quality</strong> rules flag general design mistakes that are not AI-specific but still hurt the work.</p>
<p><strong>AI slop</strong> rules flag the visible tells of AI-generated UIs. <strong>Quality</strong> rules flag general design mistakes that are not AI-specific but still hurt the work. Each rule also shows how it is detected:</p>
<dl class="anti-patterns-legend-layers">
<div><dt><span class="rule-card-layer" data-layer="cli">CLI</span></dt><dd>Deterministic. Runs from <code>npx impeccable detect</code> on files, no browser required.</dd></div>
<div><dt><span class="rule-card-layer" data-layer="browser">Browser</span></dt><dd>Deterministic, but needs real browser layout. Runs via the browser extension or Puppeteer, not the plain CLI.</dd></div>
<div><dt><span class="rule-card-layer" data-layer="llm">LLM only</span></dt><dd>No deterministic detector. Caught by <a href="/skills/critique">/critique</a> during its LLM design review.</dd></div>
</dl>
</section>
<div class="anti-patterns-sections">
+21 -3
View File
@@ -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');