uxskill
Star on GitHub

Linter catalogue · v3.0.0

145 fingerprints of AI design slop.

Every rule in data/anti-patterns.json, browseable. Run the linter (uxskill lint) and it scans your HTML/CSS/JS for these regex patterns in <50 ms — no LLM, no API call, no telemetry. Each rule names the fingerprint, explains why it's slop, and tells the AI session what to ship instead.

145 rules total 50 high severity 63 medium 26 low 9 categories
#anchor-no-href-as-button HIGH A11y

Anchor styled as button without href

Why bad

An <a class="btn"> without href is not focusable, not in the tab order, and not announced as a link - looks like a button but behaves like nothing.

Fix

Use <button> for actions, <a href="..."> for navigation. If the destination is unknown until runtime, render a button or set href="#" with preventDefault.

<a[^>]+class=['"][^'"]*btn[^'"]*['"](?![^>]+href)
link
#aria-live-polite-on-error HIGH A11y

aria-live='polite' on a critical error region

Why bad

aria-live='polite' on an error region tells screen readers to wait until the user is idle before announcing - the user submits a broken form, walks away to next field, and the failure announcement may be delayed or skipped entirely. Critical errors that block the flow need to interrupt.

Fix

Use aria-live='assertive' (or role='alert', which implies assertive) for blocking validation errors and destructive-action failures. Reserve aria-live='polite' for non-critical status text (saved, syncing, last updated).

(?:className|class)\s*=\s*['"][^'"]*\b(?:error|danger|alert-danger|toast-error|notification-error)\b[^'"]*['"][^>]*\baria-live\s*=\s*['"]polite['"]|\baria-live\s*=\s*['&quo
link
#cursor-not-allowed-no-visual HIGH A11y

cursor: not-allowed with no visible disabled state

Why bad

cursor: not-allowed in a rule that has no opacity reduction, no muted color, no border tweak, and no pointer-events: none signals the disabled state was set by an AI that knew the cursor convention but never designed the actual visual. The button still looks fully active, screen reader users hear no announcement, and touch users see no change at all - the not-allowed cursor never appears.

Fix

A disabled control needs three signals: visual (opacity 0.5 + maybe muted color or removed border), behavioral (pointer-events: none, or aria-disabled='true' + handle the click), and announcement (aria-disabled on non-button elements; the disabled attribute on actual buttons does both). The cursor is the smallest of these three - never the only one.

\{[^}]*cursor\s*:\s*not-allowed\s*;(?:(?!opacity|pointer-events|filter|background|color|border|disabled)[^}])*\}
link
#generic-alt-image-photo-icon HIGH A11y

Generic alt text: image / photo / picture / icon

Why bad

alt='image' or alt='photo' tells the screen reader nothing the user did not already know from the tag name - it is technically present but functionally absent. WCAG 1.1.1 demands meaningful equivalents, not the word 'image'.

Fix

Describe what the image conveys: 'Stripe Dashboard with revenue chart', 'team standing in front of the office'. For purely decorative images, use alt='' so the screen reader skips them.

