();
+const UNKNOWN_ICON_NAME = "circle";
export type IconWrapperProps = {
name: string;
@@ -12,7 +13,8 @@ export type IconWrapperProps = {
className?: string;
};
-export const IconWrapper = ({ name, category, ...props }: IconWrapperProps) => {
+// `category` is accepted for wire compatibility but no longer picks a fallback.
+export const IconWrapper = ({ name, category: _category, ...props }: IconWrapperProps) => {
const [IconComponent, setIconComponent] = useState(
() => cachedIcons.get(name) || null,
);
@@ -52,7 +54,8 @@ export const IconWrapper = ({ name, category, ...props }: IconWrapperProps) => {
const loaded = await loadIcon(name);
if (!loaded && !cancelled) {
- const fallbackName = getFallbackIconName(category);
+ // An unknown name renders a plain circle, never a guessed topical icon.
+ const fallbackName = UNKNOWN_ICON_NAME;
if (cachedIcons.has(fallbackName)) {
setIconComponent(cachedIcons.get(fallbackName) as LucideIcon);
} else {
@@ -65,7 +68,7 @@ export const IconWrapper = ({ name, category, ...props }: IconWrapperProps) => {
return () => {
cancelled = true;
};
- }, [name, category]);
+ }, [name]);
if (!IconComponent) {
return null;
diff --git a/packages/react-ui/src/components/_shared/icons/schema.ts b/packages/react-ui/src/components/_shared/icons/schema.ts
index 167546b2e7..cbd7bcdcb2 100644
--- a/packages/react-ui/src/components/_shared/icons/schema.ts
+++ b/packages/react-ui/src/components/_shared/icons/schema.ts
@@ -17,7 +17,7 @@ export const iconPropsSchema = z.object({
.string()
.optional()
.describe(
- "Optional icon category (e.g. 'finance', 'charts', 'security', 'travel', 'people', 'time') used to pick a topical fallback icon when `name` doesn't resolve.",
+ "Optional icon category (e.g. 'finance', 'travel'). Accepted for compatibility; it does not change the rendered icon.",
),
});
diff --git a/packages/react-ui/src/components/_shared/remark/remarkHighlight.ts b/packages/react-ui/src/components/_shared/remark/remarkHighlight.ts
new file mode 100644
index 0000000000..2afe1266ab
--- /dev/null
+++ b/packages/react-ui/src/components/_shared/remark/remarkHighlight.ts
@@ -0,0 +1,58 @@
+/**
+ * # remarkHighlight
+ *
+ * Remark plugin that turns `==text==` into a `` highlight.
+ *
+ * Only plain text nodes are scanned, so `a == b` inside inline code or code
+ * blocks is never touched. The markers must hug the text (`==key phrase==`),
+ * which keeps prose like "x == y" literal. While streaming, an unclosed `==`
+ * shows as plain text until its closing pair arrives, just like `**bold**`.
+ *
+ * **Example:**
+ * Input: "You can now ==swim in the Seine==." → Output: [Text, Mark, Text]
+ */
+
+import { visit } from "unist-util-visit";
+
+const highlightRegex = /==(?=\S)([^=\n]*?\S)==/g;
+
+export const remarkHighlight = () => {
+ return (tree: any) => {
+ visit(tree, "text", (node, index, parent) => {
+ if (typeof node.value !== "string" || !node.value.includes("==")) {
+ return;
+ }
+
+ const parts = [];
+ let lastIndex = 0;
+ let match;
+
+ while ((match = highlightRegex.exec(node.value)) !== null) {
+ if (match.index > lastIndex) {
+ parts.push({ type: "text", value: node.value.slice(lastIndex, match.index) });
+ }
+ parts.push({
+ type: "highlight",
+ data: { hName: "mark" },
+ children: [{ type: "text", value: match[1] }],
+ });
+ lastIndex = match.index + match[0].length;
+ }
+ highlightRegex.lastIndex = 0;
+
+ if (parts.length === 0) {
+ return undefined;
+ }
+ if (lastIndex < node.value.length) {
+ parts.push({ type: "text", value: node.value.slice(lastIndex) });
+ }
+
+ if (parent && typeof index === "number") {
+ parent.children.splice(index, 1, ...parts);
+ return index + parts.length;
+ }
+
+ return undefined;
+ });
+ };
+};
diff --git a/packages/react-ui/src/components/_shared/shared.scss b/packages/react-ui/src/components/_shared/shared.scss
index 657ab7e69b..ad712635c3 100644
--- a/packages/react-ui/src/components/_shared/shared.scss
+++ b/packages/react-ui/src/components/_shared/shared.scss
@@ -1,6 +1,7 @@
@use "../../cssUtils" as cssUtils;
@use "./cards/smallCardBlock";
@use "./cards/mediumCardBlock";
+@use "./disclosureSection";
// ── Keyframes shared by chat primitives ──
@keyframes openui-spin {
diff --git a/packages/react-ui/src/components/index.scss b/packages/react-ui/src/components/index.scss
index c8986403fc..9444486e38 100644
--- a/packages/react-ui/src/components/index.scss
+++ b/packages/react-ui/src/components/index.scss
@@ -7,6 +7,9 @@
@forward "./Accordion/accordion.scss";
@forward "./AgentInterface/agentInterface.scss";
+@forward "./Avatar/avatar.scss";
+@forward "./AvatarStack/avatarStack.scss";
+@forward "./Bleed/bleed.scss";
@forward "./Button/button.scss";
@forward "./Buttons/buttons.scss";
@forward "./Calendar/calendar.scss";
@@ -25,10 +28,14 @@
@forward "./DatePicker/datePicker.scss";
@forward "./DotMatrixLoader/dotMatrixLoader.scss";
@forward "./EditableTable/editableTable.scss";
+@forward "./Emoji/emoji.scss";
@forward "./EntityList/entityList.scss";
+@forward "./EntityTitle/entityTitle.scss";
@forward "./FollowUpBlock/followUpBlock.scss";
@forward "./FollowUpItem/followUpItem.scss";
@forward "./FormControl/formControl.scss";
+@forward "./Grid/grid.scss";
+@forward "./Heading/heading.scss";
@forward "./IconButton/iconButton.scss";
@forward "./IconTag/iconTag.scss";
@forward "./IconText/iconText.scss";
@@ -37,13 +44,17 @@
@forward "./ImageGallery/imageGallery.scss";
@forward "./ImageText/imageText.scss";
@forward "./ImageTextLarge/imageTextLarge.scss";
+@forward "./ImageTitle/imageTitle.scss";
+@forward "./InlineGroup/inlineGroup.scss";
@forward "./InlineHeader/inlineHeader.scss";
@forward "./InlineMarkdownRenderer/inlineMarkdownRenderer.scss";
@forward "./Input/input.scss";
@forward "./Label/label.scss";
+@forward "./Link/link.scss";
@forward "./ListBlock/listBlock.scss";
@forward "./ListItem/listItem.scss";
@forward "./MarkDownRenderer/markDownRenderer.scss";
+@forward "./Metric/metric.scss";
@forward "./MetricIndicator/metricIndicator.scss";
@forward "./Modal/Modal.scss";
@forward "./ModelSwitcher/modelSwitcher.scss";
@@ -52,12 +63,14 @@
@forward "./OverviewCardBlock/overviewCardBlock.scss";
@forward "./RadioGroup/radioGroup.scss";
@forward "./RadioItem/radioItem.scss";
+@forward "./Section/section.scss";
@forward "./SectionBlock/sectionBlock.scss";
@forward "./Select/select.scss";
@forward "./Separator/separator.scss";
+@forward "./Shape/shape.scss";
+@forward "./Shell/shell.scss";
@forward "./Skeleton/skeleton.scss";
@forward "./Slider/slider.scss";
-@forward "./SnippetCardBlock/snippetCardBlock.scss";
@forward "./SourceFaviconImage/sourceFaviconImage.scss";
@forward "./Sources/sources.scss";
@forward "./Steps/steps.scss";
@@ -72,10 +85,14 @@
@forward "./TextCallout/textCallout.scss";
@forward "./TextContent/textContent.scss";
@forward "./TextContentWrapper/textContentWrapper.scss";
+@forward "./TextWithIcon/textWithIcon.scss";
+@forward "./Timeline/timeline.scss";
@forward "./ToggleGroup/toggleGroup.scss";
@forward "./ToggleItem/toggleItem.scss";
@forward "./ToolCall/toolCall.scss";
@forward "./ToolResult/toolResult.scss";
@forward "./TooltipWrapper/tooltipWrapper.scss";
+@forward "./ValueCardBlock/valueCardBlock.scss";
+@forward "./ValueLayout/valueLayout.scss";
@forward "./VisualCardBlock/visualCardBlock.scss";
@forward "./_shared/shared.scss";
diff --git a/packages/react-ui/src/cssUtils.scss b/packages/react-ui/src/cssUtils.scss
index 0be8b67484..af70091cfe 100644
--- a/packages/react-ui/src/cssUtils.scss
+++ b/packages/react-ui/src/cssUtils.scss
@@ -150,6 +150,7 @@ $font-weight-bold: var(--openui-font-weight-bold);
$font-weight-heavy: var(--openui-font-weight-heavy);
$font-variation-regular: var(--openui-font-variation-regular);
$font-variation-medium: var(--openui-font-variation-medium);
+$font-variation-bold: var(--openui-font-variation-bold);
$line-height-body: var(--openui-line-height-body);
$line-height-heading: var(--openui-line-height-heading);
$line-height-heading-large: var(--openui-line-height-heading-large);
@@ -246,8 +247,8 @@ $shadow-3xl: var(--openui-shadow-3xl);
$chat-container-bg: $background;
// $variation picks the Inter "wght" axis token: regular or medium for the
-// theme's Regular/Medium styles, null for fixed weights (600/700, code), where
-// the font shorthand's reset leaves font-weight in charge.
+// theme's Regular/Medium styles, bold for headings, null for code and other
+// fixed weights, where the font shorthand's reset leaves font-weight in charge.
@function typography($font-name, $variation: null) {
$axis: null;
@if $variation {
@@ -286,11 +287,11 @@ $typography: (
2-extra-small-heavy: typography(openui-text-label-xs-heavy, medium),
),
heading: (
- xl: typography(openui-text-heading-xl),
- large: typography(openui-text-heading-lg),
- medium: typography(openui-text-heading-md),
- small: typography(openui-text-heading-sm),
- extra-small: typography(openui-text-heading-xs),
+ xl: typography(openui-text-heading-xl, bold),
+ large: typography(openui-text-heading-lg, bold),
+ medium: typography(openui-text-heading-md, bold),
+ small: typography(openui-text-heading-sm, bold),
+ extra-small: typography(openui-text-heading-xs, bold),
),
number: (
large: typography(openui-text-numbers-lg, regular),
diff --git a/packages/react-ui/src/genui-lib/Accordion/index.tsx b/packages/react-ui/src/genui-lib/Accordion/index.tsx
index 12a95d3cf2..9a5a323cb9 100644
--- a/packages/react-ui/src/genui-lib/Accordion/index.tsx
+++ b/packages/react-ui/src/genui-lib/Accordion/index.tsx
@@ -9,6 +9,7 @@ import {
AccordionItem as OpenUIAccordionItem,
AccordionTrigger as OpenUIAccordionTrigger,
} from "../../components/Accordion";
+import { renderIconSlot } from "../iconSlot";
import { AccordionItemSchema } from "./schema";
export { AccordionItemSchema } from "./schema";
@@ -16,12 +17,18 @@ export { AccordionItemSchema } from "./schema";
export const AccordionItem = defineComponent({
name: "AccordionItem",
props: AccordionItemSchema,
- description: "value is unique id, trigger is section title",
+ description:
+ 'One collapsible item: AccordionItem(value, trigger, content, leading?), e.g. AccordionItem("refunds", "Can I get a refund?", [Markdown("Yes, within 30 days of purchase.")], Icon("receipt", "finance")). value is a unique id; trigger is the title the reader clicks, a short question or topic; content is what opens. leading is optional: an Icon for a topic, an inline Avatar (with an icon) for the person who owns that item, or an Emoji only in rare expressive content. Give every item a leading or none of them.',
component: () => null,
});
type AccordionRenderProps = ComponentRenderProps<{
- items: SubComponentOf<{ value: string; trigger: string; content: unknown[] }>[];
+ items: SubComponentOf<{
+ value: string;
+ trigger: string;
+ content: unknown[];
+ leading?: unknown;
+ }>[];
}>;
/** Shared renderer — also used by the chat library's Accordion variant (wider content union). */
@@ -49,7 +56,10 @@ export const AccordionRenderer = ({ props, renderNode }: AccordionRenderProps) =
{items.map((item) => (
-
+ {renderNode(item.props.content)}
))}
@@ -62,6 +72,7 @@ export const Accordion = defineComponent({
props: z.object({
items: z.array(AccordionItem.ref),
}),
- description: "Collapsible sections",
+ description:
+ "Collapsible items for supplementary detail the reader opens on demand, most often an FAQ: Accordion([item1, item2, item3]). Keep it to 3–5 items and place it last in its Section or Card; never hide primary information in it. For topics that are equally prominent parts of a long report use SectionBlock, and for alternative views use Tabs.",
component: AccordionRenderer,
});
diff --git a/packages/react-ui/src/genui-lib/Accordion/schema.ts b/packages/react-ui/src/genui-lib/Accordion/schema.ts
index 42d1f4a0ef..add112be47 100644
--- a/packages/react-ui/src/genui-lib/Accordion/schema.ts
+++ b/packages/react-ui/src/genui-lib/Accordion/schema.ts
@@ -1,8 +1,12 @@
import { z } from "zod/v4";
+import { Avatar } from "../Avatar";
+import { Emoji } from "../Emoji";
+import { Icon } from "../Icon";
import { ContentChildUnion } from "../unions";
export const AccordionItemSchema = z.object({
value: z.string(),
trigger: z.string(),
content: z.array(ContentChildUnion),
+ leading: z.union([Icon.ref, Avatar.ref, Emoji.ref]).optional(),
});
diff --git a/packages/react-ui/src/genui-lib/Avatar/index.tsx b/packages/react-ui/src/genui-lib/Avatar/index.tsx
new file mode 100644
index 0000000000..d5a23a6943
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Avatar/index.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Avatar as OpenUIAvatar } from "../../components/Avatar";
+import { AvatarSchema, type AvatarProps } from "./schema";
+
+export * from "./schema";
+
+function AvatarRenderer({ props, renderNode }: ComponentRenderProps) {
+ return (
+
+ );
+}
+
+export const Avatar = defineComponent({
+ name: "Avatar",
+ props: AvatarSchema,
+ description:
+ 'A small real image that can stand in for an Icon: a person\'s photo or a company logo. Avatar(name, src?, shape?, domain?, size?, icon?, color?). People: Avatar("Ada Lovelace", "https://…/ada.jpg", null, null, null, Icon("user", "people")) is a circle (default). Companies: Avatar("Stripe", null, "rounded", "stripe.com", null, Icon("building-2", "business")) shows the site\'s logo from its domain. ALWAYS pass an icon, even with src or domain: it renders when the image is missing or broken (without one a generic placeholder shows). Without an image, an Icon inside and a color tint make a badge: Avatar("Billing", null, "rounded", null, "md", Icon("credit-card", "finance"), "info"); color "neutral" (default) | "info" | "success" | "warning" | "danger" | "purple" | "pink" | "inverted". src must be a real URL from a tool result or the user; never invent one, pass the domain or an Icon instead. Pass only a domain you know is that company\'s own site. Which to use: a company is an Avatar with its domain; a person is an Avatar with src only when you have a real photo URL for them, otherwise Avatar("Ada Lovelace", null, null, null, null, Icon("user", "people")) with no src; a concept shown as a standalone tinted badge (size md or lg, e.g. leading a row or card) is an Avatar with icon and color; an icon on a text line (LeadingText, Tag, Chips) for a concept is a plain Icon. Pick color by meaning like Tag variants (success positive, danger negative, warning caution, info informational, neutral otherwise); use purple and pink only to tell categories apart. size "inline" (default, sits on a text line like an Icon) | "md" 32px | "lg" 48px for standalone use.',
+ component: AvatarRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/Avatar/schema.ts b/packages/react-ui/src/genui-lib/Avatar/schema.ts
new file mode 100644
index 0000000000..8ebf9c71b6
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Avatar/schema.ts
@@ -0,0 +1,17 @@
+import { z } from "zod/v4";
+import { Icon } from "../Icon";
+import { Shape } from "../Shape";
+
+export const AvatarSchema = z.object({
+ name: z.string(),
+ src: z.string().optional(),
+ shape: z.enum(["circle", "rounded"]).optional(),
+ domain: z.string().optional(),
+ size: z.enum(["inline", "md", "lg"]).optional(),
+ icon: z.optional(z.union([Icon.ref, Shape.ref])),
+ color: z
+ .enum(["neutral", "info", "success", "warning", "danger", "purple", "pink", "inverted"])
+ .optional(),
+});
+
+export type AvatarProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/AvatarStack/index.tsx b/packages/react-ui/src/genui-lib/AvatarStack/index.tsx
new file mode 100644
index 0000000000..9a5f762b1f
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/AvatarStack/index.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Fragment } from "react";
+import { AvatarStack as OpenUIAvatarStack } from "../../components/AvatarStack";
+import { AvatarStackSchema, type AvatarStackProps } from "./schema";
+
+export * from "./schema";
+
+type ElementNode = { props?: Record };
+
+function AvatarStackRenderer({ props, renderNode }: ComponentRenderProps) {
+ const size = props.size ?? "md";
+ const all = (props.avatars ?? []) as ElementNode[];
+ const max = props.max && props.max > 0 ? Math.floor(props.max) : all.length;
+ const shown = all.slice(0, max);
+ return (
+ (n.props?.["shape"] === "rounded" ? "rounded" : "circle"))}
+ >
+ {shown.map((node, i) => (
+ // Every avatar in the stack takes the stack's size.
+ {renderNode({ ...node, props: { ...node.props, size } })}
+ ))}
+
+ );
+}
+
+export const AvatarStack = defineComponent({
+ name: "AvatarStack",
+ props: AvatarStackSchema,
+ description:
+ 'Several Avatars overlapped left to right, for a group of real people or companies: a team, attendees, who replied, investors in a round. AvatarStack([a1, a2, a3, a4, a5], 3) shows three and a "2" count chip. Use it for 2 or more; a single one is an Avatar. max caps how many show, keep it 3–5. size "inline" | "md" (default) | "lg" applies to every avatar, so set it on the stack, not on each Avatar. Each Avatar follows the Avatar rules: a real photo src or a company domain, and always an icon. A stack does not say who is in it, so name the group in Text beside it, usually with LeadingText.',
+ component: AvatarStackRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/AvatarStack/schema.ts b/packages/react-ui/src/genui-lib/AvatarStack/schema.ts
new file mode 100644
index 0000000000..80da4c90cd
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/AvatarStack/schema.ts
@@ -0,0 +1,10 @@
+import { z } from "zod/v4";
+import { Avatar } from "../Avatar";
+
+export const AvatarStackSchema = z.object({
+ avatars: z.array(Avatar.ref).min(1),
+ max: z.number().optional(),
+ size: z.enum(["inline", "md", "lg"]).optional(),
+});
+
+export type AvatarStackProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/Bleed/index.tsx b/packages/react-ui/src/genui-lib/Bleed/index.tsx
new file mode 100644
index 0000000000..a33bfdef34
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Bleed/index.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Bleed as OpenUIBleed } from "../../components/Bleed";
+import { BleedSchema, type BleedProps } from "./schema";
+
+export * from "./schema";
+
+// Children are loose so the chat Bleed, with its own children union, can share this renderer.
+type BleedRenderProps = ComponentRenderProps<
+ Omit & { children: unknown[] }
+>;
+
+/** Shared renderer — also used by the chat library's Bleed variant (typed children). */
+export function BleedRenderer({ props, renderNode }: BleedRenderProps) {
+ return {renderNode(props.children)};
+}
+
+export const BLEED_DESCRIPTION = `Frameless content wider than the chat column, for expressive layouts: Bleed(children). Same breakout as Shell but with no header, outline or expand button; on narrow screens it just fills the column. Children stack top to bottom, 12px apart. Use it sparingly, for a part that benefits from width: a wide hero Image, a wide comparison Grid, an editorial layout or a wide Carousel, e.g. Bleed([Image("Lisbon at dusk", url, "16:9"), Grid([c1, c2, c3], 3)]). When a Grid must compare an odd number of cards side by side, such as 3 or 5 plans or options, wrap it in a Bleed so they sit in one wide row instead of wrapping into an uneven 2 + 1: Bleed([Grid([plan1, plan2, plan3], 3)]). Never nest a Bleed or Shell inside it; for a dashboard with an expand button use Shell.`;
+
+export const Bleed = defineComponent({
+ name: "Bleed",
+ props: BleedSchema,
+ description: BLEED_DESCRIPTION,
+ component: BleedRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/Bleed/schema.ts b/packages/react-ui/src/genui-lib/Bleed/schema.ts
new file mode 100644
index 0000000000..b6eb948f44
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Bleed/schema.ts
@@ -0,0 +1,11 @@
+import { z } from "zod/v4";
+import { getShellChildUnion } from "../Shell/schema";
+
+// Same children as a Shell: content, Card, Grid, Carousel, Stack, Tabs.
+export const BleedSchema = z.object({
+ get children(): z.ZodArray {
+ return z.array(getShellChildUnion());
+ },
+});
+
+export type BleedProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/BoldText/index.tsx b/packages/react-ui/src/genui-lib/BoldText/index.tsx
index b53fee1f92..5269134240 100644
--- a/packages/react-ui/src/genui-lib/BoldText/index.tsx
+++ b/packages/react-ui/src/genui-lib/BoldText/index.tsx
@@ -2,7 +2,7 @@
import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
import { TextBlockView } from "../../components/TextBlock";
-import { getMetricSubtextTone } from "../Text";
+import { getMetricSubtextTone } from "../InlineText";
import { BoldTextSchema, type BoldTextProps } from "./schema";
export * from "./schema";
diff --git a/packages/react-ui/src/genui-lib/Button/index.tsx b/packages/react-ui/src/genui-lib/Button/index.tsx
index 0962f341dc..ee813b9497 100644
--- a/packages/react-ui/src/genui-lib/Button/index.tsx
+++ b/packages/react-ui/src/genui-lib/Button/index.tsx
@@ -10,6 +10,7 @@ import {
useTriggerAction,
} from "@openuidev/react-lang";
import { Button as OpenUIButton } from "../../components/Button";
+import { renderIconSlot } from "../iconSlot";
import { ButtonSchema } from "./schema";
export { ButtonSchema } from "./schema";
@@ -24,7 +25,9 @@ const variantMap: Record = {
export const Button = defineComponent({
name: "Button",
props: ButtonSchema,
- description: "Clickable button",
+ description:
+ 'A pill button: Button(label, action?, variant?, type?, size?, leading?). The label says exactly what clicking does, e.g. Button("Book a table", @ToAssistant("Book a table at Septime"), "primary"). variant "primary" (solid, the one main action in a group) | "secondary" (supporting actions) | "tertiary" (lowest emphasis, e.g. "Skip"). type "destructive" (red) only for an action that deletes or cancels something. size "default" (32px) for almost every button; "large" (40px) only for a single prominent call to action, never for a row of buttons. Don\'t use the older "extra-small" | "small" | "medium" sizes. leading is an optional Icon before the label, added only where it clarifies the action (e.g. Icon("external-link") on a button that opens a site); most buttons have none.',
+
component: ({ props }) => {
const triggerAction = useTriggerAction();
const formName = useFormName();
@@ -35,7 +38,15 @@ export const Button = defineComponent({
return (
{
diff --git a/packages/react-ui/src/genui-lib/Button/schema.ts b/packages/react-ui/src/genui-lib/Button/schema.ts
index d93a35faa0..5d707f3e43 100644
--- a/packages/react-ui/src/genui-lib/Button/schema.ts
+++ b/packages/react-ui/src/genui-lib/Button/schema.ts
@@ -1,10 +1,14 @@
import { z } from "zod/v4";
import { actionPropSchema } from "../Action/schema";
+import { Icon } from "../Icon";
export const ButtonSchema = z.object({
label: z.string(),
action: actionPropSchema.optional(),
variant: z.enum(["primary", "secondary", "tertiary"]).optional(),
type: z.enum(["normal", "destructive"]).optional(),
- size: z.enum(["extra-small", "small", "medium", "large"]).optional(),
+ // "default" | "large". Older sizes from earlier responses ("small", etc.)
+ // are reported as a type mismatch but still render as default.
+ size: z.enum(["default", "large"]).optional(),
+ leading: z.optional(Icon.ref),
});
diff --git a/packages/react-ui/src/genui-lib/Buttons/index.tsx b/packages/react-ui/src/genui-lib/Buttons/index.tsx
index ce63cdaa2f..c757545b8e 100644
--- a/packages/react-ui/src/genui-lib/Buttons/index.tsx
+++ b/packages/react-ui/src/genui-lib/Buttons/index.tsx
@@ -16,7 +16,10 @@ export const Buttons = defineComponent({
props: ButtonsSchema,
description: 'Group of Button components. direction: "row" (default) | "column".',
component: ({ props, renderNode }) => (
-
+
{renderNode(props.buttons) as ButtonsProps["children"]}
),
diff --git a/packages/react-ui/src/genui-lib/Callout/index.tsx b/packages/react-ui/src/genui-lib/Callout/index.tsx
index 8f338af023..b90dc0495d 100644
--- a/packages/react-ui/src/genui-lib/Callout/index.tsx
+++ b/packages/react-ui/src/genui-lib/Callout/index.tsx
@@ -2,17 +2,34 @@
import { defineComponent, useStateField } from "@openuidev/react-lang";
import React from "react";
+import { IconWrapper } from "../../components/_shared/icons";
import { Callout as OpenUICallout } from "../../components/Callout";
import { MarkDownRenderer } from "../../components/MarkDownRenderer";
+import { renderIconSlot } from "../iconSlot";
import { CalloutSchema } from "./schema";
+type CalloutVariant = NonNullable["variant"]>;
+
+// Icon shown when the agent doesn't pass one.
+const DEFAULT_ICONS: Record = {
+ info: "info",
+ success: "circle-check",
+ warning: "triangle-alert",
+ danger: "circle-x",
+ error: "circle-x",
+ purple: "sparkles",
+ pink: "heart",
+ inverted: "info",
+ neutral: "lightbulb",
+};
+
export { CalloutSchema } from "./schema";
export const Callout = defineComponent({
name: "Callout",
props: CalloutSchema,
description:
- "Callout banner. Optional visible is a reactive $boolean — auto-dismisses after 3s by setting $visible to false.",
+ 'A Notion-style callout: an icon followed by Markdown content on a tinted block, with no title. Use it to make one important aside stand out from the answer around it: a tip, a caveat, a warning or the result of an action. Keep it to 1–3 sentences and lead with a short bold phrase when that helps, e.g. Callout("warning", "**Visa required.** Apply at least 3 weeks before you travel."). Use at most one or two per response; regular explanation belongs in Markdown. Pick variant by meaning, which also sets the tint and default icon: "info" context (info), "success" a positive result (circle-check), "warning" a caution (triangle-alert), "danger" a failure or risk (circle-x; "error" is an alias), "neutral" a tip (lightbulb), "purple" a highlight or something new (sparkles), "pink" something personal or loved (heart), "inverted" a high-emphasis note on a dark block (info). Most callouts are info, success, warning, danger or neutral; use purple, pink and inverted sparingly. Pass an Icon only when a more specific one helps, e.g. Callout("info", "Doors open at 7pm.", null, Icon("clock")). visible is an optional reactive $boolean for a toast: it auto-dismisses after 3s by setting $visible to false.',
component: ({ props }) => {
const field = useStateField("visible", props.visible);
@@ -32,18 +49,26 @@ export const Callout = defineComponent({
if (!isVisible) return null;
- const variantMap: Record = {
+ const variantMap: Record = {
+ neutral: "neutral",
info: "info",
- warning: "warning",
success: "success",
- error: "warning",
- neutral: "neutral",
+ warning: "warning",
+ danger: "danger",
+ error: "danger",
+ purple: "purple",
+ pink: "pink",
+ inverted: "inverted",
};
+ const variant = (props.variant as string) || "info";
+ const icon = renderIconSlot(props.icon) ?? (
+
+ );
return (
}
+ variant={variantMap[variant] || "info"}
+ icon={icon}
+ description={}
duration={hasVisibleBinding ? 3000 : undefined}
/>
);
diff --git a/packages/react-ui/src/genui-lib/Callout/schema.ts b/packages/react-ui/src/genui-lib/Callout/schema.ts
index abbd5b37a7..93fc3291ab 100644
--- a/packages/react-ui/src/genui-lib/Callout/schema.ts
+++ b/packages/react-ui/src/genui-lib/Callout/schema.ts
@@ -1,9 +1,22 @@
import { reactive } from "@openuidev/react-lang";
import { z } from "zod/v4";
+import { Emoji } from "../Emoji";
+import { Icon } from "../Icon";
export const CalloutSchema = z.object({
- variant: z.enum(["info", "warning", "error", "success", "neutral"]),
- title: z.string(),
- description: z.string(),
+ // "error" is kept as an alias of "danger" so existing calls still parse.
+ variant: z.enum([
+ "neutral",
+ "info",
+ "success",
+ "warning",
+ "danger",
+ "purple",
+ "pink",
+ "inverted",
+ "error",
+ ]),
+ content: z.string(),
visible: reactive(z.boolean().optional()),
+ icon: z.optional(z.union([Icon.ref, Emoji.ref])),
});
diff --git a/packages/react-ui/src/genui-lib/Card/index.tsx b/packages/react-ui/src/genui-lib/Card/index.tsx
index e1981d34b5..660f8ddc8c 100644
--- a/packages/react-ui/src/genui-lib/Card/index.tsx
+++ b/packages/react-ui/src/genui-lib/Card/index.tsx
@@ -1,58 +1,28 @@
"use client";
import { defineComponent } from "@openuidev/react-lang";
-import { Card as OpenUICard } from "../../components/Card";
+import { CardHeading, Card as OpenUICard } from "../../components/Card";
import { CardSchema } from "./schema";
export { CardSchema } from "./schema";
-const gapMap: Record = {
- none: "0",
- xs: "var(--openui-space-xs)",
- s: "var(--openui-space-s)",
- m: "var(--openui-space-m)",
- l: "var(--openui-space-l)",
- xl: "var(--openui-space-xl)",
- "2xl": "var(--openui-space-2xl)",
-};
-
-const alignMap: Record = {
- start: "flex-start",
- center: "center",
- end: "flex-end",
- stretch: "stretch",
- baseline: "baseline",
-};
-
-const justifyMap: Record = {
- start: "flex-start",
- center: "center",
- end: "flex-end",
- between: "space-between",
- around: "space-around",
- evenly: "space-evenly",
-};
-
export const Card = defineComponent({
name: "Card",
props: CardSchema,
- description:
- 'Styled container. variant: "card" (default, elevated) | "sunk" (recessed) | "clear" (transparent). Always full width. Accepts all Stack flex params (default: direction "column"). Cards flex to share space in row/wrap layouts.',
+ description: `Styled container, never borderless. variant: "outlined" (default, a thin border on a transparent background) | "lifted" (white, with the same border plus a soft shadow, to raise it off the page) | "sunk" (a light grey fill, no border or shadow, for a recessed group). Use "outlined" for ordinary groups such as a chart or a set of KPIs; "lifted" sparingly, for the one block that should stand out, such as a recommended option or a summary; "sunk" for secondary or nested content, such as a card inside another card or supporting notes. There is no borderless Card: to group content without a frame, use Stack (or Section for a part of the response). Never put a Table or EditableTable inside a Card: a table draws its own frame, so a Card around it doubles the border. Place it directly in a Section, TabItem or AccordionItem, with a Heading or Text title above it if it needs one. Optional title and subtitle (3rd and 4th, after variant) render as the card's own small header, 14px over 13px secondary like our other cards; use them instead of a Heading child, e.g. Card([chart], null, "Revenue by quarter", "USD thousands"). Always full width. Children stack top to bottom by default; to customise the layout (a row, a wrap, other gaps), pass a single Stack as the child, e.g. Card([Stack([a, b], "row")]). Cards flex to share space in a row Stack.`,
component: ({ props, renderNode }) => (
+
{renderNode(props.children)}
),
diff --git a/packages/react-ui/src/genui-lib/Card/schema.ts b/packages/react-ui/src/genui-lib/Card/schema.ts
index 3f7cceb8ab..3683b647bf 100644
--- a/packages/react-ui/src/genui-lib/Card/schema.ts
+++ b/packages/react-ui/src/genui-lib/Card/schema.ts
@@ -1,20 +1,34 @@
import { z } from "zod/v4";
+import { Bleed } from "../Bleed";
import { Carousel } from "../Carousel";
+import { Grid } from "../Grid";
+import { Section } from "../Section";
+import { Shell } from "../Shell";
import { Stack } from "../Stack";
-import { FlexPropsSchema } from "../Stack/schema";
import { Tabs } from "../Tabs";
import { ContentChildUnion } from "../unions";
-export const CardChildUnion = z.union([
- ...ContentChildUnion.options,
- Tabs.ref,
- Carousel.ref,
- Stack.ref,
-]);
+// Card and Carousel hold each other (a Carousel's slides are Cards), so the
+// union is built on first use rather than at import time.
+let cardChildUnion: z.ZodType | undefined;
+export const getCardChildUnion = (): z.ZodType =>
+ (cardChildUnion ??= z.union([
+ ...ContentChildUnion.options,
+ Tabs.ref,
+ Carousel.ref,
+ Stack.ref,
+ Section.ref,
+ Grid.ref,
+ Shell.ref,
+ Bleed.ref,
+ ]));
-export const CardSchema = z
- .object({
- children: z.array(CardChildUnion),
- variant: z.enum(["card", "sunk", "clear"]).optional(),
- })
- .merge(FlexPropsSchema);
+export const CardSchema = z.object({
+ get children(): z.ZodArray {
+ return z.array(getCardChildUnion());
+ },
+ variant: z.enum(["outlined", "lifted", "sunk"]).optional(),
+ // After variant so existing Card(children, variant) calls keep their meaning.
+ title: z.string().optional(),
+ subtitle: z.string().optional(),
+});
diff --git a/packages/react-ui/src/genui-lib/CardHeader/index.tsx b/packages/react-ui/src/genui-lib/CardHeader/index.tsx
index 037df48ecc..2d34c33f2d 100644
--- a/packages/react-ui/src/genui-lib/CardHeader/index.tsx
+++ b/packages/react-ui/src/genui-lib/CardHeader/index.tsx
@@ -9,6 +9,7 @@ export { CardHeaderSchema } from "./schema";
export const CardHeader = defineComponent({
name: "CardHeader",
props: CardHeaderSchema,
- description: "Header with optional title and subtitle",
+ description:
+ "Card title with optional subtitle. Only use directly inside a Card, never outside one.",
component: ({ props }) => ,
});
diff --git a/packages/react-ui/src/genui-lib/Carousel/index.tsx b/packages/react-ui/src/genui-lib/Carousel/index.tsx
index 6bfc74d0df..0ea62f47e5 100644
--- a/packages/react-ui/src/genui-lib/Carousel/index.tsx
+++ b/packages/react-ui/src/genui-lib/Carousel/index.tsx
@@ -10,21 +10,64 @@ import {
CarouselNext as OpenUICarouselNext,
CarouselPrevious as OpenUICarouselPrevious,
} from "../../components/Carousel";
-import { ContentChildUnion } from "../unions";
+import { CardSchema } from "../Card/schema";
+import { CompositeCardItem } from "../CompositeCardBlock/schema";
+import { Image } from "../Image";
+import { ImageCardItem } from "../ImageCard/schema";
+import { InsightCardItem } from "../InsightCard/schema";
+import { NestedCardContext } from "../nestedCardContext";
+import { ValueCardItem } from "../ValueCard/schema";
-type CarouselRenderProps = ComponentRenderProps<{
- children: unknown[][];
- variant?: "card" | "sunk";
-}>;
+// Card items render through their own block in its carousel layout, so they
+// keep exactly the chrome, sizing and shared hover they have there.
+const ITEM_BLOCKS: Record }> = {
+ CompositeCardItem: {
+ typeName: "CompositeCardBlock",
+ props: { layout: "carousel", responsive: true },
+ },
+ ImageCardItem: { typeName: "ImageCard", props: { layout: "carousel", responsive: true } },
+ InsightCardItem: { typeName: "InsightCard", props: { layout: "carousel", responsive: true } },
+ ValueCardItem: {
+ typeName: "ValueCard",
+ props: { layout: "vertical", responsive: true, carousel: true },
+ },
+};
+
+/** The slide types a Carousel takes, given the Card schema of its library. */
+export const carouselSlideUnion = (card: z.core.$ZodType) =>
+ z.union([
+ card,
+ Image.ref,
+ CompositeCardItem.ref,
+ ImageCardItem.ref,
+ InsightCardItem.ref,
+ ValueCardItem.ref,
+ ]);
+
+type CarouselRenderProps = ComponentRenderProps<{ children: unknown[] }>;
-/** Shared renderer — also used by the chat library's Carousel variant (wider content union). */
+// Kept under its old name for existing imports.
+export { NestedCardContext as CarouselSlideContext } from "../nestedCardContext";
+
+/** Shared renderer — also used by the chat library's Carousel, whose slides are its own Card. */
export const CarouselRenderer = ({ props, renderNode }: CarouselRenderProps) => {
const items = props.children ?? [];
+ const first = items[0] as { typeName?: string } | undefined;
+ const block = first?.typeName ? ITEM_BLOCKS[first.typeName] : undefined;
+ if (block) {
+ return renderNode({
+ type: "element",
+ typeName: block.typeName,
+ props: { ...block.props, items },
+ });
+ }
return (
-
+
{items.map((item, i) => (
- {renderNode(item)}
+
+ {renderNode(item)}
+
))}
} />
@@ -33,12 +76,16 @@ export const CarouselRenderer = ({ props, renderNode }: CarouselRenderProps) =>
);
};
+export const CAROUSEL_DESCRIPTION = `Horizontal scrollable row of 2+ slides the reader browses sideways. Every slide is the same type, one of: Card (its variant sets the look; keep every Card the same variant and shape, e.g. Carousel([Card([...]), Card([...])])); Image (a photo strip; give every Image the same aspectRatio so they line up); or one kind of card item, CompositeCardItem, ImageCardItem, InsightCardItem or ValueCardItem, which render exactly as in their block's carousel layout, e.g. Carousel([ImageCardItem(...), ImageCardItem(...)]). Which to use: a set of one card type with one shared click action uses that block\'s own carousel layout, e.g. ImageCard([lm1, lm2, lm3], "carousel", true, action) or ValueCard([v1, v2, v3], "vertical", true, null, null, true), which keeps the shared action. Use Carousel for slides of custom Cards or Images, or for card items that need no shared action.`;
+
export const Carousel = defineComponent({
name: "Carousel",
props: z.object({
- children: z.array(z.array(ContentChildUnion)),
- variant: z.enum(["card", "sunk"]).optional(),
+ // Built lazily because Card can also hold a Carousel.
+ get children(): z.ZodArray {
+ return z.array(carouselSlideUnion(CardSchema));
+ },
}),
- description: "Horizontal scrollable carousel",
+ description: CAROUSEL_DESCRIPTION,
component: CarouselRenderer,
});
diff --git a/packages/react-ui/src/genui-lib/Charts/AreaChartCondensed.ts b/packages/react-ui/src/genui-lib/Charts/AreaChartCondensed.ts
index 0f5ca83e52..6f83916ffb 100644
--- a/packages/react-ui/src/genui-lib/Charts/AreaChartCondensed.ts
+++ b/packages/react-ui/src/genui-lib/Charts/AreaChartCondensed.ts
@@ -6,6 +6,7 @@ import { z } from "zod/v4";
import { AreaChartCondensed as AreaChartCondensedComponent } from "../../components/Charts";
import { buildChartData, hasAllProps } from "../helpers";
import { SeriesSchema } from "./Series";
+import { SPARKLINE_MAX_HEIGHT, Sparkline } from "./Sparkline";
export const AreaChartCondensedSchema = z.object({
labels: z.array(z.string()),
@@ -19,11 +20,21 @@ export const AreaChartCondensedSchema = z.object({
export const AreaChartCondensed = defineComponent({
name: "AreaChart",
props: AreaChartCondensedSchema,
- description: "Filled area under lines; use for cumulative totals or volume trends over time",
+ description:
+ "Filled area under lines; use for cumulative totals or volume trends over time. A height of 120 or less draws a sparkline with no axes, legend or grid, scaled to the data, for a KPI Card.",
component: ({ props }) => {
if (!hasAllProps(props as Record, "labels", "series")) return null;
const data = buildChartData(props.labels, props.series);
if (!data.length) return null;
+ // Too short for axes and a legend: draw a full-width sparkline instead.
+ if (typeof props.height === "number" && props.height <= SPARKLINE_MAX_HEIGHT) {
+ return React.createElement(Sparkline, {
+ data,
+ height: props.height,
+ variant: props.variant as "linear" | "natural" | "step" | undefined,
+ kind: "area",
+ });
+ }
return React.createElement(AreaChartCondensedComponent, {
data,
categoryKey: "category",
diff --git a/packages/react-ui/src/genui-lib/Charts/LineChartCondensed.ts b/packages/react-ui/src/genui-lib/Charts/LineChartCondensed.ts
index 13cecc2450..3a246b6feb 100644
--- a/packages/react-ui/src/genui-lib/Charts/LineChartCondensed.ts
+++ b/packages/react-ui/src/genui-lib/Charts/LineChartCondensed.ts
@@ -6,6 +6,7 @@ import { z } from "zod/v4";
import { LineChartCondensed as LineChartCondensedComponent } from "../../components/Charts";
import { buildChartData, hasAllProps } from "../helpers";
import { SeriesSchema } from "./Series";
+import { SPARKLINE_MAX_HEIGHT, Sparkline } from "./Sparkline";
export const LineChartCondensedSchema = z.object({
labels: z.array(z.string()),
@@ -19,11 +20,21 @@ export const LineChartCondensedSchema = z.object({
export const LineChartCondensed = defineComponent({
name: "LineChart",
props: LineChartCondensedSchema,
- description: "Lines over categories; use for trends and continuous data over time",
+ description:
+ "Lines over categories; use for trends and continuous data over time. A height of 120 or less draws a sparkline with no axes, legend or grid, scaled to the data, for a KPI Card.",
component: ({ props }) => {
if (!hasAllProps(props as Record, "labels", "series")) return null;
const data = buildChartData(props.labels, props.series);
if (!data.length) return null;
+ // Too short for axes and a legend: draw a full-width sparkline instead.
+ if (typeof props.height === "number" && props.height <= SPARKLINE_MAX_HEIGHT) {
+ return React.createElement(Sparkline, {
+ data,
+ height: props.height,
+ variant: props.variant as "linear" | "natural" | "step" | undefined,
+ kind: "line",
+ });
+ }
return React.createElement(LineChartCondensedComponent, {
data,
categoryKey: "category",
diff --git a/packages/react-ui/src/genui-lib/Charts/Sparkline.tsx b/packages/react-ui/src/genui-lib/Charts/Sparkline.tsx
new file mode 100644
index 0000000000..1b441a2e63
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Charts/Sparkline.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { scaleLinear, scalePoint } from "d3-scale";
+import { area, line } from "d3-shape";
+import { useId, useRef } from "react";
+import { useContainerSize } from "../../components/Charts/hooks/core/useContainerSize";
+import { miniCurves } from "../../components/Charts/shared/mini/miniChartUtils";
+import { useChartPalette } from "../../components/Charts/utils/paletteUtils";
+
+/**
+ * At or below this height a LineChart or AreaChart has no room for axes and a
+ * legend, so it draws as a sparkline instead: full width, no axes, no legend.
+ */
+export const SPARKLINE_MAX_HEIGHT = 120;
+
+// Room for the stroke and any curve overshoot at the top and bottom.
+const INSET = 3;
+
+type Row = Record;
+
+export function Sparkline({
+ data,
+ height,
+ variant = "natural",
+ kind,
+}: {
+ data: Row[];
+ height: number;
+ variant?: "linear" | "natural" | "step";
+ kind: "line" | "area";
+}) {
+ const ref = useRef(null);
+ const gradientId = useId();
+ const { width } = useContainerSize(ref, undefined, height);
+ const keys = Object.keys(data[0] ?? {}).filter((k) => k !== "category");
+ const colors = useChartPalette({
+ themePaletteName: kind === "area" ? "areaChartPalette" : "lineChartPalette",
+ dataLength: keys.length,
+ });
+
+ // Scaled to the series' own range, not anchored at zero, so a small change
+ // still reads as a trend.
+ const values = data.flatMap((row) => keys.map((k) => Number(row[k])).filter(Number.isFinite));
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const x = scalePoint()
+ .domain(data.map((_, i) => i))
+ .range([0, width]);
+ const y = scaleLinear()
+ .domain(min === max ? [min - 1, max + 1] : [min, max])
+ .range([height - INSET, INSET]);
+ const curve = miniCurves[variant] ?? miniCurves.natural;
+
+ return (
+
+ {width > 0 && values.length > 0 && (
+
+ )}
+
+ );
+}
diff --git a/packages/react-ui/src/genui-lib/Checklist/index.tsx b/packages/react-ui/src/genui-lib/Checklist/index.tsx
new file mode 100644
index 0000000000..1af73082fb
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Checklist/index.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { defineComponent, useIsStreaming, useStateField } from "@openuidev/react-lang";
+import React from "react";
+import { Timeline as OpenUITimeline } from "../../components/Timeline";
+import { renderOneOrMany } from "../oneOrMany";
+import { ChecklistItemSchema, createChecklistSchema } from "./schema";
+
+export { ChecklistItemSchema } from "./schema";
+
+export const ChecklistItem = defineComponent({
+ name: "ChecklistItem",
+ props: ChecklistItemSchema,
+ description:
+ 'One item in a Checklist: ChecklistItem(title, subtitle?, checked?, value?). title is the task, a string or a Text, Link or Tag. subtitle is one line of context: a string, or a Text, Metric, Tag, TagBlock, LeadingText or Link. checked true starts it ticked; leave it out for items still to do. value is the key the tick is stored under in the Checklist value, such as "flights"; give every item one when the Checklist has a name or value. When title or subtitle needs two or more pieces side by side, pass an array.',
+ component: () => null,
+});
+
+export const Checklist = defineComponent({
+ name: "Checklist",
+ props: createChecklistSchema(ChecklistItem),
+ description:
+ 'A list of items with round checks: Checklist(items, name?, value?), e.g. Checklist([ChecklistItem("Book flights", "Paris, Mar 12–19", true, "flights"), ChecklistItem("Reserve hotel", "Saint-Germain, 7 nights", false, "hotel"), ChecklistItem("Get travel insurance", null, false, "insurance")], "trip-prep"). On its own the user can tick it: clicking a row, or pressing Space or Enter, toggles it, and checked rows strike through in secondary. checked on an item is its starting state. Give the Checklist a name, or bind value to a $variable holding { itemValue: boolean }, when the ticks should be kept or read back; without either, ticks live only on screen. Inside a CompositeCardItem body it is read-only, so use it there for what a plan or tier includes (checked true) or what is already done. Use it for packing lists, launch or onboarding checklists and to-dos. Use Steps for numbered instructions done in order and Timeline for time-ordered events.',
+ component: ({ props, renderNode }) => {
+ const isStreaming = useIsStreaming();
+ const items = (props.items ?? []) as {
+ props?: { title?: unknown; subtitle?: unknown; checked?: boolean; value?: string };
+ }[];
+ const keyOf = (item: (typeof items)[number], index: number) =>
+ item?.props?.value || `item-${index + 1}`;
+
+ // A name or a bound $variable keeps the ticks in state the agent can read;
+ // otherwise they live in local state for this render only.
+ const field = useStateField(props.name ?? "", props.value);
+ const persisted = Boolean(props.name) || props.value != null;
+ const [local, setLocal] = React.useState>({});
+ const stored = (persisted ? field.value : local) as Record | undefined;
+
+ const isChecked = (item: (typeof items)[number], index: number) =>
+ stored?.[keyOf(item, index)] ?? item?.props?.checked === true;
+
+ const cell = (value: unknown) => renderOneOrMany(value, renderNode);
+
+ // Inside a CompositeCard body a Checklist lists what a plan or tier
+ // includes, so it renders read-only there: no toggle, hover or checkbox role.
+ const hostRef = React.useRef(null);
+ const [readOnly, setReadOnly] = React.useState(false);
+ React.useLayoutEffect(() => {
+ setReadOnly(Boolean(hostRef.current?.closest(".openui-composite-card__body")));
+ }, []);
+
+ const toggle = (index: number) => {
+ const next: Record = {};
+ items.forEach((item, i) => {
+ next[keyOf(item, i)] = isChecked(item, i);
+ });
+ const key = keyOf(items[index]!, index);
+ next[key] = !next[key];
+ if (persisted) field.setValue(next);
+ else setLocal(next);
+ };
+
+ return (
+
+ );
} else if (
isElementOf(node, [AreaChartCondensed.name, BarChartCondensed.name, LineChartCondensed.name])
) {
value = withProps(node, { height: COMPOSITE_CARD_CHART_HEIGHT });
+ } else if (location === "price" && isElementOf(node, [Metric.name])) {
+ // The footer price is the card's headline figure: 18px.
+ value = withProps(node, { size: "large" });
} else if (isElementOf(node, [ListBlock.name])) {
value = withProps(node, { size: "small" });
} else if (isElementOf(node, [EntityList.name])) {
value = withProps(node, { size: "small", header: undefined, footer: undefined });
- } else if (isElementOf(node, [TagBlock.name])) {
- value = withProps(node, { size: "sm" });
- } else if (isElementOf(node, [Button.name])) {
- value = withProps(node, { size: "small" });
- } else if (
- location === "body" &&
- isElementOf(node, [MetricIndicatorInline.name, MetricIndicatorWithStrikethrough.name])
- ) {
- value = node;
}
return {renderNode(value)};
@@ -112,7 +123,10 @@ function CompositeCardBlockRenderer({
const item = items[index];
if (!item || !props.action || isStreaming) return;
- const { title, subtitle, imageAlt } = getCompositeHeaderText(item);
+ const header = getCompositeHeaderText(item);
+ const { title, subtitle } = header;
+ const imageAlt =
+ header.imageAlt ?? (item.props["media"]?.props as { alt?: string } | undefined)?.alt;
const { id, body, footer } = item.props;
const bodyItems = Array.isArray(body) ? body : [];
const priceProps = footer?.price?.props as Record | undefined;
@@ -127,7 +141,9 @@ function CompositeCardBlockRenderer({
itemHeaderAlt: imageAlt,
itemBodyCount: bodyItems.length,
itemFooterPrice: priceProps && "value" in priceProps ? priceProps["value"] : undefined,
- itemFooterButtonLabel: footer?.button?.props.label,
+ itemFooterButtonLabel: Array.isArray(footer?.button)
+ ? undefined
+ : (footer?.button?.props as { label?: string } | undefined)?.label,
}),
);
};
@@ -140,7 +156,7 @@ function CompositeCardBlockRenderer({
clickable={isClickable}
onItemClick={handleItemClick}
items={items.map((item, index) => {
- const { id, header, body, footer } = item.props;
+ const { id, header, body, footer, media } = item.props;
const itemKey = id?.trim()
? `composite-card-${id.trim()}-${index}`
: `composite-card-${index}`;
@@ -148,6 +164,14 @@ function CompositeCardBlockRenderer({
return {
id,
+ media: media
+ ? renderNode(
+ isElementOf(media, CHART_NAMES)
+ ? withProps(media, { height: COMPOSITE_CARD_MEDIA_CHART_HEIGHT })
+ : media,
+ )
+ : undefined,
+ mediaKind: media ? (isElementOf(media, CHART_NAMES) ? "chart" : "image") : undefined,
header: header ? renderNode(header) : undefined,
body: bodyItems.map((node, bodyIndex) =>
renderCompositeCardNode(node, renderNode, `${itemKey}-body-${bodyIndex}`, "body"),
@@ -159,17 +183,28 @@ function CompositeCardBlockRenderer({
footer.price,
renderNode,
`${itemKey}-footer-price`,
- "footer",
+ "price",
)
: undefined,
button: footer.button
- ? renderCompositeCardNode(
- footer.button,
- renderNode,
- `${itemKey}-footer-button`,
- "footer",
- )
+ ? Array.isArray(footer.button)
+ ? footer.button.map((node, i) =>
+ renderCompositeCardNode(
+ node,
+ renderNode,
+ `${itemKey}-footer-button-${i}`,
+ "footer",
+ ),
+ )
+ : renderCompositeCardNode(
+ footer.button,
+ renderNode,
+ `${itemKey}-footer-button`,
+ "footer",
+ )
: undefined,
+ iconActions:
+ Array.isArray(footer.button) || isElementOf(footer.button, [IconButton.name]),
}
: undefined,
};
@@ -182,6 +217,6 @@ export const CompositeCardBlock = defineComponent({
name: "CompositeCardBlock",
props: CompositeCardBlockSchema,
description:
- "A two-per-row grid or carousel of rich cards, each with an optional header, stacked body content (text, metrics, charts, lists, tags) and a price/button footer; an optional action makes every card clickable.",
+ 'The fallback card block: use it only when no specialised card fits, so first reach for ImageCard (photo tiles), InsightCard (takeaways), ValueCard or ValueLayout (figures), EntityList or ListBlock (rows), or OptionCards (choices in a form). A two-per-row grid or carousel of 2 or more rich cards, for products, offers, plans or deep-dive topics that need more than a title and a line, such as a header, mixed body content and a price or button footer: CompositeCardBlock([item1, item2], layout?, responsive?, action?, gap?). layout "grid" (default) for side-by-side comparison, "carousel" for secondary browsing. Every card uses the same parts in the same order. action is one shared action for every card, with the clicked card\'s details attached, so keep its @ToAssistant message generic and never use @OpenUrl there; give each card its own link with a footer Button instead. For short facts use ValueCard or ValueLayout; for photo tiles use ImageCard.',
component: CompositeCardBlockRenderer,
});
diff --git a/packages/react-ui/src/genui-lib/CompositeCardBlock/schema.ts b/packages/react-ui/src/genui-lib/CompositeCardBlock/schema.ts
index 7935277928..bfd4b90e82 100644
--- a/packages/react-ui/src/genui-lib/CompositeCardBlock/schema.ts
+++ b/packages/react-ui/src/genui-lib/CompositeCardBlock/schema.ts
@@ -1,24 +1,25 @@
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod/v4";
import { actionPropSchema } from "../Action/schema";
-import { BoldText } from "../BoldText";
import { Button } from "../Button";
+import { Buttons } from "../Buttons";
import { AreaChartCondensed, BarChartCondensed, LineChartCondensed } from "../Charts";
+import { Checklist } from "../Checklist";
import { EntityList } from "../EntityList";
-import { IconText } from "../IconText";
+import { EntityTitle } from "../EntityTitle";
+import { IconButton } from "../IconButton";
import { Image } from "../Image";
-import { ImageText } from "../ImageText";
-import { ImageTextLarge } from "../ImageTextLarge";
+import { ImageTitle } from "../ImageTitle";
+import { LeadingText } from "../LeadingText";
import { ListBlock } from "../ListBlock";
-import { MetricIndicatorInline, MetricIndicatorWithStrikethrough } from "../MetricIndicator";
+import { Metric } from "../Metric";
import { TagBlock } from "../TagBlock";
import { Text } from "../Text";
export const CompositeCardBodyItemSchema = z.union([
Text.ref,
- BoldText.ref,
- MetricIndicatorInline.ref,
- IconText.ref,
+ Metric.ref,
+ EntityTitle.ref,
Image.ref,
AreaChartCondensed.ref,
BarChartCondensed.ref,
@@ -26,20 +27,28 @@ export const CompositeCardBodyItemSchema = z.union([
ListBlock.ref,
TagBlock.ref,
EntityList.ref,
+ Checklist.ref,
]);
export const CompositeCardFooterSchema = z.object({
- price: z.union([BoldText.ref, MetricIndicatorWithStrikethrough.ref]).optional(),
- button: z.optional(Button.ref),
+ price: z.optional(Metric.ref),
+ // One Button, or a Buttons group (direction "column" stacks them full width),
+ // or one or more IconButtons that sit in a row to the right of the price.
+ button: z.optional(z.union([Button.ref, Buttons.ref, IconButton.ref, z.array(IconButton.ref)])),
});
export const CompositeCardItemSchema = z.object({
id: z.string().optional(),
header: z
- .union([IconText.ref, ImageText.ref, ImageTextLarge.ref, Text.ref, Image.ref])
+ .union([EntityTitle.ref, ImageTitle.ref, Text.ref, Image.ref, LeadingText.ref])
.optional(),
body: z.array(CompositeCardBodyItemSchema).default([]),
footer: CompositeCardFooterSchema.optional(),
+ // A photo shown edge to edge across the top of the card. Last so existing
+ // positional calls keep their meaning.
+ media: z.optional(
+ z.union([Image.ref, AreaChartCondensed.ref, BarChartCondensed.ref, LineChartCondensed.ref]),
+ ),
});
export type CompositeCardItemProps = z.infer;
@@ -48,7 +57,7 @@ export const CompositeCardItem = defineComponent({
name: "CompositeCardItem",
props: CompositeCardItemSchema,
description:
- "A single card inside a CompositeCardBlock: an optional header (icon/image/text), a stack of body elements (text, metrics, charts, lists, tags), and an optional price/button footer.",
+ 'One rich white card: CompositeCardItem(id?, header?, body?, footer?, media?), e.g. CompositeCardItem("widget-pro", EntityTitle(Icon("package", "shopping"), "Widget Pro", "Best seller"), [Text("Sold this month"), Metric("2,410 units", "+12%", null, "sm")], { price: Metric("$29.00"), button: Button("View details", @ToAssistant("Show details for Widget Pro"), "secondary") }). header is an EntityTitle for a named product, company or person, an ImageTitle or Image for a real photo, or a Text title as Text("…", "h3"). body stacks the content: Text renders as 14px body copy (secondary unless emphasis is true), Metric at size "sm", EntityTitle, Image, a chart, ListBlock, TagBlock, a small EntityList, or a Checklist for what is included or done (pricing tiers, onboarding, launch readiness), which beats a ListBlock with check icons. footer holds an optional price Metric, always shown at 18px so it needs no size (pass previous for a sale), and a Button; for two actions use Buttons([primary, secondary], "column"), which stacks them full width. For compact actions, pass one IconButton or an array of them instead; they sit in a row to the right of the price. Each must open a real page (@OpenUrl) or ask a real follow-up (@ToAssistant), never a save, share or cart action the app cannot do, e.g. { price: Metric("$179"), button: [IconButton("Show similar", Icon("search"), @ToAssistant("Show headphones similar to Studio Headphones"), "secondary"), IconButton("Open product page", Icon("external-link"), @OpenUrl("https://…"), "secondary")] }. A footer Button can open the card\'s own real page with @OpenUrl. media (5th) is a real photo, with a URL from a tool result or the user and never invented, shown edge to edge across the top of the card, above the header: CompositeCardItem("lutetia", Text("Hôtel Lutetia", "h3"), [Text("Art Deco landmark on the Left Bank.")], { price: Metric("€420 / night"), button: Button("Book", @ToAssistant("Book Hôtel Lutetia"), "primary") }, Image("Hôtel Lutetia", "https://…", "16:9")). Prefer media over an Image header when the card leads with a photo. media can instead be an AreaChart, BarChart or LineChart for a compact trend across the top of a metric or trend card, such as MRR over six months or weekly signups, e.g. CompositeCardItem("mrr", Text("MRR · last 6 months", "h3"), [Metric("$84.2k", "+12%", null, "sm")], null, AreaChart(["May", "Jun", "Jul", "Aug", "Sep", "Oct"], [Series("MRR", [52, 58, 61, 66, 75, 84])])). Keep one strong block per card: never stack two big-hierarchy blocks together, such as an EntityTitle header (with its 44px badge) next to media, a chart, an ImageTitle or a large Metric. When the card already has a hero like a photo or chart, title it with a plain Text, or a LeadingText such as LeadingText(Icon("dollar-sign", "finance"), Text("MRR", "body", true)) when an icon helps, instead of an EntityTitle.',
component: () => null,
});
diff --git a/packages/react-ui/src/genui-lib/ContextCardBlock/index.tsx b/packages/react-ui/src/genui-lib/ContextCardBlock/index.tsx
deleted file mode 100644
index 18a32fef60..0000000000
--- a/packages/react-ui/src/genui-lib/ContextCardBlock/index.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-"use client";
-
-import type { SubComponentOf } from "@openuidev/react-lang";
-import {
- ComponentRenderProps,
- defineComponent,
- useFormName,
- useIsStreaming,
- useTriggerAction,
-} from "@openuidev/react-lang";
-import { ContextCardBlock as OpenUIContextCardBlock } from "../../components/ContextCardBlock";
-import { withItemContext } from "../cardActionUtils";
-import { renderCardTag } from "../cardTagUtils";
-import {
- ContextCardBlockSchema,
- type ContextCardBlockProps,
- type ContextCardItemProps,
-} from "./schema";
-
-export * from "./schema";
-
-type ContextCardItemElement = SubComponentOf;
-
-function getContextTitleText(item: ContextCardItemElement) {
- const { title } = item.props;
- if (!title) return "";
- return typeof title === "string" ? title : (title.props?.text ?? "");
-}
-
-function ContextCardBlockRenderer({ props }: ComponentRenderProps) {
- const triggerAction = useTriggerAction();
- const formName = useFormName();
- const isStreaming = useIsStreaming();
-
- const items = (props.items ?? []) as ContextCardItemElement[];
- const isClickable = Boolean(props.action) && !isStreaming;
-
- const handleItemClick = (index: number) => {
- const item = items[index];
- if (!item || !props.action || isStreaming) return;
-
- const { id, body, bgColor, bgImageSrc, bgImageAlt } = item.props;
- triggerAction(
- getContextTitleText(item) || id || `Context card ${index + 1}`,
- formName,
- withItemContext(props.action, {
- itemIndex: index,
- itemId: id,
- itemTitle: getContextTitleText(item),
- itemBody: body,
- itemBgColor: bgColor,
- itemBgImageSrc: bgImageSrc,
- itemBgImageAlt: bgImageAlt,
- }),
- );
- };
-
- return (
- {
- const { id, title, body, bgColor, bgImageSrc, bgImageAlt } = item.props;
- return {
- id,
- title:
- typeof title === "string" ? title : renderCardTag(title, `context-card-tag-${index}`),
- body,
- bgColor,
- bgImageSrc,
- bgImageAlt,
- };
- })}
- />
- );
-}
-
-export const ContextCardBlock = defineComponent({
- name: "ContextCardBlock",
- props: ContextCardBlockSchema,
- description:
- "A grid or carousel of compact tinted context cards (title or tag plus a short bold body); an optional action makes every card clickable.",
- component: ContextCardBlockRenderer,
-});
diff --git a/packages/react-ui/src/genui-lib/ContextCardBlock/schema.ts b/packages/react-ui/src/genui-lib/ContextCardBlock/schema.ts
deleted file mode 100644
index fa6632f483..0000000000
--- a/packages/react-ui/src/genui-lib/ContextCardBlock/schema.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { defineComponent } from "@openuidev/react-lang";
-import { z } from "zod/v4";
-import { actionPropSchema } from "../Action/schema";
-import { Tag } from "../Tag";
-
-const contextCardBgColorSchema = z.enum(["gray"]);
-
-export const ContextCardItemSchema = z.object({
- id: z.string().optional(),
- title: z.union([z.string(), Tag.ref]),
- body: z.string().optional(),
- bgColor: contextCardBgColorSchema.optional(),
- bgImageSrc: z.string().optional(),
- bgImageAlt: z.string().optional(),
-});
-
-export type ContextCardItemProps = z.infer;
-
-export const ContextCardItem = defineComponent({
- name: "ContextCardItem",
- props: ContextCardItemSchema,
- description:
- "A single card inside a ContextCardBlock: a title (plain string or Tag), an optional markdown body, and an optional gray tint or background image.",
- component: () => null,
-});
-
-export const ContextCardBlockSchema = z.object({
- items: z.array(ContextCardItem.ref).min(2),
- layout: z.enum(["grid", "carousel"]).default("grid"),
- responsive: z.boolean().default(true),
- action: actionPropSchema.optional(),
- gap: z.union([z.number(), z.string()]).optional(),
-});
-
-export type ContextCardBlockProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/DatePicker/index.tsx b/packages/react-ui/src/genui-lib/DatePicker/index.tsx
index 3ce388cb79..6e34327f52 100644
--- a/packages/react-ui/src/genui-lib/DatePicker/index.tsx
+++ b/packages/react-ui/src/genui-lib/DatePicker/index.tsx
@@ -7,6 +7,7 @@ import {
useIsStreaming,
useStateField,
} from "@openuidev/react-lang";
+import clsx from "clsx";
import React from "react";
import { DatePicker as OpenUIDatePicker } from "../../components/DatePicker";
import { DatePickerSchema } from "./schema";
@@ -16,7 +17,8 @@ export { DatePickerSchema } from "./schema";
export const DatePicker = defineComponent({
name: "DatePicker",
props: DatePickerSchema,
- description: "",
+ description:
+ 'A date field with a calendar inside a FormControl: DatePicker(name, mode?, rules?, value?, variant?), e.g. DatePicker("travel-dates", "range", { required: true }). mode "single" (default) picks one date; "range" picks a start and end, for trips, bookings or report periods. value binds a $variable. variant "inline" is borderless until hover or focus, for table cells and dense rows; use "default" everywhere else.',
component: ({ props }) => {
const isStreaming = useIsStreaming();
const formValidation = useFormValidation();
@@ -41,9 +43,14 @@ export const DatePicker = defineComponent({
}
};
+ const className = clsx("openui-genui-date-picker", {
+ "openui-genui-date-picker--inline": props.variant === "inline",
+ });
+
if (mode === "range") {
return (
null,
+});
+
+const EditableTableSchema = createEditableTableSchema(EditableCol);
+type EditableTableProps = z.infer;
+
+const CELL_TYPE: Record = {
+ text: "text",
+ number: "number",
+ date: "date-single",
+ select: "select",
+ url: "url",
+};
+
+type ColNode = { props?: Partial & { options?: unknown[]; icon?: unknown } };
+type SelectItemNode = { props?: { value?: string; label?: string } };
+
+function toColumn(node: ColNode): EditableTableColumn | null {
+ const p = node?.props;
+ if (!p?.key) return null;
+ const options = (p.options as SelectItemNode[] | undefined)
+ ?.map((o) => o?.props)
+ .filter((o): o is { value: string; label?: string } => !!o?.value)
+ .map((o) => ({ value: o.value, label: o.label || o.value }));
+ return {
+ key: p.key,
+ header: p.label ?? "",
+ type: CELL_TYPE[p.type ?? "text"] ?? "text",
+ icon: p.icon ? renderIconSlot(p.icon) : undefined,
+ width: p.width,
+ options,
+ };
+}
+
function EditableTableRenderer({ props }: ComponentRenderProps) {
const triggerAction = useTriggerAction();
const setFieldValue = useSetFieldValue();
@@ -25,8 +72,18 @@ function EditableTableRenderer({ props }: ComponentRenderProps
+ (props.columns as ColNode[])
+ .map(toColumn)
+ .filter((c): c is EditableTableColumn => c !== null),
+ [props.columns],
+ );
+ // Rows are bare value arrays; their index is the row id.
+ const initialData = useMemo(
+ () => props.rows.map((values, i) => ({ id: String(i), values })),
+ [props.rows],
+ );
const columnKeys = useMemo(() => columns.map((c) => c.key), [columns]);
@@ -127,7 +184,6 @@ function EditableTableRenderer({ props }: ComponentRenderProps;
-export type EditableTableDataRow = { id: string; values: (string | number)[] };
+export function createEditableTableSchema(EditableCol: { ref: any }) {
+ return z.object({
+ /** Unique name of the table; used as the form field name for the edited data */
+ name: z.string().default(""),
+ columns: z.array(EditableCol.ref).default([]),
+ /** Rows of cell values, ordered positionally to match `columns` */
+ rows: z.array(z.array(z.union([z.string(), z.number(), z.null()]))).default([]),
+ });
+}
+
+export type EditableColProps = z.infer;
+export type EditableTableDataRow = { id: string; values: (string | number | null)[] };
export type EditableTableData = EditableTableDataRow[];
diff --git a/packages/react-ui/src/genui-lib/Emoji/index.tsx b/packages/react-ui/src/genui-lib/Emoji/index.tsx
new file mode 100644
index 0000000000..201e1031d1
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Emoji/index.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Emoji as OpenUIEmoji } from "../../components/Emoji";
+import { EmojiSchema, type EmojiProps } from "./schema";
+
+export * from "./schema";
+
+function EmojiRenderer({ props }: ComponentRenderProps) {
+ return ;
+}
+
+export const Emoji = defineComponent({
+ name: "Emoji",
+ props: EmojiSchema,
+ description:
+ 'A single emoji sized like an Icon, for a touch of expression in generated content: Emoji("🎉", "Celebration"). It fills the leading slot of a LeadingText or the icon of a Callout and never stands alone; for an emoji inside a sentence, type it in the Markdown text. Use it rarely, at most one or two per response, in content where it adds tone an Icon can\'t, e.g. Callout("success", "**You\'re all set!** Your trip is booked.", null, Emoji("🎉")) or LeadingText(Emoji("🌸"), Text("Cherry blossom season peaks in early April")). Never in UI chrome: dashboards, forms, tables, data rows, buttons, statuses and navigation use Icon. Don\'t stack several or use one to repeat what the text says. label is the accessible name; omit it when the emoji is decorative.',
+ component: EmojiRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/Emoji/schema.ts b/packages/react-ui/src/genui-lib/Emoji/schema.ts
new file mode 100644
index 0000000000..4224c2547a
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Emoji/schema.ts
@@ -0,0 +1,8 @@
+import { z } from "zod/v4";
+
+export const EmojiSchema = z.object({
+ emoji: z.string(),
+ label: z.string().optional(),
+});
+
+export type EmojiProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/EntityList/index.tsx b/packages/react-ui/src/genui-lib/EntityList/index.tsx
index cfb01d5b39..74e102d9cd 100644
--- a/packages/react-ui/src/genui-lib/EntityList/index.tsx
+++ b/packages/react-ui/src/genui-lib/EntityList/index.tsx
@@ -1,19 +1,32 @@
"use client";
import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
-import { EntityList as OpenUIEntityList } from "../../components/EntityList";
+import {
+ EntityList as OpenUIEntityList,
+ type EntityListRow as OpenUIEntityListRow,
+} from "../../components/EntityList";
import { asArray } from "../helpers";
+import { renderOneOrMany } from "../oneOrMany";
import { EntityListSchema, type EntityListProps, type EntityListRow } from "./schema";
export * from "./schema";
-function EntityListRenderer({ props }: ComponentRenderProps) {
+function EntityListRenderer({ props, renderNode }: ComponentRenderProps) {
+ // Strings pass through as text; EntityTitle, LeadingText, Metric, Tag and
+ // Text nodes render as themselves.
+ const cell = (value: unknown) => renderOneOrMany(value, renderNode);
+ // Rows are EntityListItem nodes; a bare { left, right } object also works.
+ const toRow = (node?: unknown): OpenUIEntityListRow | undefined => {
+ if (!node) return undefined;
+ const row = ((node as { props?: EntityListRow }).props ?? node) as EntityListRow;
+ return { left: cell(row.left), right: cell(row.right), rightVariant: row.rightVariant };
+ };
return (
toRow(r)!)}
size={props.size}
- header={props.header}
- footer={props.footer}
+ header={toRow(props.header)}
+ footer={toRow(props.footer)}
/>
);
}
@@ -22,6 +35,6 @@ export const EntityList = defineComponent({
name: "EntityList",
props: EntityListSchema,
description:
- "Two-column key/value rows (left label, right value). size 'default' supports optional header and footer rows; rightVariant 'number' uses tabular numbers.",
+ 'A compact list of EntityListItem rows, each a left item and an optional right value: EntityListItem(left, right?, rightVariant?). Keep left a plain string for simple key/value facts, e.g. EntityListItem("Check-in", "3pm"). Use an EntityTitle on the left when each row is a person, company or product the reader should recognise, e.g. a team list or top customers, and a LeadingText when each row is a fact with an icon cue. right is a value string, a Metric (a number with a change), a Tag (a status) or a Text; leave it out for a plain list of entities. rightVariant "number" lines up string numbers with tabular figures. size "default" supports an optional header row for column labels and a footer row for a total, both EntityListItems; "small" has neither and is only for a CompositeCardItem body. When a left or right cell needs two or more pieces side by side, pass an array and they sit on one line, e.g. EntityListItem("Trail Runner", [Metric("$89", null, null, "sm"), Tag("In stock", null, "success")]); a single piece goes in directly.',
component: EntityListRenderer,
});
diff --git a/packages/react-ui/src/genui-lib/EntityList/schema.ts b/packages/react-ui/src/genui-lib/EntityList/schema.ts
index 6107bdb1be..4e7beb728c 100644
--- a/packages/react-ui/src/genui-lib/EntityList/schema.ts
+++ b/packages/react-ui/src/genui-lib/EntityList/schema.ts
@@ -1,22 +1,17 @@
import { z } from "zod/v4";
-
-export const EntityListRowSchema = z.object({
- left: z.string(),
- right: z.string(),
- rightVariant: z.enum(["text", "number"]).default("text"),
-});
+import { EntityListItem, type EntityListItemProps } from "../EntityListItem";
export const EntityListSchema = z
.object({
- rows: z.array(EntityListRowSchema).default([]),
+ rows: z.array(EntityListItem.ref).default([]),
size: z.enum(["small", "default"]).default("default"),
- header: EntityListRowSchema.optional(),
- footer: EntityListRowSchema.optional(),
+ header: z.optional(EntityListItem.ref),
+ footer: z.optional(EntityListItem.ref),
})
.refine(({ size, header, footer }) => size === "default" || (!header && !footer), {
message: 'EntityList size="small" does not support header or footer.',
path: ["size"],
});
-export type EntityListRow = z.infer;
+export type EntityListRow = EntityListItemProps;
export type EntityListProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/EntityListItem/index.tsx b/packages/react-ui/src/genui-lib/EntityListItem/index.tsx
new file mode 100644
index 0000000000..31269b3fcf
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/EntityListItem/index.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { defineComponent } from "@openuidev/react-lang";
+import { z } from "zod/v4";
+import { EntityTitle } from "../EntityTitle";
+import { LeadingText } from "../LeadingText";
+import { Metric } from "../Metric";
+import { Tag } from "../Tag";
+import { Text } from "../Text";
+import { oneOrMany } from "../oneOrMany";
+
+export const EntityListItemSchema = z.object({
+ left: oneOrMany(z.string(), EntityTitle.ref, LeadingText.ref),
+ right: oneOrMany(z.string(), Metric.ref, Tag.ref, Text.ref).optional(),
+ rightVariant: z.enum(["text", "number"]).optional(),
+});
+
+export type EntityListItemProps = z.infer;
+
+export const EntityListItem = defineComponent({
+ name: "EntityListItem",
+ props: EntityListItemSchema,
+ description:
+ 'One row of an EntityList: EntityListItem(left, right?, rightVariant?), e.g. EntityListItem("Check-in", "3pm"). left is a plain string for a key/value fact, an EntityTitle for a person, company or product, a LeadingText for a fact with an icon, or an array of these when it needs two pieces side by side. right is an optional value: a string, a Metric for a number with its change, a Tag for a status, a Text, or an array of these shown on one line; leave it out for a plain list of entities. rightVariant "number" lines up string numbers with tabular figures; a Metric doesn\'t need it. The same item also serves as the EntityList header (column labels, e.g. EntityListItem("Item", "Price")) and footer (a total, e.g. EntityListItem("Total", "$142.50", "number")).',
+ component: () => null,
+});
diff --git a/packages/react-ui/src/genui-lib/EntityTitle/index.tsx b/packages/react-ui/src/genui-lib/EntityTitle/index.tsx
new file mode 100644
index 0000000000..de3c3d240c
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/EntityTitle/index.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { EntityTitle as OpenUIEntityTitle } from "../../components/EntityTitle";
+import { renderIconSlot } from "../iconSlot";
+import { renderOneOrMany } from "../oneOrMany";
+import { EntityTitleSchema, type EntityTitleProps } from "./schema";
+
+export * from "./schema";
+
+type LeadingNode = { typeName?: string; props?: { name?: string } };
+
+function EntityTitleRenderer({ props, renderNode }: ComponentRenderProps) {
+ const leading = props.leading as LeadingNode | undefined;
+ // A bare Icon becomes a neutral Avatar badge, so every leading reads as one
+ // filled shape beside the text.
+ const node =
+ leading?.typeName === "Icon" && leading.props?.name
+ ? { typeName: "Avatar", props: { name: leading.props.name, icon: leading } }
+ : leading;
+ return (
+
+ );
+}
+
+export const EntityTitle = defineComponent({
+ name: "EntityTitle",
+ props: EntityTitleSchema,
+ description:
+ 'A leading visual with a title and a one-line subtitle, naming a person, company, product, place or topic, e.g. EntityTitle(Avatar("Stripe", null, "rounded", "stripe.com", null, Icon("building-2", "business")), "Stripe", "Payments infrastructure"). leading is usually an Avatar: a photo (src) for a person you have a real photo URL for, a logo (domain, shape "rounded") for a company, or a tinted icon badge (icon, color) for anything else. A bare Icon renders as a neutral badge; an Emoji also works in rare expressive content. leading is 44px, or 48px in the vertical layout; the title is 16px medium. subtitle is required. A string is one line of secondary context such as a role, category or time range. To say more on that line, compose it: a Tag for a status (EntityTitle(Avatar("Ada Lovelace", null, null, null, null, Icon("user", "people")), "Ada Lovelace", Tag("On call", null, "success"))), a Metric for a price or value with its change (Metric("$29", "-12%", null, "sm")), a Link for a source (Link("stripe.com", "https://stripe.com")), a TagBlock for a few labels, or a LeadingText for a fact with an icon. If there is nothing useful to put under the title, use LeadingText instead. Use it standalone for a list of people, companies or places, and in a CompositeCardItem header to say what the card is about. Use Heading for a section title over UI. layout "vertical" stacks the leading above the text, for narrow grid cards. When the subtitle needs two or more pieces side by side, pass them as an array and they sit on one line, e.g. [Metric("$89", null, null, "sm"), Tag("In stock", null, "success")]; a single piece goes in directly.',
+ component: EntityTitleRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/EntityTitle/schema.ts b/packages/react-ui/src/genui-lib/EntityTitle/schema.ts
new file mode 100644
index 0000000000..c758f5eed8
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/EntityTitle/schema.ts
@@ -0,0 +1,28 @@
+import { z } from "zod/v4";
+import { Avatar } from "../Avatar";
+import { Emoji } from "../Emoji";
+import { Icon } from "../Icon";
+import { LeadingText } from "../LeadingText";
+import { Link } from "../Link";
+import { Metric } from "../Metric";
+import { Tag } from "../Tag";
+import { TagBlock } from "../TagBlock";
+import { Text } from "../Text";
+import { oneOrMany } from "../oneOrMany";
+
+export const EntityTitleSchema = z.object({
+ leading: z.union([Avatar.ref, Icon.ref, Emoji.ref]),
+ title: z.string(),
+ subtitle: oneOrMany(
+ z.string(),
+ Text.ref,
+ Metric.ref,
+ Tag.ref,
+ TagBlock.ref,
+ LeadingText.ref,
+ Link.ref,
+ ),
+ layout: z.enum(["horizontal", "vertical"]).optional(),
+});
+
+export type EntityTitleProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/FollowUpBlock/index.tsx b/packages/react-ui/src/genui-lib/FollowUpBlock/index.tsx
index 6bccd43ff1..5f6fd3f29b 100644
--- a/packages/react-ui/src/genui-lib/FollowUpBlock/index.tsx
+++ b/packages/react-ui/src/genui-lib/FollowUpBlock/index.tsx
@@ -1,6 +1,7 @@
"use client";
import { defineComponent, useTriggerAction } from "@openuidev/react-lang";
+import { Plus } from "lucide-react";
import { z } from "zod/v4";
import { FollowUpBlock as OpenUIFollowUpBlock } from "../../components/FollowUpBlock";
import { FollowUpItem as OpenUIFollowUpItem } from "../../components/FollowUpItem";
@@ -20,7 +21,14 @@ export const FollowUpBlock = defineComponent({
{items.map((item, i) => {
const text = String(item?.props?.text ?? "");
- return triggerAction(text)} />;
+ return (
+ }
+ onClick={() => triggerAction(text)}
+ />
+ );
})}
);
diff --git a/packages/react-ui/src/genui-lib/Form/FormRow.tsx b/packages/react-ui/src/genui-lib/Form/FormRow.tsx
new file mode 100644
index 0000000000..ae4d8560e4
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Form/FormRow.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { defineComponent } from "@openuidev/react-lang";
+import React from "react";
+import { z } from "zod/v4";
+import { FormControl } from "../FormControl";
+
+export const FormRowSchema = z.object({
+ fields: z.array(FormControl.ref),
+});
+
+export const FormRow = defineComponent({
+ name: "FormRow",
+ props: FormRowSchema,
+ description:
+ "Puts 2–3 short, related FormControls side by side inside a Form, such as first and last name or city and postcode: FormRow([firstName, lastName]). The fields share the width equally and stack into one column when the Form is narrow. Mix FormRows with full-width FormControls in the same Form fields list; long fields like TextArea stay full width.",
+ component: ({ props, renderNode }) => {
+ const fields = (props.fields ?? []) as unknown[];
+ return (
+
@@ -33,6 +38,7 @@ export const FormRenderer = ({ props, renderNode }: FormRenderProps) => {
export const Form = defineComponent({
name: "Form",
props: FormSchema,
- description: "Form container with fields and explicit action buttons",
+ description:
+ "Form container for essential input that cannot be picked from options, such as a name, an email, exact dates or free text: Form(name, buttons, fields), with one FormControl or FormRow per field and exactly one primary submit Button. Keep it to the fewest fields. When the user is choosing between options, offer tappable ImageCard or InsightCard tiles or Buttons instead of a Form.",
component: FormRenderer,
});
diff --git a/packages/react-ui/src/genui-lib/Form/schema.ts b/packages/react-ui/src/genui-lib/Form/schema.ts
index 57909dc083..2ec458b873 100644
--- a/packages/react-ui/src/genui-lib/Form/schema.ts
+++ b/packages/react-ui/src/genui-lib/Form/schema.ts
@@ -1,9 +1,10 @@
import { z } from "zod/v4";
import { Buttons } from "../Buttons";
import { FormControl } from "../FormControl";
+import { FormRow } from "./FormRow";
export const FormSchema = z.object({
name: z.string(),
buttons: Buttons.ref,
- fields: z.array(FormControl.ref).default([]),
+ fields: z.array(z.union([FormControl.ref, FormRow.ref])).default([]),
});
diff --git a/packages/react-ui/src/genui-lib/Form/stories/Form.stories.tsx b/packages/react-ui/src/genui-lib/Form/stories/Form.stories.tsx
index 4b28630d1d..53fe95ff7f 100644
--- a/packages/react-ui/src/genui-lib/Form/stories/Form.stories.tsx
+++ b/packages/react-ui/src/genui-lib/Form/stories/Form.stories.tsx
@@ -50,14 +50,14 @@ export default meta;
type Story = StoryObj;
const contactForm = `root = Stack([title, form])
-title = TextContent("Contact Us", "large-heavy")
+title = Text("Contact Us", "h2")
form = Form("contact", btns, [nameField, emailField, countryField, msgField])
nameField = FormControl("Name", Input("name", "Your name", "text", { required: true, minLength: 2 }))
emailField = FormControl("Email", Input("email", "you@example.com", "email", { required: true, email: true }), "We never share your email.")
countryField = FormControl("Country", Select("country", countryOpts, "Select a country...", { required: true }))
msgField = FormControl("Message", TextArea("message", "Tell us more...", 4, { required: true, minLength: 10 }))
countryOpts = [SelectItem("us", "United States"), SelectItem("uk", "United Kingdom"), SelectItem("de", "Germany")]
-btns = Buttons([Button("Submit", Action([@ToAssistant("Submit")]), "primary"), Button("Cancel", Action([@ToAssistant("Cancel")]), "secondary")])`;
+btns = Buttons([Button("Submit", @ToAssistant("Submit"), "primary"), Button("Cancel", @ToAssistant("Cancel"), "secondary")])`;
export const Default: Story = {
args: { response: contactForm },
@@ -86,7 +86,7 @@ food = CheckBoxItem("Food", "Local cuisine and restaurants", "food")
planField = FormControl("Plan", RadioGroup("plan", [basic, premium]))
basic = RadioItem("Basic", "Self-guided itinerary", "basic")
premium = RadioItem("Premium", "Includes a personal guide", "premium")
-btns = Buttons([Button("Save preferences", Action([@ToAssistant("Save preferences")]), "primary")])`;
+btns = Buttons([Button("Save preferences", @ToAssistant("Save preferences"), "primary")])`;
/** Form accepts every FormControl input type, not just text fields. */
export const AllControlTypes: Story = {
diff --git a/packages/react-ui/src/genui-lib/FormControl/index.tsx b/packages/react-ui/src/genui-lib/FormControl/index.tsx
index f0969a258f..a2229e4968 100644
--- a/packages/react-ui/src/genui-lib/FormControl/index.tsx
+++ b/packages/react-ui/src/genui-lib/FormControl/index.tsx
@@ -30,14 +30,17 @@ export const FormControlRenderer = ({ props, renderNode }: FormControlRenderProp
const isRequired = inputObj?.type === "element" && inputObj.props?.rules?.required === true;
return (
-
-
+
+
{props.label as string}
{renderNode(props.input)}
{error ? (
-
-
+
+
{error}
) : props.hint ? (
diff --git a/packages/react-ui/src/genui-lib/Grid/index.tsx b/packages/react-ui/src/genui-lib/Grid/index.tsx
new file mode 100644
index 0000000000..f2c234f3b8
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Grid/index.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Grid as OpenUIGrid } from "../../components/Grid";
+import { NestedCardContext } from "../nestedCardContext";
+import { GridSchema, type GridProps } from "./schema";
+
+export * from "./schema";
+
+// Children are loose so the chat Grid, with its own children union, can share this renderer.
+type GridRenderProps = ComponentRenderProps & { children: unknown[] }>;
+
+/** Shared renderer — also used by the chat library's Grid variant (typed children). */
+export function GridRenderer({ props, renderNode }: GridRenderProps) {
+ return (
+
+ {renderNode(props.children)}
+
+ );
+}
+
+export const GRID_DESCRIPTION = `Equal-width columns for dashboards: Grid(children, columns?). columns 2 (default) | 3 | 4 is the most at full width; narrower screens drop to fewer automatically, so never build rows by hand (4 steps down to 2, then 1; 3 and 2 step down to 1). Cells are 12px apart and stretch to the row's height. Fill each cell with one block, usually a Card (outlined by default), or a chart or a card block, and keep the cells of one Grid the same kind, titled with the Card's own title (not a Heading child), e.g. a KPI row Grid([Card([Metric("$48,200", "+8.1%")], null, "Revenue"), ...], 4) above a chart Grid([Card([chart1], null, "Revenue"), Card([chart2], null, "Orders")]). In a chat response the Card's title comes after sources and variant: Card([chart1], null, null, "Revenue"). For a row of mixed widths use a Stack row; for 2+ items of one card type use its card block.`;
+
+export const Grid = defineComponent({
+ name: "Grid",
+ props: GridSchema,
+ description: GRID_DESCRIPTION,
+ component: GridRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/Grid/schema.ts b/packages/react-ui/src/genui-lib/Grid/schema.ts
new file mode 100644
index 0000000000..5d8216d30e
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Grid/schema.ts
@@ -0,0 +1,27 @@
+import { z } from "zod/v4";
+import { CardSchema } from "../Card/schema";
+import { Carousel } from "../Carousel";
+import { Stack } from "../Stack";
+import { Tabs } from "../Tabs";
+import { ContentChildUnion } from "../unions";
+
+// Anything a Section holds, plus Card. Built on first use because Card and
+// Section hold a Grid in turn.
+let gridChildUnion: z.ZodType | undefined;
+export const getGridChildUnion = (): z.ZodType =>
+ (gridChildUnion ??= z.union([
+ ...ContentChildUnion.options,
+ CardSchema,
+ Tabs.ref,
+ Carousel.ref,
+ Stack.ref,
+ ]));
+
+export const GridSchema = z.object({
+ get children(): z.ZodArray {
+ return z.array(getGridChildUnion());
+ },
+ columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
+});
+
+export type GridProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/Heading/index.tsx b/packages/react-ui/src/genui-lib/Heading/index.tsx
new file mode 100644
index 0000000000..af66af0d7e
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Heading/index.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Heading as OpenUIHeading } from "../../components/Heading";
+import { getTextFont } from "../Text";
+import { HeadingSchema, type HeadingProps } from "./schema";
+
+export * from "./schema";
+
+// Title follows the Text heading scale; the description is Text body (16px)
+// in secondary at both sizes.
+const SIZES = {
+ default: { title: "h3", description: "body" },
+ lg: { title: "h1", description: "body" },
+} as const;
+
+function HeadingRenderer({ props }: ComponentRenderProps) {
+ const size = SIZES[props.size ?? "default"] ?? SIZES.default;
+ return (
+
+ );
+}
+
+export const Heading = defineComponent({
+ name: "Heading",
+ props: HeadingSchema,
+ description:
+ 'A title with an optional one-line description under it, for titling a UI section such as a card group, chart or form. Most titles need no description. Add one only when it carries a fact the title lacks (a scope, unit, time range or what to do), e.g. Heading("Top products", "By units sold, last 30 days"). Never use it to greet, introduce yourself, restate the title or say what the section contains: not Heading("Hello!", "I\'m your AI assistant"), not Heading("Revenue", "Revenue overview"). size "default" (a section title inside a response) | "lg" (a hero title at the top of a page). Use Heading when a title needs that supporting line; use Text h1/h2/h3 for a bare title, and Markdown # headings for titled prose answers.',
+ component: HeadingRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/Heading/schema.ts b/packages/react-ui/src/genui-lib/Heading/schema.ts
new file mode 100644
index 0000000000..527f67bce5
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Heading/schema.ts
@@ -0,0 +1,9 @@
+import { z } from "zod/v4";
+
+export const HeadingSchema = z.object({
+ title: z.string(),
+ description: z.string().optional(),
+ size: z.enum(["default", "lg"]).optional(),
+});
+
+export type HeadingProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/Icon/index.tsx b/packages/react-ui/src/genui-lib/Icon/index.tsx
index 4555ed02e4..8669dbd759 100644
--- a/packages/react-ui/src/genui-lib/Icon/index.tsx
+++ b/packages/react-ui/src/genui-lib/Icon/index.tsx
@@ -15,6 +15,6 @@ export const Icon = defineComponent({
name: "Icon",
props: IconSchema,
description:
- "A lucide icon by kebab-case name (e.g. 'circle-check'). Optional category picks a topical fallback when the name doesn't resolve.",
+ 'A lucide line icon, named by its exact kebab-case lucide name: Icon("circle-check"), Icon("calendar"), Icon("map-pin"). It never stands alone: it fills the icon slot of another component (LeadingText, Tag, ChipItem, Avatar, EntityTitle, IconButton, OptionCard), and that host sets its size, stroke and color, so Icon has no size or color of its own. In LeadingText it scales with the Text (about 1.1–1.2em, 1.5 stroke) and takes the text\'s color. The name is looked up in lucide when it renders; an unknown or misspelled name shows a plain circle, so use only names you know exist and prefer common ones over guesses. category is optional and does not change the icon. Use Icon for a concept, fact or status; for a specific real person or company put an Avatar in the same slot.',
component: IconRenderer,
});
diff --git a/packages/react-ui/src/genui-lib/IconButton/index.tsx b/packages/react-ui/src/genui-lib/IconButton/index.tsx
index 55604567d8..b2aba178c6 100644
--- a/packages/react-ui/src/genui-lib/IconButton/index.tsx
+++ b/packages/react-ui/src/genui-lib/IconButton/index.tsx
@@ -23,8 +23,15 @@ function IconButtonRenderer({ props }: ComponentRenderProps) {
}
variant={props.variant ?? "secondary"}
- size={props.size ?? "medium"}
- shape={props.shape ?? "square"}
+ // Agent icon buttons are circles matching the Button pills; the
+ // genui classes set size and color, the base ones stay as-is.
+ size="medium"
+ shape="circle"
+ className={
+ props.size === "large"
+ ? "openui-genui-icon-button openui-genui-icon-button--large"
+ : "openui-genui-icon-button openui-genui-icon-button--default"
+ }
disabled={isStreaming}
aria-label={props.name}
onClick={() => triggerAction(props.name, formName, props.action as ActionPlan | undefined)}
@@ -36,6 +43,6 @@ export const IconButton = defineComponent({
name: "IconButton",
props: IconButtonSchema,
description:
- "Icon-only button. name is the accessible label and the action label; icon is an Icon; action fires on click.",
+ 'A round icon-only button: IconButton(name, icon, action?, variant?, size?). name is the accessible label and the action\'s label, so it must say what the click does, e.g. IconButton("Share", Icon("share-2"), @ToAssistant("Share this itinerary"), "secondary"). Use it only where the icon alone is unmistakable (share, close, more, next) and space is tight, like a card corner or a row of tools; anything less obvious needs a Button with a label. variant "primary" (solid) | "secondary" | "tertiary" (lowest emphasis, the usual choice). size "default" (32px) for almost every case; "large" (40px) only beside a large Button.',
component: IconButtonRenderer,
});
diff --git a/packages/react-ui/src/genui-lib/IconButton/schema.ts b/packages/react-ui/src/genui-lib/IconButton/schema.ts
index 92b5dd4b93..b52756b084 100644
--- a/packages/react-ui/src/genui-lib/IconButton/schema.ts
+++ b/packages/react-ui/src/genui-lib/IconButton/schema.ts
@@ -7,7 +7,8 @@ export const IconButtonSchema = z.object({
icon: Icon.ref,
action: actionPropSchema.optional(),
variant: z.enum(["primary", "secondary", "tertiary"]).optional(),
- size: z.enum(["extra-small", "small", "medium", "large"]).optional(),
- shape: z.enum(["square", "circle"]).optional(),
+ // Round only, "default" (32px) | "large" (40px). Older sizes and the old
+ // shape argument are dropped by the parser and render at the default.
+ size: z.enum(["default", "large"]).optional(),
});
export type IconButtonProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/IconText/index.tsx b/packages/react-ui/src/genui-lib/IconTitle/index.tsx
similarity index 71%
rename from packages/react-ui/src/genui-lib/IconText/index.tsx
rename to packages/react-ui/src/genui-lib/IconTitle/index.tsx
index cace3fcda3..90cd1d6dc8 100644
--- a/packages/react-ui/src/genui-lib/IconText/index.tsx
+++ b/packages/react-ui/src/genui-lib/IconTitle/index.tsx
@@ -2,11 +2,11 @@
import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
import { IconText as OpenUIIconText } from "../../components/IconText";
-import { IconTextSchema, type IconTextProps } from "./schema";
+import { IconTitleSchema, type IconTitleProps } from "./schema";
export * from "./schema";
-function IconTextRenderer({ props }: ComponentRenderProps) {
+function IconTextRenderer({ props }: ComponentRenderProps) {
return (
) {
);
}
-export const IconText = defineComponent({
- name: "IconText",
- props: IconTextSchema,
+export const IconTitle = defineComponent({
+ name: "IconTitle",
+ props: IconTitleSchema,
description:
"An icon badge with a title and optional subtitle, laid out horizontally or vertically. iconVariant sets the badge color.",
component: IconTextRenderer,
diff --git a/packages/react-ui/src/genui-lib/IconText/schema.ts b/packages/react-ui/src/genui-lib/IconTitle/schema.ts
similarity index 81%
rename from packages/react-ui/src/genui-lib/IconText/schema.ts
rename to packages/react-ui/src/genui-lib/IconTitle/schema.ts
index 74c7e576ce..cd890615a6 100644
--- a/packages/react-ui/src/genui-lib/IconText/schema.ts
+++ b/packages/react-ui/src/genui-lib/IconTitle/schema.ts
@@ -1,7 +1,7 @@
import { z } from "zod/v4";
import { Icon } from "../Icon";
-export const IconTextSchema = z.object({
+export const IconTitleSchema = z.object({
icon: Icon.ref,
iconVariant: z
.enum(["neutral", "info", "success", "warning", "danger", "inverted", "filled", "soft"])
@@ -12,4 +12,4 @@ export const IconTextSchema = z.object({
bold: z.boolean().default(false),
layout: z.enum(["horizontal", "vertical"]).default("horizontal"),
});
-export type IconTextProps = z.infer;
+export type IconTitleProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/Image/index.tsx b/packages/react-ui/src/genui-lib/Image/index.tsx
index 5b3cf9b66f..93cfc3b631 100644
--- a/packages/react-ui/src/genui-lib/Image/index.tsx
+++ b/packages/react-ui/src/genui-lib/Image/index.tsx
@@ -9,8 +9,16 @@ export { ImageSchema } from "./schema";
export const Image = defineComponent({
name: "Image",
props: ImageSchema,
- description: "Image with alt text and optional URL",
+ description:
+ 'Image with alt text and a URL. aspectRatio crops it to a fixed frame (default "3:2"): "1:1" for logos, avatars and product shots, "4:3" or "3:2" for photos, "16:9" for hero and video-like media, "3:4" or "9:16" for portraits and phone screenshots; "auto" keeps the image\'s own proportions (charts, diagrams, screenshots that must not be cropped).',
component: ({ props }) => (
-
+
),
});
diff --git a/packages/react-ui/src/genui-lib/Image/schema.ts b/packages/react-ui/src/genui-lib/Image/schema.ts
index 541843bfc9..59c9fc6428 100644
--- a/packages/react-ui/src/genui-lib/Image/schema.ts
+++ b/packages/react-ui/src/genui-lib/Image/schema.ts
@@ -3,4 +3,5 @@ import { z } from "zod/v4";
export const ImageSchema = z.object({
alt: z.string(),
src: z.string().optional(),
+ aspectRatio: z.enum(["auto", "1:1", "4:3", "3:2", "16:9", "3:4", "9:16"]).optional(),
});
diff --git a/packages/react-ui/src/genui-lib/ImageCard/index.tsx b/packages/react-ui/src/genui-lib/ImageCard/index.tsx
new file mode 100644
index 0000000000..6d00b17500
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/ImageCard/index.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import type { SubComponentOf } from "@openuidev/react-lang";
+import {
+ ComponentRenderProps,
+ defineComponent,
+ useFormName,
+ useIsStreaming,
+ useTriggerAction,
+} from "@openuidev/react-lang";
+import { ContextCardBlock as OpenUIContextCardBlock } from "../../components/ContextCardBlock";
+import { withItemContext } from "../cardActionUtils";
+import { insightContent, insightOverline } from "../InsightCard";
+import { renderOneOrMany } from "../oneOrMany";
+import { ImageCardSchema, type ImageCardItemProps, type ImageCardProps } from "./schema";
+
+export * from "./schema";
+
+type ImageCardItemElement = SubComponentOf;
+
+function ImageCardRenderer({ props, renderNode }: ComponentRenderProps) {
+ const triggerAction = useTriggerAction();
+ const formName = useFormName();
+ const isStreaming = useIsStreaming();
+
+ const items = (props.items ?? []) as ImageCardItemElement[];
+ const isClickable = Boolean(props.action) && !isStreaming;
+
+ const handleItemClick = (index: number) => {
+ const item = items[index];
+ if (!item || !props.action || isStreaming) return;
+ const { id, title, overline, src, alt } = item.props;
+ triggerAction(
+ title || id || `Image card ${index + 1}`,
+ formName,
+ withItemContext(props.action, {
+ itemIndex: index,
+ itemId: id,
+ itemTitle: title,
+ itemOverline: overline,
+ itemBgImageSrc: src,
+ itemBgImageAlt: alt,
+ }),
+ );
+ };
+
+ return (
+ {
+ const { id, src, alt, actions } = item.props;
+ return {
+ id,
+ title: insightOverline(item),
+ content: insightContent(item, renderNode),
+ topRight: actions ? renderOneOrMany(actions, renderNode) : undefined,
+ bgImageSrc: src,
+ bgImageAlt: alt,
+ };
+ })}
+ />
+ );
+}
+
+export const ImageCard = defineComponent({
+ name: "ImageCard",
+ props: ImageCardSchema,
+ description:
+ 'A grid or carousel of 2 or more tall photo tiles for places, products, media or inspiration: ImageCard([item1, item2], layout?, responsive?, action?, gap?), e.g. ImageCard([lm1, lm2, lm3], "carousel", true, @ToAssistant("Tell me more about this Paris landmark")). Each tile is a real photo behind an overline (top left), optional IconButton actions (top right), and a title with a composable subtitle (bottom). Use it only when every item has a real image URL from a tool result or the user; with no real photos, use InsightCard. layout "grid" (default) for a main side-by-side set, "carousel" for secondary browsing. Keep every item the same shape. action is one shared action for every tile, with the clicked tile\'s details attached, so keep its @ToAssistant message generic and never use @OpenUrl there; IconButtons keep their own actions. For a single picture that illustrates an answer use Image, and for products with a price and a buy button use CompositeCardBlock.',
+ component: ImageCardRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/ImageCard/schema.ts b/packages/react-ui/src/genui-lib/ImageCard/schema.ts
new file mode 100644
index 0000000000..63e2704966
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/ImageCard/schema.ts
@@ -0,0 +1,51 @@
+import { defineComponent } from "@openuidev/react-lang";
+import { z } from "zod/v4";
+import { actionPropSchema } from "../Action/schema";
+import { Avatar } from "../Avatar";
+import { Emoji } from "../Emoji";
+import { Icon } from "../Icon";
+import { IconButton } from "../IconButton";
+import { LeadingText } from "../LeadingText";
+import { Link } from "../Link";
+import { Metric } from "../Metric";
+import { oneOrMany } from "../oneOrMany";
+import { Tag } from "../Tag";
+import { Text } from "../Text";
+
+export const ImageCardItemSchema = z.object({
+ id: z.string().optional(),
+ src: z.string(),
+ title: z.string(),
+ subtitle: oneOrMany(
+ z.string(),
+ Text.ref,
+ Metric.ref,
+ Tag.ref,
+ LeadingText.ref,
+ Link.ref,
+ ).optional(),
+ overline: z.string().optional(),
+ leading: z.union([Avatar.ref, Icon.ref, Emoji.ref]).optional(),
+ alt: z.string().optional(),
+ actions: z.union([IconButton.ref, z.array(IconButton.ref)]).optional(),
+});
+
+export type ImageCardItemProps = z.infer;
+
+export const ImageCardItem = defineComponent({
+ name: "ImageCardItem",
+ props: ImageCardItemSchema,
+ description:
+ 'One tall photo tile: ImageCardItem(id?, src, title, subtitle?, overline?, leading?, alt?, actions?), e.g. ImageCardItem("eiffel-tower", "https://…/eiffel.jpg", "Eiffel Tower", ["Open daily 9am–midnight", Tag("Must-See", null, "info")], null, null, "Aerial view of the Eiffel Tower"). src is a real image URL from a tool result or the user, never invented, shown full-bleed behind the text with dark gradients so the white text stays readable. overline is an optional short label at the top left (a category such as "Museum"), with leading (an Icon, Avatar or Emoji) before it. title (22px) is the name of the place or item, and subtitle sits under it: a string, Text, Metric, Tag, LeadingText or Link, or an array of them on one line. actions are optional IconButtons at the top right, only for something that really happens: an @OpenUrl to the item\'s own page from a tool result, or a @ToAssistant follow-up such as IconButton("Hotels nearby", Icon("bed", "travel"), @ToAssistant("Show hotels near the Eiffel Tower")); never save, share or download, which you cannot perform. alt describes the photo.',
+ component: () => null,
+});
+
+export const ImageCardSchema = z.object({
+ items: z.array(ImageCardItem.ref).min(2),
+ layout: z.enum(["grid", "carousel"]).optional(),
+ responsive: z.boolean().optional(),
+ action: actionPropSchema.optional(),
+ gap: z.union([z.number(), z.string()]).optional(),
+});
+
+export type ImageCardProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/ImageGallery/index.tsx b/packages/react-ui/src/genui-lib/ImageGallery/index.tsx
index dd211518cf..58b6a8b8d5 100644
--- a/packages/react-ui/src/genui-lib/ImageGallery/index.tsx
+++ b/packages/react-ui/src/genui-lib/ImageGallery/index.tsx
@@ -9,7 +9,8 @@ export { ImageGallerySchema } from "./schema";
export const ImageGallery = defineComponent({
name: "ImageGallery",
props: ImageGallerySchema,
- description: "Gallery grid of images with modal preview",
+ description:
+ "A grid of real photos the reader can open full size: ImageGallery([{ src, alt, details? }, ...]). Use it near the top of a visual answer (a venue, destination, style or product line) to show 3–9 photos at once; details is an optional caption in the preview. Only real image URLs from a tool result or the user, never invented. For photos that are options to pick, use ImageCard instead.",
component: ({ props }) => {
const images = Array.isArray(props.images) ? props.images : [];
if (!images.length) return null;
diff --git a/packages/react-ui/src/genui-lib/ImageTitle/index.tsx b/packages/react-ui/src/genui-lib/ImageTitle/index.tsx
new file mode 100644
index 0000000000..18782d8d79
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/ImageTitle/index.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Fragment } from "react";
+import { ImageTitle as OpenUIImageTitle } from "../../components/ImageTitle";
+import { asArray } from "../helpers";
+import { renderOneOrMany } from "../oneOrMany";
+import { ImageTitleSchema, type ImageTitleProps } from "./schema";
+
+export * from "./schema";
+
+function ImageTitleRenderer({ props, renderNode }: ComponentRenderProps) {
+ const content = asArray(props.content);
+ return (
+
+ {content.length
+ ? content.map((line, i) => {renderOneOrMany(line, renderNode)})
+ : undefined}
+
+ );
+}
+
+export const ImageTitle = defineComponent({
+ name: "ImageTitle",
+ props: ImageTitleSchema,
+ description:
+ 'An Image with a short content stack under it, for one item in a set of products, articles, places or listings: ImageTitle(Image("Trail Runner shoe", "https://…/trail.jpg", "1:1"), [Text("Trail Runner", "body", true), Metric("$89", "-26%", null, "sm", "$120")]). content is usually two parts: a title, as Text(title, "body", true), then one secondary line. The image is already the visual, so never put an EntityTitle with an icon Avatar under it; use an EntityTitle only for a real person, brand or seller with their own photo or logo, such as a host or a store. For a place or thing, use the Text title, a secondary line such as LeadingText(Icon("map-pin", "travel"), Text("Kyoto, Japan"), "secondary"), and Tags. For an article that line is the author, inline with a very small avatar: LeadingText(Avatar("Ada Lovelace", null, null, null, "inline", Icon("user", "people")), Text("Ada Lovelace · 6 min read"), "secondary"). For a product it is a price Metric (with previous for a sale); for a place, a Tag, TagBlock or a short secondary Text. Keep it to 2–3 parts; longer copy belongs in Markdown. Use ImageTitle for 2 or more comparable items side by side in a grid or as Carousel slides, one Card per item (Carousel([Card([item1]), Card([item2]), Card([item3])])), giving every item the same parts in the same order and the same Image aspectRatio ("1:1" products, "4:3" places, "16:9" articles). For a single picture that illustrates an answer, use Image with Markdown around it instead. The Image src must be a real URL from a tool result or the user; with no real image, use EntityTitle instead. When one line of content needs two or more pieces side by side, such as a price and a stock status, pass them as an array and they sit on one line: [Metric("$89", null, null, "sm"), Tag("In stock", null, "success")]; a single piece goes in directly.',
+ component: ImageTitleRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/ImageTitle/schema.ts b/packages/react-ui/src/genui-lib/ImageTitle/schema.ts
new file mode 100644
index 0000000000..0c2f739703
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/ImageTitle/schema.ts
@@ -0,0 +1,30 @@
+import { z } from "zod/v4";
+import { EntityTitle } from "../EntityTitle";
+import { Heading } from "../Heading";
+import { Image } from "../Image";
+import { LeadingText } from "../LeadingText";
+import { Link } from "../Link";
+import { Metric } from "../Metric";
+import { Tag } from "../Tag";
+import { TagBlock } from "../TagBlock";
+import { Text } from "../Text";
+import { oneOrMany } from "../oneOrMany";
+
+export const ImageTitleSchema = z.object({
+ image: Image.ref,
+ // Each line is one item, or an array of items shown side by side.
+ content: z.array(
+ oneOrMany(
+ Text.ref,
+ Heading.ref,
+ EntityTitle.ref,
+ LeadingText.ref,
+ Metric.ref,
+ Tag.ref,
+ TagBlock.ref,
+ Link.ref,
+ ),
+ ),
+});
+
+export type ImageTitleProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/InlineText/index.tsx b/packages/react-ui/src/genui-lib/InlineText/index.tsx
new file mode 100644
index 0000000000..b13b8c6813
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/InlineText/index.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { TextBlockView } from "../../components/TextBlock";
+import { InlineTextSchema, type InlineTextProps } from "./schema";
+
+export * from "./schema";
+
+export function getMetricSubtextTone(
+ subtextVariant: "text" | "number" | "metric",
+ subtext?: string,
+): "positive" | "negative" | undefined {
+ if (subtextVariant !== "metric" || !subtext) return undefined;
+ if (subtext.startsWith("+")) return "positive";
+ if (subtext.startsWith("-")) return "negative";
+ return undefined;
+}
+
+function InlineTextRenderer({ props }: ComponentRenderProps) {
+ return (
+
+ );
+}
+
+export const InlineText = defineComponent({
+ name: "InlineText",
+ props: InlineTextSchema,
+ description:
+ "Plain text line with optional subtext. variant 'number' uses tabular number styling; subtextVariant 'metric' colors a leading +/- subtext green/red.",
+ component: InlineTextRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/InlineText/schema.ts b/packages/react-ui/src/genui-lib/InlineText/schema.ts
new file mode 100644
index 0000000000..284efdb5a2
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/InlineText/schema.ts
@@ -0,0 +1,10 @@
+import { z } from "zod/v4";
+
+export const InlineTextSchema = z.object({
+ variant: z.enum(["text", "number"]).default("text"),
+ value: z.string(),
+ subtext: z.string().optional(),
+ subtextVariant: z.enum(["text", "number", "metric"]).default("text"),
+ size: z.enum(["xs", "sm", "md", "lg"]).default("sm"),
+});
+export type InlineTextProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/Input/index.tsx b/packages/react-ui/src/genui-lib/Input/index.tsx
index fa4b4e0985..7c3556cace 100644
--- a/packages/react-ui/src/genui-lib/Input/index.tsx
+++ b/packages/react-ui/src/genui-lib/Input/index.tsx
@@ -7,16 +7,28 @@ import {
useIsStreaming,
useStateField,
} from "@openuidev/react-lang";
+import clsx from "clsx";
+import { Hash, Link, Lock, Mail, Phone, Search, type LucideIcon } from "lucide-react";
import React from "react";
-import { Input as OpenUIInput } from "../../components/Input";
import { InputSchema } from "./schema";
export { InputSchema } from "./schema";
+// Leading icon per input type; plain text fields stay bare.
+const typeIcons: Record = {
+ email: Mail,
+ password: Lock,
+ number: Hash,
+ url: Link,
+ search: Search,
+ tel: Phone,
+};
+
export const Input = defineComponent({
name: "Input",
props: InputSchema,
- description: "",
+ description:
+ 'A single-line text field inside a FormControl: Input(name, placeholder?, type?, rules?, value?, variant?), e.g. Input("email", "you@example.com", "email", { required: true, email: true }). Pick type for what is being entered, "email" | "password" | "number" | "url" | "search" | "tel" or "text" (default), because it sets the leading icon and the keyboard automatically. placeholder shows an example value, not the label; the label goes on the FormControl. value binds a $variable for reactive filters such as Input("q", "Search tickets", "search", null, $search). variant "inline" is a borderless field (outlined only on hover or focus, with no leading icon) for table cells and dense rows; use "default" everywhere else. When the answer comes from a known set, prefer Select, DatePicker, Chips or OptionCards.',
component: ({ props }) => {
const isStreaming = useIsStreaming();
const formValidation = useFormValidation();
@@ -33,28 +45,43 @@ export const Input = defineComponent({
return undefined;
}, [field.name, field.value, formValidation, hasRules, isStreaming, rules]);
+ const type = (props.type as string) || "text";
+ const inline = props.variant === "inline";
+ const Icon = inline ? undefined : typeIcons[type];
+ const hasError = !!formValidation?.errors[field.name];
+
return (
- formValidation?.clearFieldError(field.name)}
- onChange={(e: React.ChangeEvent) => {
- const val = e.target.value;
- field.setValue(val);
- if (hasRules) {
- formValidation?.clearFieldError(field.name);
- }
- }}
- onBlur={(e: React.FocusEvent) => {
- if (hasRules) {
- formValidation?.validateField(field.name, e.target.value, rules);
- }
- }}
- disabled={isStreaming}
- />
+
+ );
+}
+
+// Bottom: the title and the composable subtitle under it.
+export function insightContent(
+ item: { props: { title?: string; subtitle?: unknown } },
+ renderNode: (n: unknown) => ReactNode,
+) {
+ const { title, subtitle } = item.props;
+ return (
+
+ {title ?
{title}
: null}
+ {subtitle ? (
+
{renderOneOrMany(subtitle, renderNode)}
+ ) : null}
+
+ );
+}
+
+function InsightCardRenderer({ props, renderNode }: ComponentRenderProps) {
+ const triggerAction = useTriggerAction();
+ const formName = useFormName();
+ const isStreaming = useIsStreaming();
+
+ const items = (props.items ?? []) as InsightCardItemElement[];
+ const isClickable = Boolean(props.action) && !isStreaming;
+
+ const handleItemClick = (index: number) => {
+ const item = items[index];
+ if (!item || !props.action || isStreaming) return;
+ const { id, title, overline, color } = item.props;
+ triggerAction(
+ title || id || `Insight ${index + 1}`,
+ formName,
+ withItemContext(props.action, {
+ itemIndex: index,
+ itemId: id,
+ itemTitle: title,
+ itemOverline: overline,
+ itemBgColor: color,
+ }),
+ );
+ };
+
+ return (
+ {
+ const { id, color } = item.props;
+ return {
+ id,
+ title: insightOverline(item),
+ content: insightContent(item, renderNode),
+ bgColor: color,
+ };
+ })}
+ />
+ );
+}
+
+export const InsightCard = defineComponent({
+ name: "InsightCard",
+ props: InsightCardSchema,
+ description:
+ 'A grid or carousel of 2 or more tall tiles, each one takeaway: InsightCard([item1, item2, item3], layout?, responsive?, action?, gap?). Each tile has a short overline (the topic) at the top and the insight as a large title at the bottom. Use it for key takeaways from research, trends or news (with a status Tag in each subtitle), topics to explore as entry points, plan options, or suggested next topics after an answer. layout "grid" (default) for the main side-by-side set, "carousel" for secondary browsing. Keep every item the same shape, with the same kind of subtitle. Tiles have no photo; for photo tiles use ImageCard. action is one shared action for every tile, with the clicked tile\'s details attached and hover and press feedback added automatically, so keep its @ToAssistant message generic (e.g. "Tell me more about this trend") and never use @OpenUrl. For figures use ValueCard or ValueLayout; for products with a price and a button use CompositeCardBlock.',
+ component: InsightCardRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/InsightCard/schema.ts b/packages/react-ui/src/genui-lib/InsightCard/schema.ts
new file mode 100644
index 0000000000..0d308a67a7
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/InsightCard/schema.ts
@@ -0,0 +1,59 @@
+import { defineComponent } from "@openuidev/react-lang";
+import { z } from "zod/v4";
+import { actionPropSchema } from "../Action/schema";
+import { Avatar } from "../Avatar";
+import { Emoji } from "../Emoji";
+import { Icon } from "../Icon";
+import { LeadingText } from "../LeadingText";
+import { Link } from "../Link";
+import { Metric } from "../Metric";
+import { oneOrMany } from "../oneOrMany";
+import { Tag } from "../Tag";
+import { Text } from "../Text";
+
+export const insightCardColorSchema = z.enum([
+ "neutral",
+ "info",
+ "success",
+ "warning",
+ "danger",
+ "purple",
+ "pink",
+ "inverted",
+]);
+
+export const InsightCardItemSchema = z.object({
+ id: z.string().optional(),
+ title: z.string(),
+ subtitle: oneOrMany(
+ z.string(),
+ Text.ref,
+ Metric.ref,
+ Tag.ref,
+ LeadingText.ref,
+ Link.ref,
+ ).optional(),
+ overline: z.string().optional(),
+ leading: z.union([Avatar.ref, Icon.ref, Emoji.ref]).optional(),
+ color: insightCardColorSchema.optional(),
+});
+
+export type InsightCardItemProps = z.infer;
+
+export const InsightCardItem = defineComponent({
+ name: "InsightCardItem",
+ props: InsightCardItemSchema,
+ description:
+ 'One tall insight tile: InsightCardItem(id?, title, subtitle?, overline?, leading?, color?), e.g. InsightCardItem("remote-work", "Hybrid roles are 52% of new postings", Tag("Rising", null, "success"), "Remote work", Icon("trending-up", "charts"), "info"). overline is the short label at the top, the topic or category in one to three words, with the optional leading (an Icon, Avatar or Emoji) before it. title is the insight itself, shown large at the bottom (22px), so keep it to one short sentence or phrase; it is plain text, never a Tag. subtitle sits under the title: a string, or a Text, Metric, Tag, LeadingText or Link, or an array of them on one line, such as a status Tag or a source Link. color picks a tint from the palette only, "neutral" | "info" | "success" | "warning" | "danger" | "purple" | "pink" | "inverted", and the text takes the matching colour; pick it by meaning like Tag variants, use "neutral" when nothing calls for meaning, and never invent other colours. For a tile with a real photo behind the text, use ImageCard instead.',
+ component: () => null,
+});
+
+export const InsightCardSchema = z.object({
+ items: z.array(InsightCardItem.ref).min(2),
+ layout: z.enum(["grid", "carousel"]).optional(),
+ responsive: z.boolean().optional(),
+ action: actionPropSchema.optional(),
+ gap: z.union([z.number(), z.string()]).optional(),
+});
+
+export type InsightCardProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/LeadingText/index.tsx b/packages/react-ui/src/genui-lib/LeadingText/index.tsx
new file mode 100644
index 0000000000..2bb6578f42
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/LeadingText/index.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { TextWithIcon as OpenUITextWithIcon } from "../../components/TextWithIcon";
+import { getTextFont } from "../Text";
+import { LeadingTextSchema, type LeadingTextProps } from "./schema";
+
+export * from "./schema";
+
+type TextNode = { props?: { variant?: string; emphasis?: boolean } };
+
+function TextWithIconRenderer({ props, renderNode }: ComponentRenderProps) {
+ // Size the icon frame off the Text's own variant so it aligns at any size.
+ const textProps = (props.text as TextNode | undefined)?.props;
+ const { font } = getTextFont(textProps?.variant ?? "default", !!textProps?.emphasis);
+ return (
+
+ {renderNode(props.text)}
+
+ );
+}
+
+export const LeadingText = defineComponent({
+ name: "LeadingText",
+ props: LeadingTextSchema,
+ description:
+ 'A leading visual in front of any Text, centered on its first line and scaled to the Text variant. leading is an Icon, an Avatar or an AvatarStack. Which to lead with: a concept, fact or status (a date, place, price, check) is an Icon, e.g. LeadingText(Icon("calendar", "time"), Text("Opens daily 9am–6pm")); one specific person or company is an Avatar, e.g. LeadingText(Avatar("Stripe", null, "rounded", "stripe.com", "inline", Icon("building-2", "business")), Text("Stripe")); a group of people or companies (a team, attendees, who replied) is an AvatarStack, e.g. LeadingText(AvatarStack([a1, a2, a3, a4], 3, "inline"), Text("Ada, Grace and 2 others")). Keep avatars at size "inline" here so they sit on the text line. tone "secondary" mutes both. Icon names must be exact kebab-case lucide names; an unknown name renders a plain circle.',
+ component: TextWithIconRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/LeadingText/schema.ts b/packages/react-ui/src/genui-lib/LeadingText/schema.ts
new file mode 100644
index 0000000000..30d03bc3ab
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/LeadingText/schema.ts
@@ -0,0 +1,15 @@
+import { z } from "zod/v4";
+import { Avatar } from "../Avatar";
+import { AvatarStack } from "../AvatarStack";
+import { Emoji } from "../Emoji";
+import { Icon } from "../Icon";
+import { Shape } from "../Shape";
+import { Text } from "../Text";
+
+export const LeadingTextSchema = z.object({
+ leading: z.union([Icon.ref, Avatar.ref, AvatarStack.ref, Emoji.ref, Shape.ref]),
+ text: Text.ref,
+ tone: z.enum(["primary", "secondary"]).optional(),
+});
+
+export type LeadingTextProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/Link/index.tsx b/packages/react-ui/src/genui-lib/Link/index.tsx
new file mode 100644
index 0000000000..25093f9520
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Link/index.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { defineComponent } from "@openuidev/react-lang";
+import React from "react";
+import { FaviconLink } from "../../components/Link";
+import { LinkSchema } from "./schema";
+
+export { LinkSchema } from "./schema";
+
+// default matches Text "default": 14px on a 20px line, in the Markdown link
+// style. lg matches a title (16px Medium on a 24px line); the favicon is 1em,
+// so it scales with it.
+const STYLES = {
+ default: {
+ font: "var(--openui-font-weight-medium) var(--openui-font-size-sm)/20px var(--openui-font-body)",
+ },
+ lg: {
+ font: "var(--openui-font-weight-medium) var(--openui-font-size-md)/24px var(--openui-font-body)",
+ },
+} as Record<"default" | "lg", React.CSSProperties>;
+
+export const Link = defineComponent({
+ name: "Link",
+ props: LinkSchema,
+ description: `Standalone link to a web page, shown with the site's favicon before the text. text is a short descriptive label (a page or site name, never a bare URL); url is an absolute https URL from a tool result or the user, never invented. size "default" fits everywhere else; "lg" is for a Link used as the title of a ListItem, StepsItem or TimelineItem, matching the title text: Link("Install the SDK", "https://…", "lg"). For links inside sentences, use a Markdown [text](url) link instead.`,
+ component: ({ props }) => (
+
+
+ {props.text as string}
+
+
+ ),
+});
diff --git a/packages/react-ui/src/genui-lib/Link/schema.ts b/packages/react-ui/src/genui-lib/Link/schema.ts
new file mode 100644
index 0000000000..c3d127467b
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Link/schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod/v4";
+
+export const LinkSchema = z.object({
+ text: z.string(),
+ url: z.string(),
+ size: z.enum(["default", "lg"]).optional(),
+});
diff --git a/packages/react-ui/src/genui-lib/ListBlock/index.tsx b/packages/react-ui/src/genui-lib/ListBlock/index.tsx
index 693f953e6f..134e929739 100644
--- a/packages/react-ui/src/genui-lib/ListBlock/index.tsx
+++ b/packages/react-ui/src/genui-lib/ListBlock/index.tsx
@@ -6,48 +6,69 @@ import { ChevronRight } from "lucide-react";
import { z } from "zod/v4";
import { ListBlock as OpenUIListBlock } from "../../components/ListBlock";
import { ListItem as OpenUIListItem } from "../../components/ListItem";
+import { renderIconSlot } from "../iconSlot";
import { ListItem } from "../ListItem";
+import { renderOneOrMany } from "../oneOrMany";
+
+type Node = { typeName?: string; props?: { name?: string } } | undefined;
+
+// A bare Icon leads as a neutral Avatar badge, like EntityTitle.
+const leadingNode = (node: Node) =>
+ node?.typeName === "Icon" && node.props?.name
+ ? renderIconSlot({ typeName: "Avatar", props: { name: node.props.name, icon: node } })
+ : renderIconSlot(node);
export const ListBlock = defineComponent({
name: "ListBlock",
props: z.object({
items: z.array(ListItem.ref),
- variant: z.enum(["number", "image"]).optional(),
- size: z.enum(["default", "small"]).optional(),
+ variant: z.enum(["number", "plain"]).optional(),
}),
description:
- "A list of items with number or image indicators. Each item can optionally have an action. size small renders a compact list.",
- component: ({ props }) => {
+ 'A vertical list of ListItems for a set of options, picks or ranked results: ListBlock([item1, item2, item3]). variant "number" (default) puts a small black number inline before each title, for rankings, ordered picks or a short sequence; "plain" drops the numbers, for an unordered set such as people, companies or places that each lead with an Avatar. For named steps or options with a description, give each ListItem an emphasized title, ListItem(Text("Read the docs", "body", true), "Skim the quickstart."). Use Steps for a step-by-step process and EntityList for key/value facts. Give each ListItem an action to turn the list into a menu of follow-ups, or an actionSlot Button when each row needs its own verb such as Book or Approve.',
+ component: ({ props, renderNode }) => {
const triggerAction = useTriggerAction();
- const items = (props.items ?? []) as any[];
- const variant = (props.variant as "number" | "image") ?? "number";
- const size = (props.size as "default" | "small") ?? "default";
+ const items = (props.items ?? []) as {
+ props?: {
+ title?: unknown;
+ subtitle?: unknown;
+ leading?: unknown;
+ actionLabel?: string;
+ action?: unknown;
+ actionSlot?: unknown;
+ };
+ }[];
+ const variant = (props.variant as "number" | "plain" | undefined) ?? "number";
const listHasSubtitle = items.some((item) => !!item?.props?.subtitle);
+ const cell = (value: unknown) => renderOneOrMany(value, renderNode);
return (
-
+
{items.map((item, index) => {
- const title = String(item?.props?.title ?? "");
- const subtitle = item?.props?.subtitle ? String(item.props.subtitle) : undefined;
- const image = item?.props?.image as { src: string; alt: string } | undefined;
- const actionLabel = item?.props?.actionLabel ? String(item.props.actionLabel) : undefined;
- const action = item?.props?.action;
- const hasAction = !!action;
+ const p = item?.props ?? {};
+ const titleText = typeof p.title === "string" ? p.title : String(index + 1);
+ const actionLabel = p.actionLabel ? String(p.actionLabel) : undefined;
+ const action = p.action;
+ // An actionSlot (Button, Buttons, IconButton) owns the action; the row
+ // itself is then not clickable.
+ const actionSlot = p.actionSlot ? renderNode(p.actionSlot) : undefined;
+ const hasAction = !!action && !actionSlot;
const handleClick = hasAction
- ? () => triggerAction(title, undefined, action as ActionPlan | undefined)
+ ? () => triggerAction(titleText, undefined, action as ActionPlan | undefined)
: undefined;
return (
: undefined}
onClick={handleClick}
+ actionSlot={actionSlot}
/>
);
})}
diff --git a/packages/react-ui/src/genui-lib/ListItem/index.tsx b/packages/react-ui/src/genui-lib/ListItem/index.tsx
index f4963aa937..96f90d20ce 100644
--- a/packages/react-ui/src/genui-lib/ListItem/index.tsx
+++ b/packages/react-ui/src/genui-lib/ListItem/index.tsx
@@ -3,22 +3,39 @@
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod/v4";
import { actionPropSchema } from "../Action/schema";
+import { Avatar } from "../Avatar";
+import { Button } from "../Button";
+import { Buttons } from "../Buttons";
+import { Emoji } from "../Emoji";
+import { Icon } from "../Icon";
+import { IconButton } from "../IconButton";
+import { LeadingText } from "../LeadingText";
+import { Link } from "../Link";
+import { Metric } from "../Metric";
+import { Tag } from "../Tag";
+import { TagBlock } from "../TagBlock";
+import { Text } from "../Text";
+import { oneOrMany } from "../oneOrMany";
export const ListItem = defineComponent({
name: "ListItem",
props: z.object({
- title: z.string(),
- subtitle: z.string().optional(),
- image: z
- .object({
- src: z.string(),
- alt: z.string(),
- })
- .optional(),
+ title: oneOrMany(z.string(), Text.ref, Link.ref, Tag.ref),
+ subtitle: oneOrMany(
+ z.string(),
+ Text.ref,
+ Metric.ref,
+ Tag.ref,
+ TagBlock.ref,
+ LeadingText.ref,
+ Link.ref,
+ ).optional(),
+ leading: z.union([Avatar.ref, Icon.ref, Emoji.ref]).optional(),
actionLabel: z.string().optional(),
action: actionPropSchema.optional(),
+ actionSlot: z.union([Button.ref, Buttons.ref, IconButton.ref]).optional(),
}),
description:
- "Item in a ListBlock — displays a title with an optional subtitle and image. When action is provided, the item becomes clickable.",
+ 'One row in a ListBlock: ListItem(title, subtitle?, leading?, actionLabel?, action?, actionSlot?). title is a string, or a Text, Link or Tag. For a step or option with a name plus a description, emphasize the name with the "body" Text variant (it lines up with the number) and put the description in subtitle: ListItem(Text("Read the docs", "body", true), "Skim the quickstart and the component reference."). subtitle is one line of context: a string, or a Text, Metric, Tag, TagBlock, LeadingText or Link. leading is an optional Avatar (a real photo, a company logo via domain, or a tinted icon badge), an Icon, or a rare Emoji; it is 40px when the list has subtitles and 24px otherwise, and fits best in a "plain" list since numbered rows already lead with their number. The row is clickable only when it has an action (5th argument), e.g. ListItem("Cherry blossoms at Parc de Sceaux", "March – May", null, null, @ToAssistant("Tell me about cherry blossom season in Paris")); actionLabel is an optional short word shown at the right next to the chevron, e.g. "View". Use action for a whole-row drill-in like that. For an explicit verb, put real controls on the right with actionSlot (6th argument), a Button, a Buttons group or an IconButton, e.g. ListItem("Septime", "Tonight · 8:30pm", null, null, null, Button("Book", @ToAssistant("Book Septime tonight at 8:30pm"), "secondary")), or Buttons with Approve and Decline. With actionSlot the row itself is not clickable, so leave action and actionLabel out. When title or subtitle needs two or more pieces side by side, pass an array and they sit on one line, e.g. subtitle [Metric("$89", null, null, "sm"), Tag("In stock", null, "success")]; a single piece goes in directly.',
component: () => null,
});
diff --git a/packages/react-ui/src/genui-lib/MarkDownRenderer/index.tsx b/packages/react-ui/src/genui-lib/MarkDownRenderer/index.tsx
deleted file mode 100644
index 7283c91afd..0000000000
--- a/packages/react-ui/src/genui-lib/MarkDownRenderer/index.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-"use client";
-
-import { defineComponent } from "@openuidev/react-lang";
-import { MarkDownRenderer as OpenUIMarkDownRenderer } from "../../components/MarkDownRenderer";
-import { MarkDownRendererSchema } from "./schema";
-
-export { MarkDownRendererSchema } from "./schema";
-
-export const MarkDownRenderer = defineComponent({
- name: "MarkDownRenderer",
- props: MarkDownRendererSchema,
- description: "Renders markdown text with optional container variant",
- component: ({ props }) => (
-
- ),
-});
diff --git a/packages/react-ui/src/genui-lib/MarkDownRenderer/schema.ts b/packages/react-ui/src/genui-lib/MarkDownRenderer/schema.ts
deleted file mode 100644
index c75da870b8..0000000000
--- a/packages/react-ui/src/genui-lib/MarkDownRenderer/schema.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { z } from "zod/v4";
-
-export const MarkDownRendererSchema = z.object({
- textMarkdown: z.string(),
- variant: z.enum(["clear", "card", "sunk"]).optional(),
-});
diff --git a/packages/react-ui/src/genui-lib/Markdown/index.tsx b/packages/react-ui/src/genui-lib/Markdown/index.tsx
new file mode 100644
index 0000000000..d9e554bdef
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Markdown/index.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { defineComponent } from "@openuidev/react-lang";
+import React from "react";
+import remarkGfm from "remark-gfm";
+import { remarkCitations } from "../../components/_shared/remark/remarkCitations";
+import { remarkHighlight } from "../../components/_shared/remark/remarkHighlight";
+import { TextContentCitation } from "../../components/Citation";
+import { FaviconLink } from "../../components/Link";
+import { MarkDownRenderer as OpenUIMarkDownRenderer } from "../../components/MarkDownRenderer";
+import { MarkdownSchema } from "./schema";
+
+export { MarkdownSchema } from "./schema";
+
+// Long-form body: 16px on a 24px line.
+const BODY_STYLE = {
+ "--openui-text-body-default":
+ "var(--openui-font-weight-regular) var(--openui-font-size-md)/24px var(--openui-font-body)",
+} as React.CSSProperties;
+
+// singleTilde: false keeps a lone ~ ("~$886K") literal; only ~~x~~ strikes.
+// remarkCitations resolves inline [n] against the enclosing Card's sources.
+// remarkHighlight turns ==key phrase== into .
+const OPTIONS = {
+ remarkPlugins: [[remarkGfm, { singleTilde: false }], remarkCitations, remarkHighlight],
+ components: {
+ span: TextContentCitation,
+ // Web links get the site's favicon before their text.
+ a: ({ node: _node, ...props }: React.ComponentProps<"a"> & { node?: unknown }) => (
+
+ ),
+ },
+} as React.ComponentProps["options"];
+
+export const Markdown = defineComponent({
+ name: "Markdown",
+ props: MarkdownSchema,
+ description:
+ "Long-form markdown prose: explanations, summaries and the top-level text that sets context around UI. Supports headings, lists, task lists, tables, quotes and code. When an answer needs a title, start the Markdown with a # heading instead of a separate Text heading. Not for short UI labels or single values; use Text for those.",
+ component: ({ props }) => (
+
+
+
+ ),
+});
diff --git a/packages/react-ui/src/genui-lib/Markdown/schema.ts b/packages/react-ui/src/genui-lib/Markdown/schema.ts
new file mode 100644
index 0000000000..f92b24a7fb
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Markdown/schema.ts
@@ -0,0 +1,5 @@
+import { z } from "zod/v4";
+
+export const MarkdownSchema = z.object({
+ textMarkdown: z.string(),
+});
diff --git a/packages/react-ui/src/genui-lib/Metric/index.tsx b/packages/react-ui/src/genui-lib/Metric/index.tsx
new file mode 100644
index 0000000000..bb116ca97c
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Metric/index.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Metric as OpenUIMetric } from "../../components/Metric";
+import { MetricSchema, type MetricProps } from "./schema";
+
+export * from "./schema";
+
+function MetricRenderer({ props }: ComponentRenderProps) {
+ return (
+
+ );
+}
+
+export const Metric = defineComponent({
+ name: "Metric",
+ props: MetricSchema,
+ description:
+ 'A value with an optional change, e.g. Metric("$48,200", "+8.1%"): a leading +/- on the change shows a green up or red down arrow. It has no label: in a Card give it the Card\'s title, and elsewhere compose one with Text or Heading. Metric uses the UI font, so its size follows its role: use size "sm" (14px) whenever it is not the hero number, which covers Table cells, EntityListItem right, ListItem and EntityTitle subtitles, ImageTitle content, the value of a horizontal ValueCard, and CompositeCardItem body (a CompositeCardItem footer price always renders at 18px), e.g. Col("Revenue", @Each(data.rows, "r", Metric(r.revenue, r.change, "horizontal", "sm"))) or EntityListItem("Trail Runner", Metric("$89", null, null, "sm")). Use "large" (18px) with variant "vertical" only for a standalone headline figure such as a KPI tile, Card([Metric("$48,200", "+8.1%", "vertical", "large")], null, "Revenue"); "default" (16px) is for a headline figure that sits inline. variant "horizontal" (default) puts the change beside the value, "vertical" under it. previous is an earlier value shown struck through after the current one; use it only for a real before/after, like a price drop or a revised figure, e.g. Metric("$89", "-26%", null, "sm", "$120"), and leave it out otherwise.',
+ component: MetricRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/Metric/schema.ts b/packages/react-ui/src/genui-lib/Metric/schema.ts
new file mode 100644
index 0000000000..d3d76dbf8c
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Metric/schema.ts
@@ -0,0 +1,12 @@
+import { z } from "zod/v4";
+
+export const MetricSchema = z.object({
+ value: z.string(),
+ delta: z.string().optional(),
+ variant: z.enum(["horizontal", "vertical"]).optional(),
+ size: z.enum(["sm", "default", "large"]).optional(),
+ // Last, so existing positional calls keep their meaning.
+ previous: z.string().optional(),
+});
+
+export type MetricProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/OptionCards/index.tsx b/packages/react-ui/src/genui-lib/OptionCards/index.tsx
index c63525bc1a..20f542a9c5 100644
--- a/packages/react-ui/src/genui-lib/OptionCards/index.tsx
+++ b/packages/react-ui/src/genui-lib/OptionCards/index.tsx
@@ -14,19 +14,20 @@ import {
import React from "react";
import { OptionCards as OpenUIOptionCards } from "../../components/OptionCards";
import { useHydrateDefaultFieldValue } from "../formDefaultValueUtils";
+import { renderOneOrMany } from "../oneOrMany";
import { getStoredDefaultValue, normalizeSelection, toggleSelection } from "../selectionFieldUtils";
import { OptionCardsSchema, type OptionCardProps, type OptionCardsProps } from "./schema";
export * from "./schema";
-function isImageTopContent(topContent: unknown): boolean {
- const candidate = topContent as { type?: unknown; props?: { src?: unknown } } | null | undefined;
-
+function isImageLeading(leading: unknown): boolean {
+ // Only an Image gets the large tile; an Avatar also carries a src.
+ const candidate = leading as { type?: unknown; typeName?: unknown } | null | undefined;
return (
typeof candidate === "object" &&
candidate !== null &&
candidate.type === "element" &&
- typeof candidate.props?.src === "string"
+ candidate.typeName === "Image"
);
}
@@ -110,13 +111,13 @@ function OptionCardsRenderer({ props, renderNode }: ComponentRenderProps {
- const topContent = item.props.topContent;
+ const leading = item.props.leading;
return {
value: item.props.value,
title: item.props.title,
- subtitle: item.props.subtitle,
- topContent: topContent != null ? renderNode(topContent) : undefined,
- topContentVariant: isImageTopContent(topContent) ? "image" : "icon",
+ subtitle: renderOneOrMany(item.props.subtitle, renderNode),
+ leading: leading != null ? renderNode(leading) : undefined,
+ leadingVariant: isImageLeading(leading) ? "image" : "icon",
disabled: item.props.disabled,
};
})}
@@ -128,6 +129,6 @@ export const OptionCards = defineComponent({
name: "OptionCards",
props: OptionCardsSchema,
description:
- "A form field of selectable cards (title, optional subtitle, optional icon or image) laid out in a responsive grid for choosing one or many options; the selection is stored under `name`.",
+ 'A form field of selectable cards in a responsive grid, inside a FormControl, for choosing one or many options that need a short explanation: OptionCards(name, "single" | "multiple", [OptionCard(...), ...], rules?). The selected card shows a checkmark. Use it for 2–6 meaningful choices such as a plan, trip type or working style; for many short labels use Chips, and for a long list use Select. Every card should have the same parts, all with a leading or none. The selection is stored under `name`.',
component: OptionCardsRenderer,
});
diff --git a/packages/react-ui/src/genui-lib/OptionCards/schema.ts b/packages/react-ui/src/genui-lib/OptionCards/schema.ts
index 6ad70ba195..4b0f1f1a39 100644
--- a/packages/react-ui/src/genui-lib/OptionCards/schema.ts
+++ b/packages/react-ui/src/genui-lib/OptionCards/schema.ts
@@ -1,26 +1,43 @@
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod/v4";
+import { Avatar } from "../Avatar";
+import { Emoji } from "../Emoji";
import { Icon } from "../Icon";
import { Image } from "../Image";
+import { LeadingText } from "../LeadingText";
+import { Link } from "../Link";
+import { Metric } from "../Metric";
+import { oneOrMany } from "../oneOrMany";
import { rulesSchema } from "../rules";
+import { Tag } from "../Tag";
+import { TagBlock } from "../TagBlock";
+import { Text } from "../Text";
export const OptionCardSchema = z.object({
value: z.string(),
title: z.string(),
- subtitle: z.string().optional(),
- topContent: z.union([Icon.ref, Image.ref]).optional(),
+ subtitle: oneOrMany(
+ z.string(),
+ Text.ref,
+ Metric.ref,
+ Tag.ref,
+ TagBlock.ref,
+ LeadingText.ref,
+ Link.ref,
+ ).optional(),
+ leading: z.union([Icon.ref, Avatar.ref, Emoji.ref, Image.ref]).optional(),
disabled: z.boolean().optional(),
});
export type OptionCardProps = z.infer;
-export type OptionCardTopContent = NonNullable;
+export type OptionCardLeading = NonNullable;
export const OptionCard = defineComponent({
name: "OptionCard",
props: OptionCardSchema,
description:
- "A single selectable card inside an OptionCards group, with a value, title, optional subtitle and an optional Icon or Image on top.",
+ 'One selectable card: OptionCard(value, title, subtitle?, leading?, disabled?), e.g. OptionCard("adventure", "Adventure", "Hiking, trekking & outdoor activities", Icon("mountain", "nature")). subtitle is one line of context: a string, or a Text, Metric, Tag, TagBlock, LeadingText or Link, or an array of them on one line, such as a price Metric for a plan. leading is an Icon for a concept, an Avatar for a person or company, a real Image, or a rare Emoji.',
component: () => null,
});
diff --git a/packages/react-ui/src/genui-lib/OverviewCardBlock/index.tsx b/packages/react-ui/src/genui-lib/OverviewCardBlock/index.tsx
index 84078d9432..1ae62b7866 100644
--- a/packages/react-ui/src/genui-lib/OverviewCardBlock/index.tsx
+++ b/packages/react-ui/src/genui-lib/OverviewCardBlock/index.tsx
@@ -55,6 +55,7 @@ function OverviewCardBlockRenderer({
itemTitle: title,
itemSubtitle: getStringProp(top, "subtitle") ?? getStringProp(top, "subtext"),
itemMetricValue: bottom?.props.value,
+ itemMetricDelta: bottom?.props.delta,
}),
);
};
@@ -78,13 +79,7 @@ function OverviewCardBlockRenderer({
return {
id,
top: top ? renderNode(top) : undefined,
- bottom: bottom
- ? {
- value: bottom.props.value,
- subtext: bottom.props.subtext,
- trend: bottom.props.trend,
- }
- : undefined,
+ bottomContent: bottom ? renderNode(bottom) : undefined,
};
})}
/>
diff --git a/packages/react-ui/src/genui-lib/OverviewCardBlock/schema.ts b/packages/react-ui/src/genui-lib/OverviewCardBlock/schema.ts
index 5061326280..f18e301f5f 100644
--- a/packages/react-ui/src/genui-lib/OverviewCardBlock/schema.ts
+++ b/packages/react-ui/src/genui-lib/OverviewCardBlock/schema.ts
@@ -1,22 +1,21 @@
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod/v4";
import { actionPropSchema } from "../Action/schema";
-import { IconText } from "../IconText";
-import { ImageText } from "../ImageText";
-import { MetricIndicatorInline } from "../MetricIndicator";
+import { EntityTitle } from "../EntityTitle";
+import { Metric } from "../Metric";
import { Text } from "../Text";
export const OverviewCardItemSchema = z.object({
id: z.string().optional(),
- top: z.union([IconText.ref, ImageText.ref, Text.ref]),
- bottom: z.optional(MetricIndicatorInline.ref),
+ top: z.union([EntityTitle.ref, Text.ref]),
+ bottom: z.optional(Metric.ref),
});
export const OverviewCardItem = defineComponent({
name: "OverviewCardItem",
props: OverviewCardItemSchema,
description:
- "One overview card: a heading slot at the top (IconText, ImageText or Text) and an optional MetricIndicatorInline at the bottom.",
+ "One overview card: a heading slot at the top (EntityTitle or Text) and an optional Metric at the bottom.",
component: () => null,
});
diff --git a/packages/react-ui/src/genui-lib/Section/index.tsx b/packages/react-ui/src/genui-lib/Section/index.tsx
new file mode 100644
index 0000000000..8859cff815
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Section/index.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Section as OpenUISection } from "../../components/Section";
+import { SectionSchema, type SectionProps } from "./schema";
+
+export * from "./schema";
+
+// Children are loose so the chat Section, with its own children union, can share this renderer.
+type SectionRenderProps = ComponentRenderProps<
+ Omit & { children: unknown[] }
+>;
+
+/** Shared renderer — also used by the chat library's Section variant (typed children). */
+export function SectionRenderer({ props, renderNode }: SectionRenderProps) {
+ return {renderNode(props.children)};
+}
+
+export const SECTION_DESCRIPTION = `One part of a response. Every response is built from Sections: put each distinct part (an overview, then options, then next steps) in its own Section, directly in the root, so parts get more space between them and their own children sit close together. A short single-part answer is one Section. Sections are high-level divisions, and many responses need only one: never give a lone Text, Markdown or Buttons row its own Section, nor a Section that holds only a title (a Heading or Text, with or without a subtitle; it goes at the top of the Section with the content it introduces), e.g. not Section([Markdown("Here is a snapshot…")]) followed by the table it introduces. Put an intro first inside the Section it introduces, and closing actions last in the final Section. Children stack top to bottom; compose the part yourself inside it: a Heading or Text h2/h3 title first when the part needs one, then Markdown, a Stack row or grid, a card block, a chart. Prose inside a titled Section is a Markdown block without its own # heading. e.g. Section([Heading("Where to stay"), hotels]). Never nest a Section inside a Section. For parts the reader opens on demand (an FAQ, a long report) use the collapsible SectionBlock inside a Section, and for alternative views of the same thing use Tabs.`;
+
+export const Section = defineComponent({
+ name: "Section",
+ props: SectionSchema,
+ description: SECTION_DESCRIPTION,
+ component: SectionRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/Section/schema.ts b/packages/react-ui/src/genui-lib/Section/schema.ts
new file mode 100644
index 0000000000..d719cb3913
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Section/schema.ts
@@ -0,0 +1,35 @@
+import { z } from "zod/v4";
+import { Bleed } from "../Bleed";
+import { Carousel } from "../Carousel";
+import { FollowUpBlock } from "../FollowUpBlock";
+import { Grid } from "../Grid";
+import { Shell } from "../Shell";
+import { Sources } from "../Sources";
+import { Stack } from "../Stack";
+import { Tabs } from "../Tabs";
+import { ContentChildUnion } from "../unions";
+
+// Same children a Card takes, minus Section itself (no nesting). Built on
+// first use because Carousel reaches back to Card, which holds Section.
+let sectionChildUnion: z.ZodType | undefined;
+export const getSectionChildUnion = (): z.ZodType =>
+ (sectionChildUnion ??= z.union([
+ ...ContentChildUnion.options,
+ Tabs.ref,
+ Carousel.ref,
+ Stack.ref,
+ Grid.ref,
+ Shell.ref,
+ Bleed.ref,
+ // Response endings: they close the final Section.
+ FollowUpBlock.ref,
+ Sources.ref,
+ ]));
+
+export const SectionSchema = z.object({
+ get children(): z.ZodArray {
+ return z.array(getSectionChildUnion());
+ },
+});
+
+export type SectionProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/SectionBlock/index.tsx b/packages/react-ui/src/genui-lib/SectionBlock/index.tsx
index 2daa2b7135..b2bf0a4016 100644
--- a/packages/react-ui/src/genui-lib/SectionBlock/index.tsx
+++ b/packages/react-ui/src/genui-lib/SectionBlock/index.tsx
@@ -88,6 +88,6 @@ export const SectionBlock = defineComponent({
isFoldable: z.boolean().optional(),
}),
description:
- "Collapsible accordion sections. Auto-opens sections as they stream in. Use SectionItem for each section.",
+ "Collapsible accordion sections, auto-opened as they stream in, for parts the reader opens on demand: an FAQ, a long report, supporting detail. Use a SectionItem for each one, and place the SectionBlock inside a Section like any other content; the always-visible parts of a response are Sections themselves.",
component: SectionBlockRenderer,
});
diff --git a/packages/react-ui/src/genui-lib/Select/index.tsx b/packages/react-ui/src/genui-lib/Select/index.tsx
index 679104133e..6eec2d45a5 100644
--- a/packages/react-ui/src/genui-lib/Select/index.tsx
+++ b/packages/react-ui/src/genui-lib/Select/index.tsx
@@ -7,6 +7,7 @@ import {
useIsStreaming,
useStateField,
} from "@openuidev/react-lang";
+import clsx from "clsx";
import React from "react";
import {
Select as OpenUISelect,
@@ -22,14 +23,16 @@ export { SelectItemSchema } from "./schema";
export const SelectItem = defineComponent({
name: "SelectItem",
props: SelectItemSchema,
- description: "Option for Select",
+ description:
+ "One option in a Select: SelectItem(value, label); value is what the form submits, label is what the user sees.",
component: () => null,
});
export const Select = defineComponent({
name: "Select",
props: createSelectSchema(SelectItem),
- description: "",
+ description:
+ 'A dropdown of SelectItems inside a FormControl, for picking one option from four or more: Select(name, items, placeholder?, rules?, value?, variant?), e.g. Select("country", [SelectItem("us", "United States"), SelectItem("uk", "United Kingdom"), SelectItem("de", "Germany")], "Select…", { required: true }). For two or three options, RadioGroup or Chips show every choice at once. value binds a $variable, e.g. a sort field that drives @Sort. variant "inline" is borderless until hover or focus, for table cells and dense rows; use "default" everywhere else.',
component: ({ props }) => {
const isStreaming = useIsStreaming();
const formValidation = useFormValidation();
@@ -68,9 +71,12 @@ export const Select = defineComponent({
value={value}
onValueChange={handleChange}
disabled={isStreaming}
- size={({ small: "sm", medium: "md", large: "lg" } as const)[props.size as string] ?? "md"}
>
-
+
diff --git a/packages/react-ui/src/genui-lib/Select/schema.ts b/packages/react-ui/src/genui-lib/Select/schema.ts
index 7cc21d6366..71f42c2851 100644
--- a/packages/react-ui/src/genui-lib/Select/schema.ts
+++ b/packages/react-ui/src/genui-lib/Select/schema.ts
@@ -16,6 +16,6 @@ export function createSelectSchema(SelectItem: RefComponent) {
placeholder: z.string().optional(),
rules: rulesSchema,
value: reactive(z.string().optional()),
- size: z.enum(["small", "medium", "large"]).optional(),
+ variant: z.enum(["default", "inline"]).optional(),
});
}
diff --git a/packages/react-ui/src/genui-lib/Shape/index.tsx b/packages/react-ui/src/genui-lib/Shape/index.tsx
new file mode 100644
index 0000000000..05b4cb6f00
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Shape/index.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Shape as OpenUIShape } from "../../components/Shape";
+import { ShapeSchema, type ShapeProps } from "./schema";
+
+export * from "./schema";
+
+function ShapeRenderer({ props }: ComponentRenderProps) {
+ return (
+
+ );
+}
+
+export const Shape = defineComponent({
+ name: "Shape",
+ props: ShapeSchema,
+ description:
+ 'A small solid shape sized like an Icon. It only goes inside a Tag, a LeadingText or an Avatar\'s icon slot, never on its own. Use it where a plain colored mark says more than a picture: a status dot, Tag("Online", Shape("circle", "success")); a legend or category marker, LeadingText(Shape("square", "purple"), Text("Design")); or a trend arrow, LeadingText(Shape("triangle-up", "success"), Text("+12% this week")) or triangle-down with "danger". Use an Icon instead when the mark has to depict something. shape "circle" | "square" | "diamond" | "triangle-up" | "triangle-down" | "triangle-left" | "triangle-right" | "hexagon". color is "neutral" (default) | "info" | "success" | "warning" | "danger" | "purple" | "pink" | "inverted", picked by meaning like Tag variants, with purple and pink for telling categories apart; keep one meaning per color within a response. label is the accessible name: give one when the shape carries meaning without text beside it, omit it when decorative.',
+ component: ShapeRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/Shape/schema.ts b/packages/react-ui/src/genui-lib/Shape/schema.ts
new file mode 100644
index 0000000000..14a2ac1457
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Shape/schema.ts
@@ -0,0 +1,20 @@
+import { z } from "zod/v4";
+
+export const ShapeSchema = z.object({
+ shape: z.enum([
+ "circle",
+ "square",
+ "diamond",
+ "triangle-up",
+ "triangle-down",
+ "triangle-left",
+ "triangle-right",
+ "hexagon",
+ ]),
+ color: z
+ .enum(["neutral", "info", "success", "warning", "danger", "purple", "pink", "inverted"])
+ .optional(),
+ label: z.string().optional(),
+});
+
+export type ShapeProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/Shell/index.tsx b/packages/react-ui/src/genui-lib/Shell/index.tsx
new file mode 100644
index 0000000000..5c437f55c7
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Shell/index.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
+import { Shell as OpenUIShell } from "../../components/Shell";
+import { ShellSchema, type ShellProps } from "./schema";
+
+export * from "./schema";
+
+// Children are loose so the chat Shell, with its own children union, can share this renderer.
+type ShellRenderProps = ComponentRenderProps<
+ Omit & { children: unknown[] }
+>;
+
+/** Shared renderer — also used by the chat library's Shell variant (typed children). */
+export function ShellRenderer({ props, renderNode }: ShellRenderProps) {
+ return {renderNode(props.children)};
+}
+
+export const SHELL_DESCRIPTION = `A frame for a dashboard or a large Grid composition: Shell(title, children). It shows a small title over its content, renders wider than the chat column, and has an expand button that opens the content full screen. title is a short name for what it shows (e.g. "Store performance, last 30 days"). Children stack top to bottom, usually one or two Grids of Cards. Use at most one Shell per response, only for dashboards and large Grid layouts; ordinary answers never need one.`;
+
+export const Shell = defineComponent({
+ name: "Shell",
+ props: ShellSchema,
+ description: SHELL_DESCRIPTION,
+ component: ShellRenderer,
+});
diff --git a/packages/react-ui/src/genui-lib/Shell/schema.ts b/packages/react-ui/src/genui-lib/Shell/schema.ts
new file mode 100644
index 0000000000..b779373844
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Shell/schema.ts
@@ -0,0 +1,29 @@
+import { z } from "zod/v4";
+import { CardSchema } from "../Card/schema";
+import { Carousel } from "../Carousel";
+import { Grid } from "../Grid";
+import { Stack } from "../Stack";
+import { Tabs } from "../Tabs";
+import { ContentChildUnion } from "../unions";
+
+// What a Card or Section holds, plus Card and Grid. Built on first use
+// because Card and Section hold a Shell in turn.
+let shellChildUnion: z.ZodType | undefined;
+export const getShellChildUnion = (): z.ZodType =>
+ (shellChildUnion ??= z.union([
+ ...ContentChildUnion.options,
+ CardSchema,
+ Grid.ref,
+ Tabs.ref,
+ Carousel.ref,
+ Stack.ref,
+ ]));
+
+export const ShellSchema = z.object({
+ title: z.string(),
+ get children(): z.ZodArray {
+ return z.array(getShellChildUnion());
+ },
+});
+
+export type ShellProps = z.infer;
diff --git a/packages/react-ui/src/genui-lib/Slider/index.tsx b/packages/react-ui/src/genui-lib/Slider/index.tsx
index 1eba6d5176..466c2ecfa7 100644
--- a/packages/react-ui/src/genui-lib/Slider/index.tsx
+++ b/packages/react-ui/src/genui-lib/Slider/index.tsx
@@ -8,7 +8,7 @@ import {
useStateField,
} from "@openuidev/react-lang";
import React from "react";
-import { SliderBlock as OpenUISliderBlock } from "../../components/Slider";
+import { FieldSlider as OpenUIFieldSlider } from "../../components/Slider";
import { SliderSchema } from "./schema";
export { SliderSchema } from "./schema";
@@ -35,7 +35,7 @@ export const Slider = defineComponent({
}, [field.name, field.value, formValidation, hasRules, isStreaming, rules]);
return (
-
);
},
diff --git a/packages/react-ui/src/genui-lib/SnippetCardBlock/index.tsx b/packages/react-ui/src/genui-lib/SnippetCardBlock/index.tsx
deleted file mode 100644
index ba33c1c8a8..0000000000
--- a/packages/react-ui/src/genui-lib/SnippetCardBlock/index.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-"use client";
-
-import {
- ComponentRenderProps,
- defineComponent,
- useFormName,
- useIsStreaming,
- useTriggerAction,
-} from "@openuidev/react-lang";
-import { SnippetCardBlock as OpenUISnippetCardBlock } from "../../components/SnippetCardBlock";
-import { withItemContext } from "../cardActionUtils";
-import {
- SnippetCardBlockSchema,
- type SnippetCardBlockProps,
- type SnippetCardItemNode,
-} from "./schema";
-
-export * from "./schema";
-
-function getStringProp(node: unknown, key: string): string | undefined {
- const props =
- typeof node === "object" && node !== null && "props" in node
- ? (node as { props?: Record }).props
- : undefined;
- const value = props?.[key];
- return typeof value === "string" ? value : undefined;
-}
-
-function SnippetCardBlockRenderer({
- props,
- renderNode,
-}: ComponentRenderProps) {
- const triggerAction = useTriggerAction();
- const formName = useFormName();
- const isStreaming = useIsStreaming();
-
- const items = (props.items ?? []) as SnippetCardItemNode[];
- const isClickable = Boolean(props.action) && !isStreaming;
-
- const handleItemClick = (item: SnippetCardItemNode, index: number) => {
- if (!props.action || isStreaming) return;
- const { id, lhs, rhs } = item.props;
- const label = getStringProp(lhs, "title") ?? id ?? `Snippet card ${index + 1}`;
- triggerAction(
- label,
- formName,
- withItemContext(props.action, {
- itemIndex: index,
- itemId: id,
- itemTitle: getStringProp(lhs, "title"),
- itemSubtitle: getStringProp(lhs, "subtitle"),
- itemValue: getStringProp(rhs, "value"),
- }),
- );
- };
-
- return (
- {
- const item = items[index];
- if (item) handleItemClick(item, index);
- }
- : undefined
- }
- items={items.map((item) => {
- const { id, lhs, rhs } = item.props;
- return {
- id,
- lhs: renderNode(lhs),
- rhs: rhs ? renderNode({ ...rhs, props: { ...rhs.props, size: "xs" } }) : undefined,
- lhsTooltip: {
- heading: getStringProp(lhs, "title"),
- content: getStringProp(lhs, "subtitle"),
- },
- rhsTooltip: {
- heading: getStringProp(rhs, "value"),
- content: getStringProp(rhs, "subtext"),
- },
- };
- })}
- />
- );
-}
-
-export const SnippetCardBlock = defineComponent({
- name: "SnippetCardBlock",
- props: SnippetCardBlockSchema,
- description:
- "A responsive grid of compact label/value cards (2 per row) for showing several short facts side by side; optionally clickable with a shared action.",
- component: SnippetCardBlockRenderer,
-});
diff --git a/packages/react-ui/src/genui-lib/SnippetCardBlock/schema.ts b/packages/react-ui/src/genui-lib/SnippetCardBlock/schema.ts
deleted file mode 100644
index 8e30ab29eb..0000000000
--- a/packages/react-ui/src/genui-lib/SnippetCardBlock/schema.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { defineComponent } from "@openuidev/react-lang";
-import { z } from "zod/v4";
-import { actionPropSchema } from "../Action/schema";
-import { BoldText } from "../BoldText";
-import { IconText } from "../IconText";
-import { ImageText } from "../ImageText";
-import { Text } from "../Text";
-
-export const SnippetCardItemSchema = z.object({
- id: z.string().optional(),
- lhs: z.union([IconText.ref, ImageText.ref]),
- rhs: z.union([Text.ref, BoldText.ref]).optional(),
-});
-
-export const SnippetCardItem = defineComponent({
- name: "SnippetCardItem",
- props: SnippetCardItemSchema,
- description:
- "One row-style snippet card: a label on the left (IconText or ImageText) and an optional value on the right (Text or BoldText).",
- component: () => null,
-});
-
-export const SnippetCardBlockSchema = z.object({
- items: z.array(SnippetCardItem.ref).min(2),
- layout: z.enum(["grid"]).default("grid"),
- responsive: z.boolean().default(true),
- action: actionPropSchema.optional(),
- gap: z.union([z.number(), z.string()]).optional(),
-});
-
-export type SnippetCardItemProps = z.infer;
-export type SnippetCardBlockProps = z.infer;
-export type SnippetCardItemNode = SnippetCardBlockProps["items"][number];
diff --git a/packages/react-ui/src/genui-lib/Sources/index.tsx b/packages/react-ui/src/genui-lib/Sources/index.tsx
new file mode 100644
index 0000000000..47c1831d4c
--- /dev/null
+++ b/packages/react-ui/src/genui-lib/Sources/index.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { defineComponent } from "@openuidev/react-lang";
+import { z } from "zod/v4";
+import {
+ CardSourceProvider,
+ CardSourceSchema,
+ Sources as OpenUISources,
+} from "../../components/Sources";
+
+export const Sources = defineComponent({
+ name: "Sources",
+ props: z.object({
+ items: z.array(CardSourceSchema),
+ }),
+ description:
+ 'Row of source cards (favicon, site name, page title) that closes an answer built from real references, e.g. Sources([{ title: "Apple (AAPL) Statistics & Valuation", sourceName: "Stock Analysis", url: "https://stockanalysis.com/stocks/aapl/statistics/" }]). Each item links to its url.',
+ component: ({ props }) => (
+
+
+
+ ),
+});
diff --git a/packages/react-ui/src/genui-lib/Steps/index.tsx b/packages/react-ui/src/genui-lib/Steps/index.tsx
index f31681b48b..2c26ebb0d2 100644
--- a/packages/react-ui/src/genui-lib/Steps/index.tsx
+++ b/packages/react-ui/src/genui-lib/Steps/index.tsx
@@ -1,10 +1,9 @@
"use client";
import { defineComponent } from "@openuidev/react-lang";
-import React from "react";
import { z } from "zod/v4";
-import { MarkDownRenderer } from "../../components/MarkDownRenderer";
-import { Steps as OpenUISteps, StepsItem as OpenUIStepsItem } from "../../components/Steps";
+import { Timeline as OpenUITimeline } from "../../components/Timeline";
+import { renderOneOrMany } from "../oneOrMany";
import { StepsItemSchema } from "./schema";
export { StepsItemSchema } from "./schema";
@@ -12,7 +11,8 @@ export { StepsItemSchema } from "./schema";
export const StepsItem = defineComponent({
name: "StepsItem",
props: StepsItemSchema,
- description: "title and details text for one step",
+ description:
+ 'One numbered step: StepsItem(title, subtitle?). title is the action in a few words: a string, or a Text, Link or Tag; there is no emphasis option, so to emphasize the title pass Text("Install the CLI", "body", true). subtitle is one or two plain sentences (not Markdown) on how to do it: a string, or a Text, Metric, Tag, TagBlock, LeadingText or Link. When title or subtitle needs two or more pieces side by side, pass an array.',
component: () => null,
});
@@ -21,30 +21,20 @@ export const Steps = defineComponent({
props: z.object({
items: z.array(StepsItem.ref),
}),
- description: "Step-by-step guide",
+ description:
+ 'A numbered how-to: Steps([StepsItem(Text("Install the CLI", "body", true), "Run npm i -g @openuidev/cli."), StepsItem(Text("Log in", "body", true), "Run openui login and follow the browser prompt.")]). Each step gets a 1, 2, 3 number chip automatically, so never number the titles yourself. Use it for instructions the reader carries out in order. For events in time order (an itinerary, schedule, history or tracking) use Timeline; for ranked picks or options use ListBlock.',
component: ({ props, renderNode }) => {
- const items = props.items ?? [];
- return (
-
- {items.map((item, i) => {
- const details = item.props.details;
- const detailsContent =
- typeof details === "string" ? (
-
- ) : (
- (renderNode(details) as React.ReactElement)
- );
+ const items = (props.items ?? []) as { props?: { title?: unknown; subtitle?: unknown } }[];
+ const cell = (value: unknown) => renderOneOrMany(value, renderNode);
- return (
-
- );
- })}
-
+ return (
+ ({
+ title: cell(item?.props?.title),
+ subtitle: cell(item?.props?.subtitle),
+ }))}
+ />
);
},
});
diff --git a/packages/react-ui/src/genui-lib/Steps/schema.ts b/packages/react-ui/src/genui-lib/Steps/schema.ts
index 312b68c59a..d5f45d400e 100644
--- a/packages/react-ui/src/genui-lib/Steps/schema.ts
+++ b/packages/react-ui/src/genui-lib/Steps/schema.ts
@@ -1,6 +1,21 @@
import { z } from "zod/v4";
+import { LeadingText } from "../LeadingText";
+import { Link } from "../Link";
+import { Metric } from "../Metric";
+import { Tag } from "../Tag";
+import { TagBlock } from "../TagBlock";
+import { Text } from "../Text";
+import { oneOrMany } from "../oneOrMany";
export const StepsItemSchema = z.object({
- title: z.string(),
- details: z.string(),
+ title: oneOrMany(z.string(), Text.ref, Link.ref, Tag.ref),
+ subtitle: oneOrMany(
+ z.string(),
+ Text.ref,
+ Metric.ref,
+ Tag.ref,
+ TagBlock.ref,
+ LeadingText.ref,
+ Link.ref,
+ ).optional(),
});
diff --git a/packages/react-ui/src/genui-lib/SwitchGroup/index.tsx b/packages/react-ui/src/genui-lib/SwitchGroup/index.tsx
index 4233d3a54b..df31d55d25 100644
--- a/packages/react-ui/src/genui-lib/SwitchGroup/index.tsx
+++ b/packages/react-ui/src/genui-lib/SwitchGroup/index.tsx
@@ -23,14 +23,16 @@ type SwitchItemProps = {
export const SwitchItem = defineComponent({
name: "SwitchItem",
props: SwitchItemSchema,
- description: "Individual switch toggle",
+ description:
+ 'One toggle in a SwitchGroup: SwitchItem(label?, description?, name, defaultChecked?), e.g. SwitchItem("Email alerts", "Get a message when a build fails", "email-alerts", true). name is the key submitted with the form; description is an optional one-line explanation under the label; defaultChecked starts it on.',
component: () => null,
});
export const SwitchGroup = defineComponent({
name: "SwitchGroup",
props: createSwitchGroupSchema(SwitchItem),
- description: "Group of switch toggles",
+ description:
+ "A list of on/off switches inside a FormControl, for independent settings or preferences that each turn on or off: SwitchGroup(name, items, value?). value binds a $variable holding { name: boolean }. It has no frame of its own; put it in a Card when it needs one. To pick items from a list use CheckBoxGroup, and for one choice among several use RadioGroup.",
component: ({ props }) => {
const isStreaming = useIsStreaming();
@@ -52,7 +54,7 @@ export const SwitchGroup = defineComponent({
const aggregate = getAggregate();
return (
-
+
{items.map((item, i) => (
{
const isQueryLoading = useIsQueryLoading();
const effectivePageSize = DEFAULT_PAGE_SIZE;
diff --git a/packages/react-ui/src/genui-lib/Tabs/index.tsx b/packages/react-ui/src/genui-lib/Tabs/index.tsx
index cb62559062..ba5296a399 100644
--- a/packages/react-ui/src/genui-lib/Tabs/index.tsx
+++ b/packages/react-ui/src/genui-lib/Tabs/index.tsx
@@ -9,6 +9,7 @@ import {
TabsList as OpenUITabsList,
TabsTrigger as OpenUITabsTrigger,
} from "../../components/Tabs";
+import { renderIconSlot } from "../iconSlot";
import { TabItemSchema } from "./schema";
export { TabItemSchema } from "./schema";
@@ -16,12 +17,18 @@ export { TabItemSchema } from "./schema";
export const TabItem = defineComponent({
name: "TabItem",
props: TabItemSchema,
- description: "value is unique id, trigger is tab label, content is array of components",
+ description:
+ 'One tab: TabItem(value, trigger, content, leading?), e.g. TabItem("growth", "User growth", [growthChart], Icon("trending-up", "charts")). value is a unique id; trigger is the short pill label, one to three words; content is what the tab shows. leading is optional: an Icon per view, an inline Avatar (with an icon) for a person\'s tab, or an Emoji only in rare expressive content. Give every tab a leading or none of them.',
component: () => null,
});
type TabsRenderProps = ComponentRenderProps<{
- items: SubComponentOf<{ value: string; trigger: string; content: unknown[] }>[];
+ items: SubComponentOf<{
+ value: string;
+ trigger: string;
+ content: unknown[];
+ leading?: unknown;
+ }>[];
}>;
/** Shared renderer — also used by the chat library's Tabs variant (wider content union). */
@@ -68,13 +75,14 @@ export const TabsRenderer = ({ props, renderNode }: TabsRenderProps) => {
if (!items.length) return null;
return (
-
+
{items.map((item) => (
))}
@@ -92,6 +100,7 @@ export const Tabs = defineComponent({
props: z.object({
items: z.array(TabItem.ref),
}),
- description: "Tabbed container",
+ description:
+ "Pill tabs that switch between alternative views of the same thing, such as one dataset as a chart and a table, or the same plan by season: Tabs([tab1, tab2]). Only one view shows at a time, so never hide primary information that the reader needs to compare side by side in a tab. Keep it to 2–5 tabs with short labels. For distinct parts of a response use Sections, and for supplementary on-demand detail use Accordion.",
component: TabsRenderer,
});
diff --git a/packages/react-ui/src/genui-lib/Tabs/schema.ts b/packages/react-ui/src/genui-lib/Tabs/schema.ts
index 30ae63872e..788f1daa1f 100644
--- a/packages/react-ui/src/genui-lib/Tabs/schema.ts
+++ b/packages/react-ui/src/genui-lib/Tabs/schema.ts
@@ -1,8 +1,12 @@
import { z } from "zod/v4";
+import { Avatar } from "../Avatar";
+import { Emoji } from "../Emoji";
+import { Icon } from "../Icon";
import { ContentChildUnion } from "../unions";
export const TabItemSchema = z.object({
value: z.string(),
trigger: z.string(),
content: z.array(ContentChildUnion),
+ leading: z.union([Icon.ref, Avatar.ref, Emoji.ref]).optional(),
});
diff --git a/packages/react-ui/src/genui-lib/Tag/index.tsx b/packages/react-ui/src/genui-lib/Tag/index.tsx
index 54c73a27a9..d47abd661d 100644
--- a/packages/react-ui/src/genui-lib/Tag/index.tsx
+++ b/packages/react-ui/src/genui-lib/Tag/index.tsx
@@ -1,8 +1,8 @@
"use client";
import { defineComponent } from "@openuidev/react-lang";
-import { IconWrapper } from "../../components/_shared/icons";
-import { Tag as OpenUITag } from "../../components/Tag";
+import { Tag as OpenUITag, type TagVariant } from "../../components/Tag";
+import { renderIconSlot } from "../iconSlot";
import { TagSchema } from "./schema";
export * from "./schema";
@@ -10,19 +10,14 @@ export * from "./schema";
export const Tag = defineComponent({
name: "Tag",
props: TagSchema,
- description: "Styled tag/badge with optional Icon and variant",
+ description:
+ 'A 24px pill: Tag(text, leading?, variant?). leading is an optional Icon for a concept, a Shape for a status dot or category marker (Tag("Online", Shape("circle", "success"))), an Avatar for a person or company, or an Emoji in rare expressive content. Pick variant by meaning: "success" positive, "danger" negative, "warning" cautionary, "info" informational, "neutral" (default) a plain label; "purple" and "pink" only to tell categories apart; "inverted" for a single high-emphasis label. Pass null for leading to set just a variant: Tag("Open", null, "success").',
component: ({ props }) => {
- const icon = props["icon"] as { props?: { name?: string; category?: string } } | undefined;
return (
- ) : undefined
- }
- size={props.size as "sm" | "md" | "lg" | undefined}
- variant={props.variant as "neutral" | "info" | "success" | "warning" | "danger" | undefined}
+ icon={renderIconSlot(props["leading"])}
+ variant={(props.variant ?? undefined) as TagVariant | undefined}
/>
);
},
diff --git a/packages/react-ui/src/genui-lib/Tag/schema.ts b/packages/react-ui/src/genui-lib/Tag/schema.ts
index 3ec5912c5e..7ed2bbdfcd 100644
--- a/packages/react-ui/src/genui-lib/Tag/schema.ts
+++ b/packages/react-ui/src/genui-lib/Tag/schema.ts
@@ -1,9 +1,13 @@
import { z } from "zod/v4";
+import { Avatar } from "../Avatar";
+import { Emoji } from "../Emoji";
import { Icon } from "../Icon";
+import { Shape } from "../Shape";
export const TagSchema = z.object({
text: z.string(),
- icon: z.optional(Icon.ref),
- size: z.enum(["sm", "md", "lg"]).optional(),
- variant: z.enum(["neutral", "info", "success", "warning", "danger"]).optional(),
+ leading: z.optional(z.union([Icon.ref, Avatar.ref, Emoji.ref, Shape.ref])),
+ variant: z
+ .enum(["neutral", "info", "success", "warning", "danger", "purple", "pink", "inverted"])
+ .optional(),
});
diff --git a/packages/react-ui/src/genui-lib/TagBlock/index.tsx b/packages/react-ui/src/genui-lib/TagBlock/index.tsx
index facf016a24..766d79a231 100644
--- a/packages/react-ui/src/genui-lib/TagBlock/index.tsx
+++ b/packages/react-ui/src/genui-lib/TagBlock/index.tsx
@@ -11,14 +11,13 @@ export * from "./schema";
export const TagBlock = defineComponent({
name: "TagBlock",
props: TagBlockSchema,
- description: "tags is an array of strings; optional size sm | md | lg",
+ description: "tags is an array of strings, each shown as a 24px Tag pill",
component: ({ props }) => {
const tags = asArray(props.tags) as string[];
- const size = props.size as "sm" | "md" | "lg" | undefined;
return (
{tags.map((tag, i) => (
-
+
))}
);
diff --git a/packages/react-ui/src/genui-lib/TagBlock/schema.ts b/packages/react-ui/src/genui-lib/TagBlock/schema.ts
index 7da73fd006..11dce5d995 100644
--- a/packages/react-ui/src/genui-lib/TagBlock/schema.ts
+++ b/packages/react-ui/src/genui-lib/TagBlock/schema.ts
@@ -2,5 +2,4 @@ import { z } from "zod/v4";
export const TagBlockSchema = z.object({
tags: z.array(z.string()),
- size: z.enum(["sm", "md", "lg"]).optional(),
});
diff --git a/packages/react-ui/src/genui-lib/Text/index.tsx b/packages/react-ui/src/genui-lib/Text/index.tsx
index cabb31d712..67b18656d2 100644
--- a/packages/react-ui/src/genui-lib/Text/index.tsx
+++ b/packages/react-ui/src/genui-lib/Text/index.tsx
@@ -1,39 +1,69 @@
"use client";
-import { ComponentRenderProps, defineComponent } from "@openuidev/react-lang";
-import { TextBlockView } from "../../components/TextBlock";
-import { TextSchema, type TextProps } from "./schema";
+import { defineComponent } from "@openuidev/react-lang";
+import React from "react";
+import { TextContentWrapper } from "../../components/TextContentWrapper";
+import { TextSchema } from "./schema";
-export * from "./schema";
+const REGULAR = "var(--openui-font-weight-regular)";
+const MEDIUM = "var(--openui-font-weight-medium)";
+const REGULAR_AXIS = "var(--openui-font-variation-regular)";
+const MEDIUM_AXIS = "var(--openui-font-variation-medium)";
+const BOLD = "var(--openui-font-weight-bold)";
+const BOLD_AXIS = "var(--openui-font-variation-bold)";
+const BODY = "var(--openui-font-body)";
+const HEADING = "var(--openui-font-heading)";
-export function getMetricSubtextTone(
- subtextVariant: "text" | "number" | "metric",
- subtext?: string,
-): "positive" | "negative" | undefined {
- if (subtextVariant !== "metric" || !subtext) return undefined;
- if (subtext.startsWith("+")) return "positive";
- if (subtext.startsWith("-")) return "negative";
- return undefined;
-}
+// Body sizes take the emphasis weight (Medium); headings are always Bold.
+const FONTS: Record string> = {
+ small: (w) => `${w} var(--openui-font-size-xs)/16px ${BODY}`,
+ default: (w) => `${w} var(--openui-font-size-sm)/16px ${BODY}`,
+ // Multi-line copy: matches the Markdown body (16px on a 24px line).
+ body: (w) => `${w} var(--openui-font-size-md)/24px ${BODY}`,
+ h1: () =>
+ `${BOLD} var(--openui-font-size-4xl)/var(--openui-line-height-heading-large) ${HEADING}`,
+ h2: () => `${BOLD} 22px/var(--openui-line-height-heading) ${HEADING}`,
+ h3: () => `${BOLD} var(--openui-font-size-lg)/var(--openui-line-height-heading) ${HEADING}`,
+};
+
+// Headings track slightly tighter than body text (-1.2% vs the theme's -1%).
+const HEADINGS = new Set(["h1", "h2", "h3"]);
-function TextRenderer({ props }: ComponentRenderProps) {
- return (
-
- );
+/**
+ * The font, "wght" axis and tracking for a Text variant, so other components
+ * (Heading, LeadingText) can match Text exactly, line height included.
+ */
+export function getTextFont(variant: string = "default", emphasis = false) {
+ const heading = HEADINGS.has(variant);
+ const medium = !heading && emphasis;
+ return {
+ font: (FONTS[variant] ?? FONTS["default"]!)(medium ? MEDIUM : REGULAR),
+ axis: heading ? BOLD_AXIS : medium ? MEDIUM_AXIS : REGULAR_AXIS,
+ letterSpacing: heading ? "-0.012em" : "var(--openui-letter-spacing-normal)",
+ };
}
+export { TextSchema } from "./schema";
+
export const Text = defineComponent({
name: "Text",
props: TextSchema,
description:
- "Plain text line with optional subtext. variant 'number' uses tabular number styling; subtextVariant 'metric' colors a leading +/- subtext green/red.",
- component: TextRenderer,
+ 'Short UI text: labels, headings, captions, single values. variant: "small" | "default" (single-line UI text), "body" (a few lines of supporting copy, 16px on a 24px line) or "h1" | "h2" | "h3" (headings). emphasis: true sets non-heading text in Medium weight. Text is for building UI; for explanations, titled prose answers, or anything with lists, tables or several paragraphs, use Markdown (with # headings) instead.',
+ component: ({ props }) => {
+ const variant = (props.variant as string) ?? "default";
+ const { font, axis } = getTextFont(variant, !!props.emphasis);
+ const style = {
+ "--openui-text-body-default": font,
+ // The markdown renderer reads this for the "wght" axis.
+ "--openui-text-variation": axis,
+ ...(HEADINGS.has(variant) && { "--openui-text-body-default-letter-spacing": "-0.012em" }),
+ } as React.CSSProperties;
+ const text = props.text == null ? "" : String(props.text);
+ return (
+