Skip to content

Code review rules

Check accessibility, visual design, motion, content and comprehension before you present finished work.

Accessibility (WCAG 2.1)

Critical Issues

These issues MUST be fixed before shipping. They prevent users from accessing content.

Images without alt text

Rule: All <img> elements must have an alt attribute.

// ❌ Bad
<img src="/profile.jpg" />

// ✅ Good
<img src="/profile.jpg" alt="User profile photo" />

// ✅ Good (decorative images)
<img src="/divider.png" alt="" />

WCAG Reference: 1.1.1 Non-text Content (Level A)

Why it matters: Screen readers cannot convey image content without alt text. Decorative images should use alt="" to indicate they should be skipped.

Icon-only buttons missing aria-labels

Rule: Buttons without text content must have aria-label or aria-labelledby.

// ❌ Bad
<button onClick={handleClose}>
  <CloseIcon />
</button>

// ✅ Good
<button onClick={handleClose} aria-label="Close dialog">
  <CloseIcon />
</button>

// ✅ Good (with visible text)
<button onClick={handleClose}>
  <CloseIcon />
  <span>Close</span>
</button>

WCAG Reference: 4.1.2 Name, Role, Value (Level A)

Why it matters: Screen reader users cannot understand button purpose without accessible names.

Form inputs without labels

Rule: All <input>, <select>, and <textarea> elements must have associated labels.

// ❌ Bad
<input type="email" placeholder="Email" />

// ✅ Good (visible label)
<label htmlFor="email">Email</label>
<input type="email" id="email" />

// ✅ Good (aria-label for compact layouts)
<input type="email" aria-label="Email address" />

// ✅ Good (aria-labelledby)
<span id="email-label">Email</span>
<input type="email" aria-labelledby="email-label" />

WCAG Reference: 3.3.2 Labels or Instructions (Level A)

Why it matters: Users need to understand what data to enter. Placeholders disappear and aren't reliable.

Non-semantic click handlers

Rule: Don't use onClick on non-interactive elements. Use <button> or add role + keyboard handlers.

// ❌ Bad
<div onClick={handleClick}>Click me</div>

// ✅ Good
<button onClick={handleClick}>Click me</button>

// ✅ Good (styled button)
<button onClick={handleClick} className="link-style">
  Click me
</button>

// ⚠️ Acceptable if necessary (but avoid)
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') handleClick();
  }}
>
  Click me
</div>

WCAG Reference: 2.1.1 Keyboard (Level A)

Why it matters: Keyboard users cannot interact with div onClick elements. Screen readers don't announce them as interactive.

Links without href

Rule: All <a> elements must have an href attribute. Use <button> for actions.

// ❌ Bad
<a onClick={handleClick}>Settings</a>

// ✅ Good (actual link)
<a href="/settings">Settings</a>

// ✅ Good (action - use button)
<button onClick={handleClick}>Open Settings</button>

WCAG Reference: 2.1.1 Keyboard (Level A)

Why it matters: Links without href aren't focusable by keyboard and confuse screen readers.

Serious Issues

These significantly impact accessibility but have workarounds. Should be fixed soon.

Focus outline removed without replacement

Rule: Don't use outline: none or outline-none without visible focus alternative.

// ❌ Bad
<button className="outline-none">Click me</button>

// ✅ Good (custom focus style)
<button className="outline-none focus-visible:ring-2 focus-visible:ring-primary">
  Click me
</button>

// ✅ Good (default outline)
<button>Click me</button>

WCAG Reference: 2.4.7 Focus Visible (Level AA)

Why it matters: Keyboard users cannot see where focus is without visible indicators.

Missing keyboard handlers

Rule: Elements with mouse events (onMouseEnter, onMouseLeave) should have keyboard equivalents.

// ❌ Bad
<div onMouseEnter={handleOpen}>Hover me</div>

// ✅ Good
<button
  onMouseEnter={handleOpen}
  onFocus={handleOpen}
  onMouseLeave={handleClose}
  onBlur={handleClose}
>
  Hover me
</button>

WCAG Reference: 2.1.1 Keyboard (Level A)

Why it matters: Hover-only interactions exclude keyboard users.

Color-only information

Rule: Don't convey information through color alone. Add icons, labels, or patterns.

// ❌ Bad
<span className="text-red-500">Error</span>

// ✅ Good (icon + color)
<span className="text-red-500">
  <AlertIcon aria-hidden="true" />
  Error
</span>

// ✅ Good (explicit label)
<span className="text-red-500">
  <span className="sr-only">Error:</span>
  Invalid email format
</span>

WCAG Reference: 1.4.1 Use of Color (Level A)

Why it matters: Color blind users and screen reader users cannot perceive color-only distinctions.

Touch targets under 44×44px

Rule: Interactive elements should be at least 44×44px for touch devices.

// ❌ Bad
<button className="w-8 h-8">
  <CloseIcon />
</button>

// ✅ Good
<button className="w-11 h-11">
  <CloseIcon />
</button>

// ✅ Good (small visual, large touch area with padding)
<button className="p-3">
  <CloseIcon className="w-5 h-5" />
</button>

WCAG Reference: 2.5.5 Target Size (Level AAA)

Why it matters: Small touch targets are difficult to tap accurately on mobile devices.

Moderate Issues

These are best practices that improve accessibility but aren't blockers.

Skipped heading levels

Rule: Don't skip heading levels (h1 → h2 → h3, not h1 → h3).

// ❌ Bad
<h1>Page Title</h1>
<h3>Subsection</h3>

// ✅ Good
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>

// ✅ Good (visual size ≠ semantic level)
<h2 className="text-sm">Small Section Title</h2>

WCAG Reference: 1.3.1 Info and Relationships (Level A)

Why it matters: Screen readers use heading hierarchy for navigation. Skipped levels create confusing document structure.

Positive tabIndex values

Rule: Don't use tabIndex greater than 0. Use tabIndex={0} or tabIndex={-1} only.

// ❌ Bad
<div tabIndex={1}>First</div>
<div tabIndex={2}>Second</div>

// ✅ Good (natural DOM order)
<button>First</button>
<button>Second</button>

// ✅ Good (programmatic focus management)
<div tabIndex={-1} ref={focusRef}>
  Can be focused programmatically
</div>

WCAG Reference: 2.4.3 Focus Order (Level A)

Why it matters: Custom tab orders are confusing and break user expectations.

Role without required attributes

Rule: ARIA roles require specific attributes. Check ARIA specs for requirements.

// ❌ Bad
<div role="checkbox" onClick={toggle}>Accept terms</div>

// ✅ Good
<div
  role="checkbox"
  aria-checked={isChecked}
  tabIndex={0}
  onClick={toggle}
  onKeyDown={handleKeyDown}
>
  Accept terms
</div>

// ✅ Better (use native elements)
<input type="checkbox" checked={isChecked} onChange={toggle} />
<label>Accept terms</label>

WCAG Reference: 4.1.2 Name, Role, Value (Level A)

Why it matters: Incomplete ARIA implementations confuse assistive technologies.

Animation & Motion

Review the behavior in a running interface when possible. Static code can reveal missing fallbacks and expensive properties, but timing, interruption, origin, and combined motion need direct interaction.

Critical Issues

Reduced motion hides content or blocks completion

Rule: A reduced-motion variant must reach the same visible content and completed state as the standard variant. Do not leave an element at opacity: 0, keep an exit mounted forever, or skip a completion callback because an animation was disabled. Autoplaying and looping media need a static or user-controlled alternative.

// ❌ Bad: disabling the animation also keeps the content hidden
<motion.div initial={{ opacity: 0 }} animate={reduceMotion ? undefined : { opacity: 1 }} />

// ✅ Good: both paths reach the same visible state
<motion.div
  initial={reduceMotion ? false : { opacity: 0, y: 8 }}
  animate={{ opacity: 1, y: 0 }}
/>

Why it matters: A preference intended to prevent discomfort must never remove information or make a task impossible to finish.

Severity: Critical

Serious Issues

Animation delays a frequent task

Rule: The useful state must become available as soon as the action is accepted. Do not make typing, selection, toggling, keyboard navigation, or repeated list actions wait for decorative motion. Keep frequent interactions instant or nearly instant and make exits shorter than entries.

// ❌ Bad: the state update waits for decoration to finish
await controls.start({ scale: [1, 1.08, 1], transition: { duration: 0.8 } });
setSelected(id);

// ✅ Good: state lands now; brief feedback does not block it
setSelected(id);
controls.start({ scale: [0.98, 1], transition: { duration: 0.14 } });

Why it matters: Animation time compounds on repeated actions. A polished transition becomes friction when it sits between intent and result.

Severity: Serious

Rapid input snaps, queues, or fights the user

Rule: Motion on a repeatable control must be interruptible. Trigger the action again before it finishes and verify that it retargets from the current value, reverses cleanly, or completes immediately. Do not queue stale transitions or restart keyframes from an unrelated first frame.

Why it matters: Real users double-click, change direction, and act before choreography finishes. A transition that only works at demonstration speed is not a finished interaction.

Severity: Serious

Motion has the wrong origin, direction, or response curve

Rule: Movement must preserve the relationship between trigger and result. Menus expand from their trigger, panels enter and leave toward their owning edge, and scale uses a believable transform-origin. Prefer ease-out for interactive feedback. Avoid ease-in starts; allow them only for a complete off-screen exit that does not postpone the next usable state.

/* ❌ Bad: a top-right menu grows from the page center */
.menu { transform-origin: center; transition: transform 400ms ease-in; }

/* ✅ Good: origin and response match the trigger */
.menu { transform-origin: top right; transition: transform 180ms ease-out; }

Why it matters: People read direction and acceleration as cause and physical relationship. The wrong origin makes a connected element feel detached even when the values are technically valid.

Severity: Serious

Animation misses frames during representative use

Rule: Record motion that runs on a busy surface, follows pointer input, or changes many elements. Treat repeated long frames, forced layout inside the animation loop, per-frame React state updates, and broad inherited CSS-variable updates as defects. Prefer transform and opacity, but verify the trace instead of assuming a property or library is automatically off the main thread.

// ❌ Bad: a render is scheduled for every pointer sample
onPointerMove={(event) => setDragX(event.clientX)}

// ✅ Good: update an imperative or library motion value without re-rendering the tree
onPointerMove={(event) => dragX.set(event.clientX)}