<img[^>]*\balt\s*=\s*['\"](?:image|photo|picture|icon|graphic|illustration|pic|img)['\"]
link
#generic-alt-untitled-screenshot-logo-banner HIGH A11y

Generic alt text: untitled / screenshot / logo / banner

Why bad

alt='untitled' / 'screenshot' / 'logo' / 'banner' is the CMS/Webflow/SaaS default fingerprint - signals the alt field was never filled in and the default leaked through. Screen reader users hear the same word for every image on the page.

Fix

Name the subject: alt='ux-skill logo', alt='Stripe pricing page screenshot from Mar 2026', alt='hero banner: model writes the code, designer sets the rules'. Specific enough that someone could find the image from the alt alone.

<img[^>]*\balt\s*=\s*['\"](?:untitled|screenshot|logo|banner|hero|placeholder|asset|file|upload|attachment)['\"]
link
#hover-only-card-actions HIGH A11y

Card actions revealed only on hover

Why bad

Card actions hidden at opacity: 0 and revealed only on :hover are invisible to touch users, keyboard users, and anyone using assistive tech - the action does not exist for most of your audience. WCAG 2.1.1 and 1.4.13 failure.

Fix

Show card actions persistently at reduced visual weight (lower opacity, smaller scale, secondary color), or make them reachable via a visible focus-state. Hover is a progressive disclosure, never the only path.

\.(?:card|item|tile)[^{]*\{[^}]*\}[\s\S]{0,300}?\.(?:card|item|tile)[^:]*:hover\s+(?:\.[^{\s]+|[a-z]+)[^{]*\{[^}]*opacity\s*:\s*1|opacity\s*:\s*0[^}]*\}[\s\S]{0,300}?:hover[\s\S]{0,100}?opacity\s*:\s*1
link
#iframe-without-title HIGH A11y

<iframe> missing title attribute

Why bad

An <iframe> with no title is announced to screen readers as 'frame' with no description of what it contains - blank, unactionable. WCAG 2.4.1 (Bypass Blocks) and 4.1.2 (Name, Role, Value) fail. Every embedded video, payment widget, map, or ad triggers this.

Fix

Always set title='Stripe payment form' / 'YouTube video: product tour' / 'Google Maps: office location'. The title should describe the frame's purpose, not just say 'iframe' or 'embed'.

<iframe(?![^>]*\btitle\s*=)[^>]*>
link
#img-no-alt HIGH A11y

Image missing alt attribute

Why bad

Missing alt breaks screen readers, SEO image indexing, and the broken-image fallback all at once - WCAG 1.1.1 failure.

Fix

Every <img> gets alt. Decorative images get alt="" (empty). Informative images get a description a screen reader user would need.

<img(?![^>]*\balt=)[^>]*/?>
link
#inline-svg-no-aria HIGH A11y

SVG without aria-label or aria-hidden

Why bad

SVG with no aria-label and no aria-hidden is announced by screen readers as 'graphic' with no context - failure of WCAG 1.1.1.

Fix

Decorative SVG gets aria-hidden="true". Informative SVG gets aria-label="description" or aria-labelledby pointing to a title element.

<svg(?![^>]*\baria-label=)(?![^>]*\baria-labelledby=)(?![^>]*\baria-hidden=)(?![^>]*\brole="presentation")[^>]*>
link
#lang-attribute-missing HIGH A11y

<html> without lang attribute

Why bad

<html> without a lang attribute leaves screen readers guessing which pronunciation engine to use - Arabic content gets read in English phonemes, English headings get read with Spanish stress patterns, and automatic translation breaks. WCAG 3.1.1 Level A failure.

Fix

Add lang="en" (or your primary locale) to <html>. For multilingual pages, override per-section with lang="ar" on RTL blocks. Pair with dir="rtl" for Arabic/Hebrew layouts.

<html(?![^>]*\blang\s*=)[^>]*>[\s\S]{0,800}?<head\b
link
#meta-refresh-redirect HIGH A11y

<meta http-equiv='refresh'> redirect

Why bad

<meta http-equiv='refresh'> auto-redirects without user consent, breaks back-button navigation, disorients screen reader users, and is a documented WCAG 2.2.1 (Timing Adjustable) and 3.2.5 (Change on Request) fail. Google also down-ranks pages that use it for redirects.

Fix

For a true redirect, do it server-side (301 / 302). For client-side route changes, use the router's navigate() method tied to a user action. Never use meta-refresh for the 'you'll be redirected in 5 seconds' pattern - use an inline link the user clicks.

<meta[^>]+http-equiv\s*=\s*['"]refresh['"][^>]*>
link
#overflow-hidden-on-html HIGH A11y

overflow: hidden applied to <html>

Why bad

overflow: hidden on the root <html> element breaks browser scroll restoration on back-navigation, disables 'skip to content' anchor scrolling for screen-reader users, kills smooth-scroll APIs, and prevents the URL bar from collapsing on mobile. It is almost always a hack to suppress a horizontal-overflow bug that should be fixed at the source.

Fix

Find the actual overflowing child (often a 100vw image, fixed-position element, or oversized inline-block) and constrain it. If you genuinely need a scroll-lock for a modal, toggle the class on <body> via JS for the lifetime of the modal - never permanently on <html>.

(?:^|[\s,}>+~])html\s*\{[^}]*overflow(?:-[xy])?\s*:\s*hidden
link
#placeholder-as-label HIGH A11y

Input placeholder used as the only label

Why bad

Placeholders disappear on focus, sit at low contrast by default, and are not announced consistently by assistive tech - using one as the only label fails WCAG 3.3.2.

Fix

Always pair <input> with a <label for="id"> or aria-label. Placeholder is for an optional hint, never the field name.

<input(?![^>]*\baria-label=)(?![^>]*\baria-labelledby=)[^>]+placeholder=
link
#role-button-on-anchor-without-href HIGH A11y

<a role='button'> with no href

Why bad

<a role='button'> with no href is the worst of both worlds - the link semantics are overridden, the element is not focusable (no href = not in tab order), and the WAI-ARIA promise of 'this behaves like a button' is silently broken. WCAG 4.1.2 fail.

Fix

Just use <button type='button'>. If the design demands link-text styling, give the <button> the same classes as your text-link recipe. Never patch a semantically wrong anchor with role='button' - replace it.

<a(?![^>]*\bhref=)[^>]*\brole\s*=\s*['"]button['"][^>]*>
link
#select-without-label HIGH A11y

<select> with no associated <label> or aria-label

Why bad

A <select> with no id (so <label for=> cannot reach it), no aria-label, and no aria-labelledby is announced to screen readers as just 'combobox, 1 of 3' with no clue what it controls. WCAG 1.3.1 / 4.1.2 fail.

Fix

Either give the <select> an id and reference it from <label for='...'>, or set aria-label='Country' / aria-labelledby='country-heading'. Don't rely on a placeholder option ('Select a country...') as the label - placeholder options are not labels.

<select(?![^>]*\baria-label=)(?![^>]*\baria-labelledby=)(?![^>]*\bid=)[^>]*>
link
#tabindex-positive HIGH A11y

Positive tabindex value

Why bad

tabindex='1' (or any positive value) pulls the element out of source order and into a synthetic tab sequence, so the keyboard tab order no longer matches the visual or DOM order. Any new element added to the page disrupts the explicit numbering - WCAG 2.4.3 (Focus Order) fail and an ongoing maintenance trap.

Fix

Use tabindex='0' to make a non-interactive element focusable, tabindex='-1' to remove a focusable element from the tab order (still programmatically focusable). Source order should match tab order; fix layout with CSS, not synthetic tabindex.

\btabindex\s*=\s*['"][1-9][0-9]*['"]
link
#target-blank-no-noopener HIGH A11y

target="_blank" without rel="noopener noreferrer"

Why bad

target="_blank" without rel="noopener" lets the opened page access window.opener and rewrite the source URL - a tabnabbing vector. It also blocks the browser's tab-recycle optimization. A11y-adjacent because hijacked navigation breaks the user's mental model of where they are.

Fix

Always pair target="_blank" with rel="noopener noreferrer". Better: ask whether the link actually needs a new tab - in-page navigation respects user choice and back-button history.

<a[^>]+target\s*=\s*['"]_blank['"](?![^>]+rel\s*=\s*['"][^'"]*(?:noopener|noreferrer))
link
#tooltip-on-required-info HIGH A11y

Load-bearing copy hidden in a title attribute

Why bad

A title attribute carrying 60+ characters of real content is hover-only - invisible on touch, inconsistently announced by screen readers, undiscoverable on keyboard. The information is effectively missing for most users.

Fix

Move the content into the visible flow or into an aria-describedby popover that is reachable by keyboard and touch. Reserve title for one-line, optional clarifications.

title=\"[A-Z][^\"]{60,}\"
link
#video-without-captions HIGH A11y

<video> element without <track kind='captions'>

Why bad

A <video> with no captions <track> is inaccessible to Deaf and hard-of-hearing users, fails WCAG 1.2.2 (Captions, Prerecorded) at AA, and is unusable in any sound-off context (open-plan office, public transport, autoplay-muted social feeds).

Fix

Provide at least one <track kind='captions' src='cap.vtt' srclang='en' label='English' default> child for every <video>. Subtitles (kind='subtitles') translate dialog but skip sound effects; captions include both - WCAG asks for captions, not subtitles.

<video\b(?![^>]*>(?:(?!</video>)[\s\S])*<track[^>]+\bkind\s*=\s*['"]captions['"])[^>]*>
link
#chrome-y-multi-stop-gradient HIGH Color

Chrome-y multi-stop gradient

Why bad

Three-plus color stops in a linear-gradient is the v0 / Cursor / generic-AI default; reads as chrome-y template output rather than a deliberate brand decision.

Fix

Use a two-stop gradient (or a single solid) with hue spread under 60 degrees. If depth is needed, layer a low-opacity overlay instead of stacking stops.

linear-gradient\([^)]+,[^)]+,[^)]+,[^)]+,
link
#dark-text-on-dark-card HIGH Color

Low-contrast text on card

Why bad

Dark text token (zinc-500/600/700) on a dark surface fails WCAG AA 4.5:1 contrast and signals the dark mode pairing was never tested.

Fix

Test contrast on both themes. On dark surfaces use text-zinc-100 to text-zinc-300; reserve 500-700 for tertiary captions on light surfaces.

(?:bg-zinc-(?:[89]00|950)|bg-slate-(?:[89]00|950)|bg-gray-(?:[89]00|950)|bg-black|background:\s*#0[0-9a-f]{5})[^"';]*(?:text-zinc-(?:[5-7]00)|text-slate-(?:[5-7]00)|text-gray-(?:[5-7]00)|color:\s*#[4-6][0-9a-f]{5})
link
#gradient-on-text-rainbow HIGH Color

Rainbow gradient on display text

Why bad

background-clip: text on a multi-stop linear-gradient is the AI hero's chrome-y rainbow word - the loudest signal that no taste decision was made on color.

Fix

Use one solid color on display text. If gradient text is essential, two stops in analogous hues at modest saturation, never three-plus rainbow stops.

background-clip\s*:\s*text[^;]*;\s*background[^;]*linear-gradient
link
#purple-to-blue-gradient HIGH Color

Default purple-to-blue AI gradient

Why bad

Purple-to-blue gradient on white is the strongest visual fingerprint of unconstrained model output and reads as template-marketplace AI slop.

Fix

Use a single restrained accent (Emerald, Electric Blue, Deep Rose, Amber) against neutrals; keep gradient hue spread under 60 degrees if used at all.

bg-gradient-to-[a-z]+\s+from-(purple|violet|fuchsia|indigo)-(400|500|600)\s+(?:via-[a-z]+-[0-9]+\s+)?to-(blue|sky|cyan)-(400|500|600)|linear-gradient\([^)]*#(7c3aed|8b5cf6|a855f7|6366f1)[^)]*#(3b82f6|2563eb|0ea5e9)[^)]*\)
link
#cta-text-read-more HIGH Content

Generic CTA text: Read more / View more / See more

Why bad

'Read more' / 'View more' / 'See more' tells the user nothing about the destination and fails screen-reader scan-by-link mode (where users hear only link text). Every card on the page reads the same.

Fix

Make link text describe the destination: 'Read the launch post', 'See all 145 rules', 'View the changelog'. The link should make sense out of context.

<(?:a|button)[^>]*>\s*(?:Read\s+more|View\s+more|See\s+more|Find\s+out\s+more|More\s+info|Discover\s+more)\s*</(?:a|button)>
link
#emoji-in-ui HIGH Content

Emoji used as UI element

Why bad

Emojis render inconsistently across platforms, ignore brand color, and signal informality where SVG icons signal craft.

Fix

Inline SVG from Lucide, Feather, Phosphor, or Heroicons at 1.5-2px stroke with currentColor. Whitelist U+2713 check and U+2318 command only.

<(?:button|a|h[1-6]|span|div|p|li)[^>]*>[^<]*(?:[\U0001F300-\U0001F9FF]|[\U0001F600-\U0001F64F]|[\U0001F680-\U0001F6FF]|[\u2600-\u26FF]|[\u2700-\u27BF])
link
#filler-marketing-verbs HIGH Content

Filler marketing verb in headline

Why bad

Elevate, Seamless, Unleash, Revolutionize, Empower, Supercharge, Transform - the unmistakable fingerprint of unedited marketing copy generated without a real product in mind.

Fix

Name the specific action and the specific outcome. Cut the verb if it isn't carrying weight. The reader knows what the product does only if you say what the product does.

<h[1-3][^>]*>[^<]*(?:Elevate|Seamless|Unleash|Revolutionize|Empower|Supercharge|Transform)[^<]*<
link
#generic-cta-text HIGH Content

Generic CTA text (Click here, Learn more)

Why bad

Click here / Learn more / Get started reveals nothing about the destination and fails screen-reader scan-by-link - users hear a list of identical labels with no context.

Fix

Name the specific action: Read pricing, Start free trial, View dashboard, Open API docs. The link text should make sense out of context.

<(?:a|button)[^>]*>\s*(?:Click here|Learn more|Get started)\s*</(?:a|button)>
link
#icon-emoji-stamp HIGH Content

Emoji used as icon stamp

Why bad

Emoji where an SVG icon belongs breaks brand color, scales poorly, and renders differently on every OS.

Fix

Inline SVG icon with currentColor and a consistent stroke width across the surface.

class="[^"]*(?:icon|stamp|badge|achievement|reward)[^"]*"[^>]*>\s*(?:[\U0001F300-\U0001F9FF]|[\u2600-\u27BF]|[\U0001F600-\U0001F6FF])
link
#lorem-ipsum-leak HIGH Content

Lorem ipsum in shipping code

Why bad

Lorem ipsum in production source code is unshipped placeholder content - the most obvious draft-state leak.

Fix

Write real copy that makes a specific claim about the product. If you can't write it yet, mark the block with TODO and a content owner.

\b(?:Lorem\s+ipsum|lorem\s+ipsum\s+dolor|consectetur\s+adipiscing|sed\s+do\s+eiusmod)\b
link
#picsum-photos-seed HIGH Content

picsum.photos lorem-picsum placeholder

Why bad

picsum.photos serves random rotating Lorem Picsum stock images - the design never settles, and the same hero photo on a Stripe-like landing page is an instant tell that the visuals were never chosen.

Fix

Commission, license, or generate the real image. If you need a stable placeholder, generate one once and self-host it under /assets so it stops drifting between deploys.

\b(?:https?:)?//picsum\.photos/
link
#placeholder-as-pricing HIGH Content

Default AI pricing tier amounts

Why bad

$9 / $19 / $29 / $49 / $99 / $199 are the prices AI reaches for when no real pricing exists - unconvincing placeholders that signal nobody priced the product.

Fix

Use the real price you charge, with the real currency and the real billing cadence. If pricing isn't set yet, mark the tier as TBD with an owner, not a placeholder.

\$(?:9|19|29|49|99|199)/(?:mo|month)
link
#placeholder-com-direct HIGH Content

placeholder.com direct placeholder

Why bad

placeholder.com URLs ship the gray box with the dimensions printed inside - the universal 'I forgot to swap in real art' tell. Generators emit these whenever they don't know what image to use.

Fix

Replace with a real asset under /assets, /og, or your image CDN. For dev-only scaffolds, use a local 1x1 transparent PNG or an inline SVG empty state with a real label.

\b(?:https?:)?//(?:www\.)?placeholder\.com/
link
#placeholder-via-com HIGH Content

via.placeholder.com placeholder image

Why bad

via.placeholder.com is the default stock placeholder URL that AI generators reach for - signals the design got shipped with the scaffolding still in place. Immediate trust kill on a landing page.

Fix

Use real artwork, real product screenshots, or a domain-specific empty-state SVG. If you genuinely need a placeholder for a demo, generate it locally and serve it from your own static path.

\b(?:https?:)?//via\.placeholder\.com/
link
#placekitten-com HIGH Content

placekitten.com kitten placeholder

Why bad

placekitten.com is the joke-placeholder service - shipping it to production says nobody reviewed the page before deploy. The kitten is funny in the design doc and devastating on the live landing page.

Fix

Replace every kitten URL with the real asset for that surface. If the page truly needs a stand-in, use a brand-aware blank state with copy explaining what should be there.

\b(?:https?:)?//(?:www\.)?placekitten\.com/
link
#testimonial-fake-five-stars HIGH Content

Hardcoded five-star testimonial

Why bad

Five hardcoded stars next to a fake quote is the lowest-trust pattern on the web; users have learned to discount it on sight.

Fix

Show real, named, attributable testimonials with company affiliation and a specific outcome. No star count unless aggregated from a real review system.

(?:\u2605{5}|★{5}|⭐{5}|\*{5})|<(?:span|div|p)[^>]*class="[^"]*stars?[^"]*"[^>]*>\s*(?:\u2605|★){4,}
link
#unsplash-photo-id-no-alt HIGH Content

Unsplash photo URL with empty or missing alt

Why bad

An images.unsplash.com/photo-* URL with no alt (or alt="") is the stock-photo placeholder fingerprint - signals the generator pasted the first credible image without choosing it for the content. Screen-reader users hear nothing; the page reads as filler.

Fix

Replace with a real product or brand image and write an alt that describes the content (alt="Customer service team reviewing a dashboard"). If the image stays as decoration only, set alt="" deliberately AND justify why a stock photo earns a spot on the surface.

<img[^>]+src\s*=\s*['"][^'"]*images\.unsplash\.com/photo-[^'"]+['"](?![^>]+alt\s*=\s*['"][^'"]+['"])
link
#h-screen-no-dvh-fallback HIGH Layout

100vh without 100dvh fallback for mobile

Why bad

100vh on mobile includes the browser chrome that retracts on scroll, so the layout jumps as the address bar collapses - dynamic viewport units (dvh) fix this.

Fix

Use height: 100dvh or h-[100dvh] as the primary, with h-screen / 100vh as the legacy fallback for browsers that lack dvh.

(?:height\s*:\s*100vh|class=[^>]*h-screen)(?![^}]*100dvh)
link
#three-equal-card-grid HIGH Layout

Three equal cards in a row

Why bad

Three equal cards with three icons and three short paragraphs is the safest default the generator reaches for and the strongest layout fingerprint in AI-generated marketing surfaces.

Fix

Use asymmetric layouts: bento grids, 2-and-1 splits, 4 with one spanning width. Vary card size by content priority.

(?:grid-cols-3|grid-template-columns:\s*repeat\(\s*3\s*,\s*(?:1fr|minmax))[^>]*>(?:\s*<[^/][^>]*class="[^"]*(?:card|feature)[^"]*"[^>]*>[\s\S]*?</[^>]+>\s*){3}
link
#animating-layout-properties HIGH Motion

Transition on a layout property

Why bad

Animating width, height, top, left, margin, padding triggers layout and paint on every frame - the dropped-frame jank pattern that makes the UI feel cheap.

Fix

Animate transform (translate, scale) and opacity only. Use scale instead of width, translate instead of top/left. The compositor thanks you.

transition[^;]*:\s*[^;]*(?:width|height|top|left|margin|padding)[^;]*;
link
#autoplay-without-muted HIGH Motion

<video autoplay> without muted attribute

Why bad

Browsers (Chrome, Safari, Firefox, Edge) block autoplay on any <video> that isn't muted - the video silently fails to play and the layout shifts when the user manually starts it. Also a sound-blast hazard if it ever does autoplay in an older client.

Fix

If you want hero video to autoplay, add muted (and usually playsinline, loop, preload='auto'). If you genuinely need audio, drop autoplay and require an explicit user gesture.

<video(?![^>]*\bmuted\b)[^>]*\bautoplay\b[^>]*>
link
#marquee-tag HIGH Motion

<marquee> element used

Why bad

<marquee> is deprecated, has no prefers-reduced-motion support, scrolls continuously regardless of user motion preferences, and causes nausea/dizziness for users with vestibular disorders. WCAG 2.2.2 (Pause, Stop, Hide) fail and a strong AI-shortcut signal.

Fix

Use CSS animation with prefers-reduced-motion: reduce { animation: none } as a respect-the-user fallback. For ticker/news content prefer a paginated carousel with explicit prev/next controls.

<marquee\b
link
#img-no-dimensions HIGH Performance

Image without width and height attributes

Why bad

An <img> without width and height attributes causes Cumulative Layout Shift as the image loads and the page reflows - the single biggest CLS regression on most sites.

Fix

Always set width and height (the intrinsic pixel dimensions) on every <img>, even when styled with CSS. The browser uses them to reserve space.

<img(?![^>]+\bwidth=)[^>]+>
link
#console-log-leak HIGH Quality

console.log in component code

Why bad

console.log shipped to production leaks state to anyone with DevTools open and signals the build didn't go through a real review.

Fix

Remove debug logs before shipping. If you need structured logging, use a logger module that's tree-shaken out of production builds.

console\.(?:log|warn|debug|info)\s*\(
link
#font-weight-numeric-100-or-900-on-body HIGH Typography

Body or paragraph font-weight set to 100 or 900

Why bad

Setting body or paragraph font-weight to 100 (Thin) makes long-form reading nearly impossible at any size below 24px - the strokes literally disappear on most displays. 900 (Black) is the opposite failure: every paragraph reads as shouting and tires the eye after one screen. Both extremes signal nobody actually read the text in situ.

Fix

Body weight should be 400 (Regular) or 450-500 for a slightly stronger feel. Reserve 100/200 (Thin/ExtraLight) for large hero display type only, and 800/900 (ExtraBold/Black) for short callouts or numerals - never paragraphs.

(?:^|[\s,}>+~])(?:body|p|li|main|article|\.(?:body|prose|text|content))[^{}]*\{[^}]*font-weight\s*:\s*(?:100|900)\s*[;}]
link
#inter-as-display HIGH Typography

Inter used as display font

Why bad

Inter is a body font tuned for screen legibility at small sizes; deployed as display it reads as the default startup-landing fingerprint.

Fix

Pair Inter (body) with a distinctive display face: Geist, Satoshi, Cabinet Grotesk, General Sans, Outfit, or a brand-specific variable sans.

font-family:\s*['"]?Inter['"]?[^;}]*[;}][^{]*(?:font-size:\s*([4-9]\d|\d{3,})px|\btext-(5xl|6xl|7xl|8xl|9xl)\b)
link
#emoji-bullet-marker HIGH Visual

Emoji at start of list item

Why bad

An emoji at the start of every list item is the AI signature - check marks, sparkles, rockets, party poppers used as bullet markers signal a generator that confused decoration with hierarchy.

Fix

Use the native list disc, an SVG check icon, or a numeric counter. Reserve the U+2713 check and U+2318 command as allowed dingbats.

<li[^>]*>\s*[\U0001F300-\U0001F9FF\u2600-\u2712\u2714-\u27BF]
link
#lone-emoji-as-icon HIGH Visual

Lone emoji used as functional icon

Why bad

A bare emoji standing in for an icon renders inconsistently across platforms, ignores brand color, and is announced unpredictably by screen readers.

Fix

Use inline SVG (Lucide, Feather, Phosphor, Heroicons) at 1.5-2px stroke with currentColor. Add aria-label when the icon carries meaning.

>[\u2700-\u27BF\U0001F300-\U0001F9FF](?:</)
link
#aria-busy-without-aria-live MED A11y

aria-busy='true' with no aria-live companion

Why bad

aria-busy='true' tells assistive tech to suspend updates inside a region, but without an aria-live region around it the eventual content swap is never announced - the user is left in silence after the spinner disappears.

Fix

Pair aria-busy='true' with aria-live='polite' (or role='status') on the same node or a parent region, so the post-load content is announced once aria-busy flips back to false.

<[^>]+\baria-busy\s*=\s*['"]true['"](?![^>]*\baria-live=)[^>]*>
link
#button-no-type MED A11y

Button missing type attribute

Why bad

A <button> inside a <form> without type="button" defaults to type="submit" and silently submits the form on every click.

Fix

Always set type="button" or type="submit" explicitly. The default is a footgun, not a feature.

<button(?![^>]*\btype=)[^>]*>
link
#cursor-pointer-non-interactive MED A11y

cursor:pointer on non-interactive element

Why bad

A <div> or <span> with cursor: pointer promises clickable behavior to sighted mouse users while keyboard and screen-reader users get nothing - a hidden interaction.

Fix

If it clicks, make it a <button> or <a href>. If you keep the <div>, add role and tabindex and a keydown handler - the cursor change is the smallest signal a real control owes.

<(?:div|span)[^>]+(?:class=[^>]+cursor-pointer|style=[^>]+cursor\s*:\s*pointer)
link
#fieldset-without-legend MED A11y

<fieldset> missing a <legend> child

Why bad

<fieldset> without a <legend> gives the grouped controls a semantic container but no group name - screen readers announce each control without the shared context (e.g. 'shipping address - street' becomes just 'street'). The dev got halfway to the WCAG 1.3.1 grouping pattern then stopped.

Fix

First child of every <fieldset> is <legend>Group label</legend>. Visually hide the legend with .sr-only if the design doesn't show a heading, but never omit it.

<fieldset[^>]*>\s*(?!<legend\b)
link
#generic-alt-decorative-not-empty MED A11y

alt='decorative' instead of empty alt

Why bad

alt='decorative' announces the literal word 'decorative' to a screen reader user - the opposite of what was intended. The correct way to mark a decorative image is alt='' (empty string).

Fix

Use alt='' (empty quotes) for purely presentational images so the screen reader skips them. If the image conveys any meaning at all, give it a real description instead.

<img[^>]*\balt\s*=\s*['\"](?:decorative|decoration|spacer|divider|ornament)['\"]
link
#heading-skip-h1-h3 MED A11y

Skipped heading level

Why bad

Heading hierarchy gaps (h1 then h3, or h3 with no h1/h2 preceding) break the screen-reader landmark tree and the SEO outline.

Fix

h1 once per page; nest h2/h3/h4 in order. If you need a small visual heading without a logical level, use a styled <p> instead.

<h1[^>]*>[\s\S]*?</h1>[\s\S]*?<h3[^>]*>(?![\s\S]*?<h2)|<h2[^>]*>[\s\S]*?</h2>[\s\S]*?<h4[^>]*>(?![\s\S]*?<h3)|<h3[^>]*>(?:(?!<h[12]).)*?</h3>
link
#infinite-scroll-no-pagination MED A11y

Infinite scroll without keyboard fallback

Why bad

Pure infinite scroll with no Load more button locks out keyboard users and breaks back-button history - WCAG 2.4 failure plus a usability one.

Fix

Ship infinite scroll AND a visible Load more button. The button is the keyboard path; the observer is the convenience layer.

(?:IntersectionObserver|useInfiniteScroll|InfiniteScroll|on(?:Scroll|EndReached))[\s\S]{0,500}?(?:loadMore|fetchMore|loadNext)(?![\s\S]{0,800}?<button[^>]*>(?:Load|Show|More))
link
#inline-style-display-none-no-aria MED A11y

Interactive element hidden via inline display:none without aria-hidden

Why bad

An interactive element with style='display:none' is removed from the visual flow but its DOM node and event handlers remain. Without aria-hidden='true' some screen readers still expose it to virtual cursors, and JS event delegation can still fire clicks on it - inconsistent state across users.

Fix

Toggle visibility with a class (.is-hidden { display:none }) and conditionally render via the framework (v-if / x-if / @if) for true removal. If you must use inline display:none on an interactive element, also set aria-hidden='true' AND tabindex='-1'.

<(?:button|a|input|select|textarea)(?![^>]*\baria-hidden\s*=\s*['"]true['"])[^>]*\bstyle\s*=\s*['"][^'"]*display\s*:\s*none[^'"]*['"][^>]*>
link
#safe-area-inset-only-bottom MED A11y

safe-area-inset-bottom without top/left/right siblings

Why bad

Reaching only for env(safe-area-inset-bottom) signals the dev copied the iPhone-X home-indicator fix without thinking about the notch (top), Dynamic Island, landscape mode, or rotated devices where left/right inset matters. The layout punches into hardware on three sides while pretending to be device-safe.

Fix

If the surface goes edge-to-edge, set all four insets (or `padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)` and let CSS pick the active ones). For a bottom-fixed nav, that's fine - but verify landscape with rotation doesn't expose the issue.

env\(\s*safe-area-inset-bottom\s*\)(?![\s\S]{0,400}env\(\s*safe-area-inset-(?:top|left|right)\s*\))
link
#screen-reader-only-without-class MED A11y

Skip-to-content link with no sr-only / visually-hidden class

Why bad

A 'Skip to content' link without an .sr-only / .visually-hidden / .skip-link class fallback is visually shown to all users as a stray link at the top-left of the page - signals the dev knew the accessibility convention by name but didn't apply the actual technique. The link should be visually hidden until keyboard-focused, at which point it slides into view.

Fix

Wrap with the standard sr-only pattern: <a href='#main' class='sr-only focus:not-sr-only focus:fixed focus:top-0 focus:left-0 focus:p-4 focus:bg-white focus:text-black focus:z-50'>Skip to content</a>. For Tailwind that's the canonical recipe; for custom CSS apply `position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0);` then reveal on :focus.

<a[^>]+href\s*=\s*['"]#(?:main|content|main-content|skip)['"][^>]*(?:class\s*=\s*['"](?![^'"]*(?:sr-only|visually-hidden|skip-link|screen-reader))[^'"]*['"])?[^>]*>\
link
#card-glow-purple-shadow MED Color

Purple glow shadow on cards

Why bad

Purple-tinted shadows on cards are the second-strongest 'AI premium' fingerprint after the gradient itself.

Fix

Use neutral diffusion shadow: shadow-[0_20px_40px_-15px_rgba(0,0,0,0.08)]. Color comes from the surface, not the shadow.

box-shadow:[^;]*rgba?\(\s*(?:12[0-9]|13[0-9]|14[0-9])\s*,\s*(?:5[0-9]|6[0-9]|7[0-9])\s*,\s*(?:2[0-9]\d|2[3-5]\d)|shadow-(?:purple|violet|fuchsia|indigo)-(?:400|500|600)
link
#glass-without-fallback MED Color

backdrop-filter blur without background fallback

Why bad

backdrop-filter is unsupported on Firefox and older Safari - without a translucent background fallback, the surface renders fully transparent and content underneath bleeds through.

Fix

Always pair backdrop-filter: blur with a background-color (e.g. background: rgba(255,255,255,0.7)). The bg is the fallback; the blur is the upgrade.

backdrop-filter\s*:\s*blur[^;]+;(?![^}]*background)
link
#gradient-mesh-purple-pink MED Color

Purple-pink mesh gradient hero

Why bad

Purple-to-pink mesh gradient is the second-most fingerprinted AI-hero pattern after blue-to-purple - 'I generated a landing page' aesthetic.

Fix

Single accent against neutral canvas. If a hero needs depth, layer subtle noise or a single low-saturation analogous gradient under 60deg hue spread.

radial-gradient\([^)]*#(?:a855f7|c084fc|d946ef|e879f9|ec4899|f472b6)[^)]*\)|conic-gradient\([^)]*(?:purple|fuchsia|pink|violet)[^)]*\)|bg-gradient-to-[a-z]+\s+from-(?:purple|fuchsia|violet)-(?:400|500|600)\s+to-(?:pink|rose|fuchsia)-(?:400|
link
#gradient-text-rainbow MED Color

Multi-stop gradient text

Why bad

Three-stop rainbow text on a hero word is the strongest 'AI hero' tell after the purple-to-blue gradient.

Fix

Use a single solid color for headlines. If gradient text is essential, two stops in analogous hues at modest saturation.

(?:bg-clip-text|background-clip:\s*text)[^"';]*(?:bg-gradient-to-[a-z]+\s+from-[a-z]+-[0-9]+\s+via-[a-z]+-[0-9]+\s+to-[a-z]+-[0-9]+|linear-gradient\([^)]*,[^)]*,[^)]*,[^)]*\))
link
#before-after-100-percent MED Content

Magnitude-default before-after claim

Why bad

0 to 100 / 100% guaranteed / 100% effective are the magnitude-by-default phrases AI reaches for - readers have learned to discount them.

Fix

Quote the real spread: 'from 14 manual steps to 3 automated'. If you can't measure it, don't claim it.

(?:0 to 100|100% (?:guaranteed|effective|accurate|certain))
link
#cta-text-tap-continue-go MED Content

Generic CTA text: Tap here / Continue / Go

Why bad

'Tap here' / 'Continue' / 'Go' / 'Next' name an action but not a target - the screen reader user has no idea where the link lands. 'Submit now' / 'Sign up now' add urgency without value. Generic CTA fingerprint.

Fix

Replace with destination-aware copy: 'Install the plugin', 'Open the changelog', 'See the comparison table'. The CTA should be a tiny one-line promise.

<(?:a|button)[^>]*>\s*(?:Tap\s+here|Continue|Go|Next|Submit\s+now|Sign\s+up\s+now)\s*</(?:a|button)>
link
#fake-line-of-code-count MED Content

Unsourced large-number marketing claim

Why bad

We saved 10,000 hours / Generated 1,000,000 lines of code / Reduced 50,000 tickets - large round numbers with no citation that signal the magnitude was invented to sound impressive.

Fix

Use the real measured number with the real source: 'In Q1 2026, customers saved 4,217 support hours (mean across 38 teams)'. Real numbers are messy and specific.

(?:saved|generated|reduced|increased)\s+\d{1,3}(?:[,.]?\d{3})+\s+(?:hours|lines|users|teams)
link
#fake-name-john-doe MED Content

Generic placeholder names

Why bad

John/Jane Doe and their cousins signal that nobody thought about who would actually use the product - immediate AI-generated-tutorial vibe.

Fix

Use plausible names that fit the target market: Maya Iqbal, Adam Levin, Wen Zhang, Layla Haddad. Match region for regional products.

\b(?:John\s+Doe|Jane\s+Doe|John\s+Smith|Jane\s+Smith|Sarah\s+Chan|Test\s+User|Demo\s+User|Foo\s+Bar)\b|\b(?:john\.doe|jane\.doe|test\.user)@(?:example|test|demo)\.com\b
link
#marketing-buzz-ai-powered-driven MED Content

Buzzword: AI-powered / AI-driven

Why bad

'AI-powered' has become the new 'cloud-based' - every landing page claims it, nobody asks what model, what data, what task. Empty by saturation.

Fix

Name the model and the function: 'uses Claude to generate brand-aware tokens', 'embeddings from text-embedding-3-small for palette match'. Or drop the qualifier and just describe the feature.

\bAI[\s-]?(?:powered|driven|enabled|enhanced|first)\b
link
#marketing-buzz-best-in-class MED Content

Buzzword: best-in-class

Why bad

'Best-in-class' is a vendor pitch phrase with no defined benchmark - reads as marketing autopilot. If you really are best at something, name the comparison.

Fix

Replace with a measurable claim: 'fastest CI lint at 90ms', 'most rules in the category at 145'. If no comparison exists, drop the phrase.

\bbest[\s-]in[\s-]class\b
link
#marketing-buzz-cutting-edge MED Content

Buzzword: cutting-edge

Why bad

'Cutting-edge' is the laziest tech adjective in marketing copy - empty signal that means 'we don't want to commit to a specific claim'. Skipped by anyone reading critically.

Fix

Replace with a concrete capability: 'ships in under 50ms', 'covers 145 anti-patterns', 'runs inside Cursor and Windsurf'. Specifics beat adjectives.

\bcutting[\s-]edge\b
link
#marketing-buzz-game-changing MED Content

Buzzword: game-changing / game-changer

Why bad

'Game-changing' is the most overused tech-PR adjective in the last decade - means the writer wanted hype without committing to a specific shift. Reads instantly as filler.

Fix

Describe the shift in one sentence: 'design intelligence in the model context, not just in Figma'. Concrete > grandiose.

\bgame[\s-]chang(?:er|ing|e)\b
link
#marketing-buzz-industry-leading MED Content

Buzzword: industry-leading

Why bad

'Industry-leading' is unverifiable by definition - every category leader and every follower uses it identically. Removes signal, adds slop.

Fix

Cite the specific lead: 'first design linter in Claude Code marketplace', '4x more rules than the next entrant'. No claim, no superlative.

\bindustry[\s-]leading\b
link
#marketing-buzz-leverage-ai-harness MED Content

Buzzword: leverage / harness the power of

Why bad

'Leverage AI', 'harness the power of', 'tap into the power of' are LinkedIn-thinkpiece formulas - immediately telegraphs generated marketing copy with no specifics.

Fix

Use 'use' or name the verb directly: 'uses Claude for layout suggestions'. Plain verbs beat power-of-X scaffolding.

\b(?:leverage(?:\s+the\s+power\s+of)?\s+AI|harness(?:\s+the\s+power\s+of)?|tap\s+into\s+the\s+power\s+of)\b
link
#marketing-buzz-next-generation MED Content

Buzzword: next-generation

Why bad

'Next-generation' is a category-leader cliche that names nothing - every press release in the last 30 years has claimed it. The phrase signals unedited marketing copy and shows the writer skipped the work of saying what is actually new.

Fix

Name the specific capability that is new (e.g., 'first MCP-native design engine', 'first regex linter with 145 rules'). If you can't name a specific advance, drop the qualifier.

\bnext[\s-]generation\b
link
#marketing-buzz-world-class MED Content

Buzzword: world-class

Why bad

'World-class' is consultancy filler - signals the writer either had nothing concrete to say or was scared to commit. Costs trust in a landing page.

Fix

Name the specific bar you cleared: 'tested in 17 IDEs', 'WCAG AA on every component'. If there's no bar, the word does not belong.

\bworld[\s-]class\b
link
#marketing-unlock-unleash-potential MED Content

Buzzword: unlock / unleash the potential, reach new heights

Why bad

'Unlock the potential' / 'unleash your creativity' / 'reach new heights' / 'take it to the next level' / 'the future is here' is the strongest cluster of LinkedIn-thinkpiece phrases in any landing page - immediate AI-generated marketing copy tell.

Fix

Describe what the user can do that they could not do before: 'lint AI-generated code in 50ms', 'install in three IDEs from one command'. Specifics beat 'unlock' every time.

\b(?:unlock(?:\s+the)?\s+(?:potential|power|magic|future)|unleash\s+(?:your|the)\s+(?:potential|creativity|power)|reach\s+new\s+heights|take\s+(?:it|things)\s+to\s+the\s+next\s+level|the\s+future\s+is\s+here)\b
link
#round-number-stats MED Content

Round number marketing stat

Why bad

99.99%, 10x faster, 100% guaranteed - the suspiciously round numbers AI invents to sound credible. Real measurements are messy and specific.

Fix

Use the real number you measured: 99.93% uptime over Q1, 4.2x faster median build, refunded 218 of 244 reported issues. Specificity is the signal.

(?:99\.99%|99\.9%|10x faster|100% guaranteed|10x more)
link
#timestamp-just-now MED Content

Fake just-now timestamp in static markup

Why bad

Just now / moments ago in static HTML is a fake activity signal - it stays Just now forever and signals nobody wired the real time-ago helper.

Fix

Render real timestamps via a time-ago helper bound to a real source (Date.now, server time, last_event_at). If the surface is static, use a real date string instead.

>\s*(?:Just now|Moments ago|seconds ago|few seconds ago)\s*<
link
#trust-badge-no-source MED Content

Unsourced trust-by claim

Why bad

Trusted by 1000+ teams / Used by Fortune 500 / Loved by 50k developers - unsourced trust claims that signal the generator invented the number to fill space.

Fix

Name the actual companies, link to the actual case studies, or show the actual review aggregate. Specificity beats magnitude every time.

(?:Trusted by|Used by|Loved by|Joined by)\s+\d+
link
#aspect-ratio-1-1-default MED Layout

Square aspect-ratio as default everywhere

Why bad

Every image clipped to 1:1 is the Instagram-grid AI fingerprint - signals the generator picked the safest crop instead of letting content set the frame. Real editorial layouts use varied ratios (3:2, 4:5, 16:9, 21:9) to create rhythm.

Fix

Vary aspect ratio by content: 3:2 for product shots, 4:5 for portraits, 16:9 for hero or video. Reserve 1:1 for genuine grid contexts (avatars, social previews).

<img[^>]+(?:aspect-square|aspect-\[1/1\]|aspect-ratio\s*:\s*1(?:\s*/\s*1)?\b)|aspect-ratio\s*:\s*1\s*/\s*1\b
link
#avatar-stack-overlapping MED Layout

Generic overlapping avatar stack

Why bad

Three overlapping circular avatars in the nav as 'our team' or 'join 10k users' is a content-marketing template tell.

Fix

Use real customer logos, named testimonials with quotes, or specific signal (Used by 312 engineering teams at Series-A companies).

class="[^"]*-space-x-[0-9]+[^"]*"[^>]*>(?:\s*<(?:img|div)[^>]*class="[^"]*(?:avatar|rounded-full)[^"]*"[^>]*/?>\s*){3,}
link
#centered-everything-hero MED Layout

Centered hero composition

Why bad

Centered headline + centered subtitle + centered button stack is the laziest hero composition and signals the generator picked it when it couldn't find a better layout.

Fix

Use left-aligned hero with editorial 7-5 or 8-4 grid split; let the imagery earn its own column.

<(?:section|div|header)[^>]*class="[^"]*\b(?:hero|banner)\b[^"]*"[^>]*>[\s\S]{0,800}?<h1[^>]*class="[^"]*\b(?:text-center|items-center\s+justify-center)\b[^"]*"|<(?:section|div)[^
link
#cta-buttons-clustered-in-hero MED Layout

Three-plus CTA buttons clustered in hero

Why bad

Three or more CTA-styled buttons within a single hero is decision paralysis - the AI's compromise when it can't choose the primary action. Conversion research is unanimous: one primary + one secondary outperforms three competing primaries.

Fix

Pick one primary CTA. Demote the rest to text links or move them to a secondary surface. If you genuinely need three actions, restructure the hero into a comparison or a stepped layout so the primary is unambiguous.

class=['"][^'"]*\b(?:hero|banner)\b[^'"]*['"][\s\S]{0,1200}?<(?:a|button)[^>]+class=['"][^'"]*\bbtn\b[^'"]*['"][\s\S]{0,400}?<(?:a|button)[^>]+class
link
#display-table-for-layout MED Layout

display: table used for layout outside data tables

Why bad

`display: table` on a non-<table> element is a 2008-era IE6 hack for vertical centering before flexbox existed. It defeats wrapping, ignores gap, breaks responsive collapse, and confuses screen-reader table-navigation modes when applied to non-tabular content.

Fix

Use flexbox or grid: `display: grid; place-items: center;` for the centering case, or `display: flex; align-items: center` for one-axis. Reserve `display: table` for actual <table> elements that need CSS-controlled rendering.

(?:^|[\s,>+~}])(?!table[\s,{])(?:\.[a-zA-Z_-][\w-]*|#[a-zA-Z_-][\w-]*|div|section|main|aside|article|header|footer|nav|ul|ol|li|span|a|button)[^{}]*\{[^}]*display\s*:\s*table\s*[;}]
link
#fixed-height-text-block MED Layout

Pixel height on a text container

Why bad

Fixing a pixel height on a container that also styles text breaks reflow at every other zoom level and font scale - the layout is one breakpoint away from clipping.

Fix

Use min-height for floor protection, line-height + padding for vertical rhythm, and let content determine height. Never lock text height in px.

(?:p|div|span)[^{]*\{[^}]*height\s*:\s*\d+px[^}]*color
link
#logo-cloud-no-real-logos MED Layout

Logo cloud image with empty alt

Why bad

An <img> inside a Trusted by / logo-cloud section with alt="" is an unsourced trust claim with no machine-readable company name - the logo is decorative even when the surface is selling credibility.

Fix

Set alt to the real company name (alt="Stripe"), and only use the logo if you have permission. If you don't have permission, you don't have the trust signal.

(?:trusted-by|logo-cloud|companies)[^{]*\{[^}]*<img[^>]+alt=\"\"
link
#negative-margin-pull-left-right MED Layout

Large negative horizontal margin on body content

Why bad

A negative margin of -10px or more on margin-left/right (or the logical equivalent) is almost always pulling content outside its container, which causes horizontal scroll on narrow viewports and silent overflow under RTL. Classic AI shortcut for 'align this to the edge' that should be solved by negative space, not negative margins.

Fix

Use grid/flex with gap and align-items / justify-self for edge alignment. If you genuinely need to escape the gutter, use a calc() with the gutter token (margin-inline: calc(var(--gutter) * -1)) so the escape stays in sync with the grid.

\bmargin-(?:left|right|inline-start|inline-end)\s*:\s*-(?:[1-9]\d|\d{3,})px\b
link
#animation-duration-too-long MED Motion

Micro-interaction with animation-duration over 800ms

Why bad

A hover, focus, or state-change animation longer than 800ms feels sluggish - users perceive UI under 200ms as instant, under 500ms as snappy, over 800ms as broken or laggy. Long durations on micro-interactions signal no taste decision was made.

Fix

Cap micro-interactions at 150-400ms. Reserve longer durations (800ms+) for narrative scenes, page transitions, or deliberate orchestration - never for a button hover or icon flip.

animation-duration\s*:\s*(?:[89]\d{2}|[1-9]\d{3,})ms\b|animation-duration\s*:\s*(?:[1-9]|[1-9]\.\d+)s\b|animation\s*:[^;]*\s(?:[89]\d{2}|[1-9]\d{3,})ms\b|animation\s*:[^;]*\s(?:[1-9]|[1-9]\.\d+)s\b
link
#cta-arrow-rightward-bouncing MED Motion

Bouncing arrow on CTA

Why bad

Bouncing arrow on a CTA is the desperate-attention pattern that signals the copy itself isn't doing the work.

Fix

Let the CTA copy carry the action (Start a 14-day trial, Read the deployment guide). If you need an arrow, use a static SVG that nudges 2-4px on hover.

<(?:button|a)[^>]*>[^<]*(?:→|->|&rarr;|&#8594;)[^<]*</(?:button|a)>|class="[^"]*\banimate-bounce\b[^"]*"[^>]*>\s*(?:→|→|&rarr;|<svg)
link
#transition-duration-500ms-or-longer MED Motion

Interactive transition-duration 500ms or longer

Why bad

A `transition-duration` of 500ms or more on hover, focus, or active states feels laggy - the human perception threshold for 'instant' is 100ms and for 'snappy' is 200ms. Users move the mouse 8-12 times per second; a 500ms transition means the hover state hasn't finished settling before the cursor moves on. Stretched transitions are the AI default for 'smooth.'

Fix

Cap interactive transitions at 150-300ms. Use 200ms for color/background/border hovers, 250-300ms for transform/scale, and 100-150ms for fast snap states (focus rings, active depressions). Reserve 500ms+ for scene transitions, drawer slides, and page-level state changes - never single-element hovers.

transition-duration\s*:\s*(?:[5-9]\d{2}|[1-9]\d{3,})ms\b|transition-duration\s*:\s*(?:0?\.[5-9]|[1-9](?:\.\d+)?)s\b|transition\s*:[^;]*\s(?:[5-9]\d{2}|[1-9]\d{3,})ms\b|transition\s*:[^;]*\s(?:0?\.[5-9]|[1-9](?:\.\d+)?)s\b
link
#transition-property-all MED Motion

transition: all (lazy property list)

Why bad

transition: all is the lazy default that animates every property change - including layout-triggering ones like width/height/margin - causing jank and unintended motion. It also fires on properties the author never meant to transition.

Fix

List the exact properties: transition: transform 200ms ease, opacity 200ms ease. In Tailwind, prefer transition-colors, transition-opacity, transition-transform over transition-all.

transition\s*:\s*all\b|transition-property\s*:\s*all\b|\btransition-all\b
link
#event-listener-no-passive-on-scroll MED Performance

addEventListener('scroll' | 'touchmove' | 'wheel') without passive

Why bad

scroll, touchmove, touchstart, and wheel listeners default to passive: false, which means the browser must wait for the handler to finish before scrolling - any sync work blocks the scroll thread and creates the 'janky scroll on a fancy page' feel. Chromium flags this in DevTools as a passive-listener warning for any non-passive scroll handler.

Fix

Pass `{ passive: true }` as the third argument: `el.addEventListener('scroll', fn, { passive: true })`. Only opt out (passive: false) if you genuinely need preventDefault() to block scrolling - which is rare and usually wrong.

addEventListener\s*\(\s*['"](?:scroll|touchmove|touchstart|wheel|mousewheel)['"]\s*,[^)]*\)(?![^;]*\{[^}]*passive\s*:\s*true)
link
#image-format-jpg-no-webp-avif MED Performance

<img src="*.jpg"> without <picture> source AVIF/WebP

Why bad

A raw <img> with a .jpg or .png source ships 3-10x more bytes than the same image as AVIF or WebP. On a hero photo this is 800KB vs 80KB - directly hurts LCP, mobile data, and Core Web Vitals. The fact that <picture>+<source type="image/avif"> has been universal since 2023 means this is now a generator-default tell.

Fix

Wrap in <picture> with AVIF first, WebP next, JPG fallback: <picture><source srcset="hero.avif" type="image/avif"><source srcset="hero.webp" type="image/webp"><img src="hero.jpg" alt="..." width="..." height="..."></picture>. For framework images (next/image, astro:assets), the conversion is automatic - use them.

<img\s[^>]*src\s*=\s*['"][^'"]+\.(?:jpg|jpeg|png)['"][^>]*>
link
#any-type-leak MED Quality

TypeScript any type

Why bad

any defeats the type system and signals the type was never figured out - the code is JavaScript pretending to be TypeScript.

Fix

Use unknown for genuinely unknown shapes and narrow with type guards. Use a specific interface or generic for everything else.

:\s*any(?:\s*[;,)=\]>]|\s*$)|<any[,>]|as\s+any\b
link
#arbitrary-z-index-9999 MED Quality

Lazy z-index value

Why bad

z-index: 9999 is the 'I gave up on the stacking system' value - signals there's no z-scale token map and stacking bugs are coming.

Fix

Define a z-scale token set (z-base: 0, z-dropdown: 10, z-sticky: 20, z-modal: 50, z-toast: 60). Use the tokens, never a raw 9999.

z-index:\s*(?:9999+|99999+|2147483647)\b|\bz-\[(?:9999+|99999+)\]
link
#body-cursor-pointer-default MED Quality

body or main given cursor: pointer

Why bad

cursor: pointer on body or html signals 'everything is clickable' when most things are not - users learn to distrust the cursor cue, and accessibility regressions follow. AI shortcut for 'I am not sure what is interactive so I will hint at everything'.

Fix

Remove cursor: pointer from body / html / main. Apply it only to elements that are genuinely interactive (buttons, links, custom controls with the right ARIA role).

(?:^|[\s,}>+~])(?:body|html|main|\.(?:app|root|page))[^{}]*\{[^}]*\bcursor\s*:\s*pointer\s*[;}]
link
#inline-style-attribute MED Quality

Inline style attribute

Why bad

Inline style= attributes bypass the design system, defeat caching, and signal the styling decision was made ad hoc.

Fix

Use Tailwind utilities, a CSS class, or a styled component. Reserve inline style for runtime-computed values (animated translate, dynamic CSS vars) only.

<(?:div|span|p|section|article|h[1-6]|button|a|img)[^>]*\sstyle="[^"]+"
link
#all-caps-large MED Typography

ALL CAPS at body-text size or larger

Why bad

ALL CAPS reading speed is 13-20% slower than mixed case because word shape is lost. At 14px or larger it actively impedes reading.

Fix

Reserve uppercase for eyebrows, labels, and badges under 14px. Use sentence case for anything readers need to read, not just scan.

text-transform\s*:\s*uppercase[^}]*font-size\s*:\s*(?:[2-9]\d|\d{3,})
link
#body-font-weight-bold-or-700-keyword MED Typography

body / p / li set to font-weight: bold or 700

Why bad

Making the entire body or every paragraph bold defeats hierarchy - the page has no normal weight to bold against. Long-form reading at weight 700 is measurably slower than at 400. AI shortcut for 'make it pop' that breaks the typographic system.

Fix

Keep body and paragraph weight at 400 (or 450 if the typeface needs a touch more presence). Use bold sparingly inside paragraphs (strong / b) or on headings - never as the default body weight.

(?:^|[\s,}>+~])(?:body|p|li|main|article|\.(?:body|prose|text|content|paragraph))[^{}]*\{[^}]*\bfont-weight\s*:\s*(?:bold|700|800)\s*[;}]
link
#body-letter-spacing-too-wide MED Typography

Excessive letter-spacing on body / paragraph

Why bad

Letter-spacing above ~0.25em or 5px on body text breaks reading flow and exceeds every typography guideline since the 1990s. Wide tracking belongs on display caps, never on paragraph text or list items.

Fix

Keep letter-spacing on body, p, li to ~0 to 0.02em max. Reserve wide tracking (0.2em to 0.4em) for short ALL-CAPS labels or eyebrow text only.

(?:^|[\s,}>+~])(?:body|p|li|main|article|\.(?:body|prose|text|content|paragraph))[^{}]*\{[^}]*\bletter-spacing\s*:\s*(?:0?\.[3-9]\d*em|[1-9]\d*(?:\.\d+)?em|0?\.[3-9]\d*rem|[1-9]\d*(?:\.\d+)?rem|[6-9]px|[1-9]\d+px)
link
#body-text-shadow-on-prose MED Typography

text-shadow applied to body or prose

Why bad

text-shadow on body text smears the glyph edges, drops contrast, and is the classic 2010s skeuomorphic tell. Long-form reading becomes measurably harder, and the shadow does not survive dark mode without a separate override.

Fix

Remove text-shadow from body, p, li, prose, and content classes. If you need depth on a hero title or display headline, scope text-shadow to that one element class and verify contrast in both light and dark themes.

(?:^|[\s,}>+~])(?:body|p|li|main|article|\.(?:body|prose|text|content|paragraph))[^{}]*\{[^}]*\btext-shadow\s*:
link
#display-bold-700 MED Typography

Display heading at 700+ font-weight

Why bad

Display fonts at 700 weight read dense and visually muddy. Most display faces are drawn to sing at 500-600; 700+ is for body emphasis, not headlines.

Fix

Use font-weight 500 or 600 on display headings. Reserve 700+ for body emphasis (bold inline text, table totals, primary CTA labels).

font-weight\s*:\s*(?:700|bolder)[^}]*font-size\s*:\s*(?:[3-9]\d|\d{3,})
link
#hero-text-arbitrary-90px MED Typography

Arbitrary hero font size

Why bad

Arbitrary px values like text-[90px] or text-[112px] bypass the type scale and signal the size was picked by eye, not by system.

Fix

Extend the Tailwind theme scale or use clamp() for fluid hero typography. text-6xl/text-7xl/text-8xl exist for a reason.

text-\[(?:9[0-9]|1[0-9]{2})px\]|font-size:\s*(?:9[0-9]|1[0-9]{2})px\b
link
#letterspacing-tracking-tight-display MED Typography

Tight letter-spacing on heavy display heading

Why bad

tracking-tighter (-0.05em) on font-weight 800+ display is the unmistakable 'AI hero' typographic recipe - copied from every Linear-clone landing page. The combination crushes the counters and reads as default, not deliberate.

Fix

Use letter-spacing -0.01em to -0.02em on display headings, with font-weight 500-600. Reserve heavy-and-tight pairings for short brand wordmarks, not full headlines.

letter-spacing\s*:\s*-0\.0[3-9]e?m?[^;}]*[;}][^{]*font-weight\s*:\s*[7-9]00|font-weight\s*:\s*[7-9]00[^;}]*[;}][^{]*letter-spacing\s*:\s*-0\.0[3-9]|\btracking-tighter\b[^"']*\bfont-(?:bold|extrabold|black)\b|\bfont-(?:bold|extrabo
link
#text-3xl-4xl-5xl-stack MED Typography

Tailwind text-3xl text-4xl text-5xl AI hero stack

Why bad

text-3xl md:text-4xl lg:text-5xl (and the 4xl/5xl/6xl shift) is the verbatim Tailwind tutorial cadence for every AI-generated hero - signals the responsive type was set by stepping through preset utilities, not by tuning the actual feel at each breakpoint.

Fix

Use clamp() for fluid type: text-[clamp(2.5rem,5vw,4.5rem)] or define a custom step in tailwind.config that jumps by content priority, not utility index.

\btext-3xl\b[^"]*\bmd:text-4xl\b[^"]*\blg:text-5xl\b|\btext-4xl\b[^"]*\bmd:text-5xl\b[^"]*\blg:text-6xl\b|\btext-5xl\b[^"]*\bmd:text-6xl\b[^"]*\blg:text-7xl\b
link
#title-case-headlines MED Typography

Title Case On Display Headings

Why bad

Marketing-style Title Case on every headline dates the design to a 2014 SaaS template and slows reading. Sentence case scales better and reads faster.

Fix

Default to sentence case for headlines. Reserve title case for proper nouns and brand names, or for an explicit editorial decision.

<h[12][^>]*>\s*(?:[A-Z][a-z]+\s+){3,}
link
#align-html-attribute MED Visual

Presentational align= HTML attribute

Why bad

align='left|right|center' on block elements is deprecated since HTML 4.01 - it bypasses logical properties (text-align: start/end) so it never flips correctly under dir='rtl', and the model only reaches for it when it doesn't know the modern equivalent. Critical regression for any RTL-first product.

Fix

Use CSS text-align with logical values: text-align: start for left in LTR / right in RTL; text-align: end for the opposite; text-align: center stays center. For tables use <td class='text-end'> with a logical-property utility, not align='right'.

<(?:p|div|table|td|th|tr|img|h[1-6]|caption|col|colgroup|tbody|tfoot|thead)\s+[^>]*\balign\s*=\s*['"](?:left|right|center|justify)['"][^>]*>
link
#bgcolor-html-attribute MED Visual

Presentational bgcolor= attribute

Why bad

bgcolor= is an HTML 3.2 presentational attribute deprecated since HTML 4.01 - it bypasses the design system, ignores tokens, breaks dark mode, and signals the file was either copy-pasted from a 1998 tutorial or a model that didn't know any better.

Fix

Use CSS background-color via a design token (--bg-surface-1, .bg-surface-1, bg-zinc-50). If the surface is tenant-themed, drive it from a CSS variable bound to the tenant palette.

<[a-z][a-z0-9]*\s+[^>]*\bbgcolor\s*=\s*['"][^'"]+['"][^>]*>
link
#box-shadow-multilayer-default MED Visual

Five-plus box-shadow layers stacked

Why bad

Stacking five or more shadow layers is the AI's idea of premium depth; reads as visual bloat and chews the GPU on paint.

Fix

Use one ambient diffusion shadow (e.g. 0 20px 40px -15px rgba(0,0,0,0.08)) and at most one tight contact shadow. Depth comes from contrast, not layer count.

box-shadow:\s*([^,;]+,[^,;]+,[^,;]+,[^,;]+,[^,;]+,)
link
#dynamic-island-glow-everywhere MED Visual

Dynamic Island inner glow on cards

Why bad

iOS Dynamic Island's inner-glow + blur recipe is being copied as the default on every card and pill - reads as the 2024 AI-marketing-template tell.

Fix

If you want depth, use a single outer diffusion shadow tuned to the surface. Reserve inset glows for a deliberate metallic or pill-style accent, not every surface.

box-shadow:\s*inset[^;]+blur
link
#glass-morphism-default MED Visual

Backdrop-blur frosted-glass on four-plus surfaces

Why bad

Frosted-glass everywhere is the iOS-aesthetic AI tell - signals the generator confused 'depth' with 'blur every surface'. Four-plus backdrop-blur layers on a single page is the visual equivalent of using bold on every word.

Fix

Reserve glass for one or two surfaces that have a clear reason to feel translucent (a floating nav, a notification toast). Solid surfaces with restraint read more premium than four blurred panels.

(?:backdrop-(?:blur|filter)|backdrop-filter\s*:\s*blur)[\s\S]{0,1500}?(?:backdrop-(?:blur|filter)|backdrop-filter\s*:\s*blur)[\s\S]{0,1500}?(?:backdrop-(?:blur|filter)|backdrop-filter\s*:\s*blur)[\s\S]{0,1500}?(?:backdrop-(?:blur|filter)|ba
link
#noise-texture-overlay MED Visual

Repeating noise or grain texture overlay

Why bad

A noise.png or grain.svg tiled across a hero is the 2024 generative-UI fingerprint - signals 'I added texture' without a coherent material story. Most viewers register it as visual static, not depth.

Fix

If grain is a deliberate brand choice, generate it via SVG <feTurbulence> with low baseFrequency and 4-6% opacity. Otherwise drop the texture; flatness reads more confident than fake film grain.

background(?:-image)?\s*:[^;]*url\([^)]*(?:noise|grain|texture|stipple|paper|fabric)[^)]*\.(?:png|jpe?g|svg|webp)[^)]*\)
link
#scroll-to-top-button-everywhere LOW A11y

Fixed scroll-to-top button on short pages

Why bad

A floating back-to-top button on a page under four viewports adds permanent visual noise for a vanishingly rare interaction - signals the generator included the pattern as decoration, not need.

Fix

Drop the button. If pages can grow past four viewports, gate the button on scroll position and hide it below threshold. Keyboard users have Home; mouse users have the wheel.

position\s*:\s*fixed[^}]*bottom\s*:[^;]+;[^}]*scroll
link
#tailwind-color-named-vague LOW Color

Named Tailwind colors with no semantic token

Why bad

Raw bg-blue-500 / bg-purple-500 with no semantic token (primary, success, danger) signals the color system was never designed.

Fix

Define semantic tokens in tailwind.config or CSS custom properties: bg-primary, text-success, ring-accent. Map them once.

\b(?:bg|text|border|ring|from|to|via)-(?:blue|red|green|yellow|purple|pink|indigo|orange)-(?:400|500|600)\b
link
#version-1-0-0-evergreen LOW Content

Hardcoded v1.0.0 in nav or footer

Why bad

A static 'v1.0.0' in the nav or footer never updates with the real build - signals the generator added a version chip as decoration without wiring it to package.json or the deploy SHA. Worse, the badge stays at 1.0.0 forever and reads as stagnant.

Fix

Either pull the version from package.json at build time (Vite/Webpack define), bind it to the deploy SHA, or remove the chip entirely. A wrong version is worse than no version.

<(?:nav|footer|header|aside)[\s\S]{0,800}?\bv?1\.0\.0\b|class=['"][^'"]*(?:version|footer|nav)[^'"]*['"][^>]*>[^<]*\bv?1\.0\.0\b
link
#flex-center-center-default LOW Layout

flex + justify-center + align-center as the default block

Why bad

display: flex + justify-content: center + align-items: center wrapped around content is the AI's default-centering recipe - signals the layout was reached for in three lines without thinking about asymmetric balance, content priority, or whether the content actually wants to be centered. Real layouts earn their alignment.

Fix

Use `display: grid; place-items: center` if you genuinely want full centering (one line, clearer intent). Better: ask whether centering is the right move - asymmetric or top-aligned layouts almost always read more deliberate. Reserve dead-center for hero CTAs and modals.

\{[^}]*display\s*:\s*flex\s*;[^}]*justify-content\s*:\s*center\s*;[^}]*align-items\s*:\s*center\s*;[^}]*\}
link
#grid-cols-3-1fr-default LOW Layout

