mlola-ui 1.9.2 → 1.10.0
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.
- package/README.md +38 -1
- package/package.json +11 -2
- package/registry/agents.md +1 -0
- package/registry/contract.json +6 -0
- package/registry/examples.json +21 -0
- package/registry/index.json +133 -50
- package/registry/render-app.html +8480 -0
- package/registry/render-catalog.json +1650 -0
- package/registry/render-rules.json +1356 -0
- package/registry/render.d.ts +106 -0
- package/registry/render.js +989 -0
- package/registry/source/packages/components/render-surface/render-surface.tsx +488 -0
- package/registry/source-manifest.json +1 -0
- package/src/cli.js +21 -0
- package/src/knowledge.js +15 -0
- package/src/mcp.js +92 -9
- package/src/render-rules.d.ts +5 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
createRenderHost,
|
|
6
|
+
isBinding,
|
|
7
|
+
type RenderActionMessage,
|
|
8
|
+
type RenderErrorMessage,
|
|
9
|
+
type RenderHost,
|
|
10
|
+
type RenderInstance,
|
|
11
|
+
type RenderRules,
|
|
12
|
+
} from "@mlola-ui/behavior/render/core";
|
|
13
|
+
import catalogRules from "@mlola-ui/behavior/render/rules";
|
|
14
|
+
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../accordion/accordion";
|
|
15
|
+
import { Alert, AlertDescription, AlertTitle } from "../alert/alert";
|
|
16
|
+
import { Avatar } from "../avatar/avatar";
|
|
17
|
+
import { Badge } from "../badge/badge";
|
|
18
|
+
import { Button } from "../button/button";
|
|
19
|
+
import { Card, CardContent } from "../card/card";
|
|
20
|
+
import { Checkbox } from "../checkbox/checkbox";
|
|
21
|
+
import { DatePicker } from "../date-picker/date-picker";
|
|
22
|
+
import { EmptyState } from "../empty-state/empty-state";
|
|
23
|
+
import { Input } from "../input/input";
|
|
24
|
+
import { Progress } from "../progress/progress";
|
|
25
|
+
import { RadioGroup, RadioGroupItem } from "../radio-group/radio-group";
|
|
26
|
+
import { Select } from "../select/select";
|
|
27
|
+
import { Slider } from "../slider/slider";
|
|
28
|
+
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "../table/table";
|
|
29
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs/tabs";
|
|
30
|
+
import { Textarea } from "../textarea/textarea";
|
|
31
|
+
import { Switch } from "../toggle/toggle";
|
|
32
|
+
import { cx, Heading, type HeadingLevel } from "../_internal/react";
|
|
33
|
+
|
|
34
|
+
/** The words the surface itself says. Pass your own to translate them. */
|
|
35
|
+
export interface RenderSurfaceLabels {
|
|
36
|
+
/** Under a required field that kept an action back. */
|
|
37
|
+
required: string;
|
|
38
|
+
/** Announced when required fields kept an action back, with their labels. */
|
|
39
|
+
missing: (fields: string[]) => string;
|
|
40
|
+
/** A table cell that holds true or false. */
|
|
41
|
+
yes: string;
|
|
42
|
+
no: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LABELS: RenderSurfaceLabels = {
|
|
46
|
+
required: "Fill in this field.",
|
|
47
|
+
missing: (fields) => `Fill in ${fields.join(", ")} first.`,
|
|
48
|
+
yes: "Yes",
|
|
49
|
+
no: "No",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export interface RenderSurfaceProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onError"> {
|
|
53
|
+
/**
|
|
54
|
+
* A2UI messages for the Mlola Render catalog. Pass the same array with more
|
|
55
|
+
* messages at its end as they arrive; a different conversation is a new
|
|
56
|
+
* array, which starts the surface again.
|
|
57
|
+
*/
|
|
58
|
+
messages?: readonly unknown[];
|
|
59
|
+
/** More messages are on their way: a tree that arrives in pieces is drawn when they stop. */
|
|
60
|
+
streaming?: boolean;
|
|
61
|
+
/** A host you keep yourself (`createRenderHost`), in place of `messages`. */
|
|
62
|
+
host?: RenderHost;
|
|
63
|
+
/** The surface to draw. Every surface the messages open, oldest first, when left out. */
|
|
64
|
+
surfaceId?: string;
|
|
65
|
+
/** A Button was pressed: send the message to the agent. `extra.dataModel` comes with surfaces created with sendDataModel. */
|
|
66
|
+
onAction?: (message: RenderActionMessage, extra: { dataModel?: unknown }) => void;
|
|
67
|
+
/** Messages were refused: send these to the agent so it can correct itself. Nothing refused is drawn. */
|
|
68
|
+
onError?: (messages: RenderErrorMessage[]) => void;
|
|
69
|
+
/** "surface" draws in the theme the agent named; "inherit" keeps the theme of the page around it. */
|
|
70
|
+
theme?: "surface" | "inherit";
|
|
71
|
+
/** Shown while there is nothing to draw yet. */
|
|
72
|
+
fallback?: React.ReactNode;
|
|
73
|
+
labels?: Partial<RenderSurfaceLabels>;
|
|
74
|
+
/** The catalog to draw from. The Mlola Render catalog when left out. */
|
|
75
|
+
rules?: RenderRules;
|
|
76
|
+
/** Today's date for date fields, for server rendering. */
|
|
77
|
+
today?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Drawing {
|
|
81
|
+
host: RenderHost;
|
|
82
|
+
surfaceId: string;
|
|
83
|
+
/** Unique on the page, so two surfaces never share an id. */
|
|
84
|
+
base: string;
|
|
85
|
+
labels: RenderSurfaceLabels;
|
|
86
|
+
today?: string;
|
|
87
|
+
press: (componentId: string, scope: string) => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type Props<Component extends React.ElementType> = React.ComponentProps<Component>;
|
|
91
|
+
/** A value the check already held to the catalog, as the prop it is passed to. */
|
|
92
|
+
const as = <Value,>(value: unknown) => value as Value;
|
|
93
|
+
const text = (value: unknown) => (value === undefined || value === null ? "" : String(value));
|
|
94
|
+
const maybe = (value: unknown) => (value === undefined || value === null || value === "" ? undefined : String(value));
|
|
95
|
+
/** A DOM id for one instance: letters, digits, - and _ only, whatever the agent called it. */
|
|
96
|
+
const domId = (base: string, key: string) => `${base}-${Array.from(key, (character) => (/[A-Za-z0-9_-]/.test(character) ? character : `_${character.codePointAt(0)?.toString(16)}`)).join("")}`;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Draws the interface an agent composed. The agent describes a surface as
|
|
100
|
+
* A2UI messages from the Mlola Render catalog; every message is checked
|
|
101
|
+
* before anything shows, and what is drawn is made of Mlola components in
|
|
102
|
+
* the surface's theme. Fields write to the surface's data model, and a
|
|
103
|
+
* pressed Button answers with A2UI's action through `onAction`.
|
|
104
|
+
*/
|
|
105
|
+
export function RenderSurface({ messages, streaming = false, host: givenHost, surfaceId, onAction, onError, theme = "surface", fallback = null, labels, rules = catalogRules, today, className, ...props }: RenderSurfaceProps) {
|
|
106
|
+
// The first messages are drawn in the first render, so a surface rendered on the server arrives whole.
|
|
107
|
+
const [ownHost] = React.useState(() => {
|
|
108
|
+
const made = createRenderHost(rules);
|
|
109
|
+
if (!givenHost && messages?.length) made.receive(messages, { streaming });
|
|
110
|
+
return made;
|
|
111
|
+
});
|
|
112
|
+
const host = givenHost ?? ownHost;
|
|
113
|
+
// The host this component made answers to this component's props; one you passed keeps its own.
|
|
114
|
+
React.useEffect(() => {
|
|
115
|
+
ownHost.configure({ onAction, onError });
|
|
116
|
+
}, [ownHost, onAction, onError]);
|
|
117
|
+
|
|
118
|
+
// Messages are fed once each: what the array held last time is not read again.
|
|
119
|
+
const fed = React.useRef<{ first: unknown; count: number }>({ first: messages?.[0], count: givenHost ? 0 : (messages?.length ?? 0) });
|
|
120
|
+
React.useEffect(() => {
|
|
121
|
+
if (givenHost || !messages) return;
|
|
122
|
+
const last = fed.current;
|
|
123
|
+
if (messages.length < last.count || (last.count > 0 && messages[0] !== last.first)) {
|
|
124
|
+
host.reset();
|
|
125
|
+
last.count = 0;
|
|
126
|
+
}
|
|
127
|
+
const fresh = messages.slice(last.count);
|
|
128
|
+
fed.current = { first: messages[0], count: messages.length };
|
|
129
|
+
// An empty call still closes a stream: what waited for its pause is checked and drawn.
|
|
130
|
+
if (fresh.length || !streaming) host.receive(fresh, { streaming });
|
|
131
|
+
}, [givenHost, host, messages, streaming]);
|
|
132
|
+
|
|
133
|
+
React.useSyncExternalStore(host.subscribe, host.revision, host.revision);
|
|
134
|
+
|
|
135
|
+
const base = React.useId();
|
|
136
|
+
const words = React.useMemo(() => ({ ...LABELS, ...labels }), [labels]);
|
|
137
|
+
const [notice, setNotice] = React.useState("");
|
|
138
|
+
const press = (shown: string) => (componentId: string, scope: string) => {
|
|
139
|
+
const { missing } = host.press(shown, componentId, scope);
|
|
140
|
+
setNotice(missing.length ? words.missing(missing.map((field) => text(field.label))) : "");
|
|
141
|
+
if (!missing.length) return;
|
|
142
|
+
// The first field still to fill in takes focus: the control itself, or the first one in its group.
|
|
143
|
+
const first = document.getElementById(domId(`${base}-${shown}`, missing[0].key));
|
|
144
|
+
(first?.matches("input, textarea, button") ? first : first?.querySelector<HTMLElement>("input, textarea, button"))?.focus();
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const shown = surfaceId ? (host.surface(surfaceId) ? [surfaceId] : []) : host.surfaces();
|
|
148
|
+
return (
|
|
149
|
+
<div className={cx("ml-render", className)} aria-busy={streaming || undefined} {...props}>
|
|
150
|
+
{shown.length
|
|
151
|
+
? shown.map((id) => {
|
|
152
|
+
const surface = host.surface(id);
|
|
153
|
+
if (!surface) return null;
|
|
154
|
+
const drawing: Drawing = { host, surfaceId: id, base: `${base}-${id}`, labels: words, today, press: press(id) };
|
|
155
|
+
const themed = theme === "surface" ? surface.theme : null;
|
|
156
|
+
return (
|
|
157
|
+
// audit-allow: attr.reserved — the engine's own meaning: the surface is a region in the theme and mode its agent named
|
|
158
|
+
<div key={id} className="ml-render-surface" data-surface={id} data-theme={themed?.name} data-mode={themed?.mode} data-themed={themed ? "" : undefined}>
|
|
159
|
+
<Node drawing={drawing} instance={{ id: "root", scope: "", key: "root@" }} />
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
})
|
|
163
|
+
: fallback}
|
|
164
|
+
<p role="status" className="ml-visually-hidden">
|
|
165
|
+
{notice}
|
|
166
|
+
</p>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function Children({ drawing, id, scope }: { drawing: Drawing; id: string; scope: string }) {
|
|
172
|
+
return drawing.host.children(drawing.surfaceId, id, scope).map((child) => <Node key={child.key} drawing={drawing} instance={child} />);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** One instance of a component: a template's component is drawn once for each item, each in its own scope. */
|
|
176
|
+
function Node({ drawing, instance }: { drawing: Drawing; instance: RenderInstance }) {
|
|
177
|
+
const { host, surfaceId, labels } = drawing;
|
|
178
|
+
const { id, scope, key } = instance;
|
|
179
|
+
const node = host.component(surfaceId, id);
|
|
180
|
+
if (!node) return null;
|
|
181
|
+
const props = node.props;
|
|
182
|
+
const read = (value: unknown) => host.read(surfaceId, value, scope);
|
|
183
|
+
const fieldId = domId(drawing.base, key);
|
|
184
|
+
const inside = <Children drawing={drawing} id={id} scope={scope} />;
|
|
185
|
+
const accessible = as<{ label?: unknown; description?: unknown }>(props.accessibility ?? {});
|
|
186
|
+
const named = { "aria-label": maybe(read(accessible.label)), "aria-description": maybe(read(accessible.description)) };
|
|
187
|
+
// A container with a name of its own is a group; without one it is only layout.
|
|
188
|
+
const group = named["aria-label"] ? { role: "group", ...named } : {};
|
|
189
|
+
const error = host.missing(surfaceId, id, scope) ? labels.required : undefined;
|
|
190
|
+
const required = read(props.required) === true;
|
|
191
|
+
const disabled = read(props.disabled) === true;
|
|
192
|
+
/** A bound field shows the data model and writes to it; a literal one starts from its value and keeps its own state. */
|
|
193
|
+
const field = <Value, Literal = Value>(prop: unknown, fallback: Value, cast: (value: unknown) => Value) =>
|
|
194
|
+
isBinding(prop)
|
|
195
|
+
? { bound: true as const, value: cast(read(prop) ?? fallback), write: (value: Value | Literal) => void host.write(surfaceId, prop, value, scope) }
|
|
196
|
+
: { bound: false as const, value: cast(prop ?? fallback), write: () => {} };
|
|
197
|
+
|
|
198
|
+
switch (node.type) {
|
|
199
|
+
case "Stack":
|
|
200
|
+
return (
|
|
201
|
+
<div className="ml-stack" {...group}>
|
|
202
|
+
{inside}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
case "Row":
|
|
206
|
+
return (
|
|
207
|
+
<div className="ml-cluster" {...group}>
|
|
208
|
+
{inside}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
case "Grid":
|
|
212
|
+
return (
|
|
213
|
+
<div className="ml-grid" data-columns={as<string | undefined>(props.columns)} {...group}>
|
|
214
|
+
{inside}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
case "Heading":
|
|
218
|
+
return <Heading level={as<HeadingLevel>(props.level ?? 2)}>{text(read(props.text))}</Heading>;
|
|
219
|
+
case "Text":
|
|
220
|
+
return <p className="ml-render-text">{text(read(props.text))}</p>;
|
|
221
|
+
case "Card":
|
|
222
|
+
return (
|
|
223
|
+
<Card variant={as<Props<typeof Card>["variant"]>(props.variant)} {...group}>
|
|
224
|
+
<CardContent className="ml-stack">{inside}</CardContent>
|
|
225
|
+
</Card>
|
|
226
|
+
);
|
|
227
|
+
case "Alert":
|
|
228
|
+
return (
|
|
229
|
+
<Alert tone={as<Props<typeof Alert>["tone"]>(props.tone)} variant={as<Props<typeof Alert>["variant"]>(props.variant)}>
|
|
230
|
+
{props.title !== undefined ? <AlertTitle>{text(read(props.title))}</AlertTitle> : null}
|
|
231
|
+
<AlertDescription>{text(read(props.text))}</AlertDescription>
|
|
232
|
+
</Alert>
|
|
233
|
+
);
|
|
234
|
+
case "EmptyState":
|
|
235
|
+
return (
|
|
236
|
+
<EmptyState
|
|
237
|
+
title={text(read(props.title))}
|
|
238
|
+
description={maybe(read(props.description))}
|
|
239
|
+
size={as<Props<typeof EmptyState>["size"]>(props.size)}
|
|
240
|
+
headingLevel={host.headingLevel(surfaceId, id)}
|
|
241
|
+
actions={props.children !== undefined ? inside : undefined}
|
|
242
|
+
/>
|
|
243
|
+
);
|
|
244
|
+
case "Tabs": {
|
|
245
|
+
const tabs = host.children(surfaceId, id, scope);
|
|
246
|
+
if (!tabs.length) return null;
|
|
247
|
+
return (
|
|
248
|
+
<Tabs defaultValue={tabs[0].key} variant={as<Props<typeof Tabs>["variant"]>(props.variant)}>
|
|
249
|
+
<TabsList aria-label={text(read(props.label))}>
|
|
250
|
+
{tabs.map((tab) => (
|
|
251
|
+
<TabsTrigger key={tab.key} value={tab.key}>
|
|
252
|
+
{text(host.read(surfaceId, host.component(surfaceId, tab.id)?.props.label, tab.scope))}
|
|
253
|
+
</TabsTrigger>
|
|
254
|
+
))}
|
|
255
|
+
</TabsList>
|
|
256
|
+
{tabs.map((tab) => (
|
|
257
|
+
<TabsContent key={tab.key} value={tab.key} className="ml-stack">
|
|
258
|
+
<Children drawing={drawing} id={tab.id} scope={tab.scope} />
|
|
259
|
+
</TabsContent>
|
|
260
|
+
))}
|
|
261
|
+
</Tabs>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
case "Accordion": {
|
|
265
|
+
const sections = host.children(surfaceId, id, scope);
|
|
266
|
+
return (
|
|
267
|
+
<Accordion type={as<Props<typeof Accordion>["type"]>(props.type)} headingLevel={host.headingLevel(surfaceId, id)}>
|
|
268
|
+
{sections.map((section) => (
|
|
269
|
+
<AccordionItem key={section.key} value={section.key}>
|
|
270
|
+
<AccordionTrigger>{text(host.read(surfaceId, host.component(surfaceId, section.id)?.props.title, section.scope))}</AccordionTrigger>
|
|
271
|
+
<AccordionContent>
|
|
272
|
+
<div className="ml-stack">
|
|
273
|
+
<Children drawing={drawing} id={section.id} scope={section.scope} />
|
|
274
|
+
</div>
|
|
275
|
+
</AccordionContent>
|
|
276
|
+
</AccordionItem>
|
|
277
|
+
))}
|
|
278
|
+
</Accordion>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
// A Tab or an AccordionItem is drawn by its container, which the check holds it to.
|
|
282
|
+
case "Tab":
|
|
283
|
+
case "AccordionItem":
|
|
284
|
+
return null;
|
|
285
|
+
case "Input": {
|
|
286
|
+
const value = field(props.value, "", text);
|
|
287
|
+
return (
|
|
288
|
+
<Input
|
|
289
|
+
key={value.bound ? undefined : value.value}
|
|
290
|
+
id={fieldId}
|
|
291
|
+
label={text(read(props.label))}
|
|
292
|
+
type={as<string | undefined>(props.type)}
|
|
293
|
+
placeholder={maybe(read(props.placeholder))}
|
|
294
|
+
hint={maybe(read(props.hint))}
|
|
295
|
+
error={error}
|
|
296
|
+
required={required}
|
|
297
|
+
disabled={disabled}
|
|
298
|
+
size={as<Props<typeof Input>["size"]>(props.size)}
|
|
299
|
+
variant={as<Props<typeof Input>["variant"]>(props.variant)}
|
|
300
|
+
maxLength={host.rules.limits.textLength}
|
|
301
|
+
{...(value.bound ? { value: value.value, onChange: (event: React.ChangeEvent<HTMLInputElement>) => value.write(event.target.value) } : { defaultValue: value.value })}
|
|
302
|
+
/>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
case "Textarea": {
|
|
306
|
+
const value = field(props.value, "", text);
|
|
307
|
+
return (
|
|
308
|
+
<Textarea
|
|
309
|
+
key={value.bound ? undefined : value.value}
|
|
310
|
+
id={fieldId}
|
|
311
|
+
label={text(read(props.label))}
|
|
312
|
+
placeholder={maybe(read(props.placeholder))}
|
|
313
|
+
hint={maybe(read(props.hint))}
|
|
314
|
+
error={error}
|
|
315
|
+
rows={as<number>(props.rows ?? 4)}
|
|
316
|
+
required={required}
|
|
317
|
+
disabled={disabled}
|
|
318
|
+
maxLength={host.rules.limits.textLength}
|
|
319
|
+
{...(value.bound ? { value: value.value, onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => value.write(event.target.value) } : { defaultValue: value.value })}
|
|
320
|
+
/>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
case "Select": {
|
|
324
|
+
const value = field(props.value, "", text);
|
|
325
|
+
return (
|
|
326
|
+
<Select
|
|
327
|
+
key={value.bound ? undefined : value.value}
|
|
328
|
+
id={fieldId}
|
|
329
|
+
label={text(read(props.label))}
|
|
330
|
+
options={options(read(props.options))}
|
|
331
|
+
placeholder={maybe(read(props.placeholder))}
|
|
332
|
+
error={error}
|
|
333
|
+
required={required}
|
|
334
|
+
disabled={disabled}
|
|
335
|
+
size={as<Props<typeof Select>["size"]>(props.size)}
|
|
336
|
+
{...(value.bound ? { value: value.value, onValueChange: value.write } : { defaultValue: value.value })}
|
|
337
|
+
/>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
case "RadioGroup": {
|
|
341
|
+
const value = field(props.value, "", text);
|
|
342
|
+
return (
|
|
343
|
+
<RadioGroup
|
|
344
|
+
key={value.bound ? undefined : value.value}
|
|
345
|
+
id={fieldId}
|
|
346
|
+
label={text(read(props.label))}
|
|
347
|
+
orientation={as<Props<typeof RadioGroup>["orientation"]>(props.orientation)}
|
|
348
|
+
error={error}
|
|
349
|
+
disabled={disabled}
|
|
350
|
+
aria-required={required || undefined}
|
|
351
|
+
{...(value.bound ? { value: value.value, onValueChange: value.write } : { defaultValue: value.value })}
|
|
352
|
+
>
|
|
353
|
+
{options(read(props.options)).map((option) => (
|
|
354
|
+
<RadioGroupItem key={option.value} value={option.value} label={option.label} />
|
|
355
|
+
))}
|
|
356
|
+
</RadioGroup>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
case "Checkbox": {
|
|
360
|
+
const checked = field(props.checked, false, (value) => value === true);
|
|
361
|
+
return (
|
|
362
|
+
<Checkbox
|
|
363
|
+
key={checked.bound ? undefined : String(checked.value)}
|
|
364
|
+
id={fieldId}
|
|
365
|
+
label={text(read(props.label))}
|
|
366
|
+
description={maybe(read(props.description))}
|
|
367
|
+
error={error}
|
|
368
|
+
required={required}
|
|
369
|
+
disabled={disabled}
|
|
370
|
+
{...(checked.bound ? { checked: checked.value, onCheckedChange: checked.write } : { defaultChecked: checked.value })}
|
|
371
|
+
/>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
case "Switch": {
|
|
375
|
+
const checked = field(props.checked, false, (value) => value === true);
|
|
376
|
+
return (
|
|
377
|
+
<Switch
|
|
378
|
+
key={checked.bound ? undefined : String(checked.value)}
|
|
379
|
+
id={fieldId}
|
|
380
|
+
label={text(read(props.label))}
|
|
381
|
+
disabled={disabled}
|
|
382
|
+
size={as<Props<typeof Switch>["size"]>(props.size)}
|
|
383
|
+
{...(checked.bound ? { checked: checked.value, onCheckedChange: checked.write } : { defaultChecked: checked.value })}
|
|
384
|
+
/>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
case "Slider": {
|
|
388
|
+
const min = as<number>(props.min ?? 0);
|
|
389
|
+
const value = field(props.value, min, (entry) => (typeof entry === "number" && Number.isFinite(entry) ? entry : min));
|
|
390
|
+
return (
|
|
391
|
+
<Slider
|
|
392
|
+
key={value.bound ? undefined : value.value}
|
|
393
|
+
id={fieldId}
|
|
394
|
+
label={text(read(props.label))}
|
|
395
|
+
min={min}
|
|
396
|
+
max={as<number>(props.max ?? 100)}
|
|
397
|
+
step={as<number>(props.step ?? 1)}
|
|
398
|
+
disabled={disabled}
|
|
399
|
+
size={as<Props<typeof Slider>["size"]>(props.size)}
|
|
400
|
+
showValue
|
|
401
|
+
{...(value.bound ? { value: value.value, onValueChange: value.write } : { defaultValue: value.value })}
|
|
402
|
+
/>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
case "DatePicker": {
|
|
406
|
+
const value = field<string | null>(props.value, null, (entry) => (typeof entry === "string" && entry ? entry : null));
|
|
407
|
+
return (
|
|
408
|
+
<DatePicker
|
|
409
|
+
key={value.bound ? undefined : text(value.value)}
|
|
410
|
+
id={fieldId}
|
|
411
|
+
label={text(read(props.label))}
|
|
412
|
+
hint={maybe(read(props.hint))}
|
|
413
|
+
error={error}
|
|
414
|
+
min={as<string | undefined>(props.min)}
|
|
415
|
+
max={as<string | undefined>(props.max)}
|
|
416
|
+
required={required}
|
|
417
|
+
disabled={disabled}
|
|
418
|
+
today={drawing.today}
|
|
419
|
+
{...(value.bound ? { value: value.value, onValueChange: value.write } : { defaultValue: value.value })}
|
|
420
|
+
/>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
case "Button":
|
|
424
|
+
return (
|
|
425
|
+
<Button id={fieldId} variant={as<Props<typeof Button>["variant"]>(props.variant)} size={as<Props<typeof Button>["size"]>(props.size)} width={as<Props<typeof Button>["width"]>(props.width)} disabled={disabled} onClick={() => drawing.press(id, scope)} {...named}>
|
|
426
|
+
{text(read(props.text))}
|
|
427
|
+
</Button>
|
|
428
|
+
);
|
|
429
|
+
case "Badge":
|
|
430
|
+
return (
|
|
431
|
+
<Badge tone={as<Props<typeof Badge>["tone"]>(props.tone)} variant={as<Props<typeof Badge>["variant"]>(props.variant)} size={as<Props<typeof Badge>["size"]>(props.size)}>
|
|
432
|
+
{text(read(props.text))}
|
|
433
|
+
</Badge>
|
|
434
|
+
);
|
|
435
|
+
case "Progress": {
|
|
436
|
+
const value = read(props.value);
|
|
437
|
+
return <Progress label={text(read(props.label))} value={typeof value === "number" ? value : 0} max={as<number>(props.max ?? 100)} showLabel={read(props.showLabel) === true} tone={as<Props<typeof Progress>["tone"]>(props.tone)} size={as<Props<typeof Progress>["size"]>(props.size)} />;
|
|
438
|
+
}
|
|
439
|
+
case "Avatar":
|
|
440
|
+
return <Avatar name={text(read(props.name))} size={as<Props<typeof Avatar>["size"]>(props.size)} status={as<Props<typeof Avatar>["status"]>(props.status)} />;
|
|
441
|
+
case "Table": {
|
|
442
|
+
const columns = (Array.isArray(props.columns) ? props.columns : []) as Array<{ key: string; label: unknown; align?: "left" | "center" | "right" }>;
|
|
443
|
+
const rows = read(props.rows);
|
|
444
|
+
const caption = text(read(props.caption));
|
|
445
|
+
return (
|
|
446
|
+
<Table size={as<Props<typeof Table>["size"]>(props.size)} aria-label={caption}>
|
|
447
|
+
<TableCaption>{caption}</TableCaption>
|
|
448
|
+
<TableHeader>
|
|
449
|
+
<TableRow>
|
|
450
|
+
{columns.map((column) => (
|
|
451
|
+
<TableHead key={column.key} align={column.align}>
|
|
452
|
+
{text(read(column.label))}
|
|
453
|
+
</TableHead>
|
|
454
|
+
))}
|
|
455
|
+
</TableRow>
|
|
456
|
+
</TableHeader>
|
|
457
|
+
<TableBody>
|
|
458
|
+
{(Array.isArray(rows) ? rows.slice(0, host.rules.limits.rows) : []).map((row, index) => (
|
|
459
|
+
<TableRow key={index}>
|
|
460
|
+
{columns.map((column) => (
|
|
461
|
+
<TableCell key={column.key} align={column.align}>
|
|
462
|
+
{cell(row, column.key, labels)}
|
|
463
|
+
</TableCell>
|
|
464
|
+
))}
|
|
465
|
+
</TableRow>
|
|
466
|
+
))}
|
|
467
|
+
</TableBody>
|
|
468
|
+
</Table>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
default:
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Options as the data model holds them: only the well-formed ones are offered. */
|
|
477
|
+
function options(value: unknown) {
|
|
478
|
+
if (!Array.isArray(value)) return [];
|
|
479
|
+
return value.filter((option): option is { label: string; value: string } => typeof option?.label === "string" && typeof option?.value === "string" && option.value !== "");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** A cell's words: text and numbers as they are, true and false in words, nothing as a dash. */
|
|
483
|
+
function cell(row: unknown, key: string, labels: RenderSurfaceLabels) {
|
|
484
|
+
const value = row !== null && typeof row === "object" && Object.hasOwn(row, key) ? (row as Record<string, unknown>)[key] : undefined;
|
|
485
|
+
if (typeof value === "boolean") return value ? labels.yes : labels.no;
|
|
486
|
+
if (typeof value === "string" || typeof value === "number") return String(value);
|
|
487
|
+
return "—";
|
|
488
|
+
}
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"packages/components/progress/progress.tsx": "sha256-oDxj1Ccl1pKV4+e6A7aaJL1FThW8qs9r4Gb0hEmExeM=",
|
|
49
49
|
"packages/components/radio-group/radio-group.tsx": "sha256-jhnSEBLkdpe8dv5us/Q1sMsQF4sKGRgDXWGKm2JSFew=",
|
|
50
50
|
"packages/components/rating/rating.tsx": "sha256-prhfNzlCqLRWqURtq0MrX6MLdtuDpIOf5fA1Ie26L54=",
|
|
51
|
+
"packages/components/render-surface/render-surface.tsx": "sha256-3L4CcYBHjwb1QyKGCygC4NEPL4TajSZiNcMzy4Uf0IM=",
|
|
51
52
|
"packages/components/resizable/resizable.tsx": "sha256-RFBpg/a2O82mmx+O5wT+um+y3y2w0rUMJJdBzRu9ONU=",
|
|
52
53
|
"packages/components/segmented-control/segmented-control.tsx": "sha256-MCBG5XHFWCwbACctsYKbqe8mvSSR6pf60tyRgdCDKDA=",
|
|
53
54
|
"packages/components/select/select.tsx": "sha256-7u1sshW6KAQgjgwQ1siN1P5GkBxboOiOJOdgLanForE=",
|
package/src/cli.js
CHANGED
|
@@ -15,6 +15,8 @@ import { collectEngineDependencies, installItems } from "./installer.js";
|
|
|
15
15
|
import { loadCatalogNames, loadRegistry, resolveItems } from "./registry.js";
|
|
16
16
|
import { catalogStylesheets, clearCredentials, fetchProItems, GUIDE_FILENAME, hostFrom, readCredentials, saveCredentials, verifyToken, writeProGuide, writeProStyles } from "./pro.js";
|
|
17
17
|
import { serveMcp } from "./mcp.js";
|
|
18
|
+
import { renderCatalog, renderRules } from "./knowledge.js";
|
|
19
|
+
import { formatRenderReport, renderCatalogGuide, validateRender } from "../registry/render.js";
|
|
18
20
|
import { pullTheme } from "./theme-pull.js";
|
|
19
21
|
import { buildTheme } from "./theme-build.js";
|
|
20
22
|
|
|
@@ -34,6 +36,8 @@ Usage:
|
|
|
34
36
|
mlola-ui migrate [path...] [--write] [--json]
|
|
35
37
|
mlola-ui theme pull <theme-id | url> [--host <url>] [--overwrite]
|
|
36
38
|
mlola-ui theme build [--file mlola.theme.json]
|
|
39
|
+
mlola-ui render check <file | -> [--json]
|
|
40
|
+
mlola-ui render catalog [--schema]
|
|
37
41
|
|
|
38
42
|
Examples:
|
|
39
43
|
npx mlola-ui init (also tells your coding agents about Mlola)
|
|
@@ -47,6 +51,7 @@ Examples:
|
|
|
47
51
|
npx mlola-ui mcp (an MCP server for Claude Code, Cursor, Codex…)
|
|
48
52
|
npx mlola-ui check src (colors and spacing typed by hand, classes that do not exist)
|
|
49
53
|
npx mlola-ui migrate (from shadcn/ui: what changes, then --write to make it)
|
|
54
|
+
npx mlola-ui render check ui.json (A2UI messages an agent composed, before they render)
|
|
50
55
|
`;
|
|
51
56
|
|
|
52
57
|
const CLI_VERSION = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
@@ -360,6 +365,22 @@ export async function run(argv, options = {}) {
|
|
|
360
365
|
return report.errors || (hasFlag(args, "--strict") && report.warnings) ? 1 : 0;
|
|
361
366
|
}
|
|
362
367
|
|
|
368
|
+
if (command === "render") {
|
|
369
|
+
const [subcommand, file] = args.filter((argument) => !argument.startsWith("--"));
|
|
370
|
+
const rules = renderRules();
|
|
371
|
+
if (!rules) throw new Error("This build of mlola-ui carries no Mlola Render catalog.");
|
|
372
|
+
if (subcommand === "catalog") {
|
|
373
|
+
output.log(hasFlag(args, "--schema") ? JSON.stringify(renderCatalog(), null, 2) : renderCatalogGuide(rules));
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
if (subcommand !== "check") throw new Error(`Unknown render command "${subcommand ?? ""}". Try: mlola-ui render check <file>, or mlola-ui render catalog`);
|
|
377
|
+
if (!file) throw new Error("Name the file of A2UI messages to check, or - to read them from stdin: mlola-ui render check ui.json");
|
|
378
|
+
const source = file === "-" ? (options.stdin ?? fs.readFileSync(0, "utf8")) : fs.readFileSync(path.resolve(cwd, file), "utf8");
|
|
379
|
+
const result = validateRender(source, rules);
|
|
380
|
+
output.log(hasFlag(args, "--json") ? JSON.stringify(result, null, 2) : formatRenderReport(result));
|
|
381
|
+
return result.valid ? 0 : 1;
|
|
382
|
+
}
|
|
383
|
+
|
|
363
384
|
if (command === "migrate") {
|
|
364
385
|
const targets = args.filter((argument) => !argument.startsWith("--"));
|
|
365
386
|
const result = migrateProject(cwd, { targets: targets.length ? targets : ["."], write: hasFlag(args, "--write") });
|
package/src/knowledge.js
CHANGED
|
@@ -173,6 +173,21 @@ export function designGuide() {
|
|
|
173
173
|
return bundled("agents.md");
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/** Mlola Render's catalog: the components an agent may compose at runtime, and the rules its messages are checked against. */
|
|
177
|
+
export function renderRules() {
|
|
178
|
+
return bundled("render-rules.json");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Mlola Render's MCP App: one HTML page that draws what render_ui is called with (scripts/build-render-app.mjs). */
|
|
182
|
+
export function renderApp() {
|
|
183
|
+
return bundled("render-app.html");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The same catalog as an A2UI catalog (JSON Schema). */
|
|
187
|
+
export function renderCatalog() {
|
|
188
|
+
return bundled("render-catalog.json");
|
|
189
|
+
}
|
|
190
|
+
|
|
176
191
|
export function tokens(group) {
|
|
177
192
|
const data = designData();
|
|
178
193
|
if (!data) return [];
|