When LLMs Overcorrect Your Design System: Guardrails for LLM-Ready React Components

The Talk That Stuck With Me
Last week I sat in a packed room at the Zurich Language AI Meetup and listened to Heejin Do, a researcher at ETH Zurich, answer a question that sounds almost innocent until you sit with it for a while: why can't large language models just proofread? Her talk was titled Why LLMs Can't Just Proofread – and How to Fix It, and it landed the way the best technical talks land — not because the content was flashy, but because it rearranged something in my head that I had been half-thinking for months without knowing how to articulate it.
Her argument, stripped to the bone: an LLM on its own cannot reliably perform post-correction on noisy text. It will not quietly improve the passage in front of it. It will overcorrect. It will rewrite a perfectly good sentence because its priors pull it toward what a sentence "usually" looks like, not toward what this particular sentence needs. It will introduce errors into places that were already right. It will do all this confidently, with the same tone of voice it uses when it is actually helping. Without structured grounding — without something anchoring the model to the ground truth of what the text is — the model drifts.
I build React component libraries for a living. I watched her describe the failure mode in OCR and ASR post-correction and I thought, that is exactly what coding agents do to my design system. The parallel is not a metaphor. It is the same mechanism, playing out on a different corpus. And once I saw it, I could not unsee it — so I went home and wrote this.
What Overcorrection Looks Like in a Design System
Before we talk about why this happens, it is worth grounding the discussion in ten concrete failure modes I have watched coding agents inflict on otherwise healthy component libraries. None of these are hypothetical. All of them happened in production repositories in the last six months.
One: the agent reinvents the wheel instead of reaching for what the library already ships. Asked to add a user-management page with a sortable, searchable table and row-click-to-edit, it installs @tanstack/react-table, hand-wires useReactTable, builds a column-definition object, draws sort-direction icons from scratch, and four hundred lines later lands a working-but-bespoke table — despite the fact that the library already exports <DataTable columns={…} rows={…} onRowClick={…} /> that wraps exactly this surface with the team's styling, accessibility, loading skeletons, and empty states baked in. The agent never imported DataTable. It never searched for it. The TanStack-Table priors from its training data simply dominated, and the in-house primitive stayed invisible. Reinventing the wheel is the default mode whenever grounding is weak — and it is by far the most expensive failure mode, because the duplicated surface has to be maintained forever alongside the one that already existed.
Two: the agent opens a CVA file and "fixes" what was not broken. <Button> deliberately caps its size variant at the closed union 'sm' | 'md' | 'lg' — a scale the design lead arrived at after three rounds of design review. For a hero CTA, the agent could simply layer a utility on top — className="w-full px-12 text-lg" — and ship. Instead it opens Button.cva.ts and quietly appends xl: 'h-16 px-8 text-xl' to the size variant map. TypeScript approves: adding a value to a union is a perfectly valid mutation. The build stays green. The code review reads clean, because the change lives on a single line in a single file. What silently unravels is the deliberate decision to keep the size scale closed, and with it the contract that designers and engineers agreed to uphold. This is the genuinely insidious class of CVA failure — not the fabricated variant TypeScript would have rejected, but the rewrite of the ground truth itself. Priors — "components usually come in four sizes" — beat grounding once again.
Three: design-token hallucinations. The design system is built on semantic colour tokens — bg-primary-500, bg-secondary-600, text-success-700, border-danger-400 — mapped onto the customer's brand palette in tailwind.config.ts. Asked to "use the primary yellow" for a highlight badge, the agent should reach for bg-primary-500. Instead it reaches for bg-amber-500, or bg-yellow-400, or text-teal-600 — whatever utility class the open internet's Tailwind tutorials most recently drilled into its weights. If the customer's brand yellow happens to land near amber, nobody notices in review; the shade is subtly off, and the drift accumulates across hundreds of components until the brand look no longer coheres. If amber is not in the palette at all, the JIT compiler emits nothing, and the element renders unstyled. Either way, a default-palette prior has snuck into a system that was explicitly tokenised to prevent exactly this kind of drift. The design-token discipline was the grounding. The agent did not read it.
Four: the agent crosses the /libs↔/apps boundary and produces what I can only call spaghetti. The rule in the monorepo is deliberate and load-bearing: atomic-design primitives in /libs use co-located .cva.ts files because complex components benefit from variant-driven type safety, BEM-stable class names, and a single source of truth for styling. Application components in /apps are thin business-logic wrappers around those primitives and use inline Tailwind directly, because navigating five file paths to nudge a margin is hostile to velocity — you want the full power of Tailwind where the styling is simple enough that CVA would be ceremony. It is genuinely the best of both worlds: type-safe primitives where complexity earns it, agile applications where it does not. The agent does not see the boundary. It pulls from whichever pattern dominated its last few files of context: writes inline Tailwind inside /libs and orphans the CVA file; scaffolds a full .cva.ts around a throwaway /apps wrapper. Both violations look plausible in a diff. Both degrade the codebase in opposite directions. Spelling the convention out in every prompt is not a durable answer — it is a tax the codebase pays forever.
Five: the agent over-engineers where simplicity would do. In /libs every component carries a types.ts that defines consistent shared props like ComponentClassNames and ComponentLabels, so the whole library presents a uniform surface: a classNames prop to extend styling at any slot, a labels prop to translate every visible string. For complex primitives, that is a load-bearing steel framework that pays for itself many times over. In /apps, the same framework is overkill. Application components are mostly wrappers around those primitives, composing business logic — they rarely need to solve styling, animation, or layout from scratch. An inline Props interface in the component file is entirely sufficient. The agent, ever over-motivated, imports the steel framework from /libs into throwaway app wrappers: a full types.ts, the ClassNames and Labels ceremony, the whole load-bearing apparatus — for what should have been a non-load-bearing drywall partition. This is the bazooka on sparrows. It is overcorrection in its purest form: maintenance cost up, edit friction up, delivered value flat at zero.
Six: the agent cannot tell a batteries-included component from a raw one, and reaches for batteries every single time. The library ships the split deliberately, and it ships it consistently across the whole input surface: every form primitive exists in two flavours, a raw *Input and a batteries-included *Field. TextInput alongside TextField. DateInput alongside DateField. NumberInput alongside NumberField. SelectInput alongside SelectField. The *Input is a single atomic element that does exactly one thing — render the control, emit an onChange, that is it. The *Field wires the same control into react-hook-form for state management, plugs it into a Zod schema for validation, and handles label, hint text, error state, and layout for you. The split is not cosmetic. Batteries is the literal bundle cost: Field ships react-hook-form and zod in the client bundle, Input ships neither.
They answer different questions. For a settings form with fifteen validated fields, TextField saves you thirty lines of register/control/errors boilerplate per field and gives you a single schema that doubles as the server-side contract. For a search box in a toolbar, TextInput is the whole story — one element, controlled state in a useState, no ceremony, no schema, no error slot, no form library in the bundle. Ask the agent to "build a clean solution for a toolbar search" and — reliably, without fail — you get TextField, a Zod schema, react-hook-form, and a container with onSubmit, all for a single search input that cannot be invalid and will never be submitted as a form. And every gram of that machinery ships to the client regardless. Weight you paid for, validation you never needed, a form library held in memory for a field that has no validation rules in the first place. The overcorrection here is not a wrong answer. It is a correct answer to a question you did not ask — which is the pure form of overengineering: zero upside, measurable downside, every single time. I honestly do not know why we keep typing "clean solution" into our prompts. It is not a steering signal. It is an accelerant. "Clean" to a coding agent means "the version with more machinery"; you might as well order a nuclear-powered can opener and be surprised when it arrives gift-wrapped.
Seven: the agent overcorrects proven internal conventions into the shape of the getting-started guide. In this codebase, table column definitions live in a standalone columns.tsx file next to the table component, and form validation lives in a standalone schema.ts. Columns become grep-able, schemas become shareable between the form and the server action that consumes the form, diffs stay small, reviews stay fast — a convention that has earned its keep many times over in large codebases. The docs of TanStack Table and React Hook Form show it inline. Every getting-started guide shows it inline. Every Stack Overflow answer shows it inline. Every agent I have worked with, confronted with one of our separated files, "tidies" it back to inline — sincerely convinced it is correcting a mistake. It is not correcting anything. It is overcorrecting a proven internal convention toward the shape of its training data. Correct is not the same as conforming to the docs, and internal conventions that earned their place on merit should not be erased every time an agent feels the urge to take the path of least resistance back to the starter template it was trained on.
Eight: changesets. I like keeping commits in my own hands — it is one of the few parts of the loop I still insist on owning. The agent writes changesets for me: conventional-commit style in the header, prose detail underneath, one file per logical change. I batch them into commits at a pace I control, which gives me a genuine human-in-the-loop checkpoint — every changeset is a review gate before anything actually lands on main. It is a workflow I have come to really like. Left to its own devices, though, the agent opens the most recent existing changeset at the next touchpoint and "improves" it in place, rather than creating a new file. Paste "changesets are immutable, create a new file" into the prompt and it complies — this time. The annoyance is not that it cannot be steered. The annoyance is that you have to re-steer it every single time: a real codebase produces many changesets per day, every new thread starts fresh with no memory of what you agreed yesterday, and the guidance has to be restated on loop. Forget to say it once and the agent is back to editing history — silently, confidently, in the file you least wanted touched. Why is editing the default? Because the agent does not think in touchpoints or in human-in-the-loop gates. It was trained on a more forgiving assumption: any changeset file not yet consumed by changeset version is still a mutable work-in-progress note, and rewriting one is harmless housekeeping. In my setup that assumption is dead wrong. changeset version runs in the CI/CD pipeline, not on my laptop — which is the right place for it: deterministic, auditable, one single path to production across every customer and personal project I work on. The consequence is sharp. By the time you git pull --rebase origin main, a changeset file that looked innocently mutable on your machine has already been consumed upstream — deleted from the repository and collapsed into CHANGELOG.md. If you skipped the rebase and let the agent "improve" the file anyway, you are now modifying something that no longer exists on main. Merge conflict. An avoidable mess, created entirely by the agent's urge to rewrite an artefact the pipeline had already locked down. Urrrghhh.
Nine: the review agent. Review agents measure success by the number of improvements they surface, and that success metric quietly corrupts the review. When the diff is tight and the change is correct, there is simply nothing actionable to report — and the agent, faced with an empty hand, widens its scope. Suddenly it is reading files the PR never touched. Suddenly it has found a style nit in a component unrelated to the current story. Suddenly it is recommending a refactor three directories over. Your two-file PR becomes a seven-file PR. Your two-reviewer PR now needs an extra reviewer from a team you do not normally coordinate with. That reviewer is on leave. You go back to the agent, tell it to revert the out-of-scope changes — and it over-reverts, accidentally undoing something you did want. You are now twenty minutes of micro-management deep in a review that, handled yourself, would have taken ninety seconds. At some point you start wondering whether the agent is actually saving you time, or whether you are simply paying — in minutes and in tokens — for the privilege of being supervised by software.
Ten: visual changes, freestyle. "Add gap between these components. Bump the font size on the main container. Make the background a notch lighter in dark mode." Vague by design — that is how humans talk about UI. Now the question: will the agent correctly infer which element "main container" refers to? Will it reach for the size or variant prop — the ones that carry your design tokens — or will it grab the all-powerful className and drop in an arbitrary pixel value just to close the request out? In order to please you, it reaches for whichever intervention completes the fastest. It often works — at a glance. The spacing looks right, the font looks bigger, the dark mode looks lighter. You are tired, it looks fine, you commit. What you have not noticed is that you have just set a big pot of spaghetti on the stove, and like any good Italian household, you are going to be feeding it to your family — that is, your team — for the next six months, one regrettable plate at a time.
If you have worked with coding agents on a real codebase, you have seen all ten of these. Probably more than once. The question is not whether overcorrection happens. The question is what it costs, and what you can do at the library level to make it stop.
What Overcorrection Actually Costs
Step back from the ten failure modes for a moment and look at them as one pattern. Every single one is Heejin's mechanism replaying in a new corpus: the model's priors — what a React component "usually" looks like, what a changeset "usually" is, what a toolbar search "usually" needs — are stronger than the grounding your codebase offers. When grounding is weak, priors win. The model does not see your library; it sees a distribution, and it pulls the output toward the centre of that distribution. That is the root cause. The cost of the root cause is what actually matters, and the cost is paid in two currencies.
The first currency is tokens — the one you can measure. Overcorrection is token-expensive by construction. Every duplicated DataTable, every hallucinated Tailwind class the agent then has to discover was wrong, every out-of-scope review finding that triggers a three-round revert dance — all of it runs against the same meter. Worse, it structurally tilts you toward bigger, more expensive models. Without guardrails, the prompt you are quietly handing to Opus is a prompt that Sonnet — with guardrails in place — could handle perfectly well, often better, for a fraction of the token cost. You are not buying more intelligence. You are buying the slack your codebase failed to provide. An Opus-sized agent running against an ungrounded library is, in economic terms, you paying a premium rate for the model to guess at things your repository could simply have told it.
The second currency is time — and it compounds in a way tokens do not. The more work you hand over without checking it, the faster your own confidence erodes. Agents should be an amplifier of judgement, not a replacement for it. When you find yourself committing code you have not actually read, and defending decisions you did not actually make, what can I say — you must be a spaghetti lover. The short-term productivity gain is real. The long-term craft cost is also real. Every developer I know who has been burned by this has the same story: a stretch of confident agent-driven shipping, followed by the quiet realisation that they no longer remember how the system they shipped actually works.
But it does not have to come to that. The mechanism is identifiable, which means the remedy is identifiable. You do not make the agent smarter — you never could. You make the grounding stronger, until the priors have nowhere to slip in. In a React component library, seven guardrails do most of the work — one of them enforces the others, and that is the one to start with.
Guardrail 1 — ESLint as the Enforcement Layer
Every other guardrail in this piece shapes the source surface the agent reads. ESLint shapes what the agent's output has to pass. Different category, different layer, and in my experience the most important of the seven — which is why it opens the list. When the other six fail, ESLint is what catches the overcorrection before it reaches main.
It helps to understand where ESLint sits in my day. Before the agent writes a single line, I spend serious time in plan mode: what will you do, how, have you understood the actual problem, does the approach you are proposing sound coherent? Only once that briefing holds up do I give the go. And here is the part I want to be honest about — a little overcorrection is entirely fine. I am not going to interrupt the agent twenty times for every small deviation. My primary question is whether the actual problem can be solved. If yes, the linting pass later handles the rest. In every plan I write, run lint and create changeset are the last two items on the list. That is not an afterthought. It is the explicit checkpoint at which the hallucinated and overcorrected parts get unwound.
While that linting pass runs — and on a large codebase, with a radically defined rule set, it is not instantaneous — I am not watching it. I open a second terminal with a problem that has no strong dependency on the first and pick it up in parallel, if I am still sharp. If the plan-mode briefing was mentally draining, I do not force another one. I walk to the piano, a round of Chopin to clear the head. Or laundry, or cooking, or a short nap. The linter does the enforcement I would otherwise have done by hand, at the cost of zero attention of mine.
What makes this workflow work is not ESLint itself. It is the rules on top of ESLint. I have been fairly radical in defining them — a custom i18n plugin, a custom money plugin, a custom strict plugin, each enforcing conventions that the agent would otherwise have to re-learn with every prompt. The pattern in each rule is the same, and it is the part that actually does the work: the rule does not just forbid the anti-pattern, it carries a "here is how to do this instead" message with explicit imports and a worked example. Three rules from the stack, to make the shape concrete.
Rule one — i18n/error-message-i18n. Agents love writing throw new Error('User not found'). The prior from every tutorial in the training distribution is hardcoded English. The rule catches it and the message redirects:
1// Rule violation:2throw new Error('User not found')3
4// Rule message:5// Error messages must use the t() function for internationalization.6// Use: new Error(t('errorKey')) instead of hardcoded strings.7// Translation keys for Error messages must end with 'Error' suffix.8
9// Corrected:10throw new Error(t('userNotFoundError'))Rule two — money/no-math-rounding. Agents instinctively reach for Math.round(amount * 0.15) whenever taxes or discounts come up — Intl.NumberFormat tutorials are in the training data, Martin Fowler's Money Pattern is not. The rule bans the floating-point arithmetic, and the message names the replacement API explicitly:
1// Rule violation:2const tax = Math.round(amount * 0.15)3
4// Rule message:5// Math.round() is banned to prevent monetary calculation errors.6// For monetary values, use Money class:7// import { Money, RoundingMode } from '@prasath/money'8// money.multiply(factor, RoundingMode.HALF_UP)9
10// Corrected:11import { Money, RoundingMode } from '@prasath/money'12const tax = Money.fromCents(amount, 'EUR').multiply(0.15, RoundingMode.HALF_UP).amountRule three — strict/no-empty-string-fallback. Agents paper over missing required values with ?? '' because it makes the TypeScript error go away. The rule refuses the pattern, and the message forces a decision — required or optional — rather than letting the ambiguity survive:
1// Rule violation:2const entityAccessor = params?.entityAccessor ?? ''3
4// Rule message:5// Do not use empty string as fallback. If the value is required, throw an error.6// If optional, handle the undefined case explicitly.7
8// Corrected (required path):9if (!params?.entityAccessor) {10 throw new Error(t('entityAccessorRequiredError'))11}12const entityAccessor = params.entityAccessorRead those three messages back-to-back and the shape of the guardrail becomes visible. Each one does three things in one breath: it forbids the pattern, it explains the category of danger (i18n, monetary precision, masking of required values), and it names the correct replacement with exact imports. It is not "do not do this." It is "here is how to do this instead." Agents read these messages on the first violation, rewrite the code, and the next iteration is clean. After three cycles of "how to do this instead" on a fresh agent, the banned idiom stops showing up in the output. The instruction surface is the rule file itself — the agent does not need to have read my CLAUDE.md to get it right, because ESLint delivers the instruction at exactly the moment it becomes actionable.
One concession, the one that is starting to press me. On larger client projects, a radically defined ESLint config starts to eat into iteration time. Every additional rule you add — and once you see what these messages are worth, you keep adding — is another millisecond per file, which at repository scale becomes minutes per run. The answer I am migrating to in April is Oxlint — the Rust-based linter from VoidZero, Evan You's tooling group. Oxlint 1.0 shipped in 2025 with 520+ supported ESLint rules; by early 2026 it runs 50–100× faster than ESLint and 8–12× faster than typescript-eslint on type-aware rules, and in JS-plugin alpha it accepts most existing ESLint plugins without modification. Airbnb reports linting 126,000 files in 7 seconds on CI. Shopify runs it in the admin console. With that much headroom, the calculus on "how radical should my rule set be" changes from "what can my CI afford" to "what does my codebase actually want." I will write about the migration in a separate post once it lands.
Guardrail 2 — BEM Identifiers as Round-Trip Identity
Every CVA file in the library starts with a BEM token. 'Dropzone__wrapper' for the outer wrapper, 'Dropzone__title' for the title element, 'Dropzone__description' for the description below it. These tokens are pure, static strings — no dynamic composition, no cx() calls, no template literals, no runtime interpolation. They land on the DOM as stable class names that grep finds, that Storybook exposes, and that an agent inspecting rendered HTML can trace back to a single source file.
Here is the shape every *.cva.ts in the library takes. The first string in every base array is the BEM identifier; the rest are Tailwind classes:
1// Dropzone.cva.ts2import { cva } from '@prasath/utils'3
4export const wrapperClassName = cva({5 base: ['Dropzone__wrapper', 'flex flex-col'],6})7
8export const innerWrapperClassName = cva({9 base: [10 'Dropzone__inner-wrapper',11 'relative flex h-full rounded-lg border-2 border-dashed',12 'transition-all duration-200 cursor-pointer',13 ],14 variants: {15 variant: { primary: '...', light: '...', danger: '...' },16 isDragOver: { true: ['scale-[1.02]', 'shadow-lg'] },17 disabled: { true: ['cursor-not-allowed', 'opacity-50'] },18 },19})20
21export const titleClassName = cva({22 base: ['Dropzone__title', 'font-medium px-3 text-lg'],23 variants: {24 variant: { primary: 'text-primary-600', light: 'text-gray-700' },25 },26})27
28export const descriptionClassName = cva({29 base: ['Dropzone__description', 'text-sm max-w-sm px-3 text-balance'],30})Now look at what the browser actually renders. Every class in the output tree is a literal string somewhere in the source. None of them are composed at runtime. None of them contain substrings that only appear when a condition is true:
1<div class="Dropzone__wrapper flex flex-col">2 <div class="Dropzone__inner-wrapper ... border-gray-300 bg-gray-50/50">3 <div class="Dropzone__content ...">4 <h3 class="Dropzone__title font-medium px-3 text-lg text-gray-700">5 Drop files here6 </h3>7 <p class="Dropzone__description text-sm max-w-sm px-3 text-balance">8 Supported formats: PDF, PNG, JPG9 </p>10 </div>11 </div>12</div>This is the round-trip. The agent sees Dropzone__title in the inspector. It runs one command — grep -r "Dropzone__title" libs/ — and gets exactly one file back: libs/molecules/src/components/Dropzone/Dropzone.cva.ts. The file next to it, Dropzone.tsx, applies titleClassName() to exactly one JSX element. That is the entire lookup. No reasoning. No chain-of-thought. No time or tokens burned on "let me think about where this class might come from." One short algorithmic grep, zero ambiguity, the agent is standing at the source file ready to edit. Every cycle the agent would otherwise have spent composing a tentative theory about the codebase is reclaimed and redirected at the actual task.
One more property worth calling out, because it pre-empts the first objection people raise: the BEM identifiers are stripped from the production build. They exist in development, in Storybook, and in the rendered DOM during local iteration — exactly where the agent round-trip happens. They do not ship to end users. No class-name bloat, no leakage of internal component naming into production HTML, no concern about CSS bundle size. The round-trip is a dev-time affordance with zero runtime cost.
Contrast that with the failure mode. In a codebase where class names get assembled at render time — cx('card', isActive && 'card-active', size === 'lg' && 'card-lg') — the rendered class card-active might be defined in a Tailwind plugin, overridden by a CSS variable in a parent, composed conditionally across three components, and mean different things depending on which context it appears in. The agent inspecting the DOM cannot trace the class back to one source. It has to reason about the composition, and reasoning is precisely where training-data priors overrun the codebase's actual structure. The generic React-and-Tailwind patterns from the pre-training corpus win, and the specific answer — which lives in this codebase's class-assembly function — gets rewritten.
The whole point of the BEM identifier is to refuse that invitation. When the identity is encoded into the class string itself, the agent never gets to the "reasoning" step. It reads the class, greps for the literal, opens the one file that matches, and the grounding is delivered in a single operation that no prior can displace.
Guardrail 3 — Scaffold Generators as Agent Guardrails
The third guardrail moves the agent out of the business of writing boilerplate and into the business of calling tools. Every new component, modal, form field, table, column, page, action, email — every recurring scaffold shape in the monorepo — is created through npx nx g. The generator writes the files. It sets up the CVA boilerplate. It wires the barrel exports. It scaffolds the Storybook story. It registers the new component in its parent. The agent does not free-write any of this. It calls the generator, and the generator returns a deterministic file tree.
Here is what that looks like in practice. Given a request like "add an edit action to the user table that opens a modal", the agent does not start typing a new file. It runs:
1npx nx g modal EditUserModal UserManager -p app --triggerName=actions-column --triggerIcon=EditOutlinedOne command. Six things happen in a single invocation — deterministically, in the right order, with the right wiring:
- The modal component is scaffolded under the parent's
components/directory with the CVA boilerplate already in place. - The generator walks the parent's children, discovers the
UserTable, and locates theactionscolumn. - An
editUseraction is inserted into that column'smeta.actionsarray — with theEditOutlinedicon, the translation key, and ahandleClickcallback that delegates up the tree. - The table's
getColumns()signature is updated to accept the new callback prop. - The parent component (
UserManager) receives the required state —isEditUserModalOpen,selectedRow— plus the wiring that sets the row before opening the modal. - Every barrel file along the path —
components/index.ts, the parent'sindex.ts— is updated with the new exports.
The resulting file tree is not a guess. It is the contract of the generator:
1UserManager/2├── UserManager.tsx (modified — state + wiring)3├── components/4│ ├── index.ts (updated)5│ ├── UserTable/6│ │ ├── columns.tsx (modified — action inserted)7│ │ └── UserTable.tsx (modified — prop added)8│ └── EditUserModal/9│ ├── EditUserModal.tsx10│ └── index.ts11└── index.ts (updated)This is the whole point. Free-writing is the exact surface on which training-data priors dominate. Calling a CLI is the exact surface on which they cannot. The agent cannot fabricate a file layout because it is not writing the file layout — it is invoking a tool whose output is deterministic, typed, and owned by the library maintainers. The creative surface collapses to the arguments of the generator. The agent cannot forget the barrel export. It cannot put the modal in the wrong directory. It cannot invent a new directory convention. The only decisions it gets to make are the ones the generator's flag surface lets it make.
And the generators are idempotent — a deliberate design choice that matters more than it sounds. If the agent runs the same command twice, nothing bad happens. If EditUserModal already exists and is connected to the actions column with custom business logic, re-running the generator replaces only the placeholder // TODO stubs; any real logic the agent has written inside the modal or inside the action's handleClick is preserved. That means the agent can call the generator confidently on any iteration, including mid-conversation, without corrupting prior work. The CLI is safe to retry — which is exactly what you want when the alternative is the agent free-writing a second, conflicting version of the same modal.
The pattern generalises. npx nx g field for a form field. npx nx g column for a table column. npx nx g action for a server action. npx nx g page for a Next.js route. Every recurring structural pattern in the codebase has a generator, and every generator has a CLAUDE.md entry that tells the agent to prefer the generator over free-writing. The instruction layer does the pointing; the generator does the creating; the agent does the invoking. That is a division of labour in which priors never get a seat.
Guardrail 4 — Storybook as Ground Truth
Every component in the library has a stories/ directory. One story per file, numbered so that the default variant is always [0] default. Each story file imports the component exactly the way an application would. Story-specific demo components — the fake cards, the example content — live in stories/components/, never inline. The result is that Storybook is not documentation about the component. Storybook is the ground truth for how the component is used.
A real story file looks like this:
1// libs/molecules/src/components/Dropzone/stories/Dropzone.maxFileSize.stories.tsx2import { Banner, Breadcrumbs, Divider } from '@prasath/molecules'3import { FileDownloadFilled } from '@prasath/icons'4import type { Meta, StoryObj } from '@storybook/react'5import { Dropzone } from '@/components'6
7const meta: Meta<typeof Dropzone> = {8 title: 'molecules/Dropzone',9 component: Dropzone,10}11export default meta12
13type Story = StoryObj<typeof Dropzone>14
15const MAX_FILE_SIZE = 1 * 1024 * 1024 // 1 MB16
17export const MaxFileSize: Story = {18 name: '[4] maxFileSize',19 render: () => (20 <section>21 <Divider>22 <Breadcrumbs>23 <Breadcrumbs.Item>Dropzone</Breadcrumbs.Item>24 <Breadcrumbs.Item isActive>maxFileSize</Breadcrumbs.Item>25 </Breadcrumbs>26 </Divider>27 <div>28 <Banner variant="light" title="maxFileSize" className="mb-8">29 Reject files larger than 1 MB. The Dropzone surfaces the error30 message next to the offending file.31 </Banner>32 <MaxFileSizeDemo />33 </div>34 </section>35 ),36}Three facts an agent extracts in a single pass. First, the import is app-facing — import { Dropzone } from '@/components', the exact line the agent will paste into a product page. Second, the render body uses real React — useState, real handlers, real files — not mocked render props. Third, the filename is the API — Dropzone.maxFileSize.stories.tsx is the one story file that demonstrates the maxFileSize prop, there is only one, and it lives at exactly that path. The agent does not have to guess where the canonical example of maxFileSize lives. It is a path.
The payoff compounds on every agent run. On the request "add a file upload with a 2 MB limit that rejects PDFs over the cap", the agent lists the stories/ directory, finds Dropzone.maxFileSize.stories.tsx, reads it, and copy-pastes the working example with the cap flipped. No reasoning about the API shape. No hallucinated prop names. No fabricated error-message structure. The canonical example is already in the codebase, already type-checked, already rendering in Storybook — the agent just has to find it and adapt it.
The one story per file rule is what makes the find step cheap. If stories were grouped — one file with ten export consts, one per prop — the agent would have to scan all ten to know which one covers the prop in question. With the filename carrying the prop name, the agent's work collapses to a directory listing. The filename promises the content. Grep and glob do the rest.
The stories/components/ subdirectory is the same idea one level down. When a story needs a realistic payload — a file list, a data-table row, a user card — that payload lives in its own typed component, not inline in the story. stories/components/BasicExample/BasicExample.tsx is a real React component with a real Props interface. The agent treats it as a second copy-target: the outer story file shows how to mount the component with props, the inner demo file shows how to wire realistic state around it. Two clean copy surfaces instead of one tangled 200-line render function.
Why it works as grounding: prose documentation drifts. README examples go stale the moment a prop name changes; MDX docs silently desync from the component; JSDoc comments capture intent, not behaviour. Stories cannot drift. They compile with the rest of the library, they are typed against the component they demonstrate, and they render on every Storybook build. If the component's prop surface changes, the story breaks the build — not the docs site three weeks later. That is what "ground truth" means: when the agent reads Dropzone.maxFileSize.stories.tsx, it is reading a file the compiler has already validated. The example is guaranteed to work. Storybook is not a documentation artefact; it is an executable archive of every supported usage, indexed by prop name, with zero tolerance for drift.
Guardrail 5 — CVA Wrapper + ExtractVariantProps
The fifth guardrail closes the loop at the type level. CVA variants are declared with a wrapper helper — cva.wrapper — that returns both the className function and a variantProps object. ExtractVariantProps<typeof variantProps> then projects those variants into a TypeScript union type. The union is closed. There are no open strings. size is 'sm' | 'md' | 'lg', full stop.
The idiom has three moving parts: the wrapper call, the destructure, and the type projection. Trimmed from Button.cva.ts:
1// libs/atoms/src/components/Button/Button.cva.ts2import type { ExtractVariantProps } from '@prasath/types'3import { cva } from '@prasath/utils'4
5export const [wrapperClassName, variantProps] = cva.wrapper({6 base: ['Button__wrapper', 'flex items-center rounded-theme'],7 variants: {8 variant: {9 primary: 'bg-primary-500 text-on-primary-500',10 secondary: 'bg-secondary-500 text-on-secondary-500',11 light: 'bg-gray-100 text-primary-on-gray-100',12 dark: 'bg-gray-600 text-on-gray-600',13 success: 'bg-success-600 text-on-success-600',14 warning: 'bg-warning-600 text-on-warning-600',15 danger: 'bg-danger-600 text-on-danger-600',16 },17 size: {18 xs: 'h-7 px-3 text-xs',19 sm: 'h-8 px-5 text-sm',20 md: 'h-10 px-5 text-sm',21 lg: 'h-12 px-5 text-sm',22 xl: 'h-14 px-7 text-md',23 '2xl': 'h-16 px-7 text-md',24 '3xl': 'h-20 px-9 text-lg',25 },26 outlined: { true: 'border-2 bg-transparent', false: '' },27 disabled: { true: 'cursor-not-allowed opacity-50', false: '' },28 },29 defaultVariants: { variant: 'light', size: 'md', outlined: false },30})31
32export type VariantProps = ExtractVariantProps<typeof variantProps>33export type Size = NonNullable<VariantProps['size']>34export type Variant = NonNullable<VariantProps['variant']>And the consumer — Button.tsx — extends Omit<VariantProps, 'as'> directly onto its Props interface, so every variant key in the CVA file becomes a Button prop with an identical closed type:
1// libs/atoms/src/components/Button/Button.tsx2import { type VariantProps } from './Button.cva'3
4export interface Props<T extends React.ElementType = 'button'>5 extends React.ComponentPropsWithoutRef<T>,6 Omit<VariantProps, 'as'> {7 // ...component-specific props8}Now trace what happens when an agent is asked to add an extra-large danger button. The agent opens Button.tsx, sees that size and variant come from VariantProps, follows the type to Button.cva.ts, reads the closed union 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' and the closed union 'primary' | 'secondary' | 'light' | 'dark' | 'success' | 'warning' | 'danger', and writes <Button size="3xl" variant="danger">. It cannot write size="extra-large". It cannot write variant="destructive". Both are open strings the compiler rejects before the file saves.
That last sentence is the entire point of the guardrail. In vanilla CVA — without the cva.wrapper indirection — the variants live inside a call, not a destructurable return, which makes them accessible only to CVA's own runtime. The cva.wrapper shape hoists the variant map into a named variantProps const, and ExtractVariantProps reflects it back into a type. The type is now a first-class citizen of the component's public surface — not a side effect of the styling runtime. Every variant the designer added to the CVA file is now a prop the compiler enforces. Closed at the style layer and closed at the type layer, with one source of truth between them.
The guardrail composes with Storybook. Dropzone.maxFileSize.stories.tsx is indexed by prop name; Button.cva.ts is indexed by variant value. Together they describe the full API surface — one file enumerates every prop, the other enumerates every value each prop can take. An agent that can read both has nowhere left to hallucinate.
Why it works as grounding: the agent is not guessing what variants exist. It is reading the type. The type enumerates the closed set, and the closed set is the ground truth. An agent that tries to write size: 'huge' gets a compile error before the file saves; an agent that tries to write variant: 'destructive' gets the same. Type-level constraints translate directly into agent-level constraints — and because the constraints are derived from the CVA file itself, they never drift out of sync with what is actually styled.
Guardrail 6 — CLAUDE.md as the Agent Constitution
CLAUDE.md sits at the repository root. Claude Code loads it into the context window automatically at the start of every session, before it has read a single line of my prompt. No @import. No user invocation. It is the agent's system prompt for this codebase — the one document the agent has already internalised before I say hello.
The file is not for the linter. It is for intent. It encodes the things ESLint rules cannot easily catch: architectural decisions, naming conventions, the /libs-versus-/apps styling split, the "Props interface lives in the .tsx, never in types.ts" rule, the policy that says I — not the agent — own git commits. An excerpt from the translation section:
1## Translation Requirements2
3- **NEVER hardcode text strings** — always use t-function4
5**⚠️ ABSOLUTELY FORBIDDEN — eslint-disable for i18n rules:**6
7**NEVER use `eslint-disable` comments to bypass i18n rules.**8
9The following excuses are NOT valid and will be rejected:10- "Internal error, not user-facing" — WRONG11- "Developer-facing error" — WRONG12- "System/configuration error" — WRONG13- "Temporary fix" — WRONG14
15**The ONLY solution is to add the missing translation.16No exceptions. No excuses.**Notice the register. "ABSOLUTELY FORBIDDEN", "NO EXCEPTIONS", "NO EXCUSES". The volume is deliberate. Agents are trained on a web full of soft advice — prefer, consider, you may wish to. Given any opening, they will rationalise around a rule they find inconvenient. ("This error is internal, so I will skip the translation.") Categorical rules leave no negotiation surface. The agent has no cover for a shortcut the document has preemptively named and refused.
A second excerpt — the monetary-calculation section — shows the other half of the technique:
1## Monetary Calculations and Formatting2
3**⚠️ STRICTLY FORBIDDEN — NO EXCEPTIONS:**4- NEVER use `Math.round`, `Math.floor`, `Math.ceil` for monetary values5- NEVER use manual arithmetic like `amount * percentage / 100`6- NEVER use `/ 100` or `* 100` to convert between cents and euros —7 use the Money class8
9**Calculations — Correct approach:**10
11import { Money } from '@prasath/money'12
13const price = Money.fromCents(1100, 'EUR') // €11.0014const tax = price.multiply(0.15) // HALF_EVEN by default15const total = price.add(tax)A prohibition alone is a half-guardrail. "NEVER use Math.round" tells the agent what not to do. "NEVER use Math.round — use Money.fromCents(...).multiply(...) instead" tells it what to do instead. Every rule in CLAUDE.md that bans a prior pattern also names the replacement API. Close every escape hatch before the agent finds it.
CLAUDE.md and the linter are complementary, not redundant. The linter is the enforcement layer: it blocks the violation at commit time. CLAUDE.md is the intent layer: it explains why the rule exists and, crucially, what to reach for instead. An agent can still run npx nx lint and see a red squiggle under Math.round, but without the CLAUDE.md entry it does not know that Money.fromCents(...).multiply(...) is the house alternative. It will invent its own — and the next PR will look nothing like the last one.
Why it works as grounding: CLAUDE.md is literally part of the system prompt. By the time the agent reads my first user message, it has already ingested the house rules, the replacement APIs, the forbidden phrasings, the architectural decisions. That is the single highest-leverage anchoring step in a long agentic session — the prior the agent will not overcorrect against, because it has been instructed not to, in the one document it is guaranteed to have read.
Guardrail 7 — Design-Token-first over className Overrides
Six guardrails in, the source surface is well anchored — primitives are named, variants are enumerable, the ground truth is indexed, and the system prompt knows the house rules. Two doors are still ajar, and one of them is a double door. The first is the familiar className prop: a single string every component in the library forwards onto its outermost element, every Tailwind utility one keystroke away. The second — more powerful, more dangerous — is the classNames prop (plural): a whole object the library also forwards, keyed by BEM slot, letting a caller reach into any internal element of the primitive and repaint it. className can restyle the wrapper. classNames can restyle the title, the description, the icon, the badge, the close button, the empty state, the skeleton — every named slot the component's *.cva.ts file declares. Great power requires great responsibility, and an agent left alone with classNames will exercise none of it.
Both props are open escape hatches the agent will prefer over the closed set of design tokens — every time they are available, and every time I have not explicitly told it not to. The failure mode looks harmless until you read the diff. Asked to "make the delete button more prominent", an agent will happily ship:
1<Button2 variant="danger"3 className="!px-7 !py-4 !text-[15px] !bg-[#dc2626] !rounded-md"4>5 Delete account6</Button>Every character of that override is a lie about the system. px-7 and py-4 sit outside the size scale the Button component has negotiated with the rest of the library. text-[15px] is an arbitrary value — a Tailwind escape hatch that emits a bespoke CSS class outside the typography ramp and will drift one pixel away from the next heading the component sits next to. bg-[#dc2626] is a hex literal bypassing the danger-500 token, so when the brand palette is retuned, the button will stay on the old red while the rest of the application moves. rounded-md contradicts the rounded-theme decision the CVA file made for this variant. And !important on every utility is the agent confessing — in public, on every line — that it knows the component's own styles are going to fight back and it has decided to win the fight by shouting over them.
That is the single-string escape hatch at work. The object-shaped one is worse. Ask the same agent to "make this alert loud enough that no one misses it" and you get:
1<Alert2 variant="danger"3 classNames={{4 wrapper: '!bg-[#dc2626] !p-7 !border-4 !border-[#991b1b]',5 title: '!text-[15px] !text-white !font-black',6 description: '!text-white !font-bold',7 icon: '!w-10 !h-10 !text-[#fef2f2]',8 closeButton: '!text-white !bg-[#7f1d1d]',9 }}10>11 Your account will be deleted in 24 hours.12</Alert>Every key in that object is a BEM slot the component's *.cva.ts file declared for a reason. wrapper, title, description, icon, closeButton — each one a styling decision the library negotiated once, tested once, shipped once. The object-shaped override reaches past all of them in a single prop. If className is a sledgehammer, classNames is a full demolition crew. The single-string version can only repaint the outside of the house. The plural version lets the agent tear down every interior wall in one pass — no type system to catch it (the shape is deliberately open so the component is extensible), no linter rule to flag it (the classes are static strings; the compiler is happy), no visual review that will notice because the styling still looks roughly on-brand until you put it next to an untouched Alert and realise the two no longer belong to the same design language. This is the prop that makes component libraries drift silently. An entire page, rebuilt internally, from the call site, in one object literal. Great power, no responsibility.
Shutting this guardrail has two halves, and both have to be explicit in CLAUDE.md — and both apply equally to className and to classNames. The first half is a categorical ban on the phrasings the agent is most likely to reach for. Directly from the house rules:
1- **TailwindCSS class names**: NEVER use string interpolation for TailwindCSS2 classes (e.g., avoid `xs:${prefix}-${value.xs}`). Use static class names3 only or pre-defined class arrays to ensure Tailwind's compiler can detect4 them.5- **Avoid CSS hacks**: NEVER use `!important` modifiers in Tailwind classes6 (e.g., `!p-0`, `!justify-start`). Instead, properly structure components7 and use CVA variants or proper class composition to achieve the desired8 styling.The second half is the affirmative direction — what to reach for instead. Visual changes go through size and variant props that carry design tokens across every internal slot at once. When the tokens do not yet express the change the prompt is asking for, the correct move is not to override from the call site — not the single-string className, and definitely not the multi-slot classNames. It is to open the CVA file and add a variant. A new intensity: 'muted' | 'loud' next to the existing variant union, a new tone: 'brand' | 'neutral' next to size — each a closed addition to the component's enumerable API, each immediately available to TypeScript, Storybook, and the agent's next reasoning step. Each variant also fans out internally: a well-written Alert.cva.ts threads intensity: 'loud' through the wrapper, the title, the icon, the description, the close button — one enumerable prop touching every slot the classNames object would otherwise have reached into one key at a time. The variant space grows; the override surface does not. Every future agent run reads the expanded union and has a legitimate, in-house way to ship the visual change without touching either prop.
The token palette itself is the closed vocabulary on top of which this works. bg-primary-500, text-success-700, border-danger-400 — named, mapped in tailwind.config.ts, semantically tied to the brand. Hex literals like #dc2626 and arbitrary values like text-[15px] sit outside that vocabulary. The linter can flag the worst offenders, but the structural fix is to make sure CLAUDE.md names the tokens out loud — spell out the palette, spell out the typography ramp, spell out the spacing scale — so the agent never has to infer them from training-data frequency. The moment the agent has to infer, the priors win, and you are back to bg-amber-500 showing up in a codebase that does not own amber.
Why it works as grounding: design tokens convert a visual request into a closed-set lookup. "Make the delete button more prominent" is an open-ended instruction at the className layer — and a combinatorial one at the classNames layer, where the agent gets to pick a utility for every slot independently. There are effectively infinite Tailwind utilities the agent could reach for to satisfy it, and the multi-slot prop multiplies that surface by the number of slots. At the token layer it becomes a finite question: does a matching variant already exist? If yes, use it. If no, add one. The token vocabulary is enumerable; the single-string override vocabulary is not, and the multi-slot override vocabulary is enumerable-times-enumerable. An agent reasoning inside the enumerable variant set cannot overcorrect — there is nowhere new to invent.
Putting It Together: A Guardrail Checklist
Adopting all seven guardrails in an existing library is not a weekend project. But they compose, and each one individually pays back its adoption cost within a sprint or two of agent-assisted work. A compact checklist to screenshot:
| Guardrail | Failure mode it prevents | Adoption cost |
|---|---|---|
| ESLint with radical custom rules | Everything the source surface failed to prevent | Medium — author and maintain custom plugins |
| BEM identifiers in CVA | Duplicate components, untraceable class origins | Low — rename existing classes once |
| Scaffold generators | Fabricated file layouts, inconsistent boilerplate | Medium — write generators, retrain habits |
| Storybook-as-ground-truth | Hallucinated props, misused APIs | Medium — enforce one-story-per-file |
| CVA wrapper + ExtractVariantProps | Fabricated variants, invalid sizes | Low — idiom change, type-only |
| CLAUDE.md as agent constitution | Workflow violations (changesets, review scope) | Low — a single document, maintained over time |
| Design-token-first prompting | className / classNames overrides, hallucinated tokens | Medium — disciplined prompts + token naming |
Summary
A note on what this article is — and what it is not. This is a report from a practitioner's lens, not a scientific one. I have not benchmarked these guardrails against a corpus, I have not controlled for model versions, I have not run an A/B test. I have built React component libraries shipped to real users, I have retrofitted them to be navigable by coding agents, and I have watched agents succeed and fail against the exact patterns described above. Take the seven guardrails as field notes from that work, not as a paper.
Claude 4.7 is about to land. I expect a decent improvement — Anthropic has not missed a beat in the last two years, and every release has tightened the loop. What I do not expect is for any of the seven guardrails to become optional. The failure modes in Section 2 are not artefacts of a particular model generation — they are structural properties of how an agent reasons when its priors are weak and its ground truth is weaker. Better models raise the ceiling on what an agent can do; they do not close the gap between the priors and your codebase. That gap is closed by your source surface, not by the model. I will bet — today, in writing — that every one of the seven guardrails will still be essential on release day of 4.7, and essential again on release day of 5.0. They are the difference between a component library that survives its agent-assisted future and one that gets turned into spaghetti bolognese by a vibe coder with a fresh API key.
Not LLM alone. LLM + structured grounding. Heejin's thesis, applied to a codebase instead of a text corpus. The model is still doing the work. The grounding is doing the work of making sure the work is correct.
Credit and Invitation
This article would not exist without Heejin Do's talk at the Zurich Language AI Meetup. If you work anywhere near text correction, translation quality, or LLM-assisted editing, her research is worth following — she is a Post-Doctoral Fellow at the ETH AI Center working at the intersection of NLP and education.
Thank you also to Gunther Klobe and Supertext for the invitation to attend the meetup — without which this article would not exist in the form it did.
If you are retrofitting an existing component library for coding-agent workflows, or if you are thinking about investing in one from the ground up, it is essential you add someone like me at the beginning of the process — not at the end, not as a rescue operation. In six months we can take this off the ground. I have seen entire teams work on a component library for years and not go anywhere, and the reason — in every case I have been close enough to diagnose — was the lack of this specific, deep, day-in-day-out expertise in what makes a component library hold together at the seams.
I have built component libraries at UBS and at AXA. I invest, day in and day out, in what happens at the agent frontier in the frontend space. That is what I do, it is what I am good at, and it is what I show up to every engagement with.
Let's talk. Reach out via the contact form below.