repeat(3, 1fr) as a default grid

Why bad

repeat(3, 1fr) with no minmax(), no auto-fit, and no asymmetric weighting is the laziest grid declaration - signals the layout was set by a generator that defaulted to 'three equal columns' without thinking about content priority or responsive behavior.

Fix

Use minmax for responsive floors: repeat(auto-fit, minmax(280px, 1fr)). For deliberate three-column layouts, weight by content: grid-template-columns: 2fr 1fr 1fr, or use named-area grids.

grid-template-columns\s*:\s*repeat\(\s*3\s*,\s*1fr\s*\)|grid-template-columns\s*:\s*1fr\s+1fr\s+1fr\s*[;}]
link
#pill-rounded-full-everywhere LOW Layout

rounded-full applied to everything

Why bad

rounded-full on every button and input is the iOS-tutorial fingerprint - signals the radius decision was skipped entirely.

Fix

rounded-full for pills, avatars, icon buttons only. Use rounded-md or rounded-lg for primary buttons; rounded-xl or rounded-2xl for cards.

<button[^>]*class="[^"]*\brounded-full\b[^"]*"[^>]*>(?![^<]*(?:×|close|menu|search))|<(?:input|textarea)[^>]*class="[^"]*\brounded-full\b
link
#cubic-bezier-material-only LOW Motion

