Generate /anti-patterns index page

Add the anti-patterns browser. Single page, same docs-browser shell as
the skills section, but the sidebar is a table of contents instead of
a navigation list.

- scripts/build-sub-pages.js
  - groupRulesBySection(): bucket the 25 detection rules by skill
    section (Visual Details, Typography, Color & Contrast, Layout &
    Space, Motion) plus a 'General quality' bucket for the 7 rules
    without a skillSection cross-reference. Each bucket sorts slop
    rules first, then quality rules alphabetically.
  - renderAntiPatternsSidebar(): TOC with anchor links to each
    section, per-section rule count on the right, reusing the
    .skills-sidebar styles with anti-patterns-specific tweaks for
    the two-column item layout.
  - renderRuleCard(): rule id + category badge + name + description
    + optional 'See in /impeccable' link that jumps to the right
    anchor on the skill detail page.
  - renderAntiPatternsMain(): editorial header ('25 detection rules')
    + 'How to read this' legend callout + six sections each with a
    grid of rule cards.
  - Wire the new page into the main generator loop.

- public/css/sub-pages.css
  - .rule-card grid (auto-fill minmax 320px) with header row, mono
    rule id, pill category badge, bold name, body description,
    arrow skill link at the bottom.
  - .rule-card-category variants: slop uses accent-dim background
    with accent text, quality uses mist background with charcoal text.
  - Section headers with baseline-aligned title and right-aligned
    rule count, matching the rhythm of the /skills overview.
  - Anti-patterns sidebar count styling (tabular-nums, ash).

