DEV Community

Cruz_Smith
Cruz_Smith

Posted on

How we stopped our AI UI generator from producing the same screen every time

Every prompt-to-UI tool generates the same purple gradient. Here is the retrieval architecture we built to fix it, and the two ugly repair layers nobody writes about.

Ask any AI tool to generate a dashboard and you get the same thing. Purple gradient header. Centered card. Three feature columns with rounded icons.

Lovable does it. v0 does it. Bolt does it. Ours did it too, which is where this post starts.

Why this happens

A language model generating UI has no reference for what good product design looks like. It has seen billions of tokens of HTML and CSS and it returns the statistical center of that distribution. Average is not a bug in the output. Average is the output.

You cannot prompt your way out of this. We tried. Longer system prompts, few-shot examples, explicit anti-patterns in the instructions. Every approach produced a marginally different flavour of the same screen, because we were still asking the model to invent a design from nothing.

Fine-tuning was the obvious next move and we ruled it out for three reasons. Design trends move faster than a fine-tune cycle. The labelled dataset you would need does not exist. And the moment your training set ages, the model produces 2023 interfaces with total confidence. Retrieval updates the second you add a screenshot to the index. A fine-tune does not.

So we changed the input instead of the model.

1. Intent understanding

The raw prompt makes a bad retrieval query. "A plant identification app" and "onboarding screen for a plant app, camera-first, friendly" need different references. This stage expands the prompt into a structured brief: surface type, density, tone, likely components. It also decides what to skip — if design choices are already explicit, several downstream stages are unnecessary, which is the difference between a four-second response and a twelve-second one.

2. Retrieval
Supabase with pgvector, queried by cosine distance:
create or replace function match_design_assets (
query_embedding vector(1536),
match_threshold float,
match_count int
)
returns table (id uuid, url text, metadata jsonb, similarity float)
language sql stable
as $$
select da.id, da.url, da.metadata,
1 - (da.embedding <=> query_embedding) as similarity
from design_assets da
where 1 - (da.embedding <=> query_embedding) > match_threshold
order by da.embedding <=> query_embedding
limit match_count;
$$;

Screenshot retrieval and brand lookup are independent, so they run concurrently. Biggest latency win in the system:

const [references, brand] = await Promise.all([
matchDesignAssets(embedding, { threshold: 0.78, count: 8 }),
fetchBrandContext(intent.domain)
]);

3. Vision pass
Retrieved screenshots go through a multimodal model that extracts what makes them work — spacing rhythm, type scale, where weight sits. Retrieval gives you relevant images; this turns them into constraints a text model can follow.

4. The design contract
The stage most implementations skip, and the one that matters most. Before any JSX exists, we lock palette, spacing, type ramp and radius, and hand it to the generator as a hard constraint:

const contract = {
palette: { bg: '#FAF9F6', fg: '#1A1A1A', accent: '#E8623D' },
spacing: [4, 8, 12, 16, 24, 32, 48, 64],
radius: 'rounded-xl',
typeScale: { display: 'text-4xl', body: 'text-base' }
};

Without it the model drifts. It starts on your palette and three components later has invented a blue nobody asked for.

5. Generation
React and Tailwind against the contract, then two repair layers.

The two ugly parts

Icon healing. The model hallucinated Lucide names constantly — , , . Each one is a build-breaking import, which in a live sandbox means a blank screen. So we mapped every real Lucide export and fuzzy-match every generated import:

const healIcon = (name) => {
if (LUCIDE_EXPORTS.has(name)) return name;
return closestMatch(name, LUCIDE_EXPORTS) ?? 'Circle';
};

Not elegant. Killed an entire class of failure.

Babel sanitization. Generated JSX fails to parse more than you'd expect: unclosed fragments, stray TS annotations in .jsx, surviving markdown fences. Every generation compiles in-process before reaching the preview. Parse failure triggers repair, repair failure triggers regeneration.

What still doesn't work

Multi-page drift. The first three screens hold the contract. By the fifth the spacing scale has quietly relaxed. Probably needs per-screen contract injection, not per-session.
Novel surfaces. No analogue in 200k screens means weak matches, and output regresses to the mean this post opened by complaining about.
Ranking. Pure cosine similarity. A screenshot can be semantically close and stylistically wrong, and I haven't found a better approach.

The source is public

github.com/inspoai-studio/inspoai-studio — PolyForm Noncommercial 1.0.0, which is source-available, not OSI open source. Fork it, modify it, use it for personal, academic or nonprofit work. Commercial use needs a separate licence.

React 18 + Vite, Node/Express, Supabase with pgvector. One SQL file sets up schema, vector search and RLS. MCP server at /api/mcp/sse for Cursor and Claude Desktop.

git clone https://github.com/inspoai-studio/inspoai-studio.git
cd inspoai-studio && npm install
cp frontend/.env.example frontend/.env
cp backend/.env.example backend/.env
npm run dev

If you fork it, tell me what you changed — especially if you have a better answer than cosine similarity for ranking.

Top comments (0)