Material default easing everywhere

Why bad

cubic-bezier(0.4, 0, 0.2, 1) is Material Design's default standard ease - using it as the only curve signals no taste decision was made.

Fix

Use cubic-bezier(0.16, 1, 0.3, 1) for premium exits, cubic-bezier(0.34, 1.56, 0.64, 1) for playful overshoot, or spring physics.

cubic-bezier\(\s*0?\.4\s*,\s*0\s*,\s*0?\.2\s*,\s*1\s*\)
link
#scale-1-1-on-card-hover LOW Motion

Over-the-top hover scale

Why bad

scale(1.1) or higher on hover reads as amateur - it shouts when a 1-2 percent nudge or a shadow lift would be more confident.

Fix

Use scale(1.01) to scale(1.03), or replace the scale with a translateY(-2px) plus shadow shift. Restraint reads premium.

:hover[^{]*\{[^}]*transform[^;]*scale\(1\.[1-9]\d*\)
link
#timing-300ms-default LOW Motion

Default 300ms transition timing

Why bad

300ms is the editor default - using it everywhere signals zero motion intent and reads as laziness to anyone who tunes animation curves.

Fix

Micro-interactions 150-220ms, complex transitions 250-400ms, exit 60-70% of entry. Pick durations that match the feel, not the default.

(?:transition(?:-duration)?|animation-duration):\s*300ms\b|duration-300\b|\bduration:\s*0\.3s\b
link
#animated-grain-noise-no-perf-class LOW Performance

Animated grain or noise without GPU hint

Why bad

Animated grain or noise without a will-change or transform hint forces a CPU repaint every frame and causes paint thrashing on low-end devices.

Fix

Add will-change: transform or compose the noise via an SVG <feTurbulence> overlay on a transformed layer. Or drop the animation - static grain reads the same and costs nothing.

animation\s*:[^;]*(?:grain|noise|stipple|dither)[^;]*;(?![^}]*will-change)
link
#class-multiple-utility-than-token LOW Quality