Why it matters: Smoothness is a property of the whole page under realistic load, not of an isolated component on a developer's fastest machine.

Severity: Serious

Moderate Issues

Choreography competes with hierarchy

Rule: Do not animate every visible element, give unrelated regions simultaneous entrances, or apply a uniform stagger to long lists. Motion should direct attention to the primary state change. Keep secondary regions quieter, cap staged sequences, and skip list-item entrances when the list is used repeatedly.

Why it matters: When everything moves, motion stops explaining hierarchy and becomes another source of visual noise.

Severity: Moderate

Motion values drift from the product system

Rule: Reuse the product's named duration, easing, and spring tokens. Flag isolated components with arbitrary timing or a different physical character unless the motion brief explains why the behavior needs an exception.

Why it matters: Timing and easing are part of product character. Small local differences make a connected interface feel assembled from unrelated parts.

Severity: Moderate

Before You Review

A review that starts by reading code produces findings nobody asked for, on a surface nobody changed. Pass this gate first, and say in the output that you passed it.

1. Resolve the scope

Name what you are reviewing and what you are not, before reading a line: the files, the screen, or the diff. If the request is "review this", the scope is the diff against the base branch, not the repository. State the scope in the output so the reader knows what your silence covers.

2. Resolve the mode

Three modes, and they produce different work:

  • Report. Findings only, nothing edited. This is the default. Choose it whenever the request is a question.
  • Report and fix. Findings, then the edits, each edit traceable to a finding. Choose it only when the request asks for changes.
  • Fix silently. Never. A review that edits without reporting leaves the reader unable to check the judgement.

3. Gather evidence before judging

Every finding cites something: a line, a token, a measured value, a rule in this file. A finding you cannot cite is an opinion, and an opinion costs the reader more to verify than to ignore. Where a value decides the finding, measure it rather than reading the source: a computed style, a contrast ratio, a rendered gap.

4. Do not mutate by default

In report mode, change nothing, including formatting. A review that reformats the file it reviewed makes its own findings unreadable in the diff.

Comprehension

Accessibility and visual rules catch what is broken. These catch what is unclear: copy that makes sense to the people who built the product and to nobody else. Run all three tests on every screen before presenting it. The check-comprehension MCP tool runs them automatically on the screen's copy or markup.

The mom test

Rule: Every word on the screen must be readable by someone who has never used the product. No insider vocabulary, no unexplained acronyms, no sentence longer than about 20 words.

// ❌ Bad (obvious to the team, meaningless to a user)
<p>Claim the June badge by collecting more quest points from your daily, friend and weekend quests.</p>

// ✅ Good
<p>Finish 3 more lessons this week to earn the June award.</p>

Insider vocabulary is any word the reader can only decode by working here: growth and gamification nouns (badge, quest, streak, tier, credits, XP, league, milestone) and engineering nouns (sync, token, workspace, quota, endpoint, session, schema). Either say the thing in everyday words, or define it on the screen the first time it appears.

Why it matters: The team reads its own copy with the whole product model in their head. A first-time user reads the sentence once, understands nothing, and leaves. Fluency inside the building is not evidence of clarity outside it.

Severity: Serious

The 15 second test

Rule: If explaining the screen to a colleague takes longer than 15 seconds, it is too complex for a user. Keep a screen under about 60 visible words, one main idea, one primary action and at most two secondary ones.

// ❌ Bad (three ideas competing, four ways out)
<Card>
  <h2>Your plan</h2>
  <p>You have 240 credits left this cycle, your team seats renew on 4 June, and unused credits roll over once.</p>
  <Button>Upgrade</Button><Button>Buy credits</Button><Button>Manage seats</Button><Button>See usage</Button>
</Card>

// ✅ Good
<Card>
  <h2>You have 240 credits left</h2>
  <p>They last until 4 June. Unused credits roll over once.</p>
  <Button>Buy more credits</Button>
</Card>

Why it matters: Complexity you can explain is still complexity the user has to absorb alone, with no one narrating. Explanation length is the cheapest available proxy for how hard the screen is to read.

Severity: Serious

The screenshot test

Rule: Show the screen to someone with zero context. They must be able to say what it does. That needs a title in the user's words, actions that name their outcome, and no control that is only an icon.

// ❌ Bad
<h1>Quest hub</h1>
<Button>Continue</Button>
<button><TrashIcon /></button>

// ✅ Good
<h1>Your weekly goals</h1>
<Button>Start today's lesson</Button>
<Button variant="ghost"><TrashIcon /> Delete goal</Button>

Labels like Continue, Next, OK, Got it, Submit and Learn more only work for a reader who already knows the flow. Name the outcome instead: Save changes, Start the lesson, Delete this project.

Why it matters: Most users arrive mid-task, glance once, and act. A screen that only makes sense to someone who saw the previous screen fails the moment attention breaks.

Severity: Serious for vague actions, critical for a screen with no title or a control with no words.

Numbers without a decodable unit

Rule: Every user-facing number carries a unit or basis the reader can decode with zero context, inside the same component. Write "55% of 826 owners", not a bare "453". A count whose denominator lives in a heading three sections away does not count as decodable.

// ❌ Bad (what is 453?)
<span>Turn any barbell into a landmine</span><span>453</span>

// ✅ Good
<span>Turn any barbell into a landmine</span><span>55%</span>
<p>Post-purchase survey of 826 owners, multiple answers allowed.</p>

Why it matters: The team knows what the number means; a first-time visitor does not. Bare counts read as noise or, worse, as errors. This is the most common comprehension failure in data-flavored marketing sections (survey results, rankings, usage stats).

Severity: Serious

Content

Copy and layout choices that make UI read as generated rather than designed. These rules apply to every build target: real React components and the framework-free HTML kit alike.

Content & Cognitive Load

Walls of text and unread content

Rule: Do not ship more copy, options, or UI than a person will actually read. Every block of text, list item, and control spends the reader's finite attention (processing fluency). Cut to the fewest words that carry the meaning; default to subtraction over addition.

// ❌ Bad (three sentences where one works; nothing scannable)
<p>
  Welcome to your dashboard. This is the place where you can see all of the
  different metrics and data points that we have collected for you over time,
  and you can also use the controls below to filter, sort, and customize
  exactly how all of that information is displayed on your screen.
</p>

// ✅ Good (one line, the rest revealed on demand)
<p className="text-muted-foreground">Your metrics at a glance.</p>
<button>Customize view</button>

Why it matters: AI makes it trivial to generate walls of text and long option lists. Unread content is not neutral, it raises cognitive load, buries the primary action, and makes the product feel heavier. Removing is harder than adding, so it gets skipped; flag copy and choices that do not earn their place. See ux/cognitive-laws.md (Processing Fluency, Cognitive Load) and ux/microcopy.md.

Severity: Moderate

Overwritten UI copy

Rule: UI copy is part of the design. Headlines carry at most about 6 words; a supporting line carries about 15 and each section gets at most one. Cut filler adjectives (seamless, effortless, powerful, beautiful), exclamation marks, and restated headings. Never use emoji as icons or status indicators: use the design system's icon library or status badge variants.

The remaining copy must make the user benefit and next outcome concrete. CTAs use specific action verbs; predictable doubts are reassured beside the decision; duplicate labels and sentences are removed. Remove the brand and imagery as a swap test: if a competitor could reuse the words unchanged, rewrite them from the user's actual problem and vocabulary.

// ❌ Bad (filler, emoji icon, restates itself)
<h2>Powerful, Seamless Match Predictions! ⚽</h2>
<p>Our powerful prediction engine lets you seamlessly predict matches with your friends in a fun and effortless way.</p>

// ✅ Good (specific, one line)
<h2>Predict matches</h2>
<p className="text-muted-foreground">Stake coins on tonight's fixtures.</p>

Why it matters: Filler copy and emoji icons are the fastest generated-content tells. Specific, short copy reads as designed and keeps the primary action visible.

Severity: Moderate

Ceremonial copy with no user job

Rule: Every visible text fragment must earn one concrete user-facing job. It must do at least one:

  • Identify context or origin when it is unclear.
  • Explain what happened.
  • Make the next action clear.
  • Reduce a real user doubt.
  • Satisfy a legal or security requirement.

Do not add provenance lines, helper captions, slogans, onboarding prose, or footer copy just because a familiar template usually includes them. Run the deletion test: remove the line. If meaning, action clarity, safety/trust, legal compliance, and intentional tone do not get worse, keep it removed. Provenance, legal, and security copy are valid only when they change what the user understands or can safely do.

// ❌ Bad: repeats the sender without helping the user
<AuthEmailFooter>This link was sent by Example Co.</AuthEmailFooter>

// ✅ Good: keeps only the line that reduces a real doubt
<p>If you did not request this, you can ignore this email.</p>

Why it matters: Generated UI often completes familiar templates instead of designing the moment. Self-evident copy is not neutral: it creates visual noise, slows scanning, and makes the artifact feel assembled from defaults rather than edited for the user.

Severity: Moderate

Interchangeable value proposition

Rule: A page that sells or explains a product must say who it is for and what makes it different, in the headline and the supporting line. Paste a competitor's name over the headline: if it still reads true, the page has no positioning and the copy is decoration. Fix it upstream, not by rewording. Name the person the product is for, the alternative they use today, and the one difference that would make them switch. If those answers are unknown, ask the user for them and say the positioning is missing. Never fill the gap with adjectives, and never invent a customer, a quote, a count, or an award (see "Fabricated social proof and activity").

// ❌ Bad (any competitor could run this unchanged)
<h1>Build better products, faster</h1>
<p>The all-in-one platform for modern teams.</p>

// ✅ Good (names the person, the alternative, and the difference)
<h1>Invoicing that takes a minute, not an afternoon</h1>
<p className="text-muted-foreground">For freelance editors who bill by the project and track it in a spreadsheet today.</p>

Why it matters: Launching is cheap now, so attention is the scarce resource. A headline that describes a category instead of a difference is skipped, however well the page is built. Positioning is the one part of the page that cannot be generated, because it is a fact about a real business.

Severity: Serious

Points trailing out of winding prose

Rule: When copy makes more than one point (benefits, reasons, steps, options), summarize it into a framework: name the structure first (the count and the frame), then fill it in, in that order. Do not let points trail out of a run-on sentence. The frame is a contract: a "3 things" opener followed by four points, or points in a different order, breaks it. If a point does not fit the frame, change the frame or cut the point; never bolt it on as an afterthought.

