Mass-produce web tools with an AI agent and you will hit this wall: the design drifts on every generation. Same instructions, subtly different colors, spacing, and component shapes each time. Running 600+ tools in five locales, I went through three distinct eras of trying to lock the design down. Each era had a failure worth writing down.
Era 1: define the UI in JSON → it locked. And then
First attempt: define each tool's UI as JSON and generate the page from it. Scope: simple calculator-type tools with no image preview. Labels, inputs, outputs as JSON values, rendered through a fixed pipeline.
As a locking mechanism, it worked. What's in the JSON is what renders — generation-to-generation drift: gone.
But the screens came out lifeless. Straight lines everywhere, not a curve in sight. Every value sitting in its own little cell. To put it bluntly: it looked like a spreadsheet.
In hindsight the cause is structural. In a JSON-definition scheme, the expressive ceiling is whatever the renderer implements. You can add rounded corners and gradients and exception paddings to the schema — but that means endlessly fattening the schema and the renderer, and the limit of that road is reinventing HTML and CSS.
The mechanism that killed the drift killed the expressiveness with it.
Update (Aug 2026): the actual Era 1 JSON, and the screen it produced
A commenter asked to see the actual Era 1 JSON — how exactly the structure/renderer split worked before the token-only locking. I dug the files out of the January 2026 commits.
First, the screen. This is the Era 1 BMI calculator, re-rendered today from the code exactly as it existed then (English locale, 170 cm / 65 kg entered):