Five-plus utility classes where a token would do

Why bad

Stacking `text-sm text-gray-500 font-medium leading-5 tracking-tight` on a single element signals every type decision was made inline by a generator that never reached for a token. The same text style appears across 30 components with subtle drift instead of being defined once as `.text-body-meta` or in a typography preset.

Fix

Extract recurring stacks to a named token: `@apply text-sm text-gray-500 font-medium leading-5 tracking-tight` on a `.text-body-meta` class, or define semantic font-utilities in tailwind.config.ts (`text: { 'body-meta': [...] }`). Inline 5-plus utility chains is a refactor target.

class\s*=\s*['"][^'"]*\btext-(?:xs|sm|base|lg|xl)\b[^'"]*\btext-(?:slate|gray|zinc|neutral|stone)-[0-9]{3}\b[^'"]*\bfont-(?:thin|light|normal|medium|semibold|bold)\b[^'"]*\bleading-[0-9]+\b[
link
#data-testid-in-production-markup LOW Quality

data-testid left in production markup

Why bad

data-testid attributes are test-runner hooks that have no business in the production payload - they bloat the DOM, leak internal test names to anyone who Views Source, and signal the build pipeline strips nothing. AI-pasted-from-example-code fingerprint.

Fix

Strip data-testid at build time (Vite / Webpack / esbuild plugin) or move identifiers to data-test-* / aria-label that the test harness reads. Keep production HTML free of test-only attributes.

\sdata-testid\s*=\s*['\"][^'\"]+['\"]
link
#generic-class-container-wrapper-only LOW Quality

div with only 'wrapper' / 'box' / 'inner' as class

Why bad

A bare class='wrapper' / 'box' / 'inner' with nothing else gives the design system no information about what the div is for - the throwaway naming a generator falls back to when it has no concept of role or semantic. Tailwind's '.container' utility is intentionally excluded - it carries layout intent.

Fix

Name the role: class='hero__inner', class='pricing-grid', class='footer-cta-block'. Even if it carries the layout utility, the prefix should say what kind of block it is.

<div\s+class\s*=\s*['\"](?:wrapper|box|inner|outer|main-wrapper|page-wrapper|content-wrapper|outer-wrapper|inner-wrapper)['\"]\s*>
link
#numbered-placeholder-classname LOW Quality

Numbered placeholder class names (card-1, feature-2)

Why bad

card-1, feature-2, step-3 are the throwaway class names a generator emits when it has no semantic for what each block actually contains - signals nobody named the content. They also defeat CSS reuse because every variant gets a unique class.

Fix

Name classes by purpose: .pricing-card, .feature-comparison, .onboarding-step. If the cards really are interchangeable, use a single .card class with modifiers (.card--featured) or data attributes (data-tier="pro").

class\s*=\s*['"][^'"]*\b(?:card|feature|item|tile|section|block|box|step|slide)-[0-9]+\b
link
#padding-shorthand-4-vals-zero-first LOW Quality

Verbose 4-value padding/margin where 3 would do

Why bad

Writing `padding: 0 16px 16px 16px` when `padding: 0 16px 16px` collapses to the same result is the AI tell of a generator that copied a 4-value shorthand without simplifying. It also indicates the dev didn't run `prettier` or any formatter pass.

Fix

Use the shortest equivalent shorthand: 1 value (all sides), 2 values (vertical | horizontal), 3 values (top | horizontal | bottom). If you need different values per side, the 4-value form is fine - just don't restate the same value twice.

(?:padding|margin)\s*:\s*0(?:px|rem|em)?\s+(\d+(?:\.\d+)?(?:px|rem|em|%))\s+(\d+(?:\.\d+)?(?:px|rem|em|%))\s+\1\s*[;}]
link
#shadcn-default-everywhere LOW Quality

Default shadcn token block unmodified

Why bad

The default shadcn HSL token block with --radius 0.5rem is recognizable in two seconds by anyone who has shipped one shadcn app.

Fix

Customize tokens at init: swap slate for zinc/stone, shift primary to a brand hue, set --radius to 0.625rem or 0.75rem.

--background:\s*0\s+0%\s+100%[\s\S]{0,200}--foreground:\s*222\.2\s+84%\s+4\.9%|--primary:\s*222\.2\s+47\.4%\s+11\.2%|--radius:\s*0\.5rem;
link
#todo-fixme-comment LOW Quality

TODO or FIXME in shipping code

Why bad

TODO/FIXME in shipping code is an unresolved promise to the reader and a draft-state leak.

Fix

Resolve the TODO before merge, or move it to the issue tracker with a specific owner and date.

(?://|/\*|<!--|\{/\*)\s*(?:TODO|FIXME|XXX|HACK|WIP|REFACTOR)\b
link
#font-family-monospace-fallback-default LOW Typography

font-family: monospace with no specific stack

Why bad

Bare `font-family: monospace` (no specific stack before the generic) hands rendering to Courier New on Windows and Monaco on old Macs - ugly defaults that signal nobody picked a code face. Modern stacks lead with JetBrains Mono, Fira Code, IBM Plex Mono, or `ui-monospace` for system-matched output.

Fix

Specify the stack: `font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;`. Use `ui-monospace` first if you want the OS default code face on every platform.

font-family\s*:\s*monospace\s*[;}!]
link
#font-system-only LOW Typography

System font stack with no chosen typeface

Why bad

Falling back to the system stack alone with no chosen typeface gives up the chance to have any typographic identity.

Fix

Pick a real face: Geist, Inter, Satoshi, IBM Plex Sans, Cabinet Grotesk, or the brand's variable. System stack is the absolute last fallback.

font-family:\s*(?:-apple-system|BlinkMacSystemFont)(?:\s*,\s*(?:BlinkMacSystemFont|'?Segoe UI'?|Roboto|Oxygen|Ubuntu|Cantarell|'?Helvetica Neue'?|sans-serif|system-ui))*\s*;
link
#blur-bg-only-decoration LOW Visual

Backdrop blur with no glass surface

Why bad

Backdrop blur applied without a translucent surface behind it is GPU work for zero visual benefit - decoration, not design.

Fix

Pair backdrop-blur with a translucent fill: bg-white/70 or bg-zinc-950/60. Otherwise remove the blur entirely.

(?:backdrop-blur(?:-(?:sm|md|lg|xl|2xl|3xl))?|backdrop-filter:\s*blur\([^)]+\))(?![^"';]*(?:bg-white/[0-9]|bg-black/[0-9]|bg-[a-z]+-[0-9]+/[0-9]|background:[^;]*rgba))
link
#body-background-color-inherit LOW Visual

body { background-color: inherit }

Why bad

background-color: inherit on body chains to <html>, which by default has no background and resolves to canvas/white - so the inherit chain breaks the moment <html> is themed differently from body, and the page shows a flash of system canvas before paint. AI shortcut for 'just inherit it' when the dev didn't bother to choose a real surface token.

Fix

Set body { background-color: var(--bg-surface-0, white) } with an explicit token. If dark mode is in play, the token should respond to color-scheme or .dark, not chain through inherit.

\bbody\s*\{[^}]*\bbackground(?:-color)?\s*:\s*inherit\s*[;}]
link
#border-radius-2xl-default LOW Visual

rounded-2xl or larger applied to everything

Why bad

rounded-2xl/3xl (24-32px+) on every card, button, and input is the Tailwind AI signature - signals the radius was set once and copy-pasted instead of being calibrated per element size. Buttons end up looking like pills, inputs lose definition, dense surfaces feel cartoonish.

Fix

Calibrate radius to element size: 4-8px for inputs, 8-12px for buttons, 12-16px for cards, 16-24px for marketing surfaces only. Define a 4-5 step radius scale and use the tokens.

\brounded-(?:2xl|3xl)\b|border-radius\s*:\s*(?:24|28|32|36|40)px\b|border-radius\s*:\s*1\.[5-9]rem\b|border-radius\s*:\s*[2-3]rem\b
link
#cursor-pointer-on-disabled LOW Visual

cursor:pointer applied to :disabled state

Why bad

A :disabled element with cursor: pointer signals to the user that the element is clickable - the visual affordance says 'I respond' while the actual element says 'I don't.' Confused state, immediate friction. Common when a global utility class wins over the :disabled override.

Fix

Override to cursor: not-allowed (or cursor: default) inside the :disabled selector. Pair with opacity: 0.5 and pointer-events: none on the visual layer if the disabled state must be unreachable to mouse users.

:disabled[^{]*\{[^}]*cursor\s*:\s*pointer\s*[;}]
link
#loader-spinner-border-default LOW Visual

Default CSS border-spin loader recipe

Why bad

The 4-border-color, border-top-different, animation: spin recipe is the textbook 1998 spinner that every AI generator emits for any loading state - signals the loading affordance was set by default, not designed. Modern systems use skeleton screens, progress bars, or branded indicators.

Fix

Replace with a skeleton screen for content loading, a progress indicator for known-duration tasks, or a branded SVG mark animated via transform: rotate. The bordered spinner is the loading equivalent of Lorem ipsum.

border\s*:\s*\d+px\s+solid[^;}]*[;}][^{]*border-top(?:-color)?\s*:[^;]+;[^}]*animation\s*:[^;]*spin|\.(?:loader|spinner)[^{]*\{[^}]*border-radius\s*:\s*50%[^}]*animation[^}]*:[^}]*spin
link
#aria-hidden-on-interactive MED A11y