// ❌ Bad (points trail out of one winding sentence)
<p>This plan is great because it gives you unlimited projects and it also lets your team collaborate and you get priority support too.</p>

// ✅ Good (frame first, then exactly those items in order)
<p>Three things Pro unlocks:</p>
<ul>
  <li>Unlimited projects</li>
  <li>Team collaboration</li>
  <li>Priority support</li>
</ul>

Why it matters: A named frame ("3 steps", "2 options") primes the reader and makes the copy scannable at a glance; trailing prose forces the reader to reconstruct the structure themselves. See ux/microcopy.md (The Framework Principle).

Severity: Moderate

Reward detached from user value

Rule: At meaningful confirmation, completion, security, payment, progress, or milestone moments, feedback must first confirm what happened and then surface a proportional user payoff. Choose the emotional job deliberately: control for uncertainty, competence for effort and progress, or recognition for a genuinely social accomplishment. Do not celebrate anxious states, bury a real payoff below receipts or secondary chrome, or use confetti and oversized animation for tiny repeated actions. Test whether the treatment remains useful on the tenth repetition.

// ❌ Bad: generic celebration, no system status or user value
<Success title="Amazing!" confetti>Done</Success>

// ✅ Good: confirms the outcome and makes the contextual payoff concrete
<Success title="Order confirmed" detail="Arrives between 14:00 and 15:00" />

Why it matters: Delight is not decoration. A mismatched reward feels manipulative or noisy; a concrete, contextual payoff builds trust, makes progress visible, and reinforces behavior that benefits the user.

Severity: Moderate

Line breaks preserve complete phrases

Rule: Review headings, subtitles, card titles, and short display copy in the rendered interface at every target width. Line breaks must follow the meaning of the sentence, not whichever word happens to reach the container edge. Prefer breaks between sentences or clauses. Keep short noun phrases ("product quality"), names, numbers with units, and other words that are understood together on the same line. Avoid orphaned final words and lines that end with an article, conjunction, or preposition when a small copy, width, or type adjustment can prevent it.

For headings and short display text, start with text-wrap: balance, then adjust the text measure or font size if the browser still produces an awkward break. Use intentional block spans or <br> only for stable editorial compositions such as presentation slides, and test those breaks at every supported viewport. For paragraphs and responsive body copy, do not insert manual line breaks: constrain the measure to roughly 45–75 characters and use text-wrap: pretty so the text can reflow naturally. Recheck long localized strings rather than relying on nonbreaking spaces that can cause overflow.

// ❌ Bad: the rendered heading breaks phrases by accident
// AI can generate
// code. It cannot
// generate product
// quality.
<h1 className="max-w-[11ch]">AI can generate code. It cannot generate product quality.</h1>

// ✅ Good: fixed presentation copy uses deliberate semantic lines
<h1>
  <span className="block">AI can generate code.</span>
  <span className="block">It cannot generate product quality.</span>
</h1>

// ✅ Good: responsive web copy keeps control of its own reflow
<h1 className="max-w-[22ch] text-balance">Build interfaces people can trust.</h1>
<p className="max-w-[65ch] text-pretty">Supporting copy reflows without editorial line breaks.</p>

Why it matters: Readers process text in phrases, not isolated words. A break inside a short semantic unit adds avoidable reconstruction work, weakens the message, and makes a polished slide or page feel accidental. The problem is most visible in large display type, where every line becomes part of the composition.

Severity: Serious

Microcopy details

Rule: The small strings get the same care as the headlines. Pluralize counts ("1 bet", "2 bets", never "1 bets"). No redundant self-labels ("You (you)"). Spell out abbreviations a new user cannot expand ("Matchday 3", not "MD3"). Units and currency use real words or an inline icon, never an improvised glyph ("1,000 coins", not "§1,000"). Empty states name the next action ("Pick a match to place your first bet"), not just zeros. A status badge repeated identically on every item in a list is noise; show it only when it differentiates.

// ❌ Bad
<span>1 bets placed</span> <strong>You (you)</strong> <span>§1,000</span>

// ✅ Good
<span>{n === 1 ? "1 bet placed" : `${n} bets placed`}</span> <strong>You</strong> <span>1,000 coins</span>

Why it matters: These details are where generated UI leaks through last. Each one is trivial alone; together they mark the difference between shipped and drafted.

Severity: Moderate

Contradictory displayed state and ranking

Rule: Keep one source of truth for mutable domain data and derive every balance, rank, total, return, counter, and success message from it. Exercise the main interaction through enough repetitions to cross a boundary or change an ordering, then verify that every displayed value describes the same state. Sort ranked lists by the value the rank represents. If an action changes the sort key, re-sort and re-render the full list immediately; never patch one DOM label while leaving peer values stale. Displayed balances, totals, returns, and success messages must agree after an action; never leave hard-coded sample data that contradicts the updated UI.

// ❌ Bad: the lower balance is ranked above the higher balance
<li>2. Marvin, 900 coins</li>
<li>3. Lucas, 920 coins</li>

// ✅ Good: rank and displayed balance agree
<li>2. Lucas, 920 coins</li>
<li>3. Marvin, 900 coins</li>

Why it matters: A polished surface becomes untrustworthy as soon as its numbers disagree. Generated demos often update one label while leaving related sample values untouched, so the completed interaction must be checked as a coherent state rather than as isolated components.

Severity: Serious

Generated-layout tells

Left-border accent callout boxes

