| 1 | const ANTIPATTERNS = [ |
| 2 | // ── AI slop: tells that something was AI-generated ── |
| 3 | { |
| 4 | id: 'side-tab', |
| 5 | category: 'slop', |
| 6 | name: 'Side-tab accent border', |
| 7 | description: |
| 8 | '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.', |
| 9 | skillSection: 'Visual Details', |
| 10 | skillGuideline: 'colored accent stripe', |
| 11 | }, |
| 12 | { |
| 13 | id: 'border-accent-on-rounded', |
| 14 | category: 'slop', |
| 15 | name: 'Border accent on rounded element', |
| 16 | description: |
| 17 | 'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.', |
| 18 | skillSection: 'Visual Details', |
| 19 | skillGuideline: 'colored accent stripe', |
| 20 | }, |
| 21 | { |
| 22 | id: 'overused-font', |
| 23 | category: 'slop', |
| 24 | name: 'Overused font', |
| 25 | description: |
| 26 | 'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.', |
| 27 | skillSection: 'Typography', |
| 28 | skillGuideline: 'overused fonts like Inter', |
| 29 | }, |
| 30 | { |
| 31 | id: 'single-font', |
| 32 | category: 'slop', |
| 33 | name: 'Single font for everything', |
| 34 | description: |
| 35 | 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.', |
| 36 | skillSection: 'Typography', |
| 37 | skillGuideline: 'only one font family for the entire page', |
| 38 | }, |
| 39 | { |
| 40 | id: 'flat-type-hierarchy', |
| 41 | category: 'slop', |
| 42 | name: 'Flat type hierarchy', |
| 43 | description: |
| 44 | 'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).', |
| 45 | skillSection: 'Typography', |
| 46 | skillGuideline: 'flat type hierarchy', |
| 47 | }, |
| 48 | { |
| 49 | id: 'gradient-text', |
| 50 | category: 'slop', |
| 51 | name: 'Gradient text', |
| 52 | description: |
| 53 | 'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.', |
| 54 | skillSection: 'Color & Contrast', |
| 55 | skillGuideline: 'gradient text for', |
| 56 | }, |
| 57 | { |
| 58 | id: 'ai-color-palette', |
| 59 | category: 'slop', |
| 60 | name: 'AI color palette', |
| 61 | description: |
| 62 | 'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.', |
| 63 | skillSection: 'Color & Contrast', |
| 64 | skillGuideline: 'AI color palette', |
| 65 | }, |
| 66 | { |
| 67 | id: 'cream-palette', |
| 68 | category: 'slop', |
| 69 | name: 'Cream / beige palette', |
| 70 | description: |
| 71 | 'A warm cream or beige page background has become the default "tasteful" AI surface, reached for by reflex. Choose a background that comes from a deliberate palette, not the safe warm off-white.', |
| 72 | skillSection: 'Color & Contrast', |
| 73 | skillGuideline: 'cream and beige as the default surface', |
| 74 | }, |
| 75 | { |
| 76 | id: 'nested-cards', |
| 77 | category: 'slop', |
| 78 | name: 'Nested cards', |
| 79 | description: |
| 80 | 'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.', |
| 81 | skillSection: 'Layout & Space', |
| 82 | skillGuideline: 'Nest cards inside cards', |
| 83 | }, |
| 84 | { |
| 85 | id: 'monotonous-spacing', |
| 86 | category: 'slop', |
| 87 | name: 'Monotonous spacing', |
| 88 | description: |
| 89 | 'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.', |
| 90 | skillSection: 'Layout & Space', |
| 91 | skillGuideline: 'same spacing everywhere', |
| 92 | }, |
| 93 | { |
| 94 | id: 'bounce-easing', |
| 95 | category: 'slop', |
| 96 | name: 'Bounce or elastic easing', |
| 97 | description: |
| 98 | 'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.', |
| 99 | skillSection: 'Motion', |
| 100 | skillGuideline: 'bounce or elastic easing', |
| 101 | }, |
| 102 | { |
| 103 | id: 'dark-glow', |
| 104 | category: 'slop', |
| 105 | name: 'Dark mode with glowing accents', |
| 106 | description: |
| 107 | 'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.', |
| 108 | skillSection: 'Color & Contrast', |
| 109 | skillGuideline: 'dark mode with glowing accents', |
| 110 | }, |
| 111 | { |
| 112 | id: 'icon-tile-stack', |
| 113 | category: 'slop', |
| 114 | name: 'Icon tile stacked above heading', |
| 115 | description: |
| 116 | 'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.', |
| 117 | skillSection: 'Typography', |
| 118 | skillGuideline: 'large icons with rounded corners above every heading', |
| 119 | }, |
| 120 | { |
| 121 | id: 'italic-serif-display', |
| 122 | category: 'slop', |
| 123 | name: 'Italic serif display headline', |
| 124 | description: |
| 125 | 'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.', |
| 126 | skillSection: 'Typography', |
| 127 | skillGuideline: 'oversized italic serif as the hero headline', |
| 128 | }, |
| 129 | { |
| 130 | id: 'hero-eyebrow-chip', |
| 131 | category: 'slop', |
| 132 | name: 'Hero eyebrow / pill chip', |
| 133 | description: |
| 134 | 'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.', |
| 135 | skillSection: 'Typography', |
| 136 | skillGuideline: 'tiny uppercase tracked label above the hero headline', |
| 137 | }, |
| 138 | { |
| 139 | id: 'repeated-section-kickers', |
| 140 | category: 'slop', |
| 141 | severity: 'advisory', |
| 142 | name: 'Repeated section kicker labels', |
| 143 | description: |
| 144 | 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.', |
| 145 | skillSection: 'Typography', |
| 146 | skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding', |
| 147 | }, |
| 148 | { |
| 149 | id: 'numbered-section-markers', |
| 150 | category: 'slop', |
| 151 | severity: 'advisory', |
| 152 | name: 'Numbered section markers (01 / 02 / 03)', |
| 153 | description: |
| 154 | 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.', |
| 155 | skillSection: 'Layout & Space', |
| 156 | skillGuideline: 'numbered section markers', |
| 157 | }, |
| 158 | { |
| 159 | id: 'em-dash-overuse', |
| 160 | category: 'slop', |
| 161 | name: 'Em-dash overuse', |
| 162 | description: |
| 163 | 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.', |
| 164 | skillSection: 'Copy', |
| 165 | skillGuideline: 'no em dashes', |
| 166 | }, |
| 167 | { |
| 168 | id: 'marketing-buzzword', |
| 169 | category: 'slop', |
| 170 | name: 'Marketing buzzword', |
| 171 | description: |
| 172 | 'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.', |
| 173 | skillSection: 'Copy', |
| 174 | skillGuideline: 'marketing buzzwords', |
| 175 | }, |
| 176 | { |
| 177 | id: 'aphoristic-cadence', |
| 178 | category: 'slop', |
| 179 | name: 'Aphoristic-cadence copy', |
| 180 | description: |
| 181 | 'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.', |
| 182 | skillSection: 'Copy', |
| 183 | skillGuideline: 'aphoristic cadence', |
| 184 | }, |
| 185 | { |
| 186 | id: 'oversized-h1', |
| 187 | category: 'slop', |
| 188 | name: 'Oversized hero headline', |
| 189 | description: |
| 190 | 'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.', |
| 191 | skillSection: 'Typography', |
| 192 | skillGuideline: 'long headline set at display size', |
| 193 | }, |
| 194 | { |
| 195 | id: 'extreme-negative-tracking', |
| 196 | category: 'slop', |
| 197 | name: 'Crushed letter spacing', |
| 198 | description: |
| 199 | 'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.', |
| 200 | skillSection: 'Typography', |
| 201 | skillGuideline: 'letter spacing crushed past legibility', |
| 202 | }, |
| 203 | { |
| 204 | id: 'broken-image', |
| 205 | category: 'quality', |
| 206 | name: 'Broken or placeholder image', |
| 207 | description: |
| 208 | '<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.', |
| 209 | skillSection: 'Imagery', |
| 210 | skillGuideline: 'broken image references', |
| 211 | }, |
| 212 | |
| 213 | // ── Quality: general design and accessibility issues ── |
| 214 | { |
| 215 | id: 'gray-on-color', |
| 216 | category: 'quality', |
| 217 | name: 'Gray text on colored background', |
| 218 | description: |
| 219 | 'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.', |
| 220 | skillSection: 'Color & Contrast', |
| 221 | skillGuideline: 'gray text on colored backgrounds', |
| 222 | }, |
| 223 | { |
| 224 | id: 'low-contrast', |
| 225 | category: 'quality', |
| 226 | name: 'Low contrast text', |
| 227 | description: |
| 228 | 'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.', |
| 229 | }, |
| 230 | { |
| 231 | id: 'layout-transition', |
| 232 | category: 'quality', |
| 233 | name: 'Layout property animation', |
| 234 | description: |
| 235 | 'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.', |
| 236 | skillSection: 'Motion', |
| 237 | skillGuideline: 'Animate layout properties', |
| 238 | }, |
| 239 | { |
| 240 | id: 'line-length', |
| 241 | category: 'quality', |
| 242 | name: 'Line length too long', |
| 243 | description: |
| 244 | 'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.', |
| 245 | skillSection: 'Layout & Space', |
| 246 | skillGuideline: 'wrap beyond ~80 characters', |
| 247 | }, |
| 248 | { |
| 249 | id: 'cramped-padding', |
| 250 | category: 'quality', |
| 251 | name: 'Cramped padding', |
| 252 | description: |
| 253 | 'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.', |
| 254 | skillSection: 'Layout & Space', |
| 255 | skillGuideline: 'inside bordered or colored containers', |
| 256 | }, |
| 257 | { |
| 258 | id: 'body-text-viewport-edge', |
| 259 | category: 'quality', |
| 260 | name: 'Body text touching viewport edge', |
| 261 | description: |
| 262 | 'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.', |
| 263 | }, |
| 264 | { |
| 265 | id: 'tight-leading', |
| 266 | category: 'quality', |
| 267 | name: 'Tight line height', |
| 268 | description: |
| 269 | 'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.', |
| 270 | }, |
| 271 | { |
| 272 | id: 'skipped-heading', |
| 273 | category: 'quality', |
| 274 | name: 'Skipped heading level', |
| 275 | description: |
| 276 | 'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.', |
| 277 | }, |
| 278 | { |
| 279 | id: 'justified-text', |
| 280 | category: 'quality', |
| 281 | name: 'Justified text', |
| 282 | description: |
| 283 | 'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.', |
| 284 | }, |
| 285 | { |
| 286 | id: 'tiny-text', |
| 287 | category: 'quality', |
| 288 | name: 'Tiny body text', |
| 289 | description: |
| 290 | 'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.', |
| 291 | }, |
| 292 | { |
| 293 | id: 'all-caps-body', |
| 294 | category: 'quality', |
| 295 | name: 'All-caps body text', |
| 296 | description: |
| 297 | 'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.', |
| 298 | skillSection: 'Typography', |
| 299 | skillGuideline: 'long body passages in uppercase', |
| 300 | }, |
| 301 | { |
| 302 | id: 'wide-tracking', |
| 303 | category: 'quality', |
| 304 | name: 'Wide letter spacing on body text', |
| 305 | description: |
| 306 | 'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.', |
| 307 | }, |
| 308 | { |
| 309 | id: 'text-overflow', |
| 310 | category: 'quality', |
| 311 | name: 'Content overflowing its container', |
| 312 | description: |
| 313 | 'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.', |
| 314 | skillSection: 'Layout & Space', |
| 315 | skillGuideline: 'content wider than its container', |
| 316 | }, |
| 317 | { |
| 318 | id: 'clipped-overflow-container', |
| 319 | category: 'quality', |
| 320 | name: 'Positioned child clipped by overflow container', |
| 321 | description: |
| 322 | 'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.', |
| 323 | skillSection: 'Layout & Space', |
| 324 | skillGuideline: 'overflow container clipping positioned children', |
| 325 | }, |
| 326 | |
| 327 | // ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ── |
| 328 | { |
| 329 | id: 'gpt-thin-border-wide-shadow', |
| 330 | category: 'slop', |
| 331 | severity: 'advisory', |
| 332 | gated: 'gpt', |
| 333 | name: 'Hairline border with wide shadow', |
| 334 | description: |
| 335 | 'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.', |
| 336 | skillSection: 'Visual Details', |
| 337 | skillGuideline: 'hairline border plus wide diffuse shadow', |
| 338 | }, |
| 339 | { |
| 340 | id: 'repeating-stripes-gradient', |
| 341 | category: 'slop', |
| 342 | severity: 'advisory', |
| 343 | gated: 'gpt', |
| 344 | name: 'Repeating-gradient stripes', |
| 345 | description: |
| 346 | 'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.', |
| 347 | skillSection: 'Visual Details', |
| 348 | skillGuideline: 'repeating-gradient decorative stripes', |
| 349 | }, |
| 350 | { |
| 351 | id: 'theater-slop-phrase', |
| 352 | category: 'slop', |
| 353 | severity: 'advisory', |
| 354 | gated: 'gpt', |
| 355 | name: 'Theater framing copy', |
| 356 | description: |
| 357 | 'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.', |
| 358 | skillSection: 'Copy', |
| 359 | skillGuideline: 'theater framing copy', |
| 360 | }, |
| 361 | { |
| 362 | id: 'image-hover-transform', |
| 363 | category: 'slop', |
| 364 | severity: 'advisory', |
| 365 | gated: 'gemini', |
| 366 | name: 'Image hover transform', |
| 367 | description: |
| 368 | 'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.', |
| 369 | skillSection: 'Motion', |
| 370 | skillGuideline: 'image scale or rotate on hover', |
| 371 | }, |
| 372 | ]; |
| 373 | |
| 374 | const RULE_ENGINE_SUPPORT = { |
| 375 | regex: new Set(['source', 'page-analyzer']), |
| 376 | 'static-html': new Set(['element', 'page']), |
| 377 | browser: new Set(['element', 'page', 'layout']), |
| 378 | visual: new Set(['visual-contrast']), |
| 379 | }; |
| 380 | |
| 381 | function getAntipattern(id) { |
| 382 | return ANTIPATTERNS.find(rule => rule.id === id); |
| 383 | } |
| 384 | |
| 385 | function getRulesForCategory(category) { |
| 386 | return ANTIPATTERNS.filter(rule => rule.category === category); |
| 387 | } |
| 388 | |
| 389 | function getRuleEngineSupport(engine) { |
| 390 | return RULE_ENGINE_SUPPORT[engine] || new Set(); |
| 391 | } |
| 392 | |
| 393 | // Set of provider tags that gate rules off by default (e.g. 'gpt', 'gemini'). |
| 394 | const GATED_PROVIDERS = new Set( |
| 395 | ANTIPATTERNS.map(rule => rule.gated).filter(Boolean), |
| 396 | ); |
| 397 | |
| 398 | // Drop findings for rules gated behind a provider tag unless that provider |
| 399 | // was explicitly enabled (CLI --gpt / --gemini). Non-gated findings always |
| 400 | // pass through. `findings` carry the rule id on `.antipattern`. |
| 401 | function filterByProviders(findings, providers = []) { |
| 402 | const enabled = new Set(providers || []); |
| 403 | if (!GATED_PROVIDERS.size) return findings; |
| 404 | return findings.filter(f => { |
| 405 | const rule = getAntipattern(f.antipattern); |
| 406 | if (!rule || !rule.gated) return true; |
| 407 | return enabled.has(rule.gated); |
| 408 | }); |
| 409 | } |
| 410 | |
| 411 | export { |
| 412 | ANTIPATTERNS, |
| 413 | RULE_ENGINE_SUPPORT, |
| 414 | GATED_PROVIDERS, |
| 415 | getAntipattern, |
| 416 | getRulesForCategory, |
| 417 | getRuleEngineSupport, |
| 418 | filterByProviders, |
| 419 | }; |
| 420 |