# 생성형 UI

AI 모델이 OpenUI나 json-render로 쓴 화면을 dsui 컴포넌트로 그려 주는 컴포넌트예요.

- Type: component
- Theme: base
- Tags: arc, component, gen-ui, generative-ui, ai, openui, json-render, llm
- English title: Generative UI
- URL: https://dsui.vercel.app/items/gen-ui

## Used items

- [알림 배너](https://dsui.vercel.app/items/alert): 정보, 성공, 경고, 위험 톤으로 상황을 알리고 닫을 수 있는 배너예요.
- [배지](https://dsui.vercel.app/items/badge): 중립, 성공, 정보, 경고, 위험 톤으로 상태를 작게 표시해요.
- [버튼](https://dsui.vercel.app/items/button): 눌렀을 때 살짝 눌리고 소리가 나는 기본 버튼이에요.
- [카드](https://dsui.vercel.app/items/card): 제목과 설명, 미디어를 담고 눌러 상세 내용을 펼치는 카드예요.
- [체크박스](https://dsui.vercel.app/items/checkbox): 라벨과 설명을 함께 쓰는 체크 상자예요.
- [입력 필드](https://dsui.vercel.app/items/input): 라벨과 설명, 오류 문구를 함께 보여 주는 기본 텍스트 입력 필드예요.
- [지표 카드](https://dsui.vercel.app/items/metric-card): 핵심 수치와 증감을 한눈에 보여 주는 카드예요.
- [진행률 표시](https://dsui.vercel.app/items/progress): 작업이 얼마나 진행됐는지 막대와 숫자로 보여 줘요.
- [셀렉트](https://dsui.vercel.app/items/select): 목록에서 하나를 고르고, 고른 값이 위아래로 굴러 바뀌는 선택 상자예요.
- [스위치](https://dsui.vercel.app/items/switch): 켜고 끄는 값을 바꾸는 스위치로, 누르는 동안 손잡이가 늘어나요.
- [텍스트 영역](https://dsui.vercel.app/items/textarea): 여러 줄을 입력하고 글자 수와 안내 문구를 함께 보여 주는 입력창이에요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| framework | select | 프레임워크 | openui |

## Source

### components.tsx

```tsx
import { z } from "zod";
import { Button } from "../button/button";
import { Alert } from "../alert/alert";
import { Badge } from "../badge/badge";
import { Checkbox } from "../checkbox/checkbox";
import { Card } from "../card/card";
import { Input } from "../input/input";
import { MetricCard } from "../metric-card/metric-card";
import { Progress } from "../progress/progress";
import { Select } from "../select/select";
import { Switch } from "../switch/switch";
import { Textarea } from "../textarea/textarea";
import { defineGenUi, type GenUiSpec } from "./spec";

/**
 * The starter set: actions, fields, status and numbers, and two layout containers (Stack inside Card). The descriptions are written for the model (English reads more
 * reliably there); the copy a model writes for people is governed by GENUI_RULES.
 */

export const GenButton = defineGenUi({
  name: "Button",
  description: "A button that triggers an action. Use one clear verb phrase of two or three words as the label. Use variant primary for the single main action of a view and secondary or ghost for the rest; danger only for destructive actions.",
  props: z.object({
    label: z.string(),
    variant: z.enum(["primary", "secondary", "ghost", "accent", "danger"]).optional(),
    size: z.enum(["sm", "md", "lg"]).optional(),
    disabled: z.boolean().optional(),
  }),
  events: ["press"],
  message: (_event, props) => props.label,
  render: ({ props, emit }) => (
    <span style={{ justifySelf: "start" }}>
      <Button variant={props.variant} size={props.size} disabled={props.disabled} onClick={() => emit("press")}>
        {props.label}
      </Button>
    </span>
  ),
});

export const GenInput = defineGenUi({
  name: "Input",
  description: "A single-line text field with a visible label. Give every field a name that is unique within the view; the value is reported under that name. Use description for a hint and error only to show a problem the person can fix.",
  props: z.object({
    label: z.string(),
    name: z.string(),
    placeholder: z.string().optional(),
    description: z.string().optional(),
    error: z.string().optional(),
    type: z.enum(["text", "email", "url", "tel", "number"]).optional(),
    disabled: z.boolean().optional(),
  }),
  render: ({ props, setValue }) => (
    <Input
      label={props.label}
      name={props.name}
      placeholder={props.placeholder}
      description={props.description}
      error={props.error}
      type={props.type}
      disabled={props.disabled}
      onChange={(event) => setValue(event.target.value)}
    />
  ),
});

export const GenTextarea = defineGenUi({
  name: "Textarea",
  description: "A multi-line text field with a visible label, for longer answers such as a message or a note. Give it a name that is unique within the view; the value is reported under that name.",
  props: z.object({
    label: z.string(),
    name: z.string(),
    placeholder: z.string().optional(),
    description: z.string().optional(),
    error: z.string().optional(),
    rows: z.number().optional(),
    disabled: z.boolean().optional(),
  }),
  render: ({ props, setValue }) => (
    <Textarea
      label={props.label}
      name={props.name}
      placeholder={props.placeholder}
      description={props.description}
      error={props.error}
      rows={props.rows}
      disabled={props.disabled}
      onChange={(event) => setValue(event.target.value)}
    />
  ),
});

export const GenSelect = defineGenUi({
  name: "Select",
  description: "A dropdown to pick one of a few options (two to ten). Each option has a value and a label the person reads. Use it instead of Input when the answers are a fixed list. The chosen value is reported under name.",
  props: z.object({
    label: z.string(),
    name: z.string(),
    options: z.array(z.object({ value: z.string(), label: z.string() })),
    placeholder: z.string().optional(),
    description: z.string().optional(),
    defaultValue: z.string().optional(),
    disabled: z.boolean().optional(),
  }),
  render: ({ props, setValue }) => (
    <Select
      label={props.label}
      name={props.name}
      options={props.options}
      placeholder={props.placeholder}
      description={props.description}
      defaultValue={props.defaultValue}
      disabled={props.disabled}
      onValueChange={(value) => setValue(value)}
    />
  ),
});

export const GenSwitch = defineGenUi({
  name: "Switch",
  description: "An on/off setting that takes effect right away, such as notifications. Use a Checkbox for an option that is only sent with a form. The state (true or false) is reported under name.",
  props: z.object({
    label: z.string(),
    name: z.string(),
    defaultChecked: z.boolean().optional(),
    disabled: z.boolean().optional(),
  }),
  render: ({ props, setValue }) => <Switch label={props.label} name={props.name} defaultChecked={props.defaultChecked} disabled={props.disabled} onCheckedChange={(checked) => setValue(checked)} />,
});

export const GenCheckbox = defineGenUi({
  name: "Checkbox",
  description: "A yes/no option that is sent together with a form, such as agreeing to terms. Put the full statement in label and extra detail in description. The state (true or false) is reported under name.",
  props: z.object({
    label: z.string(),
    name: z.string(),
    description: z.string().optional(),
    defaultChecked: z.boolean().optional(),
    disabled: z.boolean().optional(),
  }),
  render: ({ props, setValue }) => (
    <Checkbox
      label={props.label}
      name={props.name}
      description={props.description}
      defaultChecked={props.defaultChecked}
      disabled={props.disabled}
      onCheckedChange={(checked) => setValue(checked === true)}
    />
  ),
});

export const GenAlert = defineGenUi({
  name: "Alert",
  description: "A short message about a state: info, success, warning or danger. Put the headline in title and one sentence of detail in text. Use danger together with what the person can do next.",
  props: z.object({
    title: z.string(),
    tone: z.enum(["info", "success", "warning", "danger"]).optional(),
    text: z.string().optional(),
  }),
  render: ({ props }) => (
    <Alert tone={props.tone} title={props.title}>
      {props.text}
    </Alert>
  ),
});

export const GenBadge = defineGenUi({
  name: "Badge",
  description: "A small label for a status or a category, one or two words. Not a button: it cannot be pressed.",
  props: z.object({
    text: z.string(),
    tone: z.enum(["neutral", "info", "success", "warning", "danger"]).optional(),
  }),
  render: ({ props }) => (
    <span style={{ justifySelf: "start" }}>
      <Badge tone={props.tone}>{props.text}</Badge>
    </span>
  ),
});

export const GenMetricCard = defineGenUi({
  name: "MetricCard",
  description: "One key number with its label and a line of context, such as a count or a total. value is a plain number; put the unit in suffix and a change such as +12% in change.",
  props: z.object({
    label: z.string(),
    value: z.number(),
    context: z.string(),
    suffix: z.string().optional(),
    change: z.string().optional(),
  }),
  render: ({ props }) => <MetricCard label={props.label} value={props.value} context={props.context} suffix={props.suffix} change={props.change} />,
});

export const GenProgress = defineGenUi({
  name: "Progress",
  description: "A bar that shows how far something is, such as a quota or a step. value runs from 0 to max (default 100). Put what it measures in label.",
  props: z.object({
    value: z.number(),
    label: z.string().optional(),
    max: z.number().optional(),
    showValue: z.boolean().optional(),
  }),
  render: ({ props }) => <Progress value={props.value} max={props.max} label={props.label} showValue={props.showValue} />,
});

export const GenStack = defineGenUi({
  name: "Stack",
  description: "Lays its children out in a row (horizontal, wraps on a narrow screen) or a column (vertical, the default). Use a horizontal Stack for a row of buttons or badges. A Stack cannot hold another Stack or a Card.",
  props: z.object({
    direction: z.enum(["vertical", "horizontal"]).optional(),
  }),
  slot: true,
  render: ({ props, children }) => (
    <div style={props.direction === "horizontal" ? { display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--space-3)" } : { display: "grid", gap: "var(--space-3)", justifyItems: "stretch" }}>{children}</div>
  ),
});

export const GenCard = defineGenUi({
  name: "Card",
  description: "A titled container that stacks its children top to bottom. Use it to group one task or one piece of information, such as a form and its buttons. It can hold a Stack to put buttons or badges in a row. Do not nest a Card inside another Card.",
  props: z.object({
    title: z.string(),
    description: z.string().optional(),
  }),
  slot: true,
  render: ({ props, children }) => (
    <Card title={props.title} description={props.description}>
      {/* Children stack with the space-3 rhythm, so generated screens breathe the same everywhere. */}
      <div style={{ display: "grid", gap: "var(--space-3)", justifyItems: "stretch", marginTop: "var(--space-3)" }}>{children}</div>
    </Card>
  ),
});

/** The set handed to the adapters. Slot components come after the leaves they may hold. */
export const GENUI_SPEC: GenUiSpec = {
  root: "Card",
  components: [GenButton, GenInput, GenTextarea, GenSelect, GenSwitch, GenCheckbox, GenAlert, GenBadge, GenMetricCard, GenProgress, GenStack, GenCard],
};

/** Rules every adapter puts into the model's prompt: dsui's tone for people-facing copy. */
export const GENUI_RULES: readonly string[] = [
  "Write all text that people read in Korean, ending sentences in the polite 해요체 (for example 저장했어요, 다시 해 볼까요?). Never use 합니다 or 되었습니다.",
  "Name buttons with a verb phrase of two or three words, such as 저장하기.",
  "Write an error together with what the person can do next.",
  "Use at most one primary Button per Card.",
];

```

### gen-ui.eval.test.ts

````ts
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { compileSpecStream } from "@json-render/core";
import { GENUI_RULES, GENUI_SPEC } from "./components";
import { GenUi } from "./gen-ui";
import { jsonRenderPrompt } from "./json-render";
import { openUiPrompt } from "./openui";

/**
 * A live check of the system prompts against a real model, through the company's OpenAI-compatible gateway.
 * Off by default (it calls the network and costs money):
 *
 *   source ~/.config/ci-llm-api/env && GENUI_EVAL=1 pnpm exec vitest run components/arc/gen-ui/gen-ui.eval
 *
 * For each request and each framework it asks the model for a screen, draws the answer with the real components, and
 * checks that something was drawn and that no polite-formal Korean ending (합니다, 습니다) got through.
 */
const enabled = process.env.GENUI_EVAL === "1" && !!process.env.CI_LLM_API_KEY && !!process.env.CI_LLM_BASE_URL;

const REQUESTS = [
  "이메일로 뉴스레터를 구독하는 작은 폼을 만들어 줘.",
  "이번 달 가입자 수와 결제 성공 알림을 보여 주는 요약 카드를 만들어 줘.",
  "비밀번호를 잊은 사람이 이메일을 입력하고 재설정 메일을 받는 화면을 만들어 줘.",
  "고객 문의 폼을 만들어 줘. 문의 분류를 고르고, 내용을 적고, 개인정보 동의를 받고, 보내기와 취소하기 버튼이 한 줄에 있어야 해.",
  "알림 설정 화면을 만들어 줘. 메일 알림과 푸시 알림을 켜고 끌 수 있어야 하고 저장하기 버튼이 있어야 해.",
];
const FORMAL = /(합니다|습니다|되었습니다)[.!?\s<"]/;

async function ask(system: string, user: string): Promise<string> {
  const base = process.env.CI_LLM_BASE_URL!.replace(/\/+$/, "");
  for (let attempt = 0; attempt < 3; attempt += 1) {
    try {
      const response = await fetch(`${base}/chat/completions`, {
        method: "POST",
        headers: { "content-type": "application/json", authorization: `Bearer ${process.env.CI_LLM_API_KEY}` },
        signal: AbortSignal.timeout(60_000),
        body: JSON.stringify({ model: process.env.CI_LLM_MODEL ?? "claude-sonnet", max_tokens: 2000, messages: [{ role: "system", content: system }, { role: "user", content: user }] }),
      });
      if (response.status === 401 || response.status === 403 || response.status === 400) throw new Error(`gateway rejected the request (${response.status})`);
      if (!response.ok) continue;
      const body = (await response.json()) as { choices?: { message?: { content?: string } }[] };
      return body.choices?.[0]?.message?.content ?? "";
    } catch (error) {
      if (attempt === 2 || (error instanceof Error && error.message.startsWith("gateway rejected"))) throw error;
    }
  }
  return "";
}

/** The model may wrap its answer in a code fence. */
const unfence = (text: string) => text.replace(/^```[a-z]*\n?/im, "").replace(/\n?```\s*$/m, "").trim();

describe.skipIf(!enabled)("gen-ui prompts against a live model", () => {
  for (const request of REQUESTS) {
    it(`OpenUI: ${request}`, async () => {
      const answer = unfence(await ask(openUiPrompt(GENUI_SPEC, { rules: GENUI_RULES }), request));
      const html = renderToStaticMarkup(createElement(GenUi, { framework: "openui", response: answer }));
      console.log(`\n[openui] ${request}\n${answer}\n`);
      expect(html.length, "something was drawn").toBeGreaterThan(50);
      expect(html).not.toMatch(FORMAL);
    }, 120_000);

    it(`json-render: ${request}`, async () => {
      const answer = unfence(await ask(jsonRenderPrompt(GENUI_SPEC, { rules: GENUI_RULES }), request));
      const spec = compileSpecStream(answer);
      const html = renderToStaticMarkup(createElement(GenUi, { framework: "json-render", response: spec }));
      console.log(`\n[json-render] ${request}\n${answer.slice(0, 900)}\n`);
      expect(html.length, "something was drawn").toBeGreaterThan(50);
      expect(html).not.toMatch(FORMAL);
    }, 120_000);
  }
});

````

### gen-ui.test.ts

```ts
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { Renderer } from "@openuidev/react-lang";
import { JSONUIProvider, Renderer as JsonRenderer } from "@json-render/react";
import { z } from "zod";
import { GENUI_RULES, GENUI_SPEC } from "./components";
import { GenUi } from "./gen-ui";
import { toJsonRender, jsonRenderPrompt } from "./json-render";
import { openUiPrompt, toOpenUiLibrary } from "./openui";
import { defineGenUi, eventProp } from "./spec";

describe("neutral spec", () => {
  it("names the action prop `action` for one event and `on<Event>` for several", () => {
    expect(eventProp({ events: ["press"] }, "press")).toBe("action");
    expect(eventProp({ events: ["open", "close"] }, "close")).toBe("onClose");
    expect(eventProp({}, "press")).toBe("action");
  });

  it("describes every component for a model, with serializable props only", () => {
    expect(GENUI_SPEC.components.map((c) => c.name)).toEqual(["Button", "Input", "Textarea", "Select", "Switch", "Checkbox", "Alert", "Badge", "MetricCard", "Progress", "Stack", "Card"]);
    expect(GENUI_SPEC.components.map((c) => c.name)).toContain(GENUI_SPEC.root);
    for (const component of GENUI_SPEC.components) {
      expect(component.description.length, component.name).toBeGreaterThan(20);
      expect(() => z.toJSONSchema(component.props), component.name).not.toThrow();
    }
  });

  it("keeps required props before optional ones, because OpenUI Lang passes arguments by position", () => {
    for (const component of GENUI_SPEC.components) {
      const keys = Object.entries(component.props.shape).map(([key, field]) => ({ key, optional: (field as z.ZodType).safeParse(undefined).success }));
      const firstOptional = keys.findIndex((k) => k.optional);
      if (firstOptional >= 0) expect(keys.slice(firstOptional).every((k) => k.optional), component.name).toBe(true);
    }
  });
});

describe("OpenUI adapter", () => {
  const library = toOpenUiLibrary(GENUI_SPEC);

  it("defines each component and the root", () => {
    expect(Object.keys(library.components).sort()).toEqual(["Alert", "Badge", "Button", "Card", "Checkbox", "Input", "MetricCard", "Progress", "Select", "Stack", "Switch", "Textarea"]);
    expect(library.root).toBe("Card");
  });

  it("turns events into an action prop and slots into a children array in the prompt", () => {
    const prompt = openUiPrompt(GENUI_SPEC, { rules: GENUI_RULES });
    expect(prompt).toContain("Button(label");
    expect(prompt).toContain("ActionExpression");
    expect(prompt).toMatch(/Card\(children: [^\n]*?, title: string/);
    expect(prompt).toContain("해요체");
  });

  it("renders OpenUI Lang with the dsui components", () => {
    const response = 'root = Card([Input("이메일", "email"), Button("보내기", "primary")], "가입하기")';
    const html = renderToStaticMarkup(createElement(Renderer, { library, response }));
    expect(html).toContain("가입하기");
    expect(html).toContain("이메일");
    expect(html).toContain("보내기");
  });
});

describe("GenUi starter set", () => {
  it("draws every starter component from both formats", () => {
    const openui = 'root = Card([Badge("새 기능", "info"), MetricCard("가입자", 1280, "늘었어요", "명"), Alert("보냈어요", "success", "메일함을 확인해 주세요."), Button("확인하기", "primary")], "현황")';
    const spec = {
      root: "card",
      elements: {
        card: { type: "Card", props: { title: "현황" }, children: ["badge", "metric", "alert", "ok"] },
        badge: { type: "Badge", props: { text: "새 기능", tone: "info" }, children: [] },
        metric: { type: "MetricCard", props: { label: "가입자", value: 1280, context: "늘었어요", suffix: "명" }, children: [] },
        alert: { type: "Alert", props: { title: "보냈어요", tone: "success", text: "메일함을 확인해 주세요." }, children: [] },
        ok: { type: "Button", props: { label: "확인하기", variant: "primary" }, children: [] },
      },
    };
    const htmls = [
      renderToStaticMarkup(createElement(GenUi, { framework: "openui", response: openui })),
      renderToStaticMarkup(createElement(GenUi, { framework: "json-render", response: JSON.stringify(spec) })),
    ];
    for (const html of htmls) for (const text of ["현황", "새 기능", "가입자", "보냈어요", "메일함을 확인해 주세요.", "확인하기"]) expect(html).toContain(text);
  });

  it("draws the form fields and a Stack inside a Card from both formats", () => {
    const openui = 'root = Card([Textarea("문의 내용", "message"), Select("분류", "topic", [{value: "bug", label: "오류 신고"}, {value: "idea", label: "아이디어"}]), Switch("답변을 메일로 받기", "notify"), Checkbox("개인정보 안내를 확인했어요", "agree"), Progress(60, "작성 진행률"), Stack([Button("보내기", "primary"), Button("취소하기", "ghost")], "horizontal")], "문의하기")';
    const spec = {
      root: "card",
      elements: {
        card: { type: "Card", props: { title: "문의하기" }, children: ["msg", "topic", "notify", "agree", "bar", "row"] },
        msg: { type: "Textarea", props: { label: "문의 내용", name: "message" }, children: [] },
        topic: { type: "Select", props: { label: "분류", name: "topic", options: [{ value: "bug", label: "오류 신고" }, { value: "idea", label: "아이디어" }] }, children: [] },
        notify: { type: "Switch", props: { label: "답변을 메일로 받기", name: "notify" }, children: [] },
        agree: { type: "Checkbox", props: { label: "개인정보 안내를 확인했어요", name: "agree" }, children: [] },
        bar: { type: "Progress", props: { value: 60, label: "작성 진행률" }, children: [] },
        row: { type: "Stack", props: { direction: "horizontal" }, children: ["send", "cancel"] },
        send: { type: "Button", props: { label: "보내기", variant: "primary" }, children: [] },
        cancel: { type: "Button", props: { label: "취소하기", variant: "ghost" }, children: [] },
      },
    };
    const htmls = [
      renderToStaticMarkup(createElement(GenUi, { framework: "openui", response: openui })),
      renderToStaticMarkup(createElement(GenUi, { framework: "json-render", response: spec })),
    ];
    for (const html of htmls) for (const text of ["문의하기", "문의 내용", "분류", "답변을 메일로 받기", "개인정보 안내를 확인했어요", "작성 진행률", "보내기", "취소하기"]) expect(html, text).toContain(text);
  });

  it("lets a slot component hold only what the spec lists before it", () => {
    const prompt = openUiPrompt(GENUI_SPEC);
    expect(prompt).toMatch(/^Stack\(children: \(Button \| Input[^\n]*Progress\)\[\]/m);
    expect(prompt).toMatch(/^Card\(children: \([^\n]*Stack\)\[\]/m);
    const stackChildren = /^Stack\(children: (\([^)]*\)\[\])/m.exec(prompt)?.[1] ?? "";
    expect(stackChildren).not.toMatch(/\b(Card|Stack)\b/);
  });

  it("draws nothing for a json-render spec that is still arriving", () => {
    expect(renderToStaticMarkup(createElement(GenUi, { framework: "json-render", response: '{"root": "card", "ele' }))).toBe("");
    expect(renderToStaticMarkup(createElement(GenUi, { framework: "openui", response: null }))).toBe("");
  });
});

describe("json-render adapter", () => {
  const { catalog, registry } = toJsonRender(GENUI_SPEC);

  it("lists every component, the slot and the events in the prompt", () => {
    const prompt = jsonRenderPrompt(GENUI_SPEC, { rules: GENUI_RULES });
    for (const name of ["Button", "Input", "Textarea", "Select", "Switch", "Checkbox", "Alert", "Badge", "MetricCard", "Progress", "Stack", "Card"]) expect(prompt).toContain(name);
    expect(prompt).toContain("press");
    expect(prompt).toContain("해요체");
  });

  it("accepts null for an omitted optional prop and rejects a missing required one", () => {
    const button = (catalog as unknown as { data: { components: Record<string, { props: z.ZodType }> } }).data.components.Button;
    expect(button.props.safeParse({ label: "저장하기", variant: null }).success).toBe(true);
    expect(button.props.safeParse({ variant: "primary" }).success).toBe(false);
  });

  it("renders a spec with the dsui components", () => {
    const spec = {
      root: "card",
      elements: {
        card: { type: "Card", props: { title: "가입하기", description: null }, children: ["email", "send"] },
        email: { type: "Input", props: { label: "이메일", name: "email", placeholder: null }, children: [] },
        send: { type: "Button", props: { label: "보내기", variant: "primary" }, children: [] },
      },
    };
    // eslint-disable-next-line react/no-children-prop -- the provider's props type requires children
    const html = renderToStaticMarkup(createElement(JSONUIProvider, { registry, children: createElement(JsonRenderer, { spec, registry }) }));
    expect(html).toContain("가입하기");
    expect(html).toContain("이메일");
    expect(html).toContain("보내기");
  });
});

describe("one definition, two frameworks", () => {
  it("builds both adapters from a new component without touching either adapter", () => {
    const Badge = defineGenUi({
      name: "Note",
      description: "A short note shown to the person, one sentence.",
      props: z.object({ text: z.string() }),
      render: ({ props }) => createElement("p", null, props.text),
    });
    const spec = { root: "Note", components: [Badge] };
    expect(Object.keys(toOpenUiLibrary(spec).components)).toEqual(["Note"]);
    expect(jsonRenderPrompt(spec)).toContain("Note");
  });
});

```

### gen-ui.tsx

```tsx
"use client";

import { useMemo } from "react";
import { Renderer as OpenUiRenderer } from "@openuidev/react-lang";
import type { Spec } from "@json-render/core";
import { JSONUIProvider, Renderer as JsonRenderRenderer } from "@json-render/react";
import { GENUI_SPEC } from "./components";
import { toJsonRender } from "./json-render";
import { toOpenUiLibrary } from "./openui";
import type { GenUiSpec } from "./spec";

export type GenUiFramework = "openui" | "json-render";

export interface GenUiProps {
  /** Which format `response` is written in. */
  framework: GenUiFramework;
  /** What the model wrote: OpenUI Lang for `openui`, a JSON spec (text or an object) for `json-render`. */
  response: string | object | null;
  /** The components the model may use. Defaults to the dsui starter set. */
  spec?: GenUiSpec;
}

/**
 * Draws what a model wrote with dsui components, from OpenUI Lang or a json-render spec. Both come from the same
 * definitions (see `spec.ts`), so the same component looks and sounds the same either way. Streaming works as each
 * framework streams it: pass the partial text as it arrives.
 *
 * On the server, build the model's system prompt with `openUiPrompt` (./openui) or `jsonRenderPrompt` (./json-render).
 */
export function GenUi({ framework, response, spec = GENUI_SPEC }: GenUiProps) {
  const library = useMemo(() => (framework === "openui" ? toOpenUiLibrary(spec) : null), [framework, spec]);
  const jsonRender = useMemo(() => (framework === "json-render" ? toJsonRender(spec) : null), [framework, spec]);

  if (library) return <OpenUiRenderer library={library} response={typeof response === "string" ? response : null} />;
  if (!jsonRender) return null;
  const parsed = parseSpec(response);
  if (!parsed) return null;
  return (
    <JSONUIProvider registry={jsonRender.registry}>
      <JsonRenderRenderer spec={parsed} registry={jsonRender.registry} />
    </JSONUIProvider>
  );
}

/** A json-render spec from text or an object. Text that does not parse yet (a stream still arriving) draws nothing. */
function parseSpec(response: string | object | null): Spec | null {
  if (response === null) return null;
  if (typeof response !== "string") return response as Spec;
  try {
    return JSON.parse(response) as Spec;
  } catch {
    return null;
  }
}

export default GenUi;

```

### json-render.tsx

```tsx
import type { ReactNode } from "react";
import { z } from "zod";
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { defineRegistry, useStateStore } from "@json-render/react";
import type { AnyGenUiComponent, GenUiSpec } from "./spec";

/** A model that fills a json-render spec may send null for a prop it leaves out, so every optional prop also accepts null. */
function nullishShape(def: AnyGenUiComponent): Record<string, z.ZodType> {
  const shape: Record<string, z.ZodType> = {};
  for (const [key, field] of Object.entries(def.props.shape) as [string, z.ZodType][]) {
    shape[key] = field.safeParse(undefined).success ? field.nullable() : field;
  }
  return shape;
}

/** Drops nulls so the dsui component sees an omitted prop, the way it would from hand-written code. */
function withoutNulls(props: Record<string, unknown>): Record<string, unknown> {
  return Object.fromEntries(Object.entries(props).filter(([, value]) => value !== null));
}

/** Where input values land in json-render's state: /form/<name>. A spec can read them back with `$state`. */
export const formStatePath = (name: string) => `/form/${name}`;

/** Renders one neutral component inside json-render: events go to the element's `on` bindings, input values go to the state store. */
function JsonRenderBridge({ def, props, children, emit }: { def: AnyGenUiComponent; props: Record<string, unknown>; children?: ReactNode; emit: (event: string) => void }) {
  const { set } = useStateStore();
  const neutral = withoutNulls(props);
  return def.render({
    props: neutral,
    children: def.slot ? children : undefined,
    emit,
    setValue: (value) => {
      if (typeof neutral.name === "string") set(formStatePath(neutral.name), value);
    },
  });
}

/**
 * The json-render catalog (the model's vocabulary, `catalog.prompt()` is its system prompt) and the registry (the React
 * implementations) for a spec. Slot components take their children in json-render's `default` slot.
 */
export function toJsonRender(spec: GenUiSpec) {
  const components: Record<string, unknown> = {};
  for (const def of spec.components) {
    components[def.name] = {
      props: z.object(nullishShape(def)),
      description: def.description,
      ...(def.slot ? { slots: ["default"] } : {}),
      ...(def.events?.length ? { events: [...def.events] } : {}),
    };
  }
  const catalog = defineCatalog(schema, { components, actions: {} } as never);

  const implementations: Record<string, unknown> = {};
  for (const def of spec.components) {
    implementations[def.name] = ({ props, children, emit }: { props: Record<string, unknown>; children?: ReactNode; emit: (event: string) => void }) => (
      <JsonRenderBridge def={def} props={props} emit={emit}>
        {children}
      </JsonRenderBridge>
    );
  }
  const { registry } = defineRegistry(catalog, { components: implementations } as never);
  return { catalog, registry };
}

type CatalogPromptOptions = NonNullable<Parameters<ReturnType<typeof toJsonRender>["catalog"]["prompt"]>[0]>;

/** The system prompt for a model that writes a json-render spec with this catalog. `rules` are the tone rules every framework shares (GENUI_RULES). */
export function jsonRenderPrompt(spec: GenUiSpec, { rules = [], ...options }: CatalogPromptOptions & { rules?: readonly string[] } = {}): string {
  return toJsonRender(spec).catalog.prompt({ ...options, customRules: [...rules, ...(options.customRules ?? [])] });
}

```

### openui.tsx

```tsx
import type { ReactNode } from "react";
import { z } from "zod";
import { createLibrary, defineComponent, tagSchemaId, useFormName, useSetFieldValue, useTriggerAction } from "@openuidev/react-lang";
import type { DefinedComponent, Library, PromptOptions } from "@openuidev/react-lang";
import { eventProp, type AnyGenUiComponent, type GenUiSpec } from "./spec";

/** An OpenUI action expression (`Action([...])` in OpenUI Lang). Named so the generated signatures read `ActionExpression`, not `any`. */
const ActionExpression = z.any();
tagSchemaId(ActionExpression, "ActionExpression");

type AnyDefined = DefinedComponent;

/** Renders one neutral component inside OpenUI: events become actions, input values go to the form state, children come from renderNode. */
function OpenUiBridge({ def, props, renderNode }: { def: AnyGenUiComponent; props: Record<string, unknown>; renderNode: (value: unknown) => ReactNode }) {
  const triggerAction = useTriggerAction();
  const formName = useFormName();
  const setFieldValue = useSetFieldValue();
  const { children: childNodes, ...own } = props;
  const neutral = { ...own };
  for (const event of def.events ?? []) delete neutral[eventProp(def, event)];

  return def.render({
    props: neutral,
    children: def.slot ? renderNode(childNodes) : undefined,
    emit: (event) => {
      const action = props[eventProp(def, event)] as Parameters<typeof triggerAction>[2];
      void triggerAction(def.message?.(event, neutral) ?? event, formName, action);
    },
    setValue: (value) => {
      if (typeof neutral.name === "string") setFieldValue(formName, def.name, neutral.name, value, false);
    },
  });
}

/**
 * Builds the OpenUI component for a neutral one. OpenUI Lang passes arguments by position, so the shape is laid out as:
 * `children` first (slot components; OpenUI's own examples and the models' habit put the child list first, as in
 * `Card([...], "Title")`), then required props, then optional props, then the action props.
 */
function toOpenUiComponent(def: AnyGenUiComponent, childRefs: AnyDefined["ref"][]): AnyDefined {
  const required: Record<string, z.ZodType> = {};
  const optional: Record<string, z.ZodType> = {};
  for (const [key, field] of Object.entries(def.props.shape) as [string, z.ZodType][]) {
    (field.safeParse(undefined).success ? optional : required)[key] = field;
  }
  const actions: Record<string, z.ZodType> = {};
  for (const event of def.events ?? []) actions[eventProp(def, event)] = ActionExpression.optional();

  const children: Record<string, z.ZodType> = def.slot && childRefs.length > 0 ? { children: z.array(childRefs.length === 1 ? childRefs[0] : z.union(childRefs as [z.ZodType, z.ZodType, ...z.ZodType[]])) } : {};

  return defineComponent({
    name: def.name,
    description: def.description,
    props: z.object({ ...children, ...required, ...optional, ...actions }),
    component: ({ props, renderNode }) => <OpenUiBridge def={def} props={props as Record<string, unknown>} renderNode={renderNode} />,
  }) as AnyDefined;
}

/**
 * The OpenUI library for a spec. A slot component may hold every component listed before it in the spec, so the order of
 * `spec.components` sets how deep screens can nest (leaves, then a layout such as Stack, then the container such as Card)
 * and there is never a loop for the model to get lost in.
 */
export function toOpenUiLibrary(spec: GenUiSpec): Library {
  const defined: AnyDefined[] = [];
  for (const component of spec.components) defined.push(toOpenUiComponent(component, component.slot ? defined.map((d) => d.ref) : []));
  return createLibrary({ root: spec.root, components: defined });
}

/** The system prompt for a model that writes OpenUI Lang with this spec. `rules` are the tone rules every framework shares (GENUI_RULES). */
export function openUiPrompt(spec: GenUiSpec, { rules = [], ...options }: PromptOptions & { rules?: readonly string[] } = {}): string {
  return toOpenUiLibrary(spec).prompt({ ...options, additionalRules: [...rules, ...(options.additionalRules ?? [])] });
}

```

### spec.ts

```ts
import type { ReactNode } from "react";
import type { z } from "zod";

/**
 * The framework-neutral description of a dsui component for generative UI. One spec feeds every adapter (OpenUI, json-render),
 * so a component is described once: what it is called, when a model should reach for it, which props it takes (a zod object),
 * whether it holds children, and which events it can fire.
 *
 * Props must be serializable: strings, numbers, booleans, enums and arrays of those. A model cannot write a callback or a
 * ReactNode, so those never appear here. Behaviour reaches the host through `emit` (events) and `setValue` (input values).
 */
export interface GenUiContext<P> {
  props: P;
  /** The rendered children, present only when the component declares `slot: true`. */
  children?: ReactNode;
  /** Fires one of the declared `events`. What it does is up to the host (an OpenUI action, a json-render binding). */
  emit: (event: string) => void;
  /** Reports the current value of an input to the host's form state. A no-op for components that are not inputs. */
  setValue: (value: unknown) => void;
}

export interface GenUiComponent<S extends z.ZodRawShape = z.ZodRawShape> {
  /** The name a model writes, in PascalCase. Matches the dsui component's own name. */
  name: string;
  /** When to use it and when not to. This text goes into the model's system prompt. */
  description: string;
  /** Key order matters: OpenUI Lang passes arguments by position, in this order. Required props come first. */
  props: z.ZodObject<S>;
  /** Accepts children (rendered into `ctx.children`). */
  slot?: boolean;
  /** Events the component can fire through `ctx.emit`. */
  events?: readonly string[];
  /** The text sent to the model when an event fires, for hosts that continue the conversation (OpenUI). Defaults to the event name. */
  message?: (event: string, props: z.infer<z.ZodObject<S>>) => string;
  render: (ctx: GenUiContext<z.infer<z.ZodObject<S>>>) => ReactNode;
}

/** A component of any prop shape. `render` takes the props contravariantly, so a list of mixed components needs the wide form. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see above
export type AnyGenUiComponent = GenUiComponent<any>;

/** Identity helper that infers the prop types from the zod shape. */
export function defineGenUi<S extends z.ZodRawShape>(component: GenUiComponent<S>): GenUiComponent<S> {
  return component;
}

/** A set of components that go to a model together. `root` is the component a response starts from. */
export interface GenUiSpec {
  root: string;
  components: readonly AnyGenUiComponent[];
}

/** The prop name an adapter gives a declared event: `action` for a single event, `on<Event>` when there are several. */
export function eventProp(component: Pick<AnyGenUiComponent, "events">, event: string): string {
  return (component.events?.length ?? 0) <= 1 ? "action" : `on${event.charAt(0).toUpperCase()}${event.slice(1)}`;
}

```