Verified: /anti-patterns returns 200 with 25 rule cards rendered across
all six section anchors.
This commit is contained in:
Paul Bakaus
2026-04-08 10:14:18 -07:00
parent 71a909f7da
commit e49a4b4a02
2 changed files with 347 additions and 0 deletions
+211
View File
@@ -570,6 +570,217 @@ main#main {
background: var(--color-cream);
}
/* ============================================
ANTI-PATTERNS INDEX
============================================ */
.anti-patterns-sidebar-list a {
display: flex !important;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-sm);
}
.anti-patterns-sidebar-count {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-ash);
font-variant-numeric: tabular-nums;
}
.anti-patterns-content {
max-width: 820px;
}
.anti-patterns-header {
margin-bottom: clamp(2.5rem, 5vw, 4rem);
}
.anti-patterns-header .sub-page-lede code {
font-family: var(--font-mono);
font-size: 0.875em;
color: var(--color-ink);
background: var(--color-cream);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--color-mist);
}
.anti-patterns-header .sub-page-lede a {
color: var(--color-ink);
text-decoration: underline;
text-decoration-color: var(--color-accent);
text-decoration-thickness: 1px;
text-underline-offset: 4px;
font-family: var(--font-mono);
font-weight: 500;
font-size: 0.9375em;
}
.anti-patterns-legend {
padding: var(--spacing-lg);
background: var(--color-cream);
border: 1px solid var(--color-mist);
border-radius: 10px;
margin-bottom: clamp(2.5rem, 5vw, 4rem);
max-width: 720px;
}
.anti-patterns-legend-title {
font-family: var(--font-display);
font-size: 1.25rem;
font-style: italic;
font-weight: 500;
color: var(--color-ink);
margin-bottom: var(--spacing-sm);
}
.anti-patterns-legend p {
font-size: 0.9375rem;
line-height: 1.7;
color: var(--color-charcoal);
}
.anti-patterns-legend a {
color: var(--color-ink);
text-decoration: none;
border-bottom: 1px solid var(--color-accent);
font-family: var(--font-mono);
font-size: 0.875em;
font-weight: 500;
}
.anti-patterns-legend a:hover {
color: var(--color-accent);
}
.anti-patterns-sections {
display: flex;
flex-direction: column;
gap: clamp(3rem, 6vw, 4.5rem);
}
.anti-patterns-section-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--color-mist);
margin-bottom: var(--spacing-lg);
}
.anti-patterns-section-title {
font-family: var(--font-display);
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 500;
font-style: italic;
color: var(--color-ink);
letter-spacing: -0.01em;
}
.anti-patterns-section-count {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--color-ash);
}
.rule-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--spacing-md);
}
.rule-card {
padding: var(--spacing-md);
background: var(--color-paper);
border: 1px solid var(--color-mist);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
transition: border-color var(--duration-fast) var(--ease-out);
}
.rule-card:hover {
border-color: var(--color-ash);
}
.rule-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
margin-bottom: 2px;
}
.rule-card-id {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 500;
color: var(--color-ash);
background: transparent;
padding: 0;
border: none;
}
.rule-card-category {
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;
}
.rule-card-category[data-category="slop"] {
color: var(--color-accent);
background: var(--color-accent-dim);
}
.rule-card-category[data-category="quality"] {
color: var(--color-charcoal);
background: var(--color-mist);
}
.rule-card-name {
font-family: var(--font-body);
font-size: 1rem;
font-weight: 600;
color: var(--color-ink);
line-height: 1.35;
}
.rule-card-desc {
font-size: 0.875rem;
line-height: 1.6;
color: var(--color-charcoal);
flex: 1;
}
.rule-card-skill-link {
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 500;
color: var(--color-charcoal);
text-decoration: none;
align-self: flex-start;
padding-top: 4px;
transition: color var(--duration-fast) var(--ease-out);
}
.rule-card-skill-link:hover {
color: var(--color-accent);
}
.rule-card-skill-link::after {
content: " →";
}
/* ============================================
MOBILE: collapse sidebar into inline block
============================================ */
+136
View File
@@ -212,6 +212,124 @@ ${mainHtml}
</div>`;
}
/**
* Group anti-pattern rules by skill section.
* Rules without a skillSection fall into a 'General quality' bucket.
*/
function groupRulesBySection(rules) {
const order = [
'Visual Details',
'Typography',
'Color & Contrast',
'Layout & Space',
'Motion',
'General quality',
];
const bySection = {};
for (const name of order) bySection[name] = [];
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) => {
if (a.category !== b.category) return a.category === 'slop' ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
return { order, bySection };
}
/**
* Render the anti-patterns sidebar: a table of contents of rule sections
* with per-section rule counts. Every entry anchor-jumps to the section
* in the main column.
*/
function renderAntiPatternsSidebar(grouped) {
const entries = grouped.order
.filter((section) => grouped.bySection[section]?.length > 0)
.map((section) => {
const slug = slugify(section);
const count = grouped.bySection[section].length;
return ` <li><a href="#section-${slug}"><span>${escapeHtml(section)}</span><span class="anti-patterns-sidebar-count">${count}</span></a></li>`;
})
.join('\n');
return `
<aside class="skills-sidebar anti-patterns-sidebar" aria-label="Anti-pattern sections">
<div class="skills-sidebar-inner">
<p class="skills-sidebar-label">Sections</p>
<div class="skills-sidebar-group">
<ul class="skills-sidebar-list anti-patterns-sidebar-list">
${entries}
</ul>
</div>
</div>
</aside>`;
}
/**
* Render one rule card inside the anti-patterns main column.
*/
function renderRuleCard(rule) {
const categoryLabel = rule.category === 'slop' ? 'AI slop' : 'Quality';
const skillLink = rule.skillSection
? `<a class="rule-card-skill-link" href="/skills/impeccable#${slugify(rule.skillSection)}">See in /impeccable</a>`
: '';
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>
</div>
<h3 class="rule-card-name">${escapeHtml(rule.name)}</h3>
<p class="rule-card-desc">${escapeHtml(rule.description)}</p>
${skillLink}
</article>`;
}
/**
* Render the /anti-patterns main column content.
*/
function renderAntiPatternsMain(grouped, totalRules) {
let sectionsHtml = '';
for (const section of grouped.order) {
const rules = grouped.bySection[section] || [];
if (rules.length === 0) continue;
const slug = slugify(section);
sectionsHtml += `
<section class="anti-patterns-section" id="section-${slug}">
<header class="anti-patterns-section-header">
<h2 class="anti-patterns-section-title">${escapeHtml(section)}</h2>
<p class="anti-patterns-section-count">${rules.length} ${rules.length === 1 ? 'rule' : 'rules'}</p>
</header>
<div class="rule-card-grid">
${rules.map(renderRuleCard).join('\n')}
</div>
</section>`;
}
return `
<div class="anti-patterns-content">
<header class="anti-patterns-header">
<p class="sub-page-eyebrow">${totalRules} detection 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>
</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>
</section>
<div class="anti-patterns-sections">
${sectionsHtml}
</div>
</div>`;
}
/**
* Entry point. Generates all sub-page HTML files.
*
@@ -271,5 +389,23 @@ export async function generateSubPages(rootDir) {
generated.push(out);
}
// Anti-patterns index: single page, docs-browser shell with TOC sidebar.
{
const grouped = groupRulesBySection(data.rules);
const sidebar = renderAntiPatternsSidebar(grouped);
const main = renderAntiPatternsMain(grouped, data.rules.length);
const html = renderPage({
title: 'Anti-patterns | Impeccable',
description: `${data.rules.length} deterministic detection rules that flag the visible tells of AI-generated interfaces and common quality issues. Used by npx impeccable detect and the browser extension.`,
bodyHtml: wrapInDocsLayout(sidebar, main),
activeNav: 'anti-patterns',
canonicalPath: '/anti-patterns',
bodyClass: 'sub-page skills-layout-page anti-patterns-page',
});
const out = path.join(outDirs.antiPatterns, 'index.html');
fs.writeFileSync(out, html, 'utf-8');
generated.push(out);
}
return { files: generated };
}