Skip to content
Generative interfaces

Let a model choose, never write, your styles

When a language model builds part of your interface, give it a typed menu. Pleat recipes describe their props with Schema, so the model picks among styles that are already compiled, and its answer is decoded before it reaches a view.

Closed-world styling

A model that writes CSS or utility classes can produce anything: rules your stylesheet never shipped, values that break the layout, or text that escapes a declaration. A model that fills in recipe props can only produce combinations that were compiled when the page loaded.

  • Every option a model can name already has rules, so generated UI needs no build step and no runtime CSS from model output.
  • Props are Effect Schemas, and Recipe.jsonSchema gives the JSON Schema for structured output and tool calls.
  • Descriptions on recipes and dimensions travel into the JSON Schema, so the model reads your design guidance next to the options.
  • Theme values are checked by their token kinds, so a generated theme can only contain colors, lengths, and durations.

Describe the menu

Compose recipe schemas into the shape you want back. Here a card has content plus props for two recipes: its surface, and a button for its action.

card.tsTypeScript
import { Schema } from 'effect'

import { Recipe, Style, When } from '@pleat/core'

import { button, tokens } from '../design.ts'

const { color, radius, space, text } = tokens

// The menu a model chooses from. Every option is compiled before it
// is chosen.
export const surface = Recipe.make({
  name: 'Surface',
  description: 'How a card sits on the page.',
  base: Style.make({
    display: 'grid',
    borderRadius: radius.lg,
    border: '1px solid',
  }),
  variants: {
    tone: {
      Plain: {
        backgroundColor: color.surface,
        color: color.ink,
        borderColor: color.line,
      },
      Accent: {
        backgroundColor: color.accentSoft,
        color: color.ink,
        borderColor: color.accent,
      },
      Inverted: {
        backgroundColor: color.ink,
        color: color.canvas,
        borderColor: color.ink,
      },
    },
    density: {
      Compact: {
        gap: space[2],
        padding: space[4],
        fontSize: text.sm,
      },
      Roomy: {
        gap: space[4],
        padding: space[6],
        fontSize: text.md,
      },
    },
    isFeatured: {
      true: Style.make({
        boxShadow: `0 0 0 3px ${color.accent}`,
      }).pipe(
        Style.when(When.motionSafe, {
          transition: 'box-shadow 200ms ease',
        }),
      ),
      false: Style.empty,
    },
  },
  defaults: { tone: 'Plain', density: 'Roomy', isFeatured: false },
  descriptions: {
    tone:
      'Plain for most cards, Accent to draw attention, ' +
      'Inverted for one hero card.',
    density:
      'Compact in dense grids, Roomy when the card stands alone.',
    isFeatured:
      'Rings the card. Use for at most one card on a screen.',
  },
})

// What the model returns: content, plus props for recipes it may
// use.
export const CardSpec = Schema.Struct({
  title: Schema.String.check(
    Schema.isNonEmpty(),
    Schema.isMaxLength(60),
  ),
  body: Schema.String.check(Schema.isMaxLength(280)),
  surface: surface.schema,
  action: Schema.optionalKey(
    Schema.Struct({
      label: Schema.String.check(
        Schema.isNonEmpty(),
        Schema.isMaxLength(24),
      ),
      tone: button.schema.fields.tone,
    }),
  ),
}).annotate({
  identifier: 'Card',
  description: 'One card in a generated dashboard.',
})

export type CardSpec = typeof CardSpec.Type

Ask a model

effect/ai turns the schema into structured output for any provider and decodes the answer. The same schema decodes output from anywhere else, such as a stream or a cache.

generate.tsTypeScript
import { Effect, Schema } from 'effect'
import { LanguageModel } from 'effect/ai'

import { CardSpec } from '../demo/card.ts'

// Ask any provider's model for structured output that matches the
// schema. Its JSON Schema only admits recipe options that already
// have compiled CSS.
export const generateCard = (request: string) =>
  LanguageModel.generateObject({
    objectName: 'Card',
    prompt: `Design one dashboard card for: ${request}`,
    schema: CardSpec,
  }).pipe(Effect.map(response => response.value))

// Output from anywhere else (a stream, a cache, a tool call) is
// decoded the same way.
export const decodeCard = Schema.decodeUnknownEffect(CardSpec)

Try it

The text below stands in for a model’s answer. Edit it, or pick an example. The view decodes it with CardSpec on every change and renders the card with the surface and button recipes.

Decoded and rendered

Checkout conversion is up 4.2%

The new single-page checkout shipped Tuesday. Mobile drop-off fell the most.