aria-hidden on a focusable interactive element

Why bad

aria-hidden="true" on a focusable element (button, link, input) removes it from the accessibility tree while leaving it in the tab order - keyboard users can land on a control that screen readers refuse to announce. WCAG 4.1.2 failure.

Fix

If the control is decorative, remove it from the DOM or set tabindex="-1" AND aria-hidden together. If it has a function, drop the aria-hidden and give it a proper accessible name via aria-label or visible text.

<(?:button|a|input|select|textarea)[^>]+aria-hidden\s*=\s*['"]true['"]
link
#div-onclick-no-role MED A11y

Click handler on <div> without role or tabindex

Why bad

A <div onClick> is invisible to keyboard users and assistive tech - the action exists only for mouse users, a WCAG 2.1.1 and 4.1.2 failure.

Fix

Use <button onClick> for actions. If you must keep the <div>, add role="button" tabindex="0" and an onKeyDown handler for Enter and Space.

<div[^>]+onClick=(?![^>]+role\s*=\s*['"]button)
link
#onclick-on-non-button MED A11y

onclick handler on non-interactive element

Why bad

onclick (lowercase, HTML attribute form) on a <div>, <span>, <p>, <li>, <td>, or <h*> is invisible to keyboard users and assistive tech - WCAG 2.1.1 and 4.1.2 fail. Unlike the JSX onClick rule we already catch, this fires in plain HTML and Blade where the lowercased attribute is the only form available.

Fix

Use <button onclick='...'> for actions. If you genuinely cannot change the tag, add role='button' tabindex='0' AND an onkeydown handler that fires the same action on Enter or Space keypress.

<(?:div|span|p|li|td|h[1-6])\b[^>]*\bonclick\s*=\s*['"][^'"]+['"][^>]*>
link
#outline-none-no-focus-visible MED A11y

outline removed without focus-visible replacement

Why bad

outline: none removes the only keyboard focus indicator and leaves keyboard users with no signal of focus state - a WCAG 2.4.7 failure.

Fix

Pair outline: none with a :focus-visible rule that restores a visible ring: :focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }.

outline\s*:\s*(?:none|0)\s*;(?![^}]*:focus-visible)
link
#viewport-no-zoom MED A11y

Viewport blocks pinch-zoom

Why bad

user-scalable=no blocks pinch-zoom for low-vision users on touch devices - a WCAG 1.4.4 failure that locks out anyone who needs to enlarge text.

Fix

Remove user-scalable=no entirely, or set it to yes. Default viewport: <meta name="viewport" content="width=device-width, initial-scale=1">.

<meta[^>]+viewport[^>]+user-scalable\s*=\s*['"]?no
link