This is the screen I meant by "looked like a spreadsheet."
The entire UI definition for that tool was this JSON — the real catalog entry (ja/es locales omitted):
{
"slug": "bmi-calculator",
"template_id": "calc/bmi_calculator.html",
"category": "calc",
"locales": {
"en": {
"name": "BMI Calculator",
"desc": "Calculate Body Mass Index.",
"headline": "BMI Calculator",
"label_height": "Height",
"label_weight": "Weight",
"btn_calc": "Calculate",
"result_prefix": "BMI:"
}
}
}
That's it. Labels and strings. No colors, no spacing, no layout. Structure, styling, and the calculation logic all lived in the fixed 53-line template that template_id points to — the JSON values just get poured in:
<div>
<label class="block text-sm font-medium mb-2 ...">{{ t.label_height }}</label>
<input type="number" id="height" placeholder="cm" class="w-full p-2 border rounded ...">
</div>
<button onclick="calculateBMI()" class="w-full bg-indigo-600 ...">
{{ t.btn_calc }}
</button>
The AI could only ever fill in JSON values, so drift was structurally impossible. And the screen could never express anything the template didn't already implement. The one-value-per-cell look in the screenshot is the direct consequence of that split.
Era 2: lock design tokens, not layout
Change of policy: stop locking structure (layout); lock only identity (the system of color, typography, radii, spacing).
Concretely: a CSS variables file becomes the canonical source, and every tool uses only those variables (palette, fonts, radii, shadows). Alongside it, a living UI kit — a reference implementation you can actually open in a browser. "This is what this project's card/button/input looks like," expressed as running HTML rather than a spec. The instruction to the AI changes from "render exactly this JSON" to "use this kit's parts and variables; compose the layout freely."
The result: layout optimizes per tool while the brand stays coherent. It's Era 1 with the locking granularity inverted. Era 1 locked everything and expression died. Era 2 locks tokens only and composition is free.
One tailwind helped: the models' own design ability kept improving with each release. "Compose freely" only works when the compositions come back good. Era 1's premise — "free composition means drift" — was true at the time, and then time dissolved the premise.
Era 3 (now): ride the official rails where they exist
Today, skill mechanisms in Claude Code and official design-system integrations cover a growing share of "teach the AI your design conventions." What's left to self-build is shrinking to the genuinely project-specific parts: your tokens, your UI kit.
What transfers out of this
First: "it locked" is not the same as "it succeeded." Evaluate the locking mechanism including its side effects. Era 1 achieved its goal and failed.
Second: choose the granularity of what you lock. Lock down to layout and you get spreadsheets. Lock tokens only and brand coherence coexists with compositional freedom.
Third: put an expiry date on your premises. "Free composition means drift" was a fact, and then it wasn't. When a mechanism's premise collapses, throw the mechanism away.
Limits and caveats
Era 1's JSON approach isn't universally wrong. For outputs where structural uniformity is the value — reports, forms, genuinely spreadsheet-like artifacts — JSON definition is still the right answer. My case was web-tool UI, so it wasn't.
This whole arc ran in parallel with 2025–2026 model improvements. The optimal locking granularity will keep moving as model capability does.
As a follow-up, I built Era 1's JSON approach out until it matched the current look, and measured what that costs:
Timeframe: H1 2026 (the Era 1 JSON catalog still exists in a March 2026 backup). Environment: Claude Code / 600+ web tools × 5 locales.
Top comments (10)
Great write-up, and I really recognize this problem — design drift when scaling AI-generated UI is real pain. Would love to see 🤔 a concrete example of the Era 1 JSON schema you used (even a minimal calculator snippet) to better understand how the structure/renderer split worked before you moved to token-only locking. Thanks for sharing the journey through all three eras!
Thanks for the comment! I dug up the actual files and added an update to the Era 1 section — the real JSON catalog entry, a template excerpt, and a screenshot re-rendered from the January code. Hope it helps!
Thanks! Curious about the practical setup behind Era 2/3: what did your CLAUDE.md (or equivalent memory/instruction file) actually look like to make the agent consistently reach for the token file and UI kit instead of inventing its own values? Did you rely on prompt instructions alone, or did you pair it with a lint step to catch drift before it shipped?
Say you want a copy button on a page.
Left to the prompt, it ends up bottom-right on one tool and bottom-left on the next. Until "copy button goes bottom-right" exists as a check in code, you keep fixing it by hand, forever.
So prompting alone doesn't lock down color, spacing, or button placement. Here's what the agent gets pointed at instead:
tailwind.config.js: colors, type, radii, shadows. The source of truth the build actually usesdesign-tokens.css: the brand palette as plain CSS variables, so throwaway HTML and slides built outside the template pipeline still come out on brandcomponent_patterns.md: the standard shape of each UI part. Where copy and reset buttons go, toasts, sliders, file inputstemplate-spec.md: colors, forbidden classes, layout structurePATTERNS.md: the violations that have already happened, with the correct procedure. So the same mistake doesn't come backThe agent never reads the first two. All it writes is a name like
bg-primary, and the actual color gets filled in at build time fromtailwind.config.js.design-tokens.csshands the brand palette to anything built outside the template pipeline. What keeps the agent writing names instead of values ischeck_patterns.py: hand-writebg-[#ec1380]and it fails, even when that hex is the correct brand color.The UI kit is wired into the create stage.
draft-create.mdspecifies which finished tool to look at for each category, and the agent reads only the one matching what it is about to build. The same section bounds what may be taken from it: template structure, the i18n pattern, overall layout. It then names the three things a copied tool most often gets wrong and says those come fromcomponent_patterns.mdinstead: button placement, where the copy and reset buttons go, and how toasts are implemented. Those same three are whatcheck_patterns.pylooks for at the gate, so a violation inherited from the reference tool dies there instead of shipping.CLAUDE.mditself holds no values. The guidance is to keep it around 200 lines, so instead of the details it only says where to look: before touching a template, read these three (component_patterns.md/template-spec.md/PATTERNS.md).From creation to done it runs in three stages. The article steps are specific to my own site, so substitute whatever your equivalent is.
Create (
draft-create.md)Read the three specs above before starting. Once the template is written, run
check_patterns.py. 0 issues or the next stage doesn't open.Finish (
draft-finalize.md)Eyeball the screen in all 5 locales, write the 5 locale articles, run 6 machine checks (
generator.py/check_patterns.py/check_locale_quality.py/check_template_locale_sync.py/save_with_validation.py/verify_dist.py), and once review comes back with nothing, it's done.Post-deploy (
qa-check.md)Re-check live tools, in order of which ones are actually getting search traffic.
What those 6 do:
generator.py: regenerate the sitecheck_patterns.py: per-template violations, including colors and other values written in directly instead of coming from the configcheck_locale_quality.py: wording quality across the 5 localescheck_template_locale_sync.py: whether every translation key the template calls exists in all 5 dictionariessave_with_validation.py: whether the tool description follows the format we settled onverify_dist.py: validation on the generated HTMLHowever good the models get, you're not getting a first-pass OK as long as the human on the other end has opinions about the details. So I think you will always need a mechanism that records those preferences and makes the agent read them.
This reminds me of a broader engineering principle: constrain invariants, not implementations. The things that define your product identity—design tokens, accessibility rules, interaction contracts, brand semantics—should remain stable. Everything else should be free to adapt to the context. The same idea appears in APIs, distributed systems, and domain modeling: over-constrain the implementation and you kill evolution; under-constrain the invariants and you lose consistency. Great write-up!
Thank you for the kind words! I don't get complimented very often, so this really made my day.
I’m glad it did. 😊 And honestly, the update made the point much stronger because it showed the trade-off instead of just describing it.
What I liked most is that you didn’t present Era 1 as a mistake—you showed it as the right solution for the wrong abstraction level. That’s a distinction a lot of engineering write-ups miss.
Looking forward to seeing how this evolves as models get better.
Really interesting approach. I especially liked the realization that making the AI follow a rigid JSON structure solved consistency by essentially removing its freedom, which is something to be expected. The more deterministic it gets, more stiff and inflexible it becomes.
I think the bigger takeaway here is “constrain the invariants, not the implementation.” Locking down tokens, components, and design rules while letting the AI decide the actual composition feels like a much more scalable approach.
I’d really love to see a side-by-side example of the same page generated with the rigid JSON approach vs. the design-system approach. That would make the difference even more obvious.
The core idea itself is so interesting, I’ve never even considered using JSON for instructing AI to build anything, and I see some really good points and benefits for using this approach.
But, as you mentioned in the article, the JSON schema can get really complex, big and heavy, really fast.
This is one of the best articles I’ve read for a while, thanks!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.