DEV Community

Mxhlix
Mxhlix

Posted on • Edited on

I locked my AI's design output with JSON. It worked perfectly — and everything looked like a spreadsheet

The trap of rigid JSON schemas

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):

Era 1 BMI calculator: white card, stacked plain inputs, one flat button, result box
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:"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

To keep the AI from breaking my design, it only writes JSON. I built that out for real, and the JSON turned into code


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)

Collapse
 
wrobeltomasz profile image
Tomasz

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!

Collapse
 
mxhlix profile image
Mxhlix

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!

Collapse
 
wrobeltomasz profile image
Tomasz • Edited

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?

Thread Thread
 
mxhlix profile image
Mxhlix

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 uses
  • design-tokens.css: the brand palette as plain CSS variables, so throwaway HTML and slides built outside the template pipeline still come out on brand
  • component_patterns.md: the standard shape of each UI part. Where copy and reset buttons go, toasts, sliders, file inputs
  • template-spec.md: colors, forbidden classes, layout structure
  • PATTERNS.md: the violations that have already happened, with the correct procedure. So the same mistake doesn't come back
  • A UI kit: one preview card per color and spacing value, plus finished tools you can copy from

The 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 from tailwind.config.js. design-tokens.css hands the brand palette to anything built outside the template pipeline. What keeps the agent writing names instead of values is check_patterns.py: hand-write bg-[#ec1380] and it fails, even when that hex is the correct brand color.

The UI kit is wired into the create stage. draft-create.md specifies 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 from component_patterns.md instead: button placement, where the copy and reset buttons go, and how toasts are implemented. Those same three are what check_patterns.py looks for at the gate, so a violation inherited from the reference tool dies there instead of shipping.

CLAUDE.md itself 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.

  1. 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.

  2. 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.

  3. 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 site
  • check_patterns.py: per-template violations, including colors and other values written in directly instead of coming from the config
  • check_locale_quality.py: wording quality across the 5 locales
  • check_template_locale_sync.py: whether every translation key the template calls exists in all 5 dictionaries
  • save_with_validation.py: whether the tool description follows the format we settled on
  • verify_dist.py: validation on the generated HTML

However 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.

Collapse
 
merbayerp profile image
Mustafa ERBAY

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!

Collapse
 
mxhlix profile image
Mxhlix

Thank you for the kind words! I don't get complimented very often, so this really made my day.

Collapse
 
merbayerp profile image
Mustafa ERBAY

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.

Collapse
 
georgekobaidze profile image
Giorgi Kobaidze

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.