Rule: Do not build callouts, notes, tips, or "key finding" boxes as a tinted panel with a colored vertical bar down the left edge (border-left: 3px solid <accent> plus a faint tinted background). This admonition pattern is a generated-content tell: it reads as a docs-engine artifact, not a designed component. Use a real card instead (border on all four sides, the surface's own radius and shadow), or plain inline emphasis. If the callout needs a category, use a small label or chip inside the card, not a side stripe.

// ❌ Bad (left-bar admonition)
<div className="border-l-4 border-blue-500 bg-blue-50 p-4">
  <b>Key finding.</b> ...
</div>

// ✅ Good (a real card)
<div className="rounded-2xl border border-border bg-card p-5 shadow-sm">
  <p className="text-xs font-semibold uppercase text-muted-foreground">Key finding</p>
  <p>...</p>
</div>

Why it matters: The left-stripe callout is one of the most overused auto-generated UI shapes; shipping it makes a product look templated. A bordered card carries the same grouping with the design system's own surface treatment, so the callout belongs to the UI instead of fighting it.

Severity: Moderate

Dot-joined meta lines

Rule: Do not join facts with a middot (·). A line like Ari from Mono · Waiting 18 min chains unrelated facts at one weight, so the reader gets no help deciding which one matters. Give the line a hierarchy instead: the fact a person acts on carries the weight, and the rest recedes by size or color. Where two facts genuinely belong side by side, separate them with a comma, or place them in their own columns.

// ❌ Bad (two facts, one weight, joined by a dot)
<p className="text-sm text-muted-foreground">Ari from Mono · Waiting 18 min</p>

// ✅ Good (the wait time is the reason to act, so it leads)
<p className="text-sm font-medium text-foreground">Waiting 18 min</p>
<p className="text-xs text-muted-foreground">Ari from Mono</p>

Why it matters: The dot is the default separator of generated interfaces because it needs no decision about which fact matters. That is exactly the decision a designed row makes. A metadata line built this way scans as one grey string, so a user reading a list of them cannot triage without reading every word.

Severity: Serious

Labels above titles

Rule: Never place an eyebrow, kicker, category, or status label immediately above an h1, h2, or h3. Start with the title. If the context is essential, fold it into the title, put it in the single supporting line, or move it after the title.

// ❌ Bad (redundant label before the title)
<p className="text-xs uppercase tracking-widest">Resources</p>
<h1>Guides for better interfaces</h1>

// ✅ Good (the title leads)
<h1>Resources</h1>
<p>Guides for better interfaces.</p>

Why it matters: Labels above titles weaken hierarchy, duplicate information, and are a common generated-interface tell. A clear title should establish the section without a pre-title caption.

Severity: Moderate

Letter-spaced capital labels

Rule: Do not set labels as tiny spaced capitals. This covers text-xs uppercase tracking-widest and every variant of it, with or without font-mono, anywhere it appears: section labels, stat and KPI captions, table column headers, card headers, nav group headings, badges, and tab labels. The eyebrow-pill rule above bans one position; this rule bans the treatment everywhere. Use sentence case at the design system's own label size and muted color. Keep font-mono for code, IDs, keyboard shortcuts, and figures that must align in a column.

// ❌ Bad (the tell, repeated down a dashboard)
<p className="text-xs font-mono uppercase tracking-widest text-muted-foreground">Total revenue</p>
<h3 className="text-[10px] uppercase tracking-[0.2em]">Recent activity</h3>

// ✅ Good (the DS's own label treatment)
<p className="text-sm text-muted-foreground">Total revenue</p>
<h3 className="text-sm font-medium">Recent activity</h3>

Why it matters: Uppercase removes the ascender and descender shapes readers use to recognize words, so it is slowest to read at exactly the size where reading is hardest. Generated interfaces reach for it as a substitute for real hierarchy: when type scale, weight, and color are doing their job, the label does not need shouting to read as a label. Repeated across a screen it is the single most recognizable generated-UI signature.

A design system whose source genuinely uses spaced capitals is the exception, and only inside that system's own scoped components. .better-design/eslint-design-system.mjs flags the static-className case in feature code.

Severity: Moderate

Template hero and layout defaults

Rule: Do not default to the generated-landing-page template: a gradient hero with an uppercase eyebrow pill, a centered three-card feature grid, and glassmorphism panels over a radial-glow background. Reach for these shapes only when the brand or the user's request specifically calls for them. Default to the design system's own surfaces: plain background, real cards, the system's shadow and radius scale.

// ❌ Bad (the template stack)
<section className="bg-gradient-to-br from-green-600 to-blue-600">
  <span className="rounded-full border px-3 py-1 text-xs uppercase tracking-widest">World Cup league</span>
  <h1>Predict. Compete. Win.</h1>
</section>

// ✅ Good (the system's own surfaces)
<section className="bg-background">
  <h1>Predict matches with your friends</h1>
  <Button>Create a group</Button>
</section>

Why it matters: These shapes appear in nearly every generated landing page, so shipping them marks the product as templated even when the tokens are on-brand. The design system's own surfaces carry the identity without the tells.

Severity: Moderate

Cards, pills, and icons used as decoration

Rule: Do not wrap a single paragraph, a lone stat, or a page's only content in a card, and do not nest a card inside a card. Do not attach an icon to every list row, nav item, heading, and feature. Do not turn a plain noun into a pill or badge when it is not a status, a filter, or a count. Stretched empty panels and container overload governs which surfaces deserve a card at all and how they should size; this rule covers the containers and glyphs added purely as decoration.

// ❌ Bad (a container and an icon per line, none of them grouping or naming anything)
<div className="rounded-2xl border p-6">
  <div className="rounded-xl border p-4">
    <SparkleIcon /> <span className="rounded-full border px-2 py-0.5 text-xs">Fast</span>
    <p>Your report is ready.</p>
  </div>
</div>

// ✅ Good (plain text, one container only if it groups)
<p>Your report is ready.</p>

Why it matters: Borders, rounded corners, and small glyphs are the cheapest way to make a screen look considered without deciding what belongs together, so they accumulate wherever the layout was generated rather than designed. Each one costs contrast: when every line has an icon, the icons stop distinguishing anything, and when everything is boxed, the boxes stop grouping. Removing the container usually improves the screen, which is the test.

Severity: Moderate

Violet as the default accent

Rule: Take the accent from the project's own tokens. Do not reach for violet or indigo unless the brand actually uses it, and do not apply a gradient to a headline, a primary button, or an icon as a substitute for an accent decision. Template hero and layout defaults bans the gradient hero section; this rule covers the hue itself and gradients used as an accent anywhere else on the page.

// ❌ Bad (the default generated accent)
<h1 className="bg-gradient-to-r from-violet-500 to-fuchsia-500 bg-clip-text text-transparent">
  Ship faster
</h1>

// ✅ Good (the project's accent, applied flat)
<h1 className="text-foreground">Ship faster</h1>
<Button>Start a project</Button>

Why it matters: This one hue and its gradient are the most recognizable signature of generated interfaces, so shipping it reads as a stock template even when the rest of the page is on-brand. Gradient text also costs contrast and defeats the token system that keeps a product's color consistent.

Severity: Moderate

Dark mode nobody asked for

Rule: Ship dark mode when the brand or the request calls for it, not as a default extra. Do not add a theme toggle to a product that did not ask for one, and do not build a screen dark because dark reads as technical. When the product does support both themes, both must be complete: every surface, border, and state defined in tokens, which the Dark mode inconsistencies rule covers.

// ❌ Bad (a toggle and a second theme nobody asked for, half defined)
<ThemeToggle />
<section className="bg-zinc-950 text-zinc-100">…</section>

// ✅ Good (one theme, taken from the project's tokens)
<section className="bg-background text-foreground">…</section>

Why it matters: An unrequested theme doubles the surface area that has to be designed, reviewed, and kept correct, and it is nearly always half finished, which is worse for the user than a single well-made theme. It also hides token mistakes: a screen that looks acceptable dark can be unreadable light.

Severity: Moderate

Prose tells

The rules above catch generated interface strings. These catch generated prose: the paragraphs in a hero, an about section, a pricing explainer, a changelog entry, a docs page, an empty-state paragraph. Prose is where the model's default voice leaks through hardest, because there is no label or button length to constrain it.

Apply these to any block of running text the product ships. Do not apply them to a user's own writing quoted inside the product, and do not rewrite a real quotation to fit them.

Banned words

Rule: These words never appear in shipped copy: delve, foster, leverage, utilize, facilitate, empower, streamline, robust, cutting-edge, paradigm shift, game changer, tapestry, realm, beacon, multifaceted, meticulous, intricate, paramount, transformative, elevate, embark, supercharge, harness, ever-evolving. Add the interface-copy fillers already banned above: seamless, effortless, powerful, beautiful.

Cut these phrases when they delay the point: it's worth noting, it's important to note, at the end of the day, when it comes to, at its core, in today's world, in the age of, the reality is, the truth is, in terms of, in order to, going forward, let's dive in.

Cut these adverbs when they add nothing: just, literally, honestly, simply, actually, truly, fundamentally, importantly, crucially, inherently, inevitably. Keep one when it carries real emphasis, contrast, or uncertainty.

// ❌ Bad
<p>Our robust platform empowers teams to streamline their workflow and leverage cutting-edge tooling.</p>

// ✅ Good
<p>Ship a design system in an afternoon. Your team reviews the live components in the browser.</p>

Why it matters: This vocabulary is the single most recognizable generated-writing signal. A reader who hits two of these words in one paragraph stops reading the claim and starts reading the machine.

Severity: Moderate

Binary contrasts and negative listing

Rule: Do not define a thing by what it is not. Cut "It's not X. It's Y.", "The question isn't X, it's Y.", "It's not just X but Y.", and stacked denials ("Not a template. Not a starter kit. A design system."). State the positive claim once.

// ❌ Bad
<h2>This isn't a component library. It's a design system.</h2>

// ✅ Good
<h2>A design system with 87 components and its own tokens</h2>

Why it matters: The contrast frame borrows tension it never earns and makes the reader hold a wrong idea before the right one. It is the most-copied shape in generated marketing copy, so it dates the page immediately.

Severity: Moderate

Throat-clearing and faux-insight setups

Rule: Delete the sentence that announces the next sentence. Cut "Here's the thing", "Let me be clear", "I'll be honest", "The uncomfortable truth is", "What most people get wrong", "Here's what nobody tells you", "The part everyone misses". Cut rhetorical setups too: "What if I told you", "Think about it:", "Plot twist:", and self-answered "Question? Answer." pairs. Make the claim stand alone.

// ❌ Bad
<p>Here's what nobody tells you about design systems: distribution is the real problem.</p>

// ✅ Good
<p>Most design systems fail on distribution.</p>

Why it matters: The setup flatters the writer as the lone expert and costs the reader a full sentence before any content arrives. On a hero or a docs page, that sentence is the one the reader actually sees.

Severity: Moderate

Colon reveals and dramatic fragments

Rule: Do not use a noun phrase, a colon, then a lowercase dramatic reveal ("The best part: it learns."). Use colons for lists, labels, and quotations. Do not stack punchy fragments for rhythm ("That's it. That's the whole thing.", "X. And Y. And Z."). Write complete sentences and vary their shape.

// ❌ Bad
<p>The detail that makes it work: a second agent grades the output. That's it. That's the whole system.</p>

// ✅ Good
<p>A second agent grades the output, which is what makes the first one improve.</p>

Why it matters: Both shapes simulate emphasis instead of earning it. Repeated across a page they produce a robotic cadence that reads as generated even when every individual claim is true.

Severity: Moderate

Puffery, weasel attribution and superficial analysis

Rule: State the fact and let the reader judge whether it matters. Cut "stands as a testament", "marks a pivotal moment", "plays a vital role", "solidifies its position", "underscores its significance". Cut unsourced authority: "experts agree", "studies show", "industry reports suggest", "widely regarded as". Name the source or drop the claim; never invent one. Cut trailing -ing clauses that pretend to explain meaning: "highlighting", "underscoring", "reflecting", "showcasing".

// ❌ Bad
<p>The launch adds file search, highlighting the team's commitment to better workflows. Experts agree this marks a pivotal moment for the category.</p>

// ✅ Good
<p>The launch adds file search, so you can find an old draft without leaving the editor.</p>

Why it matters: Puffery asks the reader to accept significance the copy has not shown, and invented attribution is fabricated data rendered as fact, the same failure as a fake testimonial. Both cost trust in every other claim on the page.

Severity: Serious

Fake-profound kickers and recap endings

Rule: Do not end a section with a mic-drop line, an aphorism, or a metaphor that restates the point as something deeper ("The future isn't coming. It's already here."). Do not end with a recap either: "In conclusion", "Ultimately", "Overall", or a final paragraph that repeats the section. Delete the closing line and end on the last concrete sentence, a plain takeaway, or the next action. Do not rewrite a kicker into a better metaphor; remove it.

// ❌ Bad
<p>Ultimately, design is not about pixels. It's about people. The future of interfaces isn't coming. It's already here.</p>

// ✅ Good
<p>Pick a design system, then generate the component set against it.</p>
<Button>Browse design systems</Button>

Why it matters: The reader was just there, so a recap wastes the most valuable position on the page. A fake-profound kicker is worse: it replaces a concrete next step with a sentence that means nothing, exactly where a call to action belongs.

Severity: Moderate

Synonym cycling and weak verbs

Rule: Repeat the correct word instead of rotating synonyms for variety. If it is an agent, call it an agent every time, not "the assistant" then "the tool" then "the system". Replace weak verb phrases with direct verbs: "made a decision" becomes "decided", "has the ability to" becomes "can", "serves as a centralized hub for" becomes "tracks". Prefer "is" and "has" when they are clearer than an inflated verb.

// ❌ Bad
<p>The agent reviews the draft. The assistant then scores the piece. The tool has the ability to suggest fixes.</p>

// ✅ Good
<p>The agent reviews the draft, scores it, and suggests fixes.</p>

Why it matters: Rotating terms makes the reader check whether three things are being described or one. Inflated verbs add length without adding meaning, which is how a two-line explainer becomes a paragraph nobody finishes.

Severity: Moderate

Prose formatting slop

Rule: Formatting follows the content. No emoji in headings. No bold sprinkled mid-sentence for emphasis; use <strong> only to name a literal UI element the reader must find. No bullet list where two sentences of prose read better, and no heading over a two-sentence section. No em dashes at all: they are a rhythm crutch, and a comma, a colon, a period, or parentheses always covers the same job.

// ❌ Bad
<h2>🚀 Getting started</h2>
<ul><li>It's <b>fast</b></li><li>It's <b>simple</b></li></ul>

// ✅ Good
<h2>Getting started</h2>
<p>Install the CLI, then run <strong>Generate</strong>. The first component set takes about a minute.</p>

Why it matters: Decorative formatting is a substitute for structure. Emoji headings and mid-sentence bold are the two fastest visual tells that copy was generated rather than edited, and they survive every token change because they live in the markup, not the theme.

Severity: Moderate

Generated-code tells

Silent error swallows

Rule: A catch that degrades (return null, return [], empty body) must either log the error (console.warn/console.error) or carry a comment stating why silence is correct. Never let an outage wear the same face as empty data: a swallowed database error that returns null makes "user does not exist" and "database down" indistinguishable. Enforced at edit time by .claude/hooks/slop-guard.js and in packages/web by ESLint no-empty.

Why it matters: Generated code reaches for catch { return null } as a universal safety wrapper. Each one deletes a failure signal; together they make the system undebuggable.

Severity: Serious

Cargo-cult timers

Rule: Never use a setTimeout with a magic constant to wait for something to "settle", "be ready", or "have updated" when a real signal exists: an event (transitionend, load), a promise (document.fonts.ready, img.decode()), or a library callback. Reconnect loops need exponential backoff with a cap, not a fixed short interval. If no signal genuinely exists, the comment must say so explicitly. Enforced at edit time by .claude/hooks/slop-guard.js.

Why it matters: A guessed delay is either too short (flaky) or too long (slow), and it hides the real dependency. The canonical incident: a generated 700ms "settle" delay standing in for document.fonts.ready.

Severity: Serious

Dead code and unwired exports

Rule: Every new function, hook, component, or file must be wired to a call site in the same change (see the Wiring Rule in CLAUDE.md). Never keep an unused export "for later", never copy a file as a variant while the original stays live, and never leave a barrel re-export no consumer imports. When removing a feature, remove its helpers, types, and comments in the same commit.

Why it matters: Dead code is worse than no code: it gives a false sense of coverage, rots out of sync with the live path, and doubles the surface every future reader must understand. The 2026 slop audit removed about 2,900 lines of it in one pass.

Severity: Serious

Ethics & Dark Patterns

Whether the interface preserves informed choice. ux/anti-patterns.md (Manipulation & Ethics) covers the reasoning and the Regret and Black Mirror tests; these rules catch the patterns that show up in code.

Fabricated urgency and scarcity

Rule: Countdown timers, stock warnings, and deadlines must reflect real system state. Never hardcode a timer that resets per visit, a "spots left" count with no data source, or an expiring offer that does not expire. If the scarcity is real, bind it to the value that proves it; if it is not real, remove the element.

// ❌ Bad (timer seeded from page load; the "deadline" is fiction)
const [secondsLeft, setSecondsLeft] = useState(15 * 60);
<p>Offer expires in {format(secondsLeft)}. Only 3 spots left!</p>

// ✅ Good (real deadline from the domain, or nothing)
<p>Early-bird pricing ends {format(plan.earlyBirdEndsAt)}.</p>

Why it matters: Invented urgency converts once and burns trust permanently; users who notice the timer reset stop believing every other claim in the product. It also fails the Regret Test: knowing the deadline is fake, no user would rush. Like fabricated social proof, this is invented data rendered as real, so it carries the same severity.

Severity: Critical

Anti-user defaults

Rule: Pre-selected choices must serve the user, not the metric. Marketing emails, data sharing, add-ons, auto-renewal of a higher tier, and anything with a cost or privacy consequence require an explicit opt-in or a previously saved user preference, never a pre-checked box. A default may favor the product only for choices with no cost or privacy consequence, and only when it is also the choice most users would make with full information.

// ❌ Bad (consent harvested through inertia)
<Checkbox defaultChecked name="marketing" label="Send me tips and offers" />
<Checkbox defaultChecked name="shareData" label="Share usage data with partners" />

// ✅ Good (the user opts in)
<Checkbox name="marketing" label="Send me tips and offers" />

Why it matters: Status-quo bias means defaults decide outcomes; a pre-checked box is a decision made for the user, paid for in their money or their privacy. Consent gathered through inertia is resentment on a delay: it does not show an informed choice, and it damages trust when the user discovers it.

Severity: Serious

Confirmshaming and manufactured anxiety

Rule: The decline option in any prompt is neutral and factual ("No thanks", "Not now", "Skip"). Never write the opt-out as self-disparagement, guilt, or fear, and never frame dismissal as a loss the copy invented. Shame-free copy applies to both directions: the accept path states the benefit without inflating it.

// ❌ Bad (guilt-loaded decline)
<Button variant="primary">Yes, protect my account</Button>
<Button variant="ghost">No, I like being at risk</Button>

// ✅ Good (neutral decline)
<Button variant="primary">Enable two-factor auth</Button>
<Button variant="ghost">Not now</Button>

Why it matters: Confirmshaming trades a small conversion bump for the user's respect. The pattern is instantly recognizable, widely mocked, and marks the product as adversarial at exactly the moment it asked for trust.

Severity: Serious

Obstructed exit and cancellation

Rule: Leaving must be as findable as joining. Dismissible surfaces get a visible close affordance; subscriptions cancel in the product when the product owns billing, or hand off visibly to the billing provider's cancellation flow (never "contact support"); unsubscribe and notification controls are one obvious step, not a maze. Never hide, shrink, disable-style, or delay the exit to prolong engagement.

// ❌ Bad (exit disguised as disabled, cancellation routed to a dead end)
<button className="pointer-events-auto text-muted-foreground/30">×</button>
<p>To cancel, contact support and allow 5–7 business days.</p>

// ✅ Good (exit is a first-class action)
<Button variant="ghost" aria-label="Close">×</Button>
<Button variant="outline">Cancel subscription</Button>

Why it matters: Engagement that continues because the exit is hidden is not engagement, it is captivity, and churn metrics built on it are fiction. A clear exit is also what makes staying meaningful.

Severity: Serious

Fabricated social proof and activity

Rule: Activity indicators, viewer counts, testimonials, and "X people bought this" claims must come from real data. Never generate randomized "live" activity, invented reviews, or placeholder testimonials that ship as real. If there is no data yet, show nothing; an empty state is honest, a fake crowd is not.

// ❌ Bad (a random number cosplaying as demand)
<p>{Math.floor(Math.random() * 20) + 5} people are viewing this right now</p>

// ✅ Good (real signal, or none)
{viewerCount > 1 && <p>{viewerCount} people are viewing this</p>}

Why it matters: Fabricated social proof is deception in markup. One discovered fake ("why does the viewer count change on refresh?") retroactively poisons every genuine number the product shows.

Severity: Critical

Fabricated reference prices

Rule: A struck-through price, a "was" price, a percentage saved, or an RRP must reflect a price the product was genuinely sold at, or a real third-party list price. Never hardcode a higher number purely to make the current one look like a discount, never derive it with a multiplier, and never leave a placeholder anchor in shipped markup. If there is no real prior price, show the price alone.

// ❌ Bad (the anchor is invented to manufacture a saving)
<p><s>${(price * 1.6).toFixed(0)}</s> ${price} <span>Save 38%</span></p>

// ✅ Good (a real prior price, or just the price)
{product.previousPrice && <s>${product.previousPrice}</s>}
<span>${price}</span>

Why it matters: Anchoring works because the reader believes the first number was real, so an invented anchor is fabricated data rendered as fact, the same failure as a fake viewer count or a placeholder testimonial. It also carries legal exposure that the other patterns do not: reference-price claims are regulated advertising in the UK, the EU, and many US states.

Severity: Critical

High-stakes actions mixed with frequent ones

Rule: Destructive or irreversible actions (delete, revoke, cancel, transfer) are separated from frequent actions in space and style: never adjacent to a common tap target at the same visual weight, always labeled with the concrete consequence, and confirmed or undoable when the loss is real. The safe option is the prominent one in any confirmation.

// ❌ Bad (delete sits beside the most-used action, same weight)
<Button>Save</Button>
<Button>Delete project</Button>

// ✅ Good (separated, consequence named, safe option leads)
<Button>Save</Button>
...
<Button variant="destructive">Delete project…</Button>
// confirm dialog: "This permanently deletes 12 components." [Keep project] [Delete]

Why it matters: A slip next to a frequent target is a designed accident, and users blame themselves for it. Separation, labeling, and undo turn the worst moment in the product into proof it was built carefully. See ux/errors.md (Confirm destructive actions).

Severity: Serious

Implementation Notes

When building the review tool:

  1. Parse code - Use regex or AST parsing to find patterns
  2. Check each rule - Iterate through rules and find violations
  3. Calculate line numbers - Match violations to line numbers in source
  4. Format output - Structure issues with all required fields
  5. Calculate score - Apply scoring formula
  6. Prioritize fixes - Show critical issues first

Performance:

  • Focus on critical and serious issues first
  • Moderate issues are nice-to-have
  • Don't report every single spacing inconsistency - cluster similar issues

Context awareness:

  • Consider framework patterns (Next.js Image, Radix components have built-in a11y)
  • Allow exceptions for well-tested component libraries
  • Focus on custom code more than library usage

Review Output Format

When reviewing code, return issues in this format:

interface ReviewIssue {
  severity: 'critical' | 'serious' | 'moderate';
  category: 'accessibility' | 'visual-design' | 'animation' | 'content' | 'comprehension';
  rule: string;
  lineNumber?: number;
  codeSnippet: string;
  problem: string;
  fix: string;
  wcagReference?: string; // For accessibility issues
}

Example:

{
  "severity": "critical",
  "category": "accessibility",
  "rule": "Icon-only buttons missing aria-labels",
  "lineNumber": 24,
  "codeSnippet": "<button><CloseIcon /></button>",
  "problem": "Button has no accessible name for screen readers",
  "fix": "Add aria-label=\"Close\"",
  "wcagReference": "4.1.2 Name, Role, Value (Level A)"
}

Report what you considered and rejected

Findings alone let the reader confuse coverage with silence. After the issues, list what you looked at and chose not to flag, one line each, with the reason:

Considered and rejected
- The 14px caption under the chart. Below the body floor, but it is a legend, and the value it labels is at 16px.
- The missing focus ring on the card. The card is not focusable; its link inside is, and that ring is present.
- The 6px gap in the tag row. Off the 4pt scale, but it is optical spacing against a rounded chip.

Two rules make this section worth reading. Keep it to candidates you genuinely weighed, not a tour of everything that was fine. Never move a real finding here to shorten the list.

Close with a verdict

One line, closing the report after the findings and the score, answering the only question the reader has:

  • Ship. No critical issues and no serious issues.
  • Fix first. Any critical issue, or any serious issue. Name the one to start with: the critical issue, or the most severe when several tie.

One rule, so two reviewers reach the same verdict on the same screen.

A review without a verdict makes the reader compute it from the list, which is the work they asked you to do.

Scoring

Calculate a score out of 100:

  • Critical issues: -20 points each (max -100)
  • Serious issues: -10 points each
  • Moderate issues: -5 points each

Start at 100 and subtract points. Minimum score is 0.

Score interpretation:

  • 90-100: Excellent
  • 75-89: Good
  • 60-74: Needs improvement
  • 0-59: Poor

Per-category breakdown: Alongside the total, compute the same 100-point score per category (accessibility, visual-design, animation, content, comprehension) from that category's issues only. The weakest category is the priority: fix its critical and serious issues before polishing a category that already scores well. Averages hide failures; a screen with 95 in visual design and 40 in accessibility is not "mostly fine", it is inaccessible. After applying fixes, re-score the same categories to confirm the weakest one actually moved instead of re-polishing the strongest.

Visual Design

Layout & Spacing

Inconsistent spacing values

Rule: Use spacing values from your design system. Don't use arbitrary values.

// ❌ Bad (arbitrary values)
<div className="mt-[13px] mb-[27px]">

// ✅ Good (system values)
<div className="mt-3 mb-6">

// Reference your spacing scale
// spacing-1: 4px
// spacing-2: 8px
// spacing-3: 12px
// spacing-4: 16px
// spacing-5: 24px
// spacing-6: 32px

Why it matters: Consistent spacing creates visual rhythm and makes designs feel cohesive. The same rule governs radii, shadows, z-index, and font size (see Typography, off-scale font sizes): if the scale does not have the value, the fix is to add a step, not to inline a one-off.

Grid cards keep equal heights and aligned footers

Rule: Cards rendered side by side in a grid must read as one aligned row: equal heights, footers on the same baseline, and wrap-prone rows (badges, chips, tags) in the same slot on every card. One card's badge row wrapping to a second line must not push its footer lower than its neighbors.

// ❌ Bad (footer floats at a different height per card)
<div className="grid grid-cols-3 gap-4">
  <Card>
    <CardContent>{badges}</CardContent>
    <CardFooter>…</CardFooter>
  </Card>
</div>

// ✅ Good – React/shadcn: add flex layout manually (shadcn Card has no flex defaults)
<Card className="flex h-full flex-col">
  <CardContent>{badges}</CardContent>
  <CardFooter className="mt-auto">…</CardFooter>
</Card>

// ✅ Good – HTML kit: already baked in; no extra classes needed
// <div class="ds-card"> … <div class="ds-card-footer">…</div></div>

Severity: serious. A designer reads uneven footers as broken alignment even when every component is correct in isolation.

Stretched empty panels and container overload

Rule: Do not stretch a card merely to fill the canvas or match a taller unrelated panel. Repeated peer cards in a grid should still align; distinct panels should size to their content. Use cards for distinct interactive surfaces, reserve pills for compact controls or status, and put whitespace between groups instead of leaving a large empty floor inside bordered boxes.

/* ❌ Bad: one short match row becomes a screen-height empty card */
.layout { display: grid; align-items: stretch; min-height: 42rem; }

/* ✅ Good: content determines panel height and related rows stay compact */
.layout { display: grid; align-items: start; }
.panel { align-self: start; }

Why it matters: Container-heavy layouts make every region compete at the same visual level. Empty space inside a giant card reads as unfinished; deliberate space between related groups creates hierarchy.

Severity: Serious

Generic dashboard framing and weak domain expression

Rule: Reject the default generated-app frame: a product title followed by metric pills, equal rounded cards, and a co-equal sidebar or leaderboard. Unless the user asked for a dashboard, the primary user moment must be the largest, highest-contrast composition in the first view. Express the product through structure, not labels alone. If the screen could serve a different product after renaming labels, it lacks enough domain-specific composition.

Domain expression is pass/fail. Do not stop at domain nouns, initials, or three-letter codes. The first view needs at least two unmistakable visual cues from the real activity—such as an event stage, paired opponents, round or time strip, rank movement, route, inventory object, or timeline—expressed through the selected system's tokens and layout rather than decorative filler.

When one task dominates, compose the primary experience directly on the canvas instead of nesting it in a generic raised card beside another card. Use no more than one major bordered or elevated surface above the fold unless the surfaces are true peers.

For a sports fixture, build a compact full-width event header with a scoreboard or arena composition, not a plain form inside a white card or a flat neutral slab. Pair opponents at display scale around the competition, round, and event state, then keep choice, stake, return, and submit attached as the match's action layer. A pitch or arena cue is optional, not a checkbox. If used, confine it to the event header; it must never continue behind form controls or cross text, and its geometry must be exact: the center circle must share the exact center point of the halfway lines. Put standings below the primary action or behind a tab, never in a co-equal side panel.

When flags, crests, or logos identify opponents, paired peers use the same asset treatment and each asset must be accurate; never approximate a flag or logo with a colored rectangle or invented mark. If exact assets are unavailable, omit them for every peer and use consistent names or codes; never invent a one-letter logo. Use the inline canvas deliberately: do not float a narrow mini-dashboard in a wide preview or leave an orphaned column of background. Empty states stay compact where future content will appear; do not reserve a large bordered panel for one sentence.

/* ❌ Bad: generic mini-dashboard floating in a wide canvas */
.app { max-width: 42rem; }
.header-metrics { display: flex; }
.main { display: grid; grid-template-columns: 2fr 1fr; }
.empty-state { min-height: 16rem; }

/* ✅ Good: the primary experience owns the composition; secondary follows */
.app { max-width: 64rem; }
.primary-experience { grid-column: 1 / -1; }
.secondary-content { grid-column: 1 / -1; align-self: start; }
.empty-state { padding-block: 1rem; }

Why it matters: A technically tidy interface still feels generated when it could become a finance, travel, or task app by swapping nouns. Product character comes from the relationship, scale, and order of the real objects people came to use.

Severity: Serious

Sparse-but-oversized composition and broken proximity

Rule: Fewer words do not excuse oversized geometry. Keep one visible identity block per level; do not stack an app logo/title directly above a second logo/title for the same experience. A short primary task must size to content. At the actual canvas size, fail a major bordered or elevated surface when more than one-third of its height is unused after the primary action. Do not use viewport height or a large min-height merely to fill a preview.

Size controls from the value they accept. A numeric stake field should be roughly 8–12 characters wide, not a nearly full-width bar. Keep the input, derived value, and primary action in one compact group. On wide screens the input and button must use the same control height and bottom baseline; on narrow screens they stack in reading order. Use at least a 1:3 proximity ratio: spacing within the label/input/action group is no more than one-third of the spacing between separate sections.

/* ❌ Bad: sparse content stretched into a mock-dashboard canvas */
.task { min-height: 70vh; }
.stake { width: 75%; }
.actions { align-items: center; }

/* ✅ Good: content-sized task and one aligned action group */
.task { min-height: 0; }
.control-row {
  display: grid;
  grid-template-columns: minmax(8rem, 14rem) auto;
  gap: 0.75rem;
  align-items: end;
}

Why it matters: Empty internal space reads as unfinished, while detached labels, values, and actions force the eye to reconstruct a form that should scan as one unit. Alignment and proximity communicate the interaction more reliably than an extra container or caption.

Severity: Serious

Control placed beside something it does not change

Rule: Put every control in the same visual group as the object it acts on. Fail a control that sits next to an unrelated object when no boundary separates the two groups: neither group has its own container, heading, or surface, and the gap between the groups is under 2× the gap inside them. A shared ancestor around both groups is not a boundary. One threshold governs this rule, the 2× control-group ratio from the spacing docs; the 1:3 ratio in Sparse-but-oversized composition and broken proximity above applies at the coarser level, between separate sections. Check the seam between two stacked groups first, because that is where the wrong neighbor claims a control.

// ❌ Bad (the playlist picker sits under the TV, so it reads as the channel)
<section className="space-y-3">
  <TvPanel />
  <PlaylistSelect value={playlist} onChange={setPlaylist} />
  <VolumeSlider value={musicVolume} onChange={setMusicVolume} />
</section>

// ✅ Good (each control sits in the block it changes)
<section className="space-y-8">
  <div className="space-y-3">
    <TvPanel />
    <PowerButton />
  </div>
  <div className="space-y-3">
    <h2>Music</h2>
    <PlaylistSelect value={playlist} onChange={setPlaylist} />
    <VolumeSlider value={musicVolume} onChange={setMusicVolume} />
  </div>
</section>

Why it matters: People infer what a control does from what it sits next to, before they read its label. A misplaced control hands the user the wrong mental model, and a correct label does not take it back. Generated layouts hit this often, because the model orders elements in source and never sees the rendered screen.

Severity: Serious

Flat hierarchy and simultaneous information overload

Rule: The first view needs one P0 task and at most one P1 summary. Move P2 history, standings, detailed metadata, and empty-state chrome below the first view or behind a tab or disclosure. Unless the user explicitly requested a dense dashboard, fail any first view with more than three major regions, more than five immediately visible actions, or more than 25 visible text fragments. Remove or disclose content before rearranging it.

Blur, squint, or zoom out until the copy is unreadable. The screen's purpose and intended first action must still lead through size, color, space, placement, visualization, or purposeful motion. If a logo, illustration, decorative card, or backend-shaped metric grid wins instead, the hierarchy fails even when each component is polished. Diagnose the competing element before changing a dial; do not respond by making several elements larger, brighter, and animated at once.

Do not make a busy screen look compact by shrinking everything. The primary display, score, or event must be at least 1.5× the base UI text, and section anchors must be at least 1.2×. Reserve bold for the primary display, section anchors, and active values; keep metadata, table headers, helper text, and secondary labels quieter. Labels are a last resort: remove captions that merely restate an obvious value, and do not label the same fact twice.

/* ❌ Bad: every element has nearly the same weight and all content is visible */
.title { font-size: 1.05rem; font-weight: 600; }
.section-title { font-size: .95rem; font-weight: 600; }

/* ✅ Good: one dominant task, scan anchors, and visibly secondary support */
.primary-display { font-size: 2rem; font-weight: 700; }
.section-title { font-size: 1.25rem; font-weight: 600; }
.supporting { font-size: .875rem; font-weight: 400; }

Why it matters: A screen can obey component and copy rules yet remain hard to scan when every fact competes at the same level. Clear scale, selective emphasis, and progressive disclosure let people find the task before they read the details.

Severity: Serious

Overflow and alignment issues

Rule: Ensure content doesn't overflow containers unintentionally.

// ❌ Bad (can overflow)
<div className="w-64">
  <p>{veryLongTextWithoutBreaks}</p>
</div>

// ✅ Good (handles overflow)
<div className="w-64 overflow-hidden">
  <p className="truncate">{veryLongTextWithoutBreaks}</p>
</div>

// ✅ Good (wrap text)
<div className="w-64">
  <p className="break-words">{veryLongTextWithoutBreaks}</p>
</div>

Why it matters: Overflow breaks layouts and creates horizontal scrolling.

Z-index conflicts

Rule: Use a z-index scale. Don't use arbitrary high values.

// ❌ Bad
<div style={{ zIndex: 99999 }}>

// ✅ Good (defined scale)
<div className="z-modal"> {/* z-index: 50 */}
<div className="z-dropdown"> {/* z-index: 40 */}
<div className="z-sticky"> {/* z-index: 20 */}

// Recommended z-index scale:
// z-0: 0
// z-10: 10
// z-20: 20
// z-30: 30
// z-40: 40
// z-50: 50

Why it matters: Arbitrary z-index values create stacking conflicts and are hard to debug.

Typography

Mixed font families and weights

Rule: Stick to 1-2 font families and 2-3 weights maximum. Two weights is the working default: the body weight, and one heavier weight for headings and a single lead figure. A third has to mark a level the first two cannot. Weight is not a highlighter, so bold belongs to the hierarchy rather than to whichever words feel important (see Flat hierarchy and simultaneous information overload for which elements earn it, and Prose formatting slop for bold inside running text).

// ❌ Bad (too many fonts)
<h1 className="font-serif">Title</h1>
<p className="font-sans">Body</p>
<button className="font-mono">Action</button>

// ✅ Good (consistent family)
<h1 className="font-sans font-bold">Title</h1>
<p className="font-sans">Body</p>
<button className="font-sans font-semibold">Action</button>

Why it matters: Too many fonts create visual chaos. Professional designs use 1-2 families consistently.

Too many type sizes on one view

Rule: A single view reads with about three type sizes. The scale may hold more steps; one screen should not spend them all. Give the page title one size, section headings a second, body a third, and let weight and color carry every remaining distinction. Small print such as a caption or a legal line counts as a fourth only when it is a genuinely different kind of text, not when it is body copy that got shrunk.

// ❌ Bad (six sizes on one card, none of them a level)
<h2 className="text-2xl">Revenue</h2>
<p className="text-lg">This month</p>
<p className="text-4xl">$18,400</p>
<p className="text-sm">vs last month</p>
<p className="text-xs">+12%</p>
<p className="text-[11px]">Updated 4 June 2026</p>

// ✅ Good (three sizes, weight and color do the rest)
<h2 className="text-sm text-muted-foreground">Revenue this month</h2>
<p className="text-4xl font-medium tabular-nums">$18,400</p>
<p className="text-sm text-muted-foreground">Up 12% on last month. Updated 4 June 2026.</p>

Why it matters: Every extra size announces a new level of hierarchy, so a view with six sizes claims six levels and delivers none. Spending sizes is the cheapest way to make a screen look composed without deciding what matters, which is why generated interfaces reach for it. Three sizes force the real decision: what does the reader see first, second, and last.

Severity: Moderate

Line height issues

Rule: Use appropriate line-height for text size.

// ❌ Bad (tight line-height on body text)
<p className="text-base leading-tight">Long paragraph...</p>

// ✅ Good (appropriate line-height)
<h1 className="text-4xl leading-tight">Heading</h1>
<p className="text-base leading-relaxed">Long paragraph...</p>

// Guidelines:
// - Large headings: leading-tight (1.25)
// - Body text: leading-normal to leading-relaxed (1.5-1.75)
// - Small text: leading-relaxed (1.75)

Why it matters: Tight line-height on body text reduces readability. Headlines need tighter spacing.

Off-scale font sizes, especially in pixels

Rule: Take every font size from the type scale. An arbitrary size written inline is a failure whatever its unit: text-[15px] and text-[2rem] are both wrong, because both skip the scale. The tell that a value is invented is the reasoning behind it: "14 felt small and 16 felt big" means the scale already had the answer. When a genuine gap exists, add a step to the scale (see ui/typography.md) rather than a one-off in the markup.

This governs values authored in markup, not the units a scale is documented in. A scale step should still resolve to a relative unit so it tracks the reader's browser font-size setting; a hardcoded font-size: 15px on an element does not, which is why an inline pixel size costs accessibility on top of consistency.

Arbitrary values remain acceptable for quantities the scale does not model, in units that respond to text size: ch for line measure (max-w-[48ch]), and unitless line-height ratios for display type (leading-[1.05], where the scale's tightest step is still too loose for a large headline).

// ❌ Bad (invented sizes that skip the scale)
<blockquote className="text-[15px]">…</blockquote>
<h1 className="text-[2rem]">…</h1>

// ✅ Good (scale steps, responsive by breakpoint)
<blockquote className="text-base">…</blockquote>
<h1 className="text-3xl sm:text-5xl lg:text-6xl">…</h1>

// ✅ Acceptable arbitrary values: units the scale does not model
<p className="max-w-[48ch]">…</p>
<h1 className="text-6xl leading-[1.05]">…</h1>

Why it matters: Off-scale sizes break the vertical rhythm the scale exists to keep, and they compound: one exception invites the next until the type system is decorative.

Severity: Serious

Missing font fallbacks

Rule: Always provide fallback fonts.

/* ❌ Bad */
font-family: "CustomFont";

/* ✅ Good */
font-family: "CustomFont", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;

Why it matters: Custom fonts fail to load. Fallbacks ensure readable text.

Color & Contrast

Contrast ratio below 4.5:1

Rule: Normal text needs 4.5:1 contrast ratio. Large text (18px+ or 14px+ bold) needs 3:1.

// ❌ Bad (insufficient contrast)
<p className="text-gray-400">Important text</p> {/* on white bg */}

// ✅ Good
<p className="text-gray-900">Important text</p> {/* on white bg */}
<p className="text-gray-400">Supporting text</p> {/* less important */}

WCAG Reference: 1.4.3 Contrast (Minimum) (Level AA)

Tools: Use contrast checkers (WebAIM, Figma plugins) to verify.

Why it matters: Low contrast text is difficult to read, especially for users with visual impairments.

Host canvas text-color leakage

Rule: Inline canvas hosts can assign explicit colors to bare headings, strong, labels, and other semantic elements. A foreground color inherited only from the app root does not override those declarations. Add a root-scoped semantic text reset after host/base CSS, then inspect computed foreground and background colors in the rendered artifact. Normal visible text must remain at least 4.5:1.

Text must use foreground-role tokens: --foreground on the page, --card-foreground on cards, and --muted-foreground for secondary copy. Background, card, muted, secondary, accent, and input tokens are surfaces, never text colors. Never dim text with opacity; use the appropriate foreground token and verify its computed contrast instead.

/* ❌ Bad: host h2/strong/label colors can override root inheritance */
#app { color: var(--foreground); }

/* ✅ Good: the app root wins over bare host element selectors */
#app :where(h1,h2,h3,h4,h5,h6,p,strong,label,legend,li,dt,dd,small):not(.ds-supporting):not(.ds-card-desc):not(.ds-muted) {
  color: inherit !important;
}

Why it matters: A light design system rendered inside a dark-aware host can otherwise produce near-white headings and labels on white or cream cards even when the token palette itself is accessible.

Severity: Serious

Missing hover/focus states

Rule: Interactive elements need visible hover and focus states.

// ❌ Bad (no hover state)
<button className="bg-blue-500 text-white">
  Click me
</button>

// ✅ Good
<button className="bg-blue-500 hover:bg-blue-600 focus:bg-blue-600 focus:ring-2">
  Click me
</button>

Why it matters: Users need feedback when hovering or focusing on interactive elements.

Hover sticks on touch or fights the selected state

Rule: Hover is a pointer-only enhancement, not another persistent state. Put hover-only CSS inside @media (hover: hover) and (pointer: fine). Do not translate, scale, or otherwise move buttons, tabs, chips, or selectable tiles on hover. Unselected hover stays neutral: it may slightly strengthen the neutral surface or edge, but it never uses --primary or --accent as its background and never changes text to the primary color. Only selection gets the saturated brand treatment. A hovered selected control must keep its selected fill, edge, text contrast, and native/ARIA state instead of falling back to the unselected hover treatment. Do not add app-specific :hover rules to ds-* controls; use the kit states verbatim.

/* ❌ Bad: sticks after tap, moves the target, and can override selection */
.option:hover {
  background: var(--accent);
  color: var(--primary);
  transform: translateY(-1px);
}

/* ✅ Good: fine pointers only; pressed state remains authoritative */
@media (hover: hover) and (pointer: fine) {
  .option:hover:not([aria-pressed="true"]) {
    background: color-mix(in oklab, var(--foreground) 4%, var(--background));
    color: var(--foreground);
    border-color: color-mix(in oklab, var(--foreground) 28%, var(--border));
  }
}

.option[aria-pressed="true"],
.option[aria-pressed="true"]:hover {
  background: var(--primary);
  color: var(--primary-foreground);
  border-color: var(--primary);
}

Why it matters: Touch browsers can leave :hover stuck after a tap, and moving a target under the pointer makes dense selection controls feel unstable. A colored hover beside a selected control reads like a second, weaker selection. Selected state must remain the only branded, unambiguous choice on every input device.

Severity: Serious

Dark mode inconsistencies

Rule: If supporting dark mode, ensure all elements have dark variants.

// ❌ Bad (only light mode)
<div className="bg-white text-black">

// ✅ Good
<div className="bg-white dark:bg-gray-900 text-black dark:text-white">

Why it matters: Missing dark mode styles create unusable interfaces when dark mode is enabled.

Tinted chips and badges wash out on coloured hover rows

Rule: A chip or badge with a light or semi-transparent fill (a muted pill, an outline badge) placed on a row that gets a coloured hover highlight bleaches out, because its translucent fill alpha-composites toward the hover colour. Add mix-blend-multiply dark:mix-blend-normal so the fill darkens into the row colour in light mode and stays legible. Apply it only to light or translucent fills, and never to opaque coloured fills, because multiply shifts a solid fill toward a muddy compound colour over any non-white background. On the shared Badge primitive, scope the blend to the transparent outline variant, not the CVA base, so default, secondary, and destructive are left untouched.

// ❌ Bad (translucent chip bleaches out on a coloured hover row)
<span className="rounded-full bg-muted/50 px-2.5 py-1">Styled like Linear</span>

// ❌ Bad (blanket multiply on the shared Badge base muddies opaque variants)
const badgeVariants = cva("… mix-blend-multiply dark:mix-blend-normal", { … });

// ✅ Good (blend the translucent chip; guard dark mode)
<span className="rounded-full bg-muted/50 px-2.5 py-1 mix-blend-multiply dark:mix-blend-normal">
  Styled like Linear
</span>

// ✅ Good (scope the badge blend to the transparent outline variant only)
outline: "text-foreground mix-blend-multiply dark:mix-blend-normal",

Why it matters: Multiply keeps a tinted chip readable over a coloured hover state instead of letting it fade into the row. Multiply on a dark surface crushes the chip toward black, so the dark:mix-blend-normal guard is required. Blanket-applying it to opaque coloured badges corrupts their intended colour, so the blend must be scoped to light or translucent fills.

Severity: Moderate

Rendered Hierarchy

Flat focal order on the rendered page

Rule: Run the blur test above on the RENDERED page, not on the source, and at more than one width. Screenshot the result at a desktop and a mobile width and apply it to each viewport separately, because a screen that leads correctly at 1440px often collapses at 390px. On a commerce or product page the survivors are specific: the product image, the price, and the primary CTA. A flat design system (hairline borders, one surface color, uniform labels) supplies no hierarchy by default, so allow at most one emphasized surface per screen and reserve it for the decision point.

// ❌ Bad (every element competes at equal weight: same border, same fill, same label treatment)
<Card>…product…</Card>
<Card>…stats…</Card>
<Card>…quote…</Card>
<Card>…buy box…</Card>

// ✅ Good (one emphasized surface per screen, reserved for the decision point)
<section>…unboxed content…</section>
<Card>…supporting group…</Card>
<Card className="border-accent">…buy box: image, price, CTA…</Card>

Why it matters: Reading the markup cannot tell you what the eye lands on. A page can satisfy every written rule and still render flat, and the mobile view can fail while the desktop view passes, so the check has to happen on real screenshots at real widths.

Severity: Serious

Components

Missing button states

Rule: Buttons need disabled, loading, hover, and focus states.

// ❌ Bad (only default state)
<button onClick={handleSubmit}>Submit</button>

// ✅ Good
<button
  onClick={handleSubmit}
  disabled={isLoading}
  className="
    bg-blue-500 hover:bg-blue-600
    disabled:opacity-50 disabled:cursor-not-allowed
    focus-visible:ring-2 focus-visible:ring-offset-2
  "
>
  {isLoading ? 'Loading...' : 'Submit'}
</button>

Why it matters: Clear states provide feedback and prevent user confusion.

Missing form field states

Rule: Form fields need error, disabled, and focus states.

// ❌ Bad (no error state)
<input type="email" />

// ✅ Good
<input
  type="email"
  className={cn(
    "border rounded-lg px-3 py-2",
    "focus:border-blue-500 focus:ring-2",
    error && "border-red-500",
    disabled && "opacity-50 cursor-not-allowed"
  )}
  aria-invalid={!!error}
  aria-describedby={error ? "email-error" : undefined}
/>
{error && <p id="email-error" className="text-red-500 text-sm">{error}</p>}

Why it matters: Users need to understand field state (error, disabled, focused).

Inconsistent borders and shadows

Rule: Use shadows and borders from your design system.

// ❌ Bad (arbitrary shadow)
<div className="shadow-[0_4px_12px_rgba(0,0,0,0.15)]">

// ✅ Good (system shadow)
<div className="shadow-md">

// Define shadow scale:
// shadow-sm: subtle elevation
// shadow-md: moderate elevation
// shadow-lg: high elevation
// shadow-xl: dramatic elevation

Why it matters: Consistent shadows create coherent depth hierarchy.

Double edge on elevated surfaces (border + shadow)

Rule: Never pair a persistent border or persistent edge ring utility with a shadow utility on the same elevated surface (popover, dropdown, select content, context menu, menubar, dialog, tooltip, hover card, command palette). Focus indicators are exempt: focus-visible:ring-* and other focus-state rings provide keyboard feedback and stay. The border paints a hard 1px stroke and the shadow's soft edge starts just outside it, so the eye reads two stacked lines: the surface looks heavy, greyed, and cheap. Pick one edge treatment: either border-only (no shadow), or fold a 1px hairline ring into the shadow stack as its final layer so the edge dissolves into the shadow as one continuous stroke.

// ❌ Bad (double edge: hard border line + soft shadow line)
<PopoverContent className="rounded-lg border bg-popover shadow-md" />

// ✅ Good (border-only elevation, the catalog default)
<PopoverContent className="rounded-lg border bg-popover" />

// ✅ Good (ring folded into the shadow stack as the last layer)
<PopoverContent className="rounded-lg bg-popover shadow-[0_2px_8px_oklch(0_0_0/0.08),0_0_0_1px_var(--border)]" />

// ✅ Good (system token that already carries the hairline layer)
// --shadow-md: 0 4px 6px -1px oklch(0 0 0 / 0.08), 0 0 0 1px oklch(0 0 0 / 0.04);
<PopoverContent className="rounded-lg bg-popover shadow-md" />

Form-field borders (inputs, select triggers) with inset or focus shadows are fine; this rule is about surfaces that float above the page. Toast variants that encode meaning through border color (destructive, success) keep their borders until the variant colors move into the shadow stack too.

Why it matters: One continuous edge reads as a single elevated material. Two stacked edges are a generated-content tell and make every popover look washed out.

Status colors and selected states

Rule: Status surfaces (warnings, success banners, alerts) use the system's semantic status tokens. If the system ships none, use a bordered card with the default surface, never a chart or data-viz tint drafted into status work. Selected or highlighted rows need a clearly contrasting treatment: the primary fill, a visible edge, or a surface at least a full step darker, not the hover token's near-invisible tint. Watch the warm-neutral pileup: a cream page plus a khaki highlight plus a butter banner stacks three near-neutrals and nothing separates.

// ❌ Bad (chart tint as warning, hover tint as selection)
<div className="bg-[color:var(--chart-5)]/20 text-yellow-900">Demo mode</div>
<tr className="bg-accent">You</tr>

// ✅ Good (bordered card; selection with a real edge)
<div className="rounded-xl border border-border bg-card p-4">Demo mode</div>
<tr className="border-l-0 bg-secondary font-semibold ring-1 ring-inset ring-border">You</tr>

Why it matters: Chart tokens exist for data series, not state. Improvised status tints and washed selection states read as unfinished even when every value is technically from the palette.

Severity: Moderate

Utilities passed in from outside a component

Rule: A component's own surface (background, text color, border, radius, shadow) is set by the component through a variant, never by utilities a caller passes in. Two utilities setting the same property are resolved by their order in the generated stylesheet, not by their order in the className attribute, so an incoming class may silently lose to the component's default. Expose a variant prop for the variations the component supports, omit className from the props type so a caller gets a type error rather than silence, and apply the variant after any prop spread as the runtime backstop. A wrapper that legitimately takes layout classes from callers routes them through a conflict-aware merge (cn, built on clsx and tailwind-merge) with the caller's classes placed after the layout defaults and before the surface classes, so the caller wins on position and spacing and never on the surface.

// ❌ Bad (component renders bg-primary; this override is a coin toss)
<Button className="bg-black" />

// ❌ Bad (component concatenates, so both classes ship and one silently wins)
export function Button({ className }) {
  return <button className={`bg-primary ${className}`} />;
}

// ✅ Good (the component owns its surface)
<Button variant="secondary" />

// ✅ Good (className omitted from the type; variant applied after the spread)
type ButtonProps = Omit<ComponentProps<"button">, "className"> & {
  variant?: keyof typeof BUTTON_VARIANTS;
};

export function Button({ variant = "primary", ...props }: ButtonProps) {
  return <button {...props} className={BUTTON_VARIANTS[variant]} />;
}

// ✅ Good (wrapper takes layout classes; surface classes sit after the merge)
export function Panel({ className, ...props }) {
  return <div {...props} className={cn("p-6", className, "rounded-xl bg-card")} />;
}

Why it matters: An override that does nothing is worse than one that is rejected: nothing errors, the build stays green, and the component ships looking wrong. Variants also keep one component looking the same across the app.

Severity: Serious

Redundant utilities in a class list

Rule: Every utility on an element must change the rendered result. Drop utilities that restate a CSS default (flex-row next to flex), the unsized form next to its sized form (border next to border-2), and paired axis utilities that fold into one (pt-4 pb-4 into py-4, and the same for px, mx, my). Write alpha into the colour utility with the slash syntax. A leftover *-opacity-* class is a separate defect: Tailwind v4 removed those utilities, so it is unmigrated v3 code that does nothing.

// ❌ Bad
<div className="pt-4 pb-4 flex flex-row border border-2 border-border border-border/50">

// ✅ Good (identical rendering)
<div className="py-4 flex border-2 border-border/50">

Why it matters: Long class lists are read on every future edit. Classes that do nothing still cost that reading time, and they hide the ones that matter.

Severity: Moderate

@apply in a component stylesheet

Rule: Do not use @apply to move utilities into a named class. It reintroduces naming, and in a global stylesheet the class it produces is reachable from anywhere, so the styling is no longer tied to one component. It grows the CSS bundle too. In Tailwind v4 a stylesheet processed separately from the main entry (CSS module, or a <style> block in Vue, Svelte, or Astro) cannot see theme values, custom utilities, or custom variants without a @reference import first. Read the theme's CSS variables directly instead.

/* ❌ Bad */
.button {
  @apply bg-primary text-primary-foreground rounded-lg;
}

/* ✅ Good */
.button {
  background: var(--color-primary);
  color: var(--color-primary-foreground);
  border-radius: var(--radius-lg);
}

Why it matters: A named class collects users. Once more than one component points at it, changing it regresses surfaces nobody was looking at, which is the failure the utility layer exists to prevent.

Severity: Moderate

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-review-rules({})
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems