Skip to content

« all projects

gen ui demo

2026 · live · 2-day prototype · solo

Two Claude tool-use calls in series. Stage 1 picks components from a framework-agnostic web-component library and fills them with content. Stage 2 arranges them into a layout. The renderer is plain Svelte. The LLM never writes code.


The demo runs as a standalone app at gen-ui.jeffmills.dev. Reviewers can clone the repo and npm run dev in under a minute.


how it works

Stage 1, content + component selection
Sonnet- or Opus-tier model with one Anthropic tool definition per Shoelace component (30 of them, generated from the manifest). The model returns its choices as tool_use blocks: one tag per call, with typed attributes, default-slot content, and named slots. The app parses them into a flat array of ComponentInstance objects. No JSX, HTML, or CSS ever crosses the wire.
Stage 2, layout decision
Haiku-tier model with a single compose_layout tool. Its schema is a recursive grammar of seven layout primitives (ref, grid, stack, row, section, tabs, sidebar, divider). Output is one layout tree that references Stage 1 components by id.
Stage 3, deterministic render
A recursive Svelte component walks the layout tree and a sibling renderer materializes each web component with its attributes and slot HTML. Plain application code. No LLM involvement. Predictable, testable, debuggable.

stage 1 prompt: forcing decomposition

The naive prompt produces a model that compresses everything into one sl-alert with the whole answer as a giant content string. That throws away every layout opportunity downstream. The fix is to make decomposition the loudest rule in the system prompt and reinforce it on every user turn.

The system prompt opens with:

RULE #1 - DECOMPOSE THE ANSWER.

The renderer treats every sl-card / sl-alert / sl-badge as a separate
atom that the next stage can lay out in grids, rows, tabs, and sidebars.
If you pack a multi-part answer into ONE component's content string, all
of that layout capability is wasted and the page looks like a wall of text.

Before you call any tool, COUNT the natural sections in your answer:
  - A "2-week itinerary" has 14 sections (one per day) plus flights, tips.
  - A "compare X, Y, Z" has 3 sections (one per option) plus a verdict.
  - A "dashboard of metrics" has one component per metric.

Then emit roughly that many components.

The user-turn wrapper repeats the directive with even stronger language and an explicit reminder that parallel tool use is supported, so the model knows it can emit many tool_use blocks in a single response. Combined with temperature: 0.2, this turns a coin-flip behavior into a reliable one. Same prompt, same model: 1 tool_use vs 19 tool_uses. Below 0.5 the model stops landing on "one big summary alert" as a valid completion.


stage 2 prompt: forcing layout grouping

Stage 2 has the opposite problem. Given fifteen components, the default behavior is to dump them all into a flat vertical stack: technically correct, visually useless. The job here is to identify groupings (days of a trip, metric tiles, comparison cards) and arrange them as grids, rows, and sections rather than rows of one thing each.

The system prompt is structured around that single rule:

RULE #1 - GROUP, DON'T STACK.

A flat vertical list of 15+ components is almost always wrong. Read every
component's header label and content preview before you decide anything.
Look for:

  - Series: items numbered/named in sequence ("Day 1", "Day 2", "Day 3")
    -> arrange in a grid (2-4 columns).
  - Categories: items sharing a topic (all Tokyo cards together)
    -> nest in their own section.
  - Metrics / tiles: small components (sl-progress-ring, sl-format-number)
    -> group as a dashboard band.
  - Primary + supporting: one big thing with small things around it
    -> sidebar layout.

The prompt is followed by a worked example that draws the desired tree shape for the test prompt ("2-week Japan itinerary"), and an explicit anti-pattern showing the flat-stack output as wrong. The combination of a hard rule plus a worked example plus a negative example reliably produces nested layouts.


passing context between stages

Stage 2 cannot do its job unless it sees the semantic signals Stage 1 embedded in each component. The first version of this pipeline sent Stage 2 just the tag name and attributes:

0. <sl-card>  variant="primary"
1. <sl-card>  variant="primary"
2. <sl-card>  variant="primary"
...

From the model's perspective, that's fifteen indistinguishable cards. Of course it produces a flat stack. The fix was to include the most grouping-relevant field: the header slot, which is where Stage 1 puts the card's label.

0. <sl-card>  header="Flight: PHL -> HND"  content="Outbound ANA NH9 dep PHL 12:35..."
1. <sl-card>  header="Day 1 - Tokyo: Asakusa"  content="Morning at Senso-ji, afternoon..."
2. <sl-card>  header="Day 2 - Shibuya & Harajuku"  content="..."
...
14. <sl-card>  header="Day 14 - Return to PHL"  content="..."

With those header labels, Stage 2 immediately recognizes "days 1-14 are a sequence" and grids them, and "the flight is its own thing" and breaks it out. Same model, same prompt, dramatically different output. The reference-card format is a tiny serialization step in the Pages Function, but it's the load-bearing piece that lets the two stages reason about the same content from different angles.


rendering: getting svelte out of the way

The renderer is two recursive Svelte components and one global stylesheet. The interesting parts are where Svelte's defaults fight the goal.

Slot HTML, not slot text. The LLM emits things like <sl-icon name="exclamation-triangle"></sl-icon> into a component's icon slot. If the renderer treats that as text content the user sees escaped markup. ComponentRenderer parses each slot value as HTML and appends the resulting nodes, preserving the nested custom elements so they upgrade correctly.

Global layout primitives, not scoped CSS. Svelte's scoped-CSS pass adds a class hash to every selector inside a component, including the universal selector inside > * + * spacing rules. Adjacent-sibling selectors then only match children that carry the same hash, which excludes every Shoelace element rendered in a different component. The layout primitives (.gen-stack, .gen-grid, .gen-row, .gen-section) live in a global stylesheet so the lobotomized-owl pattern applies across every kind of child element, framework-rendered or otherwise.

Recursive layout walker. The LayoutTree.svelte component switches on node.kind and recurses into itself for each container type. grid sets grid-template-columns: repeat(N, 1fr); row and stack use flexbox with a data-gap attribute that maps to a named gap size from the global stylesheet. The tree shape is purely declarative; styling is purely CSS. No layout decisions live in JavaScript.

Content normalization. The LLM sometimes falls back to <br><br> for paragraph breaks even when the prompt asks for semantic HTML. The renderer rewrites those into real <p> blocks before injecting them, so the typography CSS for cards and alerts has elements with sensible margins to style rather than a wall of inline text.


what this proved


stack

Frontend
Astro, Svelte 5 (runes), Shoelace 2.x web components, Tailwind v4 for tokens
Pipeline
Anthropic Messages API, two-stage tool use, prompt caching on tools + system prompt, low-temperature stages for consistency
Tool generation
Build-time parser over Shoelace’s custom-elements.json manifest, emitting one Anthropic tool spec per component
Hosting
Cloudflare Pages + Pages Functions, key in Pages env vars
Original prototype
Svelte, Node.js, AWS Bedrock with Claude tool use, Meridian components

Full write-up: Generating UI Without Generating Code · live demo · source