Using ESLint to Enforce Copy and Design Quality in AI-Assisted Codebases
AI writes code fast. That's the easy part.
The hard part is that it writes generic code fast. Ask Claude to generate an email template and you get "I hope this message finds you well." Ask it to build a UI component and you get bg-gray-100 instead of your design system's surface-secondary. The output compiles, passes tests, looks reasonable in review. Then you ship it, and your customers get a robot-sounding email with colors that break in dark mode.
We use ESLint the way most teams use it: for code quality. But we also use it for something most teams don't think about: copy quality and design consistency. Especially now that AI writes most of our code.
Here are four real rules from our codebase. All of them are open source.
1. humanize-email
AI has a voice. You've heard it. "Don't hesitate to reach out." "We're thrilled to announce." "This groundbreaking, seamless experience." It sounds professional in the way a hotel lobby smells professional: manufactured and forgettable.
Our humanize-email rule catches this at lint time.
// eslint-plugin-custom/humanize-email.js
const BANNED_PHRASES = [
"i hope this helps",
"don't hesitate to",
"please don't hesitate",
"we're thrilled",
"we are thrilled",
"groundbreaking",
"seamless",
"delve",
"leveraging",
"leverage",
"elevate",
"empower",
"foster",
"tapestry",
"landscape",
"warm regards",
"best regards",
"in today's fast-paced",
"in this digital age",
"it's important to note",
"at the end of the day",
];
The rule does three things:
Bans AI phrases in email templates. Any string literal or template literal containing a banned phrase triggers an error. The phrase list came from Wikipedia's "Signs of AI-generated content" page and from patterns we caught in our own sent emails (after the damage was done).
Forces the right layout component. We have two email layouts: EmailBaseLayout (branded, with logo and footer) and HumanLikeEmailLayout (plain text, no logo, no footer, looks like a real person typed it). For personal emails like onboarding sequences and check-ins, the rule bans EmailBaseLayout and forces HumanLikeEmailLayout. AI always reaches for the branded template. The rule makes that impossible.
Limits em dashes. AI overuses em dashes. Two per file is the limit. More than that and the text starts reading like a term paper written by someone who just discovered punctuation.
Before:
// ❌ lint error: "don't hesitate to" is a banned AI phrase
// ❌ lint error: EmailBaseLayout not allowed in personal emails
// ❌ lint error: 4 em dashes found (max 2)
import { EmailBaseLayout } from './EmailBaseLayout'
export function WelcomeEmail({ name }) {
return (
<EmailBaseLayout>
<p>Hi {name},</p>
<p>
We're thrilled to have you on board! Our groundbreaking platform
— designed from the ground up — delivers a seamless experience
that will — without a doubt — elevate your workflow.
</p>
<p>Don't hesitate to reach out if you need anything.</p>
<p>Warm regards,<br/>The Team</p>
</EmailBaseLayout>
)
}
After:
// ✅ passes lint
import { HumanLikeEmailLayout } from './HumanLikeEmailLayout'
export function WelcomeEmail({ name }) {
return (
<HumanLikeEmailLayout>
<p>Hey {name},</p>
<p>
Welcome. The dashboard is at app.example.com. Poke around,
break things, let me know what's confusing.
</p>
<p>
Reply to this email if you get stuck. I read every one.
</p>
<p>Julian</p>
</HumanLikeEmailLayout>
)
}
The second email sounds like a person wrote it. The first sounds like a committee approved it.
2. prefer-semantic-classes
AI assistants don't know your design system. They know Tailwind's default palette. So when you ask for a card component, you get bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-200 dark:border-gray-700.
That's six classes to express what should be one concept: "this is a surface."
Our prefer-semantic-classes rule bans raw Tailwind color classes and forces semantic tokens:
// Pattern: any Tailwind color utility with a specific shade
const RAW_COLOR_PATTERN =
/\b(bg|text|border|ring|shadow|outline|divide|from|via|to)-(red|blue|green|yellow|gray|slate|zinc|neutral|stone|orange|amber|lime|emerald|teal|cyan|sky|indigo|violet|purple|fuchsia|pink|rose|white|black)-\d{2,3}\b/;
// Allowed exceptions
const EXCEPTIONS = [
"text-white", // sometimes you actually need white text
"bg-black", // same for black backgrounds
];
Before:
// ❌ lint error: Use semantic class "surface-primary" instead of "bg-white"
// ❌ lint error: Use semantic class "text-primary" instead of "text-gray-900"
<div className="bg-white dark:bg-gray-800 text-gray-900
dark:text-gray-100 border border-gray-200 dark:border-gray-700
rounded-lg p-4">
<h3 className="text-gray-900 font-semibold">Card Title</h3>
<p className="text-gray-500">Card description</p>
</div>
After:
// ✅ passes lint
<div className="surface-primary text-primary border-default
rounded-lg p-4">
<h3 className="text-primary font-semibold">Card Title</h3>
<p className="text-secondary">Card description</p>
</div>
Three things happen when you enforce this:
Dark mode works automatically. surface-primary maps to white in light mode and gray-900 in dark mode. You define it once in your CSS. Every component that uses the semantic class gets dark mode for free.
Theming becomes possible. Want to change your gray palette from zinc to slate? One CSS variable change. Not a find-and-replace across 400 files.
AI-generated code stays on-system. The rule doesn't care who wrote the code. Human or AI, raw colors fail the build. The AI learns from the error message and uses semantic classes in the next generation.
3. typographic-quotes
This one is small but it drives me crazy.
AI models output curly quotes ("like this"). Your keyboard types straight quotes ("like this"). When both end up in the same UI, you get inconsistent typography that looks sloppy.
Our typographic-quotes rule auto-fixes straight quotes to curly quotes in JSX text content:
// Auto-fix: straight quotes → curly quotes in JSX
// "hello" → \u201Chello\u201D
// 'world' → \u2018world\u2019
// it's → it\u2019s
This rule is auto-fixable. Run eslint --fix and every quote in your JSX becomes typographically correct. Zero effort, consistent output.
The fix only applies to JSX text nodes, not to JavaScript strings or attribute values. className="foo" stays as-is. <p>She said "hello"</p> becomes <p>She said \u201Chello\u201D</p>.
4. no-hover-translate
AI loves adding hover:-translate-y-1 to cards and buttons. It looks slick in a demo. The problem shows up when a real user approaches the element from below: the card jumps away from their cursor. They chase it. It runs. You've built a UI element that plays keep-away.
This happens because translate moves the element's hit area. When the card lifts up on hover and your cursor was near the bottom edge, the element moves out from under you, the hover state drops, the card falls back, your cursor re-enters, it lifts again. A jittery loop.
// Pattern: hover:translate-y-* or group-hover:translate-y-*
const hoverTranslatePattern = /\b(group-)?hover:-?translate-[xy]-/g
Before:
// ❌ lint error: hover translate creates a chase effect
<div className="card hover:-translate-y-1 transition-transform">
<h3>Pricing Plan</h3>
</div>
After:
// ✅ passes lint — shadow gives hover feedback without moving the hit area
<div className="card hover:shadow-lg transition-shadow">
<h3>Pricing Plan</h3>
</div>
hover:shadow-* gives the same "lift" feeling without moving the element. hover:scale-105 works too if you want the element to grow slightly. Both keep the hit area stable.
Where the phrase list came from
Wikipedia maintains a page called "Signs of AI-generated content" that catalogues patterns in AI writing. It's thorough. Inflated symbolism ("tapestry of innovation"), promotional language ("groundbreaking"), superficial analyses using -ing words ("navigating the landscape"), vague attributions ("experts say"), em dash overuse, the rule of three, specific vocabulary words (delve, foster, leverage, elevate), and negative parallelisms ("not just X, but Y").
We started with that page, then added phrases we caught in our own outbound emails. Every time we spotted a phrase that made us wince, we added it to the ban list. The list grows over time. That's the point: your standards compound.
Your lint rules are your taste, encoded
Here's the idea that ties this together.
Lint rules are usually thought of as objective. "No unused variables" isn't a matter of taste. But the rules I've described here are entirely subjective. What counts as an "AI phrase"? How many em dashes is too many? Should you use semantic classes or raw Tailwind? These are opinions.
And that's fine. Your codebase should have opinions. The alternative is that your codebase has no opinions, and the AI fills the vacuum with its own defaults: corporate language, raw utility classes, inconsistent typography.
Any subjective standard you care about can become an automated check. If you keep correcting the same thing in code review, write a rule. If you keep rewriting AI-generated copy, write a rule. If you keep swapping out components the AI chose wrong, write a rule.
The best time to catch AI-generated slop is before it ships. Not when a customer reads "We're thrilled to announce this groundbreaking experience" in their inbox. Not when your designer notices the card component uses zinc-200 instead of your design token. Not when your dark mode is broken because someone hardcoded bg-white.
Lint rules are fast. They run on every commit. And they teach the AI what your standards are, because the error messages become context in the next generation.
Your taste, automated. Running on every PR. That's the whole idea.