@assistant-ui/react-generative-ui 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/JSONGenerativeUI.client.d.ts +4 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +9 -2
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/JSONGenerativeUI.shared.d.ts +8 -0
  6. package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
  7. package/dist/JSONGenerativeUI.shared.js.map +1 -1
  8. package/dist/actionRegistry.d.ts +41 -0
  9. package/dist/actionRegistry.d.ts.map +1 -0
  10. package/dist/actionRegistry.js +41 -0
  11. package/dist/actionRegistry.js.map +1 -0
  12. package/dist/buildPresentParameters.js +10 -1
  13. package/dist/buildPresentParameters.js.map +1 -1
  14. package/dist/index.d.ts +6 -3
  15. package/dist/index.js +5 -2
  16. package/dist/index.server.d.ts +12 -0
  17. package/dist/index.server.js +10 -0
  18. package/dist/index.shared.d.ts +11 -0
  19. package/dist/index.shared.js +9 -0
  20. package/dist/ir.d.ts +115 -0
  21. package/dist/ir.d.ts.map +1 -0
  22. package/dist/ir.js +149 -0
  23. package/dist/ir.js.map +1 -0
  24. package/dist/renderGenerativeUI.d.ts +5 -4
  25. package/dist/renderGenerativeUI.d.ts.map +1 -1
  26. package/dist/renderGenerativeUI.js +18 -45
  27. package/dist/renderGenerativeUI.js.map +1 -1
  28. package/dist/types.d.ts +34 -30
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/vocabulary/alert.d.ts +34 -0
  31. package/dist/vocabulary/alert.d.ts.map +1 -0
  32. package/dist/vocabulary/alert.js +49 -0
  33. package/dist/vocabulary/alert.js.map +1 -0
  34. package/dist/vocabulary/data.d.ts +53 -0
  35. package/dist/vocabulary/data.d.ts.map +1 -0
  36. package/dist/vocabulary/data.js +63 -0
  37. package/dist/vocabulary/data.js.map +1 -0
  38. package/dist/vocabulary/fact.d.ts +20 -0
  39. package/dist/vocabulary/fact.d.ts.map +1 -0
  40. package/dist/vocabulary/fact.js +24 -0
  41. package/dist/vocabulary/fact.js.map +1 -0
  42. package/dist/vocabulary/index.d.ts +16 -0
  43. package/dist/vocabulary/index.d.ts.map +1 -0
  44. package/dist/vocabulary/index.js +30 -0
  45. package/dist/vocabulary/index.js.map +1 -0
  46. package/dist/vocabulary/interactive.d.ts +81 -0
  47. package/dist/vocabulary/interactive.d.ts.map +1 -0
  48. package/dist/vocabulary/interactive.js +123 -0
  49. package/dist/vocabulary/interactive.js.map +1 -0
  50. package/dist/vocabulary/layout.d.ts +78 -0
  51. package/dist/vocabulary/layout.d.ts.map +1 -0
  52. package/dist/vocabulary/layout.js +72 -0
  53. package/dist/vocabulary/layout.js.map +1 -0
  54. package/dist/vocabulary/media.d.ts +34 -0
  55. package/dist/vocabulary/media.d.ts.map +1 -0
  56. package/dist/vocabulary/media.js +32 -0
  57. package/dist/vocabulary/media.js.map +1 -0
  58. package/dist/vocabulary/text.d.ts +74 -0
  59. package/dist/vocabulary/text.d.ts.map +1 -0
  60. package/dist/vocabulary/text.js +48 -0
  61. package/dist/vocabulary/text.js.map +1 -0
  62. package/package.json +12 -12
  63. package/src/JSONGenerativeUI.client.tsx +10 -2
  64. package/src/JSONGenerativeUI.shared.ts +8 -0
  65. package/src/actionRegistry.test.ts +90 -0
  66. package/src/actionRegistry.ts +96 -0
  67. package/src/buildPresentParameters.ts +23 -7
  68. package/src/index.server.ts +2 -0
  69. package/src/index.shared.ts +58 -0
  70. package/src/index.ts +2 -26
  71. package/src/ir.test.ts +203 -0
  72. package/src/ir.ts +253 -0
  73. package/src/renderGenerativeUI.test.tsx +53 -0
  74. package/src/renderGenerativeUI.tsx +49 -82
  75. package/src/types.ts +57 -42
  76. package/src/vocabulary/alert.test.tsx +66 -0
  77. package/src/vocabulary/alert.tsx +47 -0
  78. package/src/vocabulary/data.test.tsx +67 -0
  79. package/src/vocabulary/data.tsx +86 -0
  80. package/src/vocabulary/fact.test.tsx +15 -0
  81. package/src/vocabulary/fact.tsx +22 -0
  82. package/src/vocabulary/index.test.ts +56 -0
  83. package/src/vocabulary/index.ts +27 -0
  84. package/src/vocabulary/interactive.dispatch.test.tsx +110 -0
  85. package/src/vocabulary/interactive.test.tsx +108 -0
  86. package/src/vocabulary/interactive.tsx +165 -0
  87. package/src/vocabulary/layout.test.tsx +61 -0
  88. package/src/vocabulary/layout.tsx +76 -0
  89. package/src/vocabulary/media.test.tsx +53 -0
  90. package/src/vocabulary/media.tsx +37 -0
  91. package/src/vocabulary/text.test.tsx +73 -0
  92. package/src/vocabulary/text.tsx +59 -0
@@ -0,0 +1,27 @@
1
+ import type { GenerativeUILibrary } from "../types";
2
+ import { alertVocabulary } from "./alert";
3
+ import { dataVocabulary } from "./data";
4
+ import { factVocabulary } from "./fact";
5
+ import { interactiveVocabulary } from "./interactive";
6
+ import { layoutVocabulary } from "./layout";
7
+ import { mediaVocabulary } from "./media";
8
+ import { textVocabulary } from "./text";
9
+
10
+ /**
11
+ * The closed generative-ui vocabulary the factory ships: a fixed set of
12
+ * intrinsic components the model may render, each a zod `properties` schema
13
+ * plus an unstyled structural `render` (semantic HTML with a `data-aui`
14
+ * attribute naming the component, plus `data-aui-<prop>` hooks, for the host
15
+ * to style). Users opt in by passing it to
16
+ * `new JSONGenerativeUI({ library: defaultGenerativeUILibrary })`, and override
17
+ * or extend entries with their own `defineGenerativeComponents`.
18
+ */
19
+ export const defaultGenerativeUILibrary: GenerativeUILibrary = {
20
+ ...textVocabulary,
21
+ ...mediaVocabulary,
22
+ ...factVocabulary,
23
+ ...interactiveVocabulary,
24
+ ...layoutVocabulary,
25
+ ...dataVocabulary,
26
+ ...alertVocabulary,
27
+ };
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { isValidElement, type ReactNode } from "react";
3
+ import { interactiveVocabulary } from "./interactive";
4
+ import { createActionRegistry } from "../actionRegistry";
5
+
6
+ /** The props shape `Button.render` receives when the renderer injects `$action`
7
+ * and `$dispatch`. Used to call `render` directly so the test can invoke
8
+ * `onClick` without a DOM. */
9
+ type ButtonRenderProps = {
10
+ label: string;
11
+ $status: "done";
12
+ $action?: { type: string; [k: string]: unknown };
13
+ $dispatch?: (a: unknown) => unknown;
14
+ };
15
+
16
+ describe("interactiveVocabulary $action dispatch", () => {
17
+ it("Button render attaches an onClick that fires $dispatch with the $action payload", () => {
18
+ const handler = vi.fn();
19
+ const registry = createActionRegistry({ purchase: handler });
20
+ const out = interactiveVocabulary.Button.render({
21
+ label: "Buy",
22
+ $status: "done",
23
+ $action: { type: "purchase", itemId: "sku-1" },
24
+ $dispatch: registry.dispatch,
25
+ } as ButtonRenderProps) as ReactNode;
26
+ expect(isValidElement(out)).toBe(true);
27
+ const onClick = (out as { props: { onClick: () => void } }).props.onClick;
28
+ expect(typeof onClick).toBe("function");
29
+ onClick();
30
+ expect(handler).toHaveBeenCalledWith({
31
+ payload: { type: "purchase", itemId: "sku-1" },
32
+ });
33
+ });
34
+
35
+ it("Button onClick is a no-op when no $dispatch is wired (read-only render)", () => {
36
+ const out = interactiveVocabulary.Button.render({
37
+ label: "Buy",
38
+ $status: "done",
39
+ $action: { type: "purchase", itemId: "sku-1" },
40
+ } as ButtonRenderProps) as ReactNode;
41
+ const onClick = (out as { props: { onClick: () => void } }).props.onClick;
42
+ expect(typeof onClick).toBe("function");
43
+ expect(() => onClick()).not.toThrow();
44
+ });
45
+
46
+ it("Button onClick is a no-op when $action is absent", () => {
47
+ const handler = vi.fn();
48
+ const registry = createActionRegistry({ purchase: handler });
49
+ const out = interactiveVocabulary.Button.render({
50
+ label: "Go",
51
+ $status: "done",
52
+ $dispatch: registry.dispatch,
53
+ } as ButtonRenderProps) as ReactNode;
54
+ (out as { props: { onClick: () => void } }).props.onClick();
55
+ expect(handler).not.toHaveBeenCalled();
56
+ });
57
+
58
+ it("Select onChange merges the selected value into $input and preserves a model-supplied $action.value", () => {
59
+ const handler = vi.fn();
60
+ const registry = createActionRegistry({ pick: handler });
61
+ const out = interactiveVocabulary.Select.render({
62
+ options: [{ label: "A", value: "a" }],
63
+ $status: "done",
64
+ $action: { type: "pick", value: "model-supplied" },
65
+ $dispatch: registry.dispatch,
66
+ }) as ReactNode;
67
+ const onChange = (
68
+ out as {
69
+ props: { onChange: (e: { currentTarget: { value: string } }) => void };
70
+ }
71
+ ).props.onChange;
72
+ onChange({ currentTarget: { value: "user-picked" } });
73
+ expect(handler).toHaveBeenCalledWith({
74
+ payload: {
75
+ type: "pick",
76
+ value: "model-supplied",
77
+ $input: "user-picked",
78
+ },
79
+ });
80
+ });
81
+
82
+ it("Input onKeyDown (Enter) merges the entered value into $input", () => {
83
+ const handler = vi.fn();
84
+ const registry = createActionRegistry({ submit: handler });
85
+ const out = interactiveVocabulary.Input.render({
86
+ $status: "done",
87
+ $action: { type: "submit" },
88
+ $dispatch: registry.dispatch,
89
+ }) as ReactNode;
90
+ const onKeyDown = (
91
+ out as {
92
+ props: {
93
+ onKeyDown: (e: {
94
+ key: string;
95
+ nativeEvent: { isComposing: boolean };
96
+ currentTarget: { value: string };
97
+ }) => void;
98
+ };
99
+ }
100
+ ).props.onKeyDown;
101
+ onKeyDown({
102
+ key: "Enter",
103
+ nativeEvent: { isComposing: false },
104
+ currentTarget: { value: "typed text" },
105
+ });
106
+ expect(handler).toHaveBeenCalledWith({
107
+ payload: { type: "submit", $input: "typed text" },
108
+ });
109
+ });
110
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { renderGenerativeUI } from "../renderGenerativeUI";
4
+ import { interactiveVocabulary } from "./interactive";
5
+
6
+ const render = (node: unknown) =>
7
+ renderToStaticMarkup(<>{renderGenerativeUI(node, interactiveVocabulary)}</>);
8
+
9
+ describe("interactiveVocabulary", () => {
10
+ it("Button renders a button with style/block hooks and the label", () => {
11
+ expect(
12
+ render({
13
+ $type: "Button",
14
+ label: "Go",
15
+ buttonStyle: "primary",
16
+ block: true,
17
+ }),
18
+ ).toBe(
19
+ '<button data-aui="button" data-aui-style="primary" data-aui-block="true">Go</button>',
20
+ );
21
+ });
22
+
23
+ it("Button omits data-aui-block when block is false or omitted", () => {
24
+ expect(render({ $type: "Button", label: "Go", block: false })).toBe(
25
+ '<button data-aui="button">Go</button>',
26
+ );
27
+ });
28
+
29
+ it("Select renders an aria-label when label is provided", () => {
30
+ const html = render({
31
+ $type: "Select",
32
+ label: "Choose",
33
+ options: [{ label: "A", value: "a" }],
34
+ });
35
+ expect(html).toContain('aria-label="Choose"');
36
+ });
37
+
38
+ it("Select keys options by index so duplicate values do not collide", () => {
39
+ const html = render({
40
+ $type: "Select",
41
+ options: [
42
+ { label: "A", value: "x" },
43
+ { label: "B", value: "x" },
44
+ ],
45
+ });
46
+ expect(html).toContain('<option value="x">A</option>');
47
+ expect(html).toContain('<option value="x">B</option>');
48
+ });
49
+
50
+ it("Button stashes $action on a data-aui-action attribute", () => {
51
+ const html = render({
52
+ $type: "Button",
53
+ label: "Buy",
54
+ $action: { type: "purchase", itemId: "sku-1" },
55
+ });
56
+ expect(html).toContain('data-aui-action="');
57
+ expect(html).toContain("&quot;type&quot;:&quot;purchase&quot;");
58
+ expect(html).toContain("&quot;itemId&quot;:&quot;sku-1&quot;");
59
+ });
60
+
61
+ it("Button without $action omits the data-aui-action attribute", () => {
62
+ expect(render({ $type: "Button", label: "Go" })).toBe(
63
+ '<button data-aui="button">Go</button>',
64
+ );
65
+ });
66
+
67
+ it("Select renders options with values and a placeholder", () => {
68
+ const html = render({
69
+ $type: "Select",
70
+ placeholder: "Pick one",
71
+ options: [
72
+ { label: "A", value: "a" },
73
+ { label: "B", value: "b" },
74
+ ],
75
+ });
76
+ expect(html).toContain('data-aui="select"');
77
+ expect(html).toContain(
78
+ 'value="" disabled="" selected="">Pick one</option>',
79
+ );
80
+ expect(html).toContain('<option value="a">A</option>');
81
+ expect(html).toContain('<option value="b">B</option>');
82
+ });
83
+
84
+ it("Input renders a single-line input by default", () => {
85
+ expect(render({ $type: "Input", placeholder: "type here" })).toBe(
86
+ '<input data-aui="input" placeholder="type here"/>',
87
+ );
88
+ });
89
+
90
+ it("Input multiline renders a textarea with the multiline hook", () => {
91
+ const html = render({ $type: "Input", multiline: true });
92
+ expect(html).toContain("data-aui-multiline");
93
+ expect(html).toContain("<textarea");
94
+ });
95
+
96
+ it("DatePicker renders a date input with min/max", () => {
97
+ const html = render({
98
+ $type: "DatePicker",
99
+ value: "2026-01-01",
100
+ min: "2025-01-01",
101
+ max: "2027-01-01",
102
+ });
103
+ expect(html).toContain('type="date"');
104
+ expect(html).toContain('value="2026-01-01"');
105
+ expect(html).toContain('min="2025-01-01"');
106
+ expect(html).toContain('max="2027-01-01"');
107
+ });
108
+ });
@@ -0,0 +1,165 @@
1
+ import { z } from "zod";
2
+ import type { Action } from "../ir";
3
+ import { BUTTON_STYLES } from "../ir";
4
+ import type { GenerativeUIDispatch, GenerativeUILibrary } from "../types";
5
+
6
+ const optionSchema = z.object({
7
+ label: z.string(),
8
+ value: z.string(),
9
+ });
10
+
11
+ const actionAttr = (a: Action | undefined): string | undefined =>
12
+ a ? JSON.stringify(a) : undefined;
13
+
14
+ /** Fires `$action` through `$dispatch` when both are present, merging a runtime
15
+ * value into the payload under the reserved `$input` key (not `value`) so the
16
+ * user's input never clobbers a model-supplied `value` field. No-op when no
17
+ * registry is wired. The returned promise from an async handler is caught and
18
+ * re-thrown on a microtask so rejections surface rather than going unhandled. */
19
+ const fire = (
20
+ $action: Action | undefined,
21
+ $dispatch: GenerativeUIDispatch | undefined,
22
+ input?: unknown,
23
+ ) => {
24
+ if (!$action || !$dispatch) return;
25
+ const payload = input === undefined ? $action : { ...$action, $input: input };
26
+ void Promise.resolve($dispatch(payload)).catch((error) => {
27
+ queueMicrotask(() => {
28
+ throw error;
29
+ });
30
+ });
31
+ };
32
+
33
+ export const interactiveVocabulary = {
34
+ Button: {
35
+ description:
36
+ "A clickable button. Carries `$action` describing the side effect or resume value.",
37
+ properties: z.object({
38
+ label: z.string().describe("Button label."),
39
+ buttonStyle: z.enum(BUTTON_STYLES).optional().describe("Visual style."),
40
+ block: z
41
+ .boolean()
42
+ .optional()
43
+ .describe("Whether the button spans the full width."),
44
+ }),
45
+ render: ({ label, buttonStyle, block, $action, $dispatch, children }) => (
46
+ <button
47
+ data-aui="button"
48
+ data-aui-style={buttonStyle}
49
+ data-aui-block={block || undefined}
50
+ data-aui-action={actionAttr($action)}
51
+ onClick={() => fire($action, $dispatch)}
52
+ >
53
+ {label}
54
+ {children}
55
+ </button>
56
+ ),
57
+ },
58
+ Select: {
59
+ description:
60
+ "A dropdown selector. Carries `$action` describing the on-select behavior.",
61
+ properties: z.object({
62
+ options: z.array(optionSchema).describe("Selectable options."),
63
+ placeholder: z
64
+ .string()
65
+ .optional()
66
+ .describe("Placeholder shown when nothing is selected."),
67
+ label: z
68
+ .string()
69
+ .optional()
70
+ .describe("Accessible label for the control."),
71
+ }),
72
+ render: ({ options, placeholder, label, $action, $dispatch, children }) => (
73
+ <select
74
+ data-aui="select"
75
+ data-aui-action={actionAttr($action)}
76
+ aria-label={label}
77
+ defaultValue=""
78
+ onChange={(e) => fire($action, $dispatch, e.currentTarget.value)}
79
+ >
80
+ {placeholder ? (
81
+ <option value="" disabled>
82
+ {placeholder}
83
+ </option>
84
+ ) : null}
85
+ {options.map((o: { label: string; value: string }, i: number) => (
86
+ <option key={i} value={o.value}>
87
+ {o.label}
88
+ </option>
89
+ ))}
90
+ {children}
91
+ </select>
92
+ ),
93
+ },
94
+ Input: {
95
+ description:
96
+ "A text input. Carries `$action` describing the on-submit behavior.",
97
+ properties: z.object({
98
+ placeholder: z.string().optional().describe("Placeholder text."),
99
+ multiline: z
100
+ .boolean()
101
+ .optional()
102
+ .describe("Render a textarea instead of a single-line input."),
103
+ label: z
104
+ .string()
105
+ .optional()
106
+ .describe("Accessible label for the control."),
107
+ }),
108
+ render: ({ placeholder, multiline, label, $action, $dispatch }) => {
109
+ const submit = (v: string) => fire($action, $dispatch, v);
110
+ return multiline ? (
111
+ <textarea
112
+ data-aui="input"
113
+ data-aui-multiline
114
+ data-aui-action={actionAttr($action)}
115
+ aria-label={label}
116
+ placeholder={placeholder}
117
+ onKeyDown={(e) => {
118
+ if (
119
+ e.key === "Enter" &&
120
+ (e.ctrlKey || e.metaKey) &&
121
+ !e.nativeEvent.isComposing
122
+ )
123
+ submit(e.currentTarget.value);
124
+ }}
125
+ />
126
+ ) : (
127
+ <input
128
+ data-aui="input"
129
+ data-aui-action={actionAttr($action)}
130
+ aria-label={label}
131
+ placeholder={placeholder}
132
+ onKeyDown={(e) => {
133
+ if (e.key === "Enter" && !e.nativeEvent.isComposing)
134
+ submit(e.currentTarget.value);
135
+ }}
136
+ />
137
+ );
138
+ },
139
+ },
140
+ DatePicker: {
141
+ description:
142
+ "A date input. Carries `$action` describing the on-select behavior.",
143
+ properties: z.object({
144
+ value: z.string().optional().describe("Initial date (YYYY-MM-DD)."),
145
+ min: z.string().optional().describe("Minimum date (YYYY-MM-DD)."),
146
+ max: z.string().optional().describe("Maximum date (YYYY-MM-DD)."),
147
+ label: z
148
+ .string()
149
+ .optional()
150
+ .describe("Accessible label for the control."),
151
+ }),
152
+ render: ({ value, min, max, label, $action, $dispatch }) => (
153
+ <input
154
+ type="date"
155
+ data-aui="datepicker"
156
+ data-aui-action={actionAttr($action)}
157
+ aria-label={label}
158
+ defaultValue={value}
159
+ min={min}
160
+ max={max}
161
+ onChange={(e) => fire($action, $dispatch, e.currentTarget.value)}
162
+ />
163
+ ),
164
+ },
165
+ } satisfies GenerativeUILibrary;
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { renderGenerativeUI } from "../renderGenerativeUI";
4
+ import { layoutVocabulary } from "./layout";
5
+
6
+ const render = (node: unknown) =>
7
+ renderToStaticMarkup(<>{renderGenerativeUI(node, layoutVocabulary)}</>);
8
+
9
+ describe("layoutVocabulary", () => {
10
+ it("Card renders a section with optional title and padding/background hooks", () => {
11
+ expect(
12
+ render({
13
+ $type: "Card",
14
+ title: "T",
15
+ padding: 2,
16
+ background: "muted",
17
+ }),
18
+ ).toBe(
19
+ '<section data-aui="card" data-aui-padding="2" data-aui-background="muted"><header data-aui="card-title">T</header></section>',
20
+ );
21
+ });
22
+
23
+ it("Card without a title omits the header", () => {
24
+ expect(render({ $type: "Card" })).toBe(
25
+ '<section data-aui="card"></section>',
26
+ );
27
+ });
28
+
29
+ it("Col renders a div with gap/align hooks and nested children", () => {
30
+ expect(
31
+ render({
32
+ $type: "Col",
33
+ gap: 2,
34
+ align: "center",
35
+ children: { $type: "Text", value: "x" } as never,
36
+ }),
37
+ ).toContain('data-aui="col"');
38
+ });
39
+
40
+ it("Row renders a div with gap/align/justify hooks", () => {
41
+ const html = render({
42
+ $type: "Row",
43
+ gap: 4,
44
+ align: "start",
45
+ justify: "between",
46
+ });
47
+ expect(html).toBe(
48
+ '<div data-aui="row" data-aui-gap="4" data-aui-align="start" data-aui-justify="between"></div>',
49
+ );
50
+ });
51
+
52
+ it("Spacer renders an empty div", () => {
53
+ expect(render({ $type: "Spacer" })).toBe('<div data-aui="spacer"></div>');
54
+ });
55
+
56
+ it("Badge renders a span with the value and variant hook", () => {
57
+ expect(render({ $type: "Badge", value: "new", variant: "success" })).toBe(
58
+ '<span data-aui="badge" data-aui-variant="success">new</span>',
59
+ );
60
+ });
61
+ });
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ import type { GenerativeUILibrary } from "../types";
3
+ import { ALIGNS, JUSTIFIES } from "../ir";
4
+
5
+ export const layoutVocabulary = {
6
+ Card: {
7
+ description:
8
+ "A bordered container grouping related content. Optionally titled.",
9
+ properties: z.object({
10
+ title: z.string().optional().describe("Optional card title."),
11
+ padding: z
12
+ .number()
13
+ .optional()
14
+ .describe("Padding in 4px units (e.g. 2 = 8px)."),
15
+ background: z.string().optional().describe("Background token or color."),
16
+ }),
17
+ render: ({ title, padding, background, children }) => (
18
+ <section
19
+ data-aui="card"
20
+ data-aui-padding={padding}
21
+ data-aui-background={background}
22
+ >
23
+ {title ? <header data-aui="card-title">{title}</header> : null}
24
+ {children}
25
+ </section>
26
+ ),
27
+ },
28
+ Col: {
29
+ description: "A vertical stack; children laid out top to bottom.",
30
+ properties: z.object({
31
+ gap: z.number().optional().describe("Gap between children in 4px units."),
32
+ align: z.enum(ALIGNS).optional().describe("Cross-axis alignment."),
33
+ }),
34
+ render: ({ gap, align, children }) => (
35
+ <div data-aui="col" data-aui-gap={gap} data-aui-align={align}>
36
+ {children}
37
+ </div>
38
+ ),
39
+ },
40
+ Row: {
41
+ description: "A horizontal row; children laid out left to right.",
42
+ properties: z.object({
43
+ gap: z.number().optional().describe("Gap between children in 4px units."),
44
+ align: z.enum(ALIGNS).optional().describe("Cross-axis alignment."),
45
+ justify: z.enum(JUSTIFIES).optional().describe("Main-axis distribution."),
46
+ }),
47
+ render: ({ gap, align, justify, children }) => (
48
+ <div
49
+ data-aui="row"
50
+ data-aui-gap={gap}
51
+ data-aui-align={align}
52
+ data-aui-justify={justify}
53
+ >
54
+ {children}
55
+ </div>
56
+ ),
57
+ },
58
+ Spacer: {
59
+ description: "Empty space that pushes neighbors apart.",
60
+ properties: z.object({}),
61
+ render: () => <div data-aui="spacer" />,
62
+ },
63
+ Badge: {
64
+ description: "A small labeled tag, e.g. for status or category.",
65
+ properties: z.object({
66
+ value: z.string().describe("Badge text."),
67
+ variant: z.string().optional().describe("Visual variant token."),
68
+ }),
69
+ render: ({ value, variant, children }) => (
70
+ <span data-aui="badge" data-aui-variant={variant}>
71
+ {value}
72
+ {children}
73
+ </span>
74
+ ),
75
+ },
76
+ } satisfies GenerativeUILibrary;
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { renderGenerativeUI } from "../renderGenerativeUI";
4
+ import { mediaVocabulary } from "./media";
5
+
6
+ const render = (node: unknown) =>
7
+ renderToStaticMarkup(<>{renderGenerativeUI(node, mediaVocabulary)}</>);
8
+
9
+ describe("mediaVocabulary", () => {
10
+ it("Image renders an img with src, alt, and size hook", () => {
11
+ const html = render({
12
+ $type: "Image",
13
+ src: "/a.png",
14
+ alt: "alt",
15
+ size: "md",
16
+ });
17
+ expect(html).toContain(
18
+ '<img data-aui="image" data-aui-size="md" src="/a.png" alt="alt"/>',
19
+ );
20
+ });
21
+
22
+ it("Image renders a numeric size as px", () => {
23
+ const html = render({
24
+ $type: "Image",
25
+ src: "/a.png",
26
+ alt: "alt",
27
+ size: 128,
28
+ });
29
+ expect(html).toContain(
30
+ '<img data-aui="image" data-aui-size="128px" src="/a.png" alt="alt"/>',
31
+ );
32
+ });
33
+
34
+ it("Image `alt` is required by the schema (not optional)", () => {
35
+ const schema = mediaVocabulary.Image.properties as unknown as {
36
+ safeParse: (v: unknown) => { success: boolean };
37
+ };
38
+ expect(schema.safeParse({ src: "/a.png" }).success).toBe(false);
39
+ expect(schema.safeParse({ src: "/a.png", alt: "x" }).success).toBe(true);
40
+ });
41
+
42
+ it("Divider omits data-aui-flush when flush is false", () => {
43
+ expect(render({ $type: "Divider", flush: false })).toBe(
44
+ '<hr data-aui="divider"/>',
45
+ );
46
+ });
47
+
48
+ it("Divider renders an hr with a flush hook", () => {
49
+ expect(render({ $type: "Divider", flush: true })).toBe(
50
+ '<hr data-aui="divider" data-aui-flush="true"/>',
51
+ );
52
+ });
53
+ });
@@ -0,0 +1,37 @@
1
+ import { z } from "zod";
2
+ import type { GenerativeUILibrary } from "../types";
3
+ import { IMAGE_SIZE_TOKENS } from "../ir";
4
+
5
+ export const mediaVocabulary = {
6
+ Image: {
7
+ description: "An image. Provide `src` and `alt` for accessibility.",
8
+ properties: z.object({
9
+ src: z.string().describe("Image URL."),
10
+ alt: z.string().describe("Alt text for accessibility."),
11
+ size: z
12
+ .union([z.enum(IMAGE_SIZE_TOKENS), z.number()])
13
+ .optional()
14
+ .describe("Size token or pixel value."),
15
+ }),
16
+ render: ({ src, alt, size }) => (
17
+ <img
18
+ data-aui="image"
19
+ data-aui-size={typeof size === "number" ? `${size}px` : size}
20
+ src={src}
21
+ alt={alt}
22
+ />
23
+ ),
24
+ },
25
+ Divider: {
26
+ description: "A horizontal rule between sections.",
27
+ properties: z.object({
28
+ flush: z
29
+ .boolean()
30
+ .optional()
31
+ .describe("Whether the divider spans the full width with no inset."),
32
+ }),
33
+ render: ({ flush }) => (
34
+ <hr data-aui="divider" data-aui-flush={flush || undefined} />
35
+ ),
36
+ },
37
+ } satisfies GenerativeUILibrary;