Where Does Your Design System End and the LLM Begin?

The Design System is your territory. The Catalog is the contract. Here's exactly where the boundary is.

6 min read

When I first heard about GenUI, a package that lets an LLM generate Flutter UIs that change in real time based on the conversation, my very first thought wasn’t just “this is cool”.

It was a question:

What do I actually control, and what does the LLM decide?

The colors, the typography, the spacing — who actually owns that? The answer lives in the distinction between two things: the Design System and the Catalog.

Let me walk you through how it works.

The confusion

When you hear “the LLM generates UI”, it’s natural to picture an AI spitting out raw widgets, arbitrary colors, random layouts, fonts it made up on the spot. The idea of handing design decisions to a language model feels terrifying if you care about consistency.

If you’ve put real work into a design system, that’s a fair concern. And now what, an LLM just freestyle invents buttons? Picks whatever font feels right? Decides on its own that today is a good day for Comic Sans and hot pink? Or whether this should be a row, a column… or somehow both?

This is fine

So the question became: how does GenUI respect an existing design system?

The Design System: Your Territory

The Design System is entirely yours. GenUI works on top of it, completely unaware of what’s inside — at least not directly.

Take the GCN26 demo app as an example, a Flutter finance dashboard built for Google Cloud Next 2026. Its design system lives under lib/design_system/ — color tokens, typography, spacing, and a set of purpose-built financial widgets — all completely separate from GenUI:

lib/design_system/
├── app_colors.dart         # color tokens
├── app_text_styles.dart    # Poppins-based typography
├── spacing.dart            # consistent spacing constants
├── themes.dart             # light/dark theme configs
└── widgets/
    ├── app_button.dart
    ├── metric_cards.dart
    ├── bar_chart.dart
    └── ...

These widgets are completely independent from GenUI. They’re just Flutter widgets. You own 100% of what they look like, how they behave, what props they accept. This layer stays entirely in your hands.

You control the components. That’s the answer to half of the question.

The Catalog: The LLM’s Menu

The Catalog is where the handoff happens.

A CatalogItem is a wrapper you write around a design system widget. It does two things:

  1. Defines a JSON schema, so the LLM knows what props exist, their types, and constraints
  2. Provides a widgetBuilder — a function that takes the LLM’s JSON and constructs the actual Flutter widget

The schema is what controls how much freedom the LLM has — no more, no less. Here’s a simplified example for an AppButton:

final appButtonItem = CatalogItem(
  name: 'AppButton',
  dataSchema: S.object(
    properties: {
      'label': S.string(description: 'The button text.'),
      'variant': S.string(
        description: 'Visual style of the button.',
        enumValues: ['filled', 'outlined'],
      ),
      'action': S.string(description: 'Event dispatched on tap.'),
    },
  ),
  widgetBuilder: (ctx) {
    final json = ctx.data as Map<String, Object?>;
    return AppButton(
      label: json['label']! as String,
      variant: json['variant'] == 'outlined'
          ? AppButtonVariant.outlined
          : AppButtonVariant.filled,
      onPressed: () => ctx.dispatchEvent(
        UserActionEvent(name: json['action']! as String),
      ),
    );
  },
);

The LLM sees the schema. It knows there’s a component called AppButton, that takes a label, a variant of either filled or outlined, and an action. That’s it — nothing more.

Here’s a second example — MetricCard, a financial summary widget. Notice the deltaDirection field: the LLM can only say "positive" or "negative". Your widget decides what color that maps to. The LLM never touches the color directly.

final metricCardItem = CatalogItem(
  name: 'MetricCard',
  dataSchema: S.object(
    properties: {
      'cards': S.list(
        items: S.object(
          properties: {
            'label': S.string(description: 'Metric name (e.g. "Fixed costs")'),
            'value': S.string(description: r'Primary value (e.g. "$4,319")'),
            'delta': S.string(description: r'Change indicator (e.g. "+1.2%")'),
            'deltaDirection': S.string(
              description: 'Whether the delta is good or bad for the user',
              enumValues: ['positive', 'negative'],
            ),
          },
        ),
      ),
    },
  ),
  widgetBuilder: (ctx) {
    final cards = (ctx.data['cards'] as List).cast<Map<String, Object?>>();
    return MetricCardsLayout(
      cards: cards.map((c) => MetricCard(
        label: c['label'] as String,
        value: c['value'] as String,
        delta: c['delta'] as String?,
        deltaDirection: c['deltaDirection'] == 'positive'
            ? MetricDeltaDirection.positive
            : MetricDeltaDirection.negative,
      )).toList(),
    );
  },
);

And this is the key point: the schema defines exactly how much creative freedom the LLM gets. A variant enum with two options means the LLM picks one of two. But you could go further — expose a pre-configured color palette, a set of font sizes, even a font family enum. You’re giving the LLM creative room, but within a space you designed. No Comic Sans, no hot pink, no surprises. The LLM works within whatever boundaries you set.

The LLM fills in the data, not the design. That’s the other half of the answer.

What the LLM Actually Generates

Once the Catalog is assembled, GenUI uses it to build a system prompt that describes every available component and its schema. When the LLM responds, it outputs structured JSON like this:

{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "onboarding_screen",
    "components": [
      { "id": "root", "component": "Container", "child": "content" },
      { "id": "content", "component": "Column", "children": ["header", "body", "cta"] },
      { "id": "header", "component": "SectionHeader", "title": "Get started" },
      { "id": "body", "component": "BodyText", "text": "Set up your profile in a few steps." },
      { "id": "cta", "component": "AppButton", "label": "Continue", "variant": "filled", "action": "onboarding_start" }
    ]
  }
}

GenUI’s SurfaceController receives this, looks up each component name in the catalog, and calls the corresponding widgetBuilder. The result is a fully rendered, on-brand Flutter screen — assembled by an LLM, built from your design system.

The LLM decided the composition. You decided the components.

Where the Lines Actually Blur

Here’s the part that surprised me: the Catalog is also where you inject intent.

Each CatalogItem can include a natural language description of the component. And on top of that, you can define a separate set of widget rules — a few paragraphs in the system prompt that tell the LLM when and how to use each component correctly:

AppButton: Use "filled" for primary actions (one per screen max).
Use "outlined" for secondary or destructive actions.
Never place two "filled" buttons on the same surface.

FilterBar: Always pair with an "action" that refreshes visible content
when the selected filter changes.

This is where the design intent lives. Not just “here’s the schema”, but “here’s the reasoning behind it.” The LLM picks this up and makes better compositional decisions as a result.

So the Catalog isn’t just a translation layer — it’s also where you encode your design system’s philosophy into a form the LLM can reason about.

The Mental Model

If you’ve ever learned about APIs using the restaurant analogy, this works the same way.

The Design System is the kitchen. The chefs (you) decide every recipe, every ingredient, every plating rule. Nothing leaves the kitchen that wasn’t designed by you.

The Catalog is the menu. It’s a curated list of what the kitchen can make, written in a language the waiter (the LLM) can read and order from.

The LLM is the waiter. It takes the customer’s order (the user’s conversation), looks at the menu, and places an order. It can only order what’s on the menu, working strictly within what the kitchen offers.

The customer never knows there’s a menu involved. They just see the food.

Practical Takeaways

If you’re integrating GenUI into a project with an existing design system, here’s how the responsibilities break down:

LayerYour jobLLM’s job
Design SystemBuild and own the widgetsWorks on top of it, never inside it
Catalog ItemDefine the schema + widgetBuilderRead the schema, pick the right component
Widget RulesWrite usage guidelinesFollow them when composing screens
Surface JSONNothingGenerate it based on the conversation

Final Thought

The thing that surprised me most is how much control you retain. GenUI doesn’t hand the wheel to the LLM — it gives the LLM a carefully scoped set of tools and says “build something with these.”

Your Design System is the craft. The Catalog is the contract. And the LLM is just doing its job within the boundaries you set.

Once I understood that, integrating GenUI stopped feeling risky and started feeling like a